Network Programming in C - Building an HTTP Server from Scratch
2025-01-01Have you ever wondered what happens when you type 'npm start' and your Node.js server spins up? Or when you run your Flask application and it starts listening for requests? Behind those convenient abstractions lies a fascinating world of system calls, network protocols, and careful orchestration between user space and kernel space.
Let's demystify this by building a simple HTTP server in C. Not because we need another HTTP server, but because understanding how things work at this level gives us invaluable insights into modern web development. C, despite being considered a "high-level" language (yes, really!), gives us direct access to system calls - the same calls that Node.js, Python, and every other networked application ultimately uses.
Through this journey, we'll see how concepts we take for granted in high-level frameworks - like listening for connections, handling requests, and sending responses - translate into direct communication with the operating system. We'll explore why certain patterns emerged in network programming and how they influence the design of modern web frameworks.
Most importantly, we'll discover that what seems like magic in higher-level frameworks is actually a well-orchestrated dance between our code, the operating system, and the network stack. Let's begin.
Understanding Sockets
A socket is fundamentally an endpoint for communication. Just like a phone number combines a country code and a local number to uniquely identify a phone, a socket combines an IP address and a port number to identify a specific endpoint on a network. But there's more to it.
In Unix-like systems, sockets are implemented following the "everything is a file" philosophy. When you create a socket, the kernel gives you a file descriptor - just another integer that points to a kernel data structure. This is powerful because it means we can use the same read/write operations we use for files to send and receive network data.
int server_fd = socket(AF_INET, SOCK_STREAM, 0);
This line creates a socket and returns its file descriptor. But what's actually happening? The kernel:
- Allocates a new file descriptor in the process's file descriptor table
- Creates a new socket structure in kernel space
- Associates the file descriptor with this socket structure
- Initializes protocol-specific data structures (in this case, TCP)
Think of it as reserving a phone line before you know your phone number. The socket exists, but it's not yet bound to any address or port.
Types of Sockets
When we create a socket, we specify its type:
socket(AF_INET, // IPv4 Internet protocols
SOCK_STREAM, // TCP: reliable, ordered, connection-based
0) // Protocol (0 = default for type)
SOCK_STREAM
gives us TCP semantics: reliable, ordered delivery of data. Each packet gets assigned a sequence number, acknowledged upon receipt, and reordered if necessary. This is why we don't have to worry about data arriving out of order or getting lost - the kernel handles all of this.
Alternatively, SOCK_DGRAM
would give us UDP sockets - no guarantees about delivery or ordering, but lower latency and overhead. This is why game servers often use UDP - they care more about speed than perfect reliability.
The beauty of the socket abstraction is that regardless of the underlying protocol, we interact with it through the same file operations - read()
, write()
, close()
. The kernel translates these familiar operations into the appropriate network protocol actions.
Binding: Giving Our Server an Address
address.sin_family = AF_INET;
address.sin_addr.s_addr = INADDR_ANY; // 0.0.0.0
address.sin_port = htons(8080);
bind(server_fd, (struct sockaddr *)&address, sizeof(address));
Here's where we assign our server an identity. INADDR_ANY
means "listen on all available network interfaces" - it's like saying "I'll take connections from anywhere." The htons()
function is crucial - it converts our port number to network byte order. This is necessary because different machines might store numbers differently (big-endian vs little-endian).
The Listening Game
listen(server_fd, 10);
This simple line has more meaning than it appears. The second argument (10) is the backlog - the number of connections that can be queued up before the system starts refusing new ones. Think of it as a waiting room for incoming connections.
Accepting Connections: The Main Loop
while(1) {
int client_fd = accept(server_fd, (struct sockaddr *)&address,
(socklen_t*)&addrlen);
// Read the request
char buffer[1024] = {0};
read(client_fd, buffer, 1024);
// Send a JSON response
char *response = "HTTP/1.1 200 OK\r\n"
"Content-Type: application/json\r\n"
"Content-Length: 27\r\n"
"\r\n"
"{\"message\":\"Hello, World!\"}";
write(client_fd, response, strlen(response));
close(client_fd);
}
This is where the real action happens. accept()
blocks until a client connects, then returns a new socket specifically for that connection. This is a key design pattern in network programming - the original socket keeps listening while each client gets its own dedicated socket.
The HTTP response format isn't arbitrary - those \r\n
sequences are crucial. HTTP was designed to be human-readable, with headers separated by carriage return and line feed characters. The empty line between headers and body is mandatory - it tells the client "headers are done, here comes the content."
Putting It All Together
Here's the complete server:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
int main() {
int server_fd;
struct sockaddr_in address;
int addrlen = sizeof(address);
// Create socket
if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0) {
perror("Socket failed");
exit(EXIT_FAILURE);
}
// Configure socket options
int opt = 1;
if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt))) {
perror("Setsockopt failed");
exit(EXIT_FAILURE);
}
// Setup address structure
address.sin_family = AF_INET;
address.sin_addr.s_addr = INADDR_ANY;
address.sin_port = htons(8080);
// Bind to port
if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) {
perror("Bind failed");
exit(EXIT_FAILURE);
}
// Start listening
if (listen(server_fd, 10) < 0) {
perror("Listen failed");
exit(EXIT_FAILURE);
}
printf("Server listening on port 8080\n");
while(1) {
int client_fd = accept(server_fd, (struct sockaddr *)&address,
(socklen_t*)&addrlen);
if (client_fd < 0) {
perror("Accept failed");
continue;
}
char buffer[1024] = {0};
read(client_fd, buffer, 1024);
printf("Received: %s\n", buffer);
char *response = "HTTP/1.1 200 OK\r\n"
"Content-Type: application/json\r\n"
"Content-Length: 27\r\n"
"\r\n"
"{\"message\":\"Hello, World!\"}";
write(client_fd, response, strlen(response));
close(client_fd);
}
return 0;
}
Why This Matters
Understanding socket programming in C gives you insight into how higher-level frameworks work under the hood. When you use Express.js, Django, or any other web framework, they're ultimately using these same system calls.
This basic server is missing many things - proper error handling, request parsing, concurrent connections - but it demonstrates the fundamental concepts that all network programs build upon. The beauty of C is that it lets us see these concepts without any abstraction getting in the way.
Next time you're using a high-level web framework, remember that at its heart, it's just creating sockets, binding them to ports, and shuffling data back and forth - just like our simple server.
This post is part of my journey exploring systems programming. Stay tuned for more deep dives into low-level programming concepts!