Intro to TCP Socket Programming in C


Introduction and Context

First-sockets are not solely a network concept. Berkeley Sockets are merely an abstraction of passing messages between two parties. That could be two programs on your local machine (not using any IP addresses) or it could be two machines across the internet. Sockets are just a two-way pipe, and a pipe is just a first-in-first-out buffer, i.e. you receive messages in the order I send them. Sockets let both parties send messages simultaneously, and each party receives messages in the order they were sent.

That said, we often use sockets for communicating across the internet domain using the internet protocol (IP). This protocol allows two devices to communicate to one another using addresses, e.g. 192.168.1.2 → 192.168.1.3. You'll learn more about the protocol and its addressing later in the course.

On top of IP, we can use other protocols such as UDP or TCP. UDP lets us send singular messages with no guarantee that it will reach the destination—like sending a letter in the mail. TCP, on the other hand, is connection-based; it establishes a connection between both parties. This is more similar to a phone call wherein I request a connection by dialing you, you accept by picking up, we talk, and we end the connection. We'll revisit UDP next week, but for this lab we'll start with TCP.

The Essential Server

Let's start with a basic TCP server, a program that runs and receives incoming messages. We'll need to do the following:

  1. Create our socket
  2. Bind it to a port and address on our computer
  3. Listen for a queue of connections
  4. Accept an incoming connection
  5. Read and/or write to our socket to communicate with the client

To create a socket, we can include the <sys/socket.h> library and use the aptly-name socket function:

// my_server.c
#include <sys/socket.h>

int main(){
    int sock_fd = socket(PF_INET, SOCK_STREAM, 0);
    if(sock_fd <= 0){
        // uh-oh! failed to get a socket and file descriptor
        // the `perror` command can be helpful here
    }
}

The first argument to socket tells the OS what type of socket we'd like to open. We want one for communicating with version 4 of the internet protocol (IPv4), so we'll use PF_INET. Other options are listed in the manual page. The latter two arguments to socket, SOCK_STREAM and 0, indicate that we want a stream-like protocol (TCP) and we have 0 special options to set. The function will return an integer called a file descriptor which helps keep track of the socket as a place where our program can read from and write to. If creating the socket fails, it will return a -1 and set the global errno variable. The perror function from <stdio.h> uses this errno value and can be helpful in printing the reason behind a function's failure.

We're not done yet, though. We still need to bind our socket to to an address. In other words, we need to tell the operating system we'd like all incoming traffic going to a specific network address and port to be passed in to our socket. For a server, it might be useful to listen to all of the IP addresses our computer has. For the port number, we can pick any number at or above 1024 (ports 0-1023 are reserved and require special privileges for our program to receive on).

To do this, we'll need to use a struct sockaddr. Note that we don't create an actual struct sockaddr explicitly, but instead create a sort-of sub-struct like sockaddr_in, which is a type of sockaddr address struct specifically for the internet domain. If you're famiilar with inheritance or derived classes in other languages, you might think of this as C's version.

We'll have to give it a family (once again, PF_INET), an address, and a port. When we do this, we need to use special functions that make sure the way our system stores integers doesn't matter (you might've covered this topic, endianness, in previous classes). The "host to network short" function, htons helps us do this by taking an integer from our system and converting it into the standard network order. This way, both the client and server can agree on the ordering of bytes in numbers. We also will use the special address INADDR_ANY, which tells our OS we'd like to listen on all of our available interfaces.

// my_server.c
#include <sys/socket.h>
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include <arpa/inet.h>

#define PORT 4890

int main(){
    int sock_fd = socket(PF_INET, SOCK_STREAM, 0);
    if(sock_fd <= 0){
        // uh-oh! failed to get a socket and file descriptor
        // the `perror` command can be helpful here
    }

    // build the socket address we'll listen on
    struct sockaddr_in server_addr;
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = INADDR_ANY;
    server_addr.sin_port = htons(PORT);

    if(bind(sock_fd, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {
        // important! if we fail to bind, it's good to close the socket and exit
        close(sock_fd);
        exit(1);
    }
}

Now we have a socket bound to an address. The next step is to open for business! We can use the listen command to let our server build up a queue of incoming connections. For this lab, feel free to set the backlog to anything greater than or equal to 1, e.g. 10.

#define BACKLOG 10 // max number of connections in queue

/* ... do our socket creation, binding, listening ... */

// listen for incoming connections
listen(sock_fd, BACKLOG);

Next, it's time to handle individual connections. For this, we use the accept function. The accept parameters are a little more confusing than most. First, we pass in the file descriptor of the socket we're accepting a connection from. Then, we need to let accept tell us the address information of the client that's connecting. To do this, we pass in two pointers: one to a struct sockaddr, and one to a socklen_t. The struct sockaddr_in is an "output" parameter--the function fills it out completely. The socklen_t is an input AND output parameter, so we set its value to the initial size of the struct sockaddr we give accept and accept will modify it to reflect the true size of the struct sockaddr it fills out. Lastly, we'll store the return value, a file descriptor representing that particular connection. Here's what this looks like in action:

/* .. other variable definitions ... */

// set up our client addr struct and size variable
struct sockaddr_in client_addr;
socklen_t client_addr_len = sizeof(client_addr);

/* ... do our socket creation, binding, listening ... */

// accept a single connection and store the file descriptor
int conn_fd = accept(sock_fd, (struct sockaddr_in*)&client_addr, &client_addr_len);

It may help to also error check your connection's file descriptor. Once we have it, though, it's time to send or receive. For this, we'll use the write function and read function. Remember that read will be, by default, a blocking function--your program will halt and wait for something to be available to read on your socket. Both of these accept similar arguments: a file descriptor (conn_fd in our case), a buffer to either write from or read to, and a length. For write, this is the number of bytes to be written from your program's buffer to the socket. For read, this is the number of bytes that will be transferred from that socket to your program's buffer.

Let's first read from the client, then write a response:

/* ... other includes ... */
#include <string.h>
/* ... other defines ... */
#define BUFFER_SIZE 4096
/* ... other variable definitions ... */
char read_buf[BUFFER_SIZE];

/* ... socket creation, binding, listening, accepting ... */

/* read from the client. Reserve the last byte for adding a null-terminator, 
 * making it safe for printing */
int num_bytes_read = read(conn_fd, read_buf, BUFFER_SIZE - 1);
read_buf[num_bytes_read] = '\0';
printf("Received message: %s\n", read_buf);

/* write a response to the client */
write(conn_fd, "Hi!", strlen("Hi!"));

/* close the connection to our client */
close(conn_fd);

And there we have it, a simple TCP server! If you haven't pieced together the above snippets, you should! I've included an abbreviated version below.

#include <sys/socket.h>
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <string.h>

#define PORT 5000
#define BACKLOG 10
#define BUFFER_SIZE 4096

char read_buf[BUFFER_SIZE];

int main(){
    int sock_fd = socket(PF_INET, SOCK_STREAM, 0);
    if(sock_fd <= 0){
        // uh-oh! failed to get a socket and file descriptor
        // the `perror` command can be helpful here
    }

    // build our socket address variables
    struct sockaddr_in server_addr, client_addr;
    socklen_t client_addr_len;

    // set the server address
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = INADDR_ANY;
    server_addr.sin_port = htons(PORT);

    // bind
    bind(sock_fd, (struct sockaddr*)&server_addr, sizeof(server_addr));

    // listen
    listen(sock_fd, BACKLOG);

    // accept
    int conn_fd = accept(sock_fd, (struct sockaddr*)&client_addr, &client_addr_len);

    // read
    int num_bytes_read = read(conn_fd, read_buf, BUFFER_SIZE - 1);
    read_buf[num_bytes_read] = '\0';
    printf("Received message: %s\n", read_buf);

    // write
    write(conn_fd, "Hi!", strlen("Hi!"));

    // close
    close(conn_fd);
    close(sock_fd);
}

Next Steps

There's a handful of things missing from this which you'll need to implement for your lab's server:

  1. Error checking and handling. Use perror for this as mentioned before.
  2. The server should remain in a loop of accepting and handling client connections. After it finishes one, it should close that connection and accept the next.
  3. Uptime! We've just written an arbitrary message. It's up to you to use popen or another method to read the output from the uptime command and send it back to the client.

You'll also need to implement the client. I'll leave this as an exercise for you. Just like we did for the server, use manual pages or other resources to figure out usage and implement a client which does the following:

  1. Create a socket using socket
  2. (Optional) bind to an address. If you don't, the OS will select an address and port for you. This is usually OK for clients since the address and port portions of the IP header for the packets sent will tell the server where to respond to us at.
  3. connect to the server using a struct sockaddr_in which holds the server's family, address, and port.
  4. Similar to the server, write and read on your socket's file descriptor!

Good luck! Ask your TA if you have any questions!