For newly minted low-level programmers, network programming is a rite of passage. Especially for learners of C, a working server can be a satisfying project that’s accessible right away. Unfortuantely, while conceptually simple, C networking code can seem impenetrable. Let’s clear that up. In this tutorial, we wil start with the basics of a server, explaining both networking and C programming concepts as they come up.

This tutorial assumes some familiarity with networking basics, such as the layered computer networking model, ipv4 vs ipv6, and TCP vs UDP. Also, for brevity, we skip over some error checking that would be included in production code.

Socket == phone

Higher level languages like Python offer the ability to listen and respond directly to requests coming in on a port. Not so with C. Instead, we manage connections using the socket, which is essentially a file descriptor the operating system uses to act as a representation of data flow. Think of it like a phone. It’s the terminal through which communications come in and go out.

To create a socket, we can just call the socket() function in <sys/socket.h>. Going to a terminal and typing man socket we see the synopsis

SYNOPSIS
  #include <sys/socket.h>

  int
  socket(int domain, int type, int protocol);

domain is used to pick the protocol (for example, PF_INET is ipv4 and PF_INET6 is ipv6. For type pass in SOCK_STREAM to use TCP and SOCK_DGRAM to use UDP. protocol isn’t relevant at the moment, for right now we can just pass in zero.

Wrapping up, we start with basic code that looks like this.

#include <sys/socket.h>

int main() {
  int socketfd = socket(PF_INET, SOCK_STREAM, 0);
  return 0;
}

Binding == signing up for phone plan

So you have a phone, and you want to make calls. Right now, though, it’s just a brick. The next step is to sign up for a phone plan and get a number. Similarly, just having a socket doesn’t allow us to connect to the internet. We need to pick an address (phone number) and then bind it to our socket (associate it with our phone).

struct sockaddr_in socketAddress = {
  .sin_family = AF_INET,
  .sin_port = htons(80),
  .sin_addr = INADDR_ANY,
};
bind(socketfd, (const struct sockaddr*)&socketAddress, sizeof(socketAddress));

bind simply takes the ID of the socket we created, along with info about the address. AF_INET means we are accepting connections on ipv4, and INADDR_ANY means we’ll accept a connection from anywhere. Finally, we need to pass in the port to listen on, making sure to convert it to network bite order. In this example, we choose standard HTTP port 80.

Listening == Connect to cell towers, receive calls

Now we have our phone, with a number registered to it. Still, though, if our phone is in airplane mode, nothing is going to happen. We need to connect to the cell towers to begin receiving requests. We can do so with listen.

listen(socketfd, 64);

Just pass in the ID of the socket (already bound to an address earlier), along with an integer representing the maximum number of connection requests we will allow to queue up.

Accept == Pick up calls

Of course, just receiving a connection request doesn’t mean we are automatically communicating. If listen allows our phone to start receiving calls, accept is the button we press to pick up. Breaking our analogy a little bit, it’s possible for the server to handle multiple requests at once. To keep different clients uniquely identified, the accept function gives us another socket file descriptor to identify this connection in particular. Think of a phone that can switch between multiple calls at once, and the client file descriptor as being the ID of each phone you’re in the middle of communicating with.

struct sockaddr_in clientAddress;
socklen_t clientAddressSize = sizeof(clientAddress);
int clientfd =
  accept(socketfd, (struct sockaddr*)&clientAddress, &clientAddressSize);

We pass in a pointer to a struct sockaddr_in and a socklen_t, this just allows the function to communicate back to us the the address information of the client. The client socket ID is the most important, as that’s what we use for communication.

Receiving data

Finally, it’s not enough to just press a button and accept the call. We need to bring the phone up to our ears, and the other party needs to speak for us to receive a message. The equivalent of bringing the phone up to our ear is calling recv, for “receive”.

char buffer[512];
ssize_t bytesReceived = recv(clientfd, buffer, sizeof(buffer) - 1, 0);
buffer[bytesReceived] = '\0';
printf("%s\n", buffer);

Pass in the client socket descriptor to pick which connection we want to hear from, then pass in the buffer we want to receive information on. Next we want to receive at most one less than the size of the buffer, so we can make sure we set a null byte after the data received, so we can print it out as a valid C string. The final parameter is a flags parameter we can ignore for now.

And that’s it! At this point we have a basic functioning server written in C. For reference here is what your completed code might look like.

#include <arpa/inet.h>
#include <netinet/in.h>
#include <stdio.h>
#include <sys/socket.h>

#define PORT 80

int main() {
  // Create socket
  int socketfd = socket(PF_INET, SOCK_STREAM, 0);

  // Assign address to socket
  struct sockaddr_in socketAddress = {
      .sin_family = AF_INET,
      .sin_port = htons(80),
      .sin_addr = INADDR_ANY,
  };
  bind(socketfd, (const struct sockaddr*)&socketAddress, sizeof(socketAddress));

  // Listen for and start queueing up connections on socket
  listen(socketfd, 64);

  // Wait for and accept incoming TCP connection
  struct sockaddr_in clientAddress;
  socklen_t clientAddressSize = sizeof(clientAddress);
  int clientfd =
      accept(socketfd, (struct sockaddr*)&clientAddress, &clientAddressSize);

  // Wait for and print out data received
  char buffer[512];
  ssize_t bytesReceived = recv(clientfd, buffer, sizeof(buffer) - 1, 0);
  buffer[bytesReceived] = '\0';
  printf("%s\n", buffer);

  printf("Success");
  return 0;
}

Demo

Build the executable with gcc or clang then run it. Since the accept function blocks until a message is received, the executable should appear to hang. Then, open up another terminal and make an HTTP request with wget or curl.

cbrown@typhoon ~ % wget localhost:80
--2024-06-04 14:11:03--  http://localhost/
Resolving localhost (localhost)... 127.0.0.1, ::1
Connecting to localhost (localhost)|127.0.0.1|:80... connected.
HTTP request sent, awaiting response... No data received.
Retrying.

--2024-06-04 14:11:04--  (try: 2)  http://localhost/
Connecting to localhost (localhost)|127.0.0.1|:80... failed: Connection refused.
Connecting to localhost (localhost)|::1|:80... failed: Connection refused.
Resolving localhost (localhost)... 127.0.0.1, ::1
Connecting to localhost (localhost)|127.0.0.1|:80... failed: Connection refused.
Connecting to localhost (localhost)|::1|:80... failed: Connection refused.

In this case, wget reports the connection as being refused. This is just because we haven’t programmed our server to send a valid HTTP response. But, looking at the output of our server, we should see the HTTP get request that wget made.

cbrown@typhoon ~/Documents/repo/server % ./server
GET / HTTP/1.1
Host: localhost
User-Agent: Wget/1.24.5
Accept: */*
Accept-Encoding: identity
Connection: Keep-Alive


Success

Looks like it worked! One more thing we can do is make the request using a browser, for example if I start the server back up again and connect with Chrome I get the following:

cbrown@typhoon ~/Documents/repo/server % ./server
GET / HTTP/1.1
Host: localhost
Connection: keep-alive
sec-ch-ua: "Google Chrome";v="125", "Chromium";v="125",
...
Success

This also confirms that the server we built is working. Coming up in part 2 we’ll learn how to send data back over the pipe.