Networking in C · advanced · ~10 min
- By the end you can create a `SOCK_DGRAM` socket and send a single datagram to a peer with `sendto`. - By the end you can receive a datagram with `recvfrom` and recover the sender's address from the value-result length argument. - By the end you can explain why UDP is connectionless and what "no delivery guarantee" means for lost, reordered, and duplicated packets. - By the end you can fill a `struct sockaddr_in` correctly with `htons` and `inet_pton`, and null-terminate received bytes safely. - By the end you can reason about UDP-specific security hazards (spoofed source addresses, amplification, truncation) and code defensively against them.
In the sockets intro you learned that a socket is a file descriptor plus an address, and you saw the connection-oriented flow: socket → connect → send/recv. UDP throws away the middle step. There is no handshake, no connection state, no stream — just self-contained messages called datagrams. This lesson builds directly on the socket/address knowledge you already have: the same int file descriptor, the same struct sockaddr_in, the same htons/inet_pton helpers. What changes is the socket type (SOCK_DGRAM instead of SOCK_STREAM) and the send/receive calls (sendto/recvfrom instead of send/recv).
Because there is no connection, the destination address travels with every single send. That one difference — "carry the address each time" — is the heart of the UDP client. Everything else you already know still applies.
UDP is the transport under DNS, DHCP, NTP, QUIC/HTTP-3, most game netcode, and real-time audio/video — anywhere latency matters more than perfect delivery. Understanding it makes you faster at those systems and safer around them: because UDP has no handshake, the source address is trivially forged, which is exactly how DNS/NTP amplification DDoS attacks work. A defensive engineer needs to know that a datagram's claimed sender is unverified, that a single recvfrom can silently truncate an oversized message, and that unbounded reply sizes turn your service into someone else's weapon.
A TCP program calls connect once, then every send implicitly goes to the connected peer. UDP has no such memory. Each sendto is stateless and must be told where to go:
TCP: connect(fd, peer) UDP: (no connect)
send(fd, buf, len) sendto(fd, buf, len, 0, peer, peerlen)
send(fd, buf, len) sendto(fd, buf, len, 0, peer, peerlen)
^^^^ every time
There is no "session." The kernel does not track a conversation; it just drops your bytes into an IP packet addressed to peer and hands it to the network. A UDP client is therefore almost trivial: make a SOCK_DGRAM socket, fill in a destination sockaddr_in, call sendto.
TCP is a byte stream: if you send 100 bytes then 50 bytes, the receiver might recv 150 bytes at once, or 30 then 120. Boundaries vanish. UDP preserves message boundaries: one sendto of 14 bytes becomes exactly one recvfrom of 14 bytes — never merged with the next message, never split. This is why UDP is called message-oriented.
The flip side: if your receive buffer is smaller than the datagram, the extra bytes are discarded, not saved for the next read. A 14-byte datagram read into a 8-byte buffer gives you 8 bytes and silently loses 6.
| Property | TCP (SOCK_STREAM) |
UDP (SOCK_DGRAM) |
|---|---|---|
| Connection setup | 3-way handshake | none |
| Data model | byte stream | discrete datagrams |
| Message boundaries | not preserved | preserved |
| Reliable delivery | yes (retransmits) | no |
| Ordering | in-order | may reorder |
| Duplicates | removed | possible |
| Send call | send/write |
sendto |
| Per-send address | no (connected) | yes |
recvfrom and the value-result lengthrecvfrom fills in the sender's address for you — handy, since a UDP socket can receive from anyone. The address is returned through two arguments that work as a pair:
struct sockaddr_in from;
socklen_t flen = sizeof from; <-- YOU set this to the buffer size FIRST
recvfrom(fd, buf, buflen, 0, (struct sockaddr*)&from, &flen);
^^^^^
on entry: flen = capacity you provided
on return: flen = actual bytes the kernel wrote
This "value-result" convention trips up beginners: if you forget to initialize flen, the kernel reads garbage as your buffer capacity and may refuse to fill the address or clobber memory. Always set it to sizeof from before the call.
Knowledge check: you sendto a 2000-byte message and the peer's recvfrom used a 512-byte buffer. TCP would let the peer read the rest later. What happens with UDP?
The peer gets the first 512 bytes and the remaining 1488 bytes are thrown away — datagrams are atomic, and the leftover is not queued for the next
recvfrom. On Linux you can even detect this:recvmsgsets theMSG_TRUNCflag. The defensive habit is to size your receive buffer to the largest datagram you will accept (e.g. 65535 for IPv4) and treat a full buffer as suspicious.
A UDP datagram is a tiny 8-byte header followed by your payload — far leaner than TCP's 20+ byte header, which is part of why it is fast:
IP header (20 bytes) UDP header (8 bytes) payload
+----+----+---------+----+ +--------+--------+--------+--------+ +---------+
|ver | ...| src IP |dst | | src | dst | length |checksum| | your |
| | | dst IP | IP | | port | port | | | | bytes |
+----+----+---------+----+ +--------+--------+--------+--------+ +---------+
16b 16b 16b 16b
Critically, nothing in this header is authenticated. The source IP and source port are whatever the sender wrote. A UDP server cannot trust that a datagram claiming to come from 10.0.0.5 actually did. This is the root of spoofing and amplification attacks (see memory-safety and real-world notes below).
Same as any IPv4 socket: multi-byte integers on the network are big-endian, so ports go through htons and you parse dotted-quad IPs with inet_pton. Zero the struct first so unused fields (like sin_zero padding) are clean.
| Helper | Purpose | Direction |
|---|---|---|
htons(port) |
host→network short | filling sin_port |
ntohs(x) |
network→host short | reading a port back |
inet_pton(AF_INET, "1.2.3.4", &addr) |
text→binary IP | filling sin_addr |
inet_ntop(AF_INET, &addr, buf, len) |
binary→text IP | printing a sender |
int socket(int domain, int type, int protocol);
// domain = AF_INET (IPv4); type = SOCK_DGRAM (datagram); protocol = 0 (UDP).
// Returns a file descriptor >= 0, or -1 with errno set. Must be close()d.
ssize_t sendto(int fd, const void *buf, size_t len, int flags,
const struct sockaddr *dest, socklen_t destlen);
// Sends ONE datagram of `len` bytes to `dest`. flags usually 0.
// Returns bytes queued (normally == len; UDP does not do partial sends of a
// datagram) or -1/errno. destlen = sizeof(the concrete address struct).
ssize_t recvfrom(int fd, void *buf, size_t len, int flags,
struct sockaddr *src, socklen_t *srclen);
// Reads ONE datagram (up to `len` bytes; excess is DISCARDED). Fills *src with
// the sender's address. *srclen is value-result: set it to the buffer size
// BEFORE calling; on return it holds the address length written. src/srclen may
// be NULL if you don't care who sent it. Returns byte count or -1/errno.
// Returns 0 only for a genuinely zero-length datagram (NOT end-of-stream).
int getsockname(int fd, struct sockaddr *addr, socklen_t *len);
// Reports the local address a socket is bound to -- used here to learn the
// ephemeral port the kernel picked after bind(...:0).
uint16_t htons(uint16_t); uint16_t ntohs(uint16_t);
int inet_pton(int af, const char *src, void *dst); // 1 ok, 0 bad text, -1 err
const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);
Struct to fill for an IPv4 destination:
struct sockaddr_in {
sa_family_t sin_family; // AF_INET
in_port_t sin_port; // htons(port)
struct in_addr sin_addr; // set via inet_pton
/* sin_zero padding -- keep it zeroed */
};
UDP (User Datagram Protocol) does not set up a connection before sending data. Each message, called a datagram, is sent on its own.
Because there is no connection, the steps are simple:
SOCK_DGRAM socket (a datagram socket).sendto(...) to send a message. You pass the destination address every time you send.recvfrom(...) to receive a message. It fills in the sender's address for you.UDP does not promise reliable delivery. With UDP, packets may be:
If your application needs reliability, you must add it yourself. Common additions include retransmission (retrying lost messages) and sequence numbers (to detect order and duplicates).
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <netinet/in.h>
/* Hermetic UDP demo: one process runs both a server socket (bound to an
ephemeral loopback port) and a client socket. The client sends a datagram
with sendto(); the server reads it with recvfrom() and learns the sender's
address. No root, no external network -- everything stays on 127.0.0.1. */
int main(void) {
/* --- 1. Server socket: bind to 127.0.0.1:0 (kernel picks the port) --- */
int srv = socket(AF_INET, SOCK_DGRAM, 0);
if (srv < 0) { perror("socket(srv)"); return 1; }
struct sockaddr_in srv_addr = {0};
srv_addr.sin_family = AF_INET;
srv_addr.sin_port = htons(0); /* 0 => let kernel choose */
inet_pton(AF_INET, "127.0.0.1", &srv_addr.sin_addr);
if (bind(srv, (struct sockaddr *)&srv_addr, sizeof srv_addr) < 0) {
perror("bind"); return 1;
}
/* Discover which port we actually got. */
struct sockaddr_in bound = {0};
socklen_t blen = sizeof bound;
if (getsockname(srv, (struct sockaddr *)&bound, &blen) < 0) {
perror("getsockname"); return 1;
}
unsigned short port = ntohs(bound.sin_port);
printf("server listening on 127.0.0.1:%u\n", port);
/* --- 2. Client socket: unbound; sendto() carries the destination --- */
int cli = socket(AF_INET, SOCK_DGRAM, 0);
if (cli < 0) { perror("socket(cli)"); return 1; }
struct sockaddr_in dst = {0};
dst.sin_family = AF_INET;
dst.sin_port = htons(port);
inet_pton(AF_INET, "127.0.0.1", &dst.sin_addr);
const char msg[] = "hello over UDP";
ssize_t sent = sendto(cli, msg, sizeof msg - 1, 0,
(struct sockaddr *)&dst, sizeof dst);
if (sent < 0) { perror("sendto"); return 1; }
printf("client sent %zd bytes\n", sent);
/* --- 3. Server receives and recovers the sender's address --- */
char buf[256];
struct sockaddr_in from = {0};
socklen_t flen = sizeof from; /* value-result: set before! */
ssize_t got = recvfrom(srv, buf, sizeof buf - 1, 0,
(struct sockaddr *)&from, &flen);
if (got < 0) { perror("recvfrom"); return 1; }
buf[got] = '\0'; /* datagrams are not NUL-terminated */
char ip[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &from.sin_addr, ip, sizeof ip);
printf("server got %zd bytes from %s:%u -> \"%s\"\n",
got, ip, ntohs(from.sin_port), buf);
close(cli);
close(srv);
return 0;
}
socket(AF_INET, SOCK_DGRAM, 0) — the one line that makes this UDP. SOCK_DGRAM selects datagram semantics; protocol 0 lets the kernel pick UDP for that type. Returns a plain file descriptor, checked against < 0.struct sockaddr_in srv_addr = {0} — zero-initialize so padding is clean, then set family, port, and address. We bind the server here only so the demo is self-contained; a pure client never needs bind.htons(0) — port 0 is a special request: "kernel, assign me any free ephemeral port." This keeps the demo hermetic (no fixed port to collide with another process).bind(...) — associates the server socket with 127.0.0.1:<ephemeral> so it has an address others can send to.getsockname(...) — after binding to port 0 we don't yet know the real port; getsockname reports it. blen is value-result, set to sizeof bound first.dst struct — the destination the client will send to: loopback IP + the port we just learned. This is the address that will ride along with sendto.sendto(cli, msg, sizeof msg - 1, 0, &dst, sizeof dst) — the core client operation. sizeof msg - 1 sends the text without its trailing NUL. The destination and its length are passed explicitly — this is what distinguishes UDP from a connected send.flen = sizeof from — critical setup for the value-result argument. Set to the capacity before recvfrom, or the kernel misreads it.recvfrom(srv, buf, sizeof buf - 1, 0, &from, &flen) — reads the single datagram and fills from with the sender's address/port. We pass sizeof buf - 1 to leave room for a terminator.buf[got] = '\0' — datagrams carry raw bytes with no terminator. recvfrom returns the exact length; we terminate at that offset so printf("%s") is safe. Never assume received network bytes are a valid C string.inet_ntop(...) — converts the binary sender IP back to text for display, mirroring inet_pton.close(cli); close(srv) — sockets are file descriptors; leaking them leaks kernel resources.1. Using send/write on an unconnected UDP socket.
// WRONG: no peer set, kernel has no idea where to deliver
send(cli, "hi", 2, 0); // fails with ENOTCONN / EDESTADDRREQ
Why it breaks: a UDP socket you never connected has no default destination, so send has nowhere to go.
// FIXED: carry the address explicitly
sendto(cli, "hi", 2, 0, (struct sockaddr *)&dst, sizeof dst);
2. Forgetting to initialize the value-result length.
// WRONG: flen is uninitialized garbage
socklen_t flen;
recvfrom(fd, buf, sizeof buf, 0, (struct sockaddr *)&from, &flen);
Why it breaks: the kernel reads flen as the capacity of your address buffer; garbage means it may not fill from correctly (or, with a too-large struct, read into memory you didn't intend).
// FIXED
socklen_t flen = sizeof from;
recvfrom(fd, buf, sizeof buf, 0, (struct sockaddr *)&from, &flen);
3. Treating received bytes as a NUL-terminated string.
// WRONG: buf may have no terminator; printf reads past the data
ssize_t n = recvfrom(fd, buf, sizeof buf, 0, NULL, NULL);
printf("%s\n", buf); // out-of-bounds read
Why it breaks: UDP delivers raw bytes; there is no implicit \0. If the datagram filled the buffer, there is no room to add one either.
// FIXED: reserve a byte and terminate at the true length
ssize_t n = recvfrom(fd, buf, sizeof buf - 1, 0, NULL, NULL);
if (n < 0) { perror("recvfrom"); return 1; }
buf[n] = '\0';
4. Using sizeof buf when buf is a pointer, not an array.
// WRONG: buf is char*, sizeof buf == 8 (pointer size), not the real capacity
void handle(char *buf) { recvfrom(fd, buf, sizeof buf, 0, NULL, NULL); }
Why it breaks: you silently cap every read at 8 bytes and truncate every datagram.
// FIXED: pass the real length alongside the pointer
void handle(char *buf, size_t cap) { recvfrom(fd, buf, cap, 0, NULL, NULL); }
ntohs of both the destination you send to and the address you bound to. A silent mismatch is the #1 cause.sendto returns -1 — check errno with perror. EDESTADDRREQ/ENOTCONN means you used send without connecting; EMSGSIZE means the datagram exceeds what a single packet can carry; EACCES on broadcast without the flag set.sudo tcpdump -i lo0 udp (macOS) or -i lo (Linux) shows every loopback datagram, its ports, and its length — proof of whether your bytes actually left the client.strace -e trace=network ./m prints each socket/sendto/recvfrom with arguments and return values; on macOS use dtruss. Great for spotting a wrong length or a missing bind.recvfrom? UDP recvfrom blocks until a datagram arrives; if the send was lost or misaddressed it never returns. Add a receive timeout with setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, ...) so a lost packet doesn't hang your program.printf debugging: log the return value of every sendto/recvfrom and the sender IP/port from recvfrom. A datagram "from" an unexpected address is a strong signal of a bug or a spoofed packet.MSG_TRUNC via recvmsg to detect over-large messages instead of trusting them.recvfrom's return value is attacker-controllable up to your buffer size. Always terminate at buf[n] (with a reserved byte), never index buf past n, and parse defensively — a malformed datagram must not drive an out-of-bounds read or write.sin_addr/sin_port. Do not make security decisions (auth, rate-limit exemptions) based solely on the claimed source. This is the mechanism behind DNS/NTP amplification: an attacker spoofs a victim's IP as the source, and your server floods the victim with large replies. Defensive rule: keep responses small relative to requests, and require a challenge/response (e.g. a cookie) before sending large replies — the approach QUIC and DTLS use.socklen_t* to recvfrom/getsockname lets the kernel read a garbage capacity. Always initialize to sizeof the struct.recvfrom calls.SO_RCVTIMEO so lost packets don't hang you, keep server replies small to avoid becoming an amplification vector, and validate every byte you parse. In production, prefer a vetted library (QUIC/DTLS) over hand-rolling reliability and security.Modify the demo so the client sends three datagrams ("one", "two", "three") in a loop and the server reads and prints all three. Confirm that message boundaries are preserved — each recvfrom returns exactly one message.
Add a 2-second receive timeout to the server using setsockopt(srv, SOL_SOCKET, SO_RCVTIMEO, ...). Then comment out the sendto and observe recvfrom returning -1 with errno == EAGAIN instead of blocking forever.
Deliberately trigger truncation: send a 300-byte datagram but give recvfrom only a 16-byte buffer. Print the return value and prove the extra bytes are gone (not delivered on the next read).
Turn the server into an echo server: after receiving, use the from address filled by recvfrom to sendto the same bytes back to the client, and have the client read and verify the echo.
Build a tiny request/response pair where the server only replies after the client's datagram includes a matching one-time token the server sent first (a challenge-response). Explain in a comment how this defeats source-address spoofing and amplification.
sendto carries its own destination address.SOCK_DGRAM socket; send with sendto, receive with recvfrom (which also reports the sender's address).socklen_t flen = sizeof from;) must be initialized before recvfrom/getsockname.buf[n] = '\0'; never read past the returned length.