Networking in C · intermediate · ~10 min
- Explain what a socket is and why it behaves like a file descriptor - Choose the correct **address family** (`AF_INET`, `AF_INET6`, `AF_UNIX`) and **type** (`SOCK_STREAM`, `SOCK_DGRAM`) for a task - Create a socket with `socket()` and fill in a `struct sockaddr_in` correctly - Convert port numbers and addresses to **network byte order** with `htons`/`htonl` - Trace the server call sequence `socket → bind → listen → accept` and the client sequence `socket → connect` - Close every socket and check every return value so no descriptor or connection leaks
A socket is one endpoint of a two-way communication link between two programs — often on two different machines. Think of a network connection as a pipe stretched across the network: a socket is the mouth of that pipe on your side. You write bytes into it and they come out the other end; the other program writes bytes back and they come out of yours.
The most important idea in this lesson connects directly to the prerequisite File descriptors: on Unix, a socket is a file descriptor. When you call socket() you get back a small non-negative integer, exactly like open() gives you for a file. You can read() and write() it, you must close() it, and it counts against your process's descriptor limit. Everything you already know about descriptors — that they are just indexes into a per-process table, that leaking them is a real bug, that -1 means failure — applies here unchanged.
What makes sockets special is that one small API covers every kind of network communication. The same handful of calls — socket, bind, listen, accept, connect, read, write, close — work for TCP, UDP, and local same-machine channels. You do not learn a new API for each protocol; you pass different constants to socket() and the kernel does the rest. This design, the Berkeley (BSD) sockets API, has been the standard since the early 1980s and is what Python, Go, Rust, and Java all wrap underneath.
In plain language: a socket is a phone line. socket() buys the phone, bind() assigns it a number, listen()/accept() waits for calls, and connect() dials out. In this lesson you set up the phone; in the next lessons you make and answer calls.
Sockets sit underneath essentially every networked program you use:
Because higher-level libraries wrap this exact API, understanding C sockets means you understand what every networking framework is actually doing. When a Python requests call hangs, or a Go server reports "address already in use", the cause lives in the socket layer you learn here. It is also the foundation for the security side of networking: to reason about what a service exposes, who can connect, and how data crosses trust boundaries, you first need to know how the connection is built.
Definition. A socket is a kernel object for network I/O, referenced from your program by a file descriptor — a small non-negative integer.
Plain-language explanation. When you open a file you get a descriptor; when you create a socket you get one too. The number is meaningless on its own; it is an index into a table the kernel keeps for your process. Everything downstream — reading, writing, closing — uses that number.
How it works internally. The kernel keeps a per-process descriptor table. Entry N points at a kernel structure describing the open resource. For a file that structure tracks a position in the file; for a socket it tracks connection state, buffers, and the protocol. Your program never sees the structure — only the integer handle.
Process descriptor table
+-----+---------------------------+
| fd | kernel object |
+-----+---------------------------+
| 0 | stdin (terminal) |
| 1 | stdout (terminal) |
| 2 | stderr (terminal) |
| 3 | SOCKET --> TCP endpoint | <- socket() returned 3
+-----+---------------------------+
|
v
[ send buffer ][ recv buffer ]
[ protocol state: TCP, peer, port ]
When to use / not use. Treat a socket like any descriptor for read/write/close. But do not assume file conveniences carry over: sockets have no meaningful "position", lseek fails on them, and a single read may return fewer bytes than the peer sent.
Pitfall. Forgetting that socket() returns -1 on failure. If you skip the check and use -1 as a descriptor, every later call fails in confusing ways.
Knowledge check: In your own words, why can you call
close()on both a file and a socket even though one is on disk and one is a network connection?
Definition. The address family selects the naming scheme for endpoints.
| Family | Meaning | Address struct |
|---|---|---|
AF_INET |
IPv4 (e.g. 127.0.0.1) |
struct sockaddr_in |
AF_INET6 |
IPv6 (e.g. ::1) |
struct sockaddr_in6 |
AF_UNIX |
Local, same-machine, by path | struct sockaddr_un |
How it works. The family decides which address structure you fill in and how the kernel routes the traffic. AF_INET uses a 32-bit IPv4 address plus a 16-bit port. AF_UNIX uses a filesystem path and never touches the network card at all — it is the fastest way for two processes on one machine to talk.
When to use / not use. Use AF_INET/AF_INET6 to talk across a network; use AF_UNIX for local inter-process channels (a database and its client on the same host). Do not mix a family with the wrong address struct — that is a classic beginner crash.
Pitfall. Setting sin_family to AF_INET but casting a sockaddr_un into bind. The sizes and layouts differ; the kernel rejects it or misreads memory.
Definition. The socket type selects the delivery guarantees.
| Type | Protocol | Guarantees | Analogy |
|---|---|---|---|
SOCK_STREAM |
TCP | Reliable, ordered, connection-based | A phone call |
SOCK_DGRAM |
UDP | Best-effort, unordered, message | Postcards |
How it works. SOCK_STREAM (TCP) gives you a byte stream: the kernel handles acknowledgements, retransmission, and ordering, so what you write eventually arrives, in order — but with no message boundaries. SOCK_DGRAM (UDP) gives you discrete datagrams: each sendto is one message that may arrive, arrive out of order, or vanish, but boundaries are preserved.
SOCK_STREAM (TCP): boundaries dissolve
write("HEL"); write("LO"); ---> peer read() might get "HELLO" or "HE"+"LLO"
SOCK_DGRAM (UDP): boundaries kept, delivery not guaranteed
sendto("HELLO"); ---> peer gets "HELLO" as one datagram, or nothing
When to use / not use. Use SOCK_STREAM when you need every byte in order (HTTP, databases, file transfer). Use SOCK_DGRAM when speed beats reliability and you can tolerate loss (live video, games, DNS queries). Do not assume one TCP read equals one logical message — you must frame messages yourself.
Pitfall. Expecting SOCK_STREAM to preserve your write() boundaries. It does not; treat incoming data as an unbounded stream and re-assemble.
Knowledge check (find-the-bug): A learner writes a 100-byte request with one
write, then does onereadexpecting 100 bytes back and gets 40. Nothing is broken — what did they forget aboutSOCK_STREAM?
Definition. The network transmits multi-byte integers big-endian first ("network byte order"), regardless of how your CPU stores them internally ("host byte order").
How it works. A port like 8080 is a 16-bit number = 0x1F90. A big-endian machine stores it as bytes 1F 90; a little-endian machine (x86, most ARM) stores it 90 1F. If you put raw host bytes on the wire, the peer reads the wrong number. The conversion helpers fix this:
| Helper | Direction | Width |
|---|---|---|
htons |
host → network | 16-bit |
htonl |
host → network | 32-bit |
ntohs |
network → host | 16-bit |
ntohl |
network → host | 32-bit |
On a big-endian CPU these are no-ops; on little-endian they swap bytes. Because you cannot know at compile time which CPU runs your code, always wrap ports and addresses in them.
When to use / not use. Wrap the port (sin_port) with htons and numeric addresses with htonl. Do not wrap payload data you send with write — byte order helpers are only for the address fields and any integers in your own protocol.
Pitfall. Writing .sin_port = 8080 directly. It compiles, but binds to port 36895 (the byte-swapped value) on a little-endian machine.
Knowledge check (predict-the-output): On an x86 laptop,
htons(1)returns what 16-bit value in hex, and why?
Definition. Server and client use the same primitives in different orders.
SERVER CLIENT
socket() create endpoint socket() create endpoint
bind() claim host:port
listen() mark passive
accept() wait for a client <--- connect() reach the server
read()/write() exchange data <--> write()/read()
close() close()
How it works. bind reserves an address so clients can find the server. listen flips the socket into passive mode with a backlog queue. accept blocks until a client arrives and returns a new descriptor for that one conversation — the original listening socket keeps waiting for the next client. On the client, connect performs the TCP handshake to the server's address.
When to use / not use. Only servers call bind/listen/accept. A pure client never binds; it just connects. Do not confuse the listening socket with the connected socket accept returns — they are two different descriptors with different jobs.
Pitfall. Reading from the listening socket instead of the descriptor returned by accept. The listener never carries client data.
Every lab in this course binds to 127.0.0.1 (the loopback address, INADDR_LOOPBACK). Traffic to it never leaves your machine, so nothing is exposed to the outside world while you learn. Binding to INADDR_ANY (0.0.0.0) would accept connections from the whole network — powerful, but unsafe for practice code.
#include <sys/socket.h> // socket, bind, listen, accept
#include <netinet/in.h> // struct sockaddr_in, htons, htonl, INADDR_LOOPBACK
#include <arpa/inet.h> // inet_ntop / inet_pton (address <-> text)
// 1. Create a TCP/IPv4 socket. 0 = default protocol for the type (TCP).
int s = socket(AF_INET, SOCK_STREAM, 0);
if (s < 0) { /* handle error: socket returned -1 */ }
// 2. Describe the address to bind to.
struct sockaddr_in addr = {0}; // zero every field first
addr.sin_family = AF_INET; // IPv4
addr.sin_port = htons(8080); // port in NETWORK byte order
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); // 127.0.0.1
// 3. bind takes a generic sockaddr*, so cast the concrete struct.
if (bind(s, (struct sockaddr *)&addr, sizeof addr) < 0) { /* handle */ }
// 4. Become a passive listener with a backlog of 16 pending connections.
if (listen(s, 16) < 0) { /* handle */ }
Key points: socket() returns a descriptor or -1; bind, listen, accept, connect all return 0/descriptor on success and -1 on failure with errno set. The {0} initializer is important — leftover garbage in unset fields of sockaddr_in causes intermittent bind failures.
A socket is a file descriptor for network I/O. The BSD sockets API has been the C standard for this since the early 1980s.
The core calls each do one job:
socket() — create a socketbind() — tie it to an addresslisten() / accept() — wait for incoming connectionsconnect() — reach out to another machineTwo socket types cover most needs:
SOCK_STREAM is TCP — reliable, ordered bytes.SOCK_DGRAM is UDP — packets, which may be lost or reordered.// tcp_echo_once.c — a minimal, safe TCP echo server bound to localhost. // It accepts ONE client, echoes back whatever the client sends, then exits. // Build: cc -std=c11 -Wall -Wextra -o tcp_echo_once tcp_echo_once.c // Test : in another terminal run: nc 127.0.0.1 8080 #include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> // read, write, close #include <sys/socket.h> // socket, bind, listen, accept, setsockopt #include <netinet/in.h> // sockaddr_in, htons, htonl, INADDR_LOOPBACK #include <arpa/inet.h> // inet_ntop
#define PORT 8080 #define BACKLOG 16
int main(void) { // 1. Create a TCP/IPv4 socket. int listen_fd = socket(AF_INET, SOCK_STREAM, 0); if (listen_fd < 0) { perror("socket"); return 1; }
// Allow immediate reuse of the port after the program exits,
// avoiding "Address already in use" during rapid restarts.
int yes = 1;
if (setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof yes) < 0) {
perror("setsockopt");
close(listen_fd);
return 1;
}
// 2. Describe the local address: 127.0.0.1:8080.
struct sockaddr_in addr = {0}; // zero all fields
addr.sin_family = AF_INET;
addr.sin_port = htons(PORT); // network byte order
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); // loopback only, safe
// 3. Claim the address.
if (bind(listen_fd, (struct sockaddr *)&addr, sizeof addr) < 0) {
perror("bind");
close(listen_fd);
return 1;
}
// 4. Switch to passive/listening mode.
if (listen(listen_fd, BACKLOG) < 0) {
perror("listen");
close(listen_fd);
return 1;
}
printf("Listening on 127.0.0.1:%d ...\n", PORT);
// 5. Wait for and accept ONE client. accept() returns a NEW descriptor
// dedicated to this one connection; listen_fd keeps listening.
struct sockaddr_in peer = {0};
socklen_t peer_len = sizeof peer;
int conn_fd = accept(listen_fd, (struct sockaddr *)&peer, &peer_len);
if (conn_fd < 0) {
perror("accept");
close(listen_fd);
return 1;
}
char ip[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &peer.sin_addr, ip, sizeof ip); // binary -> text
printf("Client connected from %s:%d\n", ip, ntohs(peer.sin_port));
// 6. Echo loop: read a chunk, write exactly what we read back.
char buf[1024];
ssize_t n;
while ((n = read(conn_fd, buf, sizeof buf)) > 0) {
// recv/read data is NOT null-terminated; use the (buf, n) length.
ssize_t off = 0;
while (off < n) { // write may be partial; loop it
ssize_t w = write(conn_fd, buf + off, (size_t)(n - off));
if (w < 0) { perror("write"); break; }
off += w;
}
}
if (n < 0) perror("read"); // n == 0 means client closed cleanly
// 7. Clean up BOTH descriptors.
close(conn_fd);
close(listen_fd);
printf("Connection closed.\n");
return 0;
}
**What it does.** The program opens a TCP socket, binds it to `127.0.0.1:8080`, listens, and waits for a single client. When you connect with `nc 127.0.0.1 8080` and type a line, the server reads your bytes and writes them straight back — an echo. Typing `hello` shows `hello` again in the `nc` window. When you close `nc` (Ctrl-D or Ctrl-C), `read` returns `0`, the loop ends, both descriptors close, and the server prints `Connection closed.` and exits.
**Expected output (server terminal):**
```text
Listening on 127.0.0.1:8080 ...
Client connected from 127.0.0.1:54321
Connection closed.
(The client's source port 54321 is chosen by the OS and will differ each run.)
Edge cases to note. If port 8080 is already taken, bind fails with "Address already in use" (mitigated by SO_REUSEADDR). If the client sends more than 1024 bytes at once, the loop simply handles it in multiple iterations — the buffer size does not limit total data. A partial write (rare on loopback, common on real networks) is handled by the inner off loop.
Step 1 — socket(AF_INET, SOCK_STREAM, 0). Asks the kernel for an IPv4 TCP endpoint. It returns a descriptor (say 3) stored in listen_fd, or -1. The < 0 check catches failure before anything else runs.
Step 2 — setsockopt(..., SO_REUSEADDR, ...). Sets a flag so the port can be re-bound immediately after the program exits, instead of sitting in the TCP TIME_WAIT state. Without it, restarting quickly gives "Address already in use".
Step 3 — filling struct sockaddr_in. = {0} zeroes every byte, including padding, so no garbage leaks into the kernel. sin_family = AF_INET says IPv4. sin_port = htons(8080) stores the port in network byte order. sin_addr.s_addr = htonl(INADDR_LOOPBACK) sets the address to 127.0.0.1.
Step 4 — bind. Hands the kernel the address, reserving 127.0.0.1:8080 for this socket. The cast (struct sockaddr *)&addr is required because bind takes the generic base type but reads the concrete IPv4 struct.
Step 5 — listen(listen_fd, 16). Marks the socket passive and creates a queue holding up to 16 connections that have handshaked but not yet been accepted.
Step 6 — accept. Blocks (the program pauses) until a client connects. It returns a brand-new descriptor conn_fd for that single conversation and fills peer with the client's address. inet_ntop turns the binary address into readable text; ntohs(peer.sin_port) converts the client's port back to host order for printing.
Step 7 — the echo loop. Here is a trace for a client that sends hi (2 bytes) then disconnects:
| Iteration | read returns n |
buf contents |
Inner write loop | Result |
|---|---|---|---|---|
| 1 | 2 |
h,i |
writes 2 bytes | client sees hi |
| 2 | 0 |
— | (skipped) | loop exits (peer closed) |
Each outer iteration reads whatever bytes are available (not necessarily a whole line). The inner off loop guarantees the full chunk is written even if write accepts only part of it. read returning 0 is the signal that the peer closed the connection — the normal end.
Step 8 — cleanup. close(conn_fd) releases the conversation; close(listen_fd) releases the listener. Both matter: leaking either one leaks a descriptor.
Mistake 1 — Forgetting htons on the port.
addr.sin_port = 8080; // WRONG on little-endian machines
Why it's wrong: the raw 16 bits are stored in host order, so the kernel reads a byte-swapped port (8080 → 36895). Your server "works" but listens on the wrong port and clients can't reach it.
addr.sin_port = htons(8080); // CORRECT
Recognise it: connect fails / times out even though bind succeeded; ss -tlnp shows a surprising port number.
Mistake 2 — Reading from the listening socket.
read(listen_fd, buf, sizeof buf); // WRONG — listener carries no data
The listening socket only produces new connections via accept. Read the descriptor accept returns instead:
int conn_fd = accept(listen_fd, NULL, NULL);
read(conn_fd, buf, sizeof buf); // CORRECT
Recognise it: read blocks forever or returns errors; no data ever arrives.
Mistake 3 — Treating received bytes as a C string.
ssize_t n = read(conn_fd, buf, sizeof buf);
printf("%s\n", buf); // WRONG — buf is not null-terminated
The network gives you (bytes, length), not a terminated string, so printf("%s") reads past the data. Either use the length or add a terminator you have room for:
ssize_t n = read(conn_fd, buf, sizeof buf - 1);
if (n > 0) { buf[n] = '\0'; printf("%s\n", buf); } // CORRECT
Recognise it: garbage or extra characters printed; occasional crashes under ASan.
Mistake 4 — Ignoring return values.
bind(s, (struct sockaddr *)&addr, sizeof addr); // WRONG — unchecked
listen(s, 16);
If bind fails, listen and accept fail too, and you chase the symptom instead of the cause. Always check for < 0 and print errno with perror.
Mistake 5 — Leaking the connected socket. Closing only listen_fd at the end but forgetting conn_fd (or vice-versa) leaks a descriptor per client. In a long-running server this eventually hits the descriptor limit and every new accept fails with EMFILE.
Compiler errors.
storage size of 'addr' isn't known → you forgot #include <netinet/in.h>.implicit declaration of function 'socket' → missing #include <sys/socket.h>.struct sockaddr_in * where struct sockaddr * is expected → add the cast (struct sockaddr *)&addr.Runtime errors (check errno / perror).
Address already in use (EADDRINUSE) → a previous run holds the port in TIME_WAIT; set SO_REUSEADDR, or wait ~60s, or pick another port.Permission denied (EACCES) → you tried to bind a well-known port (1–1023) without privilege. Use a port ≥ 1024.accept hangs forever → nothing has connected yet (normal), or you're reading the listening socket by mistake.Logic errors.
read on SOCK_STREAM can return fewer bytes than sent; loop until you have what you need.htons.Concrete debugging steps.
ss -tlnp (or netstat -tlnp) — confirm your process is actually listening on the expected address and port.nc 127.0.0.1 8080 — a one-line client to test the handshake and echo without writing client code.-Wall -Wextra and run under valgrind or -fsanitize=address to catch buffer misuse and descriptor leaks.perror("<callname>") after every socket call so a failure names itself.Questions to ask when it doesn't work.
>= 0? Which one first returned -1?htons? Is the address INADDR_LOOPBACK?accept, not the listener?ss -tlnp)?Sockets are descriptors, so classic descriptor and buffer rules dominate.
Descriptor lifetime / leaks. Close every socket on every path, including error paths — not just the happy path. A server that leaks one descriptor per client eventually exhausts its limit and cannot accept anymore (EMFILE). In the example, both close(conn_fd) and close(listen_fd) run, and each early-error branch closes what it already opened.
Received data is not a string. read/recv fill a buffer with raw bytes and return a length; there is no trailing '\0'. Never pass that buffer to printf("%s"), strlen, strcpy, or strcmp without either using the returned length or writing your own terminator within the buffer's bounds. Reading sizeof buf - 1 and setting buf[n] = '\0' is the safe pattern.
Bounds and the byte count. Always cap read/write at your buffer size (sizeof buf), and always respect the returned count n. Do not assume one read returns a whole message, and do not write more than n bytes back.
Initialisation. Zero the whole struct sockaddr_in with = {0} before filling it. Uninitialised padding or unset fields passed to bind/connect cause undefined behaviour and flaky failures.
Integer / port validation. A TCP/UDP port is a 16-bit value; valid usable ports are 1..65535. Validate any port that comes from user input before calling htons (this is exactly what the related exercises sockets-intro-ex1 and sockets-intro-ex2 drill). Feeding an out-of-range or negative port through wraps silently.
Defensive habit for later (network input is untrusted). Even though this lesson binds to loopback only, get in the habit now: treat every byte from the network as attacker-controlled. Validate lengths before indexing, never let received data pick a buffer size, and bind to 127.0.0.1 — not INADDR_ANY — while developing so practice servers are never reachable from outside your machine.
Concrete use case. Every web server — nginx, Apache, a Node.js app, a Go microservice — creates a SOCK_STREAM socket, binds it to a port (usually 80 or 443), listens, and accepts a fresh connected socket per browser request. The exact socket → bind → listen → accept sequence in this lesson is the literal core of an HTTP server's main loop. Databases (PostgreSQL on 5432, MySQL on 3306) do the same; their clients connect and exchange a wire protocol over the stream.
Professional best-practice habits.
listen_fd, conn_fd), not s/s2, so the two-socket distinction is obvious.perror/strerror(errno) so logs name the failing syscall.0.0.0.0 deliberately.Beginner vs advanced.
| Aspect | Beginner (this lesson) | Advanced (later) |
|---|---|---|
| Connections | Handle one client, then exit | Loop accept forever, one thread/process/event per client |
| Concurrency | Blocking calls, single-threaded | epoll/kqueue, non-blocking sockets, thread pools |
| Addressing | Hard-coded AF_INET + sockaddr_in |
getaddrinfo for IPv4/IPv6-agnostic, name resolution |
| Robustness | Basic perror checks |
Timeouts, SIGPIPE handling, graceful shutdown, TLS |
Beginner 1 — Create and inspect a socket.
Objective: confirm socket() returns a real descriptor.
Requirements: call socket(AF_INET, SOCK_STREAM, 0), print the returned integer, check for -1 with perror, and close it.
Expected output: something like socket fd = 3.
Hint: descriptors are usually the smallest unused non-negative integer.
Concepts: socket-as-descriptor, error checking, cleanup.
Beginner 2 — Fill an address struct correctly.
Objective: build a struct sockaddr_in for 127.0.0.1:9000.
Requirements: zero the struct, set sin_family, wrap the port with htons and the address with htonl(INADDR_LOOPBACK). Print the port back using ntohs(addr.sin_port) to prove the round-trip.
Expected output: port = 9000.
Constraints: no hard-coded byte values.
Hint: if the printed port is not 9000, you skipped a conversion helper.
Concepts: address family, network byte order.
Intermediate 1 — Bind and listen with error reporting.
Objective: turn the address struct into a listening socket.
Requirements: create the socket, set SO_REUSEADDR, bind to 127.0.0.1:9000, listen with a backlog of 8, and print Listening.... Check every call and perror on failure. Verify with ss -tlnp in another terminal that the port is open, then close cleanly.
Constraints: must not crash if the port is already in use — report and exit.
Hint: the cast (struct sockaddr *)&addr is required in bind.
Concepts: bind, listen, SO_REUSEADDR, error handling.
Intermediate 2 — Accept one client and report its address.
Objective: extend Intermediate 1 to accept a connection.
Requirements: accept a client, use inet_ntop + ntohs to print the client IP and port, then close both descriptors. Test with nc 127.0.0.1 9000.
Expected output: Client connected from 127.0.0.1:<random port>.
Hint: accept returns a new descriptor — do not overwrite listen_fd.
Concepts: accept, connected vs listening socket, address text conversion.
Challenge — A length-safe echo server.
Objective: build a one-client echo server that never mishandles buffers.
Requirements: read into a fixed buffer, echo back exactly n bytes using a write loop that handles partial writes, and loop until read returns 0. Never treat the buffer as a null-terminated string. Add a rule: if a single line begins with the four bytes QUIT, stop echoing and shut down. Handle client disconnect (read == 0) and errors (read < 0) distinctly.
Constraints: no strlen/%s on network data; every path closes both descriptors.
Hint: compare the first bytes with memcmp(buf, "QUIT", 4) only when n >= 4.
Concepts: stream framing, partial writes, buffer bounds, descriptor cleanup.
read, write, and close it like any descriptor, and check for -1.AF_INET/AF_INET6/AF_UNIX) and type (SOCK_STREAM = reliable TCP stream, SOCK_DGRAM = best-effort UDP datagrams).socket → bind → listen → accept; clients run socket → connect. accept returns a new descriptor for each connection — never read the listener.htons, addresses with htonl. Forgetting this is the most common beginner bug.read is not one message — respect the returned length and loop.127.0.0.1 while learning, validate ports (1..65535), check every return value, and close every socket on every path. From here you can build any client (tcp-client, the next lesson) or server.