Networking in C · intermediate · ~15 min

A complete simple TCP server (localhost)

- By the end you can name every syscall in the server lifecycle (`socket → setsockopt → bind → listen → accept → recv/send → close`) and say what each one does. - By the end you can distinguish the *listening* socket from the *per-client* socket that `accept` returns, and explain why there are two. - By the end you can bind safely to loopback with `INADDR_LOOPBACK` and use `SO_REUSEADDR` for clean restarts. - By the end you can read a complete, compilable one-client echo server and trace data as it flows in and back out. - By the end you can spot and fix the classic mistakes: `EADDRINUSE`, partial `send`, unterminated buffers, and closing the wrong descriptor.

Overview

You already know the individual moves: the listen-accept lesson showed how a socket is turned passive and how accept hands you a connection, and the send-recv lesson showed how bytes travel over an established connection. This lesson assembles those pieces into one working program you can compile and run — a server that binds to 127.0.0.1, waits for a single client, echoes one line back, and exits.

The value here is the whole shape: the exact order of calls, which errors each step can raise, and the crucial detail that a TCP server juggles two kinds of socket. Keeping the server to one connection then exit makes it small enough to hold in your head — the perfect skeleton to grow into everything that follows.

Why it matters

Almost every network service you will ever touch — web servers, databases, message brokers, SSH — is built on exactly this skeleton. Getting the lifecycle right is also a security boundary: binding to INADDR_LOOPBACK instead of INADDR_ANY is the difference between a lab tool only you can reach and a service exposed to your whole network. Mishandling the return value of recv/send, or forgetting to null-terminate a received buffer before treating it as a string, are the exact bugs that turn into remote crashes and buffer overreads in real code. Learn the safe pattern once and you carry it into every server you write.

Core concepts

Two sockets, not one

The single most important idea: a TCP server owns two different sockets with two different jobs.

  • The listening socket (returned by socket, then bind + listen) never carries user data. Its only job is to sit at an address/port and produce connections.
  • Each call to accept returns a brand-new connected socket dedicated to one client. All recv/send traffic happens on this descriptor, never on the listener.
  socket() ─► fd 3  (listening socket, passive)
                 │  bind + listen
                 ▼
            [ 127.0.0.1:8080 ]  ◄── client connects
                 │  accept()
                 ▼
              fd 4  (connected socket, one client)
                 │  recv / send
                 ▼
              close(4)  then later  close(3)

The process file-descriptor table after accept looks like this:

 fd | refers to
----+---------------------------------
  0 | stdin
  1 | stdout
  2 | stderr
  3 | listening socket  (127.0.0.1:PORT)
  4 | connected socket  (this client)

If you accidentally recv from fd 3 (the listener) you get an error, not data — a very common beginner mix-up.

The lifecycle, call by call

Step Call Job Common error
1 socket Create an endpoint (AF_INET, SOCK_STREAM) returns -1, errno=EMFILE (out of fds)
2 setsockopt Set options (SO_REUSEADDR) before bind -1 on bad option
3 bind Attach socket to address + port EADDRINUSE, EACCES (port < 1024)
4 listen Mark passive; create the backlog queue EADDRINUSE (if bind was skipped)
5 accept Block until a client connects; return new fd ECONNABORTED, EINTR
6 recv / send Move bytes on the connected socket 0 = peer closed, -1 = error
7 close Release each socket (both of them)

Endianness matters at step 3: ports and IPv4 addresses go on the wire in network byte order (big-endian), so you must wrap them with htons() (host-to-network short) for the port and htonl() for the address. Forget these and you bind to the wrong port on a little-endian machine.

Knowledge check: after accept returns fd 4, which descriptor do you call send on to reply to the client, and what happens to fd 3?

You reply on fd 4, the connected socket accept just returned — that is the only socket wired to this client. fd 3 (the listener) stays open and passive; you would call accept on it again to serve the next client. In this one-shot lesson we simply close it after the single client is done.

SO_REUSEADDR and why restarts fail without it

When a TCP connection closes, the kernel keeps the local address in the TIME_WAIT state for a while to catch stray packets. If you kill your server and restart it immediately, bind may fail with EADDRINUSE because the old address is still lingering. Setting SO_REUSEADDR before bind tells the kernel "let me re-bind this address even if a previous socket is winding down," which is exactly what you want during rapid edit-compile-run lab cycles.

Loopback vs. any interface (the defensive default)

bind needs an address to attach to, and the choice is a security decision:

Address macro Binds to Reachable from Use when
INADDR_LOOPBACK 127.0.0.1 This machine only Labs, local-only services, anything you don't want exposed
INADDR_ANY 0.0.0.0 (all interfaces) Any host that can route to you A real public/LAN service you intend to expose

For learning and for any service that has no business talking to the outside world (a local admin port, a debug endpoint), bind to INADDR_LOOPBACK. It means the operating system itself refuses connections from other machines — a firewall you get for free. Reaching for INADDR_ANY "just to make it work" is how debug servers end up exposed to the internet.

Framing: TCP is a byte stream, not messages

One subtle point that this echo server quietly relies on: TCP has no message boundaries. A single send of 15 bytes may arrive as one recv of 15, or as a 10 then a 5, and two sends may coalesce into one recv. Our lesson server reads exactly once and echoes what it got, which is fine for a one-line demo, but real protocols must define their own framing (a length prefix, or a delimiter like \n) and loop until a full message is received.

Syntax notes

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

int socket(int domain, int type, int protocol);
//   domain=AF_INET (IPv4), type=SOCK_STREAM (TCP), protocol=0 (default).
//   Returns a new file descriptor (>= 0), or -1 with errno set.
//   Must be close()d.

int setsockopt(int fd, int level, int optname,
               const void *optval, socklen_t optlen);
//   level=SOL_SOCKET, optname=SO_REUSEADDR, optval=&(int){1}, optlen=sizeof(int).
//   Returns 0 on success, -1 on error. Call BEFORE bind().

int bind(int fd, const struct sockaddr *addr, socklen_t addrlen);
//   addr is a (struct sockaddr *)-cast pointer to your struct sockaddr_in.
//   Returns 0, or -1 (EADDRINUSE, EACCES). Fill sin_family/sin_port/sin_addr first.

int listen(int fd, int backlog);
//   backlog = max pending, not-yet-accepted connections queued by the kernel.
//   Returns 0, or -1.

int accept(int fd, struct sockaddr *addr, socklen_t *addrlen);
//   Blocks until a client connects. addr/addrlen may be NULL if you don't
//   want the peer address. Returns a NEW connected fd (>= 0), or -1.
//   The returned fd must be close()d separately from the listener.

ssize_t recv(int fd, void *buf, size_t len, int flags);
//   Returns bytes read (> 0), 0 if the peer performed an orderly shutdown,
//   or -1 on error. flags=0 for normal blocking read.

ssize_t send(int fd, const void *buf, size_t len, int flags);
//   Returns bytes actually written (may be < len — a PARTIAL send), or -1.
//   Loop until everything is sent.

int close(int fd);   // Release the descriptor. Call on BOTH sockets.

// Byte-order helpers (host <-> network / big-endian):
uint16_t htons(uint16_t);  // port  -> wire
uint32_t htonl(uint32_t);  // addr  -> wire  (e.g. htonl(INADDR_LOOPBACK))
uint16_t ntohs(uint16_t);  // wire  -> host

Lesson

The full server, end to end

This lesson puts every piece together in order:

socket → setsockopt → bind → listen → accept → read/write → close

Each call has one job:

  • socket creates the listening endpoint.
  • setsockopt sets an option on it (here, address reuse).
  • bind attaches the socket to an address and port.
  • listen marks it ready to accept incoming connections.
  • accept waits for a client and returns a new socket for that client.
  • read/write (here recv/send) move data over the connection.
  • close releases each socket.

The server handles one connection, then exits. That keeps it small and predictable, which makes it ideal for labs and experiments.

Code examples

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

/* The client runs in a background thread so this demo is fully
   self-contained: no separate process, no external tool needed. */
static void *client_thread(void *arg) {
    unsigned short port = *(unsigned short *)arg;

    int c = socket(AF_INET, SOCK_STREAM, 0);
    if (c < 0) { perror("client socket"); return NULL; }

    struct sockaddr_in dst;
    memset(&dst, 0, sizeof dst);
    dst.sin_family      = AF_INET;
    dst.sin_port        = htons(port);
    dst.sin_addr.s_addr = htonl(INADDR_LOOPBACK);   /* 127.0.0.1 */

    if (connect(c, (struct sockaddr *)&dst, sizeof dst) < 0) {
        perror("connect"); close(c); return NULL;
    }

    const char *msg = "hello over tcp\n";
    send(c, msg, strlen(msg), 0);

    char buf[256];
    ssize_t n = recv(c, buf, sizeof buf - 1, 0);
    if (n > 0) { buf[n] = '\0'; printf("client: echo was: %s", buf); }

    close(c);
    return NULL;
}

int main(void) {
    /* 1. Create the listening socket. */
    int srv = socket(AF_INET, SOCK_STREAM, 0);
    if (srv < 0) { perror("socket"); return 1; }

    /* 2. Allow immediate reuse of the address on restart. */
    int yes = 1;
    setsockopt(srv, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof yes);

    /* 3. Bind to loopback, port 0 = "let the kernel pick a free port". */
    struct sockaddr_in a;
    memset(&a, 0, sizeof a);
    a.sin_family      = AF_INET;
    a.sin_port        = htons(0);
    a.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
    if (bind(srv, (struct sockaddr *)&a, sizeof a) < 0) {
        perror("bind"); return 1;
    }

    /* 4. Discover which port the kernel actually assigned. */
    struct sockaddr_in bound;
    socklen_t blen = sizeof bound;
    if (getsockname(srv, (struct sockaddr *)&bound, &blen) < 0) {
        perror("getsockname"); return 1;
    }
    unsigned short port = ntohs(bound.sin_port);
    printf("server: listening on 127.0.0.1:%u\n", port);

    /* 5. Mark the socket passive so it can accept connections. */
    if (listen(srv, 16) < 0) { perror("listen"); return 1; }

    /* Launch the demo client now that the port is known. */
    pthread_t tid;
    pthread_create(&tid, NULL, client_thread, &port);

    /* 6. Block until a client connects; get a fresh per-client socket. */
    struct sockaddr_in peer;
    socklen_t plen = sizeof peer;
    int client = accept(srv, (struct sockaddr *)&peer, &plen);
    if (client < 0) { perror("accept"); return 1; }

    char ip[INET_ADDRSTRLEN];
    inet_ntop(AF_INET, &peer.sin_addr, ip, sizeof ip);
    printf("server: accepted %s:%u\n", ip, ntohs(peer.sin_port));

    /* 7. Read one line, echo it back on the SAME per-client socket. */
    char buf[256];
    ssize_t n = recv(client, buf, sizeof buf - 1, 0);
    if (n > 0) {
        buf[n] = '\0';
        printf("server: got: %s", buf);
        ssize_t off = 0;
        while (off < n) {                    /* send may be partial */
            ssize_t w = send(client, buf + off, (size_t)(n - off), 0);
            if (w <= 0) { perror("send"); break; }
            off += w;
        }
    }

    /* 8. Close both sockets: client first, then the listener. */
    close(client);
    close(srv);

    pthread_join(tid, NULL);
    return 0;
}

Line by line

  • client_thread — a lab convenience so the whole exchange runs in one process. It creates its own socket, connects to 127.0.0.1:port, sends one line, and prints the echo it receives. In real life this would be a separate program (a browser, nc, or the companion simple-tcp-client lesson).
  • Step 1 socket(AF_INET, SOCK_STREAM, 0) — creates the listening endpoint. AF_INET = IPv4, SOCK_STREAM = TCP. We check for -1 because we can run out of file descriptors.
  • Step 2 setsockopt(..., SO_REUSEADDR, ...) — set before bind so a killed-and-restarted server can grab the same address without waiting out TIME_WAIT.
  • Step 3 bindmemset zeroes the struct so no padding byte carries garbage. sin_family = AF_INET, sin_port = htons(0) (0 means "kernel picks a free port"), sin_addr = htonl(INADDR_LOOPBACK) restricts us to local traffic. Both fields go through htons/htonl for network byte order.
  • Step 4 getsockname — because we asked for port 0, we don't know the real port until we ask the kernel; we print it so the demo client knows where to connect. (A fixed-port server would skip this and just use, say, htons(8080).)
  • Step 5 listen(srv, 16) — flips the socket to passive and sizes the backlog queue at 16 pending connections.
  • pthread_create — kicks off the demo client only after listen, guaranteeing the server is ready to accept.
  • Step 6 accept — blocks until the client connects, then returns client, a new descriptor for this one connection. We pass &peer/&plen to capture who connected.
  • inet_ntop — converts the peer's binary address back to a readable 127.0.0.1 string for logging (never use the unsafe inet_ntoa).
  • Step 7 recv + echo loop — read up to sizeof buf - 1 so there is always room for a null terminator; buf[n] = '\0' makes it a safe C string before we printf it. The while (off < n) loop handles a partial sendsend may write fewer bytes than asked, so we resend the remainder until it's all out.
  • Step 8 close(client); close(srv); — release the per-client socket first, then the listener. Two sockets, two closes.
  • pthread_join — waits for the client thread to finish so the program exits cleanly with no dangling thread.

Common mistakes

1. Forgetting SO_REUSEADDR (or setting it too late).

int srv = socket(AF_INET, SOCK_STREAM, 0);
bind(srv, ...);            // second run fails: EADDRINUSE

Why it breaks: the previous run's address lingers in TIME_WAIT, so bind is refused on a quick restart. setsockopt after bind is also useless — the option must be set on the listener before binding.

int srv = socket(AF_INET, SOCK_STREAM, 0);
int yes = 1;
setsockopt(srv, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof yes);  // BEFORE bind
bind(srv, ...);

2. Reading/writing on the listening socket instead of the accepted one.

int client = accept(srv, NULL, NULL);
recv(srv, buf, sizeof buf, 0);   // wrong fd! srv never carries data

Why it breaks: the listener only produces connections; user bytes live on the fd accept returned. The recv fails or hangs.

int client = accept(srv, NULL, NULL);
recv(client, buf, sizeof buf - 1, 0);   // use the connected socket

3. Treating the received buffer as a string without terminating it.

char buf[256];
ssize_t n = recv(client, buf, sizeof buf, 0);  // fills all 256 bytes
printf("%s", buf);   // no '\0' -> reads past the buffer

Why it breaks: TCP data is not null-terminated. If recv fills the whole buffer, printf("%s") reads past the end — undefined behaviour / info leak.

char buf[256];
ssize_t n = recv(client, buf, sizeof buf - 1, 0);  // leave room
if (n > 0) { buf[n] = '\0'; printf("%s", buf); }

4. Assuming send writes everything in one call.

send(client, buf, (size_t)n, 0);   // may write fewer than n bytes

Why it breaks: send can return a count smaller than requested (a partial write), silently dropping the tail of your reply.

ssize_t off = 0;
while (off < n) {
    ssize_t w = send(client, buf + off, (size_t)(n - off), 0);
    if (w <= 0) break;
    off += w;
}

Debugging tips

  • Check every return value. socket, bind, listen, accept, recv, send all return -1 on error; call perror("bind") (or print strerror(errno)) so you see which step failed and why (Address already in use, Permission denied, etc.).
  • EADDRINUSE on restart? You forgot SO_REUSEADDR, or another process holds the port. Find it with lsof -i :8080 (macOS/Linux) or ss -ltnp (Linux).
  • Nothing connects? Confirm the server is actually listening: ss -ltn / netstat -an | grep LISTEN. If you bound to INADDR_LOOPBACK, a client on another machine will never reach you — that's by design.
  • strace -e trace=network ./server (Linux) or dtruss (macOS, needs sudo) prints each syscall and its arguments/return — you can literally watch socket → bind → listen → accept → recv → send.
  • recv returns 0 means the peer closed the connection cleanly; -1 means an error (inspect errno). Treat them differently.
  • Hang on accept? That's normal — accept blocks until someone connects. Point a client at it (nc 127.0.0.1 8080) to unblock.
  • Run under valgrind ./server to catch reads past the buffer if you mishandle the recv length or terminator.

Memory safety

  • Always leave room for a terminator. Pass sizeof buf - 1 to recv, then write buf[n] = '\0' only when n > 0. Passing the full sizeof buf and then indexing buf[n] can write one byte out of bounds.
  • Never trust network length as a string length. Received bytes may contain embedded \0 or none at all; use the returned n, not strlen, to know how much you have.
  • Zero your sockaddr_in with memset before filling it so padding bytes don't leak stack garbage into bind/connect.
  • Don't use the accepted fd after close. Once you close(client), that descriptor number can be reused by the next socket/accept; a stale recv(client, ...) could then hit an unrelated connection (a use-after-close bug).
  • accept and recv can be interrupted (EINTR). In a signal-handling server, restart the call rather than treating EINTR as fatal.
  • Concurrency: this demo's client runs in a thread purely for a self-contained run; the server and client touch separate sockets and don't share mutable state, so there's no data race. If you extend the server to spawn a thread per client, each connection's recv/send must use its own per-client fd and its own buffer — never a shared global buffer.

Real-world uses

  • Every network server — HTTP servers (nginx, Apache), databases (PostgreSQL, Redis), and message brokers all start from this exact socket/bind/listen/accept core, then add concurrency (threads, fork, or an event loop like epoll/kqueue) to serve many clients at once.
  • Local IPC and admin ports — tools expose a control interface on 127.0.0.1 so only local processes can reach it; binding to loopback is the standard defensive default for anything not meant to be public.
  • Health checks and sidecars — microservices bind tiny TCP listeners for liveness/readiness probes.
  • Best practice: validate all return values; bind to the narrowest address that works (INADDR_LOOPBACK unless you truly need INADDR_ANY); run the service as an unprivileged user (ports below 1024 need root — pick a high port to avoid it); define explicit message framing rather than assuming one recv == one message; and set timeouts (SO_RCVTIMEO) so a slow or malicious client can't hang your accept loop forever.

Practice tasks

  1. Fixed port + peer logging. Modify the server to bind to a fixed port (e.g. 9090) instead of port 0, and print the client's IP and port using getpeername/inet_ntop after accept.

  2. Serve many clients. Wrap the accept → recv → send → close(client) part in an infinite loop so the server keeps handling one client after another (only the listener stays open across iterations). Add a way to exit on a special line like quit.

  3. Full-line framing. The demo reads exactly once. Change it to loop recv until it has received a complete line ending in \n, accumulating into a buffer, so it works even when the line arrives in several TCP segments.

  4. Uppercase echo with bounds safety. Instead of echoing verbatim, transform the received bytes to uppercase before sending them back, and prove with a test that a 255-byte input (filling the buffer) never overflows or drops its terminator.

  5. Harden against a stalled client. Set a receive timeout with setsockopt(client, SOL_SOCKET, SO_RCVTIMEO, ...) so a client that connects but never sends is dropped after N seconds instead of blocking the server forever; log the timeout via errno == EAGAIN.

Summary

  • A TCP server follows one fixed lifecycle: socket → setsockopt → bind → listen → accept → recv/send → close.
  • There are two sockets: the listening socket (from socket/bind/listen) that only produces connections, and the connected socket (from accept) that carries data. You close both.
  • Set SO_REUSEADDR before bind for clean restarts, and use INADDR_LOOPBACK to keep a lab/local service off the network.
  • Ports and addresses go on the wire in network byte order — wrap them with htons/htonl.
  • Defensive habits that carry into every server: check every return value, read with sizeof buf - 1 and null-terminate only when n > 0, and loop on send because it can write partially. TCP is a byte stream with no message boundaries, so real protocols must define their own framing.

Practice with these exercises