C Socket Programming in Linux: Build a TCP Client & Server (Complete Guide)


I have always been fascinated by building things from scratch, and sockets in C have always scared me, since there are A LOT of things that can fail in the context of sockets, and - as you probably know already - C is not great when it comes to handling errors.

Many tutorials you find online either use old and outdated APIs and/or do not use rigorous error handling and memory management practices.

In this guide I would like to show you what modern C socket programming can look like with - in relation to C - modern APIs and good practices when it comes to error handling and cleanup.

I will assume some prior knowledge about C programming in general and a basic knowledge of how sockets work. Please note that this tutorial will only work on POSIX-compliant systems (most notably macOS and Linux), since on Windows you would have to use the native Windows sockets API.

Quick Intro to Sockets on UNIX Systems

I will be referring to Linux when talking about the operating system, but the same things apply to macOS most of the time.

One of the nice properties of UNIX-like systems is that everything (or I guess almost everything) is or behaves like a file. This includes sockets. The way Linux exposes sockets to the programmer is through file descriptors. Basically, when you create a socket through a syscall to the operating system, you are given a file descriptor. For those who don’t know, on a high level, a syscall is basically a “function exposed by the operating system” which you, as the programmer, can call.

Allocating memory, creating sockets, even printing to stdout, are all things that use syscalls in the background.

A file descriptor is basically an ID which is used to identify some file used in the process of your program by the operating system.

For us, this is very convenient, because it basically means that we can treat sockets as if they were normal files for the most part.

We have to build two programs: one for the client and one for the server.

In this basic example, we will only support accepting one client connection on the server and sending a basic message from the server to the client.

In the future I will write another blog post in which I will extend this simple example to accept more than one connection simultaneously and to send messages of arbitrary length (maybe coming from some input by the user).

For now, let us stick to simple concepts first. The code will fit into two files: one for the server and one for the client.

Server Code

We start with the server.

General Setup

We first include some important headers at the top of our file.

#include <stdio.h>
#include <string.h>

#include <netdb.h>
#include <unistd.h>

stdio.h is of course used for IO purposes (for us this basically involves printing to stdout). string.h will be used to get the length of the message we want to send with strlen just before sending it.

netdb.h contains a bunch of functions to translate hostnames and ports into usable addresses. We will get into it soon. unistd.h are some UNIX-standard functions. We will mainly be using the close function to close a file by giving it a file descriptor (which in our case is a socket).

We create a constant at the top, for convenience. This will be the port that our server will run on.

#define PORT "8080"

We have to use a string for this, not an integer. In other tutorials you can find online, which as I said, often use older and outdated APIs, integers are often used.

Now we create our main function. I will use some error-handling techniques to make our program more robust, which is why I will not return a hard-coded 0, but put it in a variable first. You will see why this is convenient in a second.

int main() {
    int exit_code = 0;

    // ...

    return exit_code;
}

In between we will put our code.

At the top of our main function, we initialize all the resources we will need to free or close later with invalid values (NULL for pointers and -1 for file descriptors).

struct addrinfo *addr_info = NULL;
int server_socket = -1;
int client_socket = -1;

At the bottom of the main function, just before the return statement, we create a label cleanup. If you don’t know about C labels, you can look it up, but it is actually a very simple concept, so maybe try and see if you can figure it out through this example.

You basically specify a label somewhere, and you can use the goto keyword to immediately jump execution to that label. Using the goto keyword is generally discouraged, but there are some patterns which make C code much cleaner and readable where goto is actually idiomatic, and this is one of them.

This pattern is used extensively in big projects like the Linux kernel codebase, for instance.

cleanup:
    // free all resources in reverse order of usage
    if (client_socket != -1) {
        if (close(client_socket) == -1)
            perror("[SERVER] Error closing client socket.");
    }
    if (server_socket != -1) {
        if (close(server_socket) == -1)
            perror("[SERVER] Error closing server socket.");
    }
    if (addr_info != NULL) freeaddrinfo(addr_info);

Whenever something fails, we print an error and jump to the cleanup label to free all our memory and close all our files. This way, we don’t have to repeat code. If everything goes well, then our code will naturally get to the cleanup label on its own and clean everything up as normal.

Getting the Address Info

Now it’s time to create a struct which will contain some configuration which will tell our program how to create the address to connect to.

struct addrinfo addr_flags = {
    .ai_family = AF_UNSPEC,
    .ai_socktype = SOCK_STREAM,
    .ai_flags = AI_PASSIVE,
};

ai_family can be left unspecified with AF_UNSPEC. The operating system can and will give you whatever is available. In any case, this would refer to whether we want an IPv4 or IPv6 address. In this case, we really do not mind. If we wanted to specify this option, we could use AF_INET or AF_INET6 for IPv4 and IPv6 respectively.

ai_socktype basically tells the operating system to use TCP for the connection. For those who do not know already, TCP is a reliable connection protocol. Lately, the QUIC protocol (developed by Google) has been growing in popularity, but TCP is still the most prevalent option. For UDP, you would use SOCK_DGRAM, but that is not recommended unless you have very specific reasons to use UDP.

ai_flags being set to AI_PASSIVE basically means “accept connections from all addresses”. Not only from our localhost (127.0.0.1), but from any address (basically a wildcard address).

We now use getaddrinfo to populate our address pointer variable we defined above.

int addr_info_status = getaddrinfo(NULL, PORT, &addr_flags, &addr_info);
if (addr_info_status != 0) {
    fprintf(
        stderr,
        "[SERVER] Error getting address info: %s\n",
        gai_strerror(addr_info_status)
    );
    exit_code = 1;
    goto cleanup;
}

Notice that getaddrinfo expects a pointer to a pointer, which is why we have to use the & operator on a variable which is already a pointer.

The first parameter to getaddrinfo specifies the hostname. As I said before, we want all hostnames, so we leave this blank (or in C terms - NULL).

The next parameter is self-explanatory: it specifies the port we want our server to listen on, we specified PORT as a macro at the start of the file.

In the next parameter, we pass a pointer to the addr_flags we specified earlier. These are basically hints that tell the operating system some parameters about the kind of address we want.

The last parameter is the output variable which will be populated with the data we want.

The remaining lines are for error handling. We will use this pattern a lot throughout this small project. We print an error. This depends on how the error is thrown, in this case, for getaddrinfo, we can “convert” the returned error code returned by the function to a string-like error message with gai_strerror.

The prefix of gai_ stands for getaddrinfo. We set the exit code which will be returned at the end of our main function and jump to the cleanup label.

In reality, getaddrinfo returns a linked list, and you are supposed to iterate over all the options and find the first suitable one. In most cases, using the first element of the linked list (as I did above) will work just fine.

Just know that in real-world production code, one would need to iterate.

Creating the Socket

We can finally create our socket which we will use to communicate with the client. To do this, we pass the information we received from getaddrinfo to the socket function.

server_socket = socket(
    addr_info->ai_family,
    addr_info->ai_socktype,
    addr_info->ai_protocol
);
if (server_socket == -1) {
    perror("[SERVER] Error creating socket.");
    exit_code = 1;
    goto cleanup;
}

Generally, from now on, all the errors can be printed with perror. The perror function reads the global errno variable set by the kernel after a failed system call.

Allowing for Address Reuse

Before we do any communication, it’s good to set an option for the socket. This can be done with the setsockopt function. I will enable SO_REUSEADDR, which allows us to stop and start the server right after.

If you don’t set this, sometimes, when stopping the server abruptly, you could run into an issue where if you run the server again quickly, you get an “address already in use” error. With this option, this won’t happen.

int enable_opt = 1;
int setsockopt_status = setsockopt(
    server_socket,
    SOL_SOCKET,
    SO_REUSEADDR,
    &enable_opt,
    sizeof(enable_opt)
);
if (setsockopt_status == -1) {
    perror("[SERVER] Error setting socket option.");
    exit_code = 1;
    goto cleanup;
}

Most parameters should be self-explanatory, but one might be confusing, which is SOL_SOCKET. SOL_SOCKET just tells the function that the option we are about to set (SO_REUSEADDR) will refer to the socket level, so it is more general than a protocol-level option, since you can also set option for TCP specifically amongst other things.

Binding to the Socket

Now we can bind to our socket. Think of it like this: up until this point, we have created a socket and have an address. The problem is, the operating system does not yet know that we want the address to be associated with that socket. We have to bind the socket to the address by telling the OS that this address belongs to our socket, and that we want all data sent to port 8080 to go through our socket.

int bind_status = bind(
    server_socket,
    addr_info->ai_addr,
    addr_info->ai_addrlen
);
if (bind_status == -1) {
    perror("[SERVER] Error binding to socket.");
    exit_code = 1;
    goto cleanup;
}

Listening on the Socket

We can now start listening on our socket for incoming connections. This is done via the listen function.

int listen_status = listen(server_socket, 1);
if (listen_status == -1) {
    perror("[SERVER] Error listening to socket.");
    exit_code = 1;
    goto cleanup;
}

The second argument to the listen function (in our case, 1), tells the operating system how many connections to put in the queue before refusing connections.

For this simple example, I set it to 1, since we will only be working with one client.

The way this would work for multiple clients is as follows. When clients try to connect to our server, the operating system puts them in a queue. The clients have to wait until their connection is handled by the server. This can even be multithreaded by assigning a thread to each connection, but for this simple example, it is not necessary. This can also be done synchronously, where you accept each client one by one. If you do this, all clients have to wait until the current one is handled and it is their turn. If you set the second argument of listen to, say, 5, once 5 connections are in the queue the operating system will refuse any further connection attempts until at least one of the connections in the queue is handled.

To accept a connection, we use the accept function, as you’ll see now.

Accepting a Connection

As I just said, a connection can be accepted from the queue with the accept function. If no connection is available in the queue, accept simply waits (i.e. block the execution of the program) until a connection is available.

client_socket = accept(server_socket, NULL, NULL);
if (client_socket == -1) {
    perror("[SERVER] Error accepting connection.");
    exit_code = 1;
    goto cleanup;
}

The second and third parameters of the accept function are pointers to the address of the client who connected and the length of this address respectively. We could use these parameters if we were interested in knowing who connected (their address). For this example, we do not care, so we can leave them as NULL.

The server_socket we created before is a listening socket, which means that we use it to wait for and queue incoming connections. To communicate with one specific client, we use the socket file descriptor returned by the accept function.

So, the client_socket is specific to a client, whereas the server_socket is reused to accept each client connection.

Sending a Message Over Sockets

Finally, we can actually send some data over our socket.

char server_message[] = "Hello World!";
int send_status = send(
    client_socket,
    server_message,
    strlen(server_message) + 1,
    0
);
if (send_status == -1) {
    perror("[SERVER] Error sending data to client.");
    exit_code = 1;
    goto cleanup;
}

We create a char array to store the data we want to send. In this example, we want to send the string "Hello World!". To send the data over our client socket, we use the send function.

The last parameter of the function represents additional flags that can be passed. I pass 0, since we just want the default behavior and are not interested in passing any special flags.

The second to last parameter is the length of the message we want to send. We add one to account for the null terminator (\0), since we want our client to receive a null-terminated string, but strlen returns the length of the string without the null terminator.

For this example it will not be a problem, but for real-world usage, send might not send all the data we requested. In fact, the returned value is not simply a “status”, but the amount of bytes sent. In practice, we would have to check how much of the data was sent and keep trying to send the remaining data until all the data we needed to send was sent.

This can happen because the operating system allocates a relatively small buffer for sockets. If the buffer is already quite full or if the message we want to send is too long, it could not fit in the buffer. In that case, send sends what it can and then just returns. Also, send can be abruptly stopped by operating system interrupts.

Our message is very short, so this will not happen, but in a real production-level codebase, this must be handled.

Final Server Code

We are done with our server code. This will safely send a message over a socket to our client (which we will write the code for shortly). Here is the final version of the code described above.

#include <stdio.h>
#include <string.h>

#include <netdb.h>
#include <unistd.h>

#define PORT "8080"
int main() {
    int exit_code = 0;

    struct addrinfo *addr_info = NULL;
    int server_socket = -1;
    int client_socket = -1;

    // -------------------------------------------------- ADDRESS
    struct addrinfo addr_flags = {
        .ai_family = AF_UNSPEC,
        .ai_socktype = SOCK_STREAM,
        .ai_flags = AI_PASSIVE,
    };

    int addr_info_status = getaddrinfo(NULL, PORT, &addr_flags, &addr_info);
    if (addr_info_status != 0) {
        fprintf(
            stderr,
            "[SERVER] Error getting address info: %s\n",
            gai_strerror(addr_info_status)
        );
        exit_code = 1;
        goto cleanup;
    }

    // -------------------------------------------------- SOCKET
    server_socket = socket(
        addr_info->ai_family,
        addr_info->ai_socktype,
        addr_info->ai_protocol
    );
    if (server_socket == -1) {
        perror("[SERVER] Error creating socket.");
        exit_code = 1;
        goto cleanup;
    }

    int enable_opt = 1;
    int setsockopt_status = setsockopt(
        server_socket,
        SOL_SOCKET,
        SO_REUSEADDR,
        &enable_opt,
        sizeof(enable_opt)
    );
    if (setsockopt_status == -1) {
        perror("[SERVER] Error setting socket option.");
        exit_code = 1;
        goto cleanup;
    }

    // -------------------------------------------------- BINDING
    int bind_status = bind(
        server_socket,
        addr_info->ai_addr,
        addr_info->ai_addrlen
    );
    if (bind_status == -1) {
        perror("[SERVER] Error binding to socket.");
        exit_code = 1;
        goto cleanup;
    }

    // -------------------------------------------------- LISTENING
    int listen_status = listen(server_socket, 1);
    if (listen_status == -1) {
        perror("[SERVER] Error listening to socket.");
        exit_code = 1;
        goto cleanup;
    }

    client_socket = accept(server_socket, NULL, NULL);
    if (client_socket == -1) {
        perror("[SERVER] Error accepting connection.");
        exit_code = 1;
        goto cleanup;
    }

    // -------------------------------------------------- SENDING
    char server_message[] = "Hello World!";
    int send_status = send(
        client_socket,
        server_message,
        strlen(server_message) + 1,
        0
    );
    if (send_status == -1) {
        perror("[SERVER] Error sending data to client.");
        exit_code = 1;
        goto cleanup;
    }

cleanup:
    // -------------------------------------------------- CLEANUP
    if (client_socket != -1) {
        if (close(client_socket) == -1)
            perror("[SERVER] Error closing client socket.");
    }
    if (server_socket != -1) {
        if (close(server_socket) == -1)
            perror("[SERVER] Error closing server socket.");
    }
    if (addr_info != NULL) freeaddrinfo(addr_info);

    return exit_code;
}

Client Code

We move on to the client. The code for the client is slightly shorter, since we only have to create one socket (which will create the connection to the server).

General Setup

Just like with the server, we import almost the same headers. For the client - in this example - we do not need string.h.

#include <stdio.h>

#include <netdb.h>
#include <unistd.h>

#define HOST "127.0.0.1"
#define PORT "8080"

As you can see, there is an additional HOST macro which is defined. This is simply the IP-address of the server we want to connect to.

For me (and most likely for you too), the server is running on the same machine as the client, so we just give it the local address. Theoretically, you could also do this with two different machines. If both computers are on the same network, you can just give it the local IP-address in the network and it will work.

Again, we create a main function with some initialization at the top and the cleanup part at the bottom.

int main() {
    int exit_code = 0;

    struct addrinfo *addr_info = NULL;
    int server_socket = -1;

    // ...

cleanup:
    if (server_socket != -1) {
        if (close(server_socket) == -1)
            perror("[CLIENT] Error closing socket.");
    }
    if (addr_info != NULL) freeaddrinfo(addr_info);

    return exit_code;
}

Getting the Address Info

These steps are similar to what we did on the server.

struct addrinfo addr_flags = {
    .ai_family = AF_UNSPEC,
    .ai_socktype = SOCK_STREAM,
};

int addr_info_status = getaddrinfo(HOST, PORT, &addr_flags, &addr_info);
if (addr_info_status != 0) {
    fprintf(
        stderr,
        "[CLIENT] Error in getaddrinfo: %s.\n",
        gai_strerror(addr_info_status)
    );
    exit_code = 1;
    goto cleanup;
}

As you can see, we left out the AI_PASSIVE flag, since we do not want to accept connections from anywhere. We are the ones connecting, and we know the HOST in advance, so this is not needed.

Also, this time we actually pass the HOST as a parameter, since we want to communicate with a very specific machine (the server).

Just like on the server, you would have to iterate over the linked list returned by getaddrinfo.

Creating the Socket

We create the server socket, similarly to how we created it on the server code.

server_socket = socket(
    addr_info->ai_family,
    addr_info->ai_socktype,
    addr_info->ai_protocol
);
if (server_socket == -1) {
    perror("[CLIENT] Error creating socket.");
    exit_code = 1;
    goto cleanup;
}

Connecting to the Socket

We can now connect to the socket. On the server, we used bind, which tells the operating system what the address is which clients can use to reach us.

On the client, we use connect, which tells the operating system the destination we want to reach. We want to reach our server.

int connect_status = connect(
    server_socket,
    addr_info->ai_addr,
    addr_info->ai_addrlen
);
if (connect_status == -1) {
    perror("[CLIENT] Error connecting to socket.");
    exit_code = 1;
    goto cleanup;
}

Receiving Data

Finally, we can receive data sent by the server on the client. This is done with the recv (receive) function. On the server, we used send to send data to the client. recv is what allows us to read this data.

char server_response[256] = {0};
int receive_status = recv(
    server_socket,
    server_response,
    sizeof(server_response),
    0
);
if (receive_status == -1) {
    perror("[CLIENT] Error receiving data from socket.");
    exit_code = 1;
    goto cleanup;
}

printf("Data received: %s\n", server_response);

Since we don’t know how long the data is that the server will send, I allocate an array of 256 characters, just to be sure. Of course, for more serious example, this would not be enough. In real applications, you typically receive data in a loop and use some protocol to determine when a full message has been receive. This could be sending the length of the message at the start, or using a delimiter to denote the end of the message.

For now, we stick to the simple example.

The {0} syntax basically initializes the buffer to all \0s. This is convenient, because if for some reason the server forgets to send a \0 at the end of the message, we don’t really care, since all the padding at the end of our string will be \0.

Just like with the send function, the last argument to recv are flags, which we are not interested in specifying, so we pass 0 as the last argument.

At the end we can safely print the data we received.

Final Client Code

This is the final code for our client.

#include <stdio.h>

#include <netdb.h>
#include <unistd.h>

#define HOST "127.0.0.1"
#define PORT "8080"

int main(int argc, char const* argv[]) {
    int exit_code = 0;

    struct addrinfo *addr_info = NULL;
    int server_socket = -1;

    // -------------------------------------------------- ADDRESS
    struct addrinfo addr_flags = {
        .ai_family = AF_UNSPEC,
        .ai_socktype = SOCK_STREAM,
    };

    int addr_info_status = getaddrinfo(HOST, PORT, &addr_flags, &addr_info);
    if (addr_info_status != 0) {
        fprintf(
            stderr,
            "[CLIENT] Error in getaddrinfo: %s.\n",
            gai_strerror(addr_info_status)
        );
        exit_code = 1;
        goto cleanup;
    }

    // -------------------------------------------------- SOCKET
    server_socket = socket(
        addr_info->ai_family,
        addr_info->ai_socktype,
        addr_info->ai_protocol
    );
    if (server_socket == -1) {
        perror("[CLIENT] Error creating socket.");
        exit_code = 1;
        goto cleanup;
    }

    // -------------------------------------------------- CONNECTING
    int connect_status = connect(
        server_socket,
        addr_info->ai_addr,
        addr_info->ai_addrlen
    );
    if (connect_status == -1) {
        perror("[CLIENT] Error connecting to socket.");
        exit_code = 1;
        goto cleanup;
    }

    // -------------------------------------------------- RECEIVING
    char server_response[256] = {0};
    int receive_status = recv(
        server_socket,
        server_response,
        sizeof(server_response),
        0
    );
    if (receive_status == -1) {
        perror("[CLIENT] Error receiving data from socket.");
        exit_code = 1;
        goto cleanup;
    }

    printf("Data received: %s\n", server_response);

cleanup:
    // -------------------------------------------------- CLEANUP
    if (server_socket != -1) {
        if (close(server_socket) == -1)
            perror("[CLIENT] Error closing socket.");
    }
    if (addr_info != NULL) freeaddrinfo(addr_info);

    return exit_code;
}

Running the Code

Be careful to run the server before the client, otherwise the client will crash as soon as it is unable to reach the server. On the other hand, the server waits for a connection with the accept function.

In the same directory as your code, you can run the following commands to compile and execute your program. I am assuming that your files are called server.c and client.c for the server and the client respectively.

This compiles and executes the server.

gcc -oserver server.c && ./server

In a different terminal window, run

gcc -oclient client.c && ./client

to compile and run the client.

If everything went well, you should see the following in the the terminal window of the client.

Data received: Hello World!

Conclusion

As you can see, in less than 200 lines of code (roughly 100 for the client and 100 for the server), we were able to send a message over sockets without the help of external dependencies (except the operating system itself, of course).

Network programming involves a lot of complexity not covered in this simple guide, since we are only sending a small static message, but for more flexible approaches a lot more checks would be necessary.

You now have everything you need to start experimenting with networked programs in C. I am planning to write another article where I upgrade this example and handle multiple clients and gradually move away from the “toy example” limitations introduced in the current code.