Your phone is a socket: Networking basics and building a server (C Networking Part 1)
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.