Networking in C · beginner · ~8 min

What are sockets in C?

- By the end you can explain what a socket is and why the kernel hands it to you as an ordinary file descriptor. - By the end you can name the two roles in a TCP connection (server vs. client) and describe what each one does. - By the end you can create a socket with `socket()`, and recognise the `AF_INET`/`SOCK_STREAM` vs. `SOCK_DGRAM` choices. - By the end you can trace a full connection: `socket` -> `bind` -> `listen` -> `accept` on the server, and `socket` -> `connect` on the client. - By the end you can move bytes over a socket with `read()`/`write()` and release it with `close()`, all safely on localhost.

Overview

You already know how to package logic into functions — call something, pass arguments, get a return value, check it. A socket is nothing more exotic than a resource you obtain from a function (socket()), operate on through more functions (read, write, close), and always error-check the return of. That is the whole mental model: the sockets API is a family of ordinary C functions that hand you a small integer handle and expect you to check every return value.

This lesson introduces the idea of a socket before any of the later lessons drill into TCP vs. UDP, binding, or protocols. The key insight is that a socket is a file descriptor — the same kind of int handle the OS gives you for an open file — so the calls you use to read and write files work on sockets too. The only difference is where the bytes go: instead of flowing to and from disk, they flow between two programs. Everything here stays on localhost (127.0.0.1), your own machine talking to itself: safe, offline, and sends nothing to anyone else.

Why it matters

Almost every networked program you use — web browsers, databases, SSH, game servers, chat apps — is built on sockets underneath. Understanding that a socket is just a file descriptor demystifies all of it and lets you reason about resource leaks (an unclosed socket is a leaked fd, exactly like an unclosed file) and blocking behaviour. On the security side, every listening socket is an attack surface: knowing precisely which side binds a port, who can connect, and how bytes arrive is the foundation for writing servers that validate input instead of trusting it. Practising on loopback first means you learn these mechanics without ever exposing a port to the outside world.

Core concepts

A socket is a file descriptor

When you open a file, the kernel gives you a small non-negative integer — a file descriptor (fd) — that indexes into a per-process table of open resources. socket() does the same thing, but the resource it opens is a communication endpoint instead of a file on disk. That is why the functions you already know work on it:

  Your process                         Kernel
  ------------                         ------
  int fd = socket(...)  ------------>  allocates endpoint, returns 3

  Per-process fd table
  +-----+---------------------------+
  |  0  | stdin                     |
  |  1  | stdout                    |
  |  2  | stderr                    |
  |  3  | socket -> TCP endpoint    |  <-- read()/write()/close() act here
  +-----+---------------------------+

Because fd 3 is just an index, read(fd, ...), write(fd, ...), and close(fd) behave the same whether fd points at a file or a socket. The bytes simply travel to a different place.

The two roles: server and client

Every TCP conversation is asymmetric. One side is the server: it claims a port and waits. The other is the client: it reaches out to that port. They run different function sequences:

Step Server Client
1 socket() — make an endpoint socket() — make an endpoint
2 bind() — claim an address+port
3 listen() — mark it passive, start a queue
4 accept() — take one waiting client, get a new fd connect() — reach the server's port
5 read()/write() on the accepted fd read()/write() on the connected fd
6 close() both fds close() its fd

The subtle-but-important part: accept() returns a brand-new file descriptor dedicated to that one client. The original listening fd stays open and keeps accepting more clients. So a busy server holds one listening fd plus one connected fd per active conversation.

Knowledge check: after accept() succeeds, how many socket file descriptors does the server have open for one connected client, and what is each one for?

Two. The listening fd (from the original socket()/bind()/listen()) stays open to accept future clients; it is never used to exchange data. The connected fd returned by accept() is the one you read()/write() to talk to this particular client. Both must eventually be close()d.

TCP vs. UDP: the two socket kinds

The second argument to socket() picks the delivery style. You will study these in depth later; for now, know the two you will use:

Type Constant Guarantees Analogy
Stream SOCK_STREAM (TCP) Reliable, ordered, connection-based byte stream A phone call
Datagram SOCK_DGRAM (UDP) Best-effort, unordered, connectionless messages Postcards

A SOCK_STREAM socket delivers your bytes in order with no gaps or duplicates, but it has no notion of "messages" — if the client writes 20 bytes, the server might read() them as one chunk of 20, or two chunks of 10, or any split. That is why real code loops on read/write rather than assuming one call moves everything.

Addresses, ports, and localhost

To reach an endpoint you need an address (which machine) and a port (which program on it, 0–65535). On loopback the address is always 127.0.0.1 (the constant INADDR_LOOPBACK). Ports below 1024 are privileged; pick something high (e.g. 50000+) or ask the kernel to choose by binding to port 0 and reading back the assigned port with getsockname().

  addr.sin_family = AF_INET;                 // IPv4
  addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); // 127.0.0.1
  addr.sin_port = htons(50000);              // network byte order!

Multi-byte numbers on the wire use network byte order (big-endian), so ports and addresses are wrapped in htons()/htonl() (host-to-network) and unwrapped with ntohs()/ntohl(). Forgetting this is the classic beginner bug — the connection either fails or lands on the wrong port.

Syntax notes

#include <sys/socket.h>   // socket, bind, listen, accept, connect, setsockopt
#include <netinet/in.h>   // struct sockaddr_in, INADDR_LOOPBACK
#include <arpa/inet.h>    // htons, htonl, ntohs, ntohl, inet_pton
#include <unistd.h>       // read, write, close

int socket(int domain, int type, int protocol);
//   domain:   AF_INET (IPv4) or AF_INET6 (IPv6)
//   type:     SOCK_STREAM (TCP) or SOCK_DGRAM (UDP)
//   protocol: 0 lets the kernel pick the default for that type
//   RETURNS:  a new fd (>= 0) on success, or -1 with errno set. MUST be close()d.

int bind(int fd, const struct sockaddr *addr, socklen_t len);
//   Associates a socket with a local address+port. Cast &struct sockaddr_in to
//   (struct sockaddr *). RETURNS 0, or -1/errno (EADDRINUSE if the port is taken).

int listen(int fd, int backlog);
//   Marks fd passive; backlog is the max queue of not-yet-accepted connections.
//   RETURNS 0 or -1/errno.

int accept(int fd, struct sockaddr *addr, socklen_t *len);
//   Removes one connection from the queue. addr/len may be NULL if you don't
//   care who connected. RETURNS a NEW connected fd (>= 0), or -1/errno.
//   The returned fd must be close()d separately from the listening fd.

int connect(int fd, const struct sockaddr *addr, socklen_t len);
//   Client side: establishes a connection to a remote address. RETURNS 0 or -1/errno.

ssize_t read(int fd, void *buf, size_t n);   // returns bytes read, 0 at EOF/peer-closed, -1/errno
ssize_t write(int fd, const void *buf, size_t n); // returns bytes written (may be < n!), -1/errno
int close(int fd);                            // releases the fd; always close every socket

int setsockopt(int fd, int level, int optname, const void *val, socklen_t len);
//   SO_REUSEADDR at SOL_SOCKET lets you re-bind a recently-used port during dev.

Error convention: nearly every call returns -1 on failure and sets the global errno; use perror("context") to print a readable message. read/write may transfer fewer bytes than requested — always loop.

Lesson

What is a socket?

A socket is a communication endpoint provided by the operating system kernel. Your program sees it as a file descriptor (an int handle the OS gives you to refer to an open resource).

Because it is a file descriptor, you use the same calls you already know:

  • read() to receive bytes
  • write() to send bytes
  • close() to release it

The difference is where the bytes go. With a regular file, bytes flow to and from disk. With a socket, bytes flow between processes — usually across the network.

The two sides of a TCP connection

Every TCP conversation has two roles:

  • Server — binds to a port and listens for incoming connections.
  • Client — connects to that port and exchanges bytes.

We stay on localhost

Every lesson in this topic uses localhost only. Localhost (127.0.0.1) is your own machine talking to itself.

You will:

  • Write a TCP server that listens on 127.0.0.1.
  • Write a client that connects to it on the same machine.
  • Exchange a few bytes between them.

This is a safe, legal, and ethical sandbox. It works offline, runs on a single laptop, and sends no traffic to anyone else.

Code examples

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

/* Send all bytes, looping until the whole buffer is written. */
static int write_all(int fd, const char *buf, size_t len) {
    size_t sent = 0;
    while (sent < len) {
        ssize_t n = write(fd, buf + sent, len - sent);
        if (n < 0) { if (errno == EINTR) continue; return -1; }
        sent += (size_t)n;
    }
    return 0;
}

int main(void) {
    /* 1. A LISTENING server socket on loopback -- no real network. */
    int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (listen_fd < 0) { perror("socket"); return 1; }

    int yes = 1;
    setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof yes);

    struct sockaddr_in addr;
    memset(&addr, 0, sizeof addr);
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); /* 127.0.0.1 */
    addr.sin_port = 0;                             /* let the kernel pick a free port */

    if (bind(listen_fd, (struct sockaddr *)&addr, sizeof addr) < 0) { perror("bind"); return 1; }
    if (listen(listen_fd, 1) < 0) { perror("listen"); return 1; }

    /* Discover which port the kernel actually assigned. */
    socklen_t alen = sizeof addr;
    getsockname(listen_fd, (struct sockaddr *)&addr, &alen);
    int port = ntohs(addr.sin_port);
    printf("server listening on 127.0.0.1:%d\n", port);

    /* 2. A CLIENT socket connects to that port. */
    int client_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (client_fd < 0) { perror("socket"); return 1; }
    if (connect(client_fd, (struct sockaddr *)&addr, sizeof addr) < 0) { perror("connect"); return 1; }
    printf("client connected\n");

    /* 3. Server accepts, producing a NEW fd for this one conversation. */
    int conn_fd = accept(listen_fd, NULL, NULL);
    if (conn_fd < 0) { perror("accept"); return 1; }
    printf("server accepted a connection (conn_fd=%d)\n", conn_fd);

    /* 4. Client writes; server reads. A socket is just a file descriptor. */
    const char *msg = "hello over a socket";
    if (write_all(client_fd, msg, strlen(msg)) < 0) { perror("write"); return 1; }

    char buf[64];
    ssize_t got = read(conn_fd, buf, sizeof buf - 1);
    if (got < 0) { perror("read"); return 1; }
    buf[got] = '\0';
    printf("server read %zd bytes: \"%s\"\n", got, buf);

    /* 5. Close every fd. Same close() you use for files. */
    close(client_fd);
    close(conn_fd);
    close(listen_fd);
    printf("all sockets closed\n");
    return 0;
}

Line by line

  • write_all() helperwrite() may send fewer bytes than asked, so we loop until the whole buffer is out. EINTR means a signal interrupted the call; we simply retry. This is the correct pattern for any real stream socket.
  • socket(AF_INET, SOCK_STREAM, 0) — creates an IPv4 TCP endpoint and returns its fd. We check < 0 because every socket call can fail.
  • setsockopt(..., SO_REUSEADDR, ...) — lets us re-bind the port quickly during repeated test runs instead of waiting for the kernel's TIME_WAIT timeout. Not strictly required here (we use port 0) but shown because it is standard server hygiene.
  • memset(&addr, 0, sizeof addr) — zeroes the address struct so no stray bytes leak into padding fields. Always do this before filling a sockaddr_in.
  • addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK) — binds to 127.0.0.1 only, so nothing outside this machine can reach us. htonl converts to network byte order.
  • addr.sin_port = 0 — asks the kernel to assign any free port; we read it back afterwards. In real servers you would set a fixed port with htons(50000).
  • bind() then listen()bind claims the address; listen flips the socket into passive mode with a backlog queue of 1 pending connection.
  • getsockname() — since we bound to port 0, this tells us the real port the kernel chose, which we print.
  • connect() — the client reaches the server's address. Because the server already called listen, the kernel completes the TCP handshake and queues the connection even before accept runs.
  • accept() — pulls that queued connection off the queue and returns conn_fd, a separate fd for talking to this client. listen_fd remains open for future clients.
  • write_all(client_fd, ...) / read(conn_fd, ...) — the actual data exchange. Note the client writes and the server reads through two different fds that the kernel has wired together. We reserve one byte for the terminating '\0' and print what arrived.
  • The three close() calls — every fd we opened (client_fd, conn_fd, listen_fd) is released. Skipping any of these leaks a descriptor.

Common mistakes

1. Forgetting network byte order

addr.sin_port = 50000;            // WRONG: raw host-order integer

Why it breaks: on a little-endian machine the bytes are reversed on the wire, so you bind/connect to a different port (or fail entirely). Fix:

addr.sin_port = htons(50000);     // convert host -> network order

2. Ignoring return values / errno

int fd = socket(AF_INET, SOCK_STREAM, 0);
bind(fd, ...);                    // WRONG: unchecked; may have failed silently

Why it breaks: bind can fail with EADDRINUSE, and you proceed on a broken socket, getting confusing errors later. Fix:

if (bind(fd, ...) < 0) { perror("bind"); return 1; }

3. Reading/writing the listening fd instead of the accepted fd

int conn = accept(listen_fd, NULL, NULL);
read(listen_fd, buf, sizeof buf); // WRONG: listen_fd never carries data

Why it breaks: the listening socket only accepts connections; the data lives on the fd accept returned. Fix:

read(conn, buf, sizeof buf);      // use the accepted fd

4. Assuming one read returns the whole message

char buf[64];
read(fd, buf, 64);
buf[63] = '\0';                   // WRONG: assumes exactly 64 bytes, no length check

Why it breaks: TCP is a byte stream; a read may return 1, 19, or 63 bytes, and 0 means the peer closed. Terminating at a fixed index can read garbage. Fix:

ssize_t n = read(fd, buf, sizeof buf - 1);
if (n < 0) { perror("read"); return 1; }
buf[n] = '\0';                    // terminate at actual length; loop if you need more

Debugging tips

  • perror / strerror(errno) everywhere: the first -1 you fail to check is usually the real bug. Print the syscall name so you know which one failed.
  • errno cheatsheet: EADDRINUSE (port already bound — pick another or set SO_REUSEADDR), ECONNREFUSED (nothing is listening on that port), EACCES (tried a privileged port < 1024 without permission), EPIPE (wrote to a socket the peer already closed).
  • lsof -i or ss -tlnp (Linux) / lsof -i -nP (macOS): list which processes hold which ports — great for "why is my port in use?".
  • strace ./prog (Linux) / dtruss ./prog (macOS): watch the actual socket/bind/connect/accept/read/write syscalls and their return values in order.
  • nc -l 127.0.0.1 50000 (netcat) as a stand-in server, and nc 127.0.0.1 50000 as a stand-in client, to isolate whether the bug is in your server or your client.
  • Valgrind (valgrind --track-fds=yes ./prog) reports file descriptors still open at exit — a fast way to catch leaked sockets.

Memory safety

  • Descriptor leaks are resource leaks. Every socket() and every accept() returns an fd that must be close()d. A server that forgets to close accepted fds will hit the per-process fd limit (EMFILE) and stop accepting clients. Treat fds like malloc/free.
  • Never index a buffer past what read returned. read returns the actual byte count; using the requested size instead reads uninitialised or out-of-bounds bytes. Always size buffers to leave room for a '\0' if you will treat data as a string.
  • Bytes off a socket are untrusted input. Data may be any length, unterminated, or malicious. Bound every copy (memcpy/strncpy with explicit limits), never strcpy into a fixed buffer from socket data — that is the classic buffer-overflow vector. Validate length before you trust it.
  • zero from read means the peer closed, not an error. Distinguish 0 (EOF), < 0 (error, check errno), and > 0 (data) — mixing them up causes infinite loops or dropped data.
  • Writing to a closed peer raises SIGPIPE, which by default kills your process. In real servers ignore it (signal(SIGPIPE, SIG_IGN)) or use send(..., MSG_NOSIGNAL) and handle EPIPE.
  • Zero your sockaddr_in with memset before use so padding bytes are defined; passing uninitialised address structs is undefined behaviour on some platforms.

Real-world uses

  • Web and API servers (nginx, Node.js, Python's http.server) are all socket/bind/listen/accept loops underneath, usually multiplexed with epoll/kqueue so one thread handles thousands of connections.
  • Databases and caches (PostgreSQL, Redis) listen on a TCP port and speak a custom byte protocol over stream sockets — the same primitives you just used.
  • Unix domain sockets (AF_UNIX) use the identical API for fast local IPC between processes on one machine (e.g. Docker's /var/run/docker.sock).
  • Security tooling: honeypots, port scanners' targets, and firewalls all reason about listening sockets. Best practice for anything you expose: bind to the narrowest interface (loopback for local-only services), drop privileges after binding a low port, set timeouts, validate every byte, and cap connection counts to resist resource-exhaustion attacks.
  • Best-practice defaults: check every return value, always loop on partial read/write, close fds promptly, and prefer explicit fixed ports plus SO_REUSEADDR in servers so restarts don't fail with EADDRINUSE.

Practice tasks

  1. Print the fd numbers. Modify the example to print listen_fd, client_fd, and conn_fd. Confirm that conn_fd differs from listen_fd, proving accept created a new descriptor.
  2. Echo it back. After the server reads the message, have it write_all the same bytes back on conn_fd, and have the client read and print the echo. You now have a one-round-trip echo service on loopback.
  3. Fixed port with error handling. Change the code to bind a fixed port (e.g. 50505) with htons. Run it twice quickly and observe the EADDRINUSE error; then confirm SO_REUSEADDR fixes it.
  4. Handle partial reads. Send a longer message (say 5000 bytes) and write a read_exact(fd, buf, n) helper that loops until it has read exactly n bytes or hits EOF, mirroring the write_all pattern.
  5. Two clients, one listener. Keep listen_fd open and accept twice, exchanging a distinct message with each client fd in turn, then close all four fds. Verify with valgrind --track-fds=yes that no descriptor is leaked.

Summary

  • A socket is a kernel communication endpoint exposed to your program as a file descriptor — an int handle just like an open file.
  • Because it is an fd, you use read(), write(), and close() on it; only the destination of the bytes differs.
  • A TCP connection has two asymmetric roles: the server does socket -> bind -> listen -> accept, the client does socket -> connect.
  • accept() returns a new fd per client; the listening fd stays open for more connections. Close every fd you open.
  • SOCK_STREAM = TCP (reliable, ordered byte stream); SOCK_DGRAM = UDP (best-effort datagrams).
  • Ports and addresses go on the wire in network byte order (htons/htonl); check every return value against -1/errno; loop on partial read/write; and treat all incoming bytes as untrusted. Everything here runs safely on localhost only.

Practice with these exercises