Home

Build a simple chat in C from scratch

10.02.2024

Introduction

Welcome to my first post (Hooray!). I don't usually write and honestly don't expect people to read this. In fact, this blog is kind of a place where I can keep personal notes on things I've learned for future reference if I ever need to remember something. Anyway, let's get started already.

This semester we have a course on the C programming language and although I already have a good understanding of C, it's been a few years and in fact it's the first programming language I learned. But I quickly switched to C++ and there are a lot of differences, so I wondered what I could do to practice C and learn something new? I've been wanting to get into network programming for a while. That's why I decided to build a chat from scratch.

Just a little warning, for this post I'm going to document my code a lot but don't expect this post (and my future posts) to be a step-by-step tutorial, I'll never do that because it kills the learning process. When people watch tutorials, they learn by imitation but their knowledge is limited because they get into a cycle where they watch lots of tutorials and reproduce specific things in a specific context but end up with this feeling of never really understanding the subjects. A good tutorial is one that gives you a starting point and guides you through the process so you don't waste a lot of time, but never gives you all the answers at every step, that's the philosophy I'll try to have in my posts.

Basics

First of all, it would be good to understand what we're trying to build here. The main purpose of our chat is to communicate, isn't it? This means that our program will have to establish some kind of connection between two devices. We want our two hosts to be able to read and write messages, so we need two-way communication, and we want messages to be sent in their entirety and data not lost along the way, so communication must be reliable.

Now, our data (i.e. our messages) have to be transported somehow, right? But how do we describe the way we want to transport our messages? When it comes to computer-to-computer communication, we often talk about protocols.

TCP

A protocol is essentially a set of rules describing communication between devices. The best-know transport protocol are TCP and UDP. TCP stands for Transport Control Protocol and enables a reliable two-way connection between hosts - exactly what we want! Now, we could use UDP and we'd have advantages like data transmission speed, but we wouldn't have data integrity and for a chat application this isn't very practical, so we use TCP.

TCP is in charge of data transmisison and maintains communication between application processes, it is present on the Transport Layer of something that we call the TCP/IP stack.

TCP/IP stack

End-to-end data communications is in reality provided by what we call the Internet Protocol suite, which is a specification of how data is transmitted and consist of different layers that handles different stage of the transmission process, from hardware to application. This is a conceptual model made up of 4 abstraction layer:

┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ APPLICATION LAYER                ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
│ http, ftp, smtp, ssh, etc.       │
└──────────────────────────────────┘
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ TRANSPORT LAYER                  ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
│ tcp, udp, etc.                   │
└──────────────────────────────────┘
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ INTERNET LAYER                   ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
│ ipv4, ipv6, etc.                 │
└──────────────────────────────────┘
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ LINK LAYER                       ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
│ arp, mac (ethernet, wifi, etc.)  │
└──────────────────────────────────┘

When data is received on the application layer, for our chat this will be the terminal, it talks to the transport layer through a port. Each port can be assigned to a different protocol in the application layer. This means we will have to specify a port so that TCP knows where the data is coming from or where it is sent. When a message is sent by the application, it passes through the layers on one side and back up through layers on the other side. One layer talks to the corresponding layer on the other side. The transmission of our messages can be represented as:

                                         
      ┌─┐                       ┌─┐      
      └┬┘                       ╞ │      
     client                    server    
┏━━━━━━━━━━━━━┓╷╷       ╭╮┏━━━━━━━━━━━━━┓
┃ APPLICATION ┃││ <---> ││┃ APPLICATION ┃
┗━━━━━━━━━━━━━┛││       ││┗━━━━━━━━━━━━━┛
┏━━━━━━━━━━━━━┓│▲       ▼│┏━━━━━━━━━━━━━┓
┃ TRANSPORT   ┃││ <---> ││┃ TRANSPORT   ┃
┗━━━━━━━━━━━━━┛││       ││┗━━━━━━━━━━━━━┛
┏━━━━━━━━━━━━━┓││       ││┏━━━━━━━━━━━━━┓
┃ INTERNET    ┃││ <---> ││┃ INTERNET    ┃
┗━━━━━━━━━━━━━┛▼│       │▲┗━━━━━━━━━━━━━┛
┏━━━━━━━━━━━━━┓││       ││┏━━━━━━━━━━━━━┓
┃ LINK        ┃││ <---> ││┃ LINK        ┃
┗━━━━━━━━━━━━━┛│╰───────╯│┗━━━━━━━━━━━━━┛
               ╰─────────╯                

To recap, we want to build a chat application, that is able to reiceive and send text messages through the transport layer using TCP which is a protocol part of the Internet Protocol suite (aka TCP/IP stack) provided by the operating system.

Implementation

Let's take a look at the man page for TCP as a reference to start implementing our chat:

TCP(7)                                        Linux Programmer's Manual                                        TCP(7)

NAME
       tcp - TCP protocol

SYNOPSIS
       #include <sys/socket.h>
       #include <netinet/in.h>
       #include <netinet/tcp.h>

       tcp_socket = socket(AF_INET, SOCK_STREAM, 0);

DESCRIPTION
       ...

       This  is  an implementation of the TCP protocol defined in RFC 2001 [...] It provides a reliable, stream-orie-
       nted, full-duplex connection between two sockets  on  top of  ip(7),  for  both  v4 and v6 versions.  TCP gua-
       rantees that the data arrives in order and retransmits lost packets.  It generates and checks a per-packet ch-
       ecksum to catch transmission errors.  TCP does  not  preserve record boundaries.

       A  newly  created TCP socket has no remote or local address and is not fully specified.  To create an outgoing
       TCP connection use connect(2) to establish a connection to another TCP socket.  To receive new  incoming  con‐
       nections,  first bind(2) the socket to a local address and port and then call listen(2) to put the socket into
       the listening state.  After that a new socket for each incoming connection can be accepted using accept(2).  A
       socket  which  has  had  accept(2) or connect(2) successfully called on it is fully specified and may transmit
       data.  Data cannot be transmitted on listening or not yet connected sockets.

       ...

From that page we now know that we need to implement something called a socket that we need to 'bind' to a local address. then we need to put this socket in 'listen' state in order to 'accept' incoming connnection. The following diagram gives a bit of an overview of what we need to implement.

                                                                                    
┏━━━━━━━━━━━━┓   ┏━━━━━━━━━━━━┓   ┏━━━━━━━━━━━━┓   ┏━━━━━━━━━━━━┓   ┏━━━━━━━━━━━━━━┓
┃   SOCKET   ┃ > ┃    BIND    ┃ > ┃   LISTEN   ┃ > ┃   ACCEPT   ┃ > ┃  READ/WRITE  ┃
┗━━━━━━━━━━━━┛   ┗━━━━━━━━━━━━┛   ┗━━━━━━━━━━━━┛   ┗━━━━━━━━━━━━┛   ┗━━━━━━━━━━━━━━┛

Setting up

Let's start by getting our development environment up and running, we can create a simple file called server.c that will be running on the server side. And let's print a simple "hello world" to be sure everything is working.

//server.c
#include <stdio.h>

int main() {
    printf("Hello world!\n");
    return 0;
}

We can compile by using the gcc command:

$  gcc -Wall -o server server.c

If you run that:

$  ./server
Hello world!

Perfect! Now we are ready to implement our socket.

Socket

From what we've seen in the tcp man page, we know we have to implement a socket, let's check the man page for that:

SOCKET(2)                                     Linux Programmer's Manual                                     SOCKET(2)

NAME
       socket - create an endpoint for communication

SYNOPSIS
       #include <sys/types.h>          /* See NOTES */
       #include <sys/socket.h>

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

DESCRIPTION
       socket()  creates  an  endpoint  for communication and returns a file descriptor that refers to that endpoint.
       The file descriptor returned by a successful call will be the lowest-numbered file  descriptor  not  currently
       open for the process.

Apparently a socket creates an endpoint for communications, in order to use them we need to include "sys/socket.h" and call a function named, well .. socket(). This function will return a socket descriptor and take three parameters : domain, type and protocol.

If you read the rest of the man page for socket you can see that we need the AF_INET domain in order to use IPv4 protocols, then we want to use the SOCK_STREAM socket type wich is the type of socket that support TCP and we can leave the protocol argument by default as 0 wich is TCP. The return value for the socket function is a file descriptor (an integer) that handles the socket we just created, if an error occur , the errno will be set and the function will return -1. errno is an integer variable that is set when an error was raised and we can use the perror function to print exactly what error was raised. Let's update our main function:

//server.c
#include <sys/socket.h>
#include <errno.h>
#include <stdio.h>

int main(int argc, char* argv[]) {
       
       //Create a socket to instantiate communication
       int server_fd;
       if( (server_fd = socket(AF_INET, SOCK_STREAM, 0)) < 0 ){
       	perror("ERROR: Failed to create socket");
       	return -1;
       }
       printf("INFO: Socket successfully created");
       return 0;
}

Nice, now if we check our little diagram the next step is to bind our socket to a local adress and port to be able to communicate through this socket.

┏━━━━━━━━━━━━┓   ┏━━━━━━━━━━━━┓   ┏━━━━━━━━━━━━┓   ┏━━━━━━━━━━━━┓   ┏━━━━━━━━━━━━┓
┃   SOCKET   ┃ > ┃    BIND    ┃ > ┃   LISTEN   ┃ > ┃   ACCEPT   ┃ > ┃ READ/WRITE ┃
┗━━━━━━━━━━━━┛   ┗━━━━━━━━━━━━┛   ┗━━━━━━━━━━━━┛   ┗━━━━━━━━━━━━┛   ┗━━━━━━━━━━━━┛
 ───────────▶                                                                     

Binding the socket to an address

Let's check the man page for bind in order to see how we need to implement the binding.

BIND(2)                                       Linux Programmer's Manual                                       BIND(2)

NAME
       bind - bind a name to a socket

SYNOPSIS
       #include <sys/types.h>          /* See NOTES */
       #include <sys/socket.h>

       int bind(int sockfd, const struct sockaddr *addr,
                socklen_t addrlen);

DESCRIPTION
       When  a  socket  is  created with socket(2), it exists in a name space (address family) but has no address as‐
       signed to it.  bind() assigns the address specified by addr to the socket referred to by the  file  descriptor
       sockfd.   addrlen  specifies  the size, in bytes, of the address structure pointed to by addr.  Traditionally,
       this operation is called “assigning a name to a socket”.

       It is normally necessary to assign a local address using bind() before a SOCK_STREAM socket may  receive  con‐
       nections (see accept(2)).

Apparently we need to "assign a name to the socket", that means assigning a local adress by calling the bind function. Again, three parameters required : the socket file descriptor, we already have that, a sockaddr struct and an addrlen integer. The addr parameter defines the local address we want to bind our socket to and depends on the family we specified before (AF_INET). In our case, it looks something like (You can check that on the man page for ip):

struct sockaddr_in {
   sa_family_t    sin_family; /* address family: AF_INET */
   in_port_t      sin_port;   /* port in network byte order */
   struct in_addr sin_addr;   /* internet address */
};

sin_family is already set to AF_INET by default, sin_port contains the port of the address and is in network byte order. To make things simple for us, engineer though it would be a good idea to store bytes in different order depending on the computer you are so when we try to communicate some computer don't understand each others. Joke apart, we need to store bytes in the same order when we receive or send data, this way a computer in 'little endian' can talk to a computer in 'big endian', if you don't know what i'm talking about feel free to do some research on your own but long story short you can store binary number either with the most significant bit (the one the most to the left) first or last. In order to convert a number in network order we use a function called htons(), can be read as "h to n" or "host to network" and this function will convert your number from host order (the order your machine stores number) to network order and the 's' stands for 'short' because we use short integer.
The sin_addr holds the adress and we can use INADDR_ANY which is a constant value defined in the "netinet/in.h" header file and means any address. It represent the 0.0.0.0 address of your host interface and is already in network byte order and is the default value used when we put 0. We use this address because we don't need a specific address since we will be testing our chat on local in the same interface's address.

The third argument was addrlen and is just the size in byte of the sockaddrr struct so we can use the sizeof() function. The return value will be 0 if there is no error and -1 if an error occured. Same as before the implementation will look something like this:

//server.c
#include <sys/socket.h>
#include <errno.h>
#include <stdio.h>
#include <arpa/inet.h>

#define PORT 6969

int main(int argc, char* argv[]){

       . . .

	// Create the address to bind the socket to
	struct sockaddr server_addr = {
		AF_INET,
		htons(PORT),
		0,
	};

       //Bind the socket to the address
	if ( bind(server_fd, &server_addr, sizeof(server_addr)) < 0 ){
		perror("Failed to bind socket");
		return -1;
	}
       printf("INFO: Successfully bound the socket on port %d", PORT);

       return 0;
}

Referring back to our diagram, we’ve now also bound the socket to a specific address. Now we are ready to listen for incoming connections. So lets implement that.

┏━━━━━━━━━━━━┓   ┏━━━━━━━━━━━━┓   ┏━━━━━━━━━━━━┓   ┏━━━━━━━━━━━━┓   ┏━━━━━━━━━━━━┓
┃   SOCKET   ┃ > ┃    BIND    ┃ > ┃   LISTEN   ┃ > ┃   ACCEPT   ┃ > ┃ READ/WRITE ┃
┗━━━━━━━━━━━━┛   ┗━━━━━━━━━━━━┛   ┗━━━━━━━━━━━━┛   ┗━━━━━━━━━━━━┛   ┗━━━━━━━━━━━━┛
 ───────────▶    ───────────▶                                                    

Listen state

We’ve created a socket and bounded it to a local address, now we need to make sure that the socket is listening for incoming connection. We do that by using listen function. This will make the socket available for incoming connections. Let’s see what the man pages can show us on how to use listen.

LISTEN(2)                                     Linux Programmer's Manual                                     LISTEN(2)

NAME
       listen - listen for connections on a socket

SYNOPSIS
       #include <sys/types.h>          /* See NOTES */
       #include <sys/socket.h>

       int listen(int sockfd, int backlog);

DESCRIPTION
       listen() marks the socket referred to by sockfd as a passive socket, that is, as a socket that will be used to
       accept incoming connection requests using accept(2).

The listen function will put the socket in a passive mode. When we create a socket it is set by default to active mode and can be used to create a connection with a passive socket that will allow incoming connection. Generally, on application that uses stream socket (TCP) the server perform passive socket listening and the client establish an active socket connection. Since we are creating the server side of our chat application we use the listen function in order to listen for incoming connections, the socket that we’ve created will be a passive socket, and will be used to accept connections from other (active) sockets.

The backlog parameters specifies the queue lenght for sockets waiting to be accepted, it has a default value of 128.

//server.c
int main(int argc, char* argv[]){

       . . .

	int backlog = 128;
	if( listen(server_fd, backlog) < 0 ){
		perror("ERROR: Failed to listen to connection");
		return -1;
	}
       printf("INFO : Listening for connection...");

       return 0;
}

Referring back to our diagram, we’ve created a socket, bound it to a local address, and we’ve put the socket into ‘passive’ mode. Now we can accept incoming connections.

┏━━━━━━━━━━━━┓   ┏━━━━━━━━━━━━┓   ┏━━━━━━━━━━━━┓   ┏━━━━━━━━━━━━┓   ┏━━━━━━━━━━━━┓
┃   SOCKET   ┃ > ┃    BIND    ┃ > ┃   LISTEN   ┃ > ┃   ACCEPT   ┃ > ┃ READ/WRITE ┃
┗━━━━━━━━━━━━┛   ┗━━━━━━━━━━━━┛   ┗━━━━━━━━━━━━┛   ┗━━━━━━━━━━━━┛   ┗━━━━━━━━━━━━┛
 ───────────▶    ───────────▶     ───────────▶                                  

Accept connections

Now we’re ready to make sure the socket will accept connections. We need to use the accept() function, you know the drill let’s check the man pages again on how we need to implement this.

ACCEPT(2)                                     Linux Programmer's Manual                                     ACCEPT(2)

NAME
       accept, accept4 - accept a connection on a socket

SYNOPSIS
       #include <sys/types.h>          /* See NOTES */
       #include <sys/socket.h>

       int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

       #define _GNU_SOURCE             /* See feature_test_macros(7) */
       #include <sys/socket.h>

       int accept4(int sockfd, struct sockaddr *addr,
                   socklen_t *addrlen, int flags);

DESCRIPTION
       The  accept()  system  call  is used with connection-based socket types (SOCK_STREAM, SOCK_SEQPACKET).  It ex‐
       tracts the first connection request on the queue of pending connections for the listening socket, sockfd, cre‐
       ates  a  new  connected socket, and returns a new file descriptor referring to that socket.  The newly created
       socket is not in the listening state.  The original socket sockfd is unaffected by this call.

Nothing fancy here, same as before we call the accept function that takes the listening socket as an argument. In the man page we can see that the other two arguments are nullable so we will let them at zero. The return value is a file descriptor for the connected client's socket.

//server.c
int main(int argc, char* argv[]){

       . . .

	int client_fd;
	if( (client_fd = accept(server_fd, 0, 0)) < 0)	{
		perror("ERROR: Failed to accept connection");
		return -1;
	}
       printf("INFO : Client connected.");

       return 0;
}
┏━━━━━━━━━━━━┓   ┏━━━━━━━━━━━━┓   ┏━━━━━━━━━━━━┓   ┏━━━━━━━━━━━━┓   ┏━━━━━━━━━━━━┓
┃   SOCKET   ┃ > ┃    BIND    ┃ > ┃   LISTEN   ┃ > ┃   ACCEPT   ┃ > ┃ READ/WRITE ┃
┗━━━━━━━━━━━━┛   ┗━━━━━━━━━━━━┛   ┗━━━━━━━━━━━━┛   ┗━━━━━━━━━━━━┛   ┗━━━━━━━━━━━━┛
 ───────────▶    ───────────▶     ───────────▶    ───────────▶                 

Read and Write messages

Finally we get to the most important part where we actually read and write messages ! For that we need to somehow watch for incoming (or outgoing) events and by that I mean messages. For outgoing mesages it is simple we just need to read the input from the standard input of the terminal and for incoming messages we need to check for input from the socket we connected to.

To acheive a such thing we can use a function called poll. You know the drill, let's check the man page:

POLL(2)                                       Linux Programmer's Manual                                       POLL(2)

NAME
       poll, ppoll - wait for some event on a file descriptor

SYNOPSIS
       #include <poll.h>

       int poll(struct pollfd *fds, nfds_t nfds, int timeout);

       #define _GNU_SOURCE         /* See feature_test_macros(7) */
       #include <signal.h>
       #include <poll.h>

       int ppoll(struct pollfd *fds, nfds_t nfds,
               const struct timespec *tmo_p, const sigset_t *sigmask);

DESCRIPTION
       poll()  performs a similar task to select(2): it waits for one of a set of file descriptors to become ready to
       perform I/O.  The Linux-specific epoll(7) API performs a similar task, but offers features beyond those  found
       in poll().
       The  set of file descriptors to be monitored is specified in the fds argument, which is an array of structures
       of the following form:

           struct pollfd {
               int   fd;         /* file descriptor */
               short events;     /* requested events */
               short revents;    /* returned events */
           };

We can see that this function waits for a set of file descriptor to perform I/O, this set of file desciptor is specified as an array of pollfd struct. Let's implement it

//server.c
#include <poll.h>
#include <signal.h>

. . .

struct pollfd fds[2] = {
	{
		0,
		POLLIN,
		0,
	},
	{
		client_fd,
		POLLIN,
		0,
	}};

Don't forget to include poll.h ans signal.h, the first element of the array represent the server side, a.k.a the messages we are typing on the terminal, this is specified with the file descriptor "0". The possible events are listed on the man page, the one we are interested in is POLLIN, as described it means there is data to read. For the return value, the revents is an output parameter filled by the kernel when an event occured. We do the same thing for the client socket by just changing the file descriptor field.

Now we can call the poll function, we want to put it inside a for loop because we want our application to run continueously. The third arguments is the timeout for polling, we set it to -1 for infinite timeout.

for(;;) {
poll(fds,2,-1);
}

The last thing we want to do is actually sending messages and printing incoming messages to the screen. To do that we can check if the returned event match the POLLIN with a bitwise And. For sending the message we need to read from the stdin (file descriptor 0) and then use the send function to send the data on the client_fd. For receiving messages we nead to use the recv function to reiceive data from the client_fd and then print to screen the message. Of course we need to store the data on a buffer when we receive it (recv or read) before processing it (sending or printing).

You can check the man pages for read, send and recv, those are pretty straighforward function nothing more different from what we've done, here's the implementation:

//server.c
#include <unistd.h>
       . . .

       	//Infinite loop for receiving and sending datta as long as the application is running
	for(;;) {
		char buffer[256] = { 0 };
		poll(fds,2,-1);
		if(fds[0].revents & POLLIN){
			read(0, buffer, sizeof(buffer));
			send(client_fd, buffer, sizeof(buffer), 0);
		} else if (fds[1].revents & POLLIN) {
			recv(client_fd, buffer, sizeof(buffer), 0);
			printf("%s", buffer);
		}
	}

       return 0;

Implementing the client

Having a server up and running and waiting for connection is good, but we're gonna be waiting a looooong time if we don't write a client app to connect on that server. Luckily for us, the code is pretty much the same, we have the same for loop, we need to rename the socket to client_fd and replace the part where we bind and accept conneciton by the connect function. Again I let you check the documentation for that but it is prety simple:

//client.c
#include <sys/socket.h>
#include <arpa/inet.h>
#include <stdio.h>
#include <errno.h>
#include <poll.h>
#include <unistd.h>

int main(int argc, char* argv[]){

	//Create a socket to instantiate communication
	int client_fd;
	if( (client_fd = socket(AF_INET, SOCK_STREAM, 0)) < 0 ){
		perror("ERROR: Failed to create socket");
		return -1;
	}
	printf("INFO: Socket successfully created");


	struct sockaddr server_addr = {
		AF_INET,
		htons(6969),
		0,
	};
	
	//Connect to the server (server socket need to be on passive mode) 
	if( connect(client_fd, &server_addr, sizeof(server_addr)) < 0){
		perror("ERROR: Failed to connect to server :");
	       	return -1;
	}
	printf("INFO : Connected to server.");

	struct pollfd fds[2] = {
		{
			0,
			POLLIN,
			0
		},
		{
			client_fd,
			POLLIN,
			0
		}};

	for(;;) {
		
		char buffer[256] = { 0 };
		poll(fds, 2, -1);
		
		if(fds[0].revents & POLLIN){
			read(0, buffer, sizeof(buffer));
			send(client_fd, buffer, sizeof(buffer), 0);
		} else if (fds[1].revents & POLLIN) {
			recv(client_fd, buffer, sizeof(buffer), 0);
			printf("%s", buffer);
		}
	}	

	return 0;
}

Testing

The most exciting part, let's test our chat application ! First let's compile it because the code is good and all but we want some binary files.

$ gcc server.c -o server
$ gcc client.c -o client

Now open up two terminals and launch server then client and you should be able to chat in real time with yourself, yeah this is not a tutorial on how to make friend, don't ask to much.

Conclusion

If you've followed along and read the whole post, first I'd like to thank you because it take some time to write a post like that and I'd appreciate if you did not scroll to the end just to get the whole code like most people do, but if did, well, I'll be nice and give you the code anyway but man you should really read the whole thing you'd learn a lot of things. If you had any problem you can compare your code with mine here : server.c, client.c

Of course this is a very minimalist chat and we can add a lot of functionalities but this should be a pretty good base for creating a more complex chat application, feel free to adapt the code on your own.

References

The main reference is of course the linux documentation as you've seen throughout the post but if you are interested in Network Programming I suggest reading Beej's book on socket programming.