08 · Networking Basics¶
A socket is a file descriptor that happens to have another machine on the
other end. Once you have one, read/write (or their socket-flavoured
cousins recv/send) work much like they do on a file — which is the good
news. The bad news is everything around it: addresses have to be in the
right byte order, connections take four calls to set up, and TCP will
happily hand you half a message because it has no idea your bytes were
ever grouped into messages at all.
This module builds a working echo server, talks to it from a C client, then
serves real HTTP to curl.
The four calls that make a server¶
A TCP server always follows the same sequence:
| Call | What it does |
|---|---|
socket() |
Create an unbound endpoint, get an fd back |
bind() |
Claim a local address and port |
listen() |
Start queueing incoming connections |
accept() |
Pull one connection off the queue as a new fd |
accept is the one people misread. It does not return data — it returns a
second file descriptor representing one client. The listening socket
stays open, ready for the next accept.
// echo_server.c -- accept one client at a time and echo back what it sends
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#define PORT 9090
int main(void) {
int srv = socket(AF_INET, SOCK_STREAM, 0);
if (srv < 0) { perror("socket"); return 1; }
int yes = 1;
setsockopt(srv, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof yes);
struct sockaddr_in addr = {0};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_ANY); // every local interface
addr.sin_port = htons(PORT); // network byte order!
if (bind(srv, (struct sockaddr *)&addr, sizeof addr) < 0) {
perror("bind"); return 1;
}
if (listen(srv, 16) < 0) { perror("listen"); return 1; }
printf("listening on port %d\n", PORT);
fflush(stdout);
for (;;) {
struct sockaddr_in peer;
socklen_t plen = sizeof peer;
int c = accept(srv, (struct sockaddr *)&peer, &plen);
if (c < 0) { perror("accept"); continue; }
char ip[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &peer.sin_addr, ip, sizeof ip);
printf("connection from %s:%u\n", ip, ntohs(peer.sin_port));
fflush(stdout);
char buf[256];
ssize_t n;
while ((n = recv(c, buf, sizeof buf, 0)) > 0) {
printf("received %zd bytes\n", n);
fflush(stdout);
ssize_t sent = 0;
while (sent < n) { // send may be partial too
ssize_t w = send(c, buf + sent, (size_t)(n - sent), 0);
if (w <= 0) { perror("send"); break; }
sent += w;
}
}
close(c);
printf("client disconnected\n");
fflush(stdout);
}
}
SO_REUSEADDR deserves a note. After a connection closes, the kernel keeps
the address in TIME_WAIT for a couple of minutes; without this option a
restarted server gets bind: Address already in use and refuses to start.
Every server you write should set it.
The client side¶
Clients skip bind and listen entirely — connect picks a local port
automatically:
// echo_client.c -- connect, send a line, read the echo back
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
int main(int argc, char **argv) {
const char *msg = (argc > 1) ? argv[1] : "hello sockets";
int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0) { perror("socket"); return 1; }
struct sockaddr_in addr = {0};
addr.sin_family = AF_INET;
addr.sin_port = htons(9090);
if (inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr) != 1) {
fprintf(stderr, "bad address\n"); return 1;
}
if (connect(fd, (struct sockaddr *)&addr, sizeof addr) < 0) {
perror("connect"); return 1;
}
size_t len = strlen(msg);
if (send(fd, msg, len, 0) < 0) { perror("send"); return 1; }
char buf[256];
ssize_t n = recv(fd, buf, sizeof buf - 1, 0);
if (n < 0) { perror("recv"); return 1; }
buf[n] = '\0'; // recv does NOT null-terminate
printf("sent: \"%s\" (%zu bytes)\n", msg, len);
printf("echoed: \"%s\" (%zd bytes)\n", buf, n);
close(fd);
return 0;
}
Build both and run the server in the background:
clang -Wall -Wextra -o echo_server echo_server.c
clang -Wall -Wextra -o echo_client echo_client.c
./echo_server &
./echo_client "hello sockets"
./echo_client "second connection"
sent: "hello sockets" (13 bytes)
echoed: "hello sockets" (13 bytes)
sent: "second connection" (17 bytes)
echoed: "second connection" (17 bytes)
And on the server's side of the conversation:
listening on port 9090
connection from 127.0.0.1:53433
received 13 bytes
client disconnected
connection from 127.0.0.1:53434
received 17 bytes
client disconnected
Two clients, two different ephemeral source ports (53433, 53434) chosen by
the kernel. The recv loop ended each time because a closed connection
makes recv return 0 — that is end-of-stream, not an error. A return of
-1 is the error case, and the two must never be conflated.
Byte order is not optional¶
Network protocols fixed on big-endian ("network byte order") decades ago. Most machines you will run on today are little-endian, so the conversion is a real byte swap, not a no-op:
// byteorder.c -- what htons/htonl actually do on this machine
#include <stdio.h>
#include <arpa/inet.h>
static void dump(const char *label, const void *p, size_t n) {
const unsigned char *b = p;
printf("%-18s", label);
for (size_t i = 0; i < n; i++) printf(" %02x", b[i]);
putchar('\n');
}
int main(void) {
uint16_t port = 9090;
uint16_t net_port = htons(port);
uint32_t ip = 0x7f000001; // 127.0.0.1
uint32_t net_ip = htonl(ip);
printf("port host order = %u\n", port);
dump(" bytes in memory:", &port, sizeof port);
printf("port network order= %u\n", net_port);
dump(" bytes in memory:", &net_port, sizeof net_port);
dump("ip host order:", &ip, sizeof ip);
dump("ip network order:", &net_ip, sizeof net_ip);
printf("ntohs(htons(9090)) = %u\n", ntohs(htons(port)));
return 0;
}
port host order = 9090
bytes in memory: 82 23
port network order= 33315
bytes in memory: 23 82
ip host order: 01 00 00 7f
ip network order: 7f 00 00 01
ntohs(htons(9090)) = 9090
Port 9090 stored as 82 23 in memory becomes 23 82 on the wire. Forget
htons and you bind to port 33315 instead — the program starts happily and
your client just can't find it. Note also that htons returning 33315 is
not a bug: that is what 9090's swapped bytes read as when reinterpreted as
a host-order integer. Never print or compare a network-order value as a
number; only pass it to the socket API.
TCP is a byte stream, not a message queue¶
This is the single biggest source of networking bugs in C. TCP guarantees that your bytes arrive in order and without corruption. It guarantees nothing about how they are grouped.
// stream_demo.c -- TCP has no message boundaries: three sends, one recv
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
int main(void) {
int fd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in a = {0};
a.sin_family = AF_INET;
a.sin_port = htons(9090);
inet_pton(AF_INET, "127.0.0.1", &a.sin_addr);
if (connect(fd, (struct sockaddr *)&a, sizeof a) < 0) {
perror("connect"); return 1;
}
const char *parts[] = { "ALPHA", "BETA", "GAMMA" };
for (int i = 0; i < 3; i++) {
send(fd, parts[i], strlen(parts[i]), 0);
printf("send #%d: \"%s\"\n", i + 1, parts[i]);
}
char buf[256];
ssize_t n = recv(fd, buf, sizeof buf - 1, 0);
buf[n] = '\0';
printf("one recv returned %zd bytes: \"%s\"\n", n, buf);
close(fd);
return 0;
}
Meanwhile the echo server logged:
Three send calls arrived as two reads on the server (5 bytes, then
BETA and GAMMA merged into 9) and came back to the client as one
14-byte blob. Nothing here is broken — this is TCP working exactly as
specified.
The consequence: you must define your own framing. The usual choices are a
delimiter (HTTP uses \r\n\r\n to end headers) or a length prefix (send a
4-byte htonl length, then that many bytes). Whichever you pick, recv
must run in a loop that accumulates until a complete frame is present.
Serving real HTTP¶
HTTP over TCP is just text with a required shape: a request line, headers, a
blank line, then the body. That is little enough that curl will talk to
about eighty lines of C:
// tiny_http.c -- speak just enough HTTP/1.1 for curl to be happy
static void serve(int c) {
char req[2048];
ssize_t n = recv(c, req, sizeof req - 1, 0);
if (n <= 0) return;
req[n] = '\0';
char method[16] = "", path[256] = "";
sscanf(req, "%15s %255s", method, path); // width limits are the point
printf("%s %s\n", method, path);
fflush(stdout);
char body[512];
int blen = snprintf(body, sizeof body,
"{\"method\":\"%s\",\"path\":\"%s\"}\n", method, path);
char head[512];
int hlen = snprintf(head, sizeof head,
"HTTP/1.1 200 OK\r\n"
"Content-Type: application/json\r\n"
"Content-Length: %d\r\n"
"Connection: close\r\n"
"\r\n", blen);
send(c, head, (size_t)hlen, 0);
send(c, body, (size_t)blen, 0);
}
main is the same socket/bind/listen/accept loop as the echo
server, on port 8080, calling serve(c) then close(c).
clang -Wall -Wextra -o tiny_http tiny_http.c
./tiny_http &
curl -s http://localhost:8080/hello
curl -s -i http://localhost:8080/status
{"method":"GET","path":"/hello"}
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 34
Connection: close
{"method":"GET","path":"/status"}
Those %15s and %255s width limits in the sscanf are load-bearing
security, not tidiness. A bare %s writing into method[16] from an
attacker-controlled request line is a stack buffer overflow reachable by
anyone who can open a socket — Level 4's security
module takes that apart properly. Note too
that this server reads the request with a single recv, which the
previous section just showed is unsafe: a client that dribbles its request
line out in pieces would be mis-parsed. Real servers loop until they see the
blank line that ends the headers.
Cheat sheet¶
| Call | Purpose |
|---|---|
socket(AF_INET, SOCK_STREAM, 0) |
New TCP/IPv4 endpoint (SOCK_DGRAM for UDP) |
bind(fd, addr, len) |
Claim a local port; needs SO_REUSEADDR to restart cleanly |
listen(fd, backlog) |
Begin queueing connections |
accept(fd, &peer, &len) |
Return a new fd for one client |
connect(fd, addr, len) |
Client-side: reach out to a server |
recv(fd, buf, n, 0) |
Read; 0 means peer closed, -1 means error |
send(fd, buf, n, 0) |
Write; may send fewer bytes than asked — loop |
close(fd) |
Ends the connection (sends FIN) |
htons / htonl |
Host → network byte order (16-bit / 32-bit) |
ntohs / ntohl |
Network → host byte order |
inet_pton / inet_ntop |
Text address ↔ binary; the _ntoa/_addr versions are legacy |
setsockopt(..., SO_REUSEADDR, ...) |
Rebind a port still in TIME_WAIT |
Traps worth re-reading before you debug for an hour:
recvdoes not null-terminate. Alwaysbuf[n] = '\0'with room reserved (sizeof buf - 1).sendreturning less thannis normal on a busy socket. The loop inecho_server.cis the minimum correct form.- Writing to a socket whose peer has closed raises
SIGPIPE, killing your process by default. Servers setsignal(SIGPIPE, SIG_IGN)and checkerrno == EPIPEinstead. struct sockaddr_inmust be zeroed before use;= {0}does it. Leftover stack garbage in the padding has caused real bind failures.
How It Actually Works¶
A "socket" being just a file descriptor is not a loose analogy — sockets,
regular files, pipes, and terminals are all represented inside the kernel
by the same underlying abstraction, and recv/send are thin wrappers
around the exact same read()/write() system calls used for ordinary
file I/O in Level 1, Module 8, just with a few
socket-specific flags. This is why close(fd) works identically on a
socket as on a file, and why the kernel enforces the same per-process file
descriptor limit across both — a program that leaks unclosed sockets
exhausts the same finite resource as one leaking unclosed files.
TCP being "a byte stream, not a message queue" is a direct consequence of
what the protocol actually transmits: TCP breaks your data into IP packets
however is convenient for the network at the moment — based on the
path's maximum transmission unit, current congestion, retransmissions of
lost packets, and Nagle's algorithm potentially coalescing several small
send calls into one packet — and the receiving kernel's TCP stack
reassembles those packets back into one ordered stream of bytes before
handing them to your recv call, with absolutely no memory of where your
original send boundaries were. This is exactly why "ALPHABETAGAMMA"
arrives however it arrives: the three separate send calls' boundaries
were never protocol-level information to begin with, only ever
convenient chunks your program handed to the kernel — the kernel was
always free to merge or split them differently before transmission, which
is why length-prefixing or a delimiter (information your own application
protocol carries inside the byte stream itself) is the only reliable way
to recover message boundaries.
htons/htonl performing a real byte swap on a little-endian machine is
the exact same endianness mechanism from
Level 3, Module 4's bit-manipulation module,
applied to a concrete, load-bearing case: a 16-bit port number like 9090
sits in memory as the bytes 82 23 on a little-endian CPU (least
significant byte first), but the TCP/IP specification fixed network byte
order as big-endian decades ago, so every machine on the internet needs to
agree on one convention regardless of its own native byte order. htons
is literally ((x << 8) | (x >> 8)) (for the 16-bit case) — an actual
shift-and-OR bit operation executed on the value's bits, not a no-op or a
labeling exercise — which is exactly why skipping it silently binds to
the wrong numeric port (33315 instead of 9090) rather than producing
any error: the bind call has no way to know you meant the swapped value.
Exercise¶
Give the echo server length-prefixed framing so message boundaries
survive the stream. The client sends a 4-byte htonl length followed by
exactly that many bytes; the server reads the 4-byte header in a loop, then
reads exactly that many payload bytes in a second loop, and echoes back the
same framing.
Prove it works by sending three messages back to back from a single
connect — the same pattern that produced ALPHABETAGAMMA above — and
confirming the server now reports three separate messages of the right
lengths. Then deliberately break it: have the client send the 4-byte length
and then only half the payload before sleeping two seconds. A correct
implementation blocks in its read loop and completes the message when the
rest arrives; a naive one returns a short, corrupt message immediately.
Finally, send a length prefix of 0xFFFFFFFF and make sure your server
rejects it instead of trying to malloc four gigabytes.