Networking in C · intermediate · ~12 min
- By the end you can write a full TCP client from scratch: `socket` -> `connect` -> `send` -> `recv` -> `close`. - By the end you can fill a `struct sockaddr_in` correctly, using `htons`/`htonl` for byte order and `INADDR_LOOPBACK` for 127.0.0.1. - By the end you can explain why `send` and `recv` are partial-transfer calls and write loops that handle short writes. - By the end you can NUL-terminate a received buffer safely before treating it as a string, avoiding a classic out-of-bounds bug. - By the end you can diagnose the common client failures — connection refused, blocked connect, silent EOF — and set a connect/receive timeout.
You already met connect and the send/recv pair as individual syscalls in the prerequisite lessons. This lesson stitches them into one complete, runnable client program so you can see the whole lifecycle in context, on your own machine, with no external server required. A TCP client is simply the side of a connection that initiates the conversation.
Everything here stays on 127.0.0.1 (the loopback address, also called localhost) — traffic that never leaves your computer. That makes it a safe lab: you can experiment freely without touching the real network, needing root, or opening ports to the outside world. This client is the mirror image of the server you saw earlier: the server waits and accepts; the client reaches out and connects.
Nearly every networked program you use — a browser, curl, a database driver, a package manager, an SSH login — is a TCP client at its core, running exactly this lifecycle. Getting the small details right is what separates working code from subtle bugs: forgetting htons sends your bytes to the wrong port, skipping the NUL terminator turns a recv buffer into an out-of-bounds read, and assuming recv returns a whole message causes data corruption under load. From a security standpoint, a client that trusts the length or content of server data without bounds-checking is the classic entry point for buffer overflows, so treating every received byte as untrusted input is a habit worth building from your very first client.
A one-shot TCP client always follows the same sequence. Unlike a server, it does not bind or listen — it just creates a socket and dials out:
socket() connect() send()/recv() close()
-------- --------- ------------- -------
make an -> three-way -> exchange bytes -> release
endpoint handshake over the stream the fd
with server
socket() returns a file descriptor — an integer index into your process's open-file table — that represents the unconnected endpoint. connect() performs the TCP three-way handshake (SYN, SYN-ACK, ACK) with the server's address and port; when it returns 0 the connection is live and full-duplex. Then send/recv move bytes in either direction until you close().
To dial out you must tell connect where. For IPv4 that means filling a struct sockaddr_in with three things: the address family, the port, and the IP address.
struct sockaddr_in {
sa_family_t sin_family; // AF_INET (IPv4)
in_port_t sin_port; // port, in NETWORK byte order <- htons()
struct in_addr sin_addr; // 32-bit IPv4 addr, NETWORK order <- htonl()
char sin_zero[8]; // padding; zero it with memset
};
Multi-byte integers on the wire use network byte order (big-endian). Your CPU may be little-endian, so you must convert. htons = host to network, short (for the 16-bit port); htonl = host to network, long (for the 32-bit address). Always memset the whole struct to zero first so the padding bytes are clean.
| Helper | Converts | Used for |
|---|---|---|
htons(x) |
host -> network, 16-bit | sin_port |
htonl(x) |
host -> network, 32-bit | sin_addr.s_addr |
ntohs(x) |
network -> host, 16-bit | reading a port back |
ntohl(x) |
network -> host, 32-bit | reading an address back |
INADDR_LOOPBACK is the constant 0x7f000001 = 127.0.0.1, already in host order, so it still needs htonl.
Knowledge check: you write a.sin_port = 8080; instead of a.sin_port = htons(8080); on a little-endian x86 machine. What port does the server actually see?
8080in decimal is0x1F90. Stored little-endian and reinterpreted as big-endian network order, the bytes90 1Fbecome0x901F= 36895. Your client tries to reach port 36895, not 8080, andconnectfails with "connection refused" (or hangs). Always wrap the port inhtons.
This is the concept beginners get wrong most often. TCP is a byte stream, not a message service. send(fd, buf, n, 0) may accept fewer than n bytes (it returns how many it took); recv(fd, buf, cap, 0) returns however many bytes have arrived so far, which can be less than one logical "message."
client sends: "hello server\n" (13 bytes, one send() call)
|
v TCP may split or coalesce arbitrarily
server recv() #1: "hello ser" (9 bytes)
server recv() #2: "ver\n" (4 bytes)
So you loop on send until all bytes are gone, and — for anything longer than a token demo — you loop on recv until you have a full message or hit EOF. Two special recv returns matter:
recv return |
Meaning |
|---|---|
> 0 |
that many bytes were read into your buffer |
0 |
orderly shutdown: the peer closed its end (EOF) |
-1 |
error; check errno (EAGAIN/EWOULDBLOCK, ECONNRESET, EINTR, ...) |
recv copies raw bytes; it does not add a '\0'. If you pass the buffer straight to printf("%s") or strlen, you read past the valid data into whatever garbage follows — undefined behaviour and a potential info leak. The fix is a two-part discipline: read into sizeof buf - 1 (leave room), then set buf[n] = '\0' using the actual returned length before treating it as text.
Knowledge check: why read into sizeof buf - 1 rather than sizeof buf?
Because you need one spare byte for the terminator. If
recvfilled allsizeof bufbytes, writingbuf[n] = '\0'at indexsizeof bufwould write one byte past the array — a classic off-by-one overflow. Reserving the last slot guarantees the terminator always fits.
int socket(int domain, int type, int protocol);
// domain=AF_INET (IPv4), type=SOCK_STREAM (TCP), protocol=0 (default).
// Returns a file descriptor (>=0) or -1 on error (sets errno).
// Must eventually be close()d.
int connect(int fd, const struct sockaddr *addr, socklen_t addrlen);
// Initiates the TCP handshake to *addr. Cast &sockaddr_in to (struct sockaddr*).
// addrlen = sizeof(struct sockaddr_in). Returns 0 on success, -1 on error.
// Common errno: ECONNREFUSED (no listener), ETIMEDOUT, ENETUNREACH.
ssize_t send(int fd, const void *buf, size_t len, int flags);
// Queues up to len bytes. flags=0 for normal use.
// Returns bytes actually accepted (may be < len!) or -1 on error.
ssize_t recv(int fd, void *buf, size_t len, int flags);
// Reads up to len bytes into buf. flags=0 for normal use.
// Returns >0 bytes read, 0 on peer shutdown (EOF), -1 on error.
// Does NOT NUL-terminate.
int close(int fd);
// Releases the descriptor and, for the last reference, tears down the
// connection (sends FIN). Returns 0 or -1.
uint16_t htons(uint16_t x); uint32_t htonl(uint32_t x); // host -> network
uint16_t ntohs(uint16_t x); uint32_t ntohl(uint32_t x); // network -> host
// INADDR_LOOPBACK == 0x7f000001 (127.0.0.1), in host order.
Headers: <sys/socket.h>, <netinet/in.h>, <arpa/inet.h> (byte-order helpers, inet_ntop), <unistd.h> (close). Every syscall above sets errno on failure — check the return value and use perror/strerror.
This client mirrors the server you saw earlier. It follows the same four-step pattern, just from the other side of the connection:
socket -> connect -> send/recv -> close
/* A complete, self-contained TCP client demo over loopback (127.0.0.1).
*
* To keep the demo hermetic (no external server, no root, no fixed port),
* we start a tiny echo server in a background thread bound to an
* OS-chosen port on localhost, learn that port, then run a textbook
* TCP client against it: socket -> connect -> send -> recv -> close.
*
* The CLIENT is the star; the server is scaffolding so you can run it.
* Build: cc -std=c11 -Wall -Wextra demo.c -o demo -lpthread
*/
#include <arpa/inet.h> /* htons, htonl, inet_ntop */
#include <netinet/in.h> /* struct sockaddr_in, INADDR_LOOPBACK*/
#include <sys/socket.h> /* socket, connect, send, recv, ... */
#include <pthread.h> /* background echo server */
#include <unistd.h> /* close */
#include <string.h> /* memset, strlen */
#include <stdio.h> /* printf, perror */
#include <stdlib.h> /* exit */
#include <stdint.h> /* uint16_t */
#include <errno.h>
/* The server thread publishes the port it bound to here. */
static int g_port = 0;
static pthread_mutex_t g_lock = PTHREAD_MUTEX_INITIALIZER;
static pthread_cond_t g_ready = PTHREAD_COND_INITIALIZER;
/* Minimal one-shot echo server: accept one client, echo one message. */
static void *echo_server(void *arg) {
(void)arg;
int lfd = socket(AF_INET, SOCK_STREAM, 0);
if (lfd < 0) { perror("server socket"); exit(1); }
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 = htons(0); /* 0 = pick any free port */
if (bind(lfd, (struct sockaddr *)&addr, sizeof addr) < 0) {
perror("bind"); exit(1);
}
if (listen(lfd, 1) < 0) { perror("listen"); exit(1); }
/* Ask the kernel which port it actually gave us. */
struct sockaddr_in bound;
socklen_t blen = sizeof bound;
if (getsockname(lfd, (struct sockaddr *)&bound, &blen) < 0) {
perror("getsockname"); exit(1);
}
pthread_mutex_lock(&g_lock);
g_port = ntohs(bound.sin_port);
pthread_cond_signal(&g_ready);
pthread_mutex_unlock(&g_lock);
int cfd = accept(lfd, NULL, NULL);
if (cfd < 0) { perror("accept"); exit(1); }
char buf[256];
ssize_t n = recv(cfd, buf, sizeof buf, 0);
if (n > 0) send(cfd, buf, (size_t)n, 0); /* echo exactly what we got */
close(cfd);
close(lfd);
return NULL;
}
int main(void) {
/* --- scaffolding: bring up the loopback echo server --- */
pthread_t tid;
if (pthread_create(&tid, NULL, echo_server, NULL) != 0) {
perror("pthread_create"); return 1;
}
pthread_mutex_lock(&g_lock);
while (g_port == 0) pthread_cond_wait(&g_ready, &g_lock);
int port = g_port;
pthread_mutex_unlock(&g_lock);
/* ============ THE TCP CLIENT ============ */
/* 1. Create the endpoint: IPv4 (AF_INET), TCP stream (SOCK_STREAM). */
int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0) { perror("socket"); return 1; }
/* 2. Describe the server's address: 127.0.0.1 : <port>. */
struct sockaddr_in srv;
memset(&srv, 0, sizeof srv);
srv.sin_family = AF_INET;
srv.sin_port = htons((uint16_t)port); /* host -> network order */
srv.sin_addr.s_addr = htonl(INADDR_LOOPBACK); /* 127.0.0.1 */
/* 3. Reach out and complete the TCP three-way handshake. */
if (connect(fd, (struct sockaddr *)&srv, sizeof srv) < 0) {
perror("connect");
close(fd);
return 1;
}
char ip[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &srv.sin_addr, ip, sizeof ip);
printf("connected to %s:%d\n", ip, port);
/* 4. Send one line. send() may write fewer bytes than asked, so loop. */
const char *msg = "hello server\n";
size_t to_send = strlen(msg);
size_t sent = 0;
while (sent < to_send) {
ssize_t w = send(fd, msg + sent, to_send - sent, 0);
if (w < 0) {
if (errno == EINTR) continue; /* interrupted: retry */
perror("send");
close(fd);
return 1;
}
sent += (size_t)w;
}
printf("sent %zu bytes: %s", sent, msg);
/* 5. Read the echo. recv() returns however much has arrived so far. */
char buf[256];
ssize_t n = recv(fd, buf, sizeof buf - 1, 0);
if (n < 0) {
perror("recv");
close(fd);
return 1;
}
if (n == 0) {
printf("server closed the connection with no data\n");
} else {
buf[n] = '\0'; /* NUL-terminate before printing */
printf("recv %zd bytes: %s", n, buf);
}
/* 6. Release the socket. */
close(fd);
pthread_join(tid, NULL);
return 0;
}
Scaffolding (echo_server + the top of main). To make the demo runnable with nothing else installed, a background thread runs a tiny echo server on loopback. It binds to port 0, which tells the kernel "pick any free port," then uses getsockname to read back the port it was assigned and publishes it through a mutex+condvar so main can read it safely. The while (g_port == 0) pthread_cond_wait(...) loop is the standard guard-against-spurious-wakeups pattern. None of this is client code — it's the server the client dials. In real life this whole block is replaced by a program running elsewhere.
Step 1 — socket(AF_INET, SOCK_STREAM, 0). Creates an IPv4/TCP endpoint and returns a file descriptor. We check < 0 because every socket call can fail and returns -1 with errno set.
Step 2 — filling struct sockaddr_in. memset(&srv, 0, sizeof srv) clears every byte including the padding. sin_family = AF_INET marks it IPv4. htons((uint16_t)port) converts the port to network byte order. htonl(INADDR_LOOPBACK) sets the address to 127.0.0.1 in network order. Skip either conversion and you dial the wrong place.
Step 3 — connect. We cast &srv to struct sockaddr * (the generic address type the API expects) and pass sizeof srv. On success it returns 0 and the connection is established; on failure we perror, close(fd) to avoid leaking the descriptor, and bail.
The inet_ntop line turns the binary address back into the human-readable string "127.0.0.1" purely for the log message — good practice over the deprecated inet_ntoa.
Step 4 — the send loop. Because send can accept fewer bytes than requested, we track sent and keep calling send(fd, msg + sent, to_send - sent, 0) until everything is gone. if (errno == EINTR) continue; retries when a signal interrupts the call rather than treating it as a real error.
Step 5 — the recv. We read into sizeof buf - 1 to reserve the terminator slot. Then we branch on the three outcomes: < 0 is an error, 0 means the server closed with no data (EOF), and > 0 means we got bytes — at which point buf[n] = '\0' makes it a valid C string before printf.
Step 6 — close(fd) releases the descriptor and sends the TCP FIN. pthread_join then waits for the server thread so the program exits cleanly.
1. Forgetting byte-order conversion on the port.
srv.sin_port = 8080; // WRONG
On a little-endian machine the bytes are stored in the wrong order, so the kernel reads a different port number and connect fails or reaches the wrong service.
srv.sin_port = htons(8080); // FIXED
2. Treating the recv buffer as a string without terminating it.
ssize_t n = recv(fd, buf, sizeof buf, 0);
printf("%s", buf); // WRONG: reads past valid data (UB)
recv does not add '\0', and here it can even fill the whole buffer leaving no room. Result: out-of-bounds read, possible crash or info leak.
ssize_t n = recv(fd, buf, sizeof buf - 1, 0);
if (n > 0) { buf[n] = '\0'; printf("%s", buf); } // FIXED
3. Assuming send/recv move the whole message in one call.
send(fd, msg, strlen(msg), 0); // WRONG: ignores the return value
send may accept fewer bytes; the tail is silently dropped from your logic. Loop until everything is sent (see the code), and check the return value every time.
4. Ignoring the return value of connect (or not closing on failure).
connect(fd, (struct sockaddr *)&srv, sizeof srv);
send(fd, msg, len, 0); // WRONG: sending on a socket that never connected
If the server is not running you get ECONNREFUSED; sending afterward just fails again and leaks fd.
if (connect(fd, (struct sockaddr *)&srv, sizeof srv) < 0) {
perror("connect"); close(fd); return 1; // FIXED
}
perror/errno first. Every failure sets errno. ECONNREFUSED = nothing is listening on that port. ETIMEDOUT = the host is unreachable or a firewall dropped the SYN. EADDRNOTAVAIL/ENETUNREACH = bad address. Print it: fprintf(stderr, "connect: %s\n", strerror(errno));.ss -ltn (or netstat -ltn); on macOS lsof -iTCP -sTCP:LISTEN -n -P. If your target port is absent, start the server before the client.strace -e trace=network ./demo (Linux) or dtruss/sudo dtruss ./demo (macOS) shows each socket/connect/send/recv with its arguments and return value — the fastest way to see a wrong port or a short read.sudo tcpdump -i lo0 -X port <port> (macOS lo0, Linux lo) captures loopback traffic so you can verify what actually left and arrived.nc -l 127.0.0.1 8080 runs a listener; point your client at it. Or run your client logic by hand with nc 127.0.0.1 8080 to sanity-check the server.gdb ./demo, then catch syscall connect and inspect srv to confirm sin_port/sin_addr hold what you expect (watch for the byte-order swap).sizeof buf - 1 and terminate with the returned length buf[n] = '\0'. Terminating at a fixed size, or at sizeof buf when the buffer is full, writes out of bounds.memcpying an attacker-controlled length is the textbook remote buffer overflow.memset the whole sockaddr_in to zero before filling it; leftover stack garbage in the padding can produce confusing behaviour and non-deterministic bugs.close(fd). A leaked descriptor is a resource leak that eventually exhausts the process's fd table (EMFILE).-fsanitize=address,undefined during development to catch buffer and UB issues, and -fsanitize=thread to catch races.curl, browsers, and every language's HTTP stack open a TCP socket to port 80/443 and run this exact lifecycle underneath TLS.connect to a port to see if a service is up.Best practice: always set a connect timeout (a non-blocking connect + select/poll, or SO_RCVTIMEO/SO_SNDTIMEO) so a dead server can't hang your program; loop send/recv to handle partial transfers; check every return value; and treat all received bytes as untrusted, bounds-checked input.
Change the message. Modify the client to send your name followed by a newline and print the echoed reply. Confirm the byte counts logged for send and recv match the length of your string.
Handle a missing server. Comment out the pthread_create line (so no server runs) and point the client at a fixed dead port like 9999. Run it and confirm you get ECONNREFUSED from perror("connect"); explain in a comment why the fd must still be closed.
Drain a longer reply. Change the server to echo the message back three times, and rewrite the client's single recv into a loop that keeps reading until it sees recv return 0 (EOF), accumulating into a growing buffer. Print the total bytes received.
Add a receive timeout. Use setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv) with a 2-second struct timeval so that if the server never replies, recv fails with EAGAIN/EWOULDBLOCK instead of blocking forever. Test it against a server that accepts but never sends.
Length-prefixed framing. Change the protocol so each message is preceded by a 4-byte big-endian length (htonl). Have the client read the 4-byte header first, convert with ntohl, clamp it to the buffer size, then read exactly that many bytes. This is the safe, real-world way to know where a message ends.
socket -> connect -> send/recv -> close. It initiates; it never binds or listens.struct sockaddr_in after memsetting it to zero: sin_family = AF_INET, sin_port = htons(port), sin_addr.s_addr = htonl(INADDR_LOOPBACK) for 127.0.0.1. Byte order is not optional.send and recv are partial-transfer calls on a byte stream — loop on send until all bytes are gone, loop on recv until you have a full message or EOF (recv returns 0).recv does not NUL-terminate: read into sizeof buf - 1, then set buf[n] = '\0' using the returned length before treating the buffer as a string.close(fd) on every path, set a timeout so a dead server can't hang you, and treat all received bytes as untrusted, bounds-checked input.