Networking in C · beginner · ~8 min
- 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.
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.
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.
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.
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 byaccept()is the one youread()/write()to talk to this particular client. Both must eventually beclose()d.
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.
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.
#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.
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 byteswrite() to send bytesclose() to release itThe 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.
Every TCP conversation has two roles:
Every lesson in this topic uses localhost only. Localhost (127.0.0.1) is your own machine talking to itself.
You will:
127.0.0.1.This is a safe, legal, and ethical sandbox. It works offline, runs on a single laptop, and sends no traffic to anyone else.
#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;
}
write_all() helper — write() 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.close() calls — every fd we opened (client_fd, conn_fd, listen_fd) is released. Skipping any of these leaks a descriptor.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
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 --track-fds=yes ./prog) reports file descriptors still open at exit — a fast way to catch leaked sockets.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.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.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.SIGPIPE, which by default kills your process. In real servers ignore it (signal(SIGPIPE, SIG_IGN)) or use send(..., MSG_NOSIGNAL) and handle EPIPE.sockaddr_in with memset before use so padding bytes are defined; passing uninitialised address structs is undefined behaviour on some platforms.http.server) are all socket/bind/listen/accept loops underneath, usually multiplexed with epoll/kqueue so one thread handles thousands of connections.AF_UNIX) use the identical API for fast local IPC between processes on one machine (e.g. Docker's /var/run/docker.sock).read/write, close fds promptly, and prefer explicit fixed ports plus SO_REUSEADDR in servers so restarts don't fail with EADDRINUSE.listen_fd, client_fd, and conn_fd. Confirm that conn_fd differs from listen_fd, proving accept created a new descriptor.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.htons. Run it twice quickly and observe the EADDRINUSE error; then confirm SO_REUSEADDR fixes it.read_exact(fd, buf, n) helper that loops until it has read exactly n bytes or hits EOF, mirroring the write_all pattern.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.int handle just like an open file.read(), write(), and close() on it; only the destination of the bytes differs.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).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.