Linux Network Subsystem in 30 Days
A hands-on, experiment-driven path through the Linux kernel network stack. Read kernel source, instrument with ftrace/perf/bpftrace, change sysctls, observe behavior. Verified against kernel 7.1.
What you’ll know by Day 30
- How a packet flows from wire to userspace, and back, through every kernel layer.
- The data structures (
sk_buff,net_device,sock,dst_entry,fib_table,nf_conntrack) by name and by line number. - The TCP state machine and how Linux’s CC algorithms (CUBIC, BBR, etc.) plug in.
- Netfilter, nftables, and conntrack — the modern packet-filter trio.
- Traffic control: qdiscs, classes, fq_codel.
- Modern features: kTLS, MPTCP, io_uring networking, drop_monitor.
Phases
| Phase | Days | Focus |
|---|---|---|
| 1: Foundation | 1–5 | sk_buff, RX/TX paths, NAPI, segmentation offloads, namespaces |
| 2: L2/L3 | 6–12 | Ethernet, ARP, IP routing, neighbours, IPv6, bridges, tunnels |
| 3: L4 | 13–19 | Sockets, UDP, TCP state machine, congestion control, retransmit, sockopts, epoll |
| 4: Subsystems | 20–26 | Netfilter, nftables, conntrack, tc/qdiscs, SO_REUSEPORT, kTLS, MPTCP |
| 5: Modern + capstone | 27–30 | XDP integration, io_uring net, recent features, capstone |
How to use this
Each day is ~75–90 minutes. Most days follow this shape (loosely — not every section appears every day):
- Concepts introduced today — what the new structures and paths are.
- What to read in the kernel — specific files and functions in your kernel source checkout. Line numbers verified against kernel 7.1.
- Today’s experiment — what to trace/modify/observe to see the structures running.
- What to break — sysctls or configurations that demonstrate the effect (sometimes folded into the experiment).
- Bullet Points.
- Check question.
The labs lean on ftrace, perf, and bpftrace as observability tools. You don’t need to know how to write BPF programs to follow these labs — you’ll use one-liners and existing tools.
Tip: snapshot your sysctls
Some experiments adjust sysctls. Snapshot before:
sudo sysctl -a > sysctl.snapshot.$(date +%Y%m%d)
So you can diff back to a known-good state at the end of each day.
Companion book
After this, the Practical eBPF book builds on the kernel-internals foundation laid here. The plans are intentionally sequenced: kernel internals first, BPF as the application of that knowledge.
Day 1 — struct sk_buff: the universal packet container
Today’s mission: understand the most-touched data structure in Linux networking. Learn its anatomy, lifecycle, and the layout games it plays — and the hardware and memory machinery underneath that make those games necessary. Total time: ~75 minutes.
Phase 1 starts here. Days 1–5 cover the foundations: sk_buff, the RX/TX paths, NAPI, segmentation offloads, network namespaces. By Day 5, you’ll be able to read the kernel’s RX path top-to-bottom without losing the thread.
Why sk_buff matters more than any other structure
Every packet that crosses any layer of the kernel network stack rides in an sk_buff. RX from the NIC, TX to the wire, packet filtering, routing, encapsulation, segmentation — all operate on this structure. Knowing how it works is the difference between reading kernel networking code fluently and getting lost two function calls deep.
The structure lives at include/linux/skbuff.h (line 886 in 7.1) and the implementation at net/core/skbuff.c (~7500 lines as of 7.1 — about 80% of the file is utility functions).
Here’s the thing, though: the sk_buff doesn’t exist in a vacuum. It is the kernel’s answer to a very physical problem — a network card just dumped some bytes into your RAM, and now several different subsystems each want to look at those bytes without copying them around. To really understand why sk_buff is shaped the way it is, you need four pieces of background that most tutorials skip:
- How a NIC actually delivers a packet into memory (descriptor rings + DMA).
- How the kernel hands out memory in fixed-size pools (the slab allocator).
- How physical memory is chopped into pages, and why you can’t always get a big contiguous chunk.
- How the CPU reads memory in 64-byte lines, which dictates field ordering.
We’ll teach each of these as we hit the part of sk_buff that depends on it. By the end you’ll be able to read __alloc_skb and build_skb and know exactly what every line is fighting against.

Where packets come from: the NIC, descriptor rings, and DMA
Before we open the struct, let’s follow the bytes. When a frame arrives on the wire, the CPU is not involved in copying it into memory. That job belongs to the NIC itself, working through a mechanism called DMA.
DMA (Direct Memory Access) means the device reads and writes host RAM on its own, without the CPU moving bytes one load/store at a time. The CPU just tells the device where in RAM to put things; the device’s DMA engine does the transfer and signals when it’s done. For this to be safe, the buffer must be dma_map’d first: the kernel gives the device a bus address it can use, and the mapping makes sure CPU caches and device view of memory stay coherent.
So how does the NIC know where to put incoming frames? Through a RX descriptor ring:
- A ring is a fixed-size circular array of descriptors, living in host memory, that the driver sets up at init time.
- Each descriptor is a small record holding a DMA (bus) address plus status bits.
- At startup the driver pre-allocates a buffer (typically one page, or a slice of a page) for each descriptor and writes that buffer’s bus address into the descriptor. These are the “driver’s preallocated pages” you’ll see referenced everywhere.
- When a frame arrives, the NIC’s DMA engine writes the frame bytes directly into the buffer named by the next free descriptor, then flips a DONE bit in that descriptor and (eventually) raises an interrupt.
The crucial consequence: by the time the CPU runs, the packet bytes are already sitting in a page in RAM. The driver didn’t copy them there — the hardware did. The driver’s job in its receive routine (which on Linux runs inside NAPI poll — Day 2’s whole topic) is to walk the ring, find descriptors with the DONE bit set, and turn each already-populated DMA page into an sk_buff the stack can process.
That last step is where sk_buff enters, and it has two strategies:
- Small packets (e.g. a 64-byte ACK): copy the few bytes out of the DMA page into a fresh, small skb buffer, and immediately recycle the DMA page back into the ring. Cheap, because the copy is tiny.
- Large packets: don’t copy at all. Wrap the existing DMA page in an skb so that the skb’s
datapointer points into the page the NIC already filled. This is the zero-copy receive path, and it’s exactly whatbuild_skb()exists to do. We’ll meet it below.
TX is the mirror image. To transmit, the driver takes an outgoing skb, dma_maps its linear buffer and each page fragment, writes those bus addresses into TX-ring descriptors, and kicks the NIC. The NIC’s DMA engine reads the bytes straight out of those pages and onto the wire, then raises a completion so the driver knows it can unmap and free the skb.
Hold onto one idea: a packet’s payload often lives in independent pages that the kernel wants to reference, not copy — DMA pages on RX, file-cache pages on sendfile() TX. That single fact is why sk_buff is split into a small “descriptor + headers” part and a list of “page fragments,” which we’ll see in a moment.

Connect forward: Day 2’s RX path starts in the driver’s NAPI poll draining this exact ring. Today we only need to know the ring exists and that it leaves packet bytes pre-positioned in pages.
A common zero-copy RX path: DMA page → skb window
Here’s the part most treatments skip, and it’s the part that makes everything else click. On a common high-performance receive path, the driver gives the NIC a kernel-owned buffer for DMA, then wraps that same storage in an skb instead of copying the frame. This is an important path — not a contract every driver must follow.

A modern NIC and its driver commonly communicate through a descriptor ring: a fixed-size circular array where each receive descriptor names a DMA-capable buffer. In a page-pool-based driver, the handshake looks like this:
- Before a packet arrives, the driver obtains a page (often from a page pool), maps it for DMA, and writes its DMA address into a free receive descriptor. The kernel owns the memory; the descriptor lends it to the NIC.
- A frame lands. The NIC DMA-engines the bytes into that buffer, marks the descriptor done, and eventually causes NAPI polling to run.
- The driver processes the completion. Native XDP, when supported and attached, can inspect the raw buffer here. After
XDP_PASS, many drivers callbuild_skbornapi_build_skb: the helper allocates an skb descriptor and pointsskb->headat the caller-provided storage. That branch avoids a payload copy. - The skb travels up the stack —
ip_rcv, routing, TCP — with its pointers describing bytes in that buffer. - The last data reference releases the head.
head_fragtellsskb_free_head()that the head came from a page or page fragment. Separately, a page-pool-aware driver callsskb_mark_for_recycle(), settingpp_recycle; only then does the free path try to return the page to its page pool. Without that marker, the page-fragment free path releases it normally.
The alternatives matter. Some drivers allocate an skb with napi_alloc_skb() and copy a small frame into it (a receive copybreak); others use different buffer managers. Generic XDP also runs later, after an skb already exists. So build_skb means “wrap caller-provided storage,” not “all RX is page-pool zero-copy.”
This is why build_skb exists alongside __alloc_skb. __alloc_skb allocates a descriptor plus fresh linear storage. build_skb allocates the descriptor around storage supplied by its caller; that storage may be a DMA-filled page, but the helper itself neither knows about a NIC ring nor opts the skb into page-pool recycling.
Three consequences fall out of the common page-backed path:
head_fragandpp_recycleanswer different questions.head_fragselects page-fragment release rather thankfree;pp_recycleasks the page-pool path to reclaim eligible storage.- Native XDP can run before skb construction. A drop or redirect can therefore avoid allocating the skb descriptor at all. (Day 27.)
- The driver reserves headroom and tailroom deliberately.
NET_SKB_PAD-style headroom permits laterskb_push; tailroom must include alignedskb_shared_info. The exact layout is driver-specific.
The transmit side uses a related ring but not a perfect ownership mirror (Day 3). TCP may retain its original skb in the retransmit tree while a charged clone travels through IP, the qdisc, and the driver. The driver maps that downstream skb for a TX descriptor, and completion releases the downstream reference.
Hold onto the qualified picture: RX descriptor → driver-owned DMA buffer → optional native XDP → skb view (or copy) → stack → release/recycle. Days 2 and 3 walk it function by function.
The descriptor and the data
The sk_buff itself is a descriptor. The packet bytes live elsewhere — in a separately-allocated linear buffer plus, optionally, a tail of page fragments.

Four pointers in sk_buff define the linear region:
head— start of the allocation. Doesn’t move after creation.data— first valid byte of the current header view. Moves as you push/pull headers.tail— one past the last valid byte.end— the boundary; theskb_shared_infotrailer (fragment list + buffer refcounts, defined below) lives here.
The invariants:
head ≤ data ≤ tail ≤ endheadroom = data - head,tailroom = end - tail- For linear-only skbs,
len == tail - dataanddata_len == 0.
When the kernel pushes an outer header (e.g., adding an IP header to encapsulate), it does skb_push(skb, header_len) — which decrements data. Headroom shrinks; the prepended bytes are now part of the packet. The opposite is skb_pull — increment data, used when stripping a header you’ve already processed.
Why reserve headroom at all? NET_SKB_PAD explained
Notice that skb_push only works if there’s empty space before data to push into. That empty space is the headroom, and on the receive path the driver deliberately reserves some up front. Why?
Because as a received packet climbs the stack, layers frequently need to prepend bytes: re-insert a hardware-stripped VLAN tag, wrap the packet in an outer IP/UDP header for a tunnel, and so on. If there’s no headroom, each skb_push would have to reallocate the whole buffer (via pskb_expand_head) just to make room — expensive. Reserving a little headroom at allocation time means those pushes are nearly free.
The amount reserved by the RX allocators is NET_SKB_PAD, and it is not a magic constant — it’s defined relative to the cache line:
#define NET_SKB_PAD max(32, L1_CACHE_BYTES) /* include/linux/skbuff.h:3319 */
On x86_64, L1_CACHE_BYTES is 64 (CONFIG_X86_L1_CACHE_SHIFT=6 → 1 << 6 = 64), so NET_SKB_PAD = 64. Two things are happening with that value:
- Enough room for outer headers. 64 bytes comfortably covers a VLAN tag, a tunnel header, etc., so the common prepends never reallocate.
- Cache alignment. Because the pad equals the cache-line size, the packet’s first byte (
data, where the Ethernet header begins) starts on a cache-line boundary — which the receive path andmemcpys benefit from. (We’ll explain cache lines fully later; for now: aligned data is faster to touch.)
A separate, smaller pad you’ll see is NET_IP_ALIGN. Some architectures want the IP header 4-byte aligned. Since the Ethernet header is 14 bytes, a 2-byte pad before data makes byte 14 (the start of the IP header) land on a 4-byte boundary. But NET_IP_ALIGN = 2 (skbuff.h:3295) is only the generic fallback — it’s defined under #ifndef NET_IP_ALIGN. On x86 the arch overrides it to 0 (arch/x86/include/asm/processor.h:46: #define NET_IP_ALIGN 0), so no IP-alignment pad is inserted there at all — it’s not a per-driver choice to “skip” a 2-byte pad, the constant is simply zero. That’s why napi_alloc_skb reserving NET_SKB_PAD + NET_IP_ALIGN (skbuff.c:886) comes out to 64 + 0 = 64 on x86, matching the “~64 bytes” the headroom experiment promises. The 2-byte pad is honored only on alignment-sensitive architectures, where it costs nothing extra.
Keep NET_SKB_PAD = 64 in mind — it shows up again in the headroom experiment and in today’s Check question.

Page fragments — for big packets and zero-copy
Many real packets — especially large outbound ones from sendfile() or large GRO inbounds — don’t fit in one allocation. The kernel uses skb_shared_info (placed right after the linear buffer’s end) to chain page fragments:
struct skb_shared_info {
__u8 nr_frags;
skb_frag_t frags[MAX_SKB_FRAGS]; /* (netmem, offset, len) tuples; see below */
struct sk_buff *frag_list; /* chain of skbs (TSO) */
/* ... */
};
data_len holds the byte count in fragments. len = (tail - data) + data_len. Most code uses helpers (skb_frag_size, skb_frag_page) rather than touching the fields directly.
What a “page” is, and why payload goes into frags
To see why the kernel bothers with a separate fragment list instead of one big buffer, you need the kernel’s unit of physical memory: the page.
- Physical RAM is managed in fixed page frames of
PAGE_SIZE(4 KB on x86_64). Every allocation of physical memory is ultimately some number of these frames. - The buddy allocator hands out runs of 2^order contiguous pages: order 0 = 4 KB, order 1 = 8 KB, order 3 = 32 KB, and so on. Higher orders require more physically contiguous memory.
kmallocreturns physically contiguous memory and is therefore capped at a maximum size (KMALLOC_MAX_SIZE,slab.h:591). AboveKMALLOC_MAX_CACHE_SIZE(slab.h:593)kmallocstops using the size-bucketed slab caches and goes straight to the buddy/page allocator (still physically contiguous); the hard upper bound on one allocation isKMALLOC_MAX_SIZE. Asking for, say, one 64 KB physically contiguous block is unreliable on a busy system: there may be plenty of free pages, but not 16 of them in a row. That condition — free memory that isn’t contiguous — is fragmentation pressure.
Now the design makes sense. Think of a skb_frag_t as a (page, offset, size) reference — some bytes inside a page — which is the right first approximation. The literal struct in 7.1 is a (netmem, offset, len) tuple: struct skb_frag { netmem_ref netmem; unsigned int len; unsigned int offset; } (skbuff.h:361). The netmem_ref (include/net/netmem.h:140) is an opaque reference that encodes either a struct page or a net_iov (non-page memory, e.g. devmem-TCP), which is why a frag isn’t guaranteed to be backed by a struct page at all — skb_frag_page() first checks skb_frag_is_net_iov(frag) and returns NULL for the non-page case. Always touch a frag via the helpers skb_frag_page() / skb_frag_off() / skb_frag_size(), never the fields directly. The memory referenced by different frags need not be contiguous with each other or with the linear head. So a 64 KB GRO superpacket becomes:
- a tiny linear head holding just the Ethernet/IP/TCP headers (a small
kmalloc, easy to satisfy), plus - up to
MAX_SKB_FRAGSpage fragments holding the payload — each an ordinary 4 KB page grabbed from wherever it was free.
No 64 KB contiguous allocation needed, ever. And it’s exactly this structure that makes zero-copy possible: the frag pages can be the NIC’s DMA pages on RX, or sendfile()’d file-cache pages on TX — referenced, never copied.
MAX_SKB_FRAGS bounds how many frags one skb_shared_info holds. In v7.1 it’s configurable:
/* include/linux/skbuff.h */
#ifndef CONFIG_MAX_SKB_FRAGS
# define CONFIG_MAX_SKB_FRAGS 17
#endif
#define MAX_SKB_FRAGS CONFIG_MAX_SKB_FRAGS
and net/Kconfig declares config MAX_SKB_FRAGS with range 17 45 and default 17 — raising it helps GRO/BIG TCP pack more payload per skb.

There are no Dumb Questions
Q: Why isn’t the linear buffer just always big enough?
A: Because each skb potentially allocates its linear buffer at packet receive. For 64-byte ACKs that don’t carry payload, a 1500-byte allocation would waste memory. For 64KB GRO superpackets, a 64KB linear buffer is impractical (kmalloc max-order limits, fragmentation pressure). The split design lets the kernel allocate just enough for headers linearly and use page fragments for payload.
Q: What’s the cb[48] for?
A: It’s a per-packet scratchpad. Each protocol layer can stash state there. TCP uses
TCP_SKB_CB(skb)to keep sequence numbers, flags, and SACK info. Acrossskb_clonethecbcontents are copied verbatim into the clone —__copy_skb_header(net/core/skbuff.c:1552) doesmemcpy(new->cb, old->cb, sizeof(old->cb))— so the clone starts with the same control data. Layers that need clone-independent state must resetcbthemselves. 48 bytes is generous — most layers use a fraction.Q: How big is a fresh sk_buff descriptor?
A: Around 230 bytes on x86_64 (verify:
pahole sk_buffin your build directory after compiling — exact size is config-dependent). The structure has been carefully cache-line-aligned and the fields ordered for hot/cold separation. Read the comments around the field declarations to see what’s “RX hot” vs “TX hot.”
How the kernel hands out memory: the slab allocator
We’re about to read the allocation path, and it names several “caches.” Before that pays off, you need the slab allocator (Linux’s is called SLUB).
A general-purpose malloc is flexible but has overhead: it tracks variable-sized blocks, searches free lists, and fragments over time. The kernel allocates the same few object types over and over — millions of sk_buffs, for instance — so it uses a specialized scheme:
- The slab allocator carves whole pages into fixed-size object slots.
- A
kmem_cacheis a pool specialized for one object size/type. Allocation is essentially “pop a free slot,” free is “push it back” — O(1), with excellent cache behavior because objects of the same type cluster together. - General-purpose
kmalloc(n)is built on top of a set of size-bucketed caches (kmalloc-512,kmalloc-2k, …).kmalloc(n)roundsnup to the next bucket and pulls from that cache.
Now here’s the key structural insight about sk_buff: the descriptor and the data come from two different allocators.
-
The descriptor (
struct sk_buffitself) comes from a dedicated, fixed-size cache so every skb is the same size and stays hot in cache. In v7.1 this cache is named"skbuff_head_cache"and reached vianet_hotdata.skbuff_cache:/* net/core/skbuff.c:5189, in skb_init() */ net_hotdata.skbuff_cache = kmem_cache_create_usercopy("skbuff_head_cache", sizeof(struct sk_buff), 0, SLAB_HWCACHE_ALIGN | SLAB_PANIC | FLAG_SKB_NO_MERGE, ...); -
The linear data buffer is a separate allocation, sized to the packet. That’s why
__alloc_skballocates twice.
There are three skb-related caches created in skb_init():
| Cache (v7.1 name) | What it holds | Created at |
|---|---|---|
skbuff_head_cache | the struct sk_buff descriptor (fixed size) | skbuff.c:5189 |
skbuff_fclone_cache | a struct sk_buff_fclones = two skbs + a refcount | skbuff.c:5199 |
skb_small_head_cache (slab name "skbuff_small_head") | small linear data buffers (SKB_SMALL_HEAD_CACHE_SIZE) | skbuff.c:5208 |
Fclones. When code knows it will clone an skb soon (TCP does this constantly — it keeps a copy for retransmission while sending a clone down the stack), it sets SKB_ALLOC_FCLONE. That allocates from skbuff_fclone_cache, whose object is a struct sk_buff_fclones { struct sk_buff skb1; struct sk_buff skb2; refcount_t fclone_ref; } (skbuff.h:1396). The anticipated clone (skb2) is right there in the same allocation — no second descriptor allocation needed when the clone happens.
Per-CPU fast path. RX is hot, so there’s a per-CPU stash of recycled descriptors called the napi_alloc_cache (a per-CPU skb_cache[] array, skbuff.c:231). napi_skb_cache_get() (skbuff.c:284) pops from it; when empty it refills in bulk via kmem_cache_alloc_bulk(net_hotdata.skbuff_cache, ...). Allocating an skb with SKB_ALLOC_NAPI (or simply allocating in softirq context) pulls a descriptor from this per-CPU cache and skips the slab allocator’s slow path entirely. That’s the “per-CPU caching” the NAPI allocator advertises.
GFP flags. Every allocation takes a gfp_mask (e.g. GFP_KERNEL, GFP_ATOMIC). It tells the allocator what it’s allowed to do — most importantly, whether it may sleep. The RX path runs in softirq/IRQ (atomic) context where sleeping is forbidden, so it uses GFP_ATOMIC. That’s why the allocation functions thread a gfp_mask (and a NUMA node) through everything.

sk_buff lifecycle: cradle to grave

Now the allocation paths read cleanly, because you know what every cache and flag means.
Allocation paths (all in net/core/skbuff.c):
__alloc_skb(size, gfp_mask, flags, node)— the workhorse. Allocates the descriptor fromnet_hotdata.skbuff_fclone_cache(whenSKB_ALLOC_FCLONEis set, useful for skbs you’ll clone) or the defaultskbuff_head_cache— or, withSKB_ALLOC_NAPI, from the per-CPUnapi_alloc_cache. Then allocates the linear buffer separately viakmalloc_reserve(skbuff.c:604), which pulls fromskb_small_head_cachefor small heads or falls back to the size-bucketedkmalloccaches for larger ones. Two allocations, two allocators — exactly as the slab section predicted. (__alloc_skbis atskbuff.c:672.)__netdev_alloc_skb(dev, len, gfp_mask)— drivers’ RX-side allocator. Its buffer “hasNET_SKB_PADheadroom built in” (the kernel doc comment says so verbatim atskbuff.c:753) for cheap header insertion.napi_alloc_skb(napi, len)— NAPI fast-path allocator with the per-CPU descriptor caching described above; also reservesNET_SKB_PAD.build_skb(data, frag_size)/slab_build_skb(data)— wrap a pre-existing buffer (the driver’s preallocated DMA page) into an skb. This is the zero-copy receive path from the NIC section. Instead of allocating a data buffer and copying the frame in, it allocates only a descriptor (kmem_cache_alloc(net_hotdata.skbuff_cache, ...)) and points the skb at the page the NIC already filled.__build_skbis atskbuff.c:488;build_skbatskbuff.c:506then setsskb->head_frag = 1(the skb’s head is a page fragment,skbuff.h:828) and callsskb_propagate_pfmemalloc(...).napi_build_skb()(skbuff.c:574) is the NAPI-context variant that also sources its descriptor from the per-CPU cache. The doc comment on__build_skbspells out the RX model we taught: “Before IO, driver allocates only data buffer where NIC put incoming frame… RX rings only contain data buffers, not full skbs.”
Cloning paths — and to read these you need the refcount model, so let’s build it first.
Two refcounts, not one: skb->users vs dataref
skb_clone makes a cheap copy of an skb. To understand why it’s cheap and what it’s safe to do afterward, you must know that an skb tracks two completely separate reference counts.
1. skb->users — references to the descriptor.
refcount_t users; /* include/linux/skbuff.h:1099 */
This counts how many places hold this sk_buff pointer. skb_get() bumps it; kfree_skb() decrements it and only actually frees when it hits zero. skb_shared(skb) is literally refcount_read(&skb->users) != 1 (skbuff.h:2112). Two subsystems queuing the same skb use this.
2. skb_shared_info.dataref — references to the data buffer.
atomic_t dataref; /* include/linux/skbuff.h:612 */
This counts how many sk_buff descriptors point at the same data buffer. This is the one skb_clone touches: a clone gets a brand-new descriptor but, instead of copying the bytes, increments dataref. That is why cloning is cheap — no payload copy.
Because clones share the buffer, you must not blindly write to a cloned skb’s headers — you’d corrupt the other holder’s view. So dataref is cleverly split into two halves (SKB_DATAREF_SHIFT = 16, skbuff.h:658):
- the low 16 bits count the overall number of references to the buffer;
- the high 16 bits count how many of those are payload-only (header-release) references.
The kernel’s own doc block (skbuff.h, “DOC: dataref and headerless skbs”) explains it: TCP marks an skb nohdr via __skb_header_release(), which sets dataref = 1 + (1 << SKB_DATAREF_SHIFT) (skbuff.h:2101) so lower layers know they may prepend headers into the shared buffer safely. skb_header_cloned() (skbuff.h:2070) computes (dataref & MASK) - (dataref >> SHIFT) to decide whether the headers are safe to modify in place.
This gives you the precise meaning of two helpers you’ll see everywhere:
skb_cloned(skb)(skbuff.h:2031) is true when the data buffer is shared ((dataref & SKB_DATAREF_MASK) != 1). If it’s true, you must not write headers without first doing a copy-on-write viapskb_expand_head.skb_shared(skb)tests the descriptor count (users != 1), a different question entirely.
With that, the cloning paths are obvious:
skb_clone(skb, gfp)(skbuff.c:2088) — new descriptor, shares the data buffer (bumpsdataref). Cheap. Used heavily in packet sockets (tcpdump) and netfilter LOG. (If an fclone is available it reuses the pre-allocatedskb2— no descriptor allocation at all.)skb_copy(skb, gfp)— full copy of both descriptor and data (fresh buffer,dataref = 1). Slow; only when you must mutate the payload.pskb_copy(skb, gfp)— copies the linear head but shares the frag pages (bumps the pages’ refcounts).
This two-level scheme is why tcpdump, which clones every packet, stays cheap per packet yet still shows up measurably at high rates — today’s clone-tracing experiment makes that visible.

Free paths:
kfree_skb(skb)— the standard release. Decrements refcounts (bothusersand, whenusershits zero,datarefon the buffer); frees the data only if it’s the last reference.kfree_skb_reason(skb, reason)— newer; takes aenum skb_drop_reasonso the kernel’s drop monitor can attribute the drop. Always prefer this in new code. The reason enum is ininclude/net/dropreason-core.h.consume_skb(skb)— same askfree_skbbut doesn’t trigger drop tracepoints (used for “successful” disposals).
The CPU’s view of memory: cache lines and field ordering
One last piece of background, and it explains the comments you’ll read in the struct and the whole point of today’s pahole lab.
The CPU does not read memory one byte at a time. It moves memory in fixed cache-line units — L1_CACHE_BYTES, 64 bytes on x86_64. Touch a single field and the CPU pulls that field’s entire 64-byte line into L1 cache. Touch a field in a different line, that’s a second line fetched.
This has a direct performance consequence for a struct touched as often as sk_buff:
-
Hot/cold separation. If the fields a given code path uses are scattered across many cache lines, the path drags many lines into cache. If they’re grouped into the same line(s), the path touches few lines and runs faster. So
sk_buffis laid out so that RX-hot fields cluster, TX-hot fields cluster, and rarely-used (cold) fields sit apart so they don’t pollute hot lines. That’s why the very first fields arenext,prev, and thedev/skarea (skbuff.h:886) — the list-and-device fields the RX path hits first. -
Bit-packed flags. Boolean state like
cloned:1, nohdr:1, fclone:2, head_frag:1, pfmemalloc:1, …is packed into shared__u8bitfields (skbuff.h:956) partly to keep the hot section dense — many flags in one byte instead of one byte each. -
Descriptor alignment. Remember
skbuff_head_cachewas created withSLAB_HWCACHE_ALIGN? That makes every descriptor start on a cache-line boundary, so a given field’s offset maps predictably to a line. -
Separating head from
skb_shared_info. Look again at the comment inside__alloc_skb(right beforekmalloc_reserve):/* We do our best to align skb_shared_info on a separate cache line. * It usually works because kmalloc(X > SMP_CACHE_BYTES) gives aligned * memory blocks ... Both skb->head and skb_shared_info are cache line aligned. */The kernel places
skb_shared_infoat a cache-line boundary at the end of the data buffer so thatskb->head(written on RX) andskb_shared_info(refcount fields touched by clones) don’t share a line — otherwise the line would bounce between CPUs.
Tie it back to NET_SKB_PAD = max(32, L1_CACHE_BYTES): that’s the same L1_CACHE_BYTES, used so the data start lands on a line boundary. The cache line is the hidden constant behind half the layout decisions in this file.
This is precisely what pahole reveals: it prints each field’s offset, the padding holes, and a /* size: N, cachelines: M, members: K */ footer showing how the struct fills its lines. Today’s lab teaches you to read that.

Today’s experiment
You don’t write code today. You inspect what’s already running.
See sk_buff allocations live
sudo bpftrace -e 'kprobe:__alloc_skb { @[comm, kstack] = count(); } interval:s:5 { exit(); }' | head -50
5 seconds of skb allocations grouped by caller. You’ll see drivers and protocol layers calling in.
Watch drops with reasons
sudo perf trace --no-syscalls -e skb:kfree_skb -- sleep 5
Output shows where in the kernel each dropped skb was disposed of, plus the drop reason if kfree_skb_reason was used.
Inspect skb size on your build
cd ~/code/linux
# After building vmlinux once (needs CONFIG_DEBUG_INFO_BTF=y or DWARF):
pahole -C sk_buff vmlinux
pahole reads the struct layout from the compiled vmlinux’s debug info, not from the header source — a .h has no offsets or padding until the compiler lays the type out. -C sk_buff selects just that struct (no grep needed); it prints the field-by-field layout with a /* size: N, ... */ footer. Reveals the size, padding, and field-by-field layout. Note how next, prev, dev, sk are at the top — that’s the cache-hot section that the RX path touches first. Now that you know what a cache line is, read the padding holes and the cachelines: count as a map of the hot/cold separation we just discussed.
Trace a packet’s headroom journey
sudo bpftrace -e '
fentry:ip_rcv { @h[comm] = lhist((uint64)args->skb->data - (uint64)args->skb->head, 0, 256, 16); }
interval:s:5 { exit(); }'
Histogram of headroom on packets entering ip_rcv. You’ll see most packets have ~64 bytes of headroom (NET_SKB_PAD). That 0 vs 64 split is the whole point of the headroom section: the RX allocators (__netdev_alloc_skb, napi_alloc_skb) pre-reserve NET_SKB_PAD so later skb_pushes of outer headers don’t reallocate, while plain __alloc_skb reserves nothing (some drivers reserve more on top for crypto offload or XDP).
Watch clones share-count under tcpdump
A packet socket capture clones every packet. Start a capture (-w /dev/null discards packets so tcpdump doesn’t spam your terminal):
sudo tcpdump -i any -w /dev/null &
Then trace skb_clone:
sudo bpftrace -e 'fentry:skb_clone { @[kstack] = count(); } interval:s:5 { exit(); }' | head -30
skb_clone fires on every packet because the capture path clones each one. Each clone is a fresh descriptor that merely bumps the buffer’s dataref (not a payload copy), which keeps per-packet cost low — yet at high packet rates even that cheap clone is measurable, which is why tcpdump adds overhead. Stop the capture when done:
sudo pkill tcpdump
What to read in the kernel
include/linux/skbuff.h—struct sk_buffdefinition (line 886). Read the field comments. Then look at the helpers (skb_push,skb_pull,skb_reserve,skb_put). Also skim the bitfield block (~line 955) and the “DOC: dataref and headerless skbs” comment.net/core/skbuff.c—__alloc_skb(line 672),__build_skb(line 488),kfree_skb_reason,skb_clone(line 2088). Note the three caches created inskb_init(lines 5189–5208). ~7500 lines total, but the allocation path is < 100 lines.include/linux/skbuff_ref.h— refcount helpers; quick read.include/net/dropreason-core.h— theenum skb_drop_reasonlist (~124 reasons in 7.1). Skim. This is what you’ll see inperf trace.Documentation/networking/skbuff.rst— the official reference. One-time read.
Bullet Points
- A NIC delivers packets by DMA into pre-allocated pages named by RX-ring descriptors — bytes are in RAM before the CPU runs. The driver turns each done descriptor into an skb.
sk_buffis the descriptor; data lives in a linear buffer + optional page fragments. A frag is a(netmem, offset, len)tuple —netmemreferences a page or a net_iov (useskb_frag_page()/skb_frag_off()/skb_frag_size()) — so payload can be non-contiguous pages — DMA pages orsendfilepages — and need never be copied.- Pointer invariant:
head ≤ data ≤ tail ≤ end.headroom,tailroom,len,data_len. RX allocators pre-reserveNET_SKB_PAD = max(32, L1_CACHE_BYTES) = 64soskb_pushof outer headers doesn’t reallocate. - A 64KB linear buffer is impractical because
kmallocreturns physically-contiguous memory capped atKMALLOC_MAX_SIZE, and fragmentation makes big contiguous runs scarce — hence the linear-head + page-frags split. - The slab allocator gives O(1) fixed-size pools. The skb descriptor comes from
skbuff_head_cache; the linear buffer fromskb_small_head_cache/kmallocbuckets — two allocations.skbuff_fclone_cachepre-bundles a clone; the per-CPUnapi_alloc_cachefeeds the RX fast path.GFP_ATOMICis used in atomic RX context. cb[48]is the per-packet scratchpad each protocol layer uses.- Allocate via
__alloc_skb/napi_alloc_skb/build_skb(zero-copy wrap of a DMA page); free viakfree_skb_reason. - Two refcounts:
skb->userscounts descriptor references (skb_shared);skb_shared_info.datarefcounts how many descriptors share the data buffer (split into all-refs / payload-only halves).skb_clonebumpsdatarefand shares data;skb_copyduplicates everything (dataref = 1).skb_cloned()⇒ copy-on-write before touching headers. enum skb_drop_reasonis the new-and-required way to attribute drops.- The structure is large (~230 bytes; verify with pahole, config-dependent); the CPU reads memory in 64-byte cache lines, so fields are cache-line ordered for RX-hot/TX-hot/cold separation, the descriptor is
SLAB_HWCACHE_ALIGNed, andskb_shared_infois placed on its own line — read the comments, and readpahole’scachelines:footer.
Check question
You receive a packet at the NIC. The driver allocates an skb via napi_alloc_skb(napi, 1500). The packet is 100 bytes of Ethernet + IP + TCP. Walk through what the four pointers (head, data, tail, end) look like just after the driver finishes setup but before ip_rcv runs.
Click to reveal answer
Answer: head points at the start of the linear allocation. data points at the start of the Ethernet header (driver placed bytes there after reserving NET_SKB_PAD = 64 bytes of headroom). tail points at byte 100 past data (the packet bytes). end is at the linear buffer’s end (1500 + alignment). So: headroom = 64, len = 100, data_len = 0, tailroom = ~1400. By the time ip_rcv runs, eth_type_trans has advanced data past the Ethernet header (adjusting mac_header etc.), so data now points at the IP header. (In the zero-copy build_skb case the bytes were never copied — head would point into the NIC’s DMA page and skb->head_frag would be set — but napi_alloc_skb here allocates a fresh linear buffer the driver copies the small frame into.)
Tomorrow
Day 2: the RX path. NAPI poll → driver → __netif_receive_skb → ip_rcv. We trace a packet through every stage and see where each skb_* helper gets called — starting in the driver’s NAPI poll, draining the very RX descriptor ring we met today.
Day 2 — The RX path: from wire to ip_rcv
Today’s mission: trace a packet from the moment it hits the NIC to the moment it enters
ip_rcv. See every step — and learn the five mechanisms the path is built on (softirqs, NAPI, XDP, GRO, packet_type demux) so nothing along the way is a black box. Total time: ~110 minutes.
The journey at a glance

Every received packet on a typical Linux box traverses a sequence of handoffs. Hardware DMAs the frame into kernel-owned RX memory, the IRQ only schedules work, NAPI polls the driver under a budget, native XDP can decide before an skb exists, XDP_PASS becomes an sk_buff, GRO may coalesce related TCP segments, and the core stack dispatches the packet to the right L3 protocol handler.
That sentence names five things that this whole chapter leans on but that you’ve never been formally taught: a softirq, the NAPI state machine, XDP, the GRO accumulator, and packet_type demultiplexing. Day 1 forward-referenced all of them but taught none. So before we walk the path, we’ll teach each one — intuition first, then the concrete v7.1 struct or function — and only then trace the packet, with every stage resting on something you already understand.
We’ll anchor everything to a specific file/function in your ~/code/linux checkout (line numbers from kernel 7.1).
Two things from Day 1 are load-bearing today and will not be re-taught:
- The RX descriptor ring + DMA. Recall from Day 1 — the NIC’s DMA engine writes each frame into a pre-allocated page named by an RX-ring descriptor and flips a DONE bit; the driver’s NAPI poll drains those done descriptors. The bytes are in RAM before the CPU runs.
build_skbzero-copy wrap.build_skbwraps that already-filled DMA page (head_frag = 1) instead of copying it, giving zero-copy receive. We met it in Day 1’s lifecycle section.
Background 1: What a softirq actually is
The whole RX path runs in a softirq. Before we can say “the IRQ raises NET_RX_SOFTIRQ and the softirq runs net_rx_action,” you need to know what that machinery is.
The problem: a hardware IRQ handler must be tiny
When a NIC raises an interrupt, the CPU drops whatever it was doing and jumps to the driver’s IRQ handler. That handler runs in a brutally constrained world:
- It preempts the currently-running task (which never agreed to be interrupted).
- It runs on a small, dedicated IRQ stack.
- It cannot sleep — no blocking allocations, no waiting on locks that might sleep.
- It runs with interrupts of the same priority masked, so while it runs, other devices at that level are stalled.
Doing the full receive path — route lookup, netfilter, BPF, delivering to a socket — inside that handler would be a disaster: it would stall the machine and starve everything else. So Linux splits interrupt work into two halves:
- The top half is the actual hardware IRQ handler. It does the absolute minimum: acknowledge the device and schedule deferred work. Then it returns.
- The bottom half is that deferred work, run later in a relaxed context where it’s allowed to take its time.
A softirq is Linux’s primary bottom-half mechanism.
What “softirq” means concretely
A softirq is a statically-defined, per-CPU bottom half. “Statically defined” matters: there is a fixed, small enum of softirq vectors compiled into the kernel — you cannot create one at runtime. Here is the entire list (include/linux/interrupt.h:550):
enum
{
HI_SOFTIRQ=0,
TIMER_SOFTIRQ,
NET_TX_SOFTIRQ,
NET_RX_SOFTIRQ,
BLOCK_SOFTIRQ,
IRQ_POLL_SOFTIRQ,
TASKLET_SOFTIRQ,
SCHED_SOFTIRQ,
HRTIMER_SOFTIRQ,
RCU_SOFTIRQ, /* Preferable RCU should always be the last softirq */
NR_SOFTIRQS
};
Networking owns two of them: NET_RX_SOFTIRQ (receive) and NET_TX_SOFTIRQ (transmit). Each vector has one action function registered at boot via open_softirq() (kernel/softirq.c:806). The networking subsystem registers its two in net/core/dev.c:
open_softirq(NET_TX_SOFTIRQ, net_tx_action); // net/core/dev.c:13288
open_softirq(NET_RX_SOFTIRQ, net_rx_action); // net/core/dev.c:13289
So net_rx_action is the receive bottom half. The rest of today is mostly the story of what net_rx_action does.
Raising vs. running — they are not the same
Here’s the subtlety that trips people up. “Raising” a softirq does not run anything. It just sets a per-CPU pending bit. The hot-path raise is raise_softirq_irqoff (kernel/softirq.c:773) → __raise_softirq_irqoff (:799), which does exactly one meaningful thing:
or_softirq_pending(1UL << nr); // kernel/softirq.c:803
That’s it — OR a bit into this CPU’s pending mask. The actual draining happens elsewhere: __do_softirq (kernel/softirq.c:654) checks the pending mask and runs each pending vector’s action function. It’s invoked on IRQ-exit (right after the top half returns) and from a few other points. So the top half “raises” (sets the bit and returns); moments later, on the way out of interrupt context, __do_softirq notices the bit and calls net_rx_action.
Why a packet flood can’t freeze your shell: ksoftirqd
Softirqs run at higher priority than user threads — so what stops an endless packet flood from making __do_softirq loop forever and starving your login shell? A safety valve. __do_softirq will restart processing at most MAX_SOFTIRQ_RESTART = 10 times (kernel/softirq.c:544). If softirqs are still pending after that, it gives up and wakes a per-CPU kernel thread, ksoftirqd (run_ksoftirqd, kernel/softirq.c:1068), to finish the work as a normal schedulable thread. That thread competes fairly with user tasks, so softirq work can be intense but can never indefinitely lock out user space. This hand-off is the concrete meaning of “softirqs are lower priority than hardware IRQs but a notch above user threads.”
Per-CPU is the through-line
Notice the recurring word: per-CPU. Each CPU has its own pending mask, runs its own __do_softirq, and has its own ksoftirqd. This is why everything downstream is per-CPU too — the poll_list NAPI uses, the softnet_data struct, the /proc/net/softnet_stat counters. When you see “per-CPU” later, it traces straight back to here.

Background 2: NAPI and the two budgets
You’ll constantly read “napi_schedule,” “poll_list,” and “the poll function processes packets under a budget.” Time to make those real — and to kill a common confusion: there are two different budgets, not one.
The NAPI idea: stop interrupting, start polling
A 10/40/100-Gbit NIC under load can raise millions of interrupts per second. Even with a tiny top half, that’s death by a thousand cuts — the IRQ overhead alone melts a CPU (“receive livelock”). NAPI (“New API,” though it’s been the API for ~20 years) fixes this with a trade: at the first packet, mask the device’s RX interrupt and switch to polling. The kernel then repeatedly asks the driver “got more?” until the ring drains, and only then re-enables the interrupt. One IRQ per burst, instead of one per packet.
struct napi_struct: the per-queue polling context
Each NIC receive queue registers a struct napi_struct — the context that ties a driver’s poll routine to the core’s polling loop (include/linux/netdevice.h:381):
struct napi_struct {
unsigned long state; /* NAPI_STATE_SCHED bit lives here */
struct list_head poll_list; /* links onto the per-CPU poll_list */
int weight; /* per-poll packet cap (default 64) */
int (*poll)(struct napi_struct *, int); /* driver callback */
struct net_device *dev;
struct sk_buff *skb;
struct gro_node gro; /* the GRO accumulator (Background 4) */
/* ... timers, ids, control-path fields ... */
};
The four fields that matter today: state (holds the NAPI_STATE_SCHED bit — “I’m scheduled to be polled”), poll_list (the list node that hangs this napi off the per-CPU poll list), weight (the per-poll budget — more below), and poll (the driver’s drain routine, e.g. e1000_clean, mlx5e_napi_poll).
The schedule → poll → complete cycle
Here is the full loop. Follow the NAPI_STATE_SCHED bit and the hardware IRQ as they flip:
-
IRQ fires. The driver’s top half calls
napi_schedule(or__napi_schedule_irqoffon the hot path). Its first step,napi_schedule_prep(net/core/dev.c:6736), atomically setsNAPI_STATE_SCHED(and setsNAPI_STATE_MISSEDifSCHEDwas already set). Then____napi_schedule(net/core/dev.c:4957) adds the napi to this CPU’s poll list —list_add_tail(&napi->poll_list, &sd->poll_list); // net/core/dev.c:4984 ... raise_softirq_irqoff(NET_RX_SOFTIRQ); // net/core/dev.c:4990— and raises
NET_RX_SOFTIRQ(there’s the Background-1 mechanism). The driver also masks its own RX interrupt at this point. From now until step 3, this queue generates no IRQs. -
Softirq runs
net_rx_action, which walks the poll list and calls each napi’s->poll(napi, weight). The driver drains done descriptors from the ring (the Day-1 ring), turning each into an skb. -
Ring drained below
weight. When the driver finds fewer packets than its weight, it knows the burst is over and callsnapi_complete_done(net/core/dev.c:6771). That clearsNAPI_STATE_SCHEDand returnstrueto tell the driver the burst is over; the driver then re-enables its hardware RX interrupt (e.g.if (napi_complete_done(napi, work)) <write the device's IRQ-enable register>). Back to interrupt-driven mode until the next packet.
This is the exact mechanism behind “one IRQ per burst.” If instead the driver hits its weight (ring still full), it does not complete — it returns the full weight, stays on the poll list, and gets polled again. The interrupt stays masked while traffic is heavy.
Two budgets: weight (64) vs netdev_budget (300)
This is the part everyone conflates. There are two separate caps:
-
weight— caps how many packets one->pollcall may process. It’s the value passed into->poll. Default isNAPI_POLL_WEIGHT= 64 (include/linux/netdevice.h:2839). The per-NAPI poll wrapper reads it (__napi_poll,net/core/dev.c:7719):weight = n->weight; work = n->poll(n, weight);. -
netdev_budget— caps the total packets across all napis in a singlenet_rx_actionsoftirq run. Default 300 (net/core/hotdata.c:14). There’s also a time cap,netdev_budget_usecs(net/core/hotdata.c:16, = 2 jiffies).
So in one softirq run, net_rx_action starts with a budget of 300 and spends it across however many queues are pending, where each individual poll call can claim at most 64. The loop (net/core/dev.c:7914):
int budget = READ_ONCE(net_hotdata.netdev_budget); // 300, the OUTER budget
...
n = list_first_entry(&list, struct napi_struct, poll_list);
budget -= napi_poll(n, &repoll); // each poll capped at weight=64
Picture it as nested limits: the inner ring (one poll) holds 64; the outer ring (one softirq run) holds 300 spread across all queues.
time_squeeze and softnet_data
When net_rx_action exits the loop because budget went to zero or the time limit expired while work was still pending, it bumps a counter called time_squeeze and leaves the remaining napis to be polled in a later softirq (or punted to ksoftirqd). That counter is exactly what today’s softnet_stat lab watches: a rising time_squeeze means receive work is outrunning a single softirq window.
All of this per-CPU bookkeeping lives in struct softnet_data (DEFINE_PER_CPU_ALIGNED(struct softnet_data, softnet_data), net/core/dev.c:462): the poll_list, the processed/drop/time_squeeze counters. /proc/net/softnet_stat prints one line per CPU straight from these fields (net/core/net-procfs.c:145).

Stage 1: NIC → IRQ → softirq
Now Stage 1 reads as plain English. Modern NICs use interrupt coalescing: several packets per IRQ, configurable via ethtool -c. When the IRQ fires, the driver doesn’t process packets in IRQ context (Background 1 told you why it can’t). Instead its top half calls napi_schedule() (or __napi_schedule_irqoff on the hot path), which (Background 2):
- Sets
NAPI_STATE_SCHED(vianapi_schedule_prep). - Adds the napi to the per-CPU
poll_list. - Raises
NET_RX_SOFTIRQ.
Separately, the driver masks its own RX interrupt in its IRQ handler (Background 2, step 1) — napi_schedule itself touches no hardware.
The softirq runs net_rx_action (net/core/dev.c:7914), which iterates the per-CPU poll list, calling each napi’s poll function under the two budgets you just learned (Background 2): the inner weight (64) per ->poll call, and the outer netdev_budget (300) across all napis in the run.

The core napi_poll wrapper invokes the driver’s registered ->poll (e.g. e1000_clean, mlx5e_napi_poll), which in turn call RX helpers like e1000_clean_rx_irq / mlx5e_poll_rx_cq.
Background 3: What XDP is
Stage 2 is built around XDP, and Day 27 covers it in full. But the RX narrative is load-bearing on the basics, so here’s just enough to follow the handoff.
XDP (eXpress Data Path) is an eBPF program the driver runs on the raw RX frame, before any sk_buff exists — the earliest possible software hook. Because it runs pre-skb, it can drop or redirect at line rate without paying the cost of skb allocation (the slab work and refcount machinery you learned in Day 1). That’s the whole point: a DDoS-drop or load-balancer redirect that never even builds a packet object.
What does XDP look at? An xdp_buff — a lightweight, stack-allocated descriptor over the DMA region (include/net/xdp.h:86):
struct xdp_buff {
void *data; /* first byte of the frame */
void *data_end; /* one past the last byte */
void *data_meta; /* scratch metadata area */
void *data_hard_start; /* start of the buffer */
/* ... */
};
Contrast that with the sk_buff you spent Day 1 on: no slab allocation, no users/dataref refcounts, no headroom-reservation machinery — just four pointers into the DMA page. The xdp_buff is thrown away the moment XDP returns. It’s a temporary lens over bytes, not a packet object.
An XDP program returns one of five action codes (enum xdp_action, include/uapi/linux/bpf.h:6548):
enum xdp_action {
XDP_ABORTED = 0, /* error path — drop + tracepoint */
XDP_DROP, /* free the frame in-driver, no skb ever built */
XDP_PASS, /* the ONLY code that proceeds to build an sk_buff */
XDP_TX, /* bounce the frame back out the same NIC */
XDP_REDIRECT, /* send to another device / CPU / AF_XDP socket */
};
XDP_ABORTED, XDP_DROP, XDP_TX, and XDP_REDIRECT all consume the frame right there — the normal stack never sees it. XDP_PASS is the single code that says “turn this into a real packet” — only then does the driver build an skb and enter the path the rest of this chapter describes. Stage 2’s entire handoff hinges on that one return value.
Native vs. generic. Native XDP runs inside the driver’s poll on the DMA buffer, pre-skb — fast, the design intent. Generic XDP (do_xdp_generic → netif_receive_generic_xdp, net/core/dev.c:5656/:5576) is a fallback for drivers without native support: it runs later, in the core stack, after an skb already exists. It’s slower (you’ve already paid for the skb) — which is why the chapter notes generic XDP “runs later.” Full XDP programming, maps, and AF_XDP are Day 27; this is just enough to read the RX handoff.

Stage 2: Driver → native XDP → skb → GRO
Inside the driver’s poll, for each completed RX descriptor (recall from Day 1 — the descriptor’s DONE bit is set and the frame is already DMA’d into its page):
- Build an
xdp_buffview of the DMA buffer. Native XDP runs while the packet is still just bytes in driver-owned RX memory — nosk_buffhas been allocated yet (Background 3). - Call XDP if attached.
XDP_DROP,XDP_TX, andXDP_REDIRECTconsume the packet at the driver/XDP layer. OnlyXDP_PASSsays, “turn this into a normal kernel packet.” - Wrap the DMA buffer in an skb. After
XDP_PASS, modern drivers usebuild_skb/napi_build_skb(recall from Day 1: zero-copy — the driver already DMAed bytes into a page, so the skb’shead/data/tailjust point at it andhead_frag = 1). Generic XDP is the exception: it runs later on an already-created skb (net/core/dev.c:5576). - Set
skb->protocolviaeth_type_trans(net/ethernet/eth.c:155) — it resetsmac_headerto point at the Ethernet header (skb_reset_mac_header), pulls the 14-byte Ethernet header offdatasodatanow points at the L3 header (eth_skb_pull_mac→skb_pullETH_HLEN), and returns the EtherType. Hold that thought: the value it returns becomes the demux key in Stage 3. - Pass to GRO: the driver calls
napi_gro_receive(napi, skb), astatic inlineininclude/linux/netdevice.h:4286that funnels intogro_receive_skb(&napi->gro, skb)(anddev_gro_receive) on the napi’s GRO accumulator. What GRO merges is the next section (Background 4).
Background 4: what GRO merges, and where 64KB lives
GRO (Generic Receive Offload) tries to merge consecutive segments of the same flow into one big skb before the stack sees it. A 64KB GRO superpacket means one trip up the stack instead of 40-something — you pay the route lookup, netfilter, and socket delivery once instead of per-segment.
Two prerequisites the bare phrase “merge same-flow segments” hides:
-
What counts as “the same flow”? For GRO it’s the L3/L4 identity: matching src/dst IP, protocol, and TCP ports, with in-order, contiguous sequence numbers and compatible flags.
gro_list_prepare(net/core/gro.c:355) builds the comparison key, and per-protocol callbacks (tcp4_gro_receive,net/ipv4/tcp_offload.c:419) make the final mergeability call. A packet with mismatched flags (PSH/FIN) or a sequence gap is not merged — it flushes the held skb and starts fresh. So GRO is comparing flow identity and sequence continuity, not just “two TCP packets.” -
How does 64KB fit without a 64KB allocation? It doesn’t grow a linear buffer — each merged segment’s payload is appended as page fragments onto the held skb. (Refresher only — this is exactly the linear-head +
MAX_SKB_FRAGSpage-fragment design from Day 1; a 64KB superpacket is a tiny linear header plus a chain of page frags, no contiguous 64KB ever allocated.)
The accumulator is napi->gro (a struct gro_node, include/linux/netdevice.h:358). It flushes — handing the assembled skb onward via netif_receive_skb — when any of these happen: NAPI budget/weight is exhausted, gro_normal_one is called (net/core/gro.c:299), a non-mergeable packet arrives, or the GRO flush timeout fires (__gro_flush, net/core/gro.c:324).
Code to read: net/core/gro.c — dev_gro_receive (the workhorse, :474), the exported gro_receive_skb, gro_list_prepare, gro_complete. Tracing caveat: napi_gro_receive itself is a static inline in include/linux/netdevice.h (:4286), so it is not fentry-traceable — attach to gro_receive_skb instead.

Background 5: packet_type demultiplexing
Stage 3 says __netif_receive_skb_core “calls each registered packet_type” and “pt_prev->func dispatches to the L3 handler.” To read that you need to know what a packet_type is, how the kernel picks ip_rcv out of a list, and what the pt_prev trick buys.
A packet_type is a registration record
When a protocol wants to receive packets, it registers a struct packet_type (include/linux/netdevice.h:2968):
struct packet_type {
__be16 type; /* EtherType, e.g. htons(ETH_P_IP) — the demux key */
/* ... */
int (*func)(struct sk_buff *, struct net_device *,
struct packet_type *, struct net_device *); /* the callback */
/* ... */
struct list_head list;
};
It pairs an EtherType (type) with a callback (func). IPv4 registers one in net/ipv4/af_inet.c:1881:
static struct packet_type ip_packet_type __read_mostly = {
.type = cpu_to_be16(ETH_P_IP),
.func = ip_rcv, // af_inet.c:1883
};
...
dev_add_pack(&ip_packet_type); // af_inet.c:2006
So ip_rcv isn’t called by name from the core — it’s looked up by EtherType. Where does that EtherType come from? eth_type_trans in Stage 2. It returned the frame’s EtherType into skb->protocol, and that value is the key the core hashes on now. That’s the connection Stage 2 set up.
Two lists: ptype_all and ptype_base
The kernel keeps protocol receivers in two structures:
ptype_all— receivers that want every packet regardless of type. This is howAF_PACKETsockets andtcpdumptap the path.ptype_base[]— a hash table keyed by EtherType (net/core/dev.c:172), where exactly-one-type receivers live:ip_rcv(ETH_P_IP), the IPv6 handler, ARP. The bucket isptype_base[ntohs(pt->type) & PTYPE_HASH_MASK](net/core/dev.c:608).
__netif_receive_skb_core walks ptype_all first (taps), then indexes ptype_base by skb->protocol to find the one real L3 handler.
The pt_prev deferral trick
Watch how the core dispatches. Instead of calling each matching handler immediately, it remembers the last match in a pointer pt_prev, and only actually invokes a handler when it discovers a subsequent taker exists. The invoke goes through deliver_skb (net/core/dev.c:2485):
static int deliver_skb(struct sk_buff *skb, struct packet_type *pt_prev,
struct net_device *orig_dev)
{
...
refcount_inc(&skb->users); // bump the descriptor refcount
return pt_prev->func(skb, skb->dev, pt_prev, orig_dev);
}
Why the indirection? Because deliver_skb does a refcount_inc(&skb->users) (the descriptor refcount from Day 1) for each receiver. In the overwhelmingly common case of exactly one receiver (just ip_rcv, no tcpdump running), the deferral lets the core call that single handler without the extra atomic bump — it knows there’s no other taker. This is precisely why Stage 3 dispatches via pt_prev->func() rather than calling ip_rcv directly.
Cross-link to Day 1: when there are multiple takers (e.g. tcpdump’s ptype_all socket and ip_rcv), deliver_skb gives each earlier taker another reference to the same skb descriptor by incrementing skb->users; it does not call skb_clone. The final taker consumes the original reference without that extra atomic increment.

Scope and batching beyond the global bucket

The two global structures are only the first layer. AF_PACKET adds scoped lists rather than making every socket another global protocol handler. ETH_P_ALL capture taps live in the receive network namespace’s ptype_all list or a device’s ptype_all; protocol-bound packet sockets can live in namespace/device ptype_specific lists. __netif_receive_skb_core visits all-protocol taps early, before ingress processing may redirect or change skb->dev. After ingress, VLAN, and any RX handler, it walks the global ethertype bucket and then the applicable namespace/original/current-device protocol-specific lists. ip_rcv is normally the final match from the global bucket.
The pt_prev deferral spans all of those walks. deliver_ptype_list_skb flushes the previous match with deliver_skb only when it discovers a subsequent one; deliver_skb increments skb->users before invoking that earlier callback. With one matching L3 handler and no tap, the final pt_prev reaches __netif_receive_skb_one_core, which dispatches it with INDIRECT_CALL_INET(..., ipv6_rcv, ip_rcv, ...) and avoids the extra reference operation entirely. Adding an ETH_P_ALL capture tap means the tap must be flushed before the later L3 consumer, which is one reason packet capture has measurable cost.
There is also a list receive path. IPv4 registers .list_func = ip_list_rcv alongside .func = ip_rcv. __netif_receive_skb_list_core classifies each skb through the same core, groups adjacent packets with the same final packet_type and original device, then calls that list callback. Handlers without one fall back to per-skb .func calls. The scalar deferral and list batching are two layers of the same optimization: avoid unnecessary references for one consumer, then preserve batches for a consumer that can process them together.
If no handler matches, pt_prev remains NULL, dev_core_stats_rx_dropped_inc records the unhandled protocol, and kfree_skb_reason releases the packet.
Stage 3: GRO → netif_receive_skb → __netif_receive_skb_core
When NAPI’s poll budget is exhausted (or gro_normal_one is called), accumulated GRO superpackets are flushed via netif_receive_skb:
int netif_receive_skb(struct sk_buff *skb) // net/core/dev.c:6454
Which calls into:
static int __netif_receive_skb_core(struct sk_buff **pskb, bool pfmemalloc,
struct packet_type **ppt_prev) // line 5972
This is the function that does most of the work, and with Background 5 in hand every line of it makes sense:
- VLAN/ingress hook handling (
__skb_pushto re-add VLAN if hardware-stripped). - Calls each registered
packet_type— walkptype_all(tcpdump’s AF_PACKET socket, AF_BRIDGE, etc.) then theptype_basebucket forskb->protocol. - tc ingress hooks run here (
tcx/tc-bpfingress). pt_prev->func()dispatches to the L3 protocol handler via the deferral trick.
For IPv4 packets, pt_prev->func is ip_rcv — registered statically in net/ipv4/af_inet.c as static struct packet_type ip_packet_type (Background 5).
Stage 4: ip_rcv and netfilter
int ip_rcv(struct sk_buff *skb, struct net_device *dev,
struct packet_type *pt, struct net_device *orig_dev) // net/ipv4/ip_input.c:603
{
struct net *net = dev_net(dev);
skb = ip_rcv_core(skb, net);
if (skb == NULL)
return NET_RX_DROP;
return NF_HOOK(NFPROTO_IPV4, NF_INET_PRE_ROUTING,
net, NULL, skb, dev, NULL,
ip_rcv_finish);
}
ip_rcv_core (net/ipv4/ip_input.c:499) does sanity checks (IP header length, version, checksum if not hw-validated) and trims the skb to the IP header’s claimed tot_len. Then NF_HOOK(NFPROTO_IPV4, NF_INET_PRE_ROUTING, ...) runs all netfilter chains on PREROUTING (this is where iptables/nftables/conntrack get involved). Day 20 covers netfilter in detail.
If the hooks pass the skb, ip_rcv_finish (net/ipv4/ip_input.c:478) is called. It does the route lookup (ip_route_input_noref → FIB lookup) and then dst_input(skb), which calls ip_local_deliver (net/ipv4/ip_input.c:250) for local sockets, or ip_forward for routed traffic.
Where BPF can attach

The kernel exposes BPF hook points at four positions along the RX path: XDP at the driver (Background 3 — pre-skb), tcx at __netif_receive_skb_core, fentry on ip_rcv itself, cgroup_skb after socket lookup. We won’t write any BPF today — this map is here so the attach points have a clear place in this path. Today’s labs use one-liner BPF tools (bpftrace) only as an inspection mechanism, not for authoring.
There are no Dumb Questions
Q: If raising a softirq just sets a bit, what actually runs
net_rx_action?A:
__do_softirq(kernel/softirq.c:654). It’s invoked on the way out of interrupt context (and a few other points), checks the per-CPU pending mask, and calls each pending vector’s action function. The top half only set theNET_RX_SOFTIRQbit and returned;__do_softirqdoes the draining moments later. If it has to restart more thanMAX_SOFTIRQ_RESTART(10) times, the rest is handed toksoftirqd.
Q: I keep seeing “budget.” Which budget — 64 or 300?
A: Both, at different scopes.
weight(64) is per single->pollcall — the cap passed into the driver.netdev_budget(300) is pernet_rx_actionsoftirq run, shared across all napis polled in that run. One softirq run can poll several queues; each individual poll claims at most 64, and the run stops once the cumulative 300 (or the time limit) is hit — bumpingtime_squeezeif work remains.
Q: Why does
__netif_receive_skb_coredispatch throughpt_prev->func()instead of just callingip_rcv?A: The
pt_prevdeferral (Background 5). It defers each delivery so it can skip an atomicskb->usersbump in the common single-receiver case.ip_rcvis reached by EtherType lookup inptype_base, not by name — the EtherType being the valueeth_type_transstored inskb->protocolback in the driver.
Q: An XDP program returned
XDP_PASS. What changed?A: Nothing yet —
XDP_PASSis the only action that lets the driver proceed to build ansk_buffand enter the normal stack. The other four (ABORTED/DROP/TX/REDIRECT) consumed the frame while it was still just anxdp_buffover the DMA page, with no skb ever allocated.
Today’s experiment
Trace a real packet’s path.
Use ftrace to see the call chain
trace-cmd is not installed by default — sudo apt-get install -y trace-cmd (it needs
CONFIG_FUNCTION_GRAPH_TRACER, on by default in typical kernels). This needs two terminals: the
recorder blocks for 5 seconds, and you must fire the packet during that window.
In terminal 1, start recording:
sudo trace-cmd record -p function_graph \
-g netif_receive_skb \
-e net:netif_receive_skb \
-O nofuncgraph-overhead \
-O funcgraph-tail \
sleep 5
In terminal 2, within those 5 seconds, generate one packet:
ping -c 1 8.8.8.8
After the recorder exits, render the trace:
sudo trace-cmd report | head -100
You’ll see the function-call tree: netif_receive_skb → __netif_receive_skb_one_core → __netif_receive_skb_core.constprop.0 → ip_rcv → ip_rcv_core → nf_hook_slow → ip_rcv_finish_core → ip_local_deliver → icmp_rcv. (The compiler appends a .constprop.N/.isra.N suffix to some of these symbols, and the outer ip_rcv_finish wrapper is inlined so what you actually see in the trace is ip_rcv_finish_core.) The leaf is icmp_rcv because a ping echo reply is an ICMP packet — icmp_rcv then calls icmp_echo. (Trigger a TCP flow instead — e.g. curl -s http://example.com >/dev/null — and the leaf becomes tcp_v4_rcv.) The pt_prev->func() dispatch (Background 5) goes through deliver_skb, but on this kernel deliver_skb is inlined into __netif_receive_skb_core so it does not show up as its own node in function_graph — don’t go hunting for it. To observe the dispatch directly, attach a kprobe instead: sudo bpftrace -e 'kprobe:deliver_skb { @[comm]=count(); }'.
Or use BPF for a custom view
sudo bpftrace -e '
fentry:ip_rcv { @ip[args->skb->dev->name] = count(); }
fentry:tcp_v4_rcv { @tcp[args->skb->dev->name] = count(); }
fentry:udp_rcv { @udp[args->skb->dev->name] = count(); }
interval:s:6 { exit(); }' &
# Generate receives during the window, then let it exit:
ping -c 5 -i 0.3 8.8.8.8 >/dev/null; curl -s http://example.com >/dev/null
wait
Per-protocol receive counts per interface. Typical output:
@ip[lo]: 4
@tcp[eth0]: 21
@udp[eth0]: 2
@udp[lo]: 4
@tcp/@udp are the reliable signal. Note the @ip map: on some virtual NICs (cloud/virtio)
fentry:ip_rcv attaches but may not fire for the physical interface — you may see only @ip[lo] (or
nothing) even with eth0 traffic flowing. On other boxes @ip[eth0] populates normally. Either way it’s a
tracing-environment quirk, not a missing-packet problem; trust @tcp[eth0]/@udp[eth0] to confirm receives
are happening.
Inspect the per-CPU RX state
cat /proc/net/softnet_stat
One line per CPU — these are the per-CPU softnet_data counters from Background 2. Every field is a 32-bit counter printed in hexadecimal (zero-padded %08x) with
no header line — don’t read the values as decimal. In order the columns are: packets processed,
dropped, time_squeeze (budget exhaustions), then several zeros, with received_rps near the end (exact
trailing columns are kernel-version-dependent — see net/core/net-procfs.c:145). Convert one to decimal with
printf '%d\n' 0x<value>. High time_squeeze means a softirq run kept hitting budget <= 0 (or the time
limit) with work still pending — i.e. your netdev_budget is too small for the load.
Adjust the budget. Set it (in its own step, so you can observe the box running at the new value):
old_budget=$(cat /proc/sys/net/core/netdev_budget)
echo 600 | sudo tee /proc/sys/net/core/netdev_budget
cat /proc/sys/net/core/netdev_budget # confirm it changed
Then, under sustained RX load (e.g. iperf3 -c <host> -P 16 from another box, or a packet flood),
re-read /proc/net/softnet_stat repeatedly and watch the time_squeeze column. Be honest with
yourself about what you’ll see: on an idle host time_squeeze never moves — it only increments when
a softirq actually exhausts its budget under heavy receive load, and even under load it can stay flat on
fast CPUs / multi-queue NICs. A non-moving counter is normal, not a sign the change failed (you already
confirmed the change with the cat above).
Restore the original budget so the host isn’t left with changed RX scheduling behavior:
echo "$old_budget" | sudo tee /proc/sys/net/core/netdev_budget
What to read in the kernel
kernel/softirq.c— the softirq engine.__do_softirq(line 654),raise_softirq_irqoff(line 773) →__raise_softirq_irqoff(line 799),open_softirq(line 806),run_ksoftirqd(line 1068), and#define MAX_SOFTIRQ_RESTART 10(line 544).net/core/dev.c— the central RX dispatch.____napi_schedule(line 4957) — adds topoll_list(line 4984), raisesNET_RX_SOFTIRQ(line 4990). (TheNAPI_STATE_SCHEDbit is set earlier, bynapi_schedule_prepat line 6736.)net_rx_action(line 7914) — the softirq loop; reads the 300 budget (line 7920).__napi_poll(line 7719) — per-NAPI poll; readsweightand calls->poll.__netif_receive_skb_core(line 5972) — the workhorse; the packet_type walk.netif_receive_skb(line 6454) — entry from drivers/GRO.deliver_skb(line 2485) — thept_prev->func()dispatch +usersbump.napi_complete_done(line 6771) — clears SCHED, returnstrueso the driver can re-arm its IRQ.
net/core/hotdata.c—netdev_budget = 300(line 14),netdev_budget_usecs(line 16).net/core/gro.c— GRO machinery. Readdev_gro_receive(line 474),gro_receive_skb,gro_list_prepare(line 355),gro_complete,__gro_flush(line 324). (napi_gro_receiveis astatic inlineinnetdevice.h:4286, not here — not fentry-traceable.)net/ipv4/ip_input.c— IPv4 receive.ip_rcv(line 603),ip_rcv_core(line 499),ip_rcv_finish(line 478),ip_local_deliver(line 250).net/ipv4/af_inet.c— searchip_packet_type(line 1881), see howip_rcvis registered viadev_add_pack(line 2006).include/linux/netdevice.h—struct napi_struct(line 381),NAPI_POLL_WEIGHT 64(line 2839),struct packet_type(line 2968),struct gro_node(line 358).include/linux/interrupt.h— the softirq vector enum (line 550).include/net/xdp.h—struct xdp_buff(line 86);include/uapi/linux/bpf.h—enum xdp_action(line 6548).
Bullet Points
- A softirq is a statically-defined, per-CPU bottom half. The top half (hardware IRQ) only acks the device and raises a pending bit;
__do_softirqlater runs the action. Networking’s RX vector isNET_RX_SOFTIRQ→net_rx_action. Overflow (>10 restarts) is punted toksoftirqdso user space can’t starve. - NAPI turns IRQ floods into one IRQ per burst:
napi_schedulesetsNAPI_STATE_SCHED+ queues the napi (the driver masks the IRQ in its handler);napi_complete_doneclearsSCHEDand returnstrueso the driver re-enables the IRQ when the ring drains. State machine driven by theNAPI_STATE_SCHEDbit. - Two budgets, not one:
weight(NAPI_POLL_WEIGHT= 64) caps one->pollcall;netdev_budget(300) caps the total across all napis in onenet_rx_actionrun.time_squeezecounts budget/time exhaustions with work still pending. - Native XDP runs on an
xdp_buff(4 stack pointers, no refcounts) before any skb exists; onlyXDP_PASSproceeds to build an skb.DROP/TX/REDIRECT/ABORTEDconsume the frame in-driver. build_skbwraps a pre-existing DMA buffer into an skb (zero-copy receive — recall Day 1).- GRO (
net/core/gro.c) coalesces consecutive same-flow segments (matching IP/proto/ports + contiguous sequence) into one skb whose payload is a chain of page frags (recall Day 1) — one trip up the stack instead of dozens. - A
packet_typepairs an EtherType with a callback;ip_rcvis registered asip_packet_typeand reached by hashingskb->protocol(the valueeth_type_transreturned).ptype_all= taps (tcpdump);ptype_base[]= the L3 hash table. Thept_prevdeferral skips an atomicusersbump in the single-receiver case. - The single most-touched RX function is
__netif_receive_skb_coreatnet/core/dev.c:5972. - After
ip_rcv→ netfilter PREROUTING →ip_rcv_finish→ routing →ip_local_deliverorip_forward.
Check question
Why does the kernel run softirqs (and thus most of the RX path) outside of hardware IRQ context?
Click to reveal answer
Answer: Hardware IRQ context has strict constraints: it preempts the running task, runs with limited stack, blocks other IRQs at the same priority, and cannot sleep. Doing the full RX path (route lookup, BPF programs, conntrack, packet delivery) in IRQ context would (1) starve other CPU work — receive livelock under high traffic; (2) impose a tight time budget that complex paths can’t meet; (3) force every helper called from the RX path to be IRQ-safe. Softirqs run at a slightly higher priority than user threads but lower than IRQs, with their own per-CPU stack, and can be preempted by IRQs. If softirq work itself becomes excessive, __do_softirq caps its restarts at MAX_SOFTIRQ_RESTART (10) and hands the rest to the ksoftirqd kernel thread, which competes fairly with user tasks — so RX processing can be intense but can’t indefinitely lock out user space. NAPI splits the RX work along this seam: the IRQ just signals “more work” (sets NAPI_STATE_SCHED, raises NET_RX_SOFTIRQ); the softirq does the actual processing under a budget.
Tomorrow
Day 3: the TX path. From sendmsg to the wire. Socket buffer accounting, queue disciplines, the driver’s ndo_start_xmit.
Day 3 — The TX path: from sendmsg to the wire
Today’s mission: trace a TCP packet from a userspace
send()to the moment it leaves the NIC — and meet the four structures that gate it on the way out: the socket and its send buffer, the windows that decide what may go now, the qdisc that schedules it, and the neighbour entry that finally stamps on a MAC address. Total time: ~110 minutes.
The journey, mirrored
Yesterday’s RX path went wire → driver → IP → socket. Today’s TX path goes the other direction:

Six layers of work between your send() syscall and the bytes hitting the wire. Each one has a job and a place to fail.
But there’s a deeper asymmetry, and it’s worth naming up front because it reshapes how you read the code. The RX path is skb-centric. The TX path is sock-centric.
On RX, a packet arrived out of nowhere — the NIC DMA’d some bytes into a page, the driver wrapped them in an sk_buff (Day 1), and that skb climbed the stack as a self-contained object. Nothing “owned” it until a socket claimed it at the very top.
On TX, the story starts the other way around. The bytes begin life inside a process that already has an open connection — a long-lived object that remembers the peer’s address, the sequence numbers, how much the network can take, how much the receiver will accept. That object is struct sock, and on the way out, every skb is something the sock produces and owns. So before we can read a single line of tcp_sendmsg, we need to meet the sock.
Stage 0: the connection object — struct sock
Day 1 taught you the sk_buff: one packet. Day 2 followed packets up the RX path. But neither ever introduced the thing that owns packets on a connection. That’s the gap we close first, because the entire TX path hangs off it.
Here’s the intuition. A TCP connection is not a packet — it’s a relationship that lives for seconds, minutes, or hours and carries thousands of packets. Somebody has to remember, between one send() and the next:
- who the peer is, and the route to reach it,
- the next sequence number to use,
- how many bytes are out on the wire unacknowledged,
- how much buffer the kernel is still allowed to spend on this connection,
- a chain of packets that are queued up but not yet (fully) sent.
That somebody is struct sock — “the sock.” Where an sk_buff is one envelope, a sock is the whole mailbox: a long-lived endpoint object that owns a list of skbs and all the bookkeeping around them.

The send-side fields we care about today all live in include/net/sock.h:
struct sk_buff_head sk_write_queue; /* include/net/sock.h:491 — the chain of queued skbs */
int sk_wmem_queued; /* include/net/sock.h:484 — memory charged for unfreed send-side skbs */
int sk_sndbuf; /* include/net/sock.h:526 — the cap on those bytes */
sk_write_queue is an sk_buff_head — the same doubly-linked list of skbs you saw on RX queues — except here it hangs off the socket and holds packets that TCP has built but not yet handed to the wire. tcp_sendmsg appends to its tail; tcp_write_xmit drains from its head.
How one sock reaches the right protocol code: the proto vtable
When userspace calls send(), the kernel has a generic struct sock in hand but needs to run TCP’s send logic — not UDP’s, not raw IP’s. It avoids a giant if (protocol == TCP) ladder with a classic trick: a vtable of function pointers.
Every sock carries sk->sk_prot, a pointer to a struct proto:
struct proto { /* include/net/sock.h:1291 */
int (*sendmsg)(struct sock *sk, struct msghdr *msg, ...); /* :1321 */
/* ...recvmsg, connect, close, ... */
};
For a TCP socket, sk_prot points at tcp_prot, whose .sendmsg slot is wired to tcp_sendmsg:
/* net/ipv4/tcp_ipv4.c:3353 */
.sendmsg = tcp_sendmsg,
So sock_sendmsg → inet_sendmsg → sk->sk_prot->sendmsg(...) lands in tcp_sendmsg with no branching. The exact same indirection reappears one layer down at L3: the connection keeps an icsk->icsk_af_ops->queue_xmit pointer (include/net/inet_connection_sock.h:36) that, for AF_INET sockets, is ip_queue_xmit. Keep this vtable picture in mind — “dispatch by pointer, not by if” is how the whole stack stays protocol-agnostic.
Stage 1: syscall to socket
When userspace calls send(fd, buf, len, 0) (or write(fd, ...) on a socket), the kernel walks:
sys_sendto / sys_write
→ sock_sendmsg
→ inet_sendmsg
→ sk->sk_prot->sendmsg // dispatch by protocol (the vtable above)
For TCP that’s tcp_sendmsg at net/ipv4/tcp.c:1447. The function locks the socket, then calls tcp_sendmsg_locked (line 1117) which is where the real work happens.
Stage 2: copy and queue

tcp_sendmsg_locked does two distinct things:
-
Copy bytes from userspace into kernel skbs. Allocate skbs via
tcp_stream_alloc_skb, copy data viacopy_from_iter(or, withMSG_ZEROCOPY, pin the user pages and point skb frags at them — no copy; recall the page-fragment design from Day 1). Append each skb tosk->sk_write_queue. -
Maybe trigger transmission. Calls
tcp_push(net/ipv4/tcp.c:741) which eventually invokestcp_write_xmit(line 2963 intcp_output.c).
The split matters: queueing is cheap; sending is gated. Appending to sk_write_queue just costs a copy and a list insert. Actually putting bytes on the wire requires the congestion window to be open, the receive window to permit it, Nagle constraints to be satisfied (all three explained in the next stage). So one send() may queue 1 MB of bytes but only transmit 64 KB right now — the rest sits in sk_write_queue waiting for ACKs.
The send buffer: sk_wmem_queued vs sk_sndbuf
If a socket could queue unbounded data, a fast writer talking to a slow network would balloon kernel memory without limit. So every sock has a send-buffer cap, and accounting to enforce it:
sk_wmem_queued— total memory (the sum ofskb->truesize) charged for every send-side skb the kernel hasn’t freed yet. That spans two queues: skbs still unsent insk_write_queueand skbs already transmitted but not yet ACKed, which in v7.1 have migrated to the separate retransmit queuetcp_rtx_queue(an rbtree). It’s freed only when the data is finally ACKed. Note this is a memory figure — it includes headroom and per-skb overhead, not just payload bytes.sk_sndbuf— the cap on that memory.
The check is a one-liner in the header:
static inline bool __sk_stream_memory_free(const struct sock *sk, int wake)
{ /* include/net/sock.h:1413 */
if (READ_ONCE(sk->sk_wmem_queued) >= READ_ONCE(sk->sk_sndbuf))
return false; /* buffer full → not free */
/* ... */
}
When the buffer is full, tcp_sendmsg_locked consults sk_stream_memory_free (net/ipv4/tcp.c:1248) and then either:
- blocks —
sk_stream_wait_memory(net/ipv4/tcp.c:1405) sleeps the calling thread until ACKs free space (default, blocking socket); or - returns
EAGAIN— for a non-blocking socket, so an event loop can come back later.

sk_sndbuf isn’t a fixed number — the kernel auto-tunes it per socket between the bounds of the net.ipv4.tcp_wmem (min, default, max) sysctl, growing it for connections that can productively use more. This is exactly the accounting ss -tim surfaces: the w value inside skmem:(...) is sk_wmem_queued (charged memory) and tb is sk_sndbuf. The Send-Q column is a separate quantity — write_seq - snd_una, the count of unacknowledged sequence (payload) bytes — closer in spirit to the in-flight/unsent backlog than to the memory counter. The two correlate but are measured in different units (payload sequence bytes vs charged memory, which includes per-skb overhead). Lab 2 makes them move.
Stage 2.5: what actually gates transmission
Stage 2 said sending is “gated” by three things. Those three terms — congestion window, receive window, Nagle — get a full treatment later (congestion control is Days 16–17), but you can’t read tcp_write_xmit today without a working picture, so here it is in one pass. The goal is narrow: understand why one send() can queue 1 MB yet transmit far less right now.
Think of the path to the peer as a pipe. Two separate limits decide how much may be “in the pipe” (sent but not yet acknowledged) at once — and, crucially, Linux measures them in different units and enforces them with two separate tests, never a single min():
-
Congestion window —
snd_cwnd(include/linux/tcp.h:225), measured in packets (MSS-sized segments — initial valueTCP_INIT_CWND= 10 packets, not a byte count like the textbook cwnd). The sender’s own estimate of how many segments the network path can hold in flight without dropping. Nobody negotiates it; the sender grows it as ACKs arrive and shrinks it on loss. It’s the sender protecting the network from itself. Enforced bytcp_cwnd_test(net/ipv4/tcp_output.c:2323): in-flight packets must be belowsnd_cwnd. -
Receive window —
snd_wnd(include/linux/tcp.h:223, commented “The window we expect to receive”), measured in bytes. The amount of buffer the peer advertised it can accept. This is flow control — it stops a fast sender from overrunning a slow receiver. Enforced bytcp_snd_wnd_test(net/ipv4/tcp_output.c:2380): the segment’s end sequence number must not passsnd_una + snd_wnd(tcp_wnd_end).
The rule that combines them:
A segment may go out only if it passes both independent gates: in-flight packets <
snd_cwnd(the congestion test, in packets) and its end sequence ≤snd_una + snd_wnd(the flow-control test, in bytes).
New data may go out only while there’s room under both tests. As ACKs return, in-flight drops, the gates open, and tcp_write_xmit releases more from sk_write_queue. The in-flight quantity the congestion test uses is tcp_packets_in_flight(tp) = packets_out − (sacked + lost) + retrans (include/net/tcp.h:1502). ss -tim’s unacked column (= tp->packets_out) approximates it; the two are equal only on a clean connection with no SACKed, lost, or retransmitted segments.
The third gate is Nagle’s algorithm. Intuition: if an app does many tiny writes (think a character at a time over SSH), naively each becomes its own minimum-size segment and floods the wire with “tinygrams,” mostly header. Nagle says: don’t send a new small segment while a previous small one is still unacknowledged — coalesce instead. Whether it’s disabled is recorded in tp->nonagle (include/linux/tcp.h:291); TCP_NODELAY sets it to “off” for latency-sensitive apps.
All three are applied in one loop:
/* net/ipv4/tcp_output.c:2963 */
static bool tcp_write_xmit(struct sock *sk, unsigned int mss_now, int nonagle, ...)
tcp_write_xmit walks sk_write_queue from the head and, for each skb, asks: does cwnd allow it? does snd_wnd allow it? does Nagle allow it? (And how big a TSO chunk to cut — Day 4.) It stops at the first skb the gates reject; the rest stay queued. The connection even records whether it was the cwnd that stopped it, in is_cwnd_limited (include/linux/tcp.h:234).

Forward reference: the mechanics of how cwnd grows and collapses (slow start, congestion avoidance, the CUBIC/BBR algorithms) are Phase 3, Days 13–19. Today you only need: queue is cheap, transmit is gated, and ACKs are what reopen the gate.
What stops one flow from drowning the qdisc: TCP Small Queues
Here’s a tension the staging above quietly creates. One send() can charge a megabyte of TCP write memory, while tcp_write_xmit may have a large congestion window available. Why doesn’t one bulk flow push that entire window into the qdisc and device queues at once? TCP Small Queues (TSQ) applies per-socket backpressure before each new transmit.

First separate two lifetimes that are easy to collapse into one “send queue”:
- A newly built data skb starts on
sk_write_queueand is charged tosk_wmem_queued. - After its first successful transmission,
tcp_event_new_data_sent()unlinks that original skb fromsk_write_queueand inserts it intotcp_rtx_queue, where TCP retains it until ACK/free for possible retransmission. Itssk_wmem_queuedcharge persists across that move;tcp_wmem_free_skb()removes the charge only when TCP releases the skb. tcp_transmit_skb()normally sends a clone of that retained TCP skb down through IP. The downstream clone is charged tosk_wmem_allocand getstcp_wfreeas its destructor. This second counter represents memory still held by qdisc/device-side references, not the bytes TCP retains in its write and retransmit queues.
TSQ uses the downstream counter as its gate:
- Before sending another skb,
tcp_small_queue_check()computesmin(max(2 * skb->truesize, sk_pacing_rate >> sk_pacing_shift), tcp_limit_output_bytes). With the usual shift of 10, the pacing term is about one millisecond of bytes, while the two-skb floor prevents tiny limits. Retransmit paths can multiply the limit. There is also an escape for an empty or single-entry retransmit tree so delayed completions cannot deadlock progress. - If
sk_wmem_allocremains above the limit, TSQ setsTSQ_THROTTLEDandtcp_write_xmitstops. Unsent originals stay onsk_write_queue; already-sent originals remain intcp_rtx_queue. Neither queue is the qdisc backlog. - When a downstream skb is freed,
tcp_wfree()removes its charge. If the socket was throttled, it queues the socket on a per-CPU BH workqueue so TCP can trytcp_write_xmitagain outside the qdisc-locking context.
The payoff is bounded per-flow occupancy below TCP: one socket cannot dump an arbitrary congestion window into qdisc and driver queues. That usually keeps latency much lower, but it is not a promise that the whole qdisc is empty — many flows can still build aggregate backlog, and some devices (notably Wi-Fi aggregation) need enough queued bytes for throughput. BBR’s deliberate pacing rate feeds the TSQ limit, while a pacing qdisc such as fq separately schedules when packets leave.
Stage 3: TCP header and the IP layer
tcp_transmit_skb builds the TCP header (sequence number, ACK, flags, window, checksum if not offloaded) on the skb being sent. (tcp_transmit_skb is a thin wrapper inlined into __tcp_transmit_skb, so that’s the name you’ll actually see in ftrace output.) Then it calls into IP via the L3 vtable slot we met in Stage 0:
icsk->icsk_af_ops->queue_xmit(...)
→ ip_queue_xmit // net/ipv4/ip_output.c:546
ip_queue_xmit builds the IP header (after a route lookup if the route isn’t cached), sets TTL/DSCP, then:
ip_local_out // net/ipv4/ip_output.c:125
→ __ip_local_out
→ NF_HOOK(NFPROTO_IPV4, NF_INET_LOCAL_OUT, ...)
→ dst_output
→ ip_output // net/ipv4/ip_output.c:428
→ NF_HOOK(NFPROTO_IPV4, NF_INET_POST_ROUTING, ...)
→ ip_finish_output
→ ip_finish_output2 // net/ipv4/ip_output.c:200
Two netfilter hooks on the way out
You already met the NF_HOOK machinery on RX (Day 2, Stage 4, where NF_INET_PRE_ROUTING fires inside ip_rcv; full netfilter treatment is deferred to Day 20). The TX path has its own two hook points — same mechanism, different positions:
NF_INET_LOCAL_OUT(include/uapi/linux/netfilter.h:46) fires in__ip_local_out, just after the IP header is set for locally-generated packets.NF_INET_POST_ROUTING(:47) fires inip_output, after routing, just before the device — the last place iptables/nftables can touch the packet.
No need to re-learn what a hook is; just place these two on the path.
The last L3 step: turning a next-hop IP into a MAC
ip_finish_output2 is where the packet finally gets a link-layer destination — and this is a subsystem no earlier chapter has introduced, so here’s the background you need.
Routing produced a next-hop IP address (the gateway, or the destination if it’s on-link). But the NIC can’t send to an IP — Ethernet frames are addressed by MAC address. Something must map next-hop-IP → MAC. That something is the neighbour subsystem: ARP for IPv4, NDP for IPv6. It resolves the mapping once and caches it in a struct neighbour, so the second packet to the same next hop doesn’t pay for resolution again.
Two outcomes when ip_finish_output2 calls neigh_output (include/net/neighbour.h:547):
-
Resolved (fast path). If the neighbour is reachable and has a cached hardware header,
neigh_hh_output(include/net/neighbour.h:507) just prepends the precomputed Ethernet header and sends. This is the moment the L2 header is finally pushed onto the skb — and it’s free becausetcp_stream_alloc_skb(net/ipv4/tcp.c:927) reservedMAX_TCP_HEADERheadroom up front (alloc_skb_fclone(MAX_TCP_HEADER)thenskb_reserve).MAX_TCP_HEADERbakes inMAX_HEADER/LL_MAX_HEADER— the link-layer header space — so there’s already room in front ofdatato push the Ethernet header with no reallocation. (This is the same principle as Day 1’s headroom reservation, but the TX-side constant isMAX_TCP_HEADER, not the RX allocators’ much smallerNET_SKB_PAD.) -
Unresolved (slow path).
neigh_resolve_output(include/net/neighbour.h:364) parks the skb on the neighbour’sarp_queueand fires an ARP request. The packet is sent later, when the reply arrives — or dropped if resolution times out. This is why a brand-new flow to a fresh next hop can briefly stall right here.

Forward reference: the neighbour subsystem in full — ARP/NDP state machine,
NUD_*states, garbage collection — is a Phase 2 topic (Days 6–12). Today you only need: this is where the MAC is resolved and the Ethernet header is built, and where a packet can stall.
Stage 4: the device queue and the qdisc
dev_queue_xmit (a wrapper for __dev_queue_xmit at net/core/dev.c:4766) is the boundary between L3 and the device layer. Three new ideas live here: an egress BPF hook, TX-queue selection, and the qdisc — the software packet scheduler. Let’s build them in the order the code hits them.

Step 1 — the egress BPF hook (one line)
First, __dev_queue_xmit runs sch_handle_egress (net/core/dev.c:4524, called at dev.c:4807), which runs any attached tcx / tc-bpf egress programs via tcx_run (dev.c:4439). This is simply the egress sibling of the tcx ingress attach point you saw on the RX path (Day 2, “Where BPF can attach”). It runs before queue selection and the qdisc. No new BPF to learn here.
Step 2 — pick a TX queue (multi-queue NICs)
A modern NIC doesn’t have one transmit path — it exposes many hardware TX queues so different CPUs can transmit in parallel without fighting over a single lock. Before touching any qdisc, __dev_queue_xmit must decide which TX queue (and therefore which root qdisc) this skb uses:
queue_index = netdev_pick_tx(dev, skb, sb_dev); /* net/core/dev.c:4736 */
netdev_pick_tx (net/core/dev.c:4691) chooses in priority order:
- an explicit
skb->queue_mappingif something upstream set one (e.g. XPS or socket steering) — that wins; - otherwise a flow hash, so every packet of one connection lands on the same queue and TCP’s ordering is preserved.

This is exactly why the Lab 3 output shows an mq root with one fq_codel leaf per hardware queue: one qdisc per TX queue.
Step 3 — the qdisc: what it is and why it exists
Here’s the new structure. Between the IP/device layer and the driver sits a queueing discipline (qdisc) — a per-TX-queue software FIFO-plus-scheduler. It exists because the driver/NIC can’t always take a packet this instant (the ring may be full, or you may want to rate-limit or fairly interleave flows). The qdisc is the kernel’s buffer-and-scheduling stage in front of the hardware: it can hold packets, reorder them, rate-limit them, and pick which flow goes next.
The whole qdisc abstraction comes down to two function pointers in struct Qdisc_ops:
int (*enqueue)(struct sk_buff *skb, struct Qdisc *q, /* include/net/sch_generic.h:314 */
struct sk_buff **to_free);
struct sk_buff *(*dequeue)(struct Qdisc *q); /* include/net/sch_generic.h:317 */
enqueue— the stack hands a packet in.__dev_queue_xmitcallsq->enqueue(skb, q, &to_free)on the chosen queue’s root qdisc.dequeue— the pump pulls the next packet out to give the driver.
The pump is qdisc_run → __qdisc_run (net/sched/sch_generic.c:440), a loop that calls q->dequeue, then sch_direct_xmit (net/sched/sch_generic.c:344), then netdev_start_xmit, then the driver — draining the queue as fast as the device will accept.
So the per-queue stats tc -s qdisc prints map directly onto this machinery:
- backlog — packets currently sitting in the qdisc, enqueued but not yet dequeued.
- drops — packets the qdisc threw away (queue full, or an active-queue-management algorithm like CoDel decided to).
- requeues — packets handed back because the driver returned
NETDEV_TX_BUSY(ring full); the qdisc holds them to retry.
On an idle box all three read 0. That’s not a bug — it means nothing is backing up. Lab 4 forces backlog non-zero on purpose so you can watch it move.
Default qdisc: built-in vs configured
There’s a subtlety worth getting right. The kernel’s compiled-in default qdisc is pfifo_fast — a simple 3-band priority FIFO:
/* net/sched/sch_generic.c:37 */
const struct Qdisc_ops *default_qdisc_ops = &pfifo_fast_ops;
But most distributions change the runtime default to fq_codel (fair-queuing with CoDel AQM) via the net.core.default_qdisc sysctl, handled by set_default_qdisc (net/core/sysctl_net_core.c:595). So:
Default qdisc on most modern systems is
fq_codel(selected via thenet.core.default_qdiscsysctl; the kernel’s built-in default is stillpfifo_fast).
Day 23 covers qdiscs in detail.
The steps inside __dev_queue_xmit, summarized
- tcx/tc-bpf egress hook runs first (before TX queue selection and the qdisc lookup).
- Pick a TX queue.
netdev_pick_txuses the socket’s cached TX queue (sk_tx_queue_get), then XPS (get_xps_queue), then a flow hash (skb_tx_hash). Modern NICs have many TX queues for parallelism. - Find the root qdisc on that queue (
txq->qdisc). - Enqueue:
q->enqueue(skb, q, &to_free). - Pump:
qdisc_run→__qdisc_run→q->dequeue→sch_direct_xmit→netdev_start_xmit→ driver’sndo_start_xmit.
Stage 5: driver and hardware
netdev_start_xmit(skb, dev, txq, more) (include/linux/netdevice.h:5371) calls dev->netdev_ops->ndo_start_xmit(skb, dev) (include/linux/netdevice.h:1441). Each driver implements this differently — but fundamentally it’s the mirror of Day 1’s RX descriptor ring, run in reverse: build a DMA descriptor, write it into the TX ring, ring the NIC’s doorbell register. The NIC’s DMA engine then reads the bytes straight out of the skb’s pages and puts them on the wire (recall: zero-copy, the payload pages are referenced, not copied).
Return value:
NETDEV_TX_OK(include/linux/netdevice.h:135) — submitted to the NIC.NETDEV_TX_BUSY(include/linux/netdevice.h:136) — the TX ring is full, so the driver refuses the skb; the qdisc holds it and retries (this is the requeues counter from Stage 4).
There are no Dumb Questions
Q: Where does TSO/GSO fit into this?
A: Day 4. Briefly: TSO (TCP Segmentation Offload) lets the kernel hand a 64KB skb to the NIC, and the NIC chops it into MSS-sized packets. GSO (Generic Segmentation Offload) does the same in software when the NIC doesn’t support TSO. Both happen late — at the qdisc/driver boundary, not during
tcp_sendmsg.Q: Why does the kernel sometimes block sendmsg vs return EAGAIN?
A: Socket flag. Default sockets block (sleep until space) — that’s
sk_stream_wait_memoryfrom Stage 2. Non-blocking sockets (O_NONBLOCK) returnEAGAINinstead.epoll-based servers use non-blocking +EPOLLOUTnotifications to know when the send buffer has drained enough to retry.Q: What about MSG_ZEROCOPY?
A: A flag for
send(). The kernel pins the user pages, points skb fragments at them directly (no copy intosk_write_queue— the frags reference the user pages), and notifies userspace via the socket’s error queue when transmission completes. Useful for very large transfers; userspace must hold the buffers until the notification.
Today’s experiment
Lab 1 — Trace a TCP send all the way through
Run the trigger inside the recorded command so the send is guaranteed to fire during the capture window — no second terminal, no race against sleep:
sudo trace-cmd record -p function_graph \
-g tcp_sendmsg \
-O nofuncgraph-overhead \
-O funcgraph-tail \
bash -c 'echo hello | nc -q 1 example.com 80; sleep 1'
sudo trace-cmd report | head -200
Prerequisite: this needs outbound TCP reachability and a completed handshake during the capture — any host you can actually reach works.
example.com:80reliably accepts the connection;8.8.8.8:80is filtered on many networks, so the handshake never completes and the payloadtcp_sendmsgnever fires.-q 1is a BSD/traditionalncflag — on nmap’sncat, drop it (usenc example.com 80).
You’ll see the call tree from tcp_sendmsg down through tcp_write_xmit, tcp_transmit_skb, ip_queue_xmit, eventually dev_hard_start_xmit, then the driver’s xmit. trace-cmd is global, so unrelated sends (e.g. background sshd) may appear too — your nc send is the one that ends in the full ip_queue_xmit → ... → dev_hard_start_xmit chain.
Lab 2 — Watch socket buffer accounting
On an idle box ss -tim shows only your SSH session, with the buffer/window counters static and near zero — the send-buffer accounting from Stage 2 is invisible until something is actively transmitting. So generate a sustained send first, then snapshot:
# Sustained upload so the send buffer actually has bytes in flight:
curl -s -o /dev/null -T /dev/zero --max-time 4 https://speed.cloudflare.com/__up &
ss -tim # watch the uploading socket while curl runs
Per-socket you get: send buffer used, congestion window, RTO, retransmits. ss does not print a field literally named wmem_queued; it surfaces the charged send-side memory (the sk_wmem_queued from Stage 2) as the w value inside skmem:(...). The Send-Q column is a different quantity — write_seq - snd_una, unacknowledged payload sequence bytes — which tracks the same backlog but in payload bytes rather than charged memory. While curl runs, the uploading socket (to :https, not your idle SSH session) shows a large Send-Q and skmem w, plus cwnd, unacked, and pacing_rate:
ESTAB 0 2765014 10.0.0.4:36872 162.159.140.220:https
skmem:(r0,rb131072,t0,tb4194304,f2858,w2811094,o0,bl0,d0) cubic wscale:13,10
... cwnd:1950 ... unacked:266 ... pacing_rate 1.42Gbps delivery_rate 120Mbps
unacked (= tp->packets_out) is segments sent but not yet ACKed — it approximates the in-flight quantity from Stage 2.5 that the congestion test bounds (the exact figure is tcp_packets_in_flight(); the two diverge under loss/SACK). The skmem w value is the memory charged for not-yet-freed send-side skbs (sk_wmem_queued). Those are exactly the Stage 2 / Stage 2.5 quantities the check question asks you to compare. (Your cc may read bbr instead of cubic depending on net.ipv4.tcp_congestion_control.) If you have no internet egress, a local sink works but won’t fully exercise the buffer cap (loopback has no bottleneck, so it drains as fast as it fills).
Lab 3 — Inspect qdisc statistics
tc -s qdisc show dev eth0
Drops, requeues, current backlog — the three counters from Stage 4. On an idle box every one of these reads 0 — that is expected, not a failure:
qdisc mq 0: root
Sent 1279377437 bytes 1467116 pkt (dropped 0, overlimits 0 requeues 0)
backlog 0b 0p requeues 0
qdisc fq_codel 0: parent :1 limit 10240p flows 1024 quantum 1514 ...
Sent 895133146 bytes 775553 pkt (dropped 0, overlimits 0 requeues 0)
backlog 0b 0p requeues 0
(On a multi-queue NIC the root is mq with one fq_codel leaf per hardware TX queue — exactly the netdev_pick_tx fan-out from Stage 4, step 2 — so you’ll see several stanzas.) The next experiment forces backlog non-zero so you can actually watch it move.
Lab 4 — Force a backlog and watch
Warning: this throttles all egress on
eth0. If you are connected over that interface, your SSH/management traffic competes with the test transfer for the same 1mbit and the session may stall — run this on a throwaway VM or a non-management NIC.
First capture the qdisc that is actually there so you can restore it exactly (the live default here is mq, not fq_codel), then apply the rate limit:
ORIG=$(tc qdisc show dev eth0 | awk 'NR==1{print $2}') # remember the real default
sudo tc qdisc replace dev eth0 root tbf rate 1mbit burst 32kbit latency 50ms
# now egress is rate-limited; large transfers back up at the qdisc
Drive traffic that actually leaves eth0. The backlog only grows for traffic egressing this device — a 127.0.0.1 transfer goes through the lo qdisc and shows backlog 0b 0p, so loopback is not a substitute. On a second machine run iperf3 -s, then here:
iperf3 -c <that-host-IP> -t 30 &
watch -n1 'tc -s qdisc show dev eth0' # the `backlog ...b ...p` line climbs
No second machine or iperf3? Any sustained upload over eth0 backs up the qdisc the same way:
curl -s -o /dev/null -T /dev/zero --max-time 20 https://speed.cloudflare.com/__up &
watch -n1 'tc -s qdisc show dev eth0'
Restore (drop back to whatever was really there, and stop the transfer):
kill %1 2>/dev/null # stop the backgrounded transfer
sudo tc qdisc del dev eth0 root # restores the kernel/runtime default
# or, to put back exactly what you captured: sudo tc qdisc replace dev eth0 root "$ORIG"
What to read in the kernel
include/net/sock.h—struct socksend-side fields:sk_write_queue(491),sk_wmem_queued(484),sk_sndbuf(526); thestruct protovtable (1291) and itssendmsgslot (1321);__sk_stream_memory_free(1413).net/ipv4/tcp.c—tcp_sendmsg(line 1447),tcp_sendmsg_locked(line 1117),tcp_push(741), the send-buffer wait at 1248/1405. The core of TCP user-side semantics.net/ipv4/tcp_output.c—tcp_write_xmit(line 2963),tcp_transmit_skb. Decides what to send when, applying cwnd/snd_wnd/Nagle.include/linux/tcp.h—snd_wnd(223),snd_cwnd(225),is_cwnd_limited(234),nonagle(291).net/ipv4/ip_output.c—ip_queue_xmit(line 546),ip_local_out(line 125),ip_output(428),ip_finish_output2(200) and itsneigh_outputcall (237).include/net/neighbour.h—neigh_output(547),neigh_hh_output(507),neigh_resolve_output(364).net/core/dev.c—__dev_queue_xmit(line 4766),sch_handle_egress(4524),netdev_pick_tx(4691), the qdisc dance.net/sched/sch_generic.c—__qdisc_run(440),sch_direct_xmit(344),pfifo_fast_ops(942), the qdisc pump; thedefault_qdisc_ops = &pfifo_fast_opsline (37).include/net/sch_generic.h—Qdisc_ops.enqueue(314) /dequeue(317).include/linux/netdevice.h—ndo_start_xmit(line 1441),netdev_start_xmit(5371),NETDEV_TX_OK/NETDEV_TX_BUSY(135–136).Documentation/networking/scaling.rst— how multi-queue TX works.
Bullet Points
- TX is sock-centric where RX was skb-centric: a
struct sockis the long-lived connection that owns a chain of skbs; ansk_buffis just one packet. - TX path:
sendmsg → tcp_sendmsg → tcp_write_xmit → tcp_transmit_skb → ip_queue_xmit → dev_queue_xmit → qdisc → driver → wire. - Protocol dispatch is a vtable:
sk->sk_prot->sendmsgistcp_sendmsg;icsk->icsk_af_ops->queue_xmitisip_queue_xmit. Noif-ladder. sk_write_queueholds queued-but-not-yet-transmitted skbs; after transmission, TCP retains the originals intcp_rtx_queueuntil ACK/free.sk_wmem_queuedspans both queues and counts their charged memory (skb->truesize) against thesk_sndbufcap. Full buffer ⇒ block (sk_stream_wait_memory) orEAGAIN. Auto-tuned bytcp_wmem.tcp_write_xmitdecides what to send now based on cwnd, snd_wnd, Nagle, TSO size. A segment goes out only if it clears two independent gates: in-flight packets <snd_cwnd(congestion, in packets —tcp_cwnd_test) and its end sequence ≤snd_una + snd_wnd(flow control, in bytes —tcp_snd_wnd_test). Linux’ssnd_cwndis in MSS-sized packets, so the two can’t bemin()’d. ACKs reopen the gates. (Full congestion control: Days 16–17.)- Two netfilter hooks on TX:
NF_INET_LOCAL_OUTandNF_INET_POST_ROUTING. - Neighbour resolution (ARP/NDP) happens in
ip_finish_output2: resolved ⇒neigh_hh_outputprepends the cached Ethernet header (using Day 1’s headroom); unresolved ⇒ skb parked onarp_queue+ ARP sent. (Full neighbour subsystem: Phase 2.) __dev_queue_xmitruns tcx/tc-bpf egress, picks a TX queue (netdev_pick_tx), then enqueues to that queue’s root qdisc.- A qdisc is a per-TX-queue software FIFO+scheduler with
enqueue/dequeue;__qdisc_runpumps it to the driver. backlog/drops/requeues are its counters. Multi-queue NICs show anmqroot with anfq_codelleaf per queue. - Default qdisc on modern Linux is
fq_codel(vianet.core.default_qdisc); the built-in default is stillpfifo_fast. - Driver’s
ndo_start_xmitis the final hand-off: write the TX-ring descriptor, ring the doorbell, NIC DMAs out.NETDEV_TX_BUSY= ring full ⇒ qdisc requeues.
Check question
You call send(fd, buf, 1MB, 0) on a TCP socket whose RTT is 100ms and whose BDP — bandwidth-delay product, the bandwidth times the round-trip time, i.e. the most data that can usefully be in flight at once — is much smaller than 1MB. The send returns immediately with the full 1MB written. Where are those bytes physically right now?
Click to reveal answer
Answer: In several send-side stages, not necessarily all in one queue. Bytes not yet transmitted remain in sk_write_queue; originals already transmitted but not ACKed sit in tcp_rtx_queue; transient transmit clones may be below TCP in IP/qdisc/device queues or on the wire. Because the BDP is small, most of the 1 MB will usually still be unsent when send() returns, but the exact split can change concurrently. The call returned because sk_sndbuf was big enough to accept the charged memory — not because the bytes left the box. If you read /proc/<pid>/status you’d see that memory in kernel TCP accounting, not in the process RSS. To inspect the split, run ss -tim: unacked counts sent-but-unacknowledged segments, while skmem w reports sk_wmem_queued across TCP’s retained send-side skbs. Send-Q is the related-but-distinct write_seq - snd_una payload-byte count.
Tomorrow
Day 4: GRO, GSO, TSO. Why a 64KB packet enters ip_rcv even though Ethernet’s MTU is 1500.
Day 4 — GRO, GSO, TSO: segmentation offloads
Today’s mission: understand why a single skb that enters
ip_rcvmay represent 40+ wire packets, and why the kernel writes one 64 KB skb on TX. You’ll meet the segmentation unit (MSS), the three-field GSO marker the kernel stamps into every big skb, and the checksum contract that lets hardware finish the job — then watch all of it move under bpftrace. Total time: ~110 minutes.
The problem
Ethernet has an MTU of 1500 bytes. Most NICs can handle up to ~9000 bytes (jumbo frames) but not more. So one TCP connection sending a gigabyte through the kernel produces ~700,000 wire packets.
If the kernel ran its full TCP send code path (queue, build header, route lookup, qdisc, driver — the entire Day 3 journey) once per wire packet, the per-packet overhead would crush throughput. Same on RX: 700,000 calls up the Day 2 receive path is too many.
Three offload technologies move this work to either hardware or batched software so the stack runs at aggregate rate, not per-segment rate.

But before any of them make sense, you need to know the size of the chunk they all count in. The intro promised “40+ wire packets from one skb” — that number isn’t magic, it’s arithmetic, and the unit underneath it is the MSS.
Background 1: MTU, MSS, and the segmentation unit
You already know MTU — Day 3’s TX path ended at the wire, and the intro above names it: the largest L2 payload the link will carry, 1500 bytes on standard Ethernet. That’s the cap on one frame’s contents.
But TCP doesn’t think in frames; it thinks in segments. So there’s a second number, derived from the MTU, that every offload in this chapter actually counts in: the MSS — Maximum Segment Size. It’s the largest TCP payload one segment may carry, and it’s just the MTU with the headers subtracted:
MSS = MTU − IP header − TCP header
= 1500 − 20 − 20
= 1460 bytes (no-options IPv4)
That’s it. One Ethernet frame holds 1500 bytes of L2 payload; of those, 20 go to the IP header and 20 to the TCP header, leaving 1460 bytes of actual data. So the wire packet a full segment becomes is 1460 (data) + 20 (TCP) + 20 (IP) + 14 (Ethernet) = 1514 bytes on the wire.
Two things make MSS less of a constant than it looks:
- It’s per-connection, not global. MSS is negotiated at SYN time (each side advertises what it can receive) and recomputed when the path MTU changes (PMTU discovery). A connection over a tunnel, or one that hit an ICMP “fragmentation needed,” runs a smaller MSS. So there is no single system-wide MSS — each connection carries its own.
- The kernel stores the value the segmenter must use, per-skb. Because MSS is per-connection and can change mid-flight, the segmentation step does not recompute “MTU minus headers” on the fly. The TCP TX path writes the MSS that applies to this skb into the skb itself, and the segmenter reads it back out. (That stored field is
gso_size— Background 2.)
The arithmetic the whole chapter rests on
Now do the calculation once, because it’s the multiplier behind every histogram in today’s lab. The kernel builds one big skb carrying up to ~64 KB of payload (we’ll pin down that bound in a moment). At MSS 1460:
65536 bytes / 1460 bytes-per-segment ≈ 44.9 → ceil = 45 segments
So one 64 KB skb becomes ~44 wire packets — and the stack ran its TX path once to produce all of them. That is the “40+ wire packets from one skb” the intro promised, and the coalescing ratios you’ll watch under bpftrace are this number running in reverse on RX.
Where does the per-skb MSS live, and where does the 64 KB ceiling come from? Two anchors:
- The segmenter reads the per-skb MSS via
tcp_skb_mss()(include/net/tcp.h:1214), which simply returnsTCP_SKB_CB(skb)->tcp_gso_size— the value TCP stashed in the skb’s control block. - The “one big skb” is bounded by
GSO_LEGACY_MAX_SIZE = 65536u(include/linux/netdevice.h:2446) andGSO_MAX_SEGS = 65535u(netdevice.h:2445). That 65536 is the “64 KB” this chapter quotes everywhere.
Keep MTU and MSS straight. This chapter switches between “MTU-sized skbs” (what GSO outputs) and “MSS-sized chunks” (how TSO is described) within a couple of paragraphs. They name the same wire packet from two ends: an MSS-sized payload (1460 B) wrapped in headers is an MTU-sized frame (1500 B of L2 payload). When you read “MSS-sized chunk,” picture the 1460 bytes of data; when you read “MTU-sized skb,” picture the same thing plus its IP/TCP headers.

Background 2: the GSO marker — gso_size, gso_segs, gso_type
Here is the structure the whole chapter quietly depends on. When the kernel decides an skb should be segmented later, it doesn’t change the skb’s shape — it just leaves a three-field note in the one place every layer of the stack already carries: the skb_shared_info trailer you met on Day 1 (the thing that sits past the linear buffer’s end and holds nr_frags, frags[], frag_list).
Day 1 taught those fragment fields. It did not teach the three GSO fields living in the same struct. They are:
struct skb_shared_info {
/* ... nr_frags, frags[] from Day 1 ... */
unsigned short gso_size; /* include/linux/skbuff.h:598 — MSS each segment carries */
unsigned short gso_segs; /* skbuff.h:600 — how many segments this becomes */
struct sk_buff *frag_list; /* skbuff.h:601 — the Day 1 frag-list chain */
unsigned int gso_type; /* skbuff.h:606 — bitmask naming the protocol */
};
Read them as a recipe:
gso_size= the MSS each output segment should carry (1460 in our example). This is the per-skb MSS from Background 1 — the value the segmenter reads instead of recomputing MTU minus headers.gso_segs= how many segments this one skb will become (~44). This is the number today’s “What to break/observe” bullets tell you to multiply by when you want true per-wire-packet counts.gso_type= a bitmask naming the protocol, so the right segmentation callback is chosen. The bits are defined inskbuff.h:SKB_GSO_TCPV4 = 1 << 0(skbuff.h:669),SKB_GSO_DODGY = 1 << 1(skbuff.h:672, “this came from an untrusted source, validate it”),SKB_GSO_TCPV6 = 1 << 4(skbuff.h:679).
So when the prose says an skb is “marked with SKB_GSO_TCPV4,” it means gso_type has that bit set. And when later text says the path “detects the GSO marker,” the literal test is skb_is_gso(skb) (skbuff.h:5267), which in v7.1 returns skb_shinfo(skb)->gso_size — a non-zero gso_size is “this skb wants segmenting.”
Who writes the marker
The TCP transmit path stamps it, right before handing the skb down the Day 3 path. In tcp_transmit_skb:
skb_shinfo(skb)->gso_segs = tcp_skb_pcount(skb); /* net/ipv4/tcp_output.c:1704 */
skb_shinfo(skb)->gso_size = tcp_skb_mss(skb); /* tcp_output.c:1705 */
That’s the key move. TCP fills in gso_size = MSS and gso_segs = segment count, and then the same struct rides unchanged through ip_queue_xmit → __dev_queue_xmit → qdisc → driver, exactly the journey Day 3 described. The skb carries its own segmentation recipe the whole way down; nothing in between has to understand it.
Who reads the marker, and the TSO-vs-GSO switch
The decision happens at the very bottom of the Day 3 TX path, in validate_xmit_skb() (net/core/dev.c:4019), called from the qdisc dequeue / sch_direct_xmit path right before the driver. It asks one question — netif_needs_gso(skb, features) (dev.c:4036):
- NIC can segment this
gso_typein hardware (TSO). The big skb is handed to the driver’sndo_start_xmitas-is — hardware does the split.netif_needs_gsoreturns false; nothing else happens. - NIC can’t (GSO). The kernel calls
skb_gso_segment()(dev.c:4039) to split the skb in software before the driver sees it.
This is the single switch that unifies the three technologies this chapter presents as separate: gso_type + NIC capability. It’s the same big skb either way — the only question is whether hardware or software does the split. Hold that idea; everything below is a special case of it.

Zoom in on that fork — the big skb’s GSO marker is the only thing that decides hardware-vs-software:

Background 3: ip_summed and checksum offload
There’s one more contract a big skb carries, and the checksum Q&A at the end of this chapter leans on it entirely: skb->ip_summed, which records who is responsible for the checksum — the stack or the NIC. Day 2 mentioned “checksum if not hw-validated” inside ip_rcv_core but never defined the states, so here they are. There are exactly four (include/linux/skbuff.h:248–251):
#define CHECKSUM_NONE 0 /* nobody has computed/verified it */
#define CHECKSUM_UNNECESSARY 1 /* RX: NIC already verified — stack may skip */
#define CHECKSUM_COMPLETE 2 /* RX: NIC handed up a raw sum for the stack to fold in */
#define CHECKSUM_PARTIAL 3 /* TX: stack wrote the pseudo-header sum; NIC finishes it */
The kernel’s own header documents this contract in a long comment block (skbuff.h:98–136). Two of these states are RX-side (UNNECESSARY, COMPLETE) — the NIC telling the stack what it already checked. The one that matters for segmentation is the TX-side CHECKSUM_PARTIAL.
Why offloads require CHECKSUM_PARTIAL
Think about it: a GSO/TSO skb has no per-segment checksums yet — they can’t exist, because the segments themselves don’t exist until the split happens. So what does the stack put in the checksum field of the one big skb?
It puts the pseudo-header checksum (the part computed over the IP addresses, protocol, and length — everything except the payload), and sets ip_summed = CHECKSUM_PARTIAL. That state is a promise: “the checksum is half-done; whoever splits this skb will finish each segment’s checksum over its payload.” For TSO the NIC finishes it; for GSO the software segmenter finishes it during the split. The header spells out this coupling directly: if gso_type is SKB_GSO_TCPV4/V6, TCP checksum offload is implied, and ip_summed is CHECKSUM_PARTIAL (skbuff.h:239–242).
This is exactly why the TSO-vs-GSO switch from Background 2 also reads ip_summed. Look again at netif_needs_gso (include/linux/netdevice.h:5480):
return skb_is_gso(skb) && (!skb_gso_ok(skb, features) ||
unlikely((skb->ip_summed != CHECKSUM_PARTIAL) &&
(skb->ip_summed != CHECKSUM_UNNECESSARY)));
A GSO skb whose checksum is not PARTIAL (or UNNECESSARY) can’t be safely handed to hardware as-is, so it’s forced down the software segmentation path. Hardware-capability and the checksum contract together pick TSO over GSO.
And it’s why the segmenter re-initialises the checksum field per segment. __skb_gso_segment() (net/core/gso.c:88) calls skb_cow_head() precisely so it can write a fresh ->check field into each new TCP/UDP header (gso.c:97, “We’re going to init ->check field in TCP or UDP header”): the original skb carried only the partial (pseudo-header) sum, so every segment needs its real checksum finished. That’s the concrete reason the Q&A below tells you to read __skb_gso_segment when you debug a checksum issue.
One more parallel: checksum offload is itself an ethtool feature (
rx-checksumming/tx-checksumming), sitting right next to the segmentation features today’s lab toggles. They interact — disabling tx-checksumming can disturb segmentation, because a non-PARTIALskb gets pushed off the TSO path.

TSO — TCP Segmentation Offload
Now TSO reads as a special case of the switch. The kernel hands the NIC one large skb (up to 64 KB — GSO_LEGACY_MAX_SIZE) whose gso_type has SKB_GSO_TCPV4 (or SKB_GSO_TCPV6) set and whose ip_summed is CHECKSUM_PARTIAL. Because the NIC’s feature flags say it can segment that gso_type, netif_needs_gso returns false and the big skb goes straight to ndo_start_xmit. The NIC’s hardware then:
- Reads the skb’s TCP header as a template.
- Walks the payload in MSS-sized chunks (=
gso_sizebytes each — Background 1). - For each chunk, builds an IP+TCP header (cloning the template, advancing the TCP sequence number, incrementing the IP id, finishing the checksum the
CHECKSUM_PARTIALpromise left half-done), prepends the Ethernet header, transmits. (Fields like TTL are copied straight from the template — every segment of one flow carries the same TTL; only seq, IP id, length, and checksum change per segment.)
Result: ~44 wire packets, one kernel call to ndo_start_xmit. Per-packet stack overhead drops ~44×.
Enable/check:
ethtool -k eth0 | grep tcp-segmentation-offload
# tcp-segmentation-offload: on
ethtool -K eth0 tso off
(The feature is named tcp-segmentation-offload, not tso — grepping for tso matches nothing.)
NIC must support it (most modern NICs do).
GSO — Generic Segmentation Offload
Same big skb, same marker — but the NIC can’t segment this gso_type, so the split happens in software, late in the TX path. Recall the qdisc → ndo_start_xmit boundary from Day 3: the big GSO skb rides that path unchanged, and segmentation is bolted onto the very end of it.
- TCP builds one big skb just like for TSO, stamping
gso_size/gso_segs/gso_type(Background 2). - The skb travels down through
tcp_transmit_skb,ip_queue_xmit,dev_queue_xmitas if it were a single packet — exactly the Day 3 journey. - Just before
ndo_start_xmit, invalidate_xmit_skb()(net/core/dev.c:4019),netif_needs_gso()(dev.c:4036) sees the NIC can’t do it and callsskb_gso_segment()(dev.c:4039) →__skb_gso_segment(net/core/gso.c:88), which splits the skb into a chain of MTU-sized skbs (the L2 split runs throughskb_mac_gso_segment,gso.c:37; the TCP-specific callback istcp_gso_segment,net/ipv4/tcp_offload.c:133). - The driver receives the chain and transmits each.
GSO is universal — works on any NIC. The CPU cost of segmentation is real but smaller than running the full stack per packet.
GRO — Generic Receive Offload
The receive-side counterpart. Recall the NAPI poll loop and the napi_gro_receive → gro_receive_skb funnel from Day 2: GRO runs inside the driver’s NAPI poll, after XDP_PASS turns DMA bytes into an skb and before the stack entry. (Day 2 also established why you trace gro_receive_skb and not napi_gro_receive — the latter is a static inline in include/linux/netdevice.h:4286, not fentry-traceable; the exported entry is gro_receive_skb at net/core/gro.c:636, and the workhorse is dev_gro_receive at gro.c:474.)
The GRO engine compares each new skb against a list of “in flight” same-flow skbs. If it can merge (consecutive sequence numbers, same flow tuple, no flag changes), it appends payload to the existing one as page fragments (skb_gro_receive, gro.c:92 — never by growing the linear data buffer). Recall the linear-head + page-frags model from Day 1: a 64 KB GRO superpacket is a tiny linear header plus a chain of page frags, never a 64 KB contiguous allocation. (There’s a second assembly form — skb_shared_info.frag_list (skbuff.h:601, tested by skb_has_frag_list() at skbuff.h:4206) chains whole skbs rather than the frags[] page array; some GSO/frag-list paths use it. One line is enough — the frags[] model is the one you’ll meet most.)
Result: one ip_rcv call for what was ~44 wire packets.
Flush triggers:
- Different flow arrives.
- Timeout (per-device
/sys/class/net/<dev>/gro_flush_timeout, or per-NAPI via netlink). - NAPI poll exits — the GRO accumulator lives per-NAPI (
napi->gro), so it must drain before the softirq yields the CPU. - Special flag (FIN, RST, PSH).
There are no Dumb Questions
Q: Why doesn’t TSO break TCP correctness?
A: Because the segmenting NIC produces correct wire packets — same as if the kernel did it. From a peer’s perspective, traffic is indistinguishable. Only the local stack saves work. Same for GRO: the merged skb has the same bytes as the original segments would; the local stack just sees them as one.
Q: When should I disable these?
A: For latency-sensitive measurements (HFT, microsecond timing, packet capture for forensics) — GRO can hold a packet briefly waiting for a possible merge buddy. Cost is usually <100 µs but visible. For per-packet observability (BPF programs that need to see wire packets) — GRO at the driver hides them.
Q: How does GSO interact with checksum offload?
A: Closely — and Background 3 already gave you the machinery. The short version: no per-segment sums exist before the split, so the stack ships
CHECKSUM_PARTIAL(pseudo-header sum only) and the splitter finishes each one — the NIC for TSO,__skb_gso_segmentfor GSO. It’s also whynetif_needs_gsoforces a non-PARTIALskb down the software path. Re-read Background 3 if a checksum bug bites.
Pitfalls when offloads are on

The biggest gotcha is observability. If you trace ip_rcv and count packets, you’re counting GRO superpackets, not wire packets. Multiply by skb_shinfo(skb)->gso_segs (Background 2 — the segment count the kernel stamped in) if you have it; or disable GRO during measurement.
For BPF programs that hook into the stack (XDP runs before GRO; tc-bpf runs after), this matters: XDP sees per-segment, tc sees coalesced.
Today’s experiment
See offload state
ethtool -k eth0 | grep -E "segmentation-offload|receive-offload"
Output (typical):
tcp-segmentation-offload: on
generic-segmentation-offload: on
generic-receive-offload: on
large-receive-offload: on
The feature names are tcp-segmentation-offload / generic-*-offload — they don’t contain the
substrings tso/gso/gro, so a grep -E "tso|gso|gro" would match only unrelated lines and never
print these. Match the real names. (large-receive-offload is the hardware LRO cousin of GRO; it may
read off or off [fixed] on NICs that don’t support it.)
Watch GRO in action
GRO coalesces many wire packets into one superpacket, so the entry probe (gro_receive_skb) fires
once per arriving segment, while the post-GRO probe fires far fewer times with much larger skbs. We
watch both the lengths and the call counts:
sudo bpftrace -e '
fentry:gro_receive_skb {
@gro_lengths = lhist(args->skb->len, 0, 65536, 8192);
@gro_calls = count();
}
tracepoint:net:netif_receive_skb {
@postgro_lengths = lhist(args->len, 0, 65536, 8192);
@postgro_calls = count();
}
interval:s:8 { exit(); }' &
# While that window is open, pull a real bulk download (server-less, follows redirects).
curl -sL -o /dev/null --max-time 6 \
https://cloud-images.ubuntu.com/releases/24.04/release/ubuntu-24.04-server-cloudimg-amd64.img
wait
Run the download inside the bpftrace window, so background the tracer (&) and wait. Typical output
(numbers scale with the transfer):
@gro_calls: 237409 <- once per arriving segment, mostly small
@gro_lengths:
[0, 8K) 219020 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@|
[8K, 16K) 11732 |@@ |
...
@postgro_calls: 30527 <- ~8x fewer: GRO merged ~8 segments per superpacket
@postgro_lengths:
[0, 8K) 11056 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@|
[8K, 16K) 7203 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ |
[56K, 64K) 3260 |@@@@@@@@@@@@@@@ |
The story is in the two *_calls: gro_calls ≫ postgro_calls (here ~8:1) is the coalescing ratio,
and postgro_lengths spreads into the big buckets — those are the merged superpackets the stack
processes once instead of eight times.
Why
netif_receive_skband notip_rcv?tracepoint:net:netif_receive_skbis the post-GRO entry into the stack and fires on every NIC.fentry:ip_rcvworks on bare-metal NICs but on many virtual NICs (cloud/virtio) it attaches yet never fires — you’d see an empty histogram and wrongly conclude GRO is off. Use the tracepoint; addfentry:ip_rcv { @ip = lhist(args->skb->len,0,65536,8192); }too if you want to confirm it on bare metal.
Disable GRO and re-measure
Now turn GRO off and run the same observation. The contrast is the whole point:
sudo ethtool -K eth0 gro off
sudo bpftrace -e '
tracepoint:net:netif_receive_skb {
@postgro_lengths = lhist(args->len, 0, 65536, 8192);
@postgro_calls = count();
} interval:s:8 { exit(); }' &
sleep 1
curl -sL -o /dev/null --max-time 6 \
https://cloud-images.ubuntu.com/releases/24.04/release/ubuntu-24.04-server-cloudimg-amd64.img
wait
# Always restore — leaving GRO off slows every later experiment:
sudo ethtool -K eth0 gro on
With GRO off, postgro_calls jumps roughly back to the segment count (here ~233 000 vs ~30 000 with GRO
on — an ~8x increase), and postgro_lengths collapses almost entirely into the [0, 8K) bucket: the
stack now runs once per wire-sized packet instead of once per superpacket. That extra per-packet work is
the CPU cost GRO was hiding.
Per-segment counter
TSO is a transmit offload: the stack hands the NIC large skbs and the NIC segments them onto the wire. So you need an upload to see it (a download’s TX side is just small ACKs). Watch the size of skbs entering the device queue while pushing data out:
sudo bpftrace -e 'fentry:__dev_queue_xmit {
@tx_skb_len = lhist(args->skb->len, 0, 65536, 8192);
} interval:s:6 { exit(); }' &
# Server-less upload sink; --max-time bounds it, the timeout exit is expected (|| true).
curl -s -o /dev/null -T /dev/zero --max-time 4 https://speed.cloudflare.com/__up || true
wait
Typical output — a strong spike in the top bucket, the 64 KB GSO/TSO skbs the stack handed down:
@tx_skb_len:
[0, 8K) 206 |@@@@@@ |
[8K, 16K) 120 |@@@ |
...
[56K, 64K) 1660 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@|
Each of those ~64 KB skbs becomes ~40+ MTU-sized wire packets (that’s gso_segs from Background 2), but
the stack ran its TX path once per skb. You can see the multiplication in the NIC counters too — the
wire-packet count climbs far faster than the skb count:
ethtool -S eth0 | grep -iE 'tx_packets|tx_queue.*packets' # NIC's per-queue / aggregate wire packets
cat /sys/class/net/eth0/statistics/tx_packets # kernel-level tx_packets
The field is named tx_packets / tx_queue_N_packets — not tx_pkts, which matches nothing on any
driver. Counter layout is driver-specific: on virtualized NICs (e.g. Azure/mlx5 VF) the
tx_queue_N_packets lines may read 0 and the real counts live in vf_tx_packets / cpuN_tx_packets.
What to read in the kernel
net/core/gso.c— segmentation engine. ~300 lines.__skb_gso_segmentis the entry (gso.c:88);skb_mac_gso_segmentdoes the L2 split (gso.c:37); note theskb_cow_headcall (gso.c:97) that re-inits each segment’s checksum field.net/core/gro.c— coalescing engine. ~800 lines.dev_gro_receiveis the workhorse (gro.c:474);gro_receive_skbis the exported entry (gro.c:636);napi_gro_receiveitself is astatic inlineinnetdevice.h:4286; per-protocol callbacks (tcp4_gro_receive) live in protocol files.net/ipv4/tcp_offload.c— TCP-specific GRO/GSO callbacks;tcp_gso_segmentattcp_offload.c:133.include/linux/netdev_features.h—NETIF_F_GSO_*,NETIF_F_TSO_*,NETIF_F_GRO_*flags.include/linux/skbuff.h— the GSO marker inskb_shared_info:gso_size(598),gso_segs(600),gso_type(606); theSKB_GSO_*bits (SKB_GSO_TCPV4at 669); theip_summedcontract (CHECKSUM_*at 248–251, documented 98–136).net/core/dev.c— the TX split site:validate_xmit_skb(4019),netif_needs_gsotest (4036),skb_gso_segment(4039).Documentation/networking/segmentation-offloads.rst— official guide.
Bullet Points
- MSS = MTU − IP − TCP = 1500 − 20 − 20 = 1460 B (no-options IPv4) — the chunk size every offload counts in. It’s per-connection, stored per-skb so the segmenter reads it (
tcp_skb_mss(),tcp.h:1214) instead of recomputing. 64 KB / 1460 ≈ 44 segments from one skb. - The GSO marker lives in
skb_shared_info:gso_size(MSS,skbuff.h:598),gso_segs(count, 600),gso_type(protocol bitmask, 606). TCP stamps it intcp_transmit_skb(tcp_output.c:1704–1705); the same big skb then rides the Day 3 TX path unchanged. - One switch unifies all three:
gso_type+ NIC capability.validate_xmit_skb→netif_needs_gso(dev.c:4019/4036) hands the whole skb to hardware (TSO) or callsskb_gso_segment(dev.c:4039) for a software split (GSO). ip_summedis the checksum contract (CHECKSUM_NONE/UNNECESSARY/COMPLETE/PARTIAL,skbuff.h:248–251). Offloads requireCHECKSUM_PARTIALon TX: the stack writes only the pseudo-header sum and each segment’s real checksum is finished after the split (NIC for TSO,__skb_gso_segment→skb_cow_headfor GSO).- TSO: hardware segments large skbs into wire packets. Saves stack overhead ~44×.
- GSO: software segmentation late in TX (
net/core/gso.c:__skb_gso_segment,gso.c:88). Same stack savings as TSO; works on any NIC. - GRO: receive-side coalescing in the NAPI poll (
net/core/gro.c:dev_gro_receive,gro.c:474) — recall the Day 2 funnel. One stack pass per superpacket; payload appended as page frags (Day 1) or, less commonly, chained viafrag_list. - All three controlled with
ethtool -K. Default ON for all three on modern NICs. - Side effects: per-packet observability is wrong unless you disable GRO or multiply by
skb_shinfo(skb)->gso_segs. - For latency-critical workloads, sometimes GRO is disabled (sub-100µs latency floor).
Check question
You run iperf3 and for the first instant of a transfer the reported throughput briefly reads above the NIC’s line rate, then settles. What’s happening?
Click to reveal answer
Answer: iperf3 measures bytes through the userspace socket, not bytes on the wire. At the very start of a transfer the kernel’s socket send buffer absorbs a burst of writes faster than the NIC can drain them — the application’s bytes-per-second momentarily reflects how fast data entered the socket buffer, not how fast it left the wire. Once the buffer fills and TCP is paced by ACKs, the reading settles to the true wire rate (bounded by the NIC). TSO contributes to the illusion: those buffered bytes leave the kernel in 64 KB skbs that the NIC segments, so the “stack-level throughput” spikes briefly before flow control clamps it. Steady-state iperf3 throughput tracks wire rate.
Tomorrow
Day 5: network namespaces. Why a single kernel can run dozens of independent network stacks at once, and how struct net makes it work.
Day 5 — Network namespaces and struct net
Today’s mission: understand what a namespace even is, how a process is bound to one, and how the kernel keeps multiple independent network stacks isolated. Then create a network namespace, give it a virtual interface, run a process inside it, and watch one get born and die under bpftrace. End of Phase 1. Total time: ~110 minutes.
What a namespace IS (before we touch the network kind)
Days 1–4 never said the word namespace, so let’s build the idea from scratch — it’s the one piece of background everything today rests on.
A namespace is a kernel-wide scoping mechanism. Take one global resource — say, “the list of network interfaces,” or “the set of mounted filesystems,” or “the table of process IDs” — and make it appear as N independent copies instead of one shared thing. Two processes in different namespaces of the same kind look at the same global resource and each sees a private, isolated view. It’s the same trick a hypervisor plays with whole machines, except the kernel does it per-resource, in software, with almost no overhead.
Linux has several kinds of namespace, each scoping a different resource:
| Namespace | Scopes |
|---|---|
mnt | the mount table (which filesystems are visible where) |
pid | process IDs (PID 1 inside ≠ PID 1 outside) |
net | the entire network stack — today’s topic |
uts | hostname and domain name |
ipc | System V IPC / POSIX message queues |
time | clock offsets (boottime, monotonic) |
cgroup | the cgroup root a process sees |
A container is, at bottom, just a process whose mnt, pid, net, … pointers have been swapped to fresh namespaces. There is no “container” object in the kernel — there’s a process holding a bundle of namespace pointers. Today we care about exactly one of those pointers: net.
The network namespace scopes the whole stack. Each one has its own:
- list of network interfaces,
- routing tables,
- netfilter rules (the kernel’s packet-filtering/firewall framework; nftables is its modern ruleset),
- sockets and bound ports,
- proc/sysctl views,
- conntrack table (connection tracking — the kernel’s record of active flows, used by NAT and stateful firewalling).
This is what makes containers and VMs (with TAP/MACVTAP) practical. Every Docker/Podman/Kubernetes pod runs in its own netns. Linux can spin up tens of thousands of them at once.
How a process is bound to its namespaces: nsproxy
So where does a process keep the pointer that says “this is my netns”? Not directly in task_struct. Every task points at a small shared bundle called struct nsproxy, which holds one pointer per namespace kind:
/* include/linux/nsproxy.h:32 */
struct nsproxy {
refcount_t count;
struct uts_namespace *uts_ns;
struct ipc_namespace *ipc_ns;
struct mnt_namespace *mnt_ns;
struct pid_namespace *pid_ns_for_children;
struct net *net_ns; /* <-- the network namespace */
struct time_namespace *time_ns;
struct time_namespace *time_ns_for_children;
struct cgroup_namespace *cgroup_ns;
};
The field that matters today is net_ns, a struct net *. Every task in the same netns shares the very same struct net * — that pointer is the namespace. The nsproxy itself is refcounted (count) and shared between tasks that happen to have identical namespace bundles, which is why fork without CLONE_NEW* is cheap: the child just bumps the parent’s nsproxy.
So the chain you’ll see in code is: task_struct → nsproxy → net_ns → struct net. When kernel code wants “the current task’s network namespace” it walks exactly that path (the helper is current->nsproxy->net_ns, usually behind sock_net(sk) or dev_net(dev)).

struct net — one per namespace

Each namespace is one struct net (defined at include/net/net_namespace.h:62). Kernel networking functions take struct net *net as a first-class parameter — anywhere a function needs a routing table, a netfilter chain, a port allocator, it’s looked up via net. (We’ll see why that one pointer is enough to reach every subsystem’s per-ns state when we get to pernet_operations below.)
The “default” namespace at boot is init_net (net/core/net_namespace.c:49: struct net init_net;). The kernel’s first netdev (loopback, then physical NICs) all start there. New namespaces are empty until you put devices in them.
The inode that names a namespace: ns_common and nsfs
Here’s the part that makes today’s first lab make sense. Embedded inside struct net is a small common header shared by all namespace kinds:
/* include/net/net_namespace.h:97 — inside struct net */
struct ns_common ns;
And ns_common carries the namespace’s identity, an inode number:
/* include/linux/ns/ns_common_types.h:111 */
struct ns_common {
struct { refcount_t __ns_ref; } ____cacheline_aligned_in_smp;
u32 ns_type;
struct dentry *stashed;
const struct proc_ns_operations *ops;
unsigned int inum; /* <-- the unique identity */
/* ... */
};
That inum is a unique inode number assigned when the namespace is created. Be careful about which identity it is, though. Internally, the kernel identifies a namespace by its struct net * pointer, not by inum. “Are these two in the same netns?” is answered by net_eq(net1, net2), which is literally return net1 == net2 (include/net/net_namespace.h:302) — a pointer compare — and same-netns checks throughout the stack (e.g. inet_bind_bucket matching) call net_eq on pointers. The inum is the userspace-facing name, surfaced through nsfs as net:[<inum>]. Because each live struct net is assigned a unique inum, userspace can compare two net:[...] strings to answer “same netns?” and get the right answer for currently-live namespaces — but that is a userspace convenience built on a 1:1 mapping, not the comparison the kernel itself performs. (Inums are also recycled once a namespace is freed — the ns_common comment notes inum is “quickly recycled for non-initial namespaces” — so an inum is a stable identity only among live namespaces, whereas the pointer is the authoritative live key.)
How do you see that number from userspace? Through nsfs, a tiny internal pseudo-filesystem whose only job is to expose namespaces as files. Each kind of namespace shows up as a magic symlink under /proc/<pid>/ns/. Reading the net one (readlink /proc/<pid>/ns/net) prints net:[<inum>]. Same inum ⇒ same namespace; different inum ⇒ different struct net. That’s the entire mechanism behind the metadata lab later in this chapter.
The boot namespace init_net gets a well-known, fixed inode number rather than a freshly allocated one — NET_NS_INIT_INO = 0xEFFFFFF9 (include/uapi/linux/nsfs.h:54), wired in through ns_init_inum() (include/linux/ns/ns_common_types.h:144). So on every Linux box, init_net’s net:[...] ends in the same number; only the namespaces you create get fresh ones.
There are no Dumb Questions
Q: Is the loopback per-ns?
A: Yes. Each ns gets its own
lo. So127.0.0.1in ns A is unrelated to127.0.0.1in ns B. Two processes (one in each) can bothbind()127.0.0.1:80 without conflict.Q: Can ns A see ns B’s traffic?
A: Only via connecting devices. Common patterns: a
vethpair (two virtual NICs that act as a wire — one in each ns), a Linux bridge that interconnects veths in init_net, or a physical NIC moved to one ns.Q: Where does shared state live?
A: Hardware (NICs themselves), slab caches (sk_buff allocations are kernel-wide — recall the
skbuff_head_cacheand per-CPUnapi_alloc_cachefrom Day 1), the scheduler. A NIC is per-ns in the sense that itsdev->nd_netpoints to one ns — but the underlying hardware is one physical thing.
Setting up a netns
Linux exposes ns operations via iproute2:
# Create namespace 'red'
sudo ip netns add red
# Run a shell in it
sudo ip netns exec red bash
# (in that shell) network is empty:
ip link
# 1: lo: <LOOPBACK> ... state DOWN
ip route
# (nothing)
You’re inside a fully isolated network stack. Bring up loopback, you can localhost ping yourself, but nothing else is reachable.
Connecting to the outside
Two veth ends, one in each ns:
# in init_net:
sudo ip link add veth_red type veth peer name veth_red_peer
sudo ip link set veth_red_peer netns red
# inside 'red' (or use ip netns exec):
sudo ip netns exec red ip link set veth_red_peer up
sudo ip netns exec red ip addr add 10.99.99.2/24 dev veth_red_peer
# back in init_net:
sudo ip link set veth_red up
sudo ip addr add 10.99.99.1/24 dev veth_red
# now:
sudo ip netns exec red ping 10.99.99.1 # works
ping 10.99.99.2 # works (init→red)

The figure shows the general pattern — substitute red for “netns A” and veth_red/veth_red_peer for vethA0/vethA1.
How struct net is assembled: pernet_operations
We keep saying struct net “aggregates all per-ns subsystem state.” But who fills it in? When ip netns add red runs, the kernel allocates one bare struct net — how do routing tables, netfilter chains, conntrack counters, and TCP state all appear inside it? And later, who tears them all back down? The answer is one elegant registration mechanism, and once you see it the chapter’s central claim (“most kernel net functions take struct net *net first”) stops being a slogan and becomes obvious.
Intuition: subsystems register init/exit callbacks
Picture every networking subsystem — IPv4 routing, netfilter, conntrack, TCP, nftables — as a tenant that wants a private room in every struct net that will ever exist. At boot, each tenant signs up once by handing the core a pair of callbacks: “when a new netns is born, call my init to furnish my room; when it dies, call my exit to clear it out.” The core keeps these signups in a list and replays them for every namespace.
The signup form is struct pernet_operations:
/* include/net/net_namespace.h:459 */
struct pernet_operations {
struct list_head list;
int (*init)(struct net *net); /* :483 — furnish my per-ns state */
void (*pre_exit)(struct net *net);
void (*exit)(struct net *net); /* :485 — tear it down */
void (*exit_batch)(struct list_head *net_exit_list);
void (*exit_rtnl)(struct net *net, struct list_head *dev_kill_list);
unsigned int * const id; /* :490 — net_generic slot id */
const size_t size; /* :491 — bytes to allocate for it */
};
A subsystem registers it with register_pernet_subsys() (include/net/net_namespace.h:513), which just appends the node to a global list called pernet_list. That’s the whole registration. For example, IPv4 routing’s /proc plumbing:
/* net/ipv4/route.c:379 */
static struct pernet_operations ip_rt_proc_ops __net_initdata = { ... };
/* and registered at route.c:386 (plus sysctl_route_ops / ip_rt_ops / rt_genid_ops at :3782+) */
register_pernet_subsys(&ip_rt_proc_ops);
Dispatch: setup_net walks the list
When a namespace is created, setup_net() walks pernet_list front-to-back and calls every registered init:
/* net/core/net_namespace.c:436 */
static __net_init int setup_net(struct net *net)
{
const struct pernet_operations *ops;
/* ... */
list_for_each_entry(ops, &pernet_list, list) {
error = ops_init(ops, net); /* :446 */
if (error < 0)
goto out_undo;
}
/* ... */
}
This loop is the assembly line. Each subsystem’s init fills in its slice of per-ns state, one after another, in registration order. That’s why a fresh namespace already has a working (if empty) routing table, netfilter hooks, and so on the instant ip netns add returns.
Two ways to store per-ns state: direct field vs net_generic
Where does a subsystem put its state? There are two strategies, and the chapter’s “What’s per-ns” list mixes both:
-
A direct field of
struct net— for hot, core subsystems the state is embedded right in the struct:net->ipv4(net_namespace.h:138),net->nf(:149),net->ct(:151),net->nft(:154). Fast to reach, no indirection. -
net_generic— a side array — for everything else, baking a field intostruct netfor every module would bloat it. Instead, if apernet_operationssetsidandsize, the dispatcher allocates the room on demand. Look atops_init:/* net/core/net_namespace.c:120 */ static int ops_init(const struct pernet_operations *ops, struct net *net) { void *data = NULL; if (ops->id) { data = kzalloc(ops->size, GFP_KERNEL); /* allocate the room */ err = net_assign_generic(net, *ops->id, data); /* :131 — stash pointer in net->gen */ } /* ... */ if (ops->init) err = ops->init(net); /* then furnish it */ }The pointer lands in
net->gen(net_namespace.h:163), a resizable array indexed by the subsystem’sid. Code fetches it back withnet_generic(net, id). This is exactly whatnft_pernet(net)does under the hood — which is why the chapter calls nftables state “net_generic-backed”: it isn’t a direct field, it lives innet->gen[nft_id].
Either way, everything reachable from one struct net *. Direct field or net->gen[id], a subsystem starts from that single pointer and finds its per-ns slice. That is the real reason “most kernel net functions take struct net *net as the first parameter” — the pointer is the key to every per-ns room in the building.
Teardown is the mirror image
On destruction, cleanup_net (covered next) walks the same registrations and runs each exit callback — the matching “clear out the room” step. Because every registered subsystem must run its exit (flush conntrack, close sockets, free routing tables) one at a time, deleting a busy namespace is slow: it’s a serial walk over every tenant’s move-out.

What’s per-ns

The state per ns includes (the struct net figure above already previewed this inventory; here we mark which are direct fields vs net_generic-backed, now that you know the difference):
- Routing:
net->ipv4.fib_main,ipv4.fib_default,ipv6.fib6_main_tbl— direct fields offnet->ipv4.ip routeonly shows the current ns’s table. - Netfilter:
net->nf.hooks_ipv4[](per-hook arrays, direct field),nft_pernet(net)->tables(net_generic-backed), per-ns conntrack stats innet->ct. nftables and conntrack state are independent. - Sockets: the TCP bind hashtable (
tcp_hashinfo.bhash[]) is itself global/shared, not per-ns — isolation comes from each bind bucket recording its owning netns.struct inet_bind_bucketcarriesib_net, the bucket-match test isnet_eq(ib_net(tb), net) && tb->port == port, and thenetpointer is mixed into the hash viainet_bhashfn(net, port, ...). So two namespaces that bothbind()0.0.0.0:80land in separate buckets keyed by theirstruct netinside the one shared table — same port, no conflict. (Only the ehash can optionally be made per-net viatcp_child_ehash_entries;net->ipv4.tcp_death_row.hashinfois the per-net hashinfo pointer but it still points at the shared bind table by default.) - Sysctls: most
net.ipv4.*are per-ns (tcp_congestion_control,rp_filter,ip_forward). - proc/net: each ns sees its own
/proc/net,/proc/sys/net.
The life and death of a struct net
The chapter’s lifecycle lab traces a namespace being born (copy_net_ns/setup_net) and dying (cleanup_net). To read what you’re seeing — and to understand why deleting a netns sometimes doesn’t actually free it — you need the refcount model. Good news: it’s the same rule you already learned on Day 1.
Recall: the free-at-zero refcount
On Day 1 you saw sk_buff use a refcount_t (skb->users) that’s bumped by skb_get() and dropped by kfree_skb(), freeing only when it hits zero. struct net uses the identical pattern — only the structure being guarded differs. There’s nothing new to learn about how a refcount works; we’re just applying it to namespaces.
Tier 1: holders (get_net / put_net)
The primary count lives in the embedded ns_common.__ns_ref. Anything that needs the namespace to stay alive takes a reference:
- a non-kernel socket in that netns (
sk_alloc→get_net_track; kernel sockets withsk_net_refcnt == 0take only the passive count, not a holder), - a task’s nsproxy → net_ns pointing at it (
copy_net_nstakes aget_netfor the non-CLONE_NEWNETcase; a freshly created net starts with one__ns_ref), - an open FD or bind-mount of the ns (the nsfs name keeps it pinned).
A netdev does NOT hold a reference to its netns. Its nd_net is a plain pointer: dev_net_set() → write_pnet(&dev->nd_net, net) (netdevice.h:2774) is a bare pointer write with no refcount — grep -cE 'get_net\b|get_net_track' net/core/dev.c returns 0. The only count a netdev ever touches is the separate passive (memory-guard) count, and only transiently inside rtnl_net_dev_lock/unlock. In fact the design is the opposite of “a device pins its netns”: because devices do not hold the namespace alive, the teardown machinery must actively evict them. default_device_ops (registered via register_pernet_device) runs default_device_exit_net at teardown, which moves migratable physical devices back to init_net and deletes virtual ones (veth). That eviction code exists precisely because a netdev cannot keep a netns alive — if it could, any netns containing a device would never be freed.
get_net(net) bumps the count; put_net(net) drops it:
/* include/net/net_namespace.h:276, :295 */
static inline struct net *get_net(struct net *net)
{
ns_ref_inc(net); /* bump ns_common.__ns_ref */
return net;
}
static inline void put_net(struct net *net)
{
if (ns_ref_put(net)) /* drop; true when it hit zero */
__put_net(net);
}
There’s also maybe_get_net() (:282), the careful variant that fails (returns NULL) if the count is already zero — used when you have a pointer but aren’t sure the namespace is still alive.
Why the last put_net doesn’t free inline — the workqueue
Here’s the subtle part. When put_net drops the holder count to zero it calls __put_net, which does not free anything immediately:
/* net/core/net_namespace.c:745 */
void __put_net(struct net *net)
{
ref_tracker_dir_exit(&net->refcnt_tracker);
/* Cleanup the network namespace in process context */
if (llist_add(&net->cleanup_list, &cleanup_list))
queue_work(netns_wq, &net_cleanup_work); /* :750 — punt to a workqueue */
}
It appends the dead namespace to a cleanup_list and queues work. Destruction is deferred to a workqueue.
A workqueue is the kernel’s mechanism for running deferred functions in a kernel thread — i.e. in process context, where the code is allowed to sleep. That’s distinct from the softirq/NAPI context you met on Day 2, which is atomic and may not sleep. Why does namespace teardown need to sleep? Because tearing down subsystems (synchronize_rcu(), flushing conntrack, closing sockets) blocks — and put_net can be called from anywhere, including atomic contexts where blocking is forbidden. So the rule is: drop the refcount cheaply and atomically, then hand the heavy, sleepy teardown off to a kernel thread.
The work item and its function are wired up statically:
/* net/core/net_namespace.c:743 */
static DECLARE_WORK(net_cleanup_work, cleanup_net);
cleanup_net (net/core/net_namespace.c:662) is the function that eventually runs on that workqueue thread. It walks the pernet_operations registrations in reverse and calls every subsystem’s exit — the teardown half of the assembly line from the previous section.
This is also a handy tracing fact: because cleanup_net is a real, non-inlined function registered via DECLARE_WORK, fentry:cleanup_net attaches reliably. The birth-side setup_net, by contrast, is static __net_init and may be inlined — which is exactly the caveat in the lifecycle lab below.
Tier 2: passive (the memory guard)
There’s a second, separate refcount on struct net:
/* include/net/net_namespace.h:66 */
refcount_t passive;
Why two? The holders count answers “should this network stack stay operational?” The passive count answers a narrower question: “is anyone still touching this struct net’s memory?” A transient lookup might need to pin the bytes of the struct for a moment without keeping the whole stack alive. passive is initialized to 1 (net/core/net_namespace.c:410: refcount_set(&net->passive, 1)), and net_passive_dec() (:530) does the final teardown — freeing net->gen and ultimately kmem_cache_freeing the struct — when it reaches zero, after the operational teardown has already run. Two tiers: holders decide when the stack dies, passive decides when the memory is freed.

What ip netns delete actually does
Now the chapter’s central puzzle resolves cleanly. ip netns delete red only unlinks the name — the nsfs bind-mount under /var/run/netns/red. It does not force the namespace to die. If any process, socket, or open FD still holds a get_net reference, the holder count stays above zero, __put_net is never called, cleanup_net never runs, and the namespace persists invisibly until the last holder exits. The name is gone from ip netns list, but the struct net lives on. That is precisely the “what if a process is still inside?” problem: Linux holds struct net until the last reference goes away — process exit, FD close, etc.
Today’s experiment
See ns metadata
sudo ip netns add green
ls /var/run/netns/ # one entry per ns
# nsfs link to /proc/<pid>/ns/net:
readlink /proc/$$/ns/net # current shell's netns (init_net)
sudo ip netns exec green readlink /proc/self/ns/net # green's netns
net:[4026531833] # init_net (your number differs)
net:[4026532243] # green — a different inode
You’re looking at two ns_common.inum values resolved through nsfs — the userspace-visible name for each namespace (the kernel itself keys on the struct net * pointer, but each live net maps 1:1 to an inum, so distinct strings here mean distinct namespaces). Note the use of /proc/self in the second command, not /proc/$$. $$ is expanded by your outer interactive shell (which lives in init_net) before ip netns exec runs, so it would resolve the symlink for a process still in init_net and print the same inode twice. /proc/self is resolved by the readlink process that ip netns exec actually placed inside green, so the two inodes genuinely differ — that’s how the kernel knows which ns a process is in.
Per-ns sysctl
# In init_net — show YOUR actual value (don't assume a specific one):
cat /proc/sys/net/ipv4/tcp_congestion_control
# cubic (whatever your box uses — often cubic)
# What's even compiled in? (reno is always built in)
cat /proc/sys/net/ipv4/tcp_available_congestion_control
# reno cubic dctcp bbr htcp
# In green:
sudo ip netns exec green cat /proc/sys/net/ipv4/tcp_congestion_control
# cubic (a new ns inherits init_net's congestion control)
# Set green to reno — always built in, so guaranteed distinct from a default-cubic box:
sudo ip netns exec green sysctl -w net.ipv4.tcp_congestion_control=reno
# Confirm green changed but init_net did NOT:
sudo ip netns exec green cat /proc/sys/net/ipv4/tcp_congestion_control # reno
cat /proc/sys/net/ipv4/tcp_congestion_control # unchanged (your original value)
Different values per ns — green now reads reno while init_net keeps its original value, proving the tables are independent. The per-ns sysctl table is at net->sysctls. (If your init_net somehow already runs reno, set green to cubic instead — the point is just to pick something different from init_net’s current value.)
Per-ns routing
# In red (with veth set up):
sudo ip netns exec red ip route
# 10.99.99.0/24 dev veth_red_peer scope link
# In init_net:
ip route
# (your normal routes, including 10.99.99.0/24 if the host added one)
Different tables, different views.
Watch ns lifecycle
sudo bpftrace -e '
fentry:setup_net { printf("setup_net %p\n", (void *)args->net); }
fentry:cleanup_net { printf("cleanup_net work %p\n", (void *)args->work); }
'
# In another terminal:
sudo ip netns add demo
sudo ip netns delete demo
Caveat:
setup_netisstatic __net_init(net/core/net_namespace.c:436), so it may be inlined or freed after boot andfentry:setup_netmay not reliably attach. If it doesn’t fire, trace its callercopy_net_ns(net/core/net_namespace.c:549) instead — but note thatcopy_net_ns’sstruct net *argument (args->old_net) is the OLD/parent namespace, not the new one. The freshly createdstruct netis the function’s RETURN value, so you must usefexitandretval:sudo bpftrace -e 'fexit:copy_net_ns { printf("new net %p\n", retval); }'(
copy_net_nsalso runs forCLONEwithoutCLONE_NEWNET, in which case it just returns the old net pointer; the distinct heap addresses appear exactly whenip netns addruns.)cleanup_netis a work-queue function (registered viaDECLARE_WORKatnet/core/net_namespace.c:743) and attaches reliably — exactly as the lifecycle section explained.
You’ll see the ns being created and torn down. Tie it back to the refcount model: the cleanup_net line only fires once the holder count hit zero and __put_net queued the work — for a freshly created, empty demo with no lingering processes or sockets, that’s immediate.
What happens when you delete a netns
You just watched cleanup_net fire — recall from the refcount section that it only ran because demo had no lingering holders. (If a process, socket, or FD had still held a get_net reference, ip netns delete would have unlinked only the nsfs name and the struct net would have survived invisibly until that last holder went away.)
What to read in the kernel
include/linux/nsproxy.h—struct nsproxy(line 32). See the one-pointer-per-namespace-kind layout;net_nsis the field today is about.include/net/net_namespace.h—struct netdefinition (line 62). Read all fields once. Note the embeddedns_common ns(line 97), thepassiverefcount (line 66), thegenpointer for net_generic (line 163), and the direct fieldsipv4/nf/ct(lines 138/149/151). Alsostruct pernet_operations(line 459) andregister_pernet_subsys(line 513).include/linux/ns/ns_common_types.h—struct ns_common(line 111); theinumfield (line ~118) is the identity nsfs prints.net/core/net_namespace.c— the lifecycle:setup_net(line 436),ops_init/net_assign_generic(lines 120/83),copy_net_ns(line 549),__put_net(line 745),cleanup_net(line 662),net_passive_dec(line 530), andstruct net init_net(line 49).include/linux/netdevice.h— searchdev_net(dev). Most code calls this to get the ns from a netdev.- Any
*_pernet_opsregistration — for examplenet/ipv4/route.cregister_pernet_subsys(&ip_rt_proc_ops)(line 386). Each subsystem registers init/exit callbacks here. Documentation/admin-guide/sysctl/net.rst— which sysctls are per-ns, which are global.
What to break
Try moving a physical NIC to a netns
First find your real interface name — many cloud/test VMs use predictable names like ens5 or enp0s3, not eth0:
ip route get 1.1.1.1 # read the 'dev <name>' field; substitute it for eth0 below
sudo ip link set eth0 netns red # **CAREFUL** — your SSH may go away
If you do this on the real interface you’re using, your SSH session disconnects (init_net no longer has eth0). For real testing, do it with a non-essential interface or via a console. Under the hood, moving the NIC just reassigns its dev->nd_net to red’s struct net — a plain pointer write, not a get_net reference. The device does not pin red: at red’s destruction default_device_exit_net would push eth0 back to init_net automatically.
To recover from console — note the device comes back down with its addresses flushed, so you must bring it up and re-acquire an address:
sudo ip netns exec red ip link set eth0 netns 1 # back to init_net
sudo ip link set eth0 up
sudo dhclient eth0 # if your distro uses DHCP; otherwise re-add the static address you had before
Watch conntrack count per ns
sudo ip netns exec red sysctl net.netfilter.nf_conntrack_count
# 0 (red has no traffic)
sysctl net.netfilter.nf_conntrack_count
# possibly thousands (init_net has all real traffic)
The two views are independent — net->ct is a per-ns slice furnished by conntrack’s own pernet_operations init when red was created.
Cleanup
These experiments leave persistent namespaces and a veth pair behind. Tear them down so you don’t accumulate stale interfaces and ns mounts:
sudo ip link del veth_red 2>/dev/null # removes both ends of the pair
sudo ip netns delete green 2>/dev/null
sudo ip netns delete red 2>/dev/null # also reaps the peer living inside it
Remember: each ip netns delete only drops the name. The struct net is actually freed only once its last holder (the veth you just deleted, any ip netns exec shells you left open) is gone and cleanup_net has run.
Bullet Points
- A namespace is a kernel scoping mechanism that turns one global resource into N isolated copies. Kinds:
uts,ipc,mnt,pid,net,time,cgroup. A process points at all of them throughtask_struct → nsproxy;nsproxy->net_nsis the netns. - Network namespace =
struct net(net_namespace.h:62). One per ns; aggregates all per-ns subsystem state. The default ns isinit_net. - A namespace’s userspace identity is
ns_common.inum, an inode number exposed via nsfs as/proc/<pid>/ns/net→net:[<inum>]. The kernel itself compares thestruct net *pointer (net_eqisnet1 == net2); the inum is a 1:1 userspace name for live namespaces (and is recycled after free). init_net’s is the fixedNET_NS_INIT_INO. - Per-ns state is assembled by
pernet_operations: each subsystem registers init/exit callbacks viaregister_pernet_subsys;setup_netwalks the list calling eachinit,cleanup_netcalls eachexit. State lives either in a direct field (net->ipv4/nf/ct) or innet_generic(net->gen[id], e.g. nftables). - That single mechanism is why most kernel net functions take
struct net *netfirst — the pointer reaches every per-ns slice. struct netuses the same free-at-zero refcount rule as Day 1’s sk_buff: holders (get_net/put_netonns_common.__ns_ref) keep the stack alive; a separatepassiverefcount guards the final memory free.- The last
put_netdoesn’t free inline —__put_netqueuescleanup_neton thenetns_wqworkqueue (process context, may sleep), distinct from Day 2’s softirq context. ip netns deleteunlinks only the nsfs name; if any holder remains, the ns persists invisibly until the last one exits.vethpairs are the most common way to connect a ns to the outside world.- Per-ns: routing, netfilter rules, conntrack, sockets, sysctls, /proc/net. NOT per-ns: hardware, slab caches, scheduler.
ip netns add/del/execfor management;unshare(CLONE_NEWNET)to create programmatically.
Check question
Two TCP servers run, one in init_net and one in netns “red”, both bound to 0.0.0.0:80. Both have full read/write to their socket. A client in init_net connects to 127.0.0.1:80. Which server gets the connection?
Click to reveal answer
Answer: The init_net server. Socket bind tables are per-ns: 0.0.0.0:80 in init_net is one entry; 0.0.0.0:80 in red is a separate entry. The kernel routes the SYN by namespace: the client is in init_net, the SYN goes through init_net’s stack, lookup in init_net’s bind table finds the init_net listener. Red’s listener never sees the packet. To reach red’s listener, you’d need a process in red to connect (e.g., ip netns exec red curl 127.0.0.1), or a route + nftables setup that forwards traffic into red.
End of Phase 1
You can now read the kernel network stack from sk_buff up. RX path, TX path, offloads, namespaces. The vocabulary is real — when you see dev_net(skb->dev), napi->poll, __qdisc_run, gso_segs, get_net(sock_net(sk)), you know what they’re doing.
Phase 2 (Days 6–12) walks the L2/L3 layers in detail: Ethernet, ARP, the FIB, IPv6, bridges, tunnels.
Day 6 — Ethernet, VLAN, and the L2 layer
Today’s mission: see what
eth_type_transdoes to every received packet — and learn the four pieces of wire-format background it quietly leans on (the Ethernet header layout, MAC-address bit semantics, network byte order, and the 802.1Q tag internals) so nothing along the way is a magic constant. Then create a VLAN device and watch tags appear and disappear. Total time: ~110 minutes.
Phase 2 starts here. Days 6–12 walk the L2/L3 layers in detail: Ethernet, ARP, IP routing, IPv6, bridges, tunnels.
What L2 means in Linux
The “Layer 2” code in Linux is small but ubiquitous. Every received frame passes through it; every transmitted frame has its Ethernet header built there. The implementation lives mostly in net/ethernet/eth.c (~640 lines).
The single most important function in this layer is eth_type_trans — called by every Ethernet driver on RX, every time. By the end of Day 2 you watched the driver call it in Stage 2 of the RX path: it sets skb->protocol, and that value becomes the demux key the core stack hashes on to pick ip_rcv. Today we open it up.
But eth_type_trans parses raw bytes off the wire, and to read it without hand-waving you need to know what those bytes are. So before we walk the function, three short background sections — intuition first, then the concrete v7.1 struct — on the Ethernet header, MAC-address bits, and byte order. After that the function reads as plain English. The fourth piece of wire-format background — the 802.1Q VLAN tag (Background 4) — comes after the function, in the VLAN section, because that’s where the function hands tagged frames off to the dedicated VLAN subsystem.
One thing from Day 1 is load-bearing today and will not be re-taught:
- The skb’s four pointers (
head ≤ data ≤ tail ≤ end) andskb_pull/skb_push. Recall from Day 1 —skb->datais the first valid byte of the current header view, andskb_pull(skb, n)advancesdatabynbytes (stripping a header you’ve finished with).eth_type_transdoes exactly oneskb_pullof 14 bytes; that’s the whole “advance past the Ethernet header” trick.
Background 1: the 14 bytes of an Ethernet header
Before eth_type_trans can do anything, it has to know what the first bytes of a received frame mean. On Ethernet, every frame starts with a fixed 14-byte header, and it is gloriously simple — just three fields back to back, no options, no variable length.
struct ethhdr is exactly that (include/uapi/linux/if_ether.h:177-181):
struct ethhdr {
unsigned char h_dest[ETH_ALEN]; /* destination eth addr */
unsigned char h_source[ETH_ALEN]; /* source ether addr */
__be16 h_proto; /* packet type ID field */
} __attribute__((packed));
with ETH_ALEN 6 (if_ether.h:32) and ETH_HLEN 14 (if_ether.h:34). Lay it out as a byte ruler:

- Bytes 0–5:
h_dest— the destination MAC (who the frame is for). - Bytes 6–11:
h_source— the source MAC (who sent it). - Bytes 12–13:
h_proto— the 2-byte EtherType (what’s inside).
6 + 6 + 2 = 14 = ETH_HLEN. That is the exact number skb_pull advances by. When a frame arrives, the driver has placed skb->data on byte 0 (the start of h_dest). After eth_type_trans does skb_pull(skb, ETH_HLEN), skb->data lands precisely on byte 14 — the first byte of the L3 header (the IP header, usually). No parsing, no length math: the Ethernet header is a fixed size, so one fixed pull strips it. This is why the helper that does it, eth_skb_pull_mac, is so short (etherdevice.h:640-645):
static inline struct ethhdr *eth_skb_pull_mac(struct sk_buff *skb)
{
struct ethhdr *eth = (struct ethhdr *)skb->data; /* read the 14 bytes */
skb_pull_inline(skb, ETH_HLEN); /* then step past them */
return eth;
}
The EtherType is sometimes a length
Here’s the one wrinkle. The 2-byte h_proto field is overloaded, for historical reasons:
- If the value is ≥ 0x0600 (1536), it’s a real protocol type — “Ethernet II” framing. Examples:
ETH_P_IP0x0800 (if_ether.h:52),ETH_P_IPV60x86DD (:74),ETH_P_ARP0x0806 (:54),ETH_P_8021Q0x8100 (:71, VLAN),ETH_P_8021AD0x88A8 (:93, QinQ). - If the value is ≤ 1500, it’s a frame length, not a type — the original IEEE 802.3 framing, where the protocol was identified by an LLC header inside the payload instead.
The boundary between the two interpretations is the constant ETH_P_802_3_MIN 0x0600 (if_ether.h:127), whose comment says it outright: “If the value in the ethernet type is more than this value then the frame is Ethernet II. Else it is 802.3.” The kernel decides which case it’s in with eth_proto_is_802_3() (etherdevice.h:220). That is exactly why, at the very end, eth_type_trans returns eth->h_proto only if (eth_proto_is_802_3(...)) — and otherwise falls back to a dummy “this is really a length” protocol ID. So the “h_proto is a length field (< 1536)” aside later in this chapter is not magic: it’s the < 1536 / ≥ 0x0600 split made concrete.
Background 2: what a MAC address actually encodes
eth_type_trans classifies every frame by looking at the destination MAC (h_dest): is it for us, for everyone, for a group, or for someone else? To follow that — and to understand why the lab later invents the address 02:00:00:00:00:01 — you need to know that a MAC address isn’t just six random bytes. Two bits of the first octet carry meaning.
A MAC address is 48 bits / 6 octets. The two least-significant bits of the first octet are special:

- bit 0 — I/G (Individual/Group):
0= unicast (this frame is addressed to one NIC);1= multicast/group (addressed to a set of NICs). - bit 1 — U/L (Universal/Local):
0= globally unique, burned in by the vendor (the top 3 octets are the vendor’s OUI);1= locally administered, i.e. software-assigned.
The kernel reads “the multicast bit” with the simplest possible test — is_multicast_ether_addr() is literally 0x01 & addr[0] (etherdevice.h:130). That single bit is the multicast bit this chapter keeps mentioning. And is_local_ether_addr() is 0x02 & addr[0] (the U/L bit, etherdevice.h:163).
Broadcast is a special case of multicast. The broadcast address ff:ff:ff:ff:ff:ff has every bit set — including bit 0 — so it is a multicast address, just the all-ones one. is_broadcast_ether_addr() checks all six octets equal 0xff (etherdevice.h:176). This is exactly why the classifier checks multicast first, then asks “is it the broadcast address specifically?” Watch eth_skb_pkt_type() do it (etherdevice.h:623-638):
static inline void eth_skb_pkt_type(struct sk_buff *skb,
const struct net_device *dev)
{
const struct ethhdr *eth = eth_hdr(skb);
if (unlikely(!ether_addr_equal_64bits(eth->h_dest, dev->dev_addr))) { /* not us */
if (unlikely(is_multicast_ether_addr_64bits(eth->h_dest))) { /* group bit set? */
if (ether_addr_equal_64bits(eth->h_dest, dev->broadcast))
skb->pkt_type = PACKET_BROADCAST; /* the all-ones one */
else
skb->pkt_type = PACKET_MULTICAST; /* some other group */
} else {
skb->pkt_type = PACKET_OTHERHOST; /* unicast, but not our MAC */
}
}
/* else: leaves the default PACKET_HOST — dst == our dev_addr */
}
The four outcomes are the pkt_type constants (include/uapi/linux/if_packet.h:26-29): PACKET_HOST 0, PACKET_BROADCAST 1, PACKET_MULTICAST 2, PACKET_OTHERHOST 3. Note the function only sets a value when the dst is not our dev_addr; the skb’s default pkt_type is already PACKET_HOST, so “addressed to us” needs no assignment.
Why 02:00:00:00:00:01 is the canonical “safe to invent” MAC
Now the lab’s magic constant decodes itself. Take the first octet 0x02 = binary 0000 0010:
- bit 0 = 0 → unicast. A NIC configured with this address treats frames sent to it as
PACKET_HOST(us). - bit 1 = 1 → locally administered. By definition it can’t collide with any vendor’s globally-unique OUI.
So 02:00:...:01 is guaranteed (a) to be a normal unicast address and (b) never to clash with real hardware. That’s why it’s the textbook address to assign to a throwaway interface — which is exactly what the “Set a non-default MAC” lab does.
Background 3: network byte order and the __be16 type
There’s one more thing you must know before reading eth_type_trans, and it’s the gotcha that makes today’s bpftrace experiment print a “wrong-looking” number. It’s about byte order.
A 2-byte field like h_proto holds a 16-bit number, and there are two ways to lay those two bytes out in memory:
- Big-endian (“network byte order”): most-significant byte first. The value
0x0800is stored as the bytes08 00. - Little-endian: least-significant byte first. The same value
0x0800is stored as00 08.
The wire is always big-endian. Every multi-byte protocol field — EtherType, IP addresses, port numbers — travels most-significant-byte-first. But x86 and ARM hosts are little-endian. So if you grab the two raw wire bytes 08 00 and read them back as a host u16, the CPU interprets them in its order and you get 0x0008, not 0x0800:
| On the wire (big-endian) | Read raw as host u16 (little-endian x86) | After ntohs() |
|---|---|---|
bytes 08 00 | 0x0008 | 0x0800 = ETH_P_IP |
That table is the entire explanation for today’s experiment printing type=0x0008 for an IP frame. The bytes are right; you’re just reading them in the wrong order.
__be16 / __be32 are the kernel’s annotations marking a value as big-endian (they’re checked by the sparse static analyzer). When you see __be16 h_proto, it’s a reminder: don’t do arithmetic or comparisons on this as a native integer until you convert it. The conversion helpers are:
ntohs()/ntohl()— network-to-host, wire → host (16-bit / 32-bit).htons()/htonl()— host-to-network, host → wire.
The subtlety that ties Day 2 together
Here’s the part worth holding onto. eth_type_trans returns h_proto __be16, without swapping it — return eth->h_proto; at net/ethernet/eth.c:177, and the signature returns __be16 (eth.c:155). The driver stores that still-big-endian value straight into skb->protocol. So skb->protocol stays in network byte order end-to-end.
Where does the swap finally happen? At the L3 demux you met in Day 2. Handlers register their EtherType into a bucket with htons(pt->type) (the registration helper ptype_head() at net/core/dev.c:608), and at RX time __netif_receive_skb_core indexes that same hash table with ntohs() — ptype_base[ntohs(type) & PTYPE_HASH_MASK] where type = skb->protocol (net/core/dev.c:6147, with type assigned from skb->protocol at :6142). Registration and demux hash the same way. Recall from Day 2 — that’s how skb->protocol (big-endian) gets matched against the registered handlers to reach ip_rcv. So nothing along the RX path ever byte-swaps skb->protocol itself; the comparison is done in network order. This is precisely why a raw %04x print in bpftrace shows the swapped value: you’re seeing the honest, unswapped wire bytes.
The mental tool: when you print a wire field raw and it looks byte-swapped, read the low two hex digits, or wrap the field in bswap() in bpftrace.
eth_type_trans — the universal RX header parser

Now the function reads cleanly. eth_type_trans(skb, dev) (net/ethernet/eth.c:155) does four things in tight succession:
skb_reset_mac_header(skb)— record where the Ethernet header is by storing the offset ofskb->datawithin the buffer (skb->data - skb->head); it’s atskb->dataright now (Background 1: byte 0, the start ofh_dest).- Read and pull, in one helper —
eth_skb_pull_mac(skb). This single call both readseth = (struct ethhdr *)skb->data(the three-field header from Background 1) and does theskb_pull(skb, ETH_HLEN)that advancesskb->datapast the 14-byte header. So by the end of this one stepskb->dataalready points at the L3 payload — the read and the pull are not two separate stages. - Set
skb->pkt_type(viaeth_skb_pkt_type, called after the pull) based on destination MAC — exactly the multicast-first / broadcast / otherhost logic from Background 2. This still works even though the header was just pulled:eth_skb_pkt_typefinds the header viaeth_hdr(skb), which uses themac_headeroffset recorded in step 1, notskb->data.PACKET_BROADCASTif dst isff:ff:ff:ff:ff:ff.PACKET_MULTICASTif the multicast bit (addr[0] & 1) is set.PACKET_OTHERHOSTif dst is unicast but doesn’t matchdev->dev_addr(would be dropped on a normal NIC; promiscuous mode keeps these). Otherwise it leaves the defaultPACKET_HOSTwhen dst matchesdev_addr(us).
- Return the protocol ID — typically
eth->h_proto(ETH_P_IP0x0800,ETH_P_IPV60x86DD, orETH_P_8021Q0x8100, VLAN), but onlyif (eth_proto_is_802_3(...))— the ≥ 0x0600 / < 1536 boundary from Background 1. There are two short-circuits before that: if the device uses DSA tagging (Distributed Switch Architecture, where an embedded switch prepends its own tag) it returnsETH_P_XDSA(0x00F8) without looking at the packet, and ifh_protois a length field (< 1536, i.e. belowETH_P_802_3_MIN0x0600) it falls back toETH_P_802_3(IPX magic) orETH_P_802_2(802.2 LLC). The returned value is__be16and is not byte-swapped (Background 3).
The driver assigns the return value to skb->protocol, then passes the skb to GRO / netif_receive_skb (recall the Day 2 RX path). From here the L3 dispatcher uses skb->protocol to find the right handler (ip_rcv for IP) — hashing it via ntohs() as Background 3 described.
Background 4: the 802.1Q VLAN tag
So far every frame has been 14 bytes of header then payload. A VLAN tag inserts 4 extra bytes into that header to label which virtual LAN the frame belongs to. Let’s decompose those 4 bytes — they’re the reason skb->vlan_tci and “VLAN ID 100” exist.

The full 802.1Q tag is 4 bytes (VLAN_HLEN 4, if_vlan.h:16), inserted between the source MAC and the EtherType field. It’s two halves:
- a 2-byte TPID (Tag Protocol IDentifier) —
0x8100for 802.1Q, or0x88A8for the outer tag of stacked 802.1ad/QinQ. The TPID sits where the EtherType normally would, which is how the NIC knows a tag follows. - a 2-byte TCI (Tag Control Information) — the actual label, decomposed below.
The original EtherType (e.g. 0x0800 for IP) moves to after the tag. The concrete struct makes this layout explicit (if_vlan.h:48-55):
struct vlan_ethhdr {
unsigned char h_dest[ETH_ALEN]; /* bytes 0–5 */
unsigned char h_source[ETH_ALEN]; /* bytes 6–11 */
__be16 h_vlan_proto; /* bytes 12–13: TPID, 0x8100 */
__be16 h_vlan_TCI; /* bytes 14–15: TCI */
__be16 h_vlan_encapsulated_proto; /* bytes 16–17: inner type */
};
(struct vlan_hdr at if_vlan.h:35-38 is just the TCI plus the inner ethertype — the two __be16s that follow the TPID, not the full 4-byte tag.)
Decomposing the 16-bit TCI
The TCI packs three subfields into 16 bits:

- bits 15–13 (top 3): PCP — Priority Code Point, the 802.1p class of service (0–7). Mask
VLAN_PRIO_MASK 0xe000, shiftVLAN_PRIO_SHIFT 13. - bit 12: DEI/CFI — Drop Eligible Indicator (formerly Canonical Format Indicator). Mask
VLAN_CFI_MASK 0x1000. - bits 11–0 (bottom 12): VID — the VLAN ID. Mask
VLAN_VID_MASK 0x0fff.
(All four constants at if_vlan.h:73-77.) Twelve bits give VLAN_N_VID 4096 possible IDs (0 and 4095 are reserved, leaving 4094 usable — which is why the labs cap out there). The VID is the “VLAN ID 100” the labs use.
When a modern NIC strips the tag in hardware (HW acceleration), it stashes the TCI in skb->vlan_tci and the TPID in skb->vlan_proto. These overlay a single u32 for cheap presence-testing (include/linux/skbuff.h:1052-1055):
union {
u32 vlan_all; /* test this for "any tag present?" */
struct {
__be16 vlan_proto; /* the TPID */
__u16 vlan_tci; /* the TCI */
};
};
That’s why skb_vlan_tag_present(skb) is literally !!skb->vlan_all (if_vlan.h:82): if either half is non-zero, a tag is present. And the accessors mask the TCI apart exactly as the bit diagram shows — skb_vlan_tag_get_id(skb) is vlan_tci & VLAN_VID_MASK (if_vlan.h:84), skb_vlan_tag_get_prio shifts out the PCP, skb_vlan_tag_get_cfi tests the DEI bit.
The kernel handles VLANs in two ways:
-
HW acceleration — the
vlan_allunion path described above (NIC strips the tag on RX, stack sees an untagged frame plus metadata, tested withskb_vlan_tag_present). On TX the kernel hands the NIC a possibly-untagged skb and the NIC adds the tag if requested. -
Software path — for NICs without HW VLAN, or for stacked VLANs (QinQ),
vlan_do_receiveinnet/8021q/vlan_core.cparses the tag and dispatches.
A VLAN device (eth0.100) is a virtual netdev that filters frames by VLAN ID:
sudo ip link add link eth0 name eth0.100 type vlan id 100
sudo ip link set eth0.100 up
sudo ip addr add 192.168.100.5/24 dev eth0.100
Now eth0.100 is a fully-featured interface with its own routes, MTU, and IP. Frames with VLAN ID 100 arriving on eth0 get redirected to eth0.100’s RX path; transmits on eth0.100 get tagged and sent through eth0. That redirection is vlan_do_receive() (net/8021q/vlan_core.c:10): it reads the VID with skb_vlan_tag_get_id(skb) (the & VLAN_VID_MASK from the bit diagram) and looks up the matching device with vlan_find_dev(skb->dev, vlan_proto, vlan_id). If a device is registered for that (interface, VID) pair, the frame is steered to it; if not, the lookup returns NULL and vlan_do_receive returns false (the Check question hinges on this).

There are no Dumb Questions
Q: What’s the difference between
skb->protocoland the type field?A: They’re set from the same source (
eth->h_proto), butskb->protocolis what the stack uses for dispatch. For VLAN-tagged frames, the kernel processes the tag, then setsskb->protocolto the inner type (h_vlan_encapsulated_proto). So a TCP-over-VLAN frame ends up withskb->protocol = ETH_P_IPafter VLAN handling. (Both are stored big-endian — Background 3.)Q: Can I have a VLAN inside a VLAN?
A: Yes — and there are two flavors. The simplest is plain 802.1Q-in-802.1Q double tagging, where both the outer and inner tags use TPID 0x8100:
ip link add link eth0.100 name eth0.100.200 type vlan id 200stacks a second default-protocol (0x8100) tag on top of the first. True QinQ (802.1ad) instead uses an 0x88a8 outer S-tag with an 0x8100 inner tag — but you must ask for that protocol explicitly, because the VLAN netlink default is 802.1Q (0x8100). Create the outer device withtype vlan proto 802.1ad id 100, then stack the inner withtype vlan proto 802.1q id 200. The bare command above does not produce an 0x88a8 outer tag.Q: How does the kernel pick the source MAC for outbound frames?
A: From
dev->dev_addrof the egress device. Modern NICs allow random MACs at boot (privacy viaMACAddressPolicy=randomin systemd-networkd) — and a randomly-generated MAC always has the locally-administered bit set (Background 2), so it never collides with a vendor OUI. The Ethernet header build happens indev_hard_header→eth_header(net/ethernet/eth.c).
Today’s experiment
See eth_type_trans in action
sudo bpftrace -e '
fentry:eth_type_trans {
$eth = (struct ethhdr *)args->skb->data;
printf("dst=%02x:%02x:%02x:%02x:%02x:%02x type=0x%04x\n",
$eth->h_dest[0], $eth->h_dest[1], $eth->h_dest[2],
$eth->h_dest[3], $eth->h_dest[4], $eth->h_dest[5], $eth->h_proto);
}' &
sleep 3 # let the fentry BTF probe attach before we trigger traffic
ping -c 5 8.8.8.8
sleep 1 # let the last frames drain through the probe
sudo killall bpftrace
The sleep 3 matters: a BTF fentry probe takes a second or two to attach, and a single ping -c 1 completes in a few hundred milliseconds — without the delay the trigger (and the kill) happen before the probe is live and you see nothing. eth_type_trans fires on every received frame, so with the probe up for a few seconds you’ll get output even apart from the ping:
dst=00:22:48:7c:ffffffb3:ffffffef type=0x0008
dst=00:22:48:7c:ffffffb3:ffffffef type=0x0008
dst=00:22:48:7c:ffffffb3:ffffffef type=0x0008
You’ll see actual MAC addresses and EtherTypes flying through, and both gotchas from the background sections show up at once:
- The MAC octets print as signed bytes, so values ≥ 0x80 show sign-extended as
ffffffb3— read the low two hex digits (b3). (This is a bpftrace printing quirk, not the bit semantics from Background 2.) - The type is byte-swapped.
h_protois__be16(big-endian on the wire, Background 3), so on a little-endian host the raw%04xprint shows IP as0x0008rather than0x0800. Mentally swap the bytes, or wrap the field inbswap().
Create a VLAN and watch traffic
sudo ip link add link eth0 name eth0.100 type vlan id 100
sudo ip link set eth0.100 up
sudo ip addr add 10.100.0.1/24 dev eth0.100
# verify
ip -d link show eth0.100 # see vlan_id 100, vlan_protocol 802.1Q
# capture both:
sudo tcpdump -i eth0 -e -n vlan 100 &
sudo tcpdump -i eth0.100 -n &
sleep 1
# trigger: no peer answers, but the ARP request for 10.100.0.2
# egresses eth0 tagged with VID 100 (and untagged on eth0.100)
ping -c 3 -W1 10.100.0.2
sleep 1
You should see the same frame twice — on eth0 carrying the 802.1Q tag that -e exposes (vlan 100, p 0, ethertype ARP ...) and on eth0.100 already stripped/untagged. The vlan 100, p 0 is the TCI decoded for you: VID 100, PCP 0 (the TCI bitfields from Background 4). That side-by-side is the VLAN device doing tag insertion on egress and removal on ingress. The ping gets no replies (there’s no host at 10.100.0.2) — that’s fine; the tagged ARP requests are the point.
# cleanup
sudo pkill tcpdump # stop both backgrounded captures (and drop promisc mode)
sudo ip link del eth0.100 # also removes the 10.100.0.1/24 address
See pkt_type stats
sudo bpftrace -e '
fexit:eth_type_trans {
@pkt_types[args->skb->pkt_type] = count();
}
interval:s:5 { exit(); }'
PACKET_HOST=0, BROADCAST=1, MULTICAST=2, OTHERHOST=3 (the constants from Background 2). We attach at fexit (function return), not fentry: eth_type_trans is the function that sets pkt_type (via eth_skb_pkt_type), so at entry the field still holds its prior value (usually 0). By the time the function returns, the assignment is complete. fexit exposes args-> for the input arguments just like fentry.
What to break
Toggle promiscuous mode
sudo ip link set eth0 promisc on
# re-run the "See pkt_type stats" trace above while generating traffic
sudo ip link set eth0 promisc off # restore
Now the kernel processes packets that aren’t addressed to your MAC (where pkt_type == PACKET_OTHERHOST — unicast frames whose dst doesn’t match dev_addr, Background 2). tcpdump enables this implicitly.
Caveat: on a mirrored/SPAN port, a hub, or a shared segment you’ll now see
@pkt_types[3](PACKET_OTHERHOST) appear. On an ordinary switched link or a cloud vNIC the switch never delivers other hosts’ unicast to your port, so key 3 may stay 0 even with promisc on — that’s expected, not a bug. Alwayspromisc offafterward so you don’t leave the NIC in promiscuous mode.
Set a non-default MAC
WARNING: do not change the MAC of the interface carrying your SSH session.
eth0is usually the management interface on a cloud/test VM — changing its MAC (or bringing the link down to do so) drops your connection, and many drivers reject an address change while the link is up. Use a throwaway interface instead:
# Option A (safe): a dummy interface, so you never touch the SSH link.
sudo ip link add mac-test type dummy
sudo ip link set mac-test address 02:00:00:00:00:01
ip link show mac-test # observe the new MAC
sudo ip link delete mac-test # cleanup
# Option B: a real spare NIC (NOT the one carrying SSH).
IF=eth1 # NOT your SSH interface; eth1 is illustrative —
# substitute an actual spare NIC present on your box
orig=$(cat /sys/class/net/$IF/address)
sudo ip link set $IF down
sudo ip link set $IF address 02:00:00:00:00:01
sudo ip link set $IF up
ip link show $IF # confirm; re-run the eth_type_trans trace
sudo ip link set $IF down
sudo ip link set $IF address "$orig" # restore the original MAC
sudo ip link set $IF up
We chose 02:00:00:00:00:01 deliberately (Background 2): first octet 0x02 has the unicast bit clear and the locally-administered bit set, so it’s guaranteed to be treated as a normal unicast address and guaranteed not to collide with any vendor’s burned-in OUI. Now eth_type_trans sees 02:00:00:00:00:01 as “us” — frames addressed there are PACKET_HOST. Useful for testing identity rules.
What to read in the kernel
net/ethernet/eth.c—eth_type_trans(line 155),eth_header, header_ops. The whole file is ~640 lines.include/uapi/linux/if_ether.h—struct ethhdr(line 177),ETH_ALEN/ETH_HLEN, theETH_P_*EtherType constants, andETH_P_802_3_MIN(line 127, the type-vs-length boundary).include/linux/etherdevice.h—is_multicast_ether_addr(line 130),is_broadcast_ether_addr(line 176),eth_proto_is_802_3(line 220),eth_skb_pkt_type(line 623),eth_skb_pull_mac(line 640).include/linux/if_vlan.h—struct vlan_ethhdr(line 48), the TCI masks (VLAN_VID_MASKetc., lines 73–77),skb_vlan_tag_present/skb_vlan_tag_get_id(lines 82–84).net/8021q/vlan_core.c— VLAN reception path (vlan_do_receive, line 10).net/8021q/vlan_dev.c— VLAN device implementation.include/linux/skbuff.h— searchvlan_tci,vlan_proto,vlan_all(the union at line 1052).
Bullet Points
- The Ethernet header is 14 bytes (
ETH_HLEN):h_dest[6]+h_source[6]+h_proto[2](struct ethhdr). That fixed size is whyskb_pull(skb, 14)landsskb->dataexactly on the L3 header. - The EtherType
h_protois overloaded: ≥ 0x0600 = real type (IP 0x0800, IPv6 0x86DD, ARP 0x0806, VLAN 0x8100); ≤ 1500 = a frame length (802.3). The boundary isETH_P_802_3_MIN 0x0600, decided byeth_proto_is_802_3(). - A MAC’s first octet carries two flag bits: bit 0 = I/G (0 unicast, 1 multicast); bit 1 = U/L (0 global/vendor, 1 locally-administered). “The multicast bit” is
addr[0] & 1. Broadcastff:ff:ff:ff:ff:ffis just the all-ones multicast.02:00:..:01= safe synthetic unicast (local + not multicast). - Network byte order is big-endian.
__be16/__be32mark wire values; convert withntohs/htons.eth_type_transreturnsh_protounswapped, soskb->protocolstays big-endian end-to-end; the demux does thentohs(). A raw print on little-endian x86 shows IP as0x0008. eth_type_transis the universal RX header parser; called from every Ethernet driver. Setsskb->dev,skb->mac_headerandskb->pkt_type, advancesskb->datapast the L2 header, and returns the L3 protocol id (which the driver assigns toskb->protocol—eth_type_transnever writes that field itself).skb->pkt_type: HOST / BROADCAST / MULTICAST / OTHERHOST.- VLAN = a 4-byte tag (
VLAN_HLEN) between src MAC and EtherType: 2-byte TPID (0x8100, or 0x88a8 for QinQ) + 2-byte TCI. The TCI splits into PCP[15:13] + DEI[12] + VID[11:0]; 12-bit VID → 4094 usable IDs. - HW VLAN acceleration: NIC strips tag, kernel reads
skb->vlan_tci/vlan_proto(thevlan_allunion).skb_vlan_tag_present=!!vlan_all;skb_vlan_tag_get_id=vlan_tci & VLAN_VID_MASK. Otherwise software path innet/8021q/. - VLAN devices are virtual netdevs that filter+strip by VLAN ID;
vlan_do_receivelooks them up withvlan_find_dev. - TX side:
dev_hard_header→eth_headerbuilds the L2 frame, usingdev->dev_addras the source MAC.
Check question
A frame arrives at eth0 with VLAN tag 100, but no eth0.100 device exists. What happens?
Click to reveal answer
Answer: vlan_do_receive() reads the VID with skb_vlan_tag_get_id(skb) (= vlan_tci & VLAN_VID_MASK = 100) and looks up a registered VLAN device for (eth0, 100) via vlan_find_dev, finds none, and returns false (net/8021q/vlan_core.c:10, lookup fails at dev.c:6077). The frame isn’t redirected to a VLAN netdev; __netif_receive_skb_core marks it PACKET_OTHERHOST (net/core/dev.c:6111) and it is delivered to the L3 receive handler ip_rcv via the normal ptype dispatch — but ip_rcv (in ip_rcv_core) drops it immediately because pkt_type == PACKET_OTHERHOST, bumping the rx_otherhost_dropped core stat (via dev_core_stats_rx_otherhost_dropped_inc, net/ipv4/ip_input.c:509). So it reaches L3 ingress but is never routed or processed. To accept untagged “unknown VLAN” traffic, you’d configure a VLAN-aware bridge with the relevant VID on the port.
Tomorrow
Day 7: ARP and the neighbour subsystem. How the kernel learns its peers’ MAC addresses and what happens when neighbour entries go stale.
Day 7 — ARP and the neighbour subsystem
Today’s mission: see how Linux learns its peers’ MAC addresses, the state machine an ARP entry walks through, and what happens at scale — plus the four pieces of background that make the state machine make sense: the kernel’s notion of time, the kernel timer that drives the states, the ARP packet on the wire, and how a flat-looking “table” is really a hashed, garbage-collected cache. Total time: ~110 minutes.
ARP in one paragraph
When Linux wants to send an IP packet to some next-hop IP on a directly connected network, it needs the destination’s MAC. The protocol is ARP (RFC 826): broadcast a query, receive a reply, cache the result. The cache is what ip neigh shows.
But “ARP” in Linux is a specific case of a more general neighbour subsystem at net/core/neighbour.c. The same code handles IPv6 NDP (Neighbour Discovery Protocol) and InfiniBand IPoIB. The L4-protocol-specific bits live in net/ipv4/arp.c.
How does an arriving ARP frame even reach this code? Recall eth_type_trans from Day 6: it reads the Ethernet frame’s EtherType and stores it in skb->protocol, and the core stack uses that value to pick a handler. ARP frames carry ETH_P_ARP = 0x0806 (include/uapi/linux/if_ether.h:54), so they’re dispatched to arp_rcv rather than ip_rcv (0x0800). Same demux you saw on the RX path — just a different EtherType key.
Most of this chapter is about time: “REACHABLE for 30s,” “probe every 1s,” “stale after 60s.” So before anything else, we need to know what the kernel means by a second.
Background: jiffies and HZ — the kernel’s notion of time
The kernel can’t call gettimeofday() on every packet — that’s far too expensive for the hot path. Instead it keeps a single, dirt-cheap counter that a periodic timer interrupt bumps on a fixed schedule:
- A jiffy is one tick of the kernel’s periodic timer interrupt.
- The global counter
jiffiesincrements by one on every tick. Reading it is a single memory load — this is the kernel’s cheap monotonic clock. HZis the tick rate (ticks per second). It’s a compile-time constant —# define HZ CONFIG_HZ(include/asm-generic/param.h:8) — and common values are 100, 250, or 1000. On aHZ=1000kernel, one jiffy is 1 ms; onHZ=250, one jiffy is 4 ms.
The key consequence: a duration in seconds is seconds * HZ jiffies. That’s why the kernel source writes 30 * HZ, 1 * HZ, 5 * HZ instead of literal millisecond counts — the same expression yields the right tick count whatever HZ the kernel was built with. Look at the ARP defaults (net/ipv4/arp.c:152):
struct neigh_table arp_tbl = {
.parms = {
.reachable_time = 30 * HZ, /* REACHABLE lasts ~30s */
.data = {
[NEIGH_VAR_MCAST_PROBES] = 3,
[NEIGH_VAR_UCAST_PROBES] = 3,
[NEIGH_VAR_RETRANS_TIME] = 1 * HZ, /* probe spacing ~1s */
[NEIGH_VAR_DELAY_PROBE_TIME] = 5 * HZ, /* DELAY window ~5s */
[NEIGH_VAR_GC_STALETIME] = 60 * HZ, /* GC after ~60s */
/* ... */
},
},
.gc_thresh1 = 128, .gc_thresh2 = 512, .gc_thresh3 = 1024,
};
So when this chapter says “REACHABLE for 30s” or “retrans 1s,” the struct literally holds 30 * HZ and 1 * HZ jiffies — not seconds, not milliseconds.
The 30s is a base, not a fixed window.
.reachable_time = 30 * HZis only the seed: each entry’s effectivereachable_timeis randomized byneigh_rand_reach_time(net/core/neighbour.c:130) to a value in(1/2)*base … (3/2)*base— roughly 15–45s — and re-randomized periodically. The point is to avoid a fleet of entries all expiring on the same jiffy. So “~30s” throughout this chapter means “30s base, 15–45s in practice.”
The neighbour’s timestamp fields are jiffies snapshots too. In struct neighbour (include/net/neighbour.h:140):
unsigned long confirmed; /* neighbour.h:145 — jiffies at last reachability confirmation */
unsigned long updated; /* neighbour.h:146 — jiffies at last state change */
“Is this entry stale?” is therefore never wall-clock arithmetic; it’s a jiffy comparison like time_after(now, confirmed + reachable_time), where now = jiffies. You’ll see that exact pattern in the timer handler in a moment.
One footnote so the sysctls make sense. Because the unit is jiffies, the same
.data[]slot is exposed to userspace under two names — a ticks form and a*_MSmilliseconds alias (e.g.NEIGH_VAR_RETRANS_TIMEvsRETRANS_TIME_MS). That’s whyip-sysctl.rstlets you read or writeretrans_time(jiffies) orretrans_time_ms(milliseconds) for the same underlying value.

The ARP wire format
Before we cache MACs, let’s see what’s actually in an ARP packet — otherwise “request is broadcast, reply is unicast” and “gratuitous ARP” are just assertions. An ARP message is a tiny fixed header followed by four variable-length address fields. The fixed header is struct arphdr (include/uapi/linux/if_arp.h:145):
struct arphdr {
__be16 ar_hrd; /* hardware type (1 = Ethernet) */
__be16 ar_pro; /* protocol type (0x0800 = IPv4) */
unsigned char ar_hln; /* hardware address length (6 for MAC) */
unsigned char ar_pln; /* protocol address length (4 for IPv4)*/
__be16 ar_op; /* opcode: request or reply */
};
/* then, for ARP-over-Ethernet (commented in the header at if_arp.h:156-159): */
/* ar_sha[6] sender hardware address (SHA) */
/* ar_sip[4] sender IP address (SIP) */
/* ar_tha[6] target hardware address (THA) */
/* ar_tip[4] target IP address (TIP) */
The opcode (ar_op) is what distinguishes the two messages (include/uapi/linux/if_arp.h:107-108):
ARPOP_REQUEST= 1 — “who has TIP? tell SIP.” Sent to the Ethernet broadcast MACff:ff:ff:ff:ff:ffso every host on the segment sees it. The sender fills in SHA + SIP + TIP and leaves THA = 00:00:00:00:00:00 (it doesn’t know the target’s MAC yet — that’s the whole question).ARPOP_REPLY= 2 — “TIP is at THA.” The host that owns TIP fills in its own MAC as THA and sends it unicast straight back to the asker.
That’s exactly what a tcpdump -e capture shows in the lab below: one broadcast request with an empty target MAC, one unicast reply with it filled in. The parsing happens in arp_process (net/ipv4/arp.c:702), reached from arp_rcv (net/ipv4/arp.c:967); outgoing requests/replies are built and sent by arp_send (net/ipv4/arp.c:323).
Gratuitous ARP falls right out of this layout: it’s just a request whose TIP equals its SIP. Re-read that with the fields in hand — the node is announcing “I (SIP) now own this IP,” and because it’s a broadcast request, everyone updates their cache unsolicited. No reply is expected; the point is the announcement.

The neighbour table

struct neigh_table arp_tbl (net/ipv4/arp.c:152) holds all IPv4 neighbour entries. Each entry is a struct neighbour (include/net/neighbour.h:140) with:
primary_key(neighbour.h:169): the next-hop IP address — the lookup key.ha[MAX_ADDR_LEN]: hardware address (MAC).dev: which netdev this neighbour is reachable through.nud_state: state machine (REACHABLE, STALE, INCOMPLETE, …).confirmed/updated: jiffies snapshots (the timestamps from the jiffies section).arp_queue: skbs waiting for resolution.
It’s a hash table, not a list
We keep saying “the table,” but it isn’t a flat list. arp_tbl.nht (include/net/neighbour.h:244) points at a struct neigh_hash_table (include/net/neighbour.h:201):
struct neigh_hash_table {
struct hlist_head *hash_heads; /* array of hash buckets */
unsigned int hash_shift; /* current size = 1 << shift */
__u32 hash_rnd[NEIGH_NUM_HASH_RND]; /* random seeds (=4, anti-collision) */
struct rcu_head rcu;
};
An entry lives in the bucket chosen by hashing its key (next-hop IP) plus dev. Each struct neighbour is linked into its bucket through struct hlist_node hash; (neighbour.h:141). A lookup — neigh_lookup (net/core/neighbour.c:625), or the fast inline __neigh_lookup — hashes the key, then walks just that one bucket’s hlist. As the population grows the table is rehashed to a bigger size, keeping chains short. That’s why a gateway with thousands of peers still resolves each next hop in roughly O(1).
Two details make the hot path safe and cheap:
- RCU read side. The bucket walk happens under RCU read-side protection — no lock on the per-packet lookup path. (RCU, in one sentence: readers proceed lock-free, and a removed object isn’t actually freed until all concurrent readers have finished, so a lookup can safely hold a pointer that another CPU is deleting.)
- Refcounts, freed via RCU. Each entry carries
refcount_t refcnt;(neighbour.h:148) and astruct rcu_head rcu;(neighbour.h:166). This is the same free-at-zero refcount discipline you learned forsk_buff->userson Day 1, applied to neighbours: hold a reference while you use the entry, drop it when done, and the object is reclaimed only when the count hits zero and RCU says no reader can still see it.
Background: the timer that drives the state machine
Here’s the thing the state diagram never shows: states don’t change on their own. The engine that advances a neighbour from REACHABLE to STALE, or from INCOMPLETE to FAILED, is a per-entry kernel timer.
Each struct neighbour embeds one (include/net/neighbour.h:151):
struct timer_list timer;
A timer_list is the kernel’s basic one-shot timer: you arm it with a future expiry expressed in jiffies, and when jiffies reaches that value the kernel’s timer machinery calls the callback you registered. The neighbour subsystem wires the callback at construction time (net/core/neighbour.c:534):
timer_setup(&n->timer, neigh_timer_handler, 0);
Day 5 introduced workqueues — deferred work that runs in process context and may sleep — when tearing down a netns. A
timer_listis a different mechanism Day 5 did not cover: its callback runs atomically (softirq context, cannot sleep) and fires at a scheduled jiffy, not “whenever a worker thread gets around to it.” The neighbour subsystem uses a timer for each entry’s per-entry state, and (separately) adelayed_workfor table-wide garbage collection — we’ll meet the GC one later.
When the timer fires, neigh_timer_handler (net/core/neighbour.c:1103) runs. It reads now = jiffies (neighbour.c:1114), looks at nud_state and the timing constants, and either re-arms itself (still probing) or transitions the entry. The real transitions, straight from the source:
- REACHABLE → if
nowis pastconfirmed + reachable_time, demote to STALE (or DELAY if the entry was recently used). (neighbour.c:1120-1139) - DELAY → if confirmation came in, promote back to REACHABLE; otherwise go to PROBE and start actively soliciting. (
neighbour.c:1140-1162) - PROBE / INCOMPLETE → re-arm at
now + retrans_timeto send the next probe — until the probe budget is exhausted.
That budget is the missing piece behind “6 probes / 3 probes.” It’s computed by neigh_max_probes (net/core/neighbour.c:1054):
static __inline__ int neigh_max_probes(struct neighbour *n)
{
struct neigh_parms *p = n->parms;
return NEIGH_VAR(p, UCAST_PROBES) + NEIGH_VAR(p, APP_PROBES) +
(n->nud_state & NUD_PROBE ? NEIGH_VAR(p, MCAST_REPROBES)
: NEIGH_VAR(p, MCAST_PROBES));
}
With ARP defaults (UCAST_PROBES = 3, MCAST_PROBES = 3, MCAST_REPROBES = 0, APP_PROBES = 0):
- INCOMPLETE (not yet in PROBE):
3 + 0 + 3 = 6is the threshold. - PROBE (already had a MAC, re-confirming):
3 + 0 + 0 = 3is the threshold.
But the threshold and the number of packets actually sent differ for INCOMPLETE. When __neigh_event_send first drives an entry into INCOMPLETE it pre-seeds the probe counter to UCAST_PROBES = 3 (neighbour.c:1224, atomic_set(&neigh->probes, UCAST_PROBES)). The timer then increments the counter once per multicast solicitation, climbing 3 → 4 → 5 → 6; at 6 it equals the threshold and the entry fails. So only 3 multicast ARP requests are actually emitted in INCOMPLETE — and they are all multicast, because arp_solicit computes probes -= UCAST_PROBES (arp.c:376) which is always ≥ 0 here, forcing the multicast branch every time. No unicast probe is possible while INCOMPLETE: there is no MAC to unicast to. (Those UCAST_PROBES unicast probes belong to PROBE-state revalidation, where the entry already has a stale MAC.)
Once probes >= neigh_max_probes(neigh) for an INCOMPLETE or PROBE entry, the handler gives up (neighbour.c:1164-1169): INCOMPLETE → NUD_FAILED, PROBE → STALE/FAILED. That is the timer-driven mechanism behind “first ping takes ~1ms longer” (one resolution round-trip before the entry is REACHABLE) and “INCOMPLETE → FAILED after the probes.”

The state machine

A neighbour entry walks through states — and now you know the timer is what moves it:
- NONE — newly created, no resolution attempted yet.
- INCOMPLETE — ARP request sent, waiting for reply. Skbs going to this neighbour queue here.
- REACHABLE — fresh, recent confirmation (default
reachable_time = 30s, i.e.30 * HZjiffies). - STALE — has MAC but old. Next packet triggers a confirmation probe.
- DELAY — sent a packet expecting the L4 to confirm reachability quickly.
- PROBE — actively probing via ARP.
- FAILED — resolution timed out. INCOMPLETE sends ~3 multicast ARP requests ~1s apart, then fails; PROBE-state revalidation (entry already has a stale MAC) sends up to 3 unicast probes instead. (Full derivation — why the budget is 6 but only 3 are sent — is in the timer-background section above.)
The state machine is the canonical answer to “why does my first ping take 1ms longer than subsequent ones?” — first packet goes through INCOMPLETE → REACHABLE; later packets hit a cached entry directly.
Lookup on TX

The packet that triggers ARP: async resolution, a bounded queue, and two fast paths

ip_finish_output2 does not call ARP directly. It reaches the inline neigh_output(neigh, skb, skip_cache) helper, which makes two decisions: can it use a cached L2 header immediately, and if not, which function does neigh->output currently name? Follow the states rather than treating the pointer as the whole fast path:
-
First packet, no usable address. A new NUD_NONE entry has
neigh->output = neigh->ops->output, which for ordinary ARP isneigh_resolve_output.neigh_event_send→__neigh_event_sendchanges NONE to INCOMPLETE, arms the timer, sends a solicitation, queues the supplied skb, and returns1.neigh_resolve_outputtherefore does not transmit that skb; TX unwinds instead of blocking for an ARP round-trip. -
Only unresolved packets queue. While the state is INCOMPLETE, skbs are parked on the per-neighbour
arp_queue. Itsarp_queue_len_bytesis bounded byQUEUE_LEN_BYTES; when adding a new skb would exceed the limit, the loop dequeues and drops the oldest entries withSKB_DROP_REASON_NEIGH_QUEUEFULLuntil the new skb fits. One unreachable neighbour therefore has a fixed memory budget. -
The reply lands.
arp_rcv→neigh_updatestores the MAC and changes the state to REACHABLE.neigh_connectwritesneigh->output = neigh->ops->connected_output, andneigh_update_process_arp_queuedrains the unresolved queue. It relooks up the top-level neighbour for each skb before reinjecting it because qdiscs such as shapers can lead to a different neighbour object. -
Connected output has two forms. On the ordinary call path,
neigh_output()first checksNUD_CONNECTEDandhh.hh_len. If both hold,neigh_hh_output()copies the cached L2 header under its sequence lock, pushes it, and callsdev_queue_xmitwithout callingneigh->outputat all. If no header cache is usable, the helper falls through to the function pointer; genericneigh_connected_output()rebuilds the header withdev_hard_headerand transmits it.hh_cacheandneigh_connected_outputare alternatives, not one combined fast path. -
STALE is usable, not unresolved. When reachability ages,
neigh_suspectrestores the resolving output function. The next packet reachesneigh_resolve_output;__neigh_event_sendchanges STALE to DELAY but returns success, so that packet is sent immediately with the known MAC. If upper-layer confirmation does not arrive duringdelay_first_probe_time, the timer advances to PROBE and sends solicitations. A STALE packet does not enterarp_queue.
If INCOMPLETE probing exhausts its budget, neigh_timer_handler sets FAILED and neigh_invalidate removes queued skbs through ops->error_report before purging the remainder. For IPv4, arp_error_report calls dst_link_failure and frees the skb with SKB_DROP_REASON_NEIGH_FAILED; the exact user-visible error depends on the route/socket context.
This connects forward: routing chooses the next hop that the neighbour subsystem resolves, and the bounded oldest-first eviction pattern reappears in the queueing discussion on Day 23.
Which IPs actually get ARP’d? on-link vs via-gateway
The FAILED lab below hinges on a routing fact, so here’s the rule (the full FIB is Day 8). The kernel only ARPs for the next hop, and the next hop depends on routing:
- A destination on a directly-connected subnet (on-link) is its own next hop — the kernel ARPs for it directly.
- A destination outside every local subnet is reached via the gateway — so the kernel ARPs for the gateway, and never for the remote IP at all.
“Directly connected” just means the destination matches one of the interface’s configured subnet prefixes (e.g.
192.168.1.0/24oneth0). This is the same next-hop IP that Day 3 saidip_finish_output2hands to the neighbour subsystem: routing chooses the next-hop IP, neighbour resolves it to a MAC. And it’s confirmed by the table itself —arp_tblhaskey_len = 4and is keyed byprimary_key(neighbour.h:169), i.e. one entry per next-hop IPv4 address.This is why the “Watch FAILED state” lab must target an unused address inside your own subnet: only then is the destination its own next hop, so your packet triggers a real ARP that can time out to FAILED. An off-subnet address like
10.99.99.99would just resolve the gateway (already cached) and never produce a FAILED entry for the address you typed.
There are no Dumb Questions
Q: What is “gratuitous ARP”?
A: A node announcing its MAC unsolicited (e.g., on interface up, or after a failover). Linux sends one as an
arp_send(ARPOP_REQUEST, ETH_P_ARP, ...)with target IP == source IP, frominetdev_send_gratuitous_arpinnet/ipv4/devinet.c. It updates other nodes’ caches without them having to ask. (With the wire format in hand: it’s a request whose TIP == SIP — see the ARP wire-format section.)Q: What’s
arp_announcefor?A: Sysctl that controls which IP the kernel uses as source for ARP requests. Default 0 (any local IP); 1 prefers same-subnet; 2 always uses the primary IP of the egress interface. Important for multi-homed servers.
Q: Why are there gc_thresh1/2/3?
A: gc_thresh3 is a hard cap; new ARP attempts beyond it fail. Heavy-traffic gateways routinely hit the default 1024 — symptom:
neighbor table overflow!in dmesg. Bump via sysctl. (The exact mechanism is the next section.)
How garbage collection enforces gc_thresh1/2/3
The table can’t grow forever, so a per-table atomic counter, gc_entries, is bumped every time an entry is created — and creation consults the three thresholds before allocating. The check, in the create path (net/core/neighbour.c:507):
entries = atomic_inc_return(&tbl->gc_entries) - 1;
gc_thresh3 = READ_ONCE(tbl->gc_thresh3);
if (entries >= gc_thresh3 ||
(entries >= READ_ONCE(tbl->gc_thresh2) &&
time_after(now, READ_ONCE(tbl->last_flush) + 5 * HZ))) {
if (!neigh_forced_gc(tbl) && entries >= gc_thresh3) {
net_info_ratelimited("%s: neighbor table overflow!\n", tbl->id);
NEIGH_CACHE_STAT_INC(tbl, table_fulls);
goto out_entries; /* returns -ENOBUFS */
}
}
That’s the whole three-tier behaviour, grounded:
- Below
gc_thresh1(128): the periodic GC doesn’t even bother —neigh_periodic_workskips the bucket scan withif (atomic_read(&tbl->entries) < gc_thresh1) goto out;(net/core/neighbour.c:1000), and theout:path still re-queues the delayed_work, so the worker keeps rescheduling itself. (That periodic GC is adelayed_workrunning in the process context Day 5 introduced for netns teardown — no need to re-teach it.) - At/above
gc_thresh2(512) and more than 5 s (5 * HZ) since the last flush: a forced synchronous GC runs,neigh_forced_gc(net/core/neighbour.c:253), reclaiming stale entries back down towardgc_thresh2. - At/above
gc_thresh3(1024): if even forced GC can’t get back under the cap, creation fails with-ENOBUFSand emits the rate-limited"%s: neighbor table overflow!"— the exact dmesg symptom this chapter and the Check question describe.
Two things to keep straight:
- The data packet isn’t dropped because of this. The kernel just couldn’t create the neighbour entry; that surfaces as a resolution failure / brief hang, not a thrown-away packet at the GC layer. (That’s the grounding for the Check answer’s “it just refuses to create new entries.”)
- Reclamation targets STALE/old entries;
NUD_PERMANENTentries are exempt and never GC’d. That’s why the staticnud permanentpins in the labs survive GC pressure.
Today’s experiment
Before you start: the commands below use
eth0and the gateway192.168.1.1as examples. Replace them with your interface and default gateway — derive both fromip route show default:IFACE=$(ip route show default | awk '{print $5; exit}') GW=$(ip route show default | awk '{print $3; exit}') echo "iface=$IFACE gateway=$GW" # iface=eth0 gateway=10.0.0.1Run verbatim against a machine whose interface is
ens3/enp0s3or whose gateway is different, andip neigh flush dev eth0fails with “Cannot find device” andping 192.168.1.1goes nowhere. Substitute$IFACE/$GW(or your real values) wherever you seeeth0/192.168.1.1below.
See your ARP table
ip neigh show
# 192.168.1.1 dev eth0 lladdr aa:bb:cc:11:22:33 REACHABLE
# 192.168.1.20 dev eth0 lladdr aa:bb:cc:44:55:66 STALE
Force an ARP from scratch
sudo ip neigh flush dev eth0
ping -c 1 192.168.1.1
ip neigh show
# entry now REACHABLE
Watch ARP packets
sudo tcpdump -i eth0 -n arp -e &
sudo ip neigh flush dev eth0
ping -c 1 192.168.1.1
sudo pkill tcpdump # stop the background capture
You’ll see the ARP request (broadcast) and reply (unicast) — and now you can read them field-by-field: the request goes to ff:ff:ff:ff:ff:ff with ARPOP_REQUEST (op 1) and an empty target MAC; the reply comes back unicast with ARPOP_REPLY (op 2) and the target MAC filled in. The pkill is essential: a backgrounded tcpdump with no count/timeout otherwise runs forever, spamming your terminal with every later ARP on the link.
Trace neighbour state changes
sudo bpftrace -e '
fentry:neigh_update {
printf("update: state=%d new_state=%d\n",
args->neigh->nud_state, args->new);
}'
The numbers are NUD bitmask values (include/uapi/linux/neighbour.h): 1=INCOMPLETE, 2=REACHABLE, 4=STALE, 8=DELAY, 16=PROBE, 32=FAILED. In another terminal, flush and re-ping the gateway to drive a resolution. neigh_update() is the packet/netlink-driven path — it’s only ever called with NUD_STALE, NUD_REACHABLE, NUD_FAILED, or a user/ndisc-supplied state, so this hook catches transitions like INCOMPLETE→REACHABLE on an arriving reply:
# update: state=1 new_state=2 (INCOMPLETE -> REACHABLE, ARP reply received)
What this hook will never show is the internal STALE→DELAY, DELAY→PROBE, or timeout-driven INCOMPLETE→FAILED transitions: those are WRITE_ONCE(neigh->nud_state, ...) stores inside __neigh_event_send (neighbour.c:1249) and neigh_timer_handler — they don’t flow through neigh_update() at all. To watch those, attach an fentry on neigh_timer_handler instead, or trace the nud_state field directly.
These tie directly back to the state machine at the top of the chapter. (Swap %d for %x in the printf if you’d rather see the raw hex #define values.)
Probe gc thresholds
sysctl net.ipv4.neigh.default.gc_thresh1 # default 128
sysctl net.ipv4.neigh.default.gc_thresh2 # default 512
sysctl net.ipv4.neigh.default.gc_thresh3 # default 1024
# bump for high-fanout servers:
sudo sysctl -w net.ipv4.neigh.default.gc_thresh3=8192
# restore the default when you're done (this change is non-persistent —
# it's lost on reboot and not written to sysctl.conf):
sudo sysctl -w net.ipv4.neigh.default.gc_thresh3=1024
These are the very gc_thresh1/2/3 fields from arp_tbl (net/ipv4/arp.c:152) whose create-time check produces neighbor table overflow!.
What to break
Statically pin a wrong MAC
Warning: do not pin a wrong MAC for the gateway on the interface you’re SSH-ing in over — it drops your session before you can run the undo, locking you out. Pin a non-gateway LAN peer instead, or run this from the local console.
sudo ip neigh add 192.168.1.1 lladdr aa:bb:cc:00:00:00 dev eth0 nud permanent
ping -c 3 -W 1 192.168.1.1 # no replies — packets sent to a MAC nobody owns
# undo:
sudo ip neigh del 192.168.1.1 dev eth0
This shows the cache is authoritative until the kernel re-resolves. The -c 3 -W 1 is important: a bare ping runs until you Ctrl-C, and until you do, the del line never executes and the entry stays poisoned. (The nud permanent entry is also GC-exempt — it survives table pressure until you delete it.)
Watch FAILED state
To actually watch INCOMPLETE → FAILED you must (a) target an address that is directly connected — pick an unused IP inside your eth0 subnet, not an off-subnet one like 10.99.99.99 (those route via the gateway, so the kernel never ARPs for them — see the on-link vs via-gateway box) — and (b) send real traffic to kick off resolution. Note too that a bare ip neigh add IP dev eth0 with no lladdr/nud is rejected by modern iproute2 (“No link layer address given”) and creates no entry at all.
# pick an unused IP in your eth0 subnet, e.g. 192.168.1.250
sudo ip neigh flush 192.168.1.250 dev eth0 2>/dev/null
ping -c1 -W1 192.168.1.250 || true # queues a packet -> kernel starts ARPing
sleep 8 # past the INCOMPLETE probe budget: counter climbs from its UCAST_PROBES seed (3) to neigh_max_probes()=6, sending 3 multicast probes ~1s apart
ip neigh show 192.168.1.250
# 192.168.1.250 dev eth0 FAILED (briefly INCOMPLETE first)
sudo ip neigh del 192.168.1.250 dev eth0 # cleanup
The 8-second wait is no accident: it’s past the INCOMPLETE probe budget. The counter is seeded at UCAST_PROBES (3) and increments once per multicast solicitation, so it sends 3 multicast probes spaced retrans_time (1 * HZ) apart (~3s of probing) before neigh_max_probes() = 6 trips and the timer flips the entry to FAILED. Eight seconds leaves a comfortable margin.
What to read in the kernel
net/core/neighbour.c— the generic subsystem. Readneigh_lookup(line 625),___neigh_create(line 646),neigh_update,neigh_timer_handler(line 1103 — the state machine engine),neigh_max_probes(line 1054 — the probe budget), and the GC create-time check (lines 507–516).net/ipv4/arp.c— ARP-specific protocol.arp_rcv(line 967),arp_send(line 323),arp_process(line 702),arp_solicit, andarp_tbl(line 152). ~1500 lines.include/net/neighbour.h—struct neighbour(line 140),struct neigh_hash_table(line 201), thenhtfield (line 244), the NUD_* state constants.include/uapi/linux/if_arp.h—struct arphdr(line 145),ARPOP_REQUEST/ARPOP_REPLY(lines 107–108).Documentation/networking/ip-sysctl.rst— theneigh.*sysctls (gc thresholds, probe counts, timers, and the jiffies-vs-*_msaliases); pair it with the source itself.
Bullet Points
- The kernel measures time in jiffies (
jiffiesincrements once per timer tick;HZticks/second). A duration isseconds * HZjiffies — hence30 * HZ,1 * HZin the source.neighbour.confirmed/updatedare jiffies snapshots; staleness is atime_after(now, confirmed + reachable_time)comparison. - An ARP packet is
struct arphdr(hw/proto type, lengths, opcode) + SHA/SIP/THA/TIP.ARPOP_REQUEST(1) is broadcast with an empty THA;ARPOP_REPLY(2) is unicast with THA filled in. Gratuitous ARP = a request with TIP == SIP. - The neighbour subsystem (ARP for IPv4, NDP for IPv6) lives at
net/core/neighbour.c. The “table” is a resizable, RCU-protected hash table (neigh_hash_table) keyed per next-hop IP; lookups walk one bucket lock-free; entries are refcounted and RCU-freed (same free-at-zero model as Day 1’s sk_buff). - A per-entry
timer_listdrives the state machine:neigh_timer_handlerre-arms or transitions entries.neigh_max_probes()= 6 for INCOMPLETE, 3 for PROBE (ARP defaults), but because the counter is pre-seeded toUCAST_PROBES(3), an INCOMPLETE entry actually emits 3 multicast ARP requests before FAILED; the 3 unicast probes belong to PROBE-state revalidation of an entry that already has a (stale) MAC. - Entries cycle through NONE → INCOMPLETE → REACHABLE → STALE → DELAY → PROBE → REACHABLE/FAILED.
- The kernel only ARPs the next hop: on-link destinations are their own next hop; off-subnet ones resolve the gateway (full FIB is Day 8).
- TX-side lookup is a lockless RCU hash lookup —
ip_neigh_for_gw→__ipv4_neigh_lookup_norefinip_finish_output2, falling back to__neigh_createon a miss (the exportedneigh_lookupis for netlink/proc callers, not the per-packet path). Skbs queue onarp_queuewhile a neighbour is INCOMPLETE. - GC thresholds (
gc_thresh1/2/3, default 128/512/1024) cap entry count via agc_entriescounter checked at create time; exceedinggc_thresh3after forced GC returns-ENOBUFSand logsneighbor table overflow!.NUD_PERMANENTentries are GC-exempt. - Inspect:
ip neigh show. Manipulate:ip neigh add/del/replace.
Check question
You set net.ipv4.neigh.default.gc_thresh3=128 on a server with 500 active client peers. What symptoms appear?
Click to reveal answer
Answer: neighbor table overflow! messages in dmesg. New connections to peers whose ARP entries got evicted will hang briefly while ARP re-resolves; if entries are evicted faster than they’re re-resolved, traffic stalls. The fix is to raise gc_thresh3 (and gc_thresh1/2 proportionally — typically 4096/8192/16384 for a busy server). The kernel doesn’t drop packets directly because of this; it just refuses to create new entries, which manifests as resolution failures.
Tomorrow
Day 8: IP routing — the FIB. How the kernel decides where to send a packet (and therefore which next-hop IP the neighbour subsystem resolves), and why route lookups are so cheap on modern hardware.
Day 8 — IP routing: the FIB
Today’s mission: see how Linux decides where to send a packet, learn the data structure that powers route lookups, and understand how the result gets stapled onto the packet — and how
ip routeprograms the table in the first place. We’ll teach four mechanisms the routing path leans on (thedst_entryhandle, longest-prefix-match semantics, the LC-trie, and the rtnetlink config channel) so nothing in the path is a black box. Total time: ~110 minutes.
What “routing” means in Linux
Every packet that needs to go somewhere not-here requires a routing decision: which next-hop, which output interface, which source IP. The decision is fast — sub-microsecond on modern hardware, even against tables with hundreds of thousands of routes.
That speed comes from the FIB (Forwarding Information Base) and the LC-trie (level-compressed trie) data structure backing it. But before we touch the trie, we have to be honest about some things the rest of this chapter quietly assumes you already know: what it means for a route to “match,” what handle the lookup result is delivered through, how the trie itself works, and how a route got into the table at all. Days 1–7 never taught any of these. Two of them we teach up front (what “match” means, and the handle the result rides on); the trie itself and the write-side config channel we build up as we reach them — intuition first, then the concrete v7.1 struct.
Background 1: longest-prefix-match — what “the route matches” actually means
Picture your routing table as a stack of rules, each one saying “addresses that start with these bits go this way.” That “starts with these bits” idea is a prefix, and a route is a prefix plus a prefix length:
10.0.0.0/24means “addresses whose top 24 bits equal10.0.0.0.”10.0.0.0/8means “addresses whose top 8 bits equal10.”0.0.0.0/0— the default route — has prefix length 0, so zero bits have to match. It matches everything.
Here’s the catch that makes routing interesting: a single destination address can match several routes at once. A packet to 10.0.0.5 matches all three of the routes above simultaneously. So which one wins?
The rule: the most specific match wins — the longest prefix length. 10.0.0.0/24 (24 bits) beats 10.0.0.0/8 (8 bits) beats 0.0.0.0/0 (0 bits). This is longest-prefix-match (LPM), and it’s what makes routing deterministic. It’s also why the default route is the last resort: 0.0.0.0/0 only wins when nothing more specific matched. (That’s exactly why the “break a default route” lab later in this chapter works — strip away the specific routes and the /0 catches the packet.)

The lookup result records which prefix won. That result is a struct fib_result (include/net/ip_fib.h:173):
struct fib_result {
__be32 prefix; /* the matched prefix, e.g. 10.0.0.0 */
unsigned char prefixlen; /* its length, e.g. 24 — how the winner was chosen */
unsigned char nh_sel;
unsigned char type; /* RTN_UNICAST, RTN_LOCAL, RTN_BROADCAST, ... */
unsigned char scope;
struct fib_nh_common *nhc; /* next-hop info */
struct fib_info *fi;
struct fib_table *table;
/* ...tclassid, dscp, fa_head in v7.1... */
};
prefix and prefixlen are the matched prefix and its length — the winner of the LPM contest. The interesting field is nhc, the next-hop, a struct fib_nh_common (include/net/ip_fib.h:83):
struct fib_nh_common {
struct net_device *nhc_dev; /* egress interface */
int nhc_oif;
u8 nhc_gw_family;
union {
__be32 ipv4; /* gateway IP, if any */
struct in6_addr ipv6;
} nhc_gw;
/* ... */
};
It tells you the egress device (nhc_dev/nhc_oif) and the gateway IP (if any) (nhc_gw_family + nhc_gw). (The preferred source IP and the route MTU are not in fib_nh_common — they come from the enclosing fib_nh’s nh_saddr and from the route’s metrics on the dst, respectively.)
The type field is why the same machinery answers both “forward this” and “this is for us.” RTN_UNICAST (include/uapi/linux/rtnetlink.h:263) is a gateway or direct route; RTN_LOCAL (:264) means “accept locally”; RTN_BROADCAST (:265). The kernel keeps its own host IPs as RTN_LOCAL entries in a separate table — more on that below.
One more connection: LPM is why the FIB is a trie keyed on address bits and not a hash. A hash gives you exact-match — great for “is this exact key present?” But routing needs “what’s the longest prefix that is a leading substring of this address?” A bitwise trie answers that naturally: descend as far as the address bits keep agreeing, and the deepest matching prefix is the answer. That’s the structure we build up in Background 3.
Background 2: the dst_entry — how a route result rides on the packet
The lookup found a winner. Now what? The kernel doesn’t return a raw “go out eth0 via 192.168.1.1” answer and leave the caller to interpret it. Instead it builds a small object, staples it onto the skb, and lets the packet carry its own “what happens next” instructions. That object is the dst_entry (“destination entry”), and it’s the single most important handle in the forwarding path.
Intuition: a per-packet “next step” with two function pointers
Think of dst_entry as a tiny instruction card pinned to the packet. The card has two slots:
input(skb)— what to do with the packet on the receive/forward side.output(net, sk, skb)— what to do on the transmit side.
This is the same function-pointer-as-vtable idea Day 3 used for proto_ops and Qdisc_ops: instead of a giant if/else over packet types, you store a function pointer and call through it. The dst_entry struct itself is new, but the dispatch pattern is one you already know.
Here’s the struct (include/net/dst.h:26):
struct dst_entry {
union {
struct net_device *dev;
struct net_device __rcu *dev_rcu;
};
struct dst_ops *ops; /* :31 */
/* ...metrics, expires, xfrm... */
int (*input)(struct sk_buff *); /* :39 */
int (*output)(struct net *net, struct sock *sk, struct sk_buff *skb); /* :40 */
unsigned short flags;
/* ... */
};
The route lookup doesn’t return a dst — it returns an rtable that embeds one
When the IPv4 route lookup finishes, it produces a struct rtable (route table entry, include/net/route.h:57):
struct rtable {
struct dst_entry dst; /* <-- the very FIRST member */
int rt_genid;
unsigned int rt_flags;
__u16 rt_type;
/* ...rt_gw4/rt_gw6, rt_iif, mtu... */
};
Look at the first member: struct dst_entry dst. Because it’s first, an rtable * and a dst_entry * point at the same address — an rtable is a dst with extra fields hanging off the end. To go the other way (recover the full rtable from a bare dst), the kernel uses a container_of helper (include/net/route.h:80):
#define dst_rtable(_ptr) container_of_const(_ptr, struct rtable, dst)
This is the classic Linux “embed the generic struct as the first member, recover the concrete one with container_of” trick. The routing code fills in rt->dst.input and rt->dst.output depending on what the lookup decided (net/ipv4/route.c):
rt->dst.input = ip_local_deliver— packet is for us (:1668).rth->dst.input = ip_forward— transit traffic (:1894).rth->dst.input = ip_error— unreachable; rate-limits and sends ICMP (:2442).rt->dst.output = ip_output— the TX direction (:1666).
(ip_local_deliver lives at net/ipv4/ip_input.c:250; ip_forward at net/ipv4/ip_forward.c:83 — the same two handlers Day 2 mentioned without explaining.)
Stapling it on: skb_dst_set and dst_input
Two skb helpers connect the dst to the packet:
skb_dst_set(skb, &rt->dst)(include/linux/skbuff.h:1217) staples the dst onto the skb. It’s stored inskb->_skb_refdst(skbuff.h:923) — a singleunsigned longthat holds the pointer plus a low “is-it-refcounted?” bit.skb_dst(skb)(skbuff.h:1159) reads it back.
And the entire “dispatch the next step” mechanism is one inline function, dst_input (include/net/dst.h:478):
static inline int dst_input(struct sk_buff *skb)
{
return INDIRECT_CALL_INET(READ_ONCE(skb_dst(skb)->input),
ip6_input, ip_local_deliver, skb);
}
Strip the INDIRECT_CALL_INET wrapper (it’s a retpoline/speculation optimization that hints the two likely targets) and it reads: call skb_dst(skb)->input(skb). That’s it. The whole “the skb’s dst->input dispatches the next step” claim is this single jump through a function pointer that the route lookup pre-loaded.

The fast path: skip the lookup if a dst is already attached
The RX code first checks whether the skb already has a usable dst attached, and if so, skips the whole lookup. That check is skb_valid_dst (include/net/dst_metadata.h:93):
static inline bool skb_valid_dst(const struct sk_buff *skb)
{
struct dst_entry *dst = skb_dst(skb);
return dst && !(dst->flags & DST_METADATA);
}
“Has a dst, and it’s a real route (not a metadata placeholder used by tunnels).” If that’s true, the packet already knows where it’s going and the kernel jumps straight to dst_input.
The lookup pipeline

For an incoming packet, ip_rcv_finish (via ip_rcv_finish_core) first checks whether the skb already has a dst attached (skb_valid_dst → fast path, Background 2). If not, it calls ip_route_input_noref (net/ipv4/route.c:2546) which:
- Calls
fib_lookup→ walksfib_rulesto pick a table. - Calls
fib_table_lookup(net/ipv4/fib_trie.c:1420) on that table — the LC-trie walk (Background 3). - Builds an
rtablefrom the result, attaches it to the skb viaskb_dst_set— loadingrt->dst.inputper Background 2.
The skb’s dst->input function pointer then dispatches the next step via dst_input, jumping to whichever handler the lookup loaded (ip_local_deliver / ip_forward / ip_error — Background 2). The one new detail worth pinning down here: ip_error handles route type RTN_UNREACHABLE (it rate-limits and sends ICMP Destination Unreachable), whereas TTL-exceeded is not dispatched here — it is detected inside ip_forward, which then emits ICMP Time Exceeded.
Anatomy of a lookup

The lookup key is a struct flowi4:
struct flowi4 {
__be32 saddr;
__be32 daddr;
dscp_t flowi4_dscp;
__u32 flowi4_mark;
int flowi4_oif;
int flowi4_iif;
__u8 flowi4_proto;
/* ... ports, etc. */
};
(In v7.1 these fields are really #define aliases over an inner struct flowi_common __fl_common — e.g. flowi4_oif is __fl_common.flowic_oif. The flattened view above is a fair simplification; the trace lab below uses the real flp4->__fl_common.flowic_oif form.)
The result is the struct fib_result you met in Background 1 — prefix/prefixlen record which LPM winner was chosen, and nhc carries the egress device (nhc_dev) and the gateway (nhc_gw). (The preferred source IP and MTU live elsewhere — in the enclosing fib_nh’s nh_saddr and the route’s metrics on the dst — not in fib_nh_common.)
Background 3: the LC-trie — what it is and why it’s fast
The internet has ~1M IPv4 routes globally. A linear scan would be hopeless. The FIB uses an LC-trie (“level-compressed trie”) and the chapter keeps calling it “fast” — let’s earn that claim by building it up from scratch.
Start with a trie
A trie is a tree where you navigate by the digits of the key, not by comparing whole keys. For IP routing the key is the 32 bits of an IPv4 address, so the simplest version is a binary trie: each node looks at one bit of the address and branches left (0) or right (1). A route with prefix length L lives at depth L — you walked L bits to reach it.
The problem is depth. A /32 host route sits 32 levels deep, so a worst-case lookup is 32 memory dereferences — one per bit. On a million-route table that’s far too slow. This naive binary trie is the baseline the LC-trie improves on, with two compressions.
Compression #1: path compression (the first “C”)
Walk down a binary trie and you’ll often hit long runs where every node has exactly one child — a stretch of address space with only a single route underneath it. Spending one node (and one memory access) per bit through that run is pure waste.
Path compression collapses any chain of single-child internal nodes into one node that simply records how many bits to skip. The long single-child chains stop costing a node per bit.
Compression #2: level compression (the second “C”)
In the dense regions of the trie — where almost every branch is populated — you can do the opposite of skipping: fan out wider. Instead of branching on one bit per level, level compression replaces several binary levels with a single node that branches on multiple bits at once: a node consuming k bits has up to 2^k children and resolves k address bits in one memory access.
How v7.1 encodes both in one node
Both compressions live in a single struct, struct key_vector (net/ipv4/fib_trie.c:121):
struct key_vector {
t_key key;
unsigned char pos; /* which bit position this node tests */
unsigned char bits; /* how many bits this node consumes */
unsigned char slen;
union {
struct hlist_head leaf; /* if IS_LEAF */
DECLARE_FLEX_ARRAY(struct key_vector __rcu *, tnode); /* if IS_TNODE — :130 */
};
};
The two fields carry the whole scheme:
posis the bit position this node examines — path compression lives here, becauseposcan jump past skipped bits.bitsis how many bits this node consumes in one step — level compression lives here.bits == 0is a leaf (IS_LEAF,:119);bits > 0is an internal tnode (IS_TNODE,:118). A node withbits = 4has up to 16 children (theDECLARE_FLEX_ARRAYof child pointers is the multi-bit fan-out in memory) and consumes 4 address bits at once.
The key length is fixed by the address width: KEYLENGTH = 8 * sizeof(t_key) (:112) and typedef unsigned int t_key (:115), so KEYLENGTH = 32 for IPv4 — the 32 levels of the naive trie we started from. Internal nodes are wrapped in a struct tnode (:134) carrying child-occupancy bookkeeping (empty_children, full_children) and the parent pointer.
The payoff
On real internet tables, the dense regions get flattened (fewer levels via level compression) and the sparse single-child chains get skipped (path compression), so lookups average ~5–10 node visits regardless of table size — instead of up to 32. That’s the “fast” the chapter promised.

Finally, tie it back to LPM (Background 1): the walk descends to a leaf following the address bits, then checks the leaf’s fib_alias list to find the most specific prefix whose bits actually match the key. The trie narrows the candidates; the leaf check confirms the match length. Read net/ipv4/fib_trie.c (fib_table_lookup, line 1420) and Documentation/networking/fib_trie.rst for the gory details — both are well-commented.
How the walk actually works: each tnode owns a branching window
A plain binary trie examines one address bit per level. An LC-trie compresses both paths and levels: every internal key_vector (tnode) stores pos and bits, selecting a multi-bit window of the 32-bit host-order key and a child array of size 1 << bits.

fib_table_lookup begins with key = ntohl(flp->daddr) and uses:
struct key_vector {
t_key key;
unsigned char pos; /* low-order suffix bits below this window */
unsigned char bits; /* width of this node's child index */
unsigned char slen; /* longest suffix length below this node */
/* leaf hlist, or a (1 << bits)-entry child array */
};
The hot expression is index = get_cindex(key, n) = (key ^ n->key) >> n->pos. The shift discards the low pos suffix bits, which are below this node’s branching window; it does not discard bits already examined on the way down. If all more-significant prefix and path-compressed bits agree with n->key, the remaining value fits in n->bits and directly indexes one child. A tnode with bits = 4 therefore selects among 16 children in one dereference. If index >= 1 << n->bits, some differing bit lies above the window, so this subtree cannot contain the key and lookup enters prefix backtracking.
The first descent aims at the most specific candidate. When index fits and bits == 0, it has reached a leaf whose key passed that step’s comparison. A leaf holds a sorted hlist of fib_alias routes; fa_slen is the number of low-order suffix bits excluded from that alias’s prefix, so prefixlen = 32 - fa_slen. The semantic scan also checks DSCP, route liveness/type/scope, output-interface constraints, and usable nexthops before filling fib_result.
Backtracking enumerates valid shorter-prefix candidates
If descent finds a missing child, a prefix mismatch, or no usable alias/nexthop, lookup does not restart at the root. It keeps a parent pn and child index cindex only where slen > pos says a shorter prefix may exist below that point.
At backtrace, a nonzero cindex is changed with cindex &= cindex - 1, clearing its least-significant set bit and selecting the next prefix-aligned predecessor branch in that child array. If cindex is already zero, the code ascends until an ancestor has another candidate, recomputes that ancestor’s child index with get_index, and continues. Prefix-matching mode then follows the first non-NULL child of a candidate subtree, while prefix_mismatch(key, n) and n->slen == n->pos prune branches that cannot contain a shorter match.
At a candidate leaf, index = key ^ n->key; an alias is eligible only when every differing bit fits inside its ignored suffix (index < 1 << fa_slen, with the 32-bit guard in the source). Thus longest-prefix behavior comes from the combination of deepest-first descent, aliases ordered by prefix length, and this structured backtrace toward shorter candidates — not from “the first leaf always wins” and not from blindly climbing one trie level per prefix bit.
Route insertion keeps the structure efficient by resizing tnodes: inflate widens dense branching windows and halve narrows sparse ones. Those operations change bits and child placement without changing the lookup contract.
You can see the resulting shape directly:
cat /proc/net/fib_trie
Each +-- line is an internal tnode printed as prefix/len bits full_children empty_children; each |-- line is a leaf with its routes. Watch a bits value of 2 or 3 on a busy line — that’s level compression fanning a node out to 4 or 8 children so the walk skips whole levels.
Read net/ipv4/fib_trie.c if you want the resize math; the lookup loop itself (lines 1420–1545) is the part worth reading top-to-bottom.
Multiple tables
Linux supports multiple routing tables. The defaults:

- local (255) — IPs on local interfaces; kernel-maintained.
- main (254) — user-added routes go here by default.
- default (253) — lowest priority; rarely used.
- custom — created with
ip route add ... table N. Small examples often use IDs below 253, but the kernel carries table IDs asu32; larger IDs travel through netlink attributes such asRTA_TABLE(Background 4).
(These match RT_TABLE_LOCAL=255 / RT_TABLE_MAIN=254 / RT_TABLE_DEFAULT=253 in include/uapi/linux/rtnetlink.h:360.)
fib_rules decide which table to consult based on packet attributes: source IP, fwmark, OIF, IIF. Day 9 covers them.
Background 4: how a route gets into the FIB — the rtnetlink path
Every lab in this chapter uses ip route add/del/show, and the lookup side walks a trie those commands populated. But how does ip route add 10.99.0.0/16 dev lo actually become a leaf in the LC-trie? So far we’ve only looked at the read side. Here’s the write side.
ip route is a netlink client
ip route does not poke the kernel via ioctl or /proc. It opens an AF_NETLINK socket in the NETLINK_ROUTE family (“rtnetlink”) and sends a structured message:
- Adding a route → an
RTM_NEWROUTEmessage (include/uapi/linux/rtnetlink.h:44, value 24). - Deleting →
RTM_DELROUTE(:46). ip route show→ anRTM_GETROUTEdump (:48).
The message carries a struct rtmsg plus typed attributes (TLVs — type-length-value records): RTA_DST (the prefix), RTA_GATEWAY, RTA_OIF (egress interface), and RTA_TABLE (:385) — the very attribute the “Multiple tables” section mentioned for large table IDs.
Kernel side: per-message-type handlers that mutate the FIB
Each message type is registered to a handler function (net/ipv4/fib_frontend.c:1694):
static const struct rtnl_msg_handler fib_rtnl_msg_handlers[] __initconst = {
{.protocol = PF_INET, .msgtype = RTM_NEWROUTE,
.doit = inet_rtm_newroute, .flags = RTNL_FLAG_DOIT_PERNET},
{.protocol = PF_INET, .msgtype = RTM_DELROUTE,
.doit = inet_rtm_delroute, .flags = RTNL_FLAG_DOIT_PERNET},
{.protocol = PF_INET, .msgtype = RTM_GETROUTE, .dumpit = inet_dump_fib, ...},
};
So RTM_NEWROUTE → inet_rtm_newroute (:910), RTM_DELROUTE → inet_rtm_delroute (:876), RTM_GETROUTE → inet_dump_fib (:1018, the dump that backs ip route show). inet_rtm_newroute parses the attributes and calls fib_table_insert (:930) — the function that actually grows the LC-trie the lookup side walks.
That closes the loop:
ip route add 10.99.0.0/16 dev lo
→ AF_NETLINK socket: RTM_NEWROUTE { rtmsg + RTA_DST/RTA_OIF/RTA_TABLE }
→ inet_rtm_newroute → fib_table_insert → new leaf in the LC-trie
ping 10.99.0.1
→ fib_table_lookup walks the trie → hits that freshly-inserted leaf

Why the local table fills itself
This also explains where the kernel’s own host-IP routes come from. When an interface gets an IP assigned, the kernel issues the same RTM_NEWROUTE internally — with type RTN_LOCAL — via fib_magic (net/ipv4/fib_frontend.c:1156: fib_magic(RTM_NEWROUTE, RTN_LOCAL, addr, 32, ...)). No userspace involved; the local table (255) is populated through the identical insert path your ip route add uses. We don’t need the deeper netlink framing (nlmsghdr, attribute parsing) here — the teaching point is just config arrives as rtnetlink messages dispatched to per-type handlers that mutate the FIB.
There are no Dumb Questions
Q: How does the kernel cache routes?
A: Modern Linux (since ~3.6) does NOT cache per-flow rtables. Instead, route lookups are cheap enough to do per-packet. The exception is per-CPU caches for outbound connect paths and the
dst_cachemechanism that lwtunnels use. The pre-3.6 “rt_cache” was a security risk and is gone.Q: Where does the kernel keep its own host IPs?
A: In the
localFIB table (id 255). Kernel auto-inserts entries when interfaces get IPs assigned — via the internalfib_magic(RTM_NEWROUTE, RTN_LOCAL, ...)path from Background 4.ip route show table locallists them.Q: How fast is a lookup, in practice?
A: ~50ns–150ns on modern x86_64 against a typical Linux server’s small FIB. Internet routers with 1M routes see ~200–500ns per lookup, dominated by memory access patterns and cache effects.
Today’s experiment
Inspect your routes
ip route show table main
ip route show table local
ip rule show
ip rule is the fib_rules list. A vanilla kernel installs three rules (local, main, default); some distros or host services add extra rules (e.g. a 220: from all lookup 220 entry), so you may see more. (Each ip route show you run is an RTM_GETROUTE dump handled by inet_dump_fib — Background 4.)
Watch a route lookup
sudo bpftrace -e '
fentry:fib_table_lookup {
printf("lookup daddr=%s table_id=%d\n",
ntop(args->flp->daddr),
args->tb->tb_id);
}'
# in another terminal:
ping -c 1 8.8.8.8
You’ll see at least one fib_table_lookup per originated packet; exact counts vary with dst caching.
Add a route
sudo ip route add 10.99.0.0/16 dev lo
ip route show
# remove:
sudo ip route del 10.99.0.0/16
The add is silent on success; ip route show confirms it by listing the new prefix:
10.99.0.0/16 dev lo scope link
Under the hood, that ip route add sent an RTM_NEWROUTE message into inet_rtm_newroute → fib_table_insert, which inserted a fresh leaf into the main table’s LC-trie (Background 4). To connect this back to the FIB lookup, leave the fentry:fib_table_lookup trace from the previous section running, then in another terminal:
ping -c 1 10.99.0.1
The trace prints lookup daddr=10.99.0.1 table_id=254 — the LC-trie in the main table (254) now resolves the prefix you just added. Remember to run the ip route del above when you’re done so your host table is left clean.
Trace ip_route_output_flow
sudo bpftrace -e '
fentry:ip_route_output_flow {
printf("out: daddr=%s oif=%d\n", ntop(args->flp4->daddr), args->flp4->__fl_common.flowic_oif);
}'
# in another terminal:
ping -c 1 8.8.8.8
This is the outbound counterpart to ip_route_input — it fires for every locally-originated packet. You’ll see one line per outbound route lookup:
out: daddr=8.8.8.8 oif=0
oif=0 means the caller did not pin an egress interface, so the lookup is free to choose one. (A box that talks to the network constantly will print background lookups too, e.g. DNS resolvers and the like.) Press Ctrl-C to stop.
What to break
Break a default route safely, inside a namespace
Do not replace your host’s real default route. Put the failure in a disposable namespace:
sudo ip netns add fibbreak
sudo ip -n fibbreak link set lo up
# onlink: tell the kernel to trust this gateway even though it is
# not on any subnet configured on lo (lo carries only 127.0.0.1/8).
sudo ip -n fibbreak route add default via 10.99.99.99 dev lo onlink
sudo ip -n fibbreak route show # default via 10.99.99.99 dev lo onlink
sudo ip netns exec fibbreak ping -c 1 8.8.8.8 # 100% packet loss, namespace only
sudo ip netns del fibbreak
With onlink the route installs, so the FIB lookup succeeds and resolves a next-hop — but 10.99.99.99 answers no ARP, so transmission has nowhere to go and the ping reports 100% packet loss. (Without onlink the kernel rejects the off-subnet gateway outright with Error: Nexthop has invalid gateway. and the route is never added at all.) Either way your host routing table never changes, and ip netns del tears down the namespace and its routes completely.
This is also LPM in miniature (Background 1): the only route present is 0.0.0.0/0, so a packet to 8.8.8.8 matches it because there was nothing more specific.
Inspect rt cache stats (legacy)
cat /proc/net/stat/rt_cache
The per-flow cache-hit columns (in_hit/out_hit) are zero because the per-flow rt_cache is gone — that’s the teaching point. The in_slow_tot/out_slow_tot/in_martian_src columns are just cumulative lookup counters, so they will be non-zero. The proc file remains for compatibility.
What to read in the kernel
net/ipv4/route.c—ip_route_input_noref(line 2546),ip_route_output_flow(line 2929). Also thert->dst.input/rt->dst.outputassignments (lines 1666–1668, 1894, 2442).net/ipv4/fib_trie.c—fib_table_lookup(line 1420). The LC-trie implementation;struct key_vector(line 121),IS_TNODE/IS_LEAF(lines 118–119).net/ipv4/fib_frontend.c— netlink interface:inet_rtm_newroute(line 910),inet_rtm_delroute(line 876),inet_dump_fib(line 1018), the handler table (line 1694),fib_magic(definition at line 1099; theRTN_LOCALcall site is line 1156), and thefib_lookupwrapper.net/ipv4/fib_rules.c— fib_rules implementation.include/net/dst.h—struct dst_entry(line 26),dst_input(line 478).include/net/route.h—struct rtable(line 57),dst_rtable(line 80).include/net/flow.h—struct flowi4.include/net/ip_fib.h—struct fib_result(line 173),struct fib_nh_common(line 83).Documentation/networking/fib_trie.rst— LC-trie internals;Documentation/networking/ip-sysctl.rst— routing-related sysctls.
Bullet Points
- A route is a (prefix, prefixlen) pair. Many routes can match one address; the longest prefix wins (LPM).
0.0.0.0/0(the default) matches everything and is the last resort. - The lookup result is a
struct fib_result:prefix/prefixlenrecord the LPM winner;nhc(afib_nh_common) carries the egress dev and gateway IP — the preferred source IP (nh_saddr) and MTU live in the enclosingfib_nh/route metrics, not innhc.type(RTN_UNICAST/RTN_LOCAL/RTN_BROADCAST) is why the same machinery handles “forward” and “for us.” - A
dst_entryis the per-packet “next step” handle, carryinginput(skb)(RX/forward) andoutput(net,sk,skb)(TX) function pointers. The route lookup builds astruct rtablewhose first member is adst_entry(sortable* == dst*; recover withdst_rtable).skb_dst_setstaples it on;dst_inputjust callsskb_dst(skb)->input(skb)— that’s the entire dispatch. rt->dst.inputis set toip_local_deliver/ip_forward/ip_error.skb_valid_dstis the fast-path check that skips the lookup when a real dst is already attached.- FIB is the kernel’s routing table; lookups go through
fib_table_lookupagainst an LC-trie. A naive binary trie is 32 levels deep; path compression skips single-child chains and level compression uses multi-bit (2^bits-way) nodes, so lookups average ~5–10 node visits regardless of table size.struct key_vector{pos,bits}encodes both (bits==0= leaf). - The lookup key is
struct flowi4; the result isstruct fib_result. - Three well-known tables: local (255), main (254), default (253). Custom table IDs are
u32; low IDs are just convenient examples.fib_rulesdecide which table to consult. ip routeis a netlink client. Add/del/show map toRTM_NEWROUTE/RTM_DELROUTE/RTM_GETROUTE→inet_rtm_newroute/inet_rtm_delroute/inet_dump_fib; inserts go throughfib_table_insert. The kernel auto-populates the local table via the same path (fib_magic,RTN_LOCAL).- No more flow-level rt_cache as of ~3.6 — lookups are cheap enough per-packet.
Check question
You add ip route add 10.0.0.0/24 via 192.168.1.1 dev eth0. A packet with daddr 10.0.0.5 enters. Walk the lookup.
Click to reveal answer
Answer: ip_rcv_finish → ip_route_input_noref → fib_lookup walks rules in priority order: the local rule (from all lookup local) is consulted first, but the local table has no entry for 10.0.0.5, so evaluation falls through to the kernel’s default from all lookup main. fib_table_lookup(main, &flowi4) walks the LC-trie. Several prefixes may match 10.0.0.5 (a default 0.0.0.0/0, perhaps a 10.0.0.0/8, and the 10.0.0.0/24 you just added), but longest-prefix-match selects the /24 — the most specific. fib_result has prefix=10.0.0.0, prefixlen=24, nhc->nhc_gw.ipv4=192.168.1.1, nhc->nhc_dev=eth0. The route is the gateway form, so the kernel knows to ARP for 192.168.1.1 (Day 7’s neighbour subsystem) when transmitting. An rtable is built and attached to the skb via skb_dst_set; the lookup loaded rt->dst.input = ip_forward (assuming we’re not 10.0.0.5 ourselves), so dst_input(skb) jumps to ip_forward and forwarding proceeds.
Tomorrow
Day 9: multipath, policy routing, source-based routing. The fib_rules machinery — which decides which table each fib_table_lookup consults — gets serious.
Day 9 — Multipath, policy routing, source-based routing
Today’s mission: route packets based on something other than destination IP. Learn the four ideas the whole chapter leans on — packet marks, reverse-path filtering, why TCP hates reordering, and the nexthop object model — then use multiple FIB tables, policy rules, and ECMP with confidence. Budget ~130 minutes; if you already know fwmark, rp_filter, and DSCP, the four background boxes can be skimmed.
When destination-based routing isn’t enough
Yesterday’s lookup (Day 8) was a function of one input: the destination IP. You fed a struct flowi4 to fib_table_lookup, walked the LC-trie in the main table, and got back a struct fib_result with a next-hop. That’s the simple model and it covers ~95% of real-world routing — your laptop, most servers, most embedded devices. But there are hard cases where you genuinely need more inputs to make the decision:
- Source-based routing. Two tenants share a router; tenant A’s traffic must go through gateway A, tenant B through gateway B. The destination might be the same; the source IP is what differentiates.
- Mark-based routing. A firewall rule classified this packet as “VPN traffic” and stamped a mark on it. You want the route to follow that classification.
- Multipath (ECMP). You have two equal-cost paths to the same destination. Spread connections across them but keep each individual flow on a single path (so packets don’t reorder).
- Policy routing. “Traffic from this UID, on this incoming interface, with this DSCP, goes to that table.” The combinatorics of policy rules motivate multiple routing tables.
Linux solves all of these with the same machinery: fib_rules plus multiple FIB tables.
Four of these — the mark, reverse-path filter, DSCP byte, and flow-hash — lean on concepts you haven’t formally met (plus the nexthop object model the resilient-hashing section needs). They’re taught inline, intuition first, where the chapter first needs them — so every selector and every ip command reads as plain English when you reach it.
Background 1: what a packet mark actually is
The bullet list above, the fwmark selector, the entire “mark-based routing” section, and today’s Check question all hinge on “the mark.” Netfilter — the firewall subsystem that sets marks — isn’t taught until Day 20, so you’ve never formally met one. Let’s fix that now, because the idea is simple and you’ll lean on it all day.
A mark is a u32 scratch field that lives on the skb itself. Recall the sk_buff from Day 1: the universal packet container, a descriptor riding alongside the packet bytes. One of its fields is a 32-bit integer the kernel uses purely as an internal classification tag (include/linux/skbuff.h:1069):
union {
__u32 mark;
__u32 reserved_tailroom;
};
Two things to absorb about that field:
- It travels with the packet inside the kernel and is never put on the wire. It is not part of the IP header, not an Ethernet field, nothing a remote host can see. It exists only while the skb is alive in your kernel. Think of it as a sticky note some earlier subsystem slapped on the packet so a later subsystem can read it.
- Something must SET it before routing can match it. The mark starts at 0. It gets a non-zero value from one of two places:
-
An nftables/iptables rule —
meta mark set 0x42(nftables) or-j MARK --set-mark 0x42(iptables). This is a firewall hook stamping the packet as it passes through. -
The originating socket — a process calls
setsockopt(fd, SOL_SOCKET, SO_MARK, ...), and every packet that socket sends is born with that mark.SO_MARKis option number 36 (include/uapi/asm-generic/socket.h:56):#define SO_MARK 36
-
The fwmark rule selector you’ll meet below only reads the mark. It never sets it. This is the single most common point of confusion, so say it out loud: a routing rule reads the mark; netfilter or a socket writes it.
How the mark reaches the route lookup
Here’s the connection back to Day 8. When the kernel is about to route a packet, it copies the skb’s mark into the lookup key — specifically into flowi4_mark, a field you already saw listed in struct flowi4 on Day 8. Now the rule engine has something to compare against. The generic rule matcher does exactly this (net/core/fib_rules.c:292):
if ((rule->mark ^ fl->flowi_mark) & rule->mark_mask)
goto out; /* mismatch — this rule does not apply */
That’s a masked compare, and it’s why fwmark selectors take a VALUE/MASK form. The XOR (rule->mark ^ fl->flowi_mark) is zero exactly where the bits agree; the & rule->mark_mask ignores any bits you don’t care about. So fwmark 0x42 matches when the masked bits equal 0x42, and fwmark 0x42/0xff says “only look at the low byte.” When you create a rule, iproute2 stuffs your value into rule->mark (net/core/fib_rules.c:622):
nlrule->mark = nla_get_u32(tb[FRA_FWMARK]);
Forward-reference honestly: how marks get set — the nftables/iptables rule syntax, which chain runs when, conntrack restoring a mark on reply traffic — is netfilter’s job, covered on Day 20. For today, treat the mark as a label some earlier hook stamped on the packet, and focus on how routing reacts to it.

fib_rules: which routing table to consult
fib_rules is a per-protocol decision pipeline. Each rule is a predicate (selectors that match packet attributes) plus a target (which routing table to look up in). At lookup time, the kernel walks rules in priority order and uses the first match. Implementation: net/core/fib_rules.c:313 — fib_rules_lookup.

The default rules
Run ip rule show on a clean system and you get exactly three rules:
0: from all lookup local
32766: from all lookup main
32767: from all lookup default
- Priority 0 → table 255 (local). Holds the IPs of this host’s interfaces. Kernel auto-populates as you
ip addr add. Always tried first so packets to ourselves resolve correctly. - Priority 32766 → table 254 (main). Default destination for
ip route add. User-added routes land here. - Priority 32767 → table 253 (default). Lowest priority; rarely populated. Historical compatibility.
The lower the priority number, the earlier the rule is consulted. Custom rules slot in between.
Selectors: what each rule can match on
Each rule supports a set of selectors. The full list — see include/net/fib_rules.h:20 struct fib_rule and the per-protocol extension struct fib4_rule — includes:
from PREFIX(srcfield): match by source IP/prefix.to PREFIX(dstfield): match by destination (rarely useful — that’s what FIB lookup does anyway).iif IFNAME: match by input interface. Powerful for forwarding routers that need to apply different policy per arrival port.oif IFNAME: match by output interface. Less common; usually you want the route to select the OIF, not the other way around.tos VALUE/dsfield VALUE: match the DSCP class in the second byte of the IPv4 header (the top 6 bits — a QoS class set by applications or edge routers, “gold” vs “best-effort”; the low 2 bits are ECN, deliberately excluded from matching). You already sawflowi4_dscplisted instruct flowi4on Day 8; this selector connects that field to the wire byte, so you can steer gold traffic onto one table and bulk traffic onto another. The two spellings look at different widths:dsfieldmatches the full 6-bit DSCP (mask0xfc—flowi4_dscpis adscp_tthat already has the ECN bits cleared, so they never participate), whiletosmasks off the top three bits too and compares only the lower DSCP bits againstINET_DSCP_LEGACY_TOS_MASK(0x1c) for backward compatibility — seefib_dscp_masked_match(include/net/ip_fib.h:441). At runtime it compiles to the same masked compare as the mark —(r->dscp ^ fl4->flowi4_dscp) & r->dscp_mask(net/ipv4/fib_rules.c:197).fwmark VALUE/MASK: match the packet mark (Background 1). Critical for firewall-driven policy.uidrange UID-UID: match by socket-owner UID. Lets you route a specific user’s traffic differently (VPN-by-user).ipproto PROTO,sport/dport RANGE: match by L4. Layer-violating but powerful.
Source-based routing — the canonical example
Two tenants: tenant A uses 10.99.0.0/24 and must egress via 192.168.99.1; tenant B uses 10.100.0.0/24 and goes through 192.168.100.1.
# Custom tables for each tenant:
sudo ip route add default via 192.168.99.1 table 100
sudo ip route add default via 192.168.100.1 table 200
# Rules:
sudo ip rule add from 10.99.0.0/24 lookup 100 priority 100
sudo ip rule add from 10.100.0.0/24 lookup 200 priority 200
Now any packet from a 10.99-prefix source consults table 100 first; from 10.100, table 200; everyone else falls through to main. The rule priorities (100 and 200) are rule walk order, not the table IDs — keep them straight.
Gotcha: Linux defaults to strict reverse-path filtering (net.ipv4.conf.all.rp_filter = 1 on many systems), and strict reverse-path filtering will silently drop the traffic you just so carefully steered. To understand why, you need to know what reverse-path filtering actually does — which is Background 2, immediately below. The fix is to relax it (rp_filter=2, loose mode) on the relevant interface; the reason that fix works will be obvious in two paragraphs.
Background 2: reverse-path filtering, the mechanism
Reverse-path filtering (rp_filter) is an anti-spoofing check. The intuition: when a packet shows up claiming to be from some source address, the kernel asks a skeptical question — “if I had to send a reply back to this sender, would I even route it out the interface this packet just came in on?” If the answer is no, the packet’s source address is probably forged, and the kernel drops it as a martian (an impossible packet).
Here’s the clever part: the kernel answers that question using the exact same FIB lookup machinery you learned on Day 8 — just run backwards. It takes the packet’s source address, treats it as if it were a destination, and does a routing lookup. The function is fib_validate_source (net/ipv4/fib_frontend.c:429), the entry point invoked on RX, which calls __fib_validate_source (net/ipv4/fib_frontend.c:345). That helper takes an int rpf argument selecting the mode, and the two modes diverge right here:
if (rpf == 1) /* strict: net/ipv4/fib_frontend.c:405 */
goto e_rpf;
...
if (rpf) /* loose: net/ipv4/fib_frontend.c:417 */
goto e_rpf;
The e_rpf: label (net/ipv4/fib_frontend.c:424) is where the martian-source drop is signalled — return -SKB_DROP_REASON_IP_RPFILTER; on line 425. (Don’t confuse this with the comment at line 447 in the separate fib_validate_source wrapper, whose “within the same container, it is regarded as a martian source” path returns -SKB_DROP_REASON_IP_LOCAL_SOURCE on line 451 — a different local-source check, not the rp_filter verdict.) The three modes:
- Strict (1): the reverse lookup must resolve back to the same interface the packet arrived on. Asymmetric paths are rejected.
- Loose (2): the source need only be reachable via any interface. Asymmetric paths survive.
- Off (0): no check at all.
Why this breaks source-based routing
Now the gotcha from the previous section explains itself. The reverse-path check runs a full fib_lookup — and once you’ve added any ip rule (exactly the scenario here), net->ipv4.fib_has_custom_rules is true, so that lookup does walk your custom rules (fib_lookup only short-circuits straight to main when no custom rules exist; include/net/ip_fib.h:374). The reason it still drops your traffic is subtler: the reverse lookup is built with source and destination swapped — fl4.daddr = src; fl4.saddr = dst (net/ipv4/fib_frontend.c:359-360). Your from <tenant-prefix> rule selects on the source field, but in the reverse direction the tenant’s source address is now the lookup’s destination, so that rule no longer selects your custom table. And even when a route is found, strict mode demands its output interface equal the arrival interface (if (rpf == 1) goto e_rpf, net/ipv4/fib_frontend.c:405); under asymmetric/policy routing it doesn’t, the kernel shouts “martian!”, and your carefully-routed packet is dropped before it ever reaches your rule.
Relaxing to loose mode (2) fixes it because loose mode only asks “is this source reachable somehow?” — which it is — instead of demanding the path be symmetric. That’s the whole reason the source-routing recipe tells you to set rp_filter=2 on the relevant interface. See Documentation/networking/ip-sysctl.rst for the per-interface sysctl details (note: the effective value is the max of conf.all.rp_filter and conf.<iface>.rp_filter).

Mark-based routing
This is the second canonical fib_rules recipe (the first was source-based routing above), and it pairs the routing side with netfilter — the mark from Background 1 in action.
# Rule: marked traffic uses table 200
sudo ip rule add fwmark 0x42 lookup 200 priority 200
sudo ip route add default via 192.168.42.1 table 200
# Mark anything destined for example.com:
sudo nft add rule inet filter output ip daddr 93.184.216.34 meta mark set 0x42
That meta mark set 0x42 is the netfilter side from Background 1 writing the mark; the ip rule add fwmark 0x42 is the routing side reading it. This is how policy routers, VPN clients (mwan3), and per-application VPN configs work.
Gotcha: this is the ordering subtlety from the mark box, now concrete — mark in OUTPUT after the route was bound at connect(), and that connection’s path is already fixed. To force re-routing on mark change, applications often combine setsockopt(SO_MARK) (Background 1 — the socket stamps every packet at birth, before routing) with route lookups, or use the suppress_prefixlength 0 rule action, which makes the kernel ignore a previously-cached route decision and re-run the rules.
Multiple FIB tables in detail
Routing table IDs are 32-bit values in modern netlink (RTA_TABLE). The familiar 253/254/255 IDs are well-known defaults, and examples often use small numbers like 100 or 200, but the kernel is not capped at 256 tables. Each table is a self-contained FIB and exists as soon as you add a route to it. To list a specific table:
ip route show table 100
ip route show table all # everything everywhere
You name tables in /etc/iproute2/rt_tables (the canonical path; on some distros — e.g. Azure Linux — the file ships at /usr/share/iproute2/rt_tables and you may need to create /etc/iproute2/ first):
# echo "100 vpn" >> /etc/iproute2/rt_tables
# now you can use 'vpn' as the table name
sudo ip route add default via 10.0.0.1 table vpn
Background 3: why packet reordering wrecks TCP
The next section’s central design driver — and the entire justification for resilient nexthop groups later — is the rule “keep one flow on one path.” The chapter is about to claim that splitting a connection across two paths makes “TCP throughput tank for seconds.” But TCP isn’t taught until Day 13+, so you’re being asked to accept the most important idea in the chapter on faith. Let’s not do that. Here’s just enough to make the rule self-evident.
First, define a “flow.” A flow is the set of packets sharing the same 5-tuple: source IP, destination IP, source port, destination port, and protocol. Every packet of a single TCP connection has the same 5-tuple. ECMP’s job is to hash a connection’s fixed fields so that every packet of one connection lands on the same next-hop — which is exactly what the multipath hasher does. By default (hash policy 0) the kernel hashes only the source/destination IP pair, so all connections between the same pair of hosts share one next-hop; enabling policy 1 adds the L4 ports for true per-5-tuple spreading. Either way a single connection always hashes the same, so its packets stay on one next-hop. You can see what “a flow” means to the kernel by reading fib_multipath_hash (net/ipv4/route.c:2066): in case 0 it hashes only the v4 source and destination addresses; in case 1 it adds the L4 ports. Those fields are the flow identity.
Now, why keeping a flow together matters. TCP has no direct “a packet was lost” signal — it infers loss from ordering. The receiver ACKs the highest in-order byte it has. If packets arrive out of order, the receiver keeps re-sending the same ACK number — duplicate ACKs. When the sender sees three duplicate ACKs, it concludes a packet was lost, triggers a fast retransmit, and cuts its congestion window (its sending rate). Throughput drops, then has to climb back up slowly.
Here’s the trap: nothing was actually lost. If you spread one connection’s packets across two paths of unequal latency, the packets do all arrive — just out of order, because the slow path delivers its share late. But out-of-order arrival generates exactly the duplicate-ACK pattern that looks like loss. TCP’s loss detector fires, the window collapses, and throughput tanks for seconds. Repeat every time the paths reorder, and a “working” multipath setup delivers terrible performance.
So the rule “same flow → same next-hop” is not about correctness — the packets would arrive either way. It’s about not fooling TCP’s loss detector. This is precisely why ECMP hashes per-flow instead of per-packet, and why rehashing flows onto new paths (which the next section is all about) is so damaging.
(The full duplicate-ACK / fast-retransmit / congestion-window story is Day 16. For today: reordering looks like loss to TCP, and looking like loss costs throughput.)

ECMP — multiple next-hops to one destination
When two paths to the same destination cost the same in routing-protocol terms, you want to use both. Linux’s solution is ECMP (Equal-Cost Multi-Path): a single route entry with multiple next-hops. The kernel hashes each flow’s fixed field-tuple (the “flow” you just met in Background 3) to pick one next-hop and keep that connection on it — the diagram shows the shape; the mechanics follow.

Configure with the multi-line form of ip route:
sudo ip route add default \
nexthop via 192.168.1.1 weight 1 \
nexthop via 192.168.2.1 weight 1
How the kernel picks one
For each new flow, the kernel computes a hash from packet fields and maps it onto the next-hops using the hash-threshold algorithm (RFC 2992), not a modulo. Each next-hop owns a contiguous slice of the 0..2^31-1 hash space sized by its weight: fib_rebalance (net/ipv4/fib_semantics.c:824) gives every next-hop a cumulative upper_bound = DIV_ROUND_CLOSEST_ULL((u64)w << 31, total) - 1, and fib_select_multipath (net/ipv4/fib_semantics.c:2164) picks the first next-hop whose hash <= nh_upper_bound. Because the hash is over the 5-tuple (Background 3), every packet of a connection produces the same hash and so lands in the same slice — that’s how ECMP keeps a flow together.
The hash function is configurable via net.ipv4.fib_multipath_hash_policy:
- 0 — L3 only: hash
(src_ip, dst_ip). Same source-destination pair always uses one nexthop, even if it’s many connections. Poor balance for client-server workloads with few clients. - 1 — L4: hash
(src_ip, dst_ip, src_port, dst_port, proto). Different connections from the same client spread across nexthops. Useful when a few client/server pairs carry many connections, but it is an opt-in sysctl setting, not the kernel default. - 2 — Inner L3: for tunneled traffic, hash on the inner IPs, not the outer. Used when a single tunnel carries many flows that should spread across paths.
- 3 — Custom: hash on a bitmask of fields you select via
net.ipv4.fib_multipath_hash_fields. The mode itself is selected byfib_multipath_hash_policy(which accepts 0–3,extra2 = SYSCTL_THREE); when mode 3 is chosen, the field bitmask comes fromfib_multipath_hash_fields(a mask, validated againstfib_multipath_hash_fields_all_mask). Lets you cherry-pick exactly which L3/L4 fields enter the hash.
The kernel default is mode 0. Pick mode 1 when you want better balancing across many L4 flows between the same endpoints. Mode 2 is for overlay networks (VXLAN, GRE) where outer addresses are the same for many inner flows.
The reordering trade-off
ECMP guarantees a connection’s packets stay on the same path as long as the nexthop set is unchanged. The moment you add or remove a nexthop, fib_rebalance recomputes every slice boundary — so flows whose hash now falls in a shifted range jump to a different nexthop, mid-flight. (It’s not literally every flow under hash-threshold — flows whose hash still lands in an unshifted range stay put — but the churn is significant, since the boundaries move.) Those reassigned flows get reordered or routed differently — and now you know exactly what that costs: Background 3’s duplicate-ACK avalanche, fast-retransmit, and a window cut. TCP throughput tanks for seconds, not because anything was lost, but because reordering looks like loss.
Background 4: the nexthop object model
The fix for that boundary-shift problem (resilient hashing, next section) is about to switch from the inline nexthop via ... syntax you just used to a completely different way of writing routes:
sudo ip nexthop add id 10 ...
sudo ip nexthop add id 100 group 10/20 ...
sudo ip route add default nhid 100
Day 8 only ever showed next-hops embedded inline in a route (the fib_nh_common inside a fib_result). You’ve never seen a next-hop as a standalone, ID-addressable kernel object. Here’s the model.
Modern Linux (5.3+) lets a next-hop be a first-class object with its own id. Instead of baking the gateway/device into each route entry, you create the next-hop once, give it an id, and then point as many routes at it as you like via nhid. The object is struct nexthop (include/net/nexthop.h:142) — it carries an id and can be referenced by many routes.
A nexthop object can be a single next-hop OR a group. The group 10/20 form makes a group object whose members are other nexthop objects (id 10 and id 20). A group has a type:
mpath— classic hash-threshold ECMP (what plain inline multipath gives you): weighted slices of the hash space, recomputed when the set changes.resilient— the bucket-table scheme (next section).
Why resilient hashing needs this API at all: the bucket-to-nexthop mapping table is state, and it has to live somewhere. In the old inline route format there’s no place to attach it — a route entry is just a prefix plus an embedded next-hop. A standalone group object has somewhere to keep that table. That’s the whole reason the new syntax exists.
Contrast cleanly with Day 8. Day 8’s fib_result.nhc was an inline next-hop pointer. With the object model, the route’s fib_info instead has an fi->nh pointing at a shared struct nexthop. You can see the kernel branch on exactly that in fib_select_multipath (net/ipv4/fib_semantics.c:2164):
struct fib_info *fi = res->fi;
...
if (unlikely(res->fi->nh)) { /* a nexthop OBJECT, not an inline array */
nexthop_path_fib_result(res, hash);
return;
}
/* else: walk fi's inline nexthop array, the Day-8 form */
So res->fi->nh non-NULL means “this route points at a shared nexthop object”; NULL means “old-style inline next-hops.” Same fib_select_multipath, two source shapes.

Resilient nexthop groups (kernel 5.13+)
The fix for boundary-shift-on-change: resilient hashing. Plain ECMP recomputes the weighted hash-space slices whenever the set changes, so the boundaries move. Resilient instead inserts a fixed-size bucket table between the hash and the nexthops: nexthop_select_path_res indexes it with bucket_index = hash % res_table->num_nh_buckets (net/ipv4/nexthop.c:1479), and the bucket count (up to 65535) is constant regardless of how many nexthops exist. So the modulus never changes when you add or remove a nexthop — only the buckets that pointed at the removed nexthop get reassigned; every other bucket (and so every flow that wasn’t on the removed path) is undisturbed, confining Background 3’s penalty to just the affected flows.
sudo ip nexthop add id 10 via 192.168.1.1 dev eth0
sudo ip nexthop add id 20 via 192.168.2.1 dev eth0
sudo ip nexthop add id 100 group 10/20 type resilient buckets 65535 idle_timer 120
sudo ip route add default nhid 100
The three ip nexthop lines are the object model from Background 4: two member objects, then a resilient group object referencing them, then a route pointing at the group by nhid. The buckets 65535 sizes the bucket table — nexthop_res_table_alloc (net/ipv4/nexthop.c:561) allocates it sized by cfg->nh_grp_res_num_buckets (the buckets keyword), and the per-bucket bookkeeping uses struct nh_res_bucket (include/net/nexthop.h:74) inside a struct nh_res_table (include/net/nexthop.h:82). The idle_timer is how long a bucket can be idle before the kernel can re-balance it; the default is 120 seconds (net/ipv4/nexthop.c:20 — #define NH_RES_DEFAULT_IDLE_TIMER (120 * HZ)), which is the value cited above. unbalanced_timer is the soft deadline for forcing a rebalance.
Implementation: net/ipv4/nexthop.c:563 and surrounding (the num_nh_buckets/nh_res_table machinery). The background worker nh_res_table_upkeep (net/ipv4/nexthop.c:1841) is what walks the bucket table and migrates only the buckets that must move.
Use resilient hashing on production gateways with multiple uplinks. Plain ECMP is fine for symmetric, stable topologies (rare in practice).
Today’s experiment
# Source-based test on local interfaces. Save cleanup first.
cleanup() {
sudo ip rule del from 10.99.0.0/24 lookup 99 priority 99 2>/dev/null || true
sudo ip route del default via 127.0.0.1 table 99 2>/dev/null || true
sudo ip route del 10.99.0.0/24 dev lo 2>/dev/null || true
sudo ip addr del 10.99.0.5/32 dev lo 2>/dev/null || true
}
trap cleanup EXIT
# 10.99.0.5 must be a real local address — `ping -I` binds the socket to it,
# and the kernel rejects the bind if the address isn't assigned to an interface.
sudo ip addr add 10.99.0.5/32 dev lo
sudo ip rule add from 10.99.0.0/24 lookup 99 priority 99
sudo ip route add default via 127.0.0.1 table 99
# Trace which table is consulted
sudo bpftrace -e '
fentry:fib_table_lookup { printf("table_id=%d\n", args->tb->tb_id); }
' &
tracer=$!
sleep 2 # let bpftrace attach its fentry probes before we send traffic
# Send packet from 10.99 source — should hit table 99
ping -I 10.99.0.5 -c 1 8.8.8.8
# And from default — should hit main (254)
ping -c 1 8.8.8.8
sudo kill "$tracer"
For each ping you’ll first see one or more table_id=255 lines — that’s the always-first local table lookup (rule 0), which misses for an external destination like 8.8.8.8. The first ping then shows table_id=99 — proof the from 10.99.0.0/24 rule steered it into table 99 — while the second ping falls through to table_id=254 (main). The leading 255 lines are normal: fib_rules_lookup walks rule 0 first on every lookup, so don’t be surprised if 255 repeats.
Neither ping will actually get a reply from 8.8.8.8 — that’s expected. Table 99’s next-hop 127.0.0.1 is a deliberate dead-end used only to force a distinct table, and the route is loopback-bound. We only care about the table_id=... lines bpftrace prints, not the ping result.
There are no Dumb Questions
Q: If the mark never goes on the wire, how does the other end of a connection route reply traffic by mark?
A: It doesn’t see your mark. The mark is local to one kernel. On the reply side, that host’s own netfilter (often via conntrack restoring a saved mark) stamps its own mark on the reply skb. Each kernel marks independently. Day 20 covers the conntrack “save/restore mark” pattern that makes the two sides appear coordinated.
Q: I set a mark in the OUTPUT chain but my connection still takes the old path. Bug?
A: No — it’s the stickiness gotcha. The route was chosen when the socket bound, before your OUTPUT rule ran. The cleanest fix is to set the mark at the source via
setsockopt(SO_MARK)(Background 1) so the packet is born marked, before routing. Marking later only affects connections routed after the mark exists.
Q: rp_filter dropped my traffic but there’s nothing in the firewall logs. Where did it go?
A: rp_filter is not a firewall rule — it’s the
fib_validate_sourcemartian check (Background 2), and it drops silently (you can count martians vianet.ipv4.conf.<iface>.log_martians=1to get kernel log lines). If source-based routing “just doesn’t work” with no visible drop, suspect strictrp_filterfirst and set it to2on the arrival interface.
Q: Why not just hash per-packet and get perfect balance?
A: Per-packet spraying reorders flows across paths of unequal latency, and TCP reads that as loss — see Background 3 (“why packet reordering wrecks TCP”). Per-flow hashing trades a little balance for not lying to TCP’s loss detector.
What to read in the kernel
-
net/core/fib_rules.c:313—fib_rules_lookup. The rule-walking dispatcher used by all protocols (IPv4, IPv6). Read top to bottom (~50 lines for the function itself). Notice how it iteratesops->rules_listin priority order, callsops->matchfor each rule’s predicate, andops->actiononce a match is found. The protocol-specific rule type plugs into this generic engine. -
net/ipv4/fib_rules.c— IPv4 specialization. Thematchcallbackfib4_rule_matchchecks src, dst, tos/dscp, fwmark, ipproto. Read it to see what each rule selector compiles to at runtime — it’s just a chain of masked compares againstflowi4fields (e.g. thedscpcompare at line 197, the same form as the genericmarkcompare). -
net/ipv4/fib_frontend.c:429—fib_validate_source(and__fib_validate_sourceat line 345). This is reverse-path filtering. Watch therpf == 1(strict, line 405) vsrpf(loose, line 417) split and thee_rpf:label (line 424) whosereturn -SKB_DROP_REASON_IP_RPFILTER;(line 425) is the martian drop. It’s a reverse FIB lookup — Day 8’s machinery run backwards on the source address (with src/dst swapped). -
net/ipv4/fib_semantics.c:2164—fib_select_multipath. Picks one nexthop given a precomputed hash. Note theres->fi->nhbranch at the top (nexthop object vs inline array, Background 4), then the loop that walksfib_info’s next-hop array comparinghash <= nh_upper_bound— each nexthop owns a weighted slice of the hash space (the upper bounds set byfib_rebalanceat line 824), no modulo involved. -
net/ipv4/route.c:2066—fib_multipath_hash. Readcase 0(v4 src/dst only) andcase 1(adds L4 ports) to see exactly what “a flow” means to the hasher (Background 3). -
include/net/ip_fib.h:556—fib_multipath_hash_from_keys. The hash function forfib_multipath_hash_policy=0/1/2/3. Notice how it folds the chosen fields into aflow_keysstruct, then runsflow_hash_from_keys_seed— a keyed siphash (net/core/flow_dissector.c:1803, thesiphash()call inside__flow_hash_from_keys). Reading this answers “which exact bytes does the kernel hash for ECMP?” -
net/ipv4/nexthop.c:563and surrounding — resilient nexthop groups. Thenum_nh_buckets/nh_res_tableand bucket migration machinery. Look atnh_res_table_upkeep(line 1841) to see how the kernel decides which buckets to migrate when a nexthop is added/removed without disturbing others. -
include/net/fib_rules.h:20—struct fib_rule. The selectors are the fields. Quick read; gives you the complete vocabulary of what’s matchable. -
Documentation/networking/ip-sysctl.rst— the official reference for the routing/rp_filter/multipath sysctls used here. One read; explains the knobs end-to-end.
Bullet Points
- A packet mark is a
u32scratch field on the skb (sk_buff.mark) that rides inside the kernel and never goes on the wire. Netfilter (meta mark set) or a socket (setsockopt(SO_MARK)) writes it; thefwmarkrule selector only reads it, via a masked compare(rule->mark ^ flowi_mark) & mark_mask. fib_rulesdecide which routing table to consult. Default: local → main → default.- Selectors:
from,iif,oif,tos/dsfield(the IPv4 DSCP class →flowi4_dscp; ECN bits excluded),fwmark,uidrange,ipproto,sport/dport. - Routing table IDs are
u32. Custom tables viaip route add … table N. Names in/etc/iproute2/rt_tables. - Source-based routing is the most common use case; mark-based for firewall integration.
- Reverse-path filtering (
fib_validate_source) is an anti-spoofing reverse FIB lookup on the source address. Strict (rp_filter=1) demands the reply path return to the arrival interface and so silently drops asymmetric source-routed traffic; set loose (2) on the relevant interface. - ECMP: multi-nexthop route, kernel hashes per-flow to pick one — by default the source/destination IP pair (hash policy 0); the full 5-tuple only with policy 1. Same flow → same nexthop, because reordering a flow across paths looks like loss to TCP (dup-ACKs → fast-retransmit → window cut), not because packets would be lost.
- Hash policy: 0 (L3 — kernel default), 1 (L4, opt-in for better flow spread), 2 (inner L3 for tunnels), 3 (custom fields via
fib_multipath_hash_fields). - Plain ECMP shifts the weighted hash-space boundaries when the nexthop set changes, moving the flows whose hash falls in a shifted range. The nexthop object model (
ip nexthop add id …, referenced bynhid) lets a next-hop be a standalone/grouped object — the home for the resilient bucket table, which uses a fixedhash % num_bucketsmodulus (independent of nexthop count) and re-maps only the buckets of a removed nexthop. Use it for stable production gateways.
Check question
You add ip rule add from 192.168.1.0/24 lookup 100 priority 50 and ip rule add fwmark 0x42 lookup 200 priority 100. A packet has src 192.168.1.5 AND fwmark 0x42. Which table is consulted?
Click to reveal answer
Answer: Table 100. Rules are walked in priority order, lower number first. Priority 50 < 100, so the source-prefix rule is checked first. The packet matches (192.168.1.5 is in 192.168.1.0/24), so the lookup goes to table 100, and rule walking stops — fib_rules_lookup returns on the first matching rule. The mark rule never gets a chance, even though the mark 0x42 is present (recall the mark is just a u32 tag some earlier hook stamped on the skb; the rule only reads it). To make mark take precedence, give it the lower priority (e.g., priority 30).
Tomorrow
Day 10: IPv6 specifics — NDP, autoconfig, extension headers.
Day 10 — IPv6 specifics: NDP, autoconfig, extension headers
Today’s mission: see what’s genuinely different about IPv6 from the IPv4 you’ve traced for nine days — the 40-byte base header and its
nexthdrchain, the 128-bit address with its prefix/host split, the autoconfig flow, the neighbor discovery protocol that replaces ARP, and the extension-header chain that’s caused real CVEs. Total time: ~110 minutes.
Why IPv6 deserves its own day
Most of the kernel network stack is shared between IPv4 and IPv6: the socket layer, the routing infrastructure, netfilter, the device queue, qdiscs. But IPv6 has structurally different features that can’t be glossed over:
- A different base header and an extensible header chain. Fixed 40 bytes, no options inside it, no checksum — and a
nexthdrbyte that can point at a chain of variable-length extension headers before you ever reach L4. - 128-bit addresses with built-in scope and a prefix/host split. Not “IPv4 with bigger numbers” — the address structure drives autoconfiguration.
- Autoconfiguration. IPv6 hosts self-configure their addresses without DHCP, by listening to Router Advertisements.
- NDP replaces ARP. Same idea (resolve next-hop link-layer address) but built on ICMPv6 with richer semantics, and — crucially — multicast instead of broadcast.
- Extension headers. Variable-length, chained between the base IPv6 header and the L4 protocol. Powerful, fragile, and the source of multiple CVEs over the years.
Everything you learned Days 1–9 was IPv4-only: the sk_buff from Day 1, the RX path and protocol demux from Day 2, the neighbour/ARP subsystem from Day 7. IPv6 reuses an enormous amount of that machinery — so this chapter leans on it hard and only teaches what is genuinely new. We’ll build each new concept intuition-first, then point at the concrete v7.1 struct or function in your ~/code/linux checkout (line numbers from kernel 7.1).
The base header: 40 fixed bytes and a nexthdr byte
Everything today rests on one structure, so we open the struct before anything else. The natural point of contrast is the IPv4 header (struct iphdr, include/uapi/linux/ip.h:87), whose length varied: its 4-bit ihl field counted 32-bit words of header + options, so it could carry inline options. IPv6 throws that out. The base header is always exactly 40 bytes — no length field, no options inside it:
/* include/uapi/linux/ipv6.h:118 — simplified: real struct uses endian
* bitfield guards + __struct_group(addrs,...) wrapping saddr/daddr */
struct ipv6hdr {
__u8 priority:4,
version:4; /* version == 6 */
__u8 flow_lbl[3]; /* 8-bit traffic class is split: priority
nibble + top 4 bits of flow_lbl;
low 20 bits of flow_lbl = flow label */
__be16 payload_len; /* length of everything AFTER these 40 bytes */
__u8 nexthdr; /* what comes next: an L4 proto OR an ext header */
__u8 hop_limit; /* IPv6's name for IPv4's TTL */
struct in6_addr saddr; /* 16 bytes */
struct in6_addr daddr; /* 16 bytes */
};
Read it field by field:
version= 6, plus a 4-bitprioritynibble (the top half of the 8-bit Traffic Class; its lower half is the top 4 bits offlow_lbl[3]) and a 20-bit flow label that occupies the low 20 bits offlow_lbl[3]. The flow label lets a router hash a flow without parsing L4.payload_len— the single biggest gotcha if you’re coming from IPv4. It is the length of everything after the 40-byte header, not including the header. IPv4’stot_lenincludes the 20-byte header; IPv6’spayload_lendoes not. (This is whyip6_rcv_coretrims the skb to40 + payload_len, not justpayload_len.)nexthdr— the protocol selector. For plain TCP traffic it’s just6. But it can also name an extension header, which carries its ownnexthdr, forming a chain. We’ll build that chain below.hop_limit— exactly TTL, renamed. Decremented per hop; packet dropped at zero.saddr/daddr— two 16-byte addresses (next section).
Three structural differences to burn in
Compared to the IPv4 header (struct iphdr, the one Day 2’s ip_rcv_core parsed), three things are gone or changed, and each one explains a design decision you’ll meet later today:
- The header is fixed-length — there is no IHL field and no options inside the base header. That’s exactly why options had to move out into a separate extension-header chain. A fixed base header is cheap and predictable to parse; variable junk lives elsewhere.
- There is no header checksum field at all. Grep proves it —
include/uapi/linux/ipv6.hcontains zero occurrences of the stringcheck. IPv4 routers recompute the header checksum on every hop (because they decrement TTL); IPv6 routers don’t, because there’s nothing to recompute. Integrity is left to L4 checksums (which cover an IPv6 pseudo-header). The consequence: walking thenexthdrchain is the only per-hop parsing cost — there’s no checksum verify/update on the fast path. hop_limitreplaces TTL. Same mechanic, new name.
The nexthdr number space — the key to the whole chain
Here is the single insight that makes the extension-header “chain” make sense: nexthdr uses one shared one-byte protocol-number space, used identically by the base header and by every extension header. A value of 6 always means “TCP comes next,” whether it’s in the base header or seven extension headers deep. The kernel names them in include/net/ipv6.h:32:
#define NEXTHDR_HOP 0 /* Hop-by-Hop options */
#define NEXTHDR_TCP 6 /* TCP */
#define NEXTHDR_ROUTING 43 /* Routing header (SRv6) */
#define NEXTHDR_FRAGMENT 44 /* Fragment header */
#define NEXTHDR_ESP 50 /* IPsec ESP */
#define NEXTHDR_AUTH 51 /* IPsec Authentication */
#define NEXTHDR_NONE 59 /* nothing follows */
#define NEXTHDR_DEST 60 /* Destination options */
#define NEXTHDR_MOBILITY 135 /* Mobile IPv6 */
So parsing IPv6 is a loop: read nexthdr; if it names an extension header, parse that header (which is self-describing in length and carries the next nexthdr), and repeat; if it names an L4 protocol (TCP, UDP, ICMPv6…), stop — you’ve reached the payload. NEXTHDR_NONE (59) means “the chain ends here, no payload follows.”
ip6_rcv_core (net/ipv6/ip6_input.c:188) is the IPv6 twin of Day 2’s ip_rcv_core: it validates version == 6, uses payload_len to trim the skb to the right length, and then leaves nexthdr to be resolved by the extension-header and L4 handlers. ipv6_rcv (net/ipv6/ip6_input.c:344) is the ~10-line wrapper that calls ip6_rcv_core and then runs the netfilter PRE_ROUTING hook — structurally identical to IPv4’s ip_rcv.

IPv6 address anatomy: 128 bits, scopes, and the /64 split
The autoconfig and NDP stories below toss around fe80::/64, ::, ff02::1, “host portion,” “prefix” — none of which mean anything until you know what a 128-bit address actually is. Days 1–9 only ever handled the 32-bit IPv4 dotted-quad (__be32). IPv6 is four times wider and structured.
The struct: 16 bytes
/* include/uapi/linux/in6.h:33 */
struct in6_addr {
union {
__u8 u6_addr8[16]; /* 16 bytes = 128 bits */
__be16 u6_addr16[8]; /* 8 groups of 16 bits */
__be32 u6_addr32[4]; /* 4 words of 32 bits */
} in6_u;
};
#define s6_addr in6_u.u6_addr8 /* the byte view */
Contrast with the bare 4-byte __be32 you’ve used everywhere up to Day 9. The union exists because different code wants different granularities — byte-at-a-time text formatting (s6_addr[16]), or word-at-a-time comparison (s6_addr32[4]); you’ll see both today.
Reading the text notation
Just enough to read the rest of the chapter. An address is eight groups of 16 bits, written in hex, colon-separated: 2001:0db8:0000:0000:0000:0000:0000:0001. Two shorthands:
- Drop leading zeros in each group:
2001:db8:0:0:0:0:0:1. - One
::collapses a single run of all-zero groups — and you may use it only once (otherwise it’d be ambiguous):2001:db8::1.
Now decode the chapter’s own recurring examples:
::— all 128 bits zero. This is the unspecified address, used as the source during Duplicate Address Detection (“I don’t have an address yet”).fe80::/64— the link-local prefix (fe80::followed by a 64-bit host portion).ff02::1,ff02::2— link-local-scope multicast addresses (all-nodes, all-routers). More on multicast in the NDP section.
The /64 split — prefix half + host half
This is the structure the entire SLAAC/EUI-64 story depends on. A normal unicast IPv6 address is conceptually two 64-bit halves:
|<------ 64 bits: network prefix ------>|<------ 64 bits: interface ID ------>|
fe80:: (link-local) OR from an RA the "host portion" you keep hearing about
- The upper 64 bits are the network prefix —
fe80::for a link-local address, or a globally-routable prefix learned from a Router Advertisement. - The lower 64 bits are the interface identifier, a.k.a. the “host portion.” EUI-64,
stable_secret, andtempaddr(all in the autoconfig section) are simply three different ways to fill in that lower 64 bits.
Scopes are first-class
In IPv4, “scope” was mostly convention. In IPv6 it’s structural, and the chapter relies on it:
- Link-local (
fe80::/10): never routed off the link. Every interface always has one, generated the moment the link comes up — before any router is heard from. This is what makes DAD and NDP possible before you have a global address. - Global (routable): assigned from prefixes a router advertises.
- Multicast (
ff00::/8): one-to-many; the substrate NDP runs on.
Because a link-local address exists immediately, the kernel can do neighbor discovery and address autoconfig over it from the very first millisecond of link-up.
Where the code starts
addrconf_addr_gen (net/ipv6/addrconf.c:3417) is where concept meets code. It seeds the link-local base and then branches on the per-interface generation mode:
/* paraphrased from addrconf_addr_gen() */
ipv6_addr_set(&addr, htonl(0xFE800000), 0, 0, 0); /* fe80:: ... upper half */
switch (idev->cnf.addr_gen_mode) { /* ... then fill lower 64 bits */
case IN6_ADDR_GEN_MODE_EUI64: /* from the MAC */
case IN6_ADDR_GEN_MODE_STABLE_PRIVACY:
case IN6_ADDR_GEN_MODE_RANDOM:
case IN6_ADDR_GEN_MODE_NONE:
}
The four modes are an enum (include/uapi/linux/if_link.h:459), and their values are exactly the 0/1/2/3 you set via sysctl:
enum in6_addr_gen_mode {
IN6_ADDR_GEN_MODE_EUI64, /* 0 */
IN6_ADDR_GEN_MODE_NONE, /* 1 */
IN6_ADDR_GEN_MODE_STABLE_PRIVACY, /* 2 */
IN6_ADDR_GEN_MODE_RANDOM, /* 3 */
};

There are no Dumb Questions
Q: If a link-local address is auto-generated the instant the link comes up — before any router is heard from — why do I still need a Router Advertisement at all?
A: Because link-local is non-routable. fe80::/10 never leaves the link; a router will not forward it. It’s enough to talk to neighbors on the same segment (and to run DAD and NDP), but to reach anything off-link you need a globally-routable prefix, and the only way a host learns one via autoconfig is from an RA. Link-local gets you bootstrapped; the RA gets you on the internet.
Q: Why is the split always /64? Why not some other prefix/host boundary?
A: Because SLAAC and EUI-64 assume a 64-bit interface identifier. The whole “append the host portion to the prefix” trick — EUI-64 stuffing fffe into a 48-bit MAC, stable-privacy hashing into 64 bits — produces a 64-bit lower half by construction. A prefix longer than /64 wouldn’t leave room for it, so SLAAC simply won’t run on non-/64 prefixes. The /64 boundary is baked into the autoconfig machinery, not just a convention.
Autoconfiguration
The first time you bring up an IPv6-enabled interface, the kernel runs through this sequence — most of it before any user-supplied configuration:

Step 1: assign a link-local address
Every IPv6 interface gets at least a link-local address in fe80::/64. As we just saw, that’s fe80:: (upper 64 bits) plus a host portion (lower 64 bits) derived from one of:
- EUI-64: insert
fffebetween the two halves of the MAC address (and flip the U/L bit). For MACaa:bb:cc:dd:ee:ffyou getfe80::a8bb:ccff:fedd:eeff. Predictable but privacy-leaking — the MAC stays the same across networks. stable_privacy(RFC 7217, configured via thestable_secretsysctl): hash a per-interface seed with a cryptographic RNG. Different every fresh boot or interface reset. This is the same mechanismaddr_gen_mode=2selects below.tempaddr(RFC 4941): privacy extensions; periodically rotate the host portion.
Sysctls that matter:
net.ipv6.conf.<dev>.addr_gen_mode: 0 = EUI-64, 1 = none, 2 = stable_privacy, 3 = random (initializes a random secret, then generates via the stable_privacy algorithm).net.ipv6.conf.<dev>.use_tempaddr: 0 = no privacy addrs, 1 = use them but prefer stable, 2 = prefer privacy.
Implementation: net/ipv6/addrconf.c:3417 addrconf_addr_gen — branches on addr_gen_mode and calls the right generator (the function we read above).
Step 2: Duplicate Address Detection (DAD)
Before committing an address, the host sends an ICMPv6 Neighbor Solicitation for it — with src :: (the unspecified address you now recognize), telling everyone “I’m tentatively claiming this.” If anyone replies, the address is in use; the kernel marks it dadfailed and refuses to use it. If nobody replies after dad_transmits retries (default 1) at intervals of RetransTimer (default 1 s), the address moves from tentative to preferred.
Where does that NS get sent, though? Not to a broadcast — to the target’s solicited-node multicast address (a per-address group derived from the address’s low 24 bits, built in the NDP section below). That’s the mechanism that makes DAD (and ARP-style lookup) cheap. For now:
ip -6 addr show dev eth0 # look for 'tentative' or 'dadfailed' flags
DAD is mandatory by spec; you can disable it with net.ipv6.conf.<dev>.accept_dad=0 for niche cases (link with no neighbors yet) but never on a shared LAN.
Step 3: Listen for Router Advertisements
ICMPv6 type 134 — emitted periodically by routers (the kernel itself doesn’t originate RAs; that’s a userspace daemon like radvd, whose MaxRtrAdvInterval defaults to 600 s — RFC 4861 permits 4 s–1800 s; unsolicited RAs are actually emitted at random intervals between MinRtrAdvInterval (~200 s by default) and MaxRtrAdvInterval (600 s)), or on demand in response to a Router Solicitation (type 133) from hosts that just came up. RAs carry, among other things:
- A list of prefixes with on-link/auto-config flags.
- Default route (the router itself).
- MTU.
- M flag (“Managed” — use DHCPv6 for addresses).
- A flag (“Autonomous” — do SLAAC from this prefix).
- O flag (“Other” — use DHCPv6 for non-address config like DNS).
- RDNSS option — recursive DNS server (RFC 8106).
If A=1 in a prefix, the host runs SLAAC: take the announced 64-bit prefix, append its 64-bit host portion (Step 1’s interface identifier), run DAD on the result, and install the address. If M=1, the kernel kicks off a DHCPv6 client (handled by userspace — dhclient -6, wpa_supplicant, NetworkManager, etc.). Both can be true simultaneously.
Step 4: NDP everywhere else
After address setup, NDP keeps neighbor state fresh. Same plumbing as Day 7’s neighbour subsystem — there’s a struct neigh_table nd_tbl at net/ipv6/ndisc.c:109 that’s structurally identical to arp_tbl. Same NUD states (REACHABLE, STALE, etc.), same gc thresholds, same ip neigh show. We do not re-teach the neighbour subsystem here — flip back to Day 7 for NUD and nd_tbl internals.
NDP — Neighbor Discovery Protocol in detail
NDP runs over ICMPv6. Before the message table, two pieces of new background: how an ICMPv6 packet even reaches the NDP handlers, and why NDP’s multicast is cheaper than ARP’s broadcast.
How an inbound packet reaches ndisc_rcv (ICMPv6 as NDP’s carrier)
NDP messages aren’t a new EtherType the way ARP was (Day 7). They are ICMPv6 messages — IPPROTO_ICMPV6 = 58, which sits at the end of the nexthdr chain like any other L4 protocol. So the extension-header walk from earlier must complete before NDP is even seen.
The dispatch mechanism is the IPv6 twin of Day 2’s registered-handler demux, so we don’t re-teach it: inet6_protos[] is the IPv6 twin of Day 2’s inet_protos[] — an RCU-protected, MAX_INET_PROTOS-sized array of struct inet6_protocol, indexed by protocol number (net/ipv6/protocol.c:25). ICMPv6 registers itself at slot 58 (net/ipv6/icmp.c:96):
static const struct inet6_protocol icmpv6_protocol = {
.handler = icmpv6_rcv, /* net/ipv6/icmp.c:1101 */
...
};
What is new is the second-level demux inside ICMPv6. Unlike IPv4’s icmp_rcv (which carries only echo/error), ICMPv6 does double duty — it carries echo/error and the entire neighbor-discovery control plane and (separately) MLD. So icmpv6_rcv switches on the ICMPv6 type byte, and for the five NDP types (133–137) hands off to ndisc_rcv (net/ipv6/icmp.c:1188):
/* icmpv6_rcv(), net/ipv6/icmp.c */
case NDISC_ROUTER_SOLICITATION:
case NDISC_ROUTER_ADVERTISEMENT:
case NDISC_NEIGHBOUR_SOLICITATION:
case NDISC_NEIGHBOUR_ADVERTISEMENT:
case NDISC_REDIRECT:
reason = ndisc_rcv(skb);
ndisc_rcv (net/ipv6/ndisc.c:1805) then switches again on the type to reach the concrete handler — ndisc_recv_ns for a Neighbor Solicitation (net/ipv6/ndisc.c:1831), ndisc_recv_na for an advertisement (net/ipv6/ndisc.c:1836), and so on. That two-step switch is the missing link between “NDP runs over ICMPv6” and the handler names below. In IPv6 there is no ARP EtherType and no separate ARP handler — it all funnels through this one inet6_protos slot.
The five message types
| Type | Name | Purpose |
|---|---|---|
| 133 | Router Solicitation | “Any router on link?” — host on bring-up |
| 134 | Router Advertisement | Router announcing itself + prefixes |
| 135 | Neighbor Solicitation | “Who has this address?” (≈ ARP request); also DAD |
| 136 | Neighbor Advertisement | Reply to NS; also unsolicited (≈ gratuitous ARP) |
| 137 | Redirect | “Send packets for X via Y instead of me” |
(Type constants: include/net/ndisc.h:9 — 133 RS, 134 RA, 135 NS, 136 NA, 137 Redirect.)
Why NS is cheaper than an ARP broadcast: solicited-node multicast
Day 7’s ARP resolves a neighbor by broadcasting at L2 — every NIC on the segment receives the frame, processes it, and (almost all) discard it. NDP does better with multicast, and this is the mechanism the comparison table’s “multicast-based, saves bandwidth” line actually refers to.
Minimal multicast model:
ff00::/8is the entire multicast address space. The second nibble encodes scope —ff02::is link-local scope (the only scope NDP needs).- Two you meet immediately:
ff02::1(all-nodes) andff02::2(all-routers — where Router Solicitations go).
The clever bit is the solicited-node multicast address. For any target unicast address, you take its low 24 bits and append them to the well-known prefix ff02::1:ff00:0/104, producing ff02::1:ffXX:XXXX. A host joins the solicited-node group for each of its own addresses. So when you want to resolve (or DAD-probe) a specific address, you send the NS to that address’s solicited-node group — and (almost always) only the target is subscribed, so only the target’s NIC is interrupted. Every other host’s hardware multicast filter drops the frame without waking the CPU. Same goal as an ARP broadcast, far fewer interrupts, and L2 switches can snoop the groups.
The kernel computes it with one helper (include/net/addrconf.h:484):
static inline void addrconf_addr_solict_mult(const struct in6_addr *addr,
struct in6_addr *solicited)
{
ipv6_addr_set(solicited,
htonl(0xFF020000), 0, /* ff02:: */
htonl(0x1), /* ...1: */
htonl(0xFF000000) | addr->s6_addr32[3]); /* ff + low 24 bits */
}
This ties Steps 2 and 3 together mechanically. DAD (Step 2) sends an NS for the tentative address, from src ::, to that address’s own solicited-node multicast group — computed by exactly this helper. The NS senders call it: net/ipv6/ndisc.c:382 and net/ipv6/ndisc.c:395 both use addrconf_addr_solict_mult to pick the destination. And ndisc_send_na (net/ipv6/ndisc.c:524) carries the solicited/override/router flags the table references. The NA installs a neigh entry — same nd_tbl/NUD machinery as Day 7.

Implementation entry points:
ndisc_recv_ns— incoming neighbor solicitation.ndisc_recv_na— incoming advertisement.ndisc_send_na,ndisc_send_ns— outgoing.
net/ipv6/ndisc.c is ~2000 lines but most of it is option parsing.
Compared to ARP, NDP is also richer in other ways:
- Authenticated with SEND (RFC 3971), though deployment is rare.
- Detects unreachability actively (bidirectional reachability checks, not just on-demand).
- Carries options for MTU, source link-layer address, prefix info, route info.
Extension headers — power and pitfall
We met the nexthdr chain at the top of the day. Now we walk it. The base header is 40 bytes; its nexthdr may point at an extension header, which carries its own nexthdr, forming a chain that ends at an L4 protocol (or NEXTHDR_NONE):

The headers in the chain
- Hop-by-Hop Options (0) — every router on the path must process. Used by Jumbograms (>64KB), MLD, RPL. Must be first if present.
- Routing (43) — Source Routing Header (SRH) for SRv6, RPL.
- Fragment (44) — IPv6 fragmentation (only by source; routers don’t fragment).
- Destination Options (60) — end-host options.
- Authentication Header (51) / ESP (50) — IPsec.
- Mobility (next-header 135 — distinct from the ICMPv6 message type 135, Neighbor Solicitation, above; different namespaces) — Mobile IPv6.
Each extension is parsed by a per-protocol handler registered in inet6_protos[] (the same table ICMPv6 lives in). The Routing handler is ipv6_rthdr_rcv (net/ipv6/exthdrs.c:658), Destination is ipv6_destopt_rcv (net/ipv6/exthdrs.c:299), etc.
How an option is encoded: the TLV format
The Hop-by-Hop and Destination Options headers don’t carry fixed fields — they carry a list of TLV (Type-Length-Value) records, and you need this layout to understand both the Check question and the CVE class below.
Each option is three parts:
+--------+--------+--------------------------+
| Type | Length | Value (Length bytes) |
| 1 byte | 1 byte | <-- exactly Length --> |
+--------+--------+--------------------------+
- Type (1 byte) — which option this is.
- Length (1 byte) — the length of the Value that follows.
- Value (
Lengthbytes) — the option data.
A Hop-by-Hop or Destination Options header is just a chain of these TLVs, padded to 8-byte alignment with two special options: Pad1 (a single zero byte) and PadN (a TLV whose value is N zero bytes). This is the classic “length-prefixed-buffer parser” pattern — and the Length byte is attacker-controlled, which is the seed of the whole CVE story.
The two high bits of the Type byte: unknown-option actions
This is the exact mechanism the Check question hinges on. The top 2 bits of the Type byte tell a node what it must do when it doesn’t recognize the option:
| Top 2 bits | Action on unknown option |
|---|---|
00 | Skip this option and continue parsing |
01 | Discard the packet, silently |
10 | Discard and always send an ICMPv6 Parameter Problem |
11 | Discard, and send a Parameter Problem only if the destination was not multicast |
(A third bit, 0x20, separately flags whether the option may change en route — relevant to the Authentication Header.)
The kernel implements precisely this in ip6_tlvopt_unknown (net/ipv6/exthdrs.c:65), which ip6_parse_tlv (net/ipv6/exthdrs.c:114) calls for each unrecognized option. It dispatches on those two bits (switch at net/ipv6/exthdrs.c:79):
switch ((skb_network_header(skb)[optoff] & 0xC0) >> 6) {
case 0: /* ignore */ return true; /* skip, keep parsing */
case 1: /* drop packet */ break; /* silent drop */
case 3: /* ICMP unless multicast */ ... fallthrough;
case 2: /* send ICMP, always drop */ icmpv6_param_prob_reason(...); return false;
}
ip6_tlvopt_unknown is invoked from ip6_parse_tlv’s option loop (net/ipv6/exthdrs.c:195 and :211), which handles Destination Options via ipv6_destopt_rcv (net/ipv6/exthdrs.c:329) and Hop-by-Hop at net/ipv6/exthdrs.c:1084; a hopbyhop bool parameter distinguishes the two callers. The Jumbo Payload option’s parser, ipv6_hop_jumbo (net/ipv6/exthdrs.c:996), is dispatched from the Hop-by-Hop option table — a tiny but instructive length-field parser, and historically a buggy one.
Why this is dangerous
Extension-header parsers are length-prefixed-buffer parsers in C, deep in the network stack, fed an attacker-controlled Length byte per TLV. A parser that trusts that Length without bounding it against the header’s own declared length is the recurring bug class. They’ve been a recurring source of CVEs:
- 2026 (kernel 7.1): SRv6 RPL OOB write —
ipv6_rpl_srh_rcv(net/ipv6/exthdrs.c:491) could push a recompressed SRH that exceeded headroom, causingskb_mac_header_rebuild(called atnet/ipv6/exthdrs.c:615) to underflowmac_headerto ~65530 andmemmoveto write 14 bytes ~64KiB pastskb->head. Fix in commit9e6bf146b559. - 2022: SRv6 Segment Routing Header out-of-bounds read when setting HMAC data (CVE-2022-48687, “ipv6: sr: fix out-of-bounds read when setting HMAC data”).
- Various jumbogram bugs in
ipv6_hop_jumbo(net/ipv6/exthdrs.c:996).
The pattern: an attacker controls extension-header lengths, and a parser miscomputes how much memory to allocate or how many bytes are valid. (That 2026 SRv6 bug is a skb-geometry bug — mac_header underflowing, memmove past skb->head. That’s the head/data/tail/end + headroom geometry from Day 1’s sk_buff chapter; refer back there rather than re-deriving it.) If you write an extension header parser, treat it like a fuzzing target from day one.
Skipping the chain
To get to L4, code calls ipv6_skip_exthdr(skb, start, &nexthdr, &frag_off) — walks the chain until reaching a non-extension nexthdr and returns the offset. The signature (net/ipv6/exthdrs_core.c:72):
int ipv6_skip_exthdr(const struct sk_buff *skb, int start,
u8 *nexthdrp, __be16 *frag_offp);
Quirks:
- Some L4 lookups need to skip all extension headers; others (like conntrack) want to inspect specific ones.
- A malformed chain (loop, oversized) causes
ipv6_skip_exthdrto return-1, and the kernel drops the packet. - It takes
frag_offbecause a Fragment header tells you the packet is part of a larger original — the caller may need to defer processing.
Today’s experiment
# See your IPv6 addresses and their states
ip -6 addr show
# Look for: 'global', 'mngtmpaddr', 'dynamic', 'tentative', 'deprecated'
# Watch DAD on bring-up
sudo bpftrace -e 'fentry:ndisc_send_ns {
printf("NS sent target=%s\n", ntop(args->solicit->in6_u.u6_addr8));
}' &
sudo ip link set eth0 down && sleep 1 && sudo ip link set eth0 up
# Watch NDP traffic globally
sudo tcpdump -i eth0 -nn icmp6 and not host ::
# Inspect autoconfiguration sysctls for one interface
sysctl net.ipv6.conf.eth0 | grep -E "addr_gen_mode|accept_ra|use_tempaddr|dad_transmits"
# Trace extension-header parsing
sudo bpftrace -e 'fentry:ipv6_skip_exthdr { printf("skip nexthdr=%d start=%d\n", *args->nexthdrp, args->start); }'
What to read in the kernel
-
net/ipv6/ip6_input.c:188—ip6_rcv_core. The IPv6 receive core logic (~145 lines, ~80 of real logic).ipv6_rcv(line 344) is a ~10-line wrapper that callsip6_rcv_core, then runs the netfilter PRE_ROUTING hook. Notice how it parses the base header, validatesversion=6, trims topayload_len, and dispatches via the registeredinet6_protos[]table — same pattern as IPv4’sip_rcvbut with the extension-header parser as the first handler. -
net/ipv6/addrconf.c— autoconf state machine. ~7600 lines but you only need a few entry points:addrconf_dad_start(search for the function; no fixed line) — kicks off DAD.addrconf_rs_timer— periodic RS solicitation when no router heard from.addrconf_prefix_rcv— handle a prefix from RA: install address, run DAD on it.addrconf_addr_gen(line 3417) — host-portion generation.
Read the top of the file’s comments first; the model is a state machine per
inet6_dev. -
net/ipv6/ndisc.c:109—nd_tbl, the neighbour-table instance. The struct is identical to IPv4’sarp_tbl— confirms how generic the neighbour subsystem is. Around it,ndisc_recv_ns,ndisc_recv_na,ndisc_send_na,ndisc_send_nsare the protocol handlers;ndisc_rcv(line 1805) is the type-switch they hang off. -
net/ipv6/exthdrs.c— extension-header parsers.ipv6_rthdr_rcv(line 658): Routing header. Read this to understand SRv6 — the most actively-developed extension. Also where most CVEs have been.ipv6_destopt_rcv(line 299): Destination Options. Simpler; good warm-up. Callsip6_parse_tlv.ip6_parse_tlv(line 114): the generic TLV walker, with the unknown-option action dispatch at line 79.ipv6_hop_jumbo(line 996): the parser for the Jumbo Payload option in HOPOPT. Tiny but instructive.
-
include/uapi/linux/in6.handinclude/net/ipv6.h— the canonical structs (struct ipv6hdr,struct in6_addr, theNEXTHDR_*constants). -
Documentation/networking/ipv6.rst— overview. Light but has pointers to RFC numbers. -
RFCs to skim: 8200 (IPv6 spec), 4861 (NDP), 4862 (SLAAC), 4941 (privacy extensions), 7136 (modified EUI-64), 8754 (SRv6).
Bullet Points
- The base header is fixed at 40 bytes: version=6, a
prioritynibble plusflow_lbl[3](together holding the 8-bit traffic class — priority + top 4 bits of flow_lbl — and the 20-bit flow label in the low 20 bits of flow_lbl),payload_len(length after the header — unlike IPv4tot_len),nexthdr,hop_limit(TTL renamed), two 16-byte addresses. No IHL, no header checksum — options moved to the extension-header chain. nexthdris one shared protocol-number space (6=TCP, 0=HopByHop, 43=Routing, 44=Fragment, 59=None, 60=DestOpts): parse it as a loop until you hit an L4 protocol.- An IPv6 address is 128 bits (
struct in6_addr, 16 bytes), split into a 64-bit prefix + 64-bit host portion.::= unspecified,fe80::/64= link-local (always present),ff00::/8= multicast. - IPv6 hosts auto-configure: link-local (fe80::/64) immediately, global addresses from RAs.
- Address generation modes (
addr_gen_mode): EUI-64 (0, kernel default), none (1), stable_privacy (2), random (3). - DAD is mandatory: send NS for your tentative address to its solicited-node multicast group (
ff02::1:ffXX:XXXX, low 24 bits of the target), from src::, wait, then commit. - Solicited-node multicast is why NDP beats ARP: only the target host is subscribed, so only its NIC wakes — vs an ARP L2 broadcast every host processes.
- ICMPv6 (proto 58) carries NDP.
inet6_protos[](Day 2’sinet_protos[]twin) →icmpv6_rcv→ (type switch) →ndisc_rcv→ndisc_recv_ns/_na/… - NDP = ICMPv6 messages 133–137: RS, RA, NS, NA, Redirect. Replaces ARP. The
neighboursubsystem (nd_tbl) is structurally identical to IPv4’sarp_tbl(Day 7). - Extension headers chain between base IPv6 header and L4. Walked by
ipv6_skip_exthdr. Important ones: HopByHop (0), Routing (43, SRv6), Fragment (44), DestOpts (60), AH/ESP (51/50). - HopByHop/DestOpts options are TLV-encoded (Type, Length, Value). The top 2 bits of Type select the unknown-option action: skip / drop / drop+ICMP / drop+ICMP-unless-multicast (
ip6_parse_tlv). - Extension-header parsers trust an attacker-controlled Length byte — a recurring CVE source. Treat as a fuzz-prone area.
Check question
A node receives an IPv6 packet whose first extension header is nexthdr=0 (Hop-by-Hop Options). What action is the kernel obligated to take, even if the packet is for a peer (not for us)?
Click to reveal answer
Answer: Process the Hop-by-Hop options. HopByHop is special — every router on the path must inspect it, regardless of whether the packet’s destination is local. (That’s what “hop-by-hop” means.) The kernel parses the Hop-by-Hop options via the same ip6_parse_tlv walker you saw above (its hopbyhop caller), immediately after the base header. If an option is unknown and has the high bits in the option-type byte set to indicate “discard packet on unknown option,” the packet is dropped (and possibly an ICMPv6 Parameter Problem returned). Other extensions like Destination Options are processed only at the destination — the kernel skips them on a forwarding path. The Hop-by-Hop processing requirement is also why this header must be the first extension if present; later positions are spec-illegal.
Tomorrow
Day 11: the bridge subsystem. Linux as a software L2 switch.
Day 11 — The bridge subsystem
Today’s mission: create a software Linux bridge, attach two interfaces to it, and watch the FDB learn MACs as traffic flows. Along the way you’ll learn the four mechanisms the bridge is built on — the
rx_handlerhook that hijacks a NIC’s frames, the RCU-protected hash table that keeps the FDB lock-free for readers, why L2 loops are catastrophic (and what spanning tree does about it), and howbr_netfilterdrags bridged frames through iptables — plus how VLAN-aware bridges deliver real switch behavior. Total time: ~110 minutes.
What a Linux bridge is, and why you have one
A Linux bridge is a software L2 switch. It looks like a netdev (you can give it an IP, ping it, configure routes through it) but its job is to forward Ethernet frames between member ports based on destination MAC. Like any switch, it learns which MAC lives behind which port and stores that mapping in a table called the FDB (forwarding database). The implementation lives in net/bridge/ (~29k lines of top-level .c).
You’re using bridges constantly even if you’ve never explicitly created one:
- Container runtimes. Docker creates
docker0by default; Podman createscni-podman0; CNI plugins createcni0/cbr0. Each one is a Linux bridge that wires container veths to the host network. - VMs. libvirt’s “default” network is a bridge (
virbr0);bridge=br0mode in qemu attaches the VM’s TAP (a software NIC that hands frames to a userspace process like qemu) directly to a host bridge. - Multi-NIC servers acting as switches. A box with multiple Ethernet NICs can bridge them with one
ip link add br0 type bridgeplusip link set <if> master br0per port.
The bridge is the fastest L2-forwarding plane in the kernel — much faster than the IP routing path because, switching purely by destination MAC, it skips everything L3 does: no L3/FIB lookup, no TTL decrement and header-checksum rewrite, and (by default — see br_netfilter) no netfilter traversal or decapsulation.
This chapter leans on four pieces of machinery that no earlier day taught you: the rx_handler hook that diverts a slave NIC’s frames into bridge code; the RCU-protected rhashtable that backs the forwarding database; the broadcast-storm / spanning-tree problem that explains why switches need STP at all; and the netfilter chains that br_netfilter exposes. We’ll teach each one — intuition first, then the concrete v7.1 struct or function — right where the bridge depends on it.
Two things from earlier days are load-bearing today and will not be re-taught:
- The RX path. Recall from Day 2 — the NIC’s NAPI poll builds an skb, then
__netif_receive_skb_corewalks theptype_basehash to find the L3 handler (ip_rcvfor IPv4). Today’s whole story is about a hook that fires before that demux.- The CPU cache line. Recall from Day 1 — the CPU reads memory in 64-byte lines, so write-heavy fields are kept off read-mostly cache lines to avoid false sharing. We’ll meet that idea once more, in the FDB entry.
Anatomy

A bridge is two structs working together:
struct net_bridge(net/bridge/br_private.h:495) — the bridge itself. Holds the FDB (forwarding database) — the MAC-address-to-port table that is the heart of any L2 switch — plus the port list, VLAN config, IGMP-snooping state, MST/STP state. One of these perbrNnetdev.struct net_bridge_port(net/bridge/br_private.h:387) — one per slave interface. Holds thedevpointer (the underlying netdev), the port number, STP state (DISABLED, LEARNING, FORWARDING, …), and per-port flags (BPDU guard, root guard, hairpin, etc.).
When you do ip link set eth0 master br0, the kernel attaches a net_bridge_port to eth0, registers br_handle_frame as eth0’s rx_handler, and the slave’s frames now flow into bridge logic instead of the normal stack. That sentence hides the single most important mechanism in the chapter — so let’s open it up.
Background 1: the rx_handler — how a slave NIC’s frames get hijacked
Recall the RX path from Day 2: a frame is DMAed in, NAPI builds an skb, and __netif_receive_skb_core indexes ptype_base by skb->protocol to find the one L3 handler (ip_rcv, the IPv6 handler, ARP, …). That’s the “normal stack.” A bridge has to get its hands on the frame before that L3 demux runs — otherwise a frame destined for some other host would be delivered to this host’s IP stack instead of being switched out another port.
The kernel’s hook for exactly this is the rx_handler.
One function pointer per netdev
Every struct net_device carries a single, optional rx_handler slot (include/linux/netdevice.h:2189):
rx_handler_func_t __rcu *rx_handler;
void __rcu *rx_handler_data;
When that pointer is set, __netif_receive_skb_core calls it before the ptype_base packet-type walk that Day 2 traced. So the rx_handler sits at the exact spot in the RX path where the L3 demux would otherwise run, and gets first refusal on the frame. There is only one rx_handler per device — registering a second returns -EBUSY (include/linux/netdevice.h:461-477). The bridge, bonding, macvlan, and OVS all compete for this same plug-in slot; a “join a master” device is one that has installed an rx_handler.
Its return value decides the frame’s fate
The handler tells the receive path what to do with the skb via an enum rx_handler_result (documented at include/linux/netdevice.h:461-477). The two that matter:
RX_HANDLER_CONSUMED— “I took the skb; do not process it further.” The frame never reachesptype_base, never reachesip_rcvon the slave NIC. The bridge returns this for every data frame — including one addressed to the bridge’s own IP. That last case is the subtle one:br_handle_framestill returns CONSUMED, butbr_handle_frame_finishnotices the destination is aBR_FDB_LOCALentry and callsbr_pass_frame_up(), which re-injects the skb onto the bridge netdev (skb->dev = br0) vianetif_receive_skb(net/bridge/br_input.c:218-220). So “pingingbr0” works by re-injection onbr0, not by passing the frame through on the slave port.RX_HANDLER_PASS— “Do nothing; proceed as if no handler ran.” The bridge uses this only for the genuine pass-through cases:PACKET_LOOPBACK, and locally-consumed link-local control frames (STP BPDUs, LLDP) handled via__br_handle_local_finish. It is not how a frame reaches the bridge’s own IP.
(There are two more — RX_HANDLER_ANOTHER, re-loop because skb->dev changed, and RX_HANDLER_EXACT, force exact delivery — that the bridge doesn’t lean on today.)
Where the bridge installs it
Back in br_add_if (the code path behind ip link set eth0 master br0), the bridge registers its handler (net/bridge/br_if.c:613):
err = netdev_rx_handler_register(dev, br_get_rx_handler(dev), p);
Three things happen in that one call:
br_get_rx_handler(dev)(net/bridge/br_input.c:463) returnsbr_handle_framefor an ordinary port (a special dummy is used for DSA switch ports).netdev_rx_handler_registerplantsbr_handle_frameintodev->rx_handler.- The third argument —
p, thenet_bridge_port— is stashed asrx_handler_data, so when a frame later arrives,br_handle_framecan recover which port and which bridge it belongs to.
br_handle_frame itself is at net/bridge/br_input.c:339:
static rx_handler_result_t br_handle_frame(struct sk_buff **pskb)

So the picture is: a frame on a slave NIC reaches __netif_receive_skb_core, which checks dev->rx_handler. It’s set, so br_handle_frame runs. For any data frame — switched or destined to the bridge’s own IP — it returns RX_HANDLER_CONSUMED, so the frame is gone from the slave NIC’s perspective. A frame addressed to the bridge’s own IP still reaches ip_rcv, but by re-injection on br0 (via br_pass_frame_up → netif_receive_skb), not by falling through the slave’s ptype_base demux. RX_HANDLER_PASS is reserved for loopback and locally-handled link-local control frames.
The forwarding decision

For every frame that lands on a bridge port, the kernel runs the same compact decision tree:
- Drop or forward control frames. STP BPDUs (Bridge Protocol Data Units — Spanning Tree control frames, covered in Background 3; destined to
01:80:c2:00:00:00) get special handling; non-bridge multicast may be flooded or snooped. - Learn the source. Insert/refresh
(src MAC, vid, port)in the FDB — the forwarding database, whose internals are Background 2 just below — viabr_fdb_update(net/bridge/br_fdb.c:972). Aging timers tick. - Look up the destination.
br_fdb_find_rcu(net/bridge/br_fdb.c:263) walks the hash table. - Decide:
- Hit, same port → drop (don’t loop the frame back to where it came from).
- Hit, different port →
br_forward(net/bridge/br_forward.c:144) — send out that one port. - Miss →
br_flood(net/bridge/br_forward.c:201) — send to every port except the input port. - Broadcast/multicast — flood, possibly filtered by IGMP/MLD snooping.
The “miss → flood” rule is what makes bridges self-learning. Initial frames flood; replies teach the FDB the source’s port; subsequent frames are unicast.
The actual entry point is br_handle_frame (net/bridge/br_input.c:339), which is registered as the rx_handler when an interface joins a bridge. Following the path br_handle_frame → br_handle_frame_finish (net/bridge/br_input.c:76) gives you the full L2 forwarding code in ~250 lines.
Background 2: the FDB is an RCU-protected rhashtable
Steps 2 and 3 above both hit the FDB — one write, one read — on every single frame. At line rate that’s millions of lookups per second, so the data structure underneath has to let readers run without ever blocking each other or the writers. Two kernel mechanisms make that possible, and neither was introduced earlier, so let’s build them up.
rhashtable: the kernel’s resizable hash table
rhashtable is the kernel’s generic, automatically-resizing hash table. The FDB configures one with a small parameter block (net/bridge/br_fdb.c:27):
static const struct rhashtable_params br_fdb_rht_params = {
.head_offset = offsetof(struct net_bridge_fdb_entry, rhnode),
.key_offset = offsetof(struct net_bridge_fdb_entry, key),
.key_len = sizeof(struct net_bridge_fdb_key),
.automatic_shrinking = true,
};
The key is not just the MAC — it’s the {MAC, vlan_id} pair (net/bridge/br_private.h:286):
struct net_bridge_fdb_key {
mac_addr addr;
u16 vlan_id;
};
That single fact explains a behavior you’ll see in the VLAN lab below: the same MAC address in two different VLANs is two separate FDB entries, because the VID is part of the key. A lookup is rhashtable_lookup(tbl, &key, br_fdb_rht_params) (net/bridge/br_fdb.c:216).
RCU in one paragraph (it’s new here)
RCU (Read-Copy-Update) is a synchronization scheme tuned for read-mostly data. Readers take no lock at all: they call rcu_read_lock(), walk the structure, and call rcu_read_unlock(). A writer that wants to delete an entry first unlinks it so new readers can’t find it, then defers actually freeing the memory until every reader that might still hold a pointer has finished (the “grace period”). The payoff is enormous for the FDB: the per-frame forwarding hot path looks up destinations with zero lock contention, even while another CPU is busy learning new MACs.
This is what the _rcu suffix on br_fdb_find_rcu announces by convention: “you must call me inside an RCU read-side section, and the pointer I return is only valid until rcu_read_unlock().” So:
- Readers — the forwarding path — call
br_fdb_find_rcu(net/bridge/br_fdb.c:263) →rhashtable_lookup, no lock per frame. - Writers — the learning path — call
br_fdb_update(net/bridge/br_fdb.c:972). Crucially, the common per-frame case (an already-known source) refreshes the entry’supdated/dstfields lock-free viaWRITE_ONCE()— no lock at all.br->hash_lock(net/bridge/br_private.h:497) is taken only on the slow path that inserts a brand-new entry (fdb_create) or deletes one.
One callback to Day 1’s cache line
That updated timestamp is touched on every learning frame — it’s write-heavy. The FDB lookup fields (key, dst) are read-mostly. If they shared a 64-byte cache line, every aging-timestamp write would invalidate the line that lookups are reading — false sharing (recall Day 1). So the kernel deliberately puts updated on its own line (net/bridge/br_private.h):
/* write-heavy members should not affect lookups */
unsigned long updated ____cacheline_aligned_in_smp;
That ____cacheline_aligned_in_smp is the same cache-line lesson from Day 1, applied to keep write-heavy aging off the read-mostly lookup path.

Put together: readers are lockless. A writer makes a new entry visible atomically through the rhashtable insert (hlist_add_head_rcu), and a deleted entry’s memory is reclaimed only after an RCU grace period — so a concurrent reader mid-traversal always sees a consistent, whole entry, never a torn or freed one. The per-frame refresh of an existing source’s updated/dst fields is a lock-free in-place WRITE_ONCE(), taking no lock and forcing no grace period. That’s why FDB lookups scale.
VLAN-aware bridges
By default a bridge ignores VLAN tags — it’s just an L2 switch. Toggle VLAN-awareness:
sudo ip link set br0 type bridge vlan_filtering 1
Now the bridge implements proper 802.1Q switching:
- Each port has a list of allowed VLANs (egress: tagged or untagged) and a PVID (default VLAN for untagged ingress).
- Frames are forwarded only between ports that share a VLAN.
- The FDB is keyed on
(MAC, vid)not just MAC — same MAC in different VLANs is two separate entries (that’s thenet_bridge_fdb_keyfrom Background 2).
# Allow VLAN 100 on two ports, untagged on port A, tagged on port B:
sudo bridge vlan add dev v1p vid 100 pvid untagged
sudo bridge vlan add dev v2p vid 100 tagged
Now untagged frames from v1p are tagged with VID 100 internally, can be forwarded to v2p (which expects them tagged), and on egress the bridge tags them out.
This is the foundation of VLAN-aware bridges used in network namespaces (the netns you built in Day 5) and in VLAN-segmented host setups — libvirt/KVM VM hosting and VLAN-partitioned container/namespace networks (bridge vlan_filtering).

On ingress, an untagged frame is stamped with the port’s PVID (br_allowed_ingress); the FDB lookup is keyed on {MAC, vid}; on egress each port applies its own tag/untag policy (br_handle_vlan). Forwarding is gated by shared membership — a frame moves between two ports only if both are members of its VID. That’s why moving v2p to VID 200 breaks connectivity at the egress membership check, not at a FDB miss.
There are no Dumb Questions
Q: Why doesn’t the bridge just run netfilter (iptables) by default?
A: Because the bridge operates below IP — it switches frames by MAC and returns
RX_HANDLER_CONSUMEDlong before any L3 routing decision, so iptables rules on the IP path never see bridged traffic. Subjecting every L2 frame to netfilter would also cost performance on the hot path. You opt in by loadingbr_netfilter(Background 4), which then drags bridged frames through the IP chains — at the price of a packet sometimes traversing iptables twice.Q: Why is the input port excluded from flooding?
A: Split-horizon. The frame already arrived on that port, so resending it there is at best wasted and at worst loops on a shared medium. The exclusion is structural:
should_deliver()returns false whenskb->dev == p->dev(unlessBR_HAIRPIN_MODE), independent of what the FDB has learned.Q: Why are there two FDB entries for the same MAC under VLAN filtering?
A: The FDB key is
{MAC, vlan_id}, not just the MAC (struct net_bridge_fdb_key). The same MAC seen in VLAN 100 and VLAN 200 hashes to two distinct keys, so it occupies two separate entries — which is exactly what keeps the VLANs isolated.
Background 3: why L2 loops are catastrophic, and what STP does about it
The chapter is about to tell you to “enable STP and watch the port walk LISTENING → LEARNING → FORWARDING.” But STP solves a problem you’ve never been shown, so it would be enabling a cure for an unnamed disease. Here is the disease.
The broadcast storm
The bridge’s own rule — miss → flood, broadcast → flood — sends a frame out every port except the one it came in on. That’s perfectly safe in a tree-shaped topology. But suppose two bridges are wired together with two links (for redundancy), forming a physical loop. Now a single broadcast frame:
- arrives at bridge A, which floods it out both links to bridge B;
- bridge B floods each copy out its other ports — including back toward A on the other link;
- A floods those again…
The frame circulates forever and multiplies at every hop. Within milliseconds the links are saturated with billions of copies of one frame — a broadcast storm that takes the segment down completely. And here’s the killer detail: an Ethernet frame has no TTL field. Unlike an IP packet, which a router decrements and eventually drops, nothing in the frame itself ever stops the loop. The topology must be loop-free, or the network dies.
STP: prune the graph to a tree
STP (Spanning Tree Protocol, IEEE 802.1D) is the fix. The bridges talk to each other, elect a single root bridge, and compute a loop-free spanning tree over the physical graph. Redundant links that would close a loop are put into a BLOCKING state — they carry no data frames, so exactly one active path exists between any two ports. If an active link fails, a blocked link is brought back up to restore connectivity. You get redundancy without the storm.
The port states, grounded
The transitional states the lab makes you watch are a real kernel enum (include/uapi/linux/if_bridge.h:49-53):
#define BR_STATE_DISABLED 0
#define BR_STATE_LISTENING 1
#define BR_STATE_LEARNING 2
#define BR_STATE_FORWARDING 3
#define BR_STATE_BLOCKING 4
A newly-up port under STP doesn’t start forwarding immediately — it could create a transient loop before the tree has converged. So it walks LISTENING → LEARNING → FORWARDING, pausing one forward delay at each transitional stage to let the topology settle. The default forward delay is 15 * HZ (15 seconds), set in br_dev_setup (net/bridge/br_device.c:528):
br->bridge_forward_delay = br->forward_delay = 15 * HZ;
Two stages × 15 s ≈ the ~30 seconds of startup latency the experiment observes — and exactly why containers and VMs, where you control the topology and know there’s no loop, leave STP off. When STP is disabled (or forward delay is 0), the kernel skips the LISTENING/LEARNING walk entirely and jumps a port straight to FORWARDING (net/bridge/br_stp.c:454):
if (br->stp_enabled == BR_NO_STP || br->forward_delay == 0) {
br_set_state(p, BR_STATE_FORWARDING);
...
}
BPDUs: the control frames STP exchanges
STP bridges talk via BPDUs (Bridge Protocol Data Units), destined to the reserved group MAC 01:80:c2:00:00:00. These must never be forwarded like ordinary data — they’re consumed and processed locally. br_handle_frame special-cases the whole link-local 01:80:c2:00:00:0X range before data-frame forwarding by switching on the low byte of the destination (net/bridge/br_input.c:382-408):
switch (dest[5]) {
case 0x00: /* Bridge Group Address — STP BPDUs */
...
case 0x01: /* IEEE MAC (Pause) */
...
case 0x0E: /* 802.1AB LLDP */
...
}
So control frames branch off here; everything else continues into the learn/lookup/forward tree.

Background 4: the bridge and netfilter (a forward reference)
The next section, and one of the “What to break” labs, talk about pushing bridged frames through “iptables”, the “FORWARD chain”, and “PREROUTING”. You’ve only glimpsed netfilter once — nf_hook_slow flashing past in a Day-2 trace. Full coverage is a Phase 4 topic (Days 20–22). Here’s just enough to follow today’s bridge-specific twist, and no more.
netfilter is the kernel’s packet-filtering framework. Tools like iptables and nftables install rules into named chains that are attached at fixed hook points along the IP path — PREROUTING, FORWARD, POSTROUTING, and others. A packet normally encounters these chains only when it is routed at L3.
Now the bridge-specific point this section is really about: by default, bridged frames do not enter the IP (iptables) netfilter chains at all. The bridge operates below IP — it switches frames by MAC and returns RX_HANDLER_CONSUMED long before any L3 routing decision. So an iptables rule in the FORWARD chain simply doesn’t see bridged traffic. (Bridge-family netfilter — ebtables/nft bridge — is a separate matter and is always available; see below.)
Loading the optional br_netfilter module changes that. It sets net.bridge.bridge-nf-call-iptables=1, which pushes bridged frames through the IP netfilter chains anyway — which is exactly why, in the lab, a FORWARD -j DROP rule suddenly kills a ping that never left L2. The mechanics: for every forwarded frame, br_handle_frame’s finish path runs through nf_hook_bridge_pre() (net/bridge/br_input.c:267), which dispatches any registered NFPROTO_BRIDGE PRE_ROUTING hooks (used by ebtables/nft bridge family) and otherwise falls straight through to br_handle_frame_finish (if (!e) goto frame_finish;, br_input.c:282-284). That dispatch is not what br_netfilter adds. What br_netfilter (net/bridge/br_netfilter_hooks.c) does is register a hook at NF_BR_PRE_ROUTING which redirects bridged frames into the IP (iptables) chains — and it’s that registration, not the dispatch loop, that makes a FORWARD -j DROP rule bite.
Forward pointer: netfilter, chains, conntrack, and hooks are covered in full on Days 20–22. For today you only need two facts: iptables rules live on the IP path, and
br_netfilteris the switch that subjects bridged frames to them. We deliberately stop here — no pre-teaching the rest of Phase 4.
STP and netfilter knobs (the operator’s view)
With the why in hand, the operator-facing toggles are short.
Enable STP:
sudo ip link set br0 type bridge stp_state 1
When STP is on, the bridge sends/processes BPDUs, computes a loop-free topology, and puts redundant ports in BLOCKING. For container/VM scenarios where you control the topology, leave STP off — it adds the ~30 s startup latency from Background 3.
Enable br_netfilter:
sudo modprobe br_netfilter
Useful for: applying iptables to traffic between VMs that share a bridge, transparent proxying, host-as-firewall scenarios.
Gotcha: br_netfilter costs performance (every bridged frame now traverses netfilter); it’s also semantically tricky (a single packet may traverse iptables chains twice — once as bridge ingress, once as IP forward). For pure L2 forwarding without IP-level filtering, leave br_netfilter off.
Today’s experiment
Build a two-namespace bridged network (the veth pairs and network namespaces are from Day 5):
sudo ip link add br0 type bridge
sudo ip link set br0 up
sudo ip link add v1 type veth peer name v1p
sudo ip link add v2 type veth peer name v2p
sudo ip link set v1p master br0
sudo ip link set v2p master br0
sudo ip link set v1p up
sudo ip link set v2p up
sudo ip netns add ns1
sudo ip netns add ns2
sudo ip link set v1 netns ns1
sudo ip link set v2 netns ns2
sudo ip netns exec ns1 ip addr add 10.0.0.1/24 dev v1
sudo ip netns exec ns1 ip link set v1 up
sudo ip netns exec ns2 ip addr add 10.0.0.2/24 dev v2
sudo ip netns exec ns2 ip link set v2 up
# Test
sudo ip netns exec ns1 ping -c 2 10.0.0.2
Inspect the FDB:
bridge fdb show dev v1p # learned MAC of v1
bridge fdb show dev v2p # learned MAC of v2
bridge fdb show prints several lines per port — don’t expect just one:
92:56:a0:eb:25:81 master br0 # <- the learned entry (v1's MAC)
ee:72:dd:99:36:fa vlan 1 master br0 permanent
ee:72:dd:99:36:fa master br0 permanent
33:33:00:00:00:01 self permanent
01:00:5e:00:00:01 self permanent
The learned entry is the single line shown as <mac> master br0 without a permanent flag — that is v1’s MAC, learned from ns1’s traffic, and it ages out after 300 s (the default ageing_time). The ... permanent line is the port’s own MAC; the 33:33:.../01:00:5e:... self permanent lines are multicast-group memberships — none of those are learned traffic.
Trace the learning path. br_fdb_update (the FDB writer from Background 2) runs on the per-frame learning path, so bound the probe with an interval exit and provoke traffic while it runs:
sudo bpftrace -e 'fentry:br_fdb_update {
printf("learn vid=%d port=%s\n", args->vid, args->source->dev->name);
} interval:s:10 { exit(); }'
(If you try to list the probe first with bpftrace -l "fentry:br_fdb_update" it comes back empty — br_fdb_update is a local symbol in the loadable bridge module, and listing module fentry probes needs the module qualifier: fentry:bridge:br_fdb_update. The unqualified run form above still attaches fine; you don’t need the qualifier to probe it.)
While that runs, in another terminal flush the FDB (so re-learning fires the probe) and ping:
sudo bridge fdb flush dev v1p master
sudo ip netns exec ns1 ping -c 5 10.0.0.2
br_fdb_update fires on every received frame on a learning port, so you’ll see one learn line per packet per source port (a 5-packet ping prints ~5 lines for v1p) — not once per MAC. For an already-known source each call refreshes the entry’s updated timestamp (Background 2) lock-free via WRITE_ONCE() — which is how an active entry avoids aging out. (vid=0 appears here because vlan_filtering is still off; it becomes 100 only after you enable filtering below.) The probe auto-exits after 10 s.
Then turn on VLAN filtering:
sudo ip link set br0 type bridge vlan_filtering 1
sudo bridge vlan add dev v1p vid 100 pvid untagged
sudo bridge vlan add dev v2p vid 100 pvid untagged
sudo bridge vlan show
# After flushing FDB, traffic should still pass (both ports in VLAN 100).
# The `master` scope flushes the entries the bridge LEARNED on each port.
# Without it, iproute2 defaults to `self`, and a veth has no self-FDB delete
# handler, so the kernel returns "Operation not supported" and nothing flushes:
sudo bridge fdb flush dev v1p master
sudo bridge fdb flush dev v2p master
sudo ip netns exec ns1 ping -c 2 10.0.0.2
# Now move v2p to a different VLAN:
sudo bridge vlan del dev v2p vid 100
sudo bridge vlan add dev v2p vid 200 pvid untagged
# ping should now fail — v1p in VLAN 100, v2p in VLAN 200, frame dropped at egress:
sudo ip netns exec ns1 ping -c 2 -W 1 10.0.0.2 # 100% packet loss, exit code 1
What to break
- Set
stp_state 1on a bridge with no loops. To actually watch the state machine run, enable STP and then bounce a port — just settingstp_state 1on a bridge whose ports are already forwarding leaves them forwarding, because thelistening → learningwalk only happens when a port comes up under STP:
You’ll seesudo ip link set br0 type bridge stp_state 1 sudo ip link set v1p down; sudo ip link set v1p up watch -n1 bridge link show dev v1pstate listening→learning→forwardingover ~30 s (default forward delay is 15 s, applied once per stage — see Background 3). Containers usually don’t tolerate this delay; leave STP off. - Enable
br_netfilterand add an iptables DROP rule on the bridge. Loading the module defaultsnet.bridge.bridge-nf-call-iptables=1, which is what routes bridged frames through iptables (Background 4):
Clean up by deleting the exact rule. Note thatsudo modprobe br_netfilter sudo iptables -A FORWARD -j DROP sudo ip netns exec ns1 ping -c2 -W1 10.0.0.2 # now fails — iptables sees bridged framesiptables -P FORWARD ACCEPTonly resets the chain policy — it will NOT remove an appended-Arule, so it leaves connectivity broken:sudo iptables -D FORWARD -j DROP sudo modprobe -r br_netfilter - Mix VLANs: put
v1pandv2pin different VLANs. FDB miss → flood, but flood respects VLAN membership, so frame is dropped.bridge -s vlan showreveals the rules.
What to read in the kernel
-
net/bridge/br_input.c:339—br_handle_frame. This is the rx_handler installed bybr_add_if. Read top to bottom (~110 lines including the no-port early returns). Notice it dispatches BPDUs separately from data frames (theswitch (dest[5])at line 382), applies VLAN filtering, and then either invokes the netfilter PREROUTING hook (ifbr_netfilteris loaded) or jumps directly tobr_handle_frame_finish. -
net/bridge/br_input.c:76—br_handle_frame_finish. The actual switch logic. Walk through it: FDB lookup, multicast handling, forward vs flood decision. This is the function whose performance limits how fast a Linux bridge can switch — every cycle here is on the per-frame hot path. -
net/bridge/br_fdb.c:263—br_fdb_find_rcu. Hash lookup. Note the RCU-protected design — readers don’t lock, writers update an existing entry’s fields lock-free and takebr->hash_lockonly to insert/delete. The FDB is the kernel’srhashtable(br_fdb_rht_params, line 27), keyed on{MAC, vlan_id}(struct net_bridge_fdb_key,br_private.h:286) and looked up viarhashtable_lookup(line 216). -
net/bridge/br_fdb.c:972—br_fdb_update. The learning path — the FDB writer. For a known source it refreshes the entry’sdstand the cache-line-isolatedupdatedfield in place viaWRITE_ONCE()(no lock); it takesbr->hash_lockonly tofdb_createa new entry. Notice theBR_FDB_ADDED_BY_EXT_LEARNflag (br_private.h:278, tested viatest_bitonfdb->flags;NTF_EXT_LEARNEDon the netlink path) — this is how SDN controllers push entries from userspace. -
net/bridge/br_forward.c:144—br_forward. Egress on a single port. Updates stats, appliesBR_HAIRPIN_MODE(loops the frame back out the input port — used for some virtual networking patterns), then callsbr_forward_finishwhich hands off todev_queue_xmit. -
net/bridge/br_forward.c:201—br_flood. Iterate ports, skip the input port and any withBR_FLOODcleared, send to each via__br_forward. Notice the optimization: the original skb is consumed by the last egress port; every earlier port receives askb_clone()(deliver_clone()). The deferral carries the previously-matched port inprevand only clones to it once a subsequent deliverable port appears, so exactly one clone is saved (the last port reuses the original). -
include/linux/netdevice.h:2189— therx_handlerfield onstruct net_device, and theenum rx_handler_resultdoc at:461-477(CONSUMED vs PASS, the single-handler /-EBUSYrule).net/bridge/br_if.c:613shows thenetdev_rx_handler_registercall that wiresbr_handle_framein. -
net/bridge/br_vlan.c— VLAN-aware bridge logic. ~2350 lines. The two main functions arebr_allowed_ingress(ingress filtering) andbr_handle_vlan(egress tagging). -
net/bridge/br_stp_*.c— STP/RSTP implementation. Skimbr_stp_set_bridge_priorityandbr_become_root_bridgefor the high-level state machine;br_stp.c:454is the no-STP fast path that skips the listening/learning walk. -
net/bridge/br_netfilter_hooks.c— bridge ↔ netfilter glue. Thebr_nf_*functions hook into PRE_ROUTING and POST_ROUTING for bridged traffic. Read this if you ever need to debug “iptables sees bridged frames” issues. (Full netfilter coverage: Days 20–22.) -
Documentation/networking/bridge.rst— official guide. Brief but pointed.
Bullet Points
- A Linux bridge = software L2 switch.
ip link add br0 type bridgecreates one. - Three operations per frame: learn (FDB update with src), look up (dst), forward or flood.
rx_handleris the per-netdev hook (netdevice.h:2189) that fires inside__netif_receive_skb_corebefore the Day-2ptype_basedemux. The bridge installsbr_handle_framethere; it returnsRX_HANDLER_CONSUMEDfor every data frame — even one addressed to the bridge’s own IP, which reachesip_rcvby re-injection onbr0(br_pass_frame_up), not by passing through.RX_HANDLER_PASSis reserved for loopback and link-local control frames (STP/LLDP). One handler per device — a second register returns-EBUSY.- FDB is an RCU-protected
rhashtablekeyed on{MAC, vlan_id}. Readers (br_fdb_find_rcu) take no lock; writers (br_fdb_update) refresh a known source’s fields lock-free viaWRITE_ONCE()and takebr->hash_lockonly to insert/delete an entry. The write-heavyupdatedfield is____cacheline_aligned_in_smpto avoid false sharing (recall Day 1). Default 300 s aging;bridge fdbto inspect. vlan_filtering 1turns the bridge into a real 802.1Q switch with per-port VLAN config; the{MAC, vid}key means the same MAC in two VLANs is two entries.- L2 loops are catastrophic — Ethernet has no TTL, so a flooded frame multiplies around a loop forever (broadcast storm). STP elects a root bridge and BLOCKs redundant links to break the loop; a port walks LISTENING → LEARNING → FORWARDING at 15 s/stage (~30 s), which is why container/VM setups leave it off.
br_netfiltermodule makes iptables apply to bridged frames (setsbridge-nf-call-iptables=1) — useful but slows the data path. (netfilter itself: Days 20–22.)- Hairpin mode loops a frame back out its input port; used in some SDN patterns.
Check question
A bridge has 4 ports. A frame with destination MAC aa:bb:cc:dd:ee:ff arrives on port 1. The FDB has no entry for that MAC. What ports does the frame go out on, and why is it implemented as “everything except input”?
Click to reveal answer
Answer: Ports 2, 3, and 4 — the bridge floods to all forwarding ports except the input port (this is “split-horizon”). The input port is excluded structurally: sending the frame back out the same port would either be wasted (the source already has it) or, on a shared/hub-like medium, create a loop. Once a reply comes back from one of the flood-targets carrying aa:bb:cc:dd:ee:ff as its source MAC, the FDB learns and subsequent unicast frames go directly. Implementation: br_flood() iterates the port list and calls should_deliver() for each port, which returns false when skb->dev == p->dev (unless BR_HAIRPIN_MODE), so the ingress port is skipped structurally — not via FDB learning.
Tomorrow
Day 12: tunnels — VXLAN, GRE, IPIP, WireGuard. End of Phase 2.
Day 12 — Tunnels: VXLAN, GRE, IPIP, WireGuard
Today’s mission: build a VXLAN tunnel between two namespaces, and understand the encapsulation/decapsulation cycle that every tunnel implements — including the two demultiplexing tables a tunneled packet passes through and the MTU machinery that makes tunnels misbehave. End of Phase 2. Total time: ~110 minutes.
What “tunneling” is, in one paragraph
A tunnel takes a packet (the inner packet) and wraps it in another header (the outer packet) so it can travel through a network that wouldn’t otherwise carry it. The outer packet is delivered to a remote endpoint that strips the outer header and emits the inner packet on the other side. Different tunnel types differ in what they wrap with (IP, UDP, ESP) and what extra metadata they carry (VNI, GRE keys, sequence numbers).
In Linux, every tunnel is a netdev (vxlan0, gre0, wg0). You configure routes and bind sockets through it like any other interface. The tunneling magic lives in the netdev’s ndo_start_xmit (encapsulation — Day 3 taught what ndo_start_xmit is) and a paired RX hook (decapsulation).
Two networks: overlay and underlay
Before anything else, fix one pair of words that the rest of the chapter leans on constantly. A tunnel connects two distinct networks:
- The underlay is the real IP network that physically carries the outer packets between the tunnel endpoints. It routes the wrapped packets like any ordinary IP traffic.
- The overlay is the virtual L2/L3 network that the inner packets live on. It exists only because the endpoints agree to encapsulate and decapsulate.
A tunnel is exactly a mapping from overlay frames to underlay packets and back. In today’s lab, the br-underlay bridge and the 192.168.99.0/24 addresses ARE the underlay; the 10.100.0.0/24 network on vxlan0 IS the overlay. Keep the pair straight and every “outer” vs “inner” sentence below reads cleanly.
Background: how a packet reaches the tunnel code — IP protocol demux
Day 2 traced a received packet from the wire up to ip_rcv, and showed the L2 → L3 demux: the core walks ptype_base[], a table keyed by the 16-bit EtherType, to pick ip_rcv for ETH_P_IP. But that’s where Day 2 stopped. A tunnel decap handler — ipip_rcv, gre_rcv, vxlan_rcv — sits one layer deeper. Something has to decide, once a packet is destined for this host, “this is a GRE packet, send it to the GRE code.” That mechanism is the L3 protocol demux, and it is the exact analogue of Day 2’s EtherType table, one layer up.
Here’s the intuition. The IPv4 header has an 8-bit Protocol field that names what’s inside: TCP=6, UDP=17, IPIP=4, IPv6-in-IPv4=41, GRE=47. After ip_rcv decides a packet is for the local host, it needs to hand the packet to whichever subsystem owns that protocol number. Day 2’s L2 demux used a hash table keyed by EtherType; the L3 demux uses a flat array indexed directly by the protocol byte.
Follow the call chain. ip_local_deliver runs the local-delivery NF hook, then ip_local_deliver_finish (net/ipv4/ip_input.c:229) pulls the protocol byte straight out of the header and dispatches:
/* net/ipv4/ip_input.c:241 */
ip_protocol_deliver_rcu(net, skb, ip_hdr(skb)->protocol);
Inside ip_protocol_deliver_rcu (net/ipv4/ip_input.c:189) the dispatch is a single array index plus an indirect call:
ipprot = rcu_dereference(inet_protos[protocol]); /* ip_input.c:197 */
...
ret = INDIRECT_CALL_2(ipprot->handler, tcp_v4_rcv, udp_rcv, skb);
inet_protos[] is the L3 mirror of ptype_base[]:
/* net/ipv4/protocol.c:27 */
struct net_protocol __rcu *inet_protos[MAX_INET_PROTOS] __read_mostly;
MAX_INET_PROTOS is 256 — one slot for every possible value of the 8-bit field. Each slot holds a struct net_protocol whose handler is the receive entry (include/net/protocol.h:37):
struct net_protocol {
int (*handler)(struct sk_buff *skb);
...
};
A subsystem “plugs in” by calling inet_add_protocol(&proto, N) at init. The installer is just a compare-and-swap into the slot (net/ipv4/protocol.c:32):
int inet_add_protocol(const struct net_protocol *prot, unsigned char protocol)
{
return !cmpxchg(&inet_protos[protocol], NULL, prot) ? 0 : -1;
}
So slot-ownership in inet_protos[] is literal — but watch who actually owns the slot, because it isn’t always the driver you’d guess:
-
GRE “is registered as proto=47” and genuinely owns its slot directly:
gre_demux.cinstalls a single handler at slot 47 (net/ipv4/gre_demux.c:199):static const struct net_protocol net_gre_protocol = { .handler = gre_rcv, .err_handler = gre_err, }; ... inet_add_protocol(&net_gre_protocol, IPPROTO_GRE); /* gre_demux.c:208 */That one
gre_rcvthen sub-dispatches by GRE-header version (v0 standard GRE, v1 PPTP). “Dispatches by GRE-header version” means: the protocol table got you to GRE; GRE’s own header tells it which GRE variant you are. -
IPIP “is proto=4” in the sense that a proto-4 packet ends up at
ipip_rcv— but slot 4 is not owned byipip.c.inet_protos[IPPROTO_IPIP=4]is registered bynet/ipv4/tunnel4.cviainet_add_protocol(&tunnel4_protocol, IPPROTO_IPIP)(tunnel4.c:241), whose handler istunnel4_rcv(tunnel4.c:218, body at:95).tunnel4_rcvthen walks a second-level list ofstruct xfrm_tunnelhandlers, andipip.cjoins that list withxfrm4_tunnel_register(&ipip_handler, AF_INET)(ipip.c:654). So the real chain isinet_protos[4] = tunnel4_rcv → tunnel4_handlers list → ipip_rcv. There is an intermediate dispatcher — structurally the same shape as GRE’s version branch, one layer below the protocol table. -
6in4 “is proto=41” the same way:
inet_protos[41]istunnel64_protocol/tunnel64_rcv(tunnel4.c:244), andsit.c’sipip6_rcvhooks thetunnel64_handlerslist viaxfrm4_tunnel_register(..., AF_INET6). Again a shared demux, not a direct slot grab.
The lesson to keep: owning a slot in inet_protos[] is literal, but only GRE here grabs its slot directly; IPIP and 6in4 reach their handlers through the shared tunnel4/tunnel64 demux, which adds an intermediate dispatch step just like GRE’s version branch. Don’t assume grep ipip_rcv will turn up an inet_add_protocol call — it won’t.
The key organizing fact of this whole chapter: IPIP and GRE are reached through inet_protos[] — they have their own IP-protocol number and branch at (or just below) the IP-protocol table. UDP-based tunnels (VXLAN, GENEVE, FoU/GUE) do not get a protocol slot at all — they ride inside UDP (proto 17), reach udp_rcv like any UDP packet, and are demuxed a second time, one layer deeper, at the UDP socket. That second demux is the next background section. Hold the two-table picture: IPIP/GRE branch at the protocol array (possibly with a small second hop to the exact handler); VXLAN/GENEVE branch at the UDP encap hook.
![L3 protocol demux: inet_protos[] indexed by the IPv4 Protocol byte](diagrams/day12_ip_proto_demux.png)
The tunnel zoo
The five tunnels below differ only in what they wrap with and what metadata they carry — the diagram lines them up side by side so you can see the family resemblance before the prose enumerates each one.

IPIP — IP-in-IP (RFC 2003)
The simplest tunnel: outer IPv4 header + inner IP packet. 20 bytes overhead. No extra metadata.
- What: prepends an outer IPv4 header with
proto=4and carries an inner IP packet. (net/ipv4/ipip.calso carries MPLS-in-IPv4 the same way.) IPv6-in-IPv4 —proto=41, “6in4” — is a sibling encapsulation handled by the separatesitdriver innet/ipv6/sit.c, not byipip.c. - Why: route IP packets across a network that can’t see them directly (e.g., a private subnet across the public Internet).
- When: rarely chosen directly today. GRE or VXLAN usually wins.
- Gotcha: no peer authentication — anyone can spoof an IPIP packet. Combine with IPsec (transport mode) for security, or filter by source IP.
- Where:
net/ipv4/ipip.c. RX entryipip_rcv(line 266) →ipip_tunnel_rcv(line 215). TXipip_tunnel_xmit(line 282).
GRE — Generic Routing Encapsulation (RFC 2784)
Outer IP + 4-byte GRE header + inner packet. The GRE header is variable: optional 4-byte key (tag for tenant separation), 4-byte sequence number, 4-byte checksum. Carries any L3 protocol (IPv4, IPv6, MPLS, even Ethernet via NVGRE).
- What: generic L3-over-L3 tunneling with optional metadata (key, sequence).
- Why: carry any-protocol-over-any-protocol with optional flow tagging. Originally for Cisco router interconnect; today common in MPLS-over-IP, ERSPAN port mirroring, and some VPN setups.
- When: when you need lightweight tunneling with a tag (32-bit GRE key) and want point-to-point or point-to-multipoint. ERSPAN (Cisco port mirroring) builds on GRE.
- Gotcha: GRE keys aren’t authentication — they’re separation. Same caveats as IPIP for security; pair with IPsec for confidentiality.
- Where:
net/ipv4/ip_gre.c. RXgre_rcv(line 440) →__ipgre_rcv(line 366). TXipgre_xmit(line 652). ERSPAN variant has its own paths (line 267, 704).
VXLAN — Virtual eXtensible LAN (RFC 7348)
The standard for datacenter overlays. Outer Ethernet + outer IP + outer UDP (port 4789) + 8-byte VXLAN header + inner Ethernet frame.
- What: Ethernet-in-UDP. The 24-bit VNI (“VXLAN Network Identifier”) in the VXLAN header identifies the overlay — 16 million possible overlays per IP underlay.
- Why: scale beyond 4096 VLANs (the 802.1Q limit). Lets a single physical IP network carry many isolated L2 networks. Each VNI is its own broadcast domain.
- When: Kubernetes pod networking (Flannel, Calico in some modes, Cilium), datacenter SDN, multi-tenant cloud platforms. The dominant overlay today.
- Gotcha: MTU. Outer headers cost ~50 bytes. If the underlay MTU is 1500, the tunnel netdev should be MTU 1450. Otherwise inner 1500-byte packets won’t fit; either path-MTU discovery saves you (if ICMP works) or you get black-holed connections. Solutions: (1) set tunnel MTU correctly; (2) MSS-clamp TCP via nftables (
tcp option maxseg size set 1410) or iptables (-j TCPMSS --set-mss 1410) — rewrite the Maximum Segment Size (MSS) option in each TCP SYN so both ends agree to send segments small enough to fit after the ~50B tunnel overhead (we cover TCP MSS properly in Phase 3); (3) jumbo-frame underlay. The fragmentation section below makes this mechanism concrete. (Second deployment trap: 4789 is the IANA-assigned port, but the Linux module’s legacy default is 8472 for backward compatibility — always setdstportexplicitly, as the labs do.) - Where:
drivers/net/vxlan/vxlan_core.c. RXvxlan_rcv(line 1643) — registered as a UDP encap handler. TXvxlan_xmit(line 2722).
GENEVE — successor to VXLAN
Same Ethernet-in-UDP idea, but think VXLAN with room to bolt extra typed fields onto the header: extensible TLV options. Designed to subsume VXLAN, NVGRE, and STT into one protocol with room to grow. UDP port 6081. Heavier in the option-parsing path, but production-deployed (some Kubernetes deployments, OVN). Like VXLAN, it has no inet_protos[] slot — it rides UDP and registers an encap handler. Code: drivers/net/geneve.c.
WireGuard — modern VPN (in-tree since 5.6)
Outer UDP + WireGuard’s own framing (handshake messages or transport messages with ChaCha20-Poly1305). It is crypto-routed: the kernel decides where a packet goes by which key signed it, not by a configured tunnel endpoint — peers are identified by Curve25519 public keys, not by IP.
- What: authenticated, encrypted L3 VPN over UDP.
- Why: the only modern in-kernel VPN with serious cryptographic and code review. Replaces OpenVPN (userspace, slow), IPsec (complex), and other options for most use cases.
- When: point-to-point or point-to-multipoint VPN. Roaming clients (mobile devices). Anywhere you’d reach for OpenVPN.
- Gotcha:
AllowedIPsis both a routing rule and a source-address filter. A peer can only send packets from IPs in itsAllowedIPs; a peer can only receive packets to IPs in itsAllowedIPs. Misconfigure and you get silent drops with no diagnostic. - Where:
drivers/net/wireguard/. RX inreceive.c, TX insend.c.
VXLAN in detail (the canonical case)
A frame on the wire — 50 bytes of overhead total:
[outer Ethernet 14] [outer IP 20] [outer UDP 8] [VXLAN hdr 8] [inner Ethernet ...]
VXLAN header layout: Flags(1B) | Reserved(3B) | VNI(3B) | Reserved(1B). The Flags byte’s “I” bit must be set; the VNI is 24 bits packed into the next three bytes. This matches the in-kernel struct exactly (include/net/vxlan.h:25):
struct vxlanhdr {
__be32 vx_flags; /* the "I" bit is VXLAN_HF_VNI = cpu_to_be32(BIT(27)) */
__be32 vx_vni; /* 24-bit VNI in the high 3 bytes */
};

Background: VTEP and the VXLAN forwarding database
One term used a dozen times below needs a one-line definition: a VTEP (“VXLAN Tunnel End Point”) is the entity that does encap/decap — concretely, a vxlan netdev. Its underlay IP is the address that outer packets are sent to. A remote VTEP is the peer’s underlay IP.
When a VXLAN netdev transmits an inner frame, it must answer: which remote VTEP holds the destination MAC? It answers with a forwarding database (FDB) — and you already know how an FDB works from Day 11. Recall from Day 11: the bridge FDB is a learning hash keyed on (vlan, MAC), with the same aging and flood-on-miss behavior. Do not re-learn the hashing or learning machinery — it’s identical here (the per-VXLAN FDB is the same kind of hash, FDB_HASH_BITS = 8, include/net/vxlan.h:40).
The one VXLAN twist worth stating: the Day 11 bridge FDB maps an inner MAC → a local egress port; the VXLAN FDB maps an inner MAC → a remote underlay IP (the VTEP to encapsulate toward), stored as rdst->remote_ip (drivers/net/vxlan/vxlan_core.c:188). Same data-structure role, different value. The TX-path lookup is vxlan_find_mac_tx (vxlan_core.c:395). On a miss, VXLAN floods exactly like the Day 11 bridge — but “flood” here means sending the encapsulated frame to the configured remote unicast IP (or a multicast underlay group), not to local ports. That’s why the lab configures remote 192.168.99.2: it’s the flood target for unknown unicast.
Encap path
When a packet hits a VXLAN netdev’s TX:
- Resolve the destination VTEP — for unicast inner MAC, the bridge-style FDB (the forwarding database from the bridge chapter, Day 11; recapped above) tells which remote VTEP IP holds that MAC. For unknown unicast/multicast, send to the configured multicast underlay group (commonly
239.1.1.1in examples) or to the configured remote unicast IP. Because our lab sets a staticremote=, it uses the simplest unicast form — no multicast group and no FDB flooding are exercised; the FDB/multicast path matters when one VTEP talks to many remotes. - Build outer headers — Ethernet, IP, UDP, VXLAN. Source UDP port is hashed from the inner flow (gives ECMP-like — Equal-Cost Multi-Path, spreading flows across parallel underlay links — spread on the underlay).
udp_tunnel_xmit_skb— the generic UDP-tunnel send helper atnet/ipv4/udp_tunnel_core.c:174. Routes the outer packet through the underlay’s normal IP stack. All UDP-based tunnels (VXLAN, GENEVE, FoU, GUE) share it — it is the TX mirror of the encap hook you’re about to meet.
Background: the UDP encapsulation hook — how a UDP socket becomes a decap entry
The decap path below says the UDP socket at port 4789 is “special.” Here’s what special means concretely — and this is the second demux table the chapter promised.
First, a one-line forward-bridge to a path you haven’t fully met yet (Day 14 covers normal UDP receive in full): an ordinary UDP datagram is matched to a socket by its (src IP, dst IP, src port, dst port) 4-tuple, and the skb is appended to that socket’s sk_receive_queue for a recvmsg() call to read later. A tunnel needs the opposite of that: consume the datagram inside the kernel and re-inject the inner packet, never waking userspace.
A struct udp_sock carries an optional function pointer for exactly this (include/linux/udp.h:79):
int (*encap_rcv)(struct sock *sk, struct sk_buff *skb);
When encap_rcv is set, the UDP receive path diverts the skb to it before the skb ever reaches sk_receive_queue. That single pointer is what “special socket” means. The divert lives in udp_queue_rcv_one_skb (net/ipv4/udp.c:2349), guarded so the check is free when no tunnels exist:
if (static_branch_unlikely(&udp_encap_needed_key) &&
READ_ONCE(up->encap_type)) { /* net/ipv4/udp.c:2364 */
...
encap_rcv = READ_ONCE(up->encap_rcv); /* :2380 */
if (encap_rcv) {
int ret = encap_rcv(sk, skb);
if (ret <= 0) { ...; return -ret; } /* consumed */
}
/* FALLTHROUGH -- it's a normal UDP packet */
}
The installer is setup_udp_tunnel_sock (net/ipv4/udp_tunnel_core.c:71). It copies the config’s handler into the socket and flips on the static key so the divert check above does real work:
udp_sk(sk)->encap_type = cfg->encap_type;
udp_sk(sk)->encap_rcv = cfg->encap_rcv; /* udp_tunnel_core.c:85 */
...
udp_tunnel_encap_enable(sk); /* arms udp_encap_needed_key */
VXLAN passes vxlan_rcv as cfg->encap_rcv (drivers/net/vxlan/vxlan_core.c:3612). So the two-stage demux completes:
IP proto 17 → inet_protos[17] → udp_rcv → (encap_rcv set?) → vxlan_rcv
— the first stage at inet_protos[] (the section above), the second at the UDP socket. The encap_rcv contract is the three-way return value seen in the code: 0 = consumed (or dropped by the handler), >0 = “not mine, resubmit as a normal UDP datagram” (it falls through to sk_receive_queue), <0 = resubmit as IP proto -ret. VXLAN itself only ever uses the 0 path: vxlan_rcv returns 0 whether it successfully decaps and re-injects a frame or hits its drop: label (malformed header, missing VNI flag, unknown VNI, reserved bits set) — so a stray UDP packet to port 4789 that isn’t valid VXLAN is dropped, not handed back to the socket queue. The >0 “resubmit as ordinary UDP” path is real but used by other encap handlers (e.g. ESP-in-UDP), not VXLAN.

Decap path
A frame arrives at the underlay, traverses ip_rcv (the RX path from Days 2–3), and lands at UDP. The UDP socket at port 4789 is special — it’s a “tunnel socket” registered by setup_udp_tunnel_sock (net/ipv4/udp_tunnel_core.c:71). Instead of queueing on a normal sk_receive_queue, the kernel calls the registered encap handler — for VXLAN, vxlan_rcv at drivers/net/vxlan/vxlan_core.c:1643 (the mechanism is the encap_rcv divert you just saw).
Inside vxlan_rcv:
- Parse the 8-byte VXLAN header, extract the 24-bit VNI.
- Find the right VXLAN netdev for that VNI in this netns via
vxlan_vs_find_vni(vxlan_core.c:1679). - Strip the outer headers with
skb_pull(recallskb_pullfrom Day 1 — it advancesdatapast a header you’ve finished with). - Hand the inner Ethernet frame to the inner stack via
gro_cells_receive(vxlan_core.c:1799).
That last step deserves one line. The inner frame must re-enter the stack as if it were freshly received on vxlan0. gro_cells is a thin per-netdev GRO+RPS wrapper that does exactly that re-injection — it queues to a percpu backlog and runs GRO on the inner flow. Recall GRO and the RX re-entry from Day 2 (napi_gro_receive / netif_receive_skb); gro_cells_receive (net/core/gro_cells.c:14) is just “Day 2’s RX entry, invoked a second time for the inner packet.” No new GRO theory needed — the inner frame now climbs the stack and looks like a normal frame arriving at vxlan0.
There are no Dumb Questions
Q: Why does VXLAN ride UDP instead of getting its own IP protocol number like GRE? A: Two practical wins. NAT and firewall traversal — middleboxes understand UDP and happily forward it, whereas a novel IP protocol number is often dropped. And ECMP: the source UDP port is set to the inner-flow hash, so underlay routers spreading by 5-tuple fan parallel flows across links automatically. GRE, with a fixed proto-47 header and no port, gets none of that for free.
Q: My lab uses
remote=— where’s the multicast group I keep reading about? A: Nowhere, on purpose. A staticremote=is the simplest point-to-point form: every unknown-unicast “flood” just goes to that one IP. The multicast underlay group (e.g.239.1.1.1) and real FDB learning only come into play when one VTEP serves many remotes and must discover which one holds a given MAC.
Set up a VXLAN tunnel (lab)
Two namespaces (network namespaces, Day 5), both with VTEPs talking through the host (init_net, also Day 5) acting as the underlay:

sudo ip netns add A
sudo ip netns add B
# Underlay: bridge both host-side veths into one L2 segment (init_net is the underlay).
# Without the bridge, vethA and vethB are two separate L2 segments and ARP for the
# underlay peer never resolves — the tunnel can't carry a single packet.
sudo ip link add vethA type veth peer name vethA_p
sudo ip link add vethB type veth peer name vethB_p
sudo ip link set vethA_p netns A
sudo ip link set vethB_p netns B
sudo ip link add br-underlay type bridge
sudo ip link set vethA master br-underlay
sudo ip link set vethB master br-underlay
sudo ip link set vethA up
sudo ip link set vethB up
sudo ip link set br-underlay up
sudo ip netns exec A ip addr add 192.168.99.1/24 dev vethA_p
sudo ip netns exec B ip addr add 192.168.99.2/24 dev vethB_p
sudo ip netns exec A ip link set vethA_p up
sudo ip netns exec B ip link set vethB_p up
# The VTEP underlay addresses live only on the namespace ends; the bridge needs no IP.
# Confirm the underlay works *before* building the tunnel on top of it:
sudo ip netns exec A ping -c1 192.168.99.2 # underlay must work first
# VXLAN endpoints
sudo ip netns exec A ip link add vxlan0 type vxlan \
id 100 local 192.168.99.1 remote 192.168.99.2 dstport 4789
sudo ip netns exec A ip addr add 10.100.0.1/24 dev vxlan0
sudo ip netns exec A ip link set vxlan0 up
sudo ip netns exec B ip link add vxlan0 type vxlan \
id 100 local 192.168.99.2 remote 192.168.99.1 dstport 4789
sudo ip netns exec B ip addr add 10.100.0.2/24 dev vxlan0
sudo ip netns exec B ip link set vxlan0 up
# Test
sudo ip netns exec A ping -c 2 10.100.0.2
Notice the two networks at work: 192.168.99.0/24 is the underlay (the bridged veths), 10.100.0.0/24 is the overlay (on vxlan0). The remote 192.168.99.2 is the remote VTEP — the flood target for unknown-unicast inner MACs.
Then watch the encapsulated traffic. Start the capture before generating
traffic — if you attach tcpdump after the pings have already exited you get an
empty capture. Bound the capture with timeout so it doesn’t run forever:
sudo timeout 8 tcpdump -i br-underlay -nn 'udp port 4789' &
sleep 1
sudo ip netns exec A ping -c 5 10.100.0.2
wait
Each ICMP echo/reply appears as a UDP datagram to port 4789 between the two underlay VTEPs (192.168.99.1 and 192.168.99.2) — the VXLAN-encapsulated ping. tcpdump decodes the encapsulation and the source UDP port is the inner-flow hash, so it varies:
IP 192.168.99.1.<hashed> > 192.168.99.2.4789: VXLAN, flags [I] (0x08), vni 100
IP 192.168.99.2.<hashed> > 192.168.99.1.4789: VXLAN, flags [I] (0x08), vni 100
Background: fragmentation, the DF bit, and MSS clamping
Today’s experiment and the Check question both turn on what happens when an encapsulated packet is too big for the underlay. That hinges on IP-layer machinery Day 4 only partly covered, so let’s build it before breaking it.
Refresher (Day 4 — one line, do not re-derive): MTU is the largest L2 payload a link carries (1500 on Ethernet); MSS = MTU − IP − TCP; Path-MTU Discovery (PMTUD) shrinks a connection’s MSS when the path can’t carry full-size frames. All taught Day 4.
NEW — the DF bit. The IPv4 header’s 16-bit frag_off field packs three flag bits plus a 13-bit fragment offset (include/net/ip.h):
#define IP_DF 0x4000 /* ip.h:143 "Don't Fragment" */
#define IP_MF 0x2000 /* ip.h:144 "More Fragments" */
#define IP_OFFSET 0x1FFF /* ip.h:145 the 13-bit offset */
With DF clear, a router that meets an oversize packet splits it with ip_do_fragment (net/ipv4/ip_output.c:761) and sets IP_MF on all but the last fragment; the receiver reassembles by offset. With DF set, the router must not fragment — it drops the packet and sends an error back instead.
NEW — what “fragmentation needed” actually is. That error is ICMP type 3 (Destination Unreachable, ICMP_DEST_UNREACH = 3, include/uapi/linux/icmp.h:27) code 4 (ICMP_FRAG_NEEDED = 4, icmp.h:47), and it carries the next-hop MTU. The source receives it and caches a lower PMTU for that destination, so future packets shrink to fit. But if a firewall drops these ICMP messages — extremely common — the source never learns, keeps sending oversize DF packets, and the connection black-holes: small packets work, big ones vanish. That is the precise mechanism behind today’s Check question.
NEW — the tunnel twist (why the lab shows EMSGSIZE, not on-wire ICMP). For a locally-originated tunnel the kernel short-circuits PMTUD. skb_tunnel_check_pmtu (net/ipv4/ip_tunnel_core.c:437) computes the room left after the outer headers and, on an oversize inner frame, caches that lower PMTU on the inner route:
u32 mtu = dst_mtu(encap_dst) - headroom; /* ip_tunnel_core.c:440 */
...
skb_dst_update_pmtu_no_confirm(skb, mtu); /* cache it on the inner dst */
vxlan_xmit_one calls this on the TX path (drivers/net/vxlan/vxlan_core.c:2514 and :2586). The consequence: the first DF probe predates the cached PMTU, so it succeeds; every later DF send hits the cached lower PMTU and is rejected locally with EMSGSIZE (“sendmsg: Message too long”) before any packet leaves the host. That’s why the lab sees one success then errors, not a wire capture of ICMP.
NEW — MSS clamping as the durable fix. Because PMTUD-via-ICMP is unreliable, the robust fix rewrites the TCP MSS option in passing SYN/SYN-ACK packets down to a value that fits the tunnel (e.g. 1410). Both ends then negotiate a smaller MSS and never emit a segment too big to encapsulate. The nft rule in the Check answer does exactly this SYN-rewrite — it isn’t magic, it’s editing the MSS option as the handshake flies by.

Today’s experiment — break the MTU
# Default: vxlan0 MTU = 1500. Because this VXLAN dev is NOT bound to a lower dev
# (no `dev PHYS_DEV` on the `ip link add`), the kernel does NOT auto-subtract the
# ~50B overhead — the netdev keeps the 1500 default. But the effective path budget
# is still only ~1450, which is the bug we exploit below.
sudo ip netns exec A ip link show vxlan0
# The 1500 default is already too large for the 1450-effective path, so just send a
# 1500-byte DF packet that won't fit:
sudo ip netns exec A ping -M do -s 1472 -c 2 10.100.0.2 # don't fragment, 1500 total
# The first probe slips through; every later DF send is rejected locally — see below.
# The real fix: set a correct, fitting MTU that accounts for the overhead.
sudo ip netns exec A ip link set vxlan0 mtu 1450
What you actually see — and why it is not the on-wire ICMP you might expect:
PING 10.100.0.2 (10.100.0.2) 1472(1500) bytes of data.
1480 bytes from 10.100.0.2: icmp_seq=1 ttl=64 time=... ms
ping: sendmsg: Message too long
--- 10.100.0.2 ping statistics ---
2 packets transmitted, 1 received, +1 errors, 50% packet loss
This is the local EMSGSIZE short-circuit from the fragmentation Background above:
skb_tunnel_check_pmtu caches a lowered inner-route PMTU the first time it sees an
oversize frame, so the very first DF probe (sent before that cache exists) succeeds
at icmp_seq=1, and every later DF send hits the cached 1450 PMTU and is rejected
locally with EMSGSIZE — ping: sendmsg: Message too long. It is not an
on-wire ICMP “fragmentation needed” and not a silent drop. (Append df set to
the ip link add ... type vxlan lines to make it deterministic: then every packet
fails with Message too long, 100% loss, because the outer header now refuses to
fragment.)
This is the classic VXLAN deployment failure. Production datacenters either use jumbo frames on the underlay (MTU 9000) or rigorously MSS-clamp TCP.
Cleanup
Tear down everything the lab created. Deleting the two namespaces removes their
veth ends and vxlan0 automatically — a veth pair is deleted symmetrically, so
the init_net peers vethA/vethB disappear with their in-namespace ends. The
bridge lives in init_net and is not removed by deleting the namespaces, so
delete it explicitly:
sudo ip netns del A
sudo ip netns del B
sudo ip link del br-underlay
What to read in the kernel
-
drivers/net/vxlan/vxlan_core.c:1643—vxlan_rcv. The decap entry. Read end to end (~300 lines including option handling). Trace how the VNI is extracted, how the right VXLAN netdev is looked up viavxlan_vs_find_vni, and how the inner Ethernet frame is handed togro_cells_receivefor the inner stack. -
drivers/net/vxlan/vxlan_core.c:2722—vxlan_xmit. The encap entry. Notice the FDB lookup (per-VXLAN bridge-style FDB), the outer-header construction, and the call tovxlan_xmit_onewhich eventually invokesudp_tunnel_xmit_skb. -
net/ipv4/ip_input.c:189—ip_protocol_deliver_rcu. The L3 protocol demux:inet_protos[protocol]->handler. See howip_local_deliver_finish(line 229) feeds it the IPv4 Protocol byte. -
net/ipv4/protocol.c:32—inet_add_protocol. How GRE (gre_demux.c:208) claims slot 47 ininet_protos[]directly. IPIP and 6in4 do not call this themselves: slots 4 and 41 are owned bytunnel4.c’stunnel4_rcv/tunnel64_rcv(tunnel4.c:241,:244), which dispatch toipip_rcv/ipip6_rcvvia the second-levelxfrm_tunnellist (ipip.c:654). -
net/ipv4/udp.c:2349—udp_queue_rcv_one_skb. The UDP encap divert (encap_rcvat line 2380) — the second demux that sends a VXLAN packet tovxlan_rcvinstead of a socket queue. -
net/ipv4/udp_tunnel_core.c:71—setup_udp_tunnel_sock. How a tunnel registers itself as a UDP encap handler. Short function (~30 lines). Read the comments for what eachudp_tunnel_sock_cfgfield does. -
net/ipv4/udp_tunnel_core.c:174—udp_tunnel_xmit_skb. The generic outer-side TX. All UDP-based tunnels (VXLAN, GENEVE, FoU, GUE) use it. Notice the GSO (Generic Segmentation Offload, Day 4) interaction — the tunnel sets up gso_type so segmentation does the right thing on the outer packet. -
net/ipv4/ip_tunnel_core.c:437—skb_tunnel_check_pmtu. The tunnel PMTU check that caches a lower inner-route PMTU and produces the lab’sEMSGSIZE. -
net/ipv4/ip_tunnel.c— generic IP-tunnel infrastructure used by IPIP and GRE. Searchip_tunnel_xmitfor the common encap path. Useful comparison point withvxlan_xmit. -
net/ipv4/gre_demux.c:146—gre_rcv. This is the proto-47 entry, registered viainet_add_protocol(&net_gre_protocol, IPPROTO_GRE)(gre_demux.c:208). It reads the GRE-header version byte (skb->data[1] & 0x7f) and dispatchesgre_proto[ver]: v0 (GREPROTO_CISCO) →ip_gre.c, v1 (GREPROTO_PPTP) →drivers/net/ppp/pptp.c(include/net/gre.h). -
net/ipv4/ip_gre.c:440—gre_rcv. The version-0 handler reached after the demux above (registered withgre_add_protocol(&ipgre_protocol, GREPROTO_CISCO)). It never sees v1/PPTP; what it dispatches is ERSPAN vs. normal GRE (checkstpi.proto == ETH_P_ERSPAN), thenipgre_rcv→__ipgre_rcv. For keys/sequence/checksum parsing, look atgre_parse_header. -
net/ipv4/ipip.c:266—ipip_rcv. The simplest tunnel decap. Read it as a reference; the others add features on top. -
drivers/net/wireguard/— WireGuard. Readdevice.cfor the netdev integration,receive.candsend.cfor the data path. -
Documentation/networking/vxlan.rst— official guide. Brief.
Bullet Points
- All tunnels are netdevs; their
ndo_start_xmitencapsulates, a paired RX hook decapsulates. - Two demux tables get a packet to a tunnel handler. The L3 demux indexes
inet_protos[]by the IPv4 Protocol byte (the L3 mirror of Day 2’s EtherTypeptype_base[]): proto 47 →gre_rcv(which owns its slot directly), proto 4 →tunnel4_rcv→ipip_rcv, proto 41 →tunnel64_rcv→ 6in4 (these reach their handler via a shared second-level dispatch). UDP tunnels have no slot — they ride proto 17 →udp_rcvand branch a second time at the UDPencap_rcvhook. encap_rcvis the pointer that makes a UDP socket a tunnel ingress:setup_udp_tunnel_sockinstalls it,udp_queue_rcv_one_skbdiverts to it beforesk_receive_queue. Returns 0=consumed, >0=normal UDP, <0=resubmit.- Overlay vs underlay: the underlay is the real IP network carrying outer packets; the overlay is the virtual network the inner packets live on. After decap,
gro_cells_receivere-injects the inner frame (Day 2’s RX entry, second time around). - VTEP = the encap/decap endpoint (a
vxlannetdev); the VXLAN FDB maps inner MAC → remote VTEP IP (Day 11’s bridge FDB mapped MAC → local port). - IPIP: 20-byte overhead, simplest. Rarely first choice today.
- GRE: optional 32-bit key, sequence, checksum. Used in ERSPAN and some MPLS-over-IP setups.
- VXLAN: Ethernet-in-UDP, 24-bit VNI, port 4789. The standard for container/cloud overlays.
- GENEVE: VXLAN’s TLV-extensible successor. UDP 6081.
- WireGuard: modern UDP VPN, in-tree since 5.6. Crypto-routed via peer pubkey.
- MTU is always the trap. Tunnel netdev MTU = underlay MTU − overhead. The DF bit decides fragment-or-drop; a filtered ICMP frag-needed (type 3/code 4) black-holes the flow. Fix with jumbo frames or MSS-clamping.
- Inspect with
ip -d link showto see tunnel parameters (VNI, remote, port).
Check question
A VXLAN tunnel is set up between two hosts (underlay MTU 1500). A user complains: short pings work fine, but file transfers stall after a few KB. What’s the most likely cause and what’s the simplest fix?
Click to reveal answer
Answer: Path-MTU issue. Short ICMP pings fit in the encapsulated 1500-byte budget. File transfers (TCP) try to use the path’s full MSS — TCP negotiated MSS based on the interface MTU, but the tunnel adds ~50 bytes of overhead, and the underlay drops oversize packets. With DF set on the segments, a router that can’t fit them is supposed to return ICMP “fragmentation needed” (type 3, code 4) so the sender lowers its PMTU — but if that ICMP is being filtered (very common), the sender never learns to reduce MSS, and the connection black-holes. The simplest fix is MSS clamping: nft add rule inet filter forward tcp flags syn / syn,rst tcp option maxseg size set 1410 — the kernel rewrites the SYN’s MSS option as packets traverse, forcing both ends to use a smaller MSS that fits. Alternatives: set the tunnel MTU correctly (Linux derives 1450 for VXLAN over 1500 automatically only when the VXLAN device is bound to a lower device with dev PHYS_DEV; otherwise the netdev keeps the 1500 default and you must set it yourself), enable jumbo frames on the underlay (MTU 9000), or unblock ICMP “frag needed” so PMTUD works naturally.
End of Phase 2
You can now read the L2/L3 layers of the kernel network stack. Ethernet parsing, VLANs, ARP/NDP, the FIB and routing rules, IPv6 specifics, bridges, tunnels.
Phase 3 (Days 13–19) goes up to L4: sockets, UDP, TCP state machine, congestion control, retransmission, sockopts, epoll/io_uring. Day 13 opens at the socket layer — the struct sock behind every connection — and works up to the TCP state machine.
Day 13 — The socket layer: struct sock
Today’s mission: understand the kernel side of
socket(),bind(),listen(),connect(),accept(). See howstruct socksits underneath every networking syscall — and learn the machinery the socket layer is built on (the FD→socket bridge,sock_common, the two dispatch tables, the socket lock and receive-wakeup model, and the slab cache), taught as Backgrounds 1–5, so nothing in the lifecycle is hand-waved. Total time: ~120 minutes.
Phase 3 starts here. Days 13–19 cover L4: sockets, UDP, TCP state machine, congestion control, retransmission, sockopts, epoll/io_uring.
Phase 1 (Days 1–5) walked a packet from the wire to ip_rcv. Phase 2 routed it. Now we arrive at the layer the application actually talks to: the socket. Every socket(), bind(), connect(), send(), recv() your program calls lands here. Today’s job is to make struct sock — the kernel’s per-connection state object — completely concrete, and to teach the five mechanisms that every remaining Phase-3 chapter leans on.
We anchor everything to a specific file/function in your ~/code/linux checkout (line numbers from kernel 7.1).
Two structs you must keep straight: struct socket and struct sock
The kernel has two “socket” structures, with confusingly similar names:
struct socket(include/linux/net.h) — the BSD-API-level handle. One per file descriptor. Owns the protocol-independent stuff: the file pointer, the type (SOCK_STREAM/SOCK_DGRAM/…), the wait queue, a pointer to theproto_opstable.struct sock(include/net/sock.h:365) — the protocol-level state. Owns receive/send queues, the protocol’s send/recv functions, the routing-table reference (sk_dst_cache), the socket option storage, etc.
Each struct socket has a struct sock *sk pointing at its protocol state. They’re separate because:
- The same
struct socketAPI works for AF_INET, AF_UNIX, AF_NETLINK, AF_PACKET, etc. — wildly different protocol stacks. - Some sockets are kernel-internal and don’t have a userspace FD (e.g., the netlink socket the kernel uses for routing notifications).
When kernel code says “the socket” it usually means struct sock, not struct socket. The userspace API talks about FDs; the kernel networking code talks about sk pointers.
Background 1: how a file descriptor becomes a struct socket
Every lifecycle step below begins with the same sentence: “looks up struct socket from the FD.” But how does a small integer — fd = 5 — reach a C structure in the kernel? This is the very first thing every socket syscall does, so let’s make it real before we walk the lifecycle.
A socket FD is just a VFS file descriptor
When you call socket(AF_INET, SOCK_STREAM, 0), the kernel returns an int. That integer is the same kind of FD you get from open("/etc/passwd"). It indexes into your process’s file-descriptor table, where each slot points at a struct file. The reason read(), write(), close(), and poll() all work on a socket FD is precisely that a socket is a file as far as the VFS (Virtual File System) layer is concerned.
The trick is that a socket has no path on any real disk. So socket() creates an anonymous inode on a special, internal-only filesystem called sockfs — a filesystem that exists purely to give sockets a VFS identity. It then builds a struct file whose f_op points at socket_file_ops (the file-operations table that routes read/write/poll into the network stack) and whose private_data points at the struct socket.
Recovering the socket from the file
So the chain to get from an FD back to protocol state is fixed and short. The kernel uses two helpers:
/* net/socket.c:590 — given the struct file, return its socket */
struct socket *sock_from_file(struct file *file)
{
if (likely(file->f_op == &socket_file_ops))
return file->private_data; /* set in sock_alloc_file */
return NULL;
}
/* net/socket.c:612 — given the FD, fget() the file, then sock_from_file() */
struct socket *sockfd_lookup(int fd, int *err);
sock_from_file first checks file->f_op == &socket_file_ops — that’s how it rejects a regular-file FD passed where a socket was expected (you get -ENOTSOCK). Once it knows the file is a socket, the struct socket is simply file->private_data. And struct socket carries both a back-pointer to the file and the sk (include/linux/net.h:137):
struct socket {
socket_state state;
short type;
unsigned long flags;
struct file *file; /* back-pointer to the VFS file */
struct sock *sk; /* the protocol-level state */
const struct proto_ops *ops; /* the BSD-API vtable (Background 3) */
struct socket_wq wq; /* wait queue (Background 4) */
};
So the full descent is: fd → struct file (via the fd table) → file->private_data = struct socket → socket->sk = struct sock → sk->sk_prot = the protocol vtable. The first two hops are the VFS / file layer; the last two are the protocol layer. Calling read() on a TCP FD enters at struct file (through socket_file_ops), is routed to the socket, and ultimately lands in sk->sk_prot->recvmsg.

Sockets with no FD
This two-layer split is also why kernel-internal sockets work. The netlink socket the kernel uses for routing notifications (mentioned above) has a struct socket and a struct sock but no struct file and no FD — it’s created with sock_create_kern (net/socket.c:1739), which builds the protocol state but skips the sockfs file entirely. There’s nothing for userspace to read(); the kernel just holds the sk pointer directly. The FD bridge is a separable layer that internal sockets simply don’t bother with.
struct sock: the polymorphic descriptor

struct sock is huge (config-dependent, ~800 bytes on x86_64 — 808 in this build) and most of its fields are protocol-specific shadow state. Two structural tricks make it manageable: a shared first member (sock_common, below) and embedding for the protocol-specific layers.
Background 2: struct sock_common, the shared first member
Open struct sock and the first thing you see is not a normal field (include/net/sock.h:365):
struct sock {
/*
* Now struct inet_timewait_sock also uses sock_common, so please
* just don't add nothing before this first member (__sk_common) --acme
*/
struct sock_common __sk_common;
#define sk_family __sk_common.skc_family
#define sk_state __sk_common.skc_state
#define sk_daddr __sk_common.skc_daddr
#define sk_rcv_saddr __sk_common.skc_rcv_saddr
#define sk_dport __sk_common.skc_dport
#define sk_prot __sk_common.skc_prot
#define sk_net __sk_common.skc_net
/* ... ~30 more #defines, then the real fields ... */
};
Here’s the part that trips people up: every “field” you’ll see written as sk->sk_state, sk->sk_family, sk->sk_daddr, sk->sk_dport, sk->sk_prot is a #define alias onto __sk_common.skc_*. They are not direct members of struct sock at all — they live inside the embedded sock_common at offset 0. The aliases exist so the code reads naturally; the storage is shared.
Why does this matter? Because sock_common is deliberately the first member of three different structs:
- full sockets —
struct sock(__sk_common,include/net/sock.h:365); - TIME_WAIT minisockets —
struct inet_timewait_sock(__tw_common,include/net/inet_timewait_sock.h:38); - the request-sock / half-open path —
struct request_sock(__req_common,include/net/request_sock.h:51), theTCP_NEW_SYN_RECVstate.
Because all three begin with an identical sock_common block, the established hash (ehash, below) can store and compare any of them uniformly by the 4-tuple (saddr, sport, daddr, dport) — those fields live in sock_common, so the lookup never has to know whether a given hash entry is a full multi-kilobyte socket, a tiny TIME_WAIT stub, or a half-open request. One bucket, three shapes, one comparison. That’s the entire reason a ~230-byte TIME_WAIT/request minisocket (sock_common itself is 136 bytes; inet_timewait_sock and request_sock are each 232) and a ~2.4 KB tcp_sock can sit side by side in the same hash chain.

Embedding: the protocol-specific layers
The same first-field trick is applied one level up to build the protocol hierarchy. Each richer socket type embeds the simpler one as its first member:
struct sock { struct sock_common __sk_common; /* + queues, locks, callbacks */ ... };
struct inet_sock { struct sock sk; /* IPv4/IPv6-common stuff */ ... };
struct inet_connection_sock { struct inet_sock icsk_inet; /* + retransmit/RTO */ ... };
struct tcp_sock { struct inet_connection_sock inet_conn; /* + cwnd/snd_wnd/srtt/... */ ... };
struct udp_sock { struct inet_sock inet; /* + udp-specific */ ... };
A pointer to a tcp_sock is also a valid pointer to an inet_connection_sock, also a valid pointer to an inet_sock, also a valid sock — they all start at the same address. C’s structural inheritance via “first field” embedding. Helper macros do the cast:
struct tcp_sock *tp = tcp_sk(sk); // container_of: compile-time cast to tcp_sock
struct udp_sock *up = udp_sk(sk);
struct inet_sock *inet = inet_sk(sk);
These are container_of (tcp_sk is container_of_const(ptr, struct tcp_sock, inet_conn.icsk_inet.sk), include/linux/tcp.h:561). For a first member, container_of computes “pointer minus offsetof(member)” where the offset is 0, so it’s a free, no-op pointer cast. (tcp_sk’s member chain inet_conn.icsk_inet.sk happens to be all first members — inet_conn at offset 0 of tcp_sock, icsk_inet at 0 of inet_connection_sock, sk at 0 of inet_sock — so its cumulative offset is still 0, a no-op. The same macro also works for genuinely non-first members, where it subtracts a non-zero offset.)
Background 3: the two dispatch tables — proto_ops vs proto
The polymorphism — “the same syscall does different things for TCP vs UDP” — lives in function-pointer tables (vtables). But there are two of them, at two different layers, and confusing them is the single most common way to get lost in this code. Let’s name both.
The BSD layer: struct socket->ops (a proto_ops)
struct socket->ops points at a struct proto_ops (include/linux/net.h:181) — the BSD-API layer. It is shared by all AF_INET stream sockets (the instance is inet_stream_ops, net/ipv4/af_inet.c:1062). Its job is protocol-independent glue: argument validation, then dispatch downward.
struct proto_ops {
int family;
int (*release)(struct socket *sock);
int (*bind) (struct socket *sock, struct sockaddr_unsized *, int);
int (*connect)(struct socket *sock, struct sockaddr_unsized *, int, int);
int (*accept) (struct socket *sock, struct socket *newsock, ...);
__poll_t (*poll)(struct file *, struct socket *sock, ...);
int (*listen) (struct socket *sock, int len);
int (*sendmsg)(struct socket *sock, struct msghdr *, size_t);
/* ... */
};
Note the signatures: everything takes a struct socket *. This is the table the VFS layer reaches through. inet_stream_ops wires .listen = inet_listen, .bind = inet_bind, .sendmsg = inet_sendmsg, etc. (net/ipv4/af_inet.c:1062).
The protocol layer: struct sock->sk_prot (a proto)
struct sock->sk_prot points at a struct proto (include/net/sock.h:1291) — the protocol layer. The instance for TCP is tcp_prot; for UDP, udp_prot. Its job is the actual protocol logic.
struct proto {
void (*close) (struct sock *sk, long timeout);
int (*connect) (struct sock *sk, struct sockaddr_unsized *, int);
struct sock *(*accept)(struct sock *sk, struct proto_accept_arg *arg);
int (*init) (struct sock *sk);
int (*sendmsg) (struct sock *sk, struct msghdr *, size_t);
int (*recvmsg) (struct sock *sk, struct msghdr *, size_t, ...);
int (*bind) (struct sock *sk, struct sockaddr_unsized *, int);
int (*hash) (struct sock *sk);
int (*get_port) (struct sock *sk, unsigned short snum);
/* ... ~40 more callbacks ... */
struct kmem_cache *slab; /* per-protocol object cache (Background 5) */
unsigned int obj_size;
};
Note these signatures take a struct sock *. Same verb names sometimes appear in both tables (connect, sendmsg, bind) — that’s exactly why they’re easy to confuse.
One syscall crosses both tables
The two layers are stacked: the BSD layer validates and dispatches down to the protocol layer. The cleanest example is bind(). The BSD-level inet_bind (net/ipv4/af_inet.c:472, a proto_ops .bind) does protocol-independent validation — address length, SO_REUSEADDR/REUSEPORT checks — and then calls down:
/* inside __inet_bind, net/ipv4/af_inet.c:543 */
err = sk->sk_prot->get_port(sk, snum); /* a *proto* op: TCP's inet_csk_get_port */
So a single bind(2) syscall touches both vtables: socket->ops->bind (the proto_ops inet_bind) → sk->sk_prot->get_port (the proto op inet_csk_get_port). Likewise send() crosses both: socket->ops->sendmsg (inet_sendmsg) → sk->sk_prot->sendmsg (tcp_sendmsg).
Some operations live mostly in one table. listen() has no proto->listen slot, so it dispatches only through proto_ops (inet_listen) — but inet_listen still reaches into proto ops (get_port, hash) and allocates the accept queue. connect(), get_port, and the real TCP logic are proto ops. And RAW sockets are the rare case that define sk_prot->bind directly (most protocols leave it NULL and rely on inet_bind → get_port).
The mental model: proto_ops sits on struct socket and faces userspace; proto sits on struct sock and faces the wire. So when the lifecycle below says a syscall “dispatches through sk->sk_prot,” that’s only the bottom half of the story — the top half went through socket->ops first.

There are no Dumb Questions
Q: Why two structs —
struct socketandstruct sock— instead of one?A: Because they serve two different audiences.
struct socketis the BSD-API handle: one per FD, protocol-independent, the thing the VFS layer reaches throughproto_ops.struct sockis the protocol state: queues, theprotovtable, the route cache — the thing the wire side touches. The split lets the samestruct socketAPI drive AF_INET, AF_UNIX, AF_NETLINK, etc., and lets kernel-internal sockets exist with asockbut no FD-bearingsocket/fileat all (Background 1).Q: Then why does
bind()have to touch two vtables?A: Because the work splits cleanly across the two layers. The protocol-independent half — validating the address length, honoring
SO_REUSEADDR/REUSEPORT— lives in the BSD layer (proto_ops->bind=inet_bind). The protocol-specific half — actually reserving the port in TCP’s bind hash — lives in the protocol layer (proto->get_port=inet_csk_get_port). One syscall, one descent through both tables.
Key fields you’ll see everywhere
sk_family // AF_INET, AF_INET6, AF_UNIX, AF_NETLINK, ... (alias into sock_common)
sk_type // SOCK_STREAM, SOCK_DGRAM, SOCK_RAW, ...
sk_protocol // IPPROTO_TCP, IPPROTO_UDP, ...
sk_state // TCP_ESTABLISHED, TCP_LISTEN, ... (alias into sock_common;
// TCP-specific but reused by UDP for connected sockets)
sk_receive_queue // skb_queue: incoming packets waiting to be read
sk_write_queue // skb_queue: outgoing skbs not yet ACKed (TCP)
sk_backlog // packets parked here while the socket lock is held (Background 4)
sk_rcvbuf, sk_sndbuf // per-socket buffer limits
sk_filter // BPF socket filter (sk_attach_filter)
sk_lock // the special socket lock (lock_sock / release_sock — Background 4)
sk_data_ready // callback fired when data arrives (Background 4)
sk_prot // the proto vtable (alias into sock_common)
sk_dst_cache // refcounted route entry — see Day 8
sk_net // pointer back to the netns (alias into sock_common)
Background 4: the socket lock and the receive-wakeup model
The field list above mentions sk_lock, sk_backlog, sk_receive_queue, and sk_data_ready, and the lifecycle below will say connect()/accept() “sleep until something arrives.” All of that is hand-waving until you understand three things: the special socket lock, the backlog queue, and how an arriving packet wakes a sleeping reader. These are load-bearing for every remaining Phase-3 chapter (UDP/TCP receive, and epoll on Day 19).
sk_lock is not a plain spinlock
A socket is touched from two completely different contexts at once:
- process context — your
recv()/send()syscall, running in a schedulable task that is allowed to sleep; - softirq context — the NET_RX path from Day 2, delivering an arriving packet, which must not sleep.
A plain spinlock can’t bridge those: process context may need to hold the socket across a sleep (e.g. waiting for buffer space), but you can’t hold a spinlock across a sleep. So sk_lock is a hybrid owner lock. lock_sock() (include/net/sock.h:1711, which calls lock_sock_nested at :1709) takes a sleeping owner lock used by process context. While a syscall holds it, softirq-context delivery is not allowed to touch the socket’s receive queue.
The backlog: where packets wait out the lock
But packets keep arriving while your syscall holds the lock. They can’t be dropped, and they can’t go into sk_receive_queue (the lock owner might be mid-traversal). So they’re parked on the backlog — a tiny second queue inside struct sock (include/net/sock.h:420):
struct {
atomic_t rmem_alloc;
int len;
struct sk_buff *head;
struct sk_buff *tail;
} sk_backlog;
#define sk_rmem_alloc sk_backlog.rmem_alloc
This is why struct sock carries both sk_receive_queue and the sk_backlog sub-struct. The decision is: if the socket is locked, push the skb onto sk_backlog; otherwise queue it normally. When the syscall finishes, release_sock() (net/core/sock.c:3819) calls __release_sock() (net/core/sock.c:3208), which drains the backlog through the real receive path before dropping the lock. Nothing is lost; it’s just deferred past the critical section.
The wakeup: sk_data_ready and the wait queue
Now the other half — how does a blocked recv() wake up? When the stack queues a packet onto the receive queue, it calls the socket’s data-ready callback (net/core/sock.c:514–518):
__skb_queue_tail(list, skb);
...
sk->sk_data_ready(sk);
sk_data_ready (include/net/sock.h:453) is a function pointer. The default, wired up by sock_init_data (net/core/sock.c:3779, which sets sk->sk_data_ready = sock_def_readable at :3734), is sock_def_readable (net/core/sock.c:3614). It wakes any process sleeping on the socket’s wait queue.
Where’s the wait queue? It lives in struct socket->wq (a socket_wq), reached from the sock side via sk->sk_wq / sk_sleep(sk). A blocking accept()/recvmsg() adds itself to that queue and schedules away; when data (or a completed connection) arrives, sk_data_ready (default sock_def_readable, net/core/sock.c:3614) runs and its wakeup callback pulls the sleeper back to runnable. A blocking connect() parks on the same queue but is woken differently: handshake completion (tcp_finish_connect) flips the state to ESTABLISHED and fires sk->sk_state_change(sk) (default sock_def_wakeup, net/core/sock.c:3591, wired at :3733), not sk_data_ready. Both callbacks wake the same sk_wq->wait queue, so “sleeps until the SYN-ACK arrives” resolves either way — but for connect the named mechanism is the state-change/sock_def_wakeup path, while recvmsg/accept ride sk_data_ready/sock_def_readable.
This same wait queue is what poll()/epoll() register on for readiness notifications — which is exactly how Day 19 builds epoll on top of it. And the limits that decide when these queues count as “full” are sk_rcvbuf / sk_sndbuf (in the field list) — the very numbers today’s experiment inspects with ss -tim.

Lifecycle

Now walk a TCP server’s lifecycle, kernel-side, with every “looks up the socket from the FD” backed by Background 1 and every “dispatches” backed by Background 3.
socket(AF_INET, SOCK_STREAM, 0)
- Userspace calls the syscall — handled at
net/socket.c:1819SYSCALL_DEFINE3(socket, ...). __sock_create(net/socket.c:1594) allocates thestruct socketviasock_alloc(), then walksnet_families[AF_INET]to find the protocol family’screatecallback and invokes it with that already-allocated socket.- For AF_INET that callback is
inet_create(net/ipv4/af_inet.c:259). It:- Allocates the
struct sockviask_alloc(net/ipv4/af_inet.c:333), drawing a wholetcp_sock(orudp_socketc.) from the protocol’sprot->slabcache (Background 5). It does not allocate thestruct socket(already done above) or thestruct file(created later). - Calls
sock_init_data(net/ipv4/af_inet.c:362) to wire the generic defaults — includingsk_data_ready = sock_def_readablefrom Background 4 — before the protocol’sprot->initruns. - Then calls the protocol’s
prot->init(net/ipv4/af_inet.c:390) — for TCP,tcp_init_sock(net/ipv4/tcp.c:421) — for TCP-specific initialization: snd_cwnd, ssthresh, smoothed RTT/RTO, the retransmit (write) queue, sndbuf/rcvbuf. (No accept queue here — that’s alisten()-time allocation; see below.) - Returns. Back in
__sys_socket,sock_map_fd→sock_alloc_file(net/socket.c:1780) now creates the sockfsstruct fileand installs the FD: the FD points at the file, the file’sprivate_datapoints at the socket, the socket’sskpoints at the sock.
- Allocates the
Background 5: where the sock object comes from (slab — recall Day 1)
The allocation in step 3 is worth one line. Recall the slab allocator from Day 1: fixed-size object caches that make allocation an O(1) “pop a free slot.” Each struct proto owns its own dedicated slab cache (prot->slab, with prot->obj_size, include/net/sock.h:1382), so allocating a tcp_sock is a fast fixed-size slab alloc, not a general kmalloc. The object size is the full derived struct — a whole tcp_sock (~2 KB), not just struct sock — which is exactly why obj_size is set per protocol. inet_create (net/ipv4/af_inet.c:259) allocates through this per-protocol cache.
bind(fd, ...)
- Looks up
struct socketfrom the FD (Background 1:fd→file→private_data). - Calls
sock->ops->bind— for AF_INET TCP that’sinet_bind(net/ipv4/af_inet.c:472), aproto_opsop (Background 3). __inet_bindvalidates, then callssk->sk_prot->get_port(sk, snum)(net/ipv4/af_inet.c:543) — aprotoop, crossing from the BSD table to the protocol table. For TCP that’sinet_csk_get_port(net/ipv4/inet_connection_sock.c:500), which reserves the port in the per-netns bind hash table (tcp_hashinfo.bhash). (A separatesk_prot->bindhook exists too, but only RAW sockets define it.)- The bind table is keyed by
(netns, port)— that’s why two netns can both bind:80.
listen(fd, backlog)
A pure proto_ops op (inet_listen, net/ipv4/af_inet.c:237). Marks the sock as TCP_LISTEN. Allocates the accept queue: a FIFO list of completed connections waiting for accept() (request_sock_queue.rskq_accept_head). Half-open handshakes (SYN_RECV) don’t live here — they sit in the established hash as TCP_NEW_SYN_RECV request socks (those sock_common-fronted minisockets from Background 2) until the handshake finishes.
connect(fd, ...) — client side
- Looks up the sock from the FD.
- Calls
sk->sk_prot->connect— for TCPtcp_v4_connect(net/ipv4/tcp_ipv4.c:221), aprotoop. - Sets sock state to
TCP_SYN_SENT, then callsinet_hash_connectto pick a source port (ephemeral range) and insert into the established hash (tcp_hashinfo.ehash) keyed by 4-tuple. - Calls
tcp_connectto build and send the SYN. - Returns
EINPROGRESS(non-blocking) or sleeps until the SYN-ACK arrives (blocking) — that sleep is exactly Background 4: the task parks on the socket’s wait queue, and handshake completion wakes it via the state transition toESTABLISHED(sk_state_change/sock_def_wakeup), notsk_data_ready.
accept(fd, ...) — server side
- Pops a completed connection from the accept queue (blocking via the same wait-queue mechanism if the queue is empty).
- Allocates a new FD wrapping the new
sock(a fresh sockfs file, Background 1). - Returns the FD.
The original listening socket stays — accept just hands you a new socket for the connection.
The two hash tables: bhash and ehash
struct inet_hashinfo (used by TCP — UDP has its own struct udp_table, see Day 14) holds two key data structures:
bhash— the bind hash. Keyed by port number; each bucket holds a list of bound sockets (multiple ifSO_REUSEPORT). Lookup onbind()and on incoming SYN to find listener.ehash— the established hash. Keyed by the 4-tuple(saddr, sport, daddr, dport). Lookup on every incoming TCP packet to find the existing connection. This is the hash that holds full socks, TIME_WAIT minisocks, andTCP_NEW_SYN_RECVrequest socks side by side — possible only because all three sharesock_commonas their first member (Background 2).
Per-netns. Alongside bhash/ehash, struct inet_hashinfo carries a separate listener table, lhash2, hashed by (local port, local address). It’s a sibling of the bind and established tables, not part of bhash, and it speeds up finding the listening socket on an incoming SYN (it also carries the SO_REUSEPORT groups that listener lookup then selects from — Day 24).
How an arriving segment finds its socket: the two-tier lookup
Here’s the part the static field list above skips, and it’s the whole reason these two tables exist. Every TCP segment that arrives off the wire is just bytes with a 4-tuple in its headers — (saddr, sport, daddr, dport). Somewhere in the kernel there may be a struct sock that owns this conversation, or a listener willing to start one, or nobody at all. Demultiplexing is the act of turning that 4-tuple into the right sk pointer, and it happens on every single inbound packet. Get the mental model for this and the rest of L4 falls into place, because every later day (TCP state machine, congestion control, retransmission) assumes the segment has already been routed to its sock.

The entry point is tcp_v4_rcv, which calls __inet_lookup_skb → __inet_lookup. That helper searches in two tiers, in a deliberate order:
- Try the ehash first — entries with full identity.
__inet_lookup_establishedcomputesinet_ehashfn(net, daddr, dport, saddr, sport), selects one bucket, and walks its nulls list under RCU.inet_matchverifies namespace, exact address/port pair, and interface constraints. A hit can be a full connection, a TIME_WAIT minisock, or aTCP_NEW_SYN_RECVrequest sock;tcp_v4_rcvbranches on those special states before normal full-socket processing. Day 15 follows the TIME_WAIT branch through the full-socket-to-minisock hand-off and eventual timer expiry. - Only if ehash misses, try listener lookup.
__inet_lookup_listenerfirst offers the segment to the BPFSK_LOOKUPhook when enabled. It then hashes(netns, daddr, dport)withipv4_portaddr_hashand searches thatlhash2bucket; if no specific-address listener wins, it repeats withINADDR_ANY. Candidate scoring accounts for namespace, address, and bound-device specificity. A reuseport candidate invokesreuseport_select_sock, but lhash2 is the normal listener table, not a reuseport-only structure.
An ehash miss is not proof that the segment is a SYN — stray ACKs and other unmatched segments take the same fallback. TCP state processing decides what a listener accepts. The ordering still optimizes the common data path: established traffic performs one full-identity bucket lookup, while wildcard/scored listener selection runs only after that misses.
Two consequences fall out of this:
- An ehash hit takes a refcount.
__inet_lookup_establishedusesrefcount_inc_not_zeroand revalidates the match before returning, so an established/request/TIME_WAIT entry cannot vanish mid-RX. The listener path is RCU-protected and reportsrefcounted = false; its caller follows the appropriate listener lifetime rules. - Both tiers missing is the “no socket” path.
tcp_v4_rcvassignsSKB_DROP_REASON_NO_SOCKETand, after policy/checksum checks, sends a reset for a segment that permits one. A connect attempt observes that reset asECONNREFUSED; not every no-socket packet corresponds to a userspace connect.
Today’s experiment
# Inspect socket state
ss -tan # TCP all numeric
ss -ti # TCP with internal info: rtt, cwnd, retrans
# Trace lifecycle of one connection
sudo bpftrace -e '
fentry:tcp_init_sock { printf("init %p\n", args->sk); }
fentry:inet_csk_get_port { printf("get_port port=%d\n", args->snum); }
fentry:tcp_v4_connect { printf("connect %p\n", args->sk); }
fentry:tcp_set_state { printf("set_state %p -> %d\n", args->sk, args->state); }
'
# In another terminal:
nc -l 9999 &
echo hi | nc -q 1 localhost 9999
# Stop tracer (Ctrl-C in the bpftrace shell)
You’ll see init → get_port (server) → init (client) → connect → set_state through SYN_SENT → ESTABLISHED → CLOSE_WAIT → LAST_ACK → CLOSE.
tcp_set_state prints the new state as a raw integer, not a name — so a line like set_state 0xffff... -> 2 means “this sock moved to SYN_SENT.” The mapping (from include/net/tcp_states.h) is: 1=ESTABLISHED, 2=SYN_SENT, 3=SYN_RECV, 4=FIN_WAIT1, 5=FIN_WAIT2, 6=TIME_WAIT, 7=CLOSE, 8=CLOSE_WAIT, 9=LAST_ACK, 10=LISTEN, 11=CLOSING. (There’s also TCP_NEW_SYN_RECV in that enum — the half-open minisocket state from Background 2 — but tcp_set_state isn’t the path that sets it.) The two distinct %p sock pointers tell the server listener apart from the client connection — each transitions through its own sequence. To read the states as names directly, decode them in a BEGIN block (so the map is populated once, not re-assigned on every hit):
sudo bpftrace -e '
BEGIN { @sn[1]="ESTABLISHED";@sn[2]="SYN_SENT";@sn[3]="SYN_RECV";@sn[4]="FIN_WAIT1";@sn[5]="FIN_WAIT2";@sn[6]="TIME_WAIT";@sn[7]="CLOSE";@sn[8]="CLOSE_WAIT";@sn[9]="LAST_ACK";@sn[10]="LISTEN";@sn[11]="CLOSING"; }
fentry:tcp_set_state { printf("set_state %p -> %s\n", args->sk, @sn[args->state]); }
END { clear(@sn); }
'
See the bind/established hashes
# Bound listening sockets:
sudo ss -tlnp
# Only the established (ehash) entries, with owning process:
sudo ss -tnp state established
# (ss -tap would also include LISTEN sockets from the bhash above —
# the `state established` filter isolates the ehash side of the contrast.)
# Memory accounting:
ss -tim
ss reads from /proc/net/tcp (and the netlink-based INET_DIAG). The numbers come straight from the per-sock state — including the sk_rcvbuf/sk_sndbuf limits and the receive-queue occupancy that Background 4 explained govern wakeups and “queue full” decisions.
What to read in the kernel
-
net/socket.c:590—sock_from_file. Three lines: thef_op == &socket_file_opscheck, thenreturn file->private_data. This is the FD→socket bridge. -
net/socket.c:612—sockfd_lookup.fget(fd)→sock_from_file. The whole “look up the socket from the FD” step every syscall starts with. -
include/linux/net.h:137—struct socket. Notefile,sk,ops,wqliving side by side. -
include/linux/net.h:181—struct proto_ops. The BSD/socket-level vtable (every callback takesstruct socket *). -
include/net/sock.h:365—struct sock. Read all fields (~150 lines of struct). First notestruct sock_common __sk_commonat offset 0 and the long#define sk_* __sk_common.skc_*block right after it — those aliases are whysk->sk_stateworks. Then the__cacheline_group_begin/__cacheline_group_endmacros grouping fields into hot/cold cache lines (sock_write_rx,sock_read_rx, etc.). This struct is one of the most cache-line-tuned in the kernel; respect the layout. -
include/net/sock.h:420— thesk_backlogsub-struct (with#define sk_rmem_alloc). The second receive queue used while the socket lock is held. -
include/net/sock.h:453—sk_data_ready(and the nearby callback pointers). The receive-wakeup hook. -
include/net/sock.h:1291—struct proto. The protocol/sock-level vtable (every callback takesstruct sock *); notehash/get_portat:1339/:1342andslab/obj_sizeat:1382. -
include/net/sock.h:1711—lock_sock(andlock_sock_nestedat:1709,release_sockdeclared at:1717). The special sleeping owner lock. -
net/core/sock.c:3208—__release_sock. Drainssk_backloginto the real receive path.net/core/sock.c:3614—sock_def_readable, the defaultsk_data_readythat wakessk_sleep(sk).net/core/sock.c:3779—sock_init_data, where the defaults get wired. -
include/net/inet_sock.h:218—struct inet_sock. Adds IPv4/IPv6 common fields: addresses, ports, ttl, mc_addr, sk_dst_cache. Quick read. -
include/net/inet_connection_sock.h:81—struct inet_connection_sock. Adds retransmit timer, accept queue, ack delay timer. The “connection-oriented” base for TCP and DCCP. -
include/linux/tcp.h:197—struct tcp_sock. The full TCP state. ~150 fields covering snd_wnd, snd_cwnd, srtt_us, rcv_nxt, write_seq, sack info, RACK, retrans queue. Don’t try to memorize; just know it’s there and grep when you need a specific field. (tcp_skis#defined atinclude/linux/tcp.h:561.) -
net/socket.c:1819—SYSCALL_DEFINE3(socket, ...). The userspace entry. ~50 lines. Walk through to see how a syscall becomes astruct socket. -
net/socket.c:1594—__sock_create. The protocol-family dispatch. Readsnet_families[family]and calls the registered create callback. (sock_create_kernatnet/socket.c:1739is the no-FD variant for kernel-internal sockets.) -
net/ipv4/af_inet.c:259—inet_create. AF_INET’s create. Allocates the sock from the protocol’s slab cache, sets up inet_sk fields, calls protocol-specific init. -
net/ipv4/af_inet.c:472—inet_bind. The bind path (aproto_ops .bind). Validates addr_len, checks SO_REUSEADDR/REUSEPORT, then atnet/ipv4/af_inet.c:543dispatches tosk->sk_prot->get_port. Useful to understand whybind(0.0.0.0, port)andbind(127.0.0.1, port)behave differently. -
net/ipv4/inet_connection_sock.c:500—inet_csk_get_port. Port reservation. Read this to understand SO_REUSEPORT (Day 24): the function walks bind hash buckets and decides whether collision is allowed based on thereuseflag and UID match. -
net/ipv4/tcp.c:421—tcp_init_sock. TCP per-socket init. Sets up cwnd, ssthresh, RTT estimators, the retransmit (write) queue, sndbuf/rcvbuf. (Not the accept queue — that’s allocated atlisten()time byinet_csk_listen_start.) Read this once to see what state a fresh TCP socket starts with. -
net/ipv4/tcp_ipv4.c:221—tcp_v4_connect. The client connect path. Walk through: route lookup, source-port allocation, ehash insertion, SYN build/send. -
Documentation/networking/kapi.rst— the networking kernel API reference, including the socket/struct sockinterfaces. Brief.
Bullet Points
- An FD reaches the kernel socket through the VFS layer:
fd→struct file→file->private_data=struct socket→socket->sk=struct sock.sock_from_file/sockfd_lookup(net/socket.c:590/:612) do the recovery; kernel-internal sockets (sock_create_kern) skip the FD entirely. struct socketis the BSD-level descriptor (one per FD);struct sockis the protocol-level state. They’re linked viasock->sk.- The first member of
struct sockisstruct sock_common __sk_common, and fields likesk_state/sk_daddr/sk_protare#definealiases into it.sock_commonis also the first member ofinet_timewait_sockandrequest_sock, which is what lets the ehash compare full socks, TIME_WAIT minisocks, andTCP_NEW_SYN_RECVrequest socks uniformly by 4-tuple. - Polymorphism via embedding:
tcp_sockembedsinet_connection_sockembedsinet_sockembedssock. Thetcp_sk()/udp_sk()/inet_sk()helpers arecontainer_of(offset-0 no-op casts here). - Two dispatch tables:
struct socket->opsis aproto_ops(BSD layer —inet_stream_ops: bind/listen/poll/sendmsg, all takingstruct socket *);struct sock->sk_protis aproto(protocol layer —tcp_prot: get_port/connect/sendmsg, all takingstruct sock *). One syscall crosses both:bind()=inet_bind(proto_ops) →get_port(proto). - The socket lock (
lock_sock/release_sock) is a sleeping owner lock. While process context holds it, arriving packets are parked onsk_backloginstead ofsk_receive_queue;release_sockdrains the backlog. Arriving data callssk_data_ready(defaultsock_def_readable), which wakes any task sleeping on the socket’s wait queue — the mechanism behind blockingrecv()and behind epoll (Day 19). - Each
struct protoowns a dedicated slab cache (prot->slab,obj_size= the full derived struct liketcp_sock) — the same slab allocator from Day 1. - Bind tables (
tcp_hashinfo.bhash) and established tables (ehash) are per-netns. accept()returns a newsocktaken from the listener’s accept queue — the listener stays.- Inspect with
ss, especiallyss -tipsmfor full per-socket metadata.
Check question
Why do struct tcp_sock and struct udp_sock both embed struct inet_sock as their first field, rather than struct sock directly?
Click to reveal answer
Answer: inet_sock adds IPv4/IPv6-common fields (rcv_saddr, daddr, sport, dport, ttl, etc.) that any IP-based protocol needs. By inheriting from inet_sock, TCP and UDP share these fields without duplication, and the helper inet_sk(sk) returns a meaningful pointer for both. Non-IP protocols like UNIX sockets (struct unix_sock) skip inet_sock and embed sock directly because they don’t have IP semantics — there’s no source/dest IP address. The hierarchy is essentially “protocol-family base classes”: sock for everything, inet_sock for IP-based, inet_connection_sock for connection-oriented IP-based. (And one level deeper, sock itself starts with sock_common, the base shared even by minisockets.)
Tomorrow
Day 14: UDP — the simpler protocol. From sendmsg to wire and back, with the per-port lookup that makes UDP receive cheap — and the receive queue plus sk_data_ready wakeup you just learned put to work for real.
Day 14 — UDP: the simple protocol
Today’s mission: trace a UDP packet end to end. See why “connectionless” simplifies the kernel-side code dramatically — and learn the four mechanisms the path leans on (the 8-byte wire header and its checksum, the sleep/wake rendezvous behind
recvmsg, the receive-memory budget that decides who gets dropped, and the UDP hash table that finds your socket) so nothing along the way is a black box. Total time: ~130 minutes.
UDP looks trivial from userspace: sendto, recvfrom, done. But that simplicity rests on four pieces of kernel machinery this chapter repeatedly leans on. We teach each one intuition-first, then the concrete v7.1 struct or function, and only then walk the path, with every stage resting on something you already understand.
Several things from earlier days are load-bearing today and will not be re-taught in full:
struct sock,sk_receive_queue,sk_protvtable dispatch — Day 13. When userspace callssendto, the kernel dispatchessk->sk_prot->sendmsg, which for UDP isudp_sendmsg.bhash(port-keyed) vsehash(4-tuple-keyed) — Day 13 taught TCP’s two-table split instruct inet_hashinfo. UDP has the analogous split but in its ownstruct udp_table; Day 13 deferred the details to today, and we teach them below.- The pseudo-header and the
CHECKSUM_PARTIALoffload contract — Day 4. The L4 checksum covers a pseudo-header built from the IP addresses; withCHECKSUM_PARTIALthe kernel fills in the pseudo-header sum and the NIC finishes the fold over the payload (skbuff.h:248-251). Today we apply that to UDP specifically.skb->truesize— Day 3 introduced it on the TCP send side (sk_wmem_queued): an skb’s true memory cost is payload plus headers plus per-skb struct overhead and page-fragment slack, not its on-wire length. Today we meet the same number on the receive side.sk->sk_wq, the socket wait queue — Day 13 named it as a field ofstruct socket. Today we teach the wake path that actually uses it.- The TX/IP output path — Day 3. Once
udp_sendmsghands the datagram toip_send_skb, it follows the sameip_local_out → ip_outputroad you already traced.
Why UDP first
UDP has no state machine, no retransmissions, no congestion control. One sendmsg produces one wire packet (or one fragmented packet, if size > path MTU). One recvmsg returns one datagram. Everything else — packet loss recovery, in-order delivery, flow control — is the application’s problem.
Understanding UDP gives you the baseline L4 path. TCP (Days 15–17) is “UDP plus a state machine plus retransmission plus congestion control” — knowing where the simple version ends helps you see where TCP’s complexity goes.
Background 1: the UDP wire header and what the checksum protects
Before we trace a sendmsg, look at what actually goes on the wire. The chapter later discusses UDP-Lite’s “partial checksum,” the InCsumErrors counter, and keeps saying “one sendmsg produces one wire packet” — none of which you can reason about without seeing the 8-byte header udp_sendmsg builds.
The whole protocol is 8 bytes
The entire UDP wire header is four 16-bit big-endian fields (include/uapi/linux/udp.h:23):
struct udphdr {
__be16 source; /* source port */
__be16 dest; /* destination port */
__be16 len; /* UDP header + payload length, in bytes */
__sum16 check; /* checksum (see below) */
};
That is the whole protocol on the wire — 8 bytes. Contrast TCP’s 20+ byte header carrying sequence numbers, acknowledgments, a window, and flags. There is no sequence number because UDP never reorders or retransmits; no window because it never flow-controls; no flags because there are no connection states to signal. This minimal header is “why UDP is simple.” udp_send_skb (net/ipv4/udp.c:1092) fills these four fields in just before handing the skb to IP:
uh = udp_hdr(skb);
uh->source = inet_sk(sk)->inet_sport;
uh->dest = fl4->fl4_dport;
uh->len = htons(len);
uh->check = 0; /* finalised below */
What the checksum folds over
UDP’s check field is a ones-complement sum over three things, in order:
- The pseudo-header — source IP, destination IP, a zero byte, the protocol number (
IPPROTO_UDP= 17), and the UDP length. (Recall the pseudo-header from Day 4 — the same construction TCP uses.) Folding the IP addresses into an L4 checksum ties the datagram to its endpoints, so a packet misdelivered to the wrong host or protocol is caught even though L4 alone couldn’t tell. - The UDP header (the 8 bytes above, with
checktaken as zero during computation). - The payload.
In v7.1 this is exactly the csum_tcpudp_magic call at the end of udp_send_skb:
uh->check = csum_tcpudp_magic(fl4->saddr, fl4->daddr, len,
IPPROTO_UDP, udp_csum(skb));
if (uh->check == 0)
uh->check = CSUM_MANGLED_0;
csum_tcpudp_magic adds the pseudo-header to udp_csum(skb) (the sum over header+payload). The CHECKSUM_PARTIAL offload branch (Day 4’s contract) takes a different road — udp4_hwcsum seeds the pseudo-header sum and lets the NIC finish the fold over the payload — but the coverage is identical.
Why a zero checksum means “skip” on IPv4
Notice the if (uh->check == 0) uh->check = CSUM_MANGLED_0; above. On IPv4 the UDP checksum is optional: a received datagram whose check field is literally zero means “the sender did not compute a checksum; do not verify.” Because a genuinely-computed checksum can legitimately fold to zero, the sender rewrites that one value to the equivalent 0xFFFF (CSUM_MANGLED_0) so that an on-the-wire zero unambiguously means “absent.” The receive side honours this in udp4_csum_init (net/ipv4/udp.c:2536) via skb_checksum_init_zero_check — uh->check == 0 short-circuits verification entirely.
Why is this allowed? Because IPv4 has its own header checksum, so a corrupted address is caught at L3 even if L4 skips. IPv6 has no header checksum, so there is nothing to fall back on, and the UDP checksum is therefore mandatory there — a zero check on IPv6 receive is an error.
This optional-but-rewritable rule is exactly what UDP-Lite tweaks. UDP-Lite keeps the checksum mandatory but lets the application shrink the covered byte-range (the UDPLITE_SEND_CSCOV/UDPLITE_RECV_CSCOV sockopts): cover only the first N bytes — typically the media frame header — so a partly-corrupt video payload still gets delivered instead of dropped. Same udphdr on the wire; only the meaning of len/coverage changes.
Where a failed checksum shows up
When a received datagram’s checksum does not fold to zero, the kernel increments Udp: InCsumErrors. The MIB enum order (include/uapi/linux/snmp.h:158-162) is the same order the columns appear in /proc/net/snmp:
UDP_MIB_INDATAGRAMS, /* InDatagrams */
UDP_MIB_NOPORTS, /* NoPorts */
UDP_MIB_INERRORS, /* InErrors */
UDP_MIB_OUTDATAGRAMS, /* OutDatagrams */
UDP_MIB_RCVBUFERRORS, /* RcvbufErrors */
/* ... SndbufErrors, InCsumErrors, IgnoredMulti, MemErrors */
So InCsumErrors means “a datagram arrived but its checksum did not verify” — a corruption failure, distinct from NoPorts (a datagram arrived for a port with no listener) and RcvbufErrors (a datagram arrived but the receive queue had no room). The drops lab at the end of the chapter watches all three; now you know what each one means.

The TX side

sendmsg(fd, msg, flags)
→ sock_sendmsg
→ udp_sendmsg // net/ipv4/udp.c:1233
→ ip_make_skb / ip_append_data // net/ipv4/ip_output.c:1553, 1359
→ udp_send_skb // net/ipv4/udp.c:1092 (builds the 8-byte header above)
→ ip_send_skb // net/ipv4/ip_output.c:1506
→ ip_local_out → ip_output → ... (Day 3's TX path)
udp_sendmsg is roughly 274 lines of code — most of it option handling (MSG_CONFIRM, MSG_MORE, GSO_BY_FRAGS, control-message processing) and the route lookup for the destination. The actual datagram construction is a single call to ip_make_skb or, for cork mode, ip_append_data. The four-field header you just learned is stamped in by udp_send_skb right before ip_send_skb.
Why two paths (ip_make_skb and ip_append_data)?
ip_make_skb: build one skb directly from one userspace buffer. Fast path; one syscall, one packet.ip_append_data(corked): used whenMSG_MOREorUDP_CORKis set. The kernel accumulates writes into a partial skb; the nextsendmsg(orUDP_CORK off) finalizes it as one datagram. Useful when an application has multiple sources of data for one packet (header + payload built separately).
No socket-level send queue
Unlike TCP, UDP doesn’t keep an sk_write_queue of unsent skbs. As soon as udp_sendmsg builds the datagram, it’s handed to IP and gone. Why? Because UDP has no notion of retransmission — there’s no reason to keep the bytes around. The socket buffer (sk_sndbuf) only matters for back-pressure on sendmsg itself: if the IP stack can’t accept the packet right now (e.g., qdisc is full), sendmsg blocks or returns EAGAIN.
Background 2: the blocking/wake rendezvous behind recvmsg
A receive queue only earns its keep because two parties meet at it: the softirq producer drops a datagram in, and the syscall consumer picks it up. The whole point is the rendezvous when the consumer arrives before the producer — a blocking recvmsg on an empty queue must somehow wait and then wake the instant a packet lands. That machinery is invisible in the call chains, so let’s teach it before tracing them.
The problem: don’t spin
A blocking recvmsg on an empty queue must not busy-loop burning a CPU. Instead it parks the calling task on the socket’s wait queue and yields the CPU. Every struct sock has exactly such a wait queue, reached via sk->sk_wq (Day 13 named this field; here is where it earns its keep). A parked task sleeps until something explicitly wakes it.
The wake: sk_data_ready
The single hook that turns “a packet landed in the queue” into “the application’s recvmsg returns” is sk->sk_data_ready. Its default, installed for every socket in sock_init_data (net/core/sock.c:3734):
sk->sk_data_ready = sock_def_readable;
sock_def_readable (net/core/sock.c:3614) is short and is the entire wake path:
void sock_def_readable(struct sock *sk)
{
struct socket_wq *wq;
trace_sk_data_ready(sk);
rcu_read_lock();
wq = rcu_dereference(sk->sk_wq);
if (skwq_has_sleeper(wq))
wake_up_interruptible_sync_poll(&wq->wait, EPOLLIN | EPOLLPRI |
EPOLLRDNORM | EPOLLRDBAND);
sk_wake_async_rcu(sk, SOCK_WAKE_WAITD, POLL_IN);
rcu_read_unlock();
}
Two things happen: any sleeper parked on sk_wq is woken with an EPOLLIN poll-mask wake (that is what unblocks a sleeping recvmsg), and sk_wake_async_rcu fires the async/SIGIO and epoll/io_uring notifications. So sk_data_ready is the one call that services every waiter style — blocking recvmsg, select/poll/epoll, and SIGIO — from a single producer-side hook.
Wiring it together
The producer side calls this hook right after charging the datagram to the queue. In __udp_enqueue_schedule_skb (net/ipv4/udp.c:1745):
INDIRECT_CALL_1(READ_ONCE(sk->sk_data_ready),
sock_def_readable, sk);
INDIRECT_CALL_1 is just the kernel’s indirect-call wrapper — it calls sk->sk_data_ready (the function pointer) while hinting the compiler that the target is almost always sock_def_readable, so the common case avoids a retpoline. The effect is simply sk->sk_data_ready(sk).
The consumer side loops in the dequeue path. __skb_recv_udp (net/ipv4/udp.c:1923) is the UDP dequeue entry the recvmsg path drives; it peeks/dequeues from sk_receive_queue (via its reader_queue), and if the queue is empty and the socket is blocking, its own dequeue loop calls __skb_wait_for_more_packets (net/core/datagram.c:89, invoked from net/ipv4/udp.c:1984) to sleep until sk_data_ready wakes it — or a timeout or signal fires. (UDP does not go through the generic __skb_recv_datagram loop at net/core/datagram.c:305; that path serves other datagram protocols. UDP carries its own loop so it can splice the producer-side queue and do its own memory accounting.)
This is also exactly why the experiment later insists you attach probes before sending: a wake only reaches a waiter that is already parked. The same reason a late-attached probe sees nothing, a not-yet-blocked recvmsg misses the wake — the rendezvous only works if the consumer is waiting when the producer arrives.

Background 3: the receive-memory budget (sk_rmem_alloc vs sk_rcvbuf)
The drops story below rests entirely on “if the queue is full, the packet is dropped.” But full of what? Not a count of datagrams, and not payload bytes — the answer is a memory budget, and getting it exactly right is what lets you predict when drops happen.
The budget and the meter
Each socket’s receive queue has a memory budget: sk_rcvbuf, initialised from net.core.rmem_default and raisable via SO_RCVBUF up to net.core.rmem_max. The running charge against it is sk_rmem_alloc, an atomic counter.
What gets charged is skb->truesize — recall from Day 3 (the send-side sk_wmem_queued accounting) that truesize is payload plus headers plus the sk_buff struct overhead plus page-fragment slack, not the on-wire length. The receive side uses the very same number. The consequence is sharp: many small datagrams, each in its own small skb, each carrying fixed per-skb overhead, can exhaust the budget far below “rcvbuf bytes of payload.” A 20-byte datagram can easily cost several hundred bytes of truesize.
The enqueue gate
__udp_enqueue_schedule_skb (net/ipv4/udp.c:1655) reads the meter and the budget, then applies the gate (net/ipv4/udp.c:1679):
rmem = atomic_read(&sk->sk_rmem_alloc);
rcvbuf = READ_ONCE(sk->sk_rcvbuf);
size = skb->truesize;
/* ... */
if (rmem + size > rcvbuf) {
if (rcvbuf > INT_MAX >> 1)
goto drop;
/* Accept the packet if queue is empty. */
if (rmem)
goto drop;
}
Read it literally: if rmem + truesize > rcvbuf, drop — with one humane nuance. If the queue is currently empty (rmem is zero), the packet is accepted anyway, so a single jumbo datagram larger than the whole budget is never starved to death. The INT_MAX >> 1 guard handles the unsigned-cast boundary check. An over-budget drop is counted as RcvbufErrors / sk_drops — the d<N> token you’ll read in ss -uam and the RcvbufErrors column the lab watches.
Just past the gate (net/ipv4/udp.c:1695), when the queue is more than half full (rmem > rcvbuf >> 1), the kernel calls skb_condense(skb) to shrink the skb’s truesize — squeezing a linear copy to reclaim page-fragment slack — and stretch the budget. The charge is committed with atomic_add(q_size, &sk->sk_rmem_alloc) (net/ipv4/udp.c:1736) before the sk_data_ready wake, so a waking recvmsg always sees a consistent meter.
This is exactly why “tune rmem_max + SO_RCVBUF” is the fix for a flooded slow receiver: you are raising the truesize budget so transient bursts fit instead of being dropped before recvmsg can drain them.

The RX side
ip_rcv → ip_local_deliver → ip_local_deliver_finish
→ udp_rcv(skb) // net/ipv4/udp.c:2588
→ __udp4_lib_lookup // net/ipv4/udp.c:667 — find sock by 4-tuple
→ udp_queue_rcv_skb // net/ipv4/udp.c:2422
→ __udp_queue_rcv_skb // net/ipv4/udp.c:2307
→ __udp_enqueue_schedule_skb // the truesize gate from Background 3
→ sk->sk_data_ready(sk) // the wake from Background 2
Background 4: the UDP hash table and the most-specific-first lookup
Day 13 taught TCP’s bhash/ehash split and said “UDP has its own struct udp_table, see Day 14.” This is that section. When a datagram arrives with (saddr, sport, daddr, dport), the kernel must turn it into a struct sock, and the table it walks is not TCP’s inet_hashinfo.
Two kinds of bind drive the design:
- Unconnected (default): bound to
(local_addr, local_port). Receives datagrams to that port regardless of source. - Connected (after
connect()on aSOCK_DGRAM): bound to a specific 4-tuple. Receives only from the matching peer — and is markedTCP_ESTABLISHED(UDP reuses the TCP state constant).
Three hash arrays. struct udp_table (include/net/udp.h:94, per-netns via net->ipv4.udp_table) holds three, ordered most-general to most-specific:
struct udp_table {
struct udp_hslot *hash; /* keyed by local port only — legacy primary */
struct udp_hslot_main *hash2; /* keyed by (local port, local address) — the hslot2 */
struct udp_hslot *hash4; /* keyed by full 4-tuple — connected (out under CONFIG_BASE_SMALL) */
unsigned int mask;
unsigned int log;
};
A bucket is a struct udp_hslot (include/net/udp.h:57) — a socket-list head + count + spinlock, cache-aligned (__aligned(2 * sizeof(long))):
struct udp_hslot {
union { struct hlist_head head; struct hlist_nulls_head nulls_head; };
int count;
spinlock_t lock;
} __aligned(2 * sizeof(long));
The hash2 buckets are a richer struct udp_hslot_main (include/net/udp.h:76) that embeds an hslot plus a hash4_cnt — the count of connected (hash4) sockets sharing that (port, addr). That counter is what the udp_has_hash4(hslot2) fast-path test reads: it tells the lookup whether bothering with the connected 4-tuple table is worthwhile at all.
The lookup is most-specific-first. __udp4_lib_lookup (net/ipv4/udp.c:667) walks four steps:
hash2 = ipv4_portaddr_hash(net, daddr, hnum);
hslot2 = udp_hashslot2(udptable, hash2);
if (udp_has_hash4(hslot2)) { /* 1. connected 4-tuple table first */
result = udp4_lib_lookup4(net, saddr, sport, daddr, hnum, dif, sdif, udptable);
if (result) return result;
}
/* 2. non-wildcard (daddr,dport) hslot2 — accept if it's a connected socket */
result = udp4_lib_lookup2(net, saddr, sport, daddr, hnum, dif, sdif, hslot2, skb);
if (!IS_ERR_OR_NULL(result) && result->sk_state == TCP_ESTABLISHED)
goto done;
/* ... (BPF sk_lookup redirect omitted) ... */
if (result) goto done;
/* 3. wildcard INADDR_ANY hslot2 — unconnected binds to 0.0.0.0:port */
hash2 = ipv4_portaddr_hash(net, htonl(INADDR_ANY), hnum);
hslot2 = udp_hashslot2(udptable, hash2);
result = udp4_lib_lookup2(net, saddr, sport, htonl(INADDR_ANY), hnum, dif, sdif, hslot2, skb);
if (!IS_ERR_OR_NULL(result)) goto done;
/* 4. primary port-only hash as a race fallback */
result = udp4_lib_lookup1(net, saddr, sport, daddr, hnum, dif, sdif, udptable);
Read the ordering: (1) if any connected sockets exist for this (port, addr), try the full 4-tuple table; (2) else look up the non-wildcard hslot2 for (daddr, dport) and accept it only if it is TCP_ESTABLISHED (i.e. a connected socket); (3) fall back to the wildcard INADDR_ANY hslot2 that holds unconnected 0.0.0.0:port binds; (4) the primary port-only hash covers a narrow rehash race. This is precisely why connected UDP wins over an unconnected bind to the same port — and, as you’ll see in the experiment, why a second nc with a fresh source port fails the connected 4-tuple match and is silently not delivered.
With SO_REUSEPORT, a group of sockets share a hash2 slot and the kernel hashes the 4-tuple to pick one of the group (deferred to Day 24); that is the “multiple sockets share the bind slot” line, now grounded in which array holds them.
On a miss, the lookup returns no socket, and udp_rcv takes the no-listener path (net/ipv4/udp.c:2662):
__UDP_INC_STATS(net, UDP_MIB_NOPORTS);
icmp_send(skb, ICMP_DEST_UNREACH, ICMP_PORT_UNREACH, 0);
That UDP_MIB_NOPORTS bump plus the ICMP port-unreachable is the exact path the drops lab triggers by spraying datagrams at a closed port.

There are no Dumb Questions
Q: If UDP is connectionless, what does
connect()on aSOCK_DGRAMeven do?A: It pins the socket to one 4-tuple and marks it
TCP_ESTABLISHED, so the most-specific-first lookup above delivers only datagrams from that one peer — and lets the kernel skip the per-sendmsgroute lookup, caching the destination instead. No packets are exchanged; it is a purely local binding.Q: Why is the checksum mandatory on IPv6 but optional on IPv4?
A: IPv4 has its own L3 header checksum, so a corrupted address is caught at L3 even when L4 skips (Background 1). IPv6 dropped the header checksum to save per-hop work, so the UDP checksum is the only integrity check left — it cannot be optional.
Queueing
udp_queue_rcv_skb appends the skb to the socket’s sk_receive_queue — a doubly-linked list of skbs — via the __udp_enqueue_schedule_skb gate you met in Background 3. If that gate passes, the charge is committed and sk_data_ready fires the wake from Background 2; if the budget is exceeded, the datagram is dropped and RcvbufErrors climbs. UDP doesn’t push back on the sender (no ACK mechanism), so an over-budget burst is simply lost.
The recvmsg path
recvmsg(fd, msg, flags)
→ sock_recvmsg
→ udp_recvmsg // net/ipv4/udp.c:2031
→ __skb_recv_udp // net/ipv4/udp.c:1923 — dequeue (or park on sk_wq if empty+blocking)
→ skb_copy_datagram_msg // copy into user buffer
→ skb_consume_udp // free the skb (UDP-specific wrapper)
Each recvmsg returns exactly one datagram. If the user buffer is smaller than the datagram, the rest is silently truncated and MSG_TRUNC is set in msg->msg_flags. (TCP doesn’t do this — TCP gives you bytes, UDP gives you packets.)
MSG_PEEK returns the data without dequeueing — useful for fixed-header protocols where you peek to learn the length, then read for real. Costs more CPU because the skb stays in the queue.
When the queue is empty and the socket is blocking, __skb_recv_udp is where the consumer parks on sk_wq (Background 2) until a producer’s sk_data_ready wakes it.
UDP-Lite, GSO, and other variations
- UDP-Lite (RFC 3828): the optional-checksum tweak from Background 1 — mandatory checksum, but coverage shrunk to the first N bytes via
CSCOV. Linux exposes it asIPPROTO_UDPLITE; the code path is the sameudp_sendmsg/recv logic with a coverage flag. - UDP GSO (
UDP_SEGMENTsockopt): the application sends one large buffer, kernel fragments it into MTU-sized datagrams in software. This is how QUIC/HTTP3 implementations achieve high throughput from userspace — one syscall delivers many packets. (udp_send_skbalso has a GSO path, guarded bycork->gso_size, that setsSKB_GSO_UDP_L4so the segmentation happens later inudp_offload.c— see the reading list.) - UDP-encap (IPsec, VXLAN, GENEVE, FoU): UDP socket on a special port; instead of queueing on
sk_receive_queue, calls a registeredencap_rcvhandler. Day 12 covered VXLAN’s use of this.
Today’s experiment
Order matters: the probes must be attached before any datagram is sent, or the trace shows nothing. (This is Background 2 in action — a wake only reaches an already-parked waiter, and a probe only sees calls made after it attaches.) Use two terminals.
Terminal 1 — start a UDP listener, then attach the trace and wait for Attached 3 probes:
nc -ul 9999 &
sudo bpftrace -e '
fentry:udp_sendmsg { printf("send %d bytes from sk=%p\n", args->len, args->sk); }
fentry:udp_rcv { printf("recv at sk-lookup\n"); }
fentry:udp_queue_rcv_one_skb { printf("queue to sk=%p\n", args->sk); }
'
Terminal 2 — only once the trace prints Attached 3 probes, send two datagrams from a single client process so they share one source port:
{ echo hello; sleep 1; echo world; } | nc -u -q1 127.0.0.1 9999
-q1 makes the client exit 1 s after EOF instead of hanging on the open UDP socket. Watch Terminal 1; you’ll see one send → one rcv → one queue per datagram, then Ctrl-C the trace:
Attached 3 probes
send 6 bytes from sk=0xffff8bf601ed5400
recv at sk-lookup
queue to sk=0xffff8bf61237f380
send 6 bytes from sk=0xffff8bf601ed5400
recv at sk-lookup
queue to sk=0xffff8bf61237f380
No state-machine churn — every datagram takes the identical, stateless three-step path.
When done, stop the listener: kill %1 # (or: pkill nc)
Two pitfalls this layout avoids:
- Why one client process, not two
echo | nc? With the OpenBSDncused here, an unconnectednc -ul 9999connect()s to the first sender’s address after the first datagram. A second, separateecho | ncis a new process with a fresh ephemeral source port, so it fails the connected-socket 4-tuple match in__udp4_lib_lookupand is not delivered to the listener — you’d see itsrecvbut noqueueline. That is exactly the most-specific-first lookup from Background 4: once the listener is connected, step 1/2 demand the full 4-tuple, and a new source port no longer matches. Driving both writes from one process keeps the source port stable so both datagrams match. (Don’t collapse them intoprintf 'hello\nworld\n' | nc— a single pipe read becomes onesendto, i.e. one datagram.) - Why
udp_queue_rcv_one_skband not__udp_queue_rcv_skb? The static__udp_queue_rcv_skb(the symbol named in the RX diagram above) is inlined intoudp_queue_rcv_one_skbon most builds —grep __udp_queue_rcv_skb /proc/kallsymsreturns nothing, andfentry:__udp_queue_rcv_skbfails to attach, which makes bpftrace abort the whole program (you’d get zero output). The non-inlined wrapperudp_queue_rcv_one_skbis reliably attachable and carries the sameargs->sk. Verify withbpftrace -l 'fentry:vmlinux:udp_queue_rcv_one_skb'before running.
Watch UDP drops
On an idle box every drop counter reads 0, so first provoke a drop to have something to watch. Sending to a closed UDP port bumps the NoPorts (NO_SOCKET) counter — the simplest reliably-demonstrable UDP delivery failure, and the exact __udp4_lib_lookup miss → UDP_MIB_NOPORTS + icmp_send path from Background 4:
# Baseline:
grep -E '^Udp:' /proc/net/snmp
# Trigger: 50 datagrams to a closed port (each is dropped with NO_SOCKET):
for i in $(seq 1 50); do echo x | nc -u -w0 127.0.0.1 1; done
# Read again — NoPorts climbs by ~50:
grep -E '^Udp:' /proc/net/snmp
nstat -az | grep -i '^Udp'
# Per-socket drops:
ss -uam # u=UDP, a=all, m=memory
Expected — the NoPorts column (2nd value) jumps by exactly the 50 datagrams sent:
Udp: InDatagrams NoPorts InErrors OutDatagrams RcvbufErrors SndbufErrors InCsumErrors IgnoredMulti MemErrors
Udp: 1547 88 0 1636 0 0 0 1 0 # before
Udp: 1547 138 0 1686 0 0 0 1 0 # after (NoPorts 88 → 138)
Reading the output:
grep -E '^Udp:' /proc/net/snmpprints two rows: the first lists counter names, the second lists values; the Nth value belongs to the Nth name (this is theUDP_MIB_*enum order from Background 1). The:in^Udp:excludes the separateUdpLite:block. WatchNoPorts(no socket at the destination port — the case triggered above),InErrors(general receive errors),InCsumErrors(a datagram arrived but its checksum didn’t verify — Background 1), andRcvbufErrors(sk_rcvbufbudget exceeded — Background 3).nstat -azshows the same counters by name (UdpNoPorts,UdpInErrors,UdpRcvbufErrors). Use-az—-agives absolute (cumulative-since-boot) values and-zincludes zero-valued counters; barenstatprints only counters that changed since its last run and rewrites its history file, so a second run shows nothing.ss -uamreports per-socket drops as thed<N>token at the end of eachskmem:(...)line; on an idle socket it readsd0(andrb<bytes>is that socket’ssk_rcvbuf— the budget bar from Background 3), which is why the trigger matters.
NoPorts is a delivery failure (no listener) rather than a queue overflow; the RcvbufErrors case needs a slow receiver with a small SO_RCVBUF being flooded to provoke — i.e. driving sk_rmem_alloc past sk_rcvbuf faster than recvmsg drains it.
A common cause of UDP drops on busy servers: too small sk_rcvbuf. The default is net.core.rmem_default (often 212KB). For a high-throughput UDP receiver:
sudo sysctl -w net.core.rmem_max=33554432
# Then in app: setsockopt(fd, SOL_SOCKET, SO_RCVBUF, ..., 16MB)
Remember from Background 3 that you’re raising the truesize budget, not a byte-count of payload — so size for the per-skb overhead, not just the data rate.
What to read in the kernel
-
net/ipv4/udp.c:1233—udp_sendmsg. The TX side. Read top to bottom (~274 lines). Notice three sections: control-message parsing (cmsg loop), route lookup, and skb construction (theip_make_skbvsip_append_databranch). If you ever wonder “what does MSG_CONFIRM do?” — search this file. -
net/ipv4/udp.c:1092—udp_send_skb. Where the 8-byte header from Background 1 is stamped and the checksum mode (CHECKSUM_NONE/CHECKSUM_PARTIAL/ fullcsum_tcpudp_magic) is finalised beforeip_send_skb. -
net/ipv4/udp.c:2588—udp_rcv. The RX entry. Short (~107 lines). Performs the basic checks (length, checksum viaudp4_csum_init), looks up the destination socket via__udp4_lib_lookup, dispatches to multicast/unicast paths, and on a miss bumpsNoPorts+ sends ICMP port-unreach. -
net/ipv4/udp.c:667—__udp4_lib_lookup. The 4-tuple → socket lookup. Walk the most-specific-first ordering from Background 4: connectedhash4, non-wildcardhslot2(accept ifTCP_ESTABLISHED), wildcardINADDR_ANYhslot2, primary port-onlyhash. Reading this clarifies what “connected UDP” actually means kernel-side. -
include/net/udp.h:57,76,94—struct udp_hslot,struct udp_hslot_main,struct udp_table. The three-array structure (hash/hash2/hash4) and thehash4_cntfield that powers theudp_has_hash4fast path. -
net/ipv4/udp.c:1655—__udp_enqueue_schedule_skb. The receive-memory gate from Background 3:rmem + truesize > rcvbuf⇒ drop (with the empty-queue acceptance),skb_condenseunder pressure, then theatomic_addcharge and thesk_data_readywake. -
net/ipv4/udp.c:2422—udp_queue_rcv_skb. The queueing path. It’s a thin (~21-line) wrapper; the work — the SO_FILTER / sk_filter check (BPF socket filters), the multicast membership check, and the back-pressure handling — lives in the call chain it heads (udp_queue_rcv_one_skb→__udp_queue_rcv_skb→__udp_enqueue_schedule_skb), which runs ~200 lines total including that slow path. -
net/ipv4/udp.c:2031—udp_recvmsg. The dequeue side. Read the MSG_PEEK handling — it’s surprisingly subtle (locks the socket, walks the queue without removing). -
net/core/sock.c:3614—sock_def_readable. The defaultsk_data_readyfrom Background 2: thesk_wqsleeper wake plus the async/epoll notification. Installed atnet/core/sock.c:3734. -
net/core/datagram.c:89—__skb_wait_for_more_packets. How a blockingrecvmsgactually sleeps; for UDP it is called from__skb_recv_udp’s own dequeue loop atnet/ipv4/udp.c:1984(not the generic__skb_recv_datagramloop atnet/core/datagram.c:305, which serves other datagram protocols). -
net/ipv4/ip_output.c:1553—ip_make_skb. How a single-shot UDP packet is built. Compare against the corkedip_append_datato understand the trade-off. -
net/ipv4/udp_offload.c— UDP GSO/GRO. Searchudp4_ufo_fragmentfor the segmentation function andudp_gro_receivefor the receive-side coalescing. Read this if you’re pushing high-rate UDP (QUIC) and want to understand why GSO matters. -
net/ipv4/udp.cUDP_GRO / UDP_SEGMENT sockopt handlers — there is no dedicatedudp.rst; UDP GSO/GRO and error queues are documented mainly in the code and the original commit logs. (Documentation/networking/udplite.rstcovers the UDP-Lite variant.)
Bullet Points
- The entire UDP wire header is 8 bytes —
source, dest, len, check(udp.h:23). No sequence/ack/window/flags; that minimal header is “why UDP is simple.” - The checksum covers pseudo-header (src/dst IP, proto=17, UDP len) + UDP header + payload. On IPv4 it’s optional (
check==0⇒ skip verify; a real zero is rewritten to0xFFFF); on IPv6 it’s mandatory. UDP-Lite keeps it mandatory but shrinks the covered byte-range. A verify failure bumpsInCsumErrors. - TX:
udp_sendmsg → ip_make_skb → udp_send_skb → ip_send_skb → .... No send queue — datagrams hand off to IP immediately (UDP never retransmits). - RX:
udp_rcv → __udp4_lib_lookup → udp_queue_rcv_skb → enqueue + wake. - Lookup walks
struct udp_table’s three arrays most-specific-first: connectedhash4(4-tuple) → non-wildcardhash2/hslot2(accept ifTCP_ESTABLISHED) → wildcardINADDR_ANYhash2→ primary port-onlyhash. A miss bumpsNoPortsand sends ICMP port-unreach. - Blocking
recvmsgparks the task onsk->sk_wq; the softirq’ssk->sk_data_ready(sock_def_readable) issues anEPOLLINwake.__skb_wait_for_more_packetsis the sleep. - Drops happen when
sk_rmem_alloc + skb->truesize > sk_rcvbuf— a truesize budget (payload + overhead), not a payload-byte count. Empty queue always accepts one; over-budget bumpsRcvbufErrors/sk_drops. Fix withnet.core.rmem_max+SO_RCVBUF. - MSG_PEEK reads without dequeueing; MSG_TRUNC flags truncated reads.
- UDP GSO (
UDP_SEGMENTsockopt) gives one syscall → many MTU-sized datagrams. Used by QUIC.
Check question
Why does UDP have a per-socket receive queue (sk_receive_queue) but no send queue?
Click to reveal answer
Answer: UDP doesn’t retransmit — there are no ACKs, no reason to remember a sent datagram once it’s handed to IP. The send path builds an skb in udp_sendmsg and immediately enters the IP output path; nothing is held back. TCP, by contrast, must keep sent skbs in sk_write_queue because it may need to retransmit them when ACKs don’t arrive (Day 17). The receive queue is needed because the kernel arrives with a datagram before the application has called recvmsg to consume it; the queue holds it until the app shows up. Both protocols have receive queues; only TCP needs a send queue.
Tomorrow
Day 15: TCP state machine — eleven states, ~7,700 lines of state-handling code in tcp_input.c.
Day 15 — TCP state machine
Today’s mission: trace a TCP connection through every state. First learn the bytes that drive the transitions — the TCP header and its control flags — then the three- and four-packet choreography that moves the states, then the TIME_WAIT minisock that explains the chapter’s best puzzle. Understand why TIME_WAIT exists, why FIN_WAIT_2 sometimes hangs, and how the kernel implements every transition. Total time: ~110 minutes.
What “state machine” really means here
TCP is a connection-oriented protocol — every connection has state that both ends maintain through its lifetime. The states encode “what’s the next legal thing this connection can do?” The state transitions happen in response to:
- Application calls (
connect,listen,accept,close). - Received segments (SYN, SYN-ACK, ACK, FIN, RST).
- Timer events (RTO firing, 2MSL elapsed).
The whole apparatus is in tcp_rcv_state_process (net/ipv4/tcp_input.c:7119) for incoming-segment-driven transitions, and in tcp_set_state (net/ipv4/tcp.c:2961) for explicit state changes from anywhere. Reading those two functions teaches you 80% of TCP control flow.
But there’s a gap in that bullet list that almost every TCP tutorial leaves open. The middle item — “received segments (SYN, SYN-ACK, ACK, FIN, RST)” — names five events, and the whole rest of this chapter is a machine driven by them. Yet what is a SYN? Where does it live? Is “SYN-ACK” one packet or two? Before we can talk about states, we have to talk about the bytes that trip the transitions. That’s where we start.
Background 1: The TCP segment and its control flags
Day 3 taught the socket side of TX — sendmsg, the write queue, snd_nxt. Today we look at what actually goes on the wire and, crucially, at the handful of bits in it that the state machine reacts to.
Intuition first: flags are bits, not packet types
A TCP segment is just a TCP header followed by optional options and then payload bytes. The header is a fixed 20 bytes (more if options are present). Buried in it is a set of single-bit control flags. The state machine doesn’t react to “a SYN packet” the way you’d react to a differently-shaped envelope in the mail — it reacts to which bits are set in the one and only kind of TCP header.
This is the single most important thing to internalize today: SYN, ACK, FIN, RST, PSH are independent bits. They are not mutually-exclusive packet types. One segment can set several at once:
- SYN-ACK is not two packets — it’s one segment with both the SYN bit and the ACK bit set.
- The final packet of the handshake is a bare ACK (only the ACK bit set).
- A FIN almost never travels alone — it rides with ACK, so on the wire you see FIN+ACK. (Every segment after the handshake sets ACK, because TCP piggybacks acknowledgements on everything.)
What each bit means to the state machine:
- SYN — “open.” Synchronise sequence numbers; only seen during connection setup.
- ACK — “this segment acknowledges data I’ve received.” Set on essentially everything after the first SYN.
- FIN — “I’m done sending; graceful close of my half.”
- RST — “abort, immediately.” No graceful dance, no acknowledgement expected.
- PSH — “push this to the application now”; doesn’t drive states.
- URG / ECE / CWR / AE — urgent pointer and ECN/AccECN signalling; not relevant to the state machine.
The concrete struct: struct tcphdr
Here is the actual on-the-wire header in v7.1 (include/uapi/linux/tcp.h:25, little-endian layout shown):
struct tcphdr {
__be16 source;
__be16 dest;
__be32 seq; /* sequence number of the first payload byte */
__be32 ack_seq; /* next byte I expect from you (set when ack=1) */
__u16 ae:1,
res1:3,
doff:4, /* data offset: header length in 32-bit words */
fin:1,
syn:1,
rst:1,
psh:1,
ack:1,
urg:1,
ece:1,
cwr:1;
__be16 window;
__sum16 check;
__be16 urg_ptr;
};
The flags are literally those :1 bitfields — fin:1, syn:1, rst:1, psh:1, ack:1. The two __be32 fields above them, seq and ack_seq, are the heart of the protocol: seq numbers the bytes I’m sending, ack_seq tells you the next byte I expect from you.
![The 20-byte TCP header and its flags field (tcp[12-13])](diagrams/day15_tcp_header.png)
Two ways the kernel reads the flags
The kernel touches these flags in two different ways, and you’ll see both when you read the code:
-
Straight off the wire bitfield. Code with a
struct tcphdr *thin hand readsth->syn,th->fin,th->rst, etc. directly. There’s also a helper,tcp_flags_ntohs(th)(include/net/tcp.h:1062), that pulls all the flag bits out as one host-order value. -
Pre-extracted into the skb control block. Recall the
cb[48]scratchpad from Day 1 — the per-packet area each layer stashes state in. TCP overlays it withstruct tcp_skb_cb, reached via theTCP_SKB_CB(skb)macro (include/net/tcp.h:1149). Early on the input path the flags byte is copied intoTCP_SKB_CB(skb)->tcp_flags— a__u16commented “TCP header flags (tcp[12-13])” (include/net/tcp.h:1115) — so the rest of the input path canswitchon flags cheaply without re-parsing the header.
Each named bit has a BIT() constant for masking (include/net/tcp.h:1050-1054):
#define TCPHDR_FIN BIT(0)
#define TCPHDR_SYN BIT(1)
#define TCPHDR_RST BIT(2)
#define TCPHDR_PSH BIT(3)
#define TCPHDR_ACK BIT(4)
The fact the whole chapter silently leans on: SYN and FIN consume a sequence number
A pure ACK carries no payload and occupies no place in the byte stream — it just reports a number. But SYN and FIN each consume one sequence number, exactly as if they were a single phantom byte of data. This is not a quirk; it’s the mechanism that makes graceful close reliable:
- Because a FIN sits at a real sequence slot, the peer can acknowledge it specifically (its ACK names the byte after the FIN).
- Because it’s acknowledged, it can be retransmitted if the ACK is lost, just like any other byte (recall
snd_nxtfrom Day 3 — the FIN advances it by one).
Hold onto this. It’s the reason TIME_WAIT’s “stay alive to ACK a retransmitted FIN” guarantee works later in the chapter.
RST is the exception to all of it
RST is special: it carries no payload, occupies no sequence slot, and is not acknowledged. It doesn’t participate in the graceful FIN dance at all — it tears the connection down on the spot. That’s exactly why, when an application calls close() with unread data still in the receive queue, __tcp_close sends an RST instead of a FIN (you’ll see this in “What to read”): there’s no point gracefully draining a connection nobody is listening to.
The eleven states

Defined in include/net/tcp_states.h:13:
enum {
TCP_ESTABLISHED = 1,
TCP_SYN_SENT,
TCP_SYN_RECV,
TCP_FIN_WAIT1,
TCP_FIN_WAIT2,
TCP_TIME_WAIT,
TCP_CLOSE,
TCP_CLOSE_WAIT,
TCP_LAST_ACK,
TCP_LISTEN,
TCP_CLOSING,
TCP_NEW_SYN_RECV,
TCP_BOUND_INACTIVE,
};
(The last two are internal sub-states — NEW_SYN_RECV is a half-open SYN entry on the listener; BOUND_INACTIVE is a recently-bound socket with no listen yet.)
The order of this enum is not cosmetic — it’s the integer wire the bpftrace experiment decodes later: TCP_ESTABLISHED=1, TCP_SYN_SENT=2, TCP_SYN_RECV=3, and so on. Keep the numbers in view; you’ll be reading them raw off a probe in a few sections.
The connection-establishment states
- CLOSED — no connection. Initial and final state.
- LISTEN — passive open: the server’s listening socket. Waits for incoming SYNs.
- SYN_SENT — active open: client sent SYN, waiting for SYN-ACK.
- SYN_RECV — server received SYN, sent SYN-ACK, waiting for the ACK that completes the 3-way handshake.
- ESTABLISHED — handshake complete, data flowing.
Background 2: the three-way handshake, packet by packet
The chapter’s state diagram drops you straight into CLOSED → SYN_SENT → ESTABLISHED and the server’s LISTEN → SYN_RECV → ESTABLISHED, as if the open exchange were already obvious. It isn’t — so here are the three packets that actually move those edges.
Day 13 already taught the socket-side plumbing: connect() sends the SYN and puts the client in SYN_SENT; listen() builds the accept queue; the half-open handshake lives in the ehash as a TCP_NEW_SYN_RECV request sock; and accept() pops a completed connection. That’s the machinery — recall it from Day 13. What’s new today is the on-the-wire choreography.
Open is three packets:
- Client → server: SYN,
seq=x. (Client enters SYN_SENT.) - Server → client: SYN-ACK,
seq=y, ack=x+1. (Server creates the half-open entry and sends this; noteack=x+1because the SYN consumed sequence numberx.) - Client → server: ACK,
ack=y+1. (Server moves to ESTABLISHED on receiving it; client moved to ESTABLISHED the moment the SYN-ACK arrived.)
The real purpose of this dance is not politeness — it’s that each side learns the other’s initial sequence number (x and y). Without that exchange neither end knows where the other’s byte stream begins.
Map each packet to a state edge:
| Packet | Who moves | Edge |
|---|---|---|
| SYN sent | client | CLOSED → SYN_SENT |
| SYN received | server | LISTEN → (half-open) → sends SYN-ACK |
| SYN-ACK received | client | SYN_SENT → ESTABLISHED |
| final ACK received | server | SYN_RECV → ESTABLISHED |
That is the left half of the state diagram made concrete. The close mirrors it, but with FINs, and it takes four packets (FIN, ACK, FIN, ACK) rather than three — because each direction closes independently. That asymmetry is the whole reason the close half of the diagram is a messy diamond of states rather than a single edge; we get to it next.
In the kernel, tcp_rcv_state_process (net/ipv4/tcp_input.c:7119) dispatches on the connection’s current state and only then inspects the flags. The TCP_SYN_SENT and TCP_SYN_RECV cases (the switch is at tcp_input.c:7128, with case TCP_LISTEN at :7133) are the bulk of the function precisely because handshake completion is the most intricate part of TCP control flow.

The graceful-close states
TCP’s connection close is bidirectional: each direction can shut down independently (a “half-close”). This produces the messy diamond of close states. It’s a four-packet exchange — each side sends its own FIN and acknowledges the other’s.
The side that calls close() first goes:
- FIN_WAIT_1 — sent FIN, waiting for ACK or peer’s FIN.
- FIN_WAIT_2 — peer ACKed our FIN; waiting for peer’s FIN.
- TIME_WAIT — peer’s FIN received; ACKed; we wait 2*MSL (twice the Maximum Segment Lifetime) before fully closing.
The side that receives the close goes:
- CLOSE_WAIT — received FIN; app hasn’t called
close()yet. - LAST_ACK — app called
close(); we sent our FIN; waiting for ACK.
If both sides close simultaneously, you can hit:
- CLOSING — sent FIN, peer’s FIN arrived before our ACK; both sides are now closing.
Drivers of state changes
Application-initiated
| Call | Effect |
|---|---|
socket() + listen() | CLOSED → LISTEN |
connect() | CLOSED → SYN_SENT (sends SYN) |
close() from ESTABLISHED | ESTABLISHED → FIN_WAIT_1 (sends FIN) |
close() from CLOSE_WAIT | CLOSE_WAIT → LAST_ACK (sends FIN) |
shutdown(SHUT_WR) | half-close: same as close but socket stays open for reads |
Segment-driven
The big function is tcp_rcv_state_process (net/ipv4/tcp_input.c:7119). It dispatches by current state, then handles flags (the very th->syn/th->fin/TCP_SKB_CB(skb)->tcp_flags bits from Background 1). Pseudocode:
switch (sk->sk_state) {
case TCP_LISTEN: handle SYN -> create child sock in NEW_SYN_RECV
case TCP_SYN_SENT: handle SYN-ACK -> ESTABLISHED, send ACK
case TCP_SYN_RECV: handle ACK -> ESTABLISHED
default: handle FIN, RST, ACKs that drive close states
}
For non-trivial states (ESTABLISHED, FIN_WAIT_*) it falls through to tcp_data_queue for normal data delivery and runs the close-state transitions when a FIN arrives.
Timer-driven
- SYN-ACK retransmit timer: in SYN_SENT and SYN_RECV, the SYN/SYN-ACK is retransmitted with exponential backoff if no progress.
- Retransmit timer (RTO): in ESTABLISHED, fires when an ACK is overdue (Day 17).
- TIME_WAIT timer (2*MSL): ~60s on Linux, hardcoded to
TCP_TIMEWAIT_LEN(60*HZ,include/net/tcp.h:140) and not configurable. This is separate fromnet.ipv4.tcp_fin_timeout, which (despite its name) controls the FIN_WAIT_2 timeout, not TIME_WAIT. - Keepalive timer (if
SO_KEEPALIVE): probes idle connections.
Why TIME_WAIT exists
TIME_WAIT is the most-asked-about TCP state. Two reasons:
- Make sure our last ACK reaches the peer. If our final ACK is lost, the peer’s
LAST_ACKretransmits its FIN. We need to be in TIME_WAIT — still able to send an ACK — to handle that retransmit. If we’d already moved to CLOSED, we’d reply with RST, which the peer would interpret as an error. (This is exactly where Background 1’s “a FIN consumes a sequence number, so it can be individually acknowledged and retransmitted” pays off — the retransmitted FIN is a real, ACK-able segment.) - Prevent old packets from a previous incarnation of the 4-tuple from being delivered to a new connection. Without TIME_WAIT, a freshly-opened connection on the same 4-tuple might receive late-arriving packets from the old connection. MSL (Maximum Segment Lifetime, ~30s) is the maximum lifetime of a single in-flight packet, bounded by IP TTL and router queueing. TIME_WAIT holds for 2*MSL (~60s on Linux) to cover a full round trip — the active closer’s final ACK can take up to one MSL to reach the peer, and if it is lost the peer’s retransmitted FIN takes up to another MSL to come back. After 2*MSL, every old duplicate in both directions has certainly drained.
There are no Dumb Questions
Q: Is SYN-ACK one packet or two?
A: One. It’s a single TCP segment with two control bits set — the SYN bit and the ACK bit — in the one flags field every header carries (Background 1). “SYN-ACK” names a bit combination, not a packet type; there is no separate ACK packet in the handshake’s middle step.
Q:
tcp_fin_timeoutsounds like it controls TIME_WAIT — why doesn’t it?A: The name is misleading.
net.ipv4.tcp_fin_timeoutbounds how long we sit in FIN_WAIT_2 waiting for the peer’s FIN. TIME_WAIT’s duration is the hardcodedTCP_TIMEWAIT_LEN(60*HZ), not a sysctl — you cannot tune it without recompiling.Q: Why 2*MSL and not 1*MSL?
A: Because the worst case is a full round trip, not a one-way trip. Our final ACK may take up to one MSL to reach the peer; if it’s lost, the peer’s retransmitted FIN takes up to another MSL to come back. Waiting 2*MSL guarantees we’re still around to re-ACK that FIN, and that every stray duplicate in both directions has expired.
Background 3: the TIME_WAIT minisock
Here’s a subtlety that the chapter’s headline experiment hinges on, and it’s worth its own section: the socket that enters TIME_WAIT is not the socket you started with.
When a connection enters TIME_WAIT, the kernel does not keep the full struct sock — with its send/receive queues, congestion state, and the rest — pinned in memory for 60 seconds. That would be enormously wasteful on a busy server. Instead it allocates a tiny, stripped-down object: a struct inet_timewait_sock (include/net/inet_timewait_sock.h:33), commonly called a minisock.
The minisock holds only what’s needed to recognise and correctly ACK a retransmitted FIN:
- the connection’s 4-tuple (so the ehash lookup still finds it),
- the last sequence/ack numbers (
tw_rcv_nxt,tw_snd_nxt), - timestamps (for PAWS /
tcp_tw_reuse), - the death-row timer hook that fires after
TCP_TIMEWAIT_LEN.
The exact hand-off: replace the full socket without a lookup gap

tcp_time_wait() performs a controlled replacement:
inet_twsk_alloc()allocates aninet_timewait_sock/tcp_timewait_sockand copies the connection identity plus the limited sequence, receive-window, timestamp, mark, and queue-mapping state needed to classify late segments. It has no TCP data queues or congestion-control state.inet_twsk_hashdance_schedule()adds the minisock to bind ownership, then under the ehash lock atomically replaces the full socket’s ehash node with the minisock’s node. Lookups for the same namespace and 4-tuple therefore continue to find an object without a gap. The minisock receives references for its bind link, ehash link, and timer, and the timer is armed forTCP_TIMEWAIT_LENwhen this is true TIME_WAIT.- Back in
tcp_time_wait(),tcp_done(sk)moves the original full socket to TCP_CLOSE. Its memory is released only after its remaining references drain. This is whyfentry:tcp_set_stateon the full socket shows… → 5 → 7, never→ 6; the minisock was initialized directly withTCP_TIME_WAITand does not pass throughtcp_set_state. - A later segment still hits ehash first (Day 13).
tcp_v4_rcvsees the returned object’sTCP_TIME_WAITstate and callstcp_timewait_state_process, which may request an ACK, request a reset, consume the segment, or allow an eligible SYN to be retried against a listener after removing the old minisock. Timer expiry eventually unhashes and frees it.
This also gives the cost of TIME_WAIT a concrete shape: each TIME_WAIT consumes one small minisock plus one ehash slot. That’s why a busy short-connection server (HTTP, microservices) accumulates tens of thousands of them, and why there’s a cap — tcp_max_tw_buckets — on the total.
And there’s a failure mode worth knowing: if inet_twsk_alloc fails (out of memory, or the bucket cap is hit), there is no minisock to retransmit-ACK with. The else branch in tcp_time_wait simply bumps NET_INC_STATS(net, LINUX_MIB_TCPTIMEWAITOVERFLOW) and falls through to tcp_done(sk) — the connection skips TIME_WAIT entirely and closes immediately, sending no RST. That counter is exactly what you’ll hammer in the “What to break” lab.
TIME_WAIT cost
Recall from Background 3 that each TIME_WAIT costs one small minisock plus one ehash slot — so a busy short-connection server (HTTP, microservices) can accumulate tens of thousands of them. The mitigations:
SO_REUSEADDRlets a new socket bind even if a recent connection’s TIME_WAIT entry occupies the 4-tuple.net.ipv4.tcp_max_tw_bucketscaps the global TIME_WAIT count. Overflow follows the exact skip-TIME_WAIT path from Background 3 (no minisock, no RST;TCPTimeWaitOverflowincrements). The default isehash_entries / 2, so it scales with system memory (often tens to hundreds of thousands).net.ipv4.tcp_tw_reuse=1lets the kernel reuse TIME_WAIT sockets for new outgoing connections (uses TCP timestamps to ensure no overlap). Safe for client-side; doesn’t affect listening sockets.- (
was removed in 4.12 — was unsafe behind NAT.)tcp_tw_recycle
FIN_WAIT_2 and the 60-second timeout
If a peer ACKs our FIN but never sends its own FIN (perhaps the application is hung), we sit in FIN_WAIT_2. To avoid leaking, the kernel times out after net.ipv4.tcp_fin_timeout seconds (default 60) and force-closes. This is why “broken peers” don’t break us forever.
Today’s experiment
# Watch state in real time
watch -n 0.5 'ss -tan'
# In another terminal
nc -l 9999 &
sleep 0.5
nc localhost 9999 # both ends in ESTAB
# In each: Ctrl-D to close
# You'll see:
# initial: LISTEN (server)
# connect: ESTAB (both)
# nc client closes: FIN_WAIT_2 (client) / CLOSE_WAIT (server) briefly
# nc server closes: TIME_WAIT (client) / CLOSED (server)
# 60s later: TIME_WAIT entry expires
Trace state transitions. This probe fires for every socket system-wide but nothing drives it on an idle box, so leave it running and, in another terminal, re-run the nc -l 9999 & / echo q | nc -q 0 localhost 9999 listener+client pair from the force-close block below to provoke transitions. The interval:s:10 bound makes it exit cleanly after 10s:
sudo bpftrace -e '
fentry:tcp_set_state {
printf("sk=%p state=%d -> %d\n", args->sk, args->sk->__sk_common.skc_state, args->state);
}
interval:s:10 { exit(); }'
The integers are the tcp_states.h enum values from above: 1=ESTABLISHED, 2=SYN_SENT, 3=SYN_RECV, 4=FIN_WAIT1, 5=FIN_WAIT2, 6=TIME_WAIT, 7=CLOSE, 8=CLOSE_WAIT, 9=LAST_ACK. Because the probe is system-wide you’ll see the client’s and the server’s transitions interleaved, each tagged with the kernel’s sk pointer. A client connect/close prints roughly:
sk=0xffff… state=7 -> 2 # CLOSE -> SYN_SENT (client connect)
sk=0xffff… state=2 -> 1 # SYN_SENT -> ESTABLISHED
sk=0xffff… state=1 -> 4 # ESTABLISHED -> FIN_WAIT1 (active closer)
sk=0xffff… state=4 -> 5 # FIN_WAIT1 -> FIN_WAIT2
sk=0xffff… state=5 -> 7 # FIN_WAIT2 -> CLOSE
Note you will not see a transition into TIME_WAIT (state 6) here — the chapter’s headline state. This is Background 3 made visible: entering TIME_WAIT spins up a separate minisock and calls tcp_done(), which sets the original socket to TCP_CLOSE (7). So tcp_set_state on the full socket goes … -> 5 -> 7, never -> 6 — state 6 lives on the minisock, a different object the probe isn’t attached to. To watch TIME_WAIT itself, use ss -tan as below.
Force-close to see TIME_WAIT
# Server accepts one connection then exits; client (nc -q 0) closes first -> client TIME_WAIT
nc -l 9999 &
sleep 0.1
echo q | nc -q 0 localhost 9999
# Immediately:
ss -tan | grep 9999
# tcp TIME-WAIT 0 0 127.0.0.1:NNNN 127.0.0.1:9999 # Local = ephemeral client port, Peer = :9999
Observe: the client is the active closer (nc -q 0 closes as soon as stdin hits EOF), so its ephemeral port sits in TIME_WAIT for ~60s — note ss prints Local Address:Port first, then Peer Address:Port, so the ephemeral port is Local and :9999 is Peer. Plain nc -l accepts a single connection and exits when it closes, so only the client’s TIME-WAIT remains (no LISTEN row). The server side returns to CLOSED immediately.
What to break
-
Set
tcp_fin_timeout=5, then drive a connection into FIN_WAIT_2 — a state you reach only when the peer ACKs your FIN but never sends its own. A plainncclose won’t do it (the peer reciprocates with its own FIN), so use a listener that accepts but never closes:sudo sysctl -w net.ipv4.tcp_fin_timeout=5 # Listener that accepts but never closes (its kernel ACKs the FIN; the app sends none back): python3 -c 'import socket,time; s=socket.socket(); s.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1); s.bind(("127.0.0.1",9999)); s.listen(); c,_=s.accept(); time.sleep(600)' & sleep 0.3 # Client connects then closes its write side -> FIN_WAIT_1, then FIN_WAIT_2 after the server's ACK: exec 3<>/dev/tcp/127.0.0.1/9999; exec 3>&- watch -n0.5 'ss -tan state fin-wait-2' # the FIN_WAIT_2 row appears, then vanishes after ~5s sudo sysctl -w net.ipv4.tcp_fin_timeout=60 # restore the defaultUseful for diagnosing connection leaks.
-
Hammer
tcp_max_tw_buckets: cap the global TIME_WAIT count and watch the overflow counter climb. This is theLINUX_MIB_TCPTIMEWAITOVERFLOWpath from Background 3 — once the cap is hit,inet_twsk_allocis refused, no minisock is built, and the close skips TIME_WAIT. Capture the default first so you can restore it:orig=$(cat /proc/sys/net/ipv4/tcp_max_tw_buckets) sudo sysctl -w net.ipv4.tcp_max_tw_buckets=100 nc -l -k 9999 & # persistent listener (OpenBSD nc -k) nstat -az TcpExtTCPTimeWaitOverflow # baseline (-z shows zero-valued counters) for i in $(seq 1 500); do echo q | nc -q 0 localhost 9999; done nstat -az TcpExtTCPTimeWaitOverflow # the count has risen (e.g. ~400) sudo sysctl -w net.ipv4.tcp_max_tw_buckets=$orig # restore kill %1 # stop the listenerOnce the cap is hit, new closes skip TIME_WAIT and close immediately (no RST is sent), so
ss -tan | grep TIME-WAIT | wc -lstays low — the risingTCPTimeWaitOverflowcounter, not the visible TIME_WAIT count, is the reliable signal. -
Try
tcp_tw_reuse=1for outgoing-only workloads: it lets the kernel reuse local TIME_WAIT slots for new outgoing connections (requires TCP timestamps), relieving bucket pressure for clients that connect frequently to the same server (test clients, benchmarking tools). Make it observable:sudo sysctl -w net.ipv4.tcp_tw_reuse=1 nc -l -k 9999 & watch -n0.5 'ss -tan state time-wait | wc -l' # in one terminal # in another, open many short outgoing connections to the same server: for i in $(seq 1 2000); do echo q | nc -q 0 localhost 9999; done # the TIME_WAIT count stays bounded as the kernel reuses slots for new connects sudo sysctl -w net.ipv4.tcp_tw_reuse=2 # restore the default
What to read in the kernel
-
include/uapi/linux/tcp.h:25—struct tcphdr. The on-the-wire header. See thefin:1, syn:1, rst:1, psh:1, ack:1bitfields and theseq/ack_seq__be32s above them. This is where the state machine’s events physically live. -
include/net/tcp.h:1050— theTCPHDR_*BIT()flag constants,tcp_flags_ntohs(:1062), andstruct tcp_skb_cb’stcp_flagsfield (:1115) reached viaTCP_SKB_CB(skb)(:1149). This is the pre-extracted-flags path the input code switches on. -
include/net/tcp_states.h:13— the state enum. Tiny file (~50 lines). Read it to know the canonical names and the integer order the bpftrace decode relies on. -
net/ipv4/tcp_input.c:7119—tcp_rcv_state_process. The big state-machine function (~300 lines including all branches). Read it once with the diagram open. Notice: it dispatches by current state (switch (sk->sk_state)) and handles each state’s possible incoming events. The bulk is the SYN_SENT and SYN_RECV cases (handshake completion); the other states share a fall-through to data processing plus close-flag handling. -
net/ipv4/tcp.c:2961—tcp_set_state. The explicit state change function. Note the per-state SNMP counter increments (TCP_INC_STATS); this is hownstatreportsTcp.CurrEstab, etc. Also note the ehash insert/remove on transitions to/from ESTABLISHED. -
net/ipv4/tcp.c:3310—tcp_close. What happens when the application callsclose(). This is a thin wrapper; read the substantive ~170-line implementation in__tcp_close(net/ipv4/tcp.c:3138) end-to-end. Notice the LINGER handling, the multi-step state transitions (ESTABLISHED → FIN_WAIT_1, etc.), and the inline receive-queue flush: it walksskb_peek(&sk->sk_receive_queue)freeing any unread skbs, and if data was still unread it sends an RST instead of a graceful FIN (data_was_unread→tcp_send_active_reset) — the RST-vs-FIN choice from Background 1. -
net/ipv4/tcp_ipv4.c:2068—tcp_v4_rcv. The IP-layer entry. Looks up the sock via the ehash (4-tuple match) and dispatches into the state machine. Read this to see how a packet arrives, gets associated with a sock, and proceeds. -
net/ipv4/tcp_minisocks.c:326—tcp_time_wait, the minisock conversion from Background 3. TIME_WAIT sockets are reduced sock objects (struct inet_timewait_sock,include/net/inet_timewait_sock.h:33) holding just enough to ACK retransmitted FINs. Watch it copytw_rcv_nxt/tw_snd_nxt, hashdance the minisock into the ehash, bumpLINUX_MIB_TCPTIMEWAITOVERFLOWon alloc failure, and finish the original sock withtcp_done(sk). -
net/ipv4/tcp_timer.c— all TCP timers.tcp_keepalive_timer,tcp_compressed_ack_kick, etc. Useful when a state is stuck and you wonder which timer should be advancing it. -
Documentation/networking/ip-sysctl.rst(TCP section) andDocumentation/networking/proc_net_tcp.rst— sysctl reference and the/proc/net/tcpformat. Brief.
Bullet Points
- A TCP segment is a 20-byte
struct tcphdr(include/uapi/linux/tcp.h:25) + options + data. The state machine reacts to independent single-bit flags in it —syn,ack,fin,rst,psh— so SYN-ACK is one segment with two bits set, the final handshake packet is a bare ACK, and FIN rides as FIN+ACK. - The kernel reads flags two ways: off the wire (
th->syn,tcp_flags_ntohs) and pre-extracted inTCP_SKB_CB(skb)->tcp_flags. TheTCPHDR_*BIT()constants name each bit (include/net/tcp.h:1050). - SYN and FIN each consume one sequence number — that’s what lets a FIN be individually ACKed and retransmitted, which underpins TIME_WAIT. RST consumes none, isn’t ACKed, and aborts immediately.
- Open = 3 packets (SYN → SYN-ACK → ACK); close = 4 packets (FIN, ACK, FIN, ACK), because each direction closes independently — hence the diamond of close states.
- 11 TCP states in
include/net/tcp_states.h. ESTABLISHED is the steady state; rest are open or close transitions. tcp_rcv_state_processdrives segment-driven transitions;tcp_set_statedoes explicit changes.- TIME_WAIT runs on a minisock (
inet_timewait_sock), not the originalstruct sock; the original is set toTCP_CLOSEviatcp_done(). Holds for2*MSL(~60 s,TCP_TIMEWAIT_LEN). Two purposes: (1) ACK retransmitted FIN, (2) prevent reincarnation overlap. tcp_fin_timeoutcontrols FIN_WAIT_2 timeout (default 60), not TIME_WAIT.tcp_max_tw_bucketscaps global TIME_WAIT count; overflow bumpsTCPTimeWaitOverflowand skips TIME_WAIT (no minisock, no RST).tcp_tw_reuse=1lets clients reuse TIME_WAIT slots for new connects (safe).- Inspect with
ss -tan. Trace transitions withbpftrace fentry:tcp_set_state(remember: you won’t see state 6 on the original sock).
Check question
Why does TIME_WAIT exist? What problem does it solve, and why specifically 2*MSL seconds?
Click to reveal answer
Answer: Two distinct problems. (1) Final-ACK reliability. When we close, our final ACK might be lost; the peer (in LAST_ACK) will retransmit its FIN. To respond with another ACK we must still exist as a TCP entity — TIME_WAIT keeps us responsive. If we’d already moved to CLOSED, we’d send RST, which the peer would interpret as an error. (This works because a FIN consumes a sequence number, so it’s a real, acknowledge-able, retransmittable segment.) (2) Reincarnation safety. A new connection on the same 4-tuple could be created shortly after the old one closes. If old packets are still in flight in the network, they could be misdelivered to the new connection. Here MSL (“Maximum Segment Lifetime”, ~30s) is the worst-case time a single segment can survive in the network (bounded by IP TTL and router queueing). TIME_WAIT waits 2*MSL (~60s on Linux) — one full round trip — because the active closer’s final ACK can take up to one MSL to arrive, and if it is lost the peer’s retransmitted FIN takes up to another MSL to come back. After 2*MSL, every old duplicate in both directions has certainly been discarded; the 4-tuple is safe to reuse.
Tomorrow
Day 16: TCP congestion control. CUBIC, BBR, and the framework that lets you swap algorithms.
Day 16 — TCP congestion control: CUBIC, BBR, the framework
Today’s mission: understand how a TCP connection decides “how much to send,” learn the cwnd-dynamics machinery (slow start, AIMD, the sawtooth, ECN, pacing) that every algorithm is a variation on, see the in-tree algorithms compete on the same workload, and grasp the kernel’s pluggable framework that lets you swap them at runtime. Total time: ~110 minutes.
Why congestion control exists
A TCP sender has two limits on how much data it can have in flight at once:
- The receive window (
snd_wndon the sender side) — how much the receiver says it can buffer, as recorded on the sender. Communicated in every TCP header. Easy. (The mirror fieldrcv_wndis what this socket advertises to its peer for the reverse direction.) - The congestion window (
snd_cwnd) — how much the network between us and the receiver can absorb without dropping packets. Not communicated; the sender estimates from observed loss and RTT.
The congestion-control algorithm is what computes snd_cwnd. Send too little and you waste bandwidth. Send too much and you cause loss, which forces retransmits, wastes more bandwidth, and starves other flows. CC is the tradeoff.
(All three fields are real: the peer-receiver’s advertised window lives in snd_wnd — “the window we expect to receive” — at include/linux/tcp.h:223; this host’s own advertised window is rcv_wnd at :318; and snd_cwnd at :225. The kernel gates new sends by snd_wnd: tcp_wnd_end() = snd_una + snd_wnd at include/net/tcp.h:1569.)
Back on Day 3 we deliberately deferred the mechanics of how snd_cwnd grows and collapses. Today we pay that debt. Before we can read the pluggable framework — the vtable of callbacks every algorithm fills in — we need the model those callbacks operate on. So the chapter front-loads five pieces of background, intuition first, then the concrete v7.1 struct or function:
- How
cwndactually evolves: slow start, congestion avoidance, AIMD, the sawtooth. - The congestion-control state machine (
ca_state) — not the connection state machine from Day 15. - ECN — how the network signals congestion without dropping a packet.
- RTT estimation: the smoothed RTT (
srtt) and its variance thatssprints. - Pacing and bufferbloat — the two ideas BBR is built on.
Only then do we open struct tcp_congestion_ops, and every field will already make sense.
Background 1: how cwnd actually evolves — slow start, AIMD, the sawtooth
Here is the whole game in one picture. A connection has no idea how fast the path is, so it probes: start small, speed up until something breaks, back off, repeat. The “something breaks” signal for classic TCP is packet loss. Everything else — the exact ramp shape, what counts as “breaks,” how far to back off — is a variation on this loop.

cwnd is measured in packets, not bytes
One thing to get straight first: on Linux snd_cwnd is counted in segments (packets), not bytes. A fresh connection starts at TCP_INIT_CWND = 10 (include/net/tcp.h:269) — ten segments may be outstanding before the first ACK comes back. We made this point on Day 3; here we just lean on it: when we say “cwnd doubles,” we mean the packet count doubles.
Phase 1: slow start (which is exponential, despite the name)
A new connection enters slow start. The rule is simple: for every segment an ACK acknowledges, bump cwnd by one. Acknowledge ten segments, cwnd goes from 10 to 20; next round-trip it’s 40, then 80… So “slow” is historical — the growth is actually exponential, doubling every RTT. The point of slow start is to find the path’s capacity quickly without firing a huge burst on the very first RTT.
The kernel’s helper is tcp_slow_start (net/ipv4/tcp_cong.c:456):
__bpf_kfunc u32 tcp_slow_start(struct tcp_sock *tp, u32 acked)
{
u32 cwnd = min(tcp_snd_cwnd(tp) + acked, tp->snd_ssthresh);
acked -= cwnd - tcp_snd_cwnd(tp); /* leftover ACKs */
tcp_snd_cwnd_set(tp, min(cwnd, tp->snd_cwnd_clamp));
return acked; /* spill into congestion avoidance */
}
It adds acked straight onto cwnd — but only up to snd_ssthresh, the slow-start threshold (include/linux/tcp.h:248). That ceiling is the boundary between the two phases.
Phase 2: congestion avoidance (which is linear)
Once cwnd reaches snd_ssthresh, the connection switches to congestion avoidance, where growth becomes linear: roughly +1 segment per RTT. The test that picks the phase is exactly one comparison, tcp_in_slow_start (include/net/tcp.h:1520):
static inline bool tcp_in_slow_start(const struct tcp_sock *tp)
{
return tcp_snd_cwnd(tp) < tp->snd_ssthresh;
}
Below the threshold → slow start (exponential). At or above it → congestion avoidance (linear).
Linear “+1 per RTT” is trickier than it sounds, because the kernel processes one ACK at a time, not one RTT at a time. The generic helper tcp_cong_avoid_ai (“ai” = additive increase, net/ipv4/tcp_cong.c:470) implements it with a fractional accumulator, snd_cwnd_cnt (“Linear increase counter,” include/linux/tcp.h:425):
__bpf_kfunc void tcp_cong_avoid_ai(struct tcp_sock *tp, u32 w, u32 acked)
{
...
tp->snd_cwnd_cnt += acked;
if (tp->snd_cwnd_cnt >= w) {
u32 delta = tp->snd_cwnd_cnt / w;
tp->snd_cwnd_cnt -= delta * w;
tcp_snd_cwnd_set(tp, tcp_snd_cwnd(tp) + delta);
}
...
}
It takes w ACKs to bump cwnd by one. Pass w = cwnd and you get the classic “+1 per RTT”: it takes a full window of ACKs to add one segment.
AIMD: Additive Increase / Multiplicative Decrease
Put the two responses together and you have AIMD, the rule at the heart of classic TCP:
-
Increase (additive): +1 segment per RTT in congestion avoidance — the slow, careful climb above.
-
Decrease (multiplicative): on loss, halve. Reno’s
ssthreshcallback computes the new threshold astcp_reno_ssthresh(net/ipv4/tcp_cong.c:515):__bpf_kfunc u32 tcp_reno_ssthresh(struct sock *sk) { const struct tcp_sock *tp = tcp_sk(sk); return max(tcp_snd_cwnd(tp) >> 1U, 2U); /* cwnd/2, floor 2 */ }cwnd >> 1iscwnd / 2— a multiplicative cut, with a floor of 2 so the connection never stalls completely.
This is the famous TCP sawtooth: climb linearly, halve on loss, climb again, halve again. You can see both halves wired together in the Reno reference, tcp_reno_cong_avoid (net/ipv4/tcp_cong.c:496) — slow-start if below threshold, else additive increase:
__bpf_kfunc void tcp_reno_cong_avoid(struct sock *sk, u32 ack, u32 acked)
{
struct tcp_sock *tp = tcp_sk(sk);
if (!tcp_is_cwnd_limited(sk))
return;
if (tcp_in_slow_start(tp)) { /* exponential phase */
acked = tcp_slow_start(tp, acked);
if (!acked)
return;
}
tcp_cong_avoid_ai(tp, tcp_snd_cwnd(tp), acked); /* linear phase */
}
This is what “everything else is a variant” actually means. CUBIC keeps the AIMD skeleton but replaces the linear climb with a cubic curve (aggressive right after a loss, plateauing near the previous maximum, then probing upward). BBR throws out the loss-driven loop entirely and replaces it with a bandwidth model. But Reno is the shape to hold in your head.
And now the two most important callbacks in today’s vtable already have meaning:
cong_avoidruns the increase side — it decides slow-start-vs-avoidance and growscwnd.ssthreshruns the decrease side — it computes the new threshold after a loss.
Background 2: the congestion-control state machine (ca_state)
The vtable also has set_state(sk, new_state) and the runtime path says “on loss the kernel calls set_state(sk, CA_Loss).” That CA_Loss belongs to a state machine you have not met.
It is not the Day-15 connection state machine. A socket can sit in ESTABLISHED for hours; ca_state is a separate, orthogonal tracker for where the connection is in the loss/recovery cycle. The five values (enum tcp_ca_state, include/uapi/linux/tcp.h:194):
TCP_CA_Open(0) — normal. No reordering, no loss, no ECN marks recently.TCP_CA_Disorder(1) — a few duplicate ACKs / SACKs. Something looks off, but not yet a confirmed loss.TCP_CA_CWR(2) — Congestion Window Reduced. The sender is gently reducingcwnd, e.g. in response to an ECN mark (Background 3).TCP_CA_Recovery(3) — fast retransmit / fast recovery in progress (a loss was inferred from dup-ACKs, not a timeout).TCP_CA_Loss(4) — a retransmit timeout (RTO) fired. The worst case: full retransmit.
The kernel calls set_state(sk, new_state) to tell the algorithm it has moved between these. Why does an algorithm care? Because entering Recovery/Loss is exactly when ssthresh() is called and cwnd is cut, and leaving it is when the algorithm may resume growth. This is the hook by which CUBIC remembers its pre-loss maximum (so it can plateau there) and BBR knows not to overreact to a single loss.

ca_event: a finer-grained notification stream
Alongside whole state changes, the kernel emits ca_event notifications (enum tcp_ca_event, include/net/tcp.h:1242) through the cwnd_event callback:
enum tcp_ca_event {
CA_EVENT_TX_START, /* first transmit when no packets in flight */
CA_EVENT_CWND_RESTART, /* congestion window restart */
CA_EVENT_COMPLETE_CWR, /* end of congestion recovery */
CA_EVENT_LOSS, /* loss timeout */
CA_EVENT_ECN_NO_CE, /* ECT set, but not CE marked */
CA_EVENT_ECN_IS_CE, /* received CE marked IP packet */
};
The last two — CA_EVENT_ECN_NO_CE / CA_EVENT_ECN_IS_CE — are the ECN events DCTCP lives on. Hold that thought; Background 3 explains the marks, and the DCTCP capsule later ties it together.
The deep dive — RTO timers, fast retransmit, RACK, the recovery machinery — is tomorrow (Day 17). Today you just need the vocabulary so the callbacks aren’t opaque.
Background 3: ECN — Explicit Congestion Notification
The DCTCP algorithm reacts to congestion before any packet is dropped. That sounds impossible — how does a sender learn the network is congested if nothing was lost? The answer is ECN, and it’s the one mechanism in this chapter that crosses both the IP and TCP headers.
On Day 9 you saw that the low 2 bits of the IP TOS/DSCP byte are the ECN bits, deliberately excluded from rule matching. Here’s what they do.
Step 1: the sender marks the IP packet ECT
A sender that supports ECN sets those 2 bits to ECT (ECN-Capable Transport), advertising “I understand congestion marks — mark me instead of dropping me.” The values live in include/net/inet_ecn.h:14:
enum {
INET_ECN_NOT_ECT = 0,
INET_ECN_ECT_1 = 1,
INET_ECN_ECT_0 = 2,
INET_ECN_CE = 3,
INET_ECN_MASK = 3,
};
Step 2: a congested switch rewrites ECT → CE
When a router or switch’s queue is building, instead of dropping an ECT packet it flips the bits to INET_ECN_CE — Congestion Experienced (= 3). The test is INET_ECN_is_ce() (include/net/inet_ecn.h:23):
static inline int INET_ECN_is_ce(__u8 dsfield)
{
return (dsfield & INET_ECN_MASK) == INET_ECN_CE;
}
This is the key idea behind “reacts before loss”: congestion is signaled without a drop.
Step 3: the receiver echoes it back with ECE; the sender confirms with CWR
A CE mark is in the IP header travelling toward the receiver — but it’s the sender that needs to slow down. So the receiver echoes it back using a TCP header flag, ECE (ECN-Echo), and the sender acknowledges that it reduced its window with the CWR (Congestion Window Reduced) flag. Those flags (include/net/tcp.h:1056):
#define TCPHDR_ECE BIT(6)
#define TCPHDR_CWR BIT(7)
That ECE ↔ CWR handshake is what turns a one-way IP mark into a closed-loop signal the sender can act on.

Step 4: how it reaches the algorithm
There are two distinct paths into the algorithm, and it’s worth keeping them apart:
- ECE on an incoming ACK (the sender side learning it should slow down). When an ACK carries the ECE bit, the kernel sets the
CA_ACK_ECEflag (net/ipv4/tcp_input.c:4154;enum tcp_ca_ack_event_flags,include/net/tcp.h:1255) and passes it to thein_ack_eventcallback. DCTCP’s handler,dctcp_update_alpha(net/ipv4/tcp_dctcp.c:127, wired as.in_ack_eventat:257), recomputes alpha each RTT fromtp->delivered_ce/tp->delivered— a running estimate of the fraction of packets that got marked — and scales itscwndcut by that fraction. A little marking → a little cut; heavy marking → a big cut. That proportional response is why DCTCP hooksin_ack_eventinstead ofcong_avoid. - Receiving a CE-marked IP packet (the data-receive side, deciding whether to echo ECE).
tcp_ecn_check_ce()firesCA_EVENT_ECN_IS_CE/CA_EVENT_ECN_NO_CE(net/ipv4/tcp_input.c:362/:380) through thecwnd_eventcallback, which DCTCP catches indctcp_cwnd_event(net/ipv4/tcp_dctcp.c:193, wired as.cwnd_eventat:258) to update itsce_state. This path is about echoing ECE, not about the alpha estimate.
So the alpha estimate that drives DCTCP’s proportional cut comes from the in_ack_event/delivered_ce path, not from CA_EVENT_ECN_IS_CE.
And it’s why DCTCP is datacenter-only. Classic Reno/CUBIC treat any congestion signal as a full multiplicative cut; DCTCP reacts proportionally. Mix the two on the same path and they share unfairly — which grounds the “must not be used on the public Internet” rule in the DCTCP capsule below.
Background 4: RTT estimation — smoothed RTT and its variance
The pkts_acked callback hands the algorithm “the latest RTT measurement,” and the ss -tin lab tells you to read rtt:<srtt>/<rttvar>. Two numbers — what are they?
RTT (round-trip time) is measured per ACK: the time from when a segment was sent to when it was acknowledged. That raw sample is what pkts_acked delivers, via struct ack_sample (include/net/tcp.h:1283):
struct ack_sample {
u32 pkts_acked;
s32 rtt_us; /* the raw RTT measurement, microseconds */
u32 in_flight;
};
But the kernel does not act on raw samples — they’re jittery. It keeps an exponentially-weighted moving average, srtt_us (“smoothed round trip time,” include/linux/tcp.h:307), plus rttvar_us, the smoothed deviation (:243; the underlying mean-deviation accumulator is mdev_us at :272). ss prints these as rtt:<srtt>/<rttvar>.
One gotcha when you compare ss output to raw struct values: the comment says srtt_us is “smoothed round trip time << 3 in usecs” — it’s stored left-shifted by 3 (i.e. ×8) for fixed-point precision. ss already divides it back out, so the number it prints is the real millisecond figure; the in-kernel field is 8× larger.
Why CC cares: the minimum RTT is the path’s propagation delay with no queue. BBR maintains its own min_rtt_us (net/ipv4/tcp_bbr.c:91, a plain u32) with a time-windowed minimum in bbr_update_min_rtt (net/ipv4/tcp_bbr.c:942) over a ~10s window — seeded once from the core stack’s tcp_min_rtt(tp) at init (net/ipv4/tcp_bbr.c:1054), then run as a separate filter. The generic struct minmax rtt_min (include/linux/tcp.h:249) is the core stack’s windowed-minimum estimator, updated in tcp_update_rtt_min and read via tcp_min_rtt(). (The struct minmax inside BBR is bbr->bw, the bandwidth estimate — not min RTT.) When srtt rises above min RTT, that gap is a standing queue building up — the signal Background 5 is about.

The full RTO/timer math is Day 17; today we only need the smoothing and the two ss fields.
Background 5: pacing and bufferbloat (the BBR prerequisites)
BBR “paces the sender at the estimated bandwidth” and “copes with bufferbloat.” Two terms, neither yet defined.
Pacing: spread the window out in time
When an ACK opens room in the window, a classic loss-based CC fires the newly-allowed packets back-to-back, instantly — a burst — and relies on the network to absorb it. Pacing instead spreads a window’s worth of packets evenly across the RTT. BBR computes a target send rate (bytes/sec) from its bandwidth estimate, and the kernel spaces packets in time to match. This matters for BBR specifically: if BBR sent bursts, those bursts would themselves inflate the measured RTT and corrupt the very bandwidth estimate BBR depends on.

Where pacing happens. Recall the qdisc from Day 3 — the per-TX-queue software scheduler. The fq qdisc is the one that can stamp each packet with a target departure time and release it then. That’s why BBR “needs fq (or hardware pacing).” Since Linux 4.13 there’s also an internal fallback in the TCP stack, driven by sk->sk_pacing_status (include/net/sock.h:506), whose values are (include/net/sock.h:612):
enum sk_pacing {
SK_PACING_NONE = 0,
SK_PACING_NEEDED = 1,
SK_PACING_FQ = 2,
};
When the egress qdisc is fq, the status is SK_PACING_FQ and fq does the pacing. When it isn’t, the TCP stack paces internally (less precise, but functional) — which is exactly why today’s loopback netem experiment still demonstrates BBR even though lo’s qdisc isn’t fq.
Bufferbloat: buffers so big they hide the loss
A loss-based CC only backs off when a packet is dropped, and a packet is only dropped when a buffer overflows. So on a path with an oversized router/switch buffer, CUBIC keeps pushing — filling that buffer with tens of milliseconds of standing queue — and doesn’t stop until the buffer finally overflows. The buffer is “bloated”; latency is terrible even though throughput looks fine. Loss-based CCs don’t just tolerate bufferbloat, they actively cause it.
BBR’s model sidesteps this. By tracking min_rtt_us (propagation delay) and max bandwidth separately, it watches for the tell: RTT rising while bandwidth stays flat means the queue is building. BBR backs off then — before the drop — instead of waiting for the overflow. That’s the substance behind “copes with bufferbloat better than CUBIC.” bbr_max_bw() (net/ipv4/tcp_bbr.c:216) and min_rtt_us are the two halves of that model; bbr_main (net/ipv4/tcp_bbr.c:1028, wired as .cong_control at :1149) drives it per ACK.
(BDP — the bandwidth-delay product — and the qdisc concept itself were covered on Day 3; we’re not re-deriving them.)
The framework: struct tcp_congestion_ops

Now the vtable is legible. This is the same “ops struct = vtable” pattern you already know — just like struct Qdisc_ops on Day 3 (the per-TX-queue qdisc interface) or the *_ops structs underlying sockets on Day 13: each algorithm provides an instance, registers it, and the core stack dispatches polymorphically through the function pointers. Swapping algorithms is swapping which struct a socket points at. We won’t re-teach the pattern.
What is new to CC is one rule the v7.1 struct comment states explicitly: an algorithm provides either cong_avoid XOR cong_control, never both. Defined in include/net/tcp.h:1316:
struct tcp_congestion_ops {
/* A congestion control (CC) must provide one of either:
*
* (a) a cong_avoid function, if the CC wants the core TCP stack's
* default "classic" (Reno/CUBIC-style) loss response, ECN, pacing
* rate computations, etc. -> runs the Background-1 increase side.
*
* (b) a cong_control function, for custom behavior with complete
* control of all congestion-control behaviors. BBR uses this.
*/
void (*cong_avoid)(struct sock *sk, u32 ack, u32 acked);
void (*cong_control)(struct sock *sk, u32 ack, int flag,
const struct rate_sample *rs);
/* return slow start threshold (required) — the Background-1 decrease side. */
u32 (*ssthresh)(struct sock *sk);
/* call before changing ca_state (Background 2). */
void (*set_state)(struct sock *sk, u8 new_state);
/* call when a cwnd event occurs (Background 2's ca_event stream). */
void (*cwnd_event)(struct sock *sk, enum tcp_ca_event ev);
/* call on every ACK — DCTCP consumes ECN marks here (Background 3). */
void (*in_ack_event)(struct sock *sk, u32 flags);
/* per-ACK RTT/ECN feedback via struct ack_sample (Background 4). */
void (*pkts_acked)(struct sock *sk, const struct ack_sample *sample);
/* new value of cwnd after loss (required). */
u32 (*undo_cwnd)(struct sock *sk);
char name[TCP_CA_NAME_MAX];
struct module *owner;
struct list_head list; /* registry */
/* ... */
};
So the callback-to-phase mapping you built up is exactly the API surface: cong_avoid = increase side, ssthresh = decrease side, set_state/cwnd_event = the ca_state/ca_event machine, in_ack_event = the ECN hook, pkts_acked = the RTT feedback. A classic CC fills in cong_avoid + ssthresh; a model-based CC like BBR fills in cong_control instead and ignores the loss-response defaults.
Each algorithm registers an instance via tcp_register_congestion_control (net/ipv4/tcp_cong.c:93). The kernel maintains a global list; selection by name walks it via tcp_ca_find (net/ipv4/tcp_cong.c:26).
In-tree algorithms
Reno — the original
net/ipv4/tcp_cong.c:531 — the reference and default fallback. Pure AIMD, exactly as Background 1 built it: slow start doubles cwnd per RTT, congestion avoidance adds +1 per RTT, loss halves it. The classical TCP behavior; everything else is a variant.
CUBIC
net/ipv4/tcp_cubic.c:475 — the Linux default for ~20 years (default since kernel 2.6.19 in 2006, and still the default in 7.1). Keeps AIMD’s loss response but replaces Background 1’s linear climb with a cubic function: it grows aggressively right after a loss, plateaus near the previous maximum (which it remembers across the ca_state transition), then slowly probes upward. Designed for high-BDP networks (long fat pipes) where Reno’s linear growth was too slow.
- What: loss-based AIMD with a cubic growth curve.
- Why: Reno underutilizes long-RTT links; CUBIC fills them faster.
- When: general-purpose default. Still excellent for typical internet workloads.
- Gotcha: still loss-based — can be unfair against neighbors using BBR (which doesn’t need loss to back off).
The math lives in bictcp_update (net/ipv4/tcp_cubic.c:211), called from cubictcp_cong_avoid (net/ipv4/tcp_cubic.c:321).
BBR — Bottleneck Bandwidth and Round-trip propagation time
net/ipv4/tcp_bbr.c:1144 — Google’s algorithm (RFC draft, ~2016, in-tree since 4.9). Doesn’t use loss as a signal at all; instead it estimates the bottleneck bandwidth and minimum RTT (Background 4) and paces the sender at that bandwidth (Background 5). It fills in cong_control (bbr_main), not cong_avoid.
- What: model-based — track recent max bandwidth and min RTT, send at the estimated BDP, paced.
- Why: copes with bufferbloat (Background 5) better than CUBIC; achieves higher throughput on lossy paths.
- When: WAN, satellite, mobile, anywhere with high BDP and lossy paths. Used heavily by Google’s CDN/services.
- Gotcha: can be unfair to neighbor CUBIC flows — BBR’s bandwidth estimate doesn’t react to loss the way CUBIC does, so it can take more than its fair share. Mitigated in BBR v2/v3 (in some kernels).
- Pacing requirement: BBR needs
fqqdisc (or hardware pacing) to pace packets at the estimated rate (Background 5). Withoutfq, the internalsk_pacing_statusfallback applies, but precision drops.
DCTCP — Data Center TCP
net/ipv4/tcp_dctcp.c:255. Uses ECN-CE marks (Background 3) instead of loss as the congestion signal — switches mark before they drop, and DCTCP reads the marks directly via in_ack_event (dctcp_update_alpha). Reduces queueing latency dramatically (sub-millisecond instead of tens of ms).
- What: ECN-driven; reacts proportionally to the fraction of marked packets (its
alpha). - Why: datacenter switches mark before they drop; DCTCP reads the marks directly.
- When: datacenter only — requires ECN-marking switches and a homogeneous CC choice across all hosts.
- Gotcha: must not be used on the public Internet. Its proportional response shares unfairly against the full-cut response of Reno/CUBIC (Background 3).
Others
Vegas, Westwood, YeAH, Veno, BIC, HighSpeed (HSTCP), Hybla, LP, NV, etc. Each has a niche. Most have one or two papers and a few hundred lines of code. Read net/ipv4/tcp_*.c if you’re curious.
Selecting an algorithm
# What's available
sysctl net.ipv4.tcp_available_congestion_control
# Typically: cubic reno (order, and whether bbr already appears,
# depend on what's loaded — after 'modprobe tcp_bbr' bbr joins the list)
# Default for new sockets (system-wide)
sysctl net.ipv4.tcp_congestion_control
# Set system-wide:
sudo sysctl -w net.ipv4.tcp_congestion_control=bbr
# Per-connection (in code):
const char cc[] = "bbr";
setsockopt(fd, IPPROTO_TCP, TCP_CONGESTION, cc, sizeof(cc));
# Per-route:
sudo ip route change 10.0.0.0/24 via 192.168.1.1 congctl bbr
# Per-cgroup (via BPF sockops — see eBPF book Day 19)
Some algorithms are kernel modules (tcp_bbr.ko, tcp_dctcp.ko); load with modprobe tcp_bbr. CUBIC is usually compiled-in.
How it ties together at runtime
For each TCP connection:
- At socket creation:
tcp_init_sock(net/ipv4/tcp.c:421) callstcp_assign_congestion_control(:463) which picks the algorithm (per-route, per-default, per-app sockopt). initcallback runs once. Allocates per-sock private state inicsk_ca_priv— exactly 104 bytes of scratch space inside theinet_connection_sock(include/net/inet_connection_sock.h:141).- Per ACK:
tcp_ack(tcp_input.c) callscong_avoid(sk, ack, acked)for classic CCs (CUBIC/Reno — the Background-1 increase side). Algorithms that definecong_controlinstead (BBR) getcong_control(sk, ack, flag, rs)with a fullrate_sample. Either way the algorithm updatestp->snd_cwndbased on its model. - On loss/RTO: the kernel calls
set_state(sk, CA_Loss)(Background 2) andssthresh(sk)(the Background-1 decrease side). The algorithm computes the new threshold andcwnddrops toward it. - On RTT sample: the kernel calls
pkts_acked(sk, sample)with the lateststruct ack_sample(Background 4). Pure feedback for the algorithm.
A typical CC algorithm is ~300 lines of C, 90% of which is cong_avoid and the loss/recovery handling.
Today’s experiment
# Load BBR (if not already)
sudo modprobe tcp_bbr
# iperf3 isn't installed by default on minimal images
sudo apt-get install -y iperf3
# Start an iperf3 server
iperf3 -s -p 5201 &
# Run with CUBIC
iperf3 -c 127.0.0.1 -p 5201 -C cubic -t 30 -J | jq '.end.streams[0].sender'
# Now BBR
iperf3 -c 127.0.0.1 -p 5201 -C bbr -t 30 -J | jq '.end.streams[0].sender'
On localhost the difference is small (no real bottleneck). For a more interesting test, add latency and loss with tc netem:
sudo tc qdisc add dev lo root netem delay 50ms loss 1%
iperf3 -c 127.0.0.1 -p 5201 -C cubic -t 30
iperf3 -c 127.0.0.1 -p 5201 -C bbr -t 30
sudo tc qdisc del dev lo root
# Stop the background iperf3 server when done
pkill iperf3 # or, in the same interactive shell: kill %1
CUBIC’s loss-based response will give up bandwidth at every loss (Background 1’s multiplicative cut); BBR’s bandwidth-model approach should hold up better on the lossy path.
Note: even though lo’s root qdisc is now netem and not fq, BBR still paces here. Since Linux 4.13 the TCP stack carries an internal pacing fallback (driven by sk->sk_pacing_status, Background 5) that kicks in when the egress qdisc isn’t fq. It’s less precise than fq’s timestamp-based pacing, but functional — which is why this localhost test still demonstrates BBR’s behavior. On a real NIC you’d front BBR with fq (or hardware pacing) for accurate per-packet pacing.
Watch cwnd evolution
sudo bpftrace -e '
kprobe:tcp_write_xmit {
$tp = (struct tcp_sock *)arg0;
@cwnd = lhist($tp->snd_cwnd, 0, 1000, 50);
}
interval:s:10 { exit(); }'
tcp_write_xmit (net/ipv4/tcp_output.c:2963) only fires while data is actually being sent, so on an idle box this histogram comes back empty. Give it traffic: in one terminal start a 30s transfer (the server is still running from the experiment above) with iperf3 -c 127.0.0.1 -p 5201 -C cubic -t 30, then in a second terminal run the bpftrace above — the 10s window captures snd_cwnd across the live transfer. Repeat with -C bbr to compare the two distributions. (You’re watching the Background-1 sawtooth directly: under loss, CUBIC’s histogram should spread lower than BBR’s.)
Per-socket TCP info
ss -tin
# Look for: the CC algo as a bare token (cubic / bbr), cwnd:N,
# rtt:<srtt>/<rttvar>, retrans:X/Y (only appears after retransmissions)
To see a bbr socket with a live cwnd, run ss -tin while one of the transfers above is in flight (use -t 30 and pin the algorithm with -C bbr). On an idle box ss -tin shows only your SSH session (using whatever the system default CC is — cubic or bbr) and listeners stuck at cwnd:10 (there’s TCP_INIT_CWND from Background 1). Real output for an established socket looks like:
ESTAB 0 0 10.0.0.4:22 ...:62372
bbr wscale:6,10 rto:219 rtt:18.897/2.546 ato:40 mss:1448 cwnd:37 ...
bbr:(bw:7349256bps,mrtt:10.98,pacing_gain:2.88672,cwnd_gain:2.88672) ...
Note the CC algorithm prints as a bare token (bbr / cubic), the smoothed RTT as rtt:<srtt>/<rttvar> (Background 4 — srtt already un-shifted from the kernel’s <<3 form), and retransmits as retrans:X/Y (present only once a retransmission has occurred). The bbr:(bw:...,mrtt:...) line is BBR’s model state: bw is bbr_max_bw, mrtt is min_rtt_us in milliseconds (Background 5).
ss -tin reads tcp_get_info (net/ipv4/tcp.c, search the function) which fills struct tcp_info from the live tcp_sock state.
There are no Dumb Questions
Q: Slow start is “exponential” but congestion avoidance is “linear.” Which is the connection in right now?
A: Whichever side of
snd_ssthreshcwndis on.tcp_in_slow_start(tp)is literallycwnd < ssthresh: below the threshold the connection doublescwndper RTT (slow start); at or above it, it adds +1 per RTT (congestion avoidance). A loss resetsssthreshtocwnd/2, so the next climb starts in slow start but switches to linear much sooner.
Q: Is
CA_Lossthe same as the connection leavingESTABLISHED?A: No — completely orthogonal. The connection stays
ESTABLISHED(Day 15’s state machine).ca_stateis a second state machine tracking the loss/recovery cycle:Open → Disorder → Recovery(fast retransmit) orOpen → Loss(RTO). The kernel signals moves viaset_state();ssthresh()gets called on the way into Recovery/Loss.
Q: How can the network tell me to slow down without dropping a packet?
A: ECN. A congested switch rewrites the IP ECT bits to CE instead of dropping; the receiver echoes that back as the TCP ECE flag; the sender cuts
cwndand replies CWR. DCTCP reacts proportionally to how many packets were marked — which is why it achieves sub-millisecond datacenter latency, and why it can’t be mixed with Reno/CUBIC on the open Internet.
What to read in the kernel
-
include/net/tcp.h—struct tcp_congestion_ops(line 1316). The vtable. Read the(a) cong_avoidXOR(b) cong_controlcomment — it’s the one CC-specific rule. Alsoenum tcp_ca_event(line 1242) andstruct ack_sample(line 1283). -
include/uapi/linux/tcp.h—enum tcp_ca_state(line 194). The five loss/recovery states. -
net/ipv4/tcp_cong.c— registration framework + the AIMD primitives. Key functions:tcp_register_congestion_control(line 93): adds an algorithm to the global list.tcp_ca_find(line 26): name → algorithm lookup.tcp_set_congestion_control(line 412): switch a socket’s CC.tcp_init_congestion_control(line 236): per-sock init.tcp_slow_start(line 456): the exponential phase.tcp_cong_avoid_ai(line 470): the additive-increase (linear) helper.tcp_reno_cong_avoid(line 496) /tcp_reno_ssthresh(line 515): the AIMD reference.tcp_renoinstance (line 531): every other algorithm is “Reno plus extra cleverness.”
-
net/ipv4/tcp_input.c— searchtcp_ack. The function that callscong_avoidper incoming ACK. Trace it once to see how the kernel decides “useful ACK” vs “duplicate ACK” before invoking the algorithm. -
net/ipv4/tcp_cubic.c:475—cubictcpinstance. Readcubictcp_cong_avoid(line 321); the math is mostly inbictcp_update(line 211). -
net/ipv4/tcp_bbr.c:1144—tcp_bbr_cong_ops, with.cong_control = bbr_main(entry per ACK at line 1028). Long file (~1200 lines) but the structure is clear; read the top-of-file comment and notemin_rtt_us(line 91) andbbr_max_bw(line 216). -
net/ipv4/tcp_dctcp.c:255— DCTCP. Short and instructive. Notice howdctcp_update_alpha(line 127) is wired as.in_ack_event(line 257) to consume ECN marks. -
include/net/inet_ecn.h—INET_ECN_CEandINET_ECN_is_ce()(lines 14–24). The IP-layer ECN marking. -
Documentation/networking/ip-sysctl.rst— thetcp_congestion_controlsysctl and related knobs. See alsoDocumentation/networking/dctcp.rstfor DCTCP specifics.
Bullet Points
- TCP CC computes
snd_cwnd— how much the sender thinks the network can absorb (vsrcv_wnd, what the receiver can buffer). - cwnd is in packets. A connection starts at
TCP_INIT_CWND = 10, doubles per RTT in slow start (exponential, untilcwnd >= snd_ssthresh), then grows +1 per RTT in congestion avoidance (linear, viatcp_cong_avoid_ai). - AIMD = Additive Increase / Multiplicative Decrease. +1/RTT up, halve on loss (
ssthresh = cwnd/2). That’s the sawtooth. CUBIC replaces the linear climb with a cubic curve; BBR replaces the loss loop with a bandwidth model. ca_state(Open/Disorder/CWR/Recovery/Loss) is a separate state machine from the Day-15 connection states — it tracks loss/recovery.set_statesignals moves;cwnd_eventdelivers the finerca_eventstream (including the ECN events).- ECN signals congestion without a drop: a switch rewrites IP ECT → CE, the receiver echoes TCP ECE, the sender replies CWR. DCTCP reacts proportionally via
in_ack_event. - srtt/rttvar: the kernel smooths raw RTT samples (
ack_sample.rtt_us) intosrtt_us(stored<<3) andrttvar_us;ssprintsrtt:<srtt>/<rttvar>. Rising srtt above min RTT = queue building. - Pacing spreads a window across the RTT (needs
fqqdisc or thesk_pacing_statusfallback); bufferbloat is oversized buffers adding standing delay without dropping. BBR backs off on rising RTT before the drop; CUBIC waits for the overflow. - Pluggable framework: each algorithm is a
struct tcp_congestion_ops(a vtable, like Day 3’sQdisc_ops) registered viatcp_register_congestion_control. An algorithm providescong_avoidXORcong_control. - CUBIC — loss-based, cubic growth, longtime default. BBR — model-based, needs
fqfor pacing. DCTCP — ECN-based, datacenter only. Reno — the ~30-line reference; everyone else “is Reno but…”. - Switch via sysctl, sockopt (
TCP_CONGESTION), or per-route. BPF (sock_ops) can override per-cgroup. Per-connection state lives inicsk_ca_priv(104 bytes insideinet_connection_sock).
Check question
Why doesn’t the kernel just always use BBR (since it’s newer and often performs better)?
Click to reveal answer
Answer: Three reasons. (1) Fairness. BBR is delay-based; it estimates path bandwidth and paces accordingly. On a network where the bottleneck is a buffer also serving CUBIC flows, BBR is unfriendly: it underestimates how much the buffer is doing, takes more share than fairness would dictate, and can starve neighbors. CUBIC remains the default because it’s been validated across the public Internet for two decades and behaves predictably with other CUBIC flows. (2) Pacing requirement. BBR depends on accurate per-packet pacing, which requires the fq qdisc or hardware pacing. Without it, BBR’s bandwidth estimate is corrupted by burstiness. Many systems don’t run fq by default. (3) Application sensitivity. Some applications (real-time, low-latency request/response) are more sensitive to the variance of the algorithm than its peak throughput; CUBIC’s behavior is more predictable in this dimension. So: BBR for WAN-throughput-critical workloads where you control both ends. CUBIC for the public Internet and most general-purpose servers.
Tomorrow
Day 17: TCP retransmission and recovery — RTO, fast retransmit, RACK, the recovery state. This is where the ca_state machine from Background 2 gets its full treatment.
Day 17 — TCP retransmission, RACK, recovery
Today’s mission: understand how TCP detects packet loss and recovers without breaking ordering. Three detection mechanisms, one recovery state, lots of timers — and the three pieces of TCP machinery they all secretly rest on (sequence numbers, in-flight accounting, and the RTT estimator). Total time: ~110 minutes.
What “loss” means to TCP
TCP guarantees in-order, reliable delivery. The internet is neither in-order nor reliable. TCP’s job is to detect loss/reorder and retransmit, while telling the application “all good, here’s your bytes in order.”
The sender keeps every transmitted-but-unacked segment in the retransmit queue (tp->packets_out segments, held in the sk->tcp_rtx_queue rbtree). It listens to ACKs to learn what got through. When it suspects a segment is lost, it sends another copy.
The hard part is suspecting loss without overreacting to mere reordering.
But before any of that makes sense, we have to make three things concrete that the rest of this chapter leans on constantly and that no earlier day taught in full:
- What a sequence number actually is, and what
snd_una/snd_nxtmean — because every loss signal in this chapter is ultimately a statement about which bytes have been acknowledged. - How the kernel counts “packets in flight” out of four separate counters — because the punitive RTO behavior (
cwnd = packets_in_flight + 1) is meaningless until you know what that number is. - How TCP builds a timeout out of a smoothed average plus a variance — because the RTO formula is the first detection mechanism and it’s just a bit-shift soup until you know why it’s shaped that way.
Day 13 already pointed you at struct tcp_sock; today we give its retransmit-related fields meaning. Days 15 and 16 traced connection states and cwnd but never introduced the byte-stream/ACK model — so we start there.
Background 1: sequence numbers, snd_una, snd_nxt, and cumulative ACKs
TCP numbers bytes, not packets
Here’s the foundational idea that everything else builds on: TCP assigns a sequence number to every byte of the stream, not to every packet. A segment’s header carries the sequence number of its first byte. If a segment carries 1000 bytes starting at sequence 5000, it covers bytes 5000–5999, and the next segment will start at 6000.
The receiver doesn’t ACK packets either. Its ACK carries the sequence number of the next byte it expects — that is, one past the last contiguous byte it has received. If the receiver has everything through byte 5999, it sends ACK 6000, meaning “I have all bytes up to and including 5999; give me 6000 next.”
That word contiguous is the whole game. Read it twice.
The sender’s two edges: snd_una and snd_nxt
The sender tracks the in-flight window with two sequence numbers (both in struct tcp_sock):
snd_una— “first byte we want an ack for.” This is the left edge of the in-flight window: the oldest byte that has been sent but not yet acknowledged.snd_nxt— “next sequence we send.” This is the right edge: the first byte not yet transmitted.
/* include/linux/tcp.h */
u32 rcv_nxt; /* What we want to receive next (:303) */
u32 snd_nxt; /* Next sequence we send (:304) */
u32 snd_una; /* First byte we want an ack for (:305) */
u32 write_seq; /* Tail(+1) of data held in tcp send buffer (:269) */
u32 packets_out; /* Packets which are "in flight" (:308) */
Everything in the half-open range [snd_una, snd_nxt) is transmitted-but-unacked — that is exactly the retransmit queue this chapter centers on. packets_out is the size of that range measured in segments. write_seq sits even further right: it’s the tail of data the application has handed to the socket but TCP hasn’t put on the wire yet.
The kernel splits these two ranges into two separate queues. On transmit, tcp_event_new_data_sent (net/ipv4/tcp_output.c:88) does __skb_unlink(skb, &sk->sk_write_queue); tcp_rbtree_insert(&sk->tcp_rtx_queue, skb) — so sk_write_queue holds only the unsent range [snd_nxt, write_seq), while sk->tcp_rtx_queue (a red-black tree) holds the sent-but-unacked range [snd_una, snd_nxt) — the retransmit queue proper.
When an ACK arrives that acknowledges new data, the kernel slides snd_una to the right and frees the now-acknowledged segments from the retransmit queue. The window “opens” and new data can go out.

Why a lost segment produces duplicate ACKs
ACKs are cumulative: an ACK for N means “I have every byte up to N−1,” nothing more. The receiver cannot say “I have 5000–5999 and also 7000–7999” with a plain cumulative ACK — it can only re-advertise its highest contiguous point.
Now picture one segment lost in the middle of a burst. Bytes 6000–6999 vanish, but 7000–7999, 8000–8999, and so on keep arriving. The receiver still only has everything through byte 5999, so every one of those later arrivals triggers the same ACK: ACK 6000, again and again. The cumulative ACK number is stuck even though data keeps showing up.
That stalled, repeated ACK number IS the duplicate-ACK signal that feeds Fast Retransmit (mechanism 2 below). Duplicate ACKs aren’t an error condition — they’re the receiver politely screaming “I’m still missing byte 6000” the only way the cumulative-ACK protocol lets it.
The recovery point: high_seq
When the sender finally detects loss and enters recovery, it snapshots the current snd_nxt into high_seq (“snd_nxt at onset of congestion”):
/* include/linux/tcp.h:446 */
u32 high_seq; /* snd_nxt at onset of congestion (the recovery point) */
This is the recovery point. Recovery is over precisely when snd_una crosses high_seq — i.e. once everything that was outstanding at the moment loss began has finally been acknowledged. Hold onto that: when later sections say “exit when snd_una reaches the recovery point,” this is the field they mean.
The per-ACK machine that does all this dispatching, tcp_fastretrans_alert, takes a prior_snd_una argument — the value of snd_una before this ACK was processed — so it can tell whether the current ACK advanced the left edge (real new acknowledgment) or merely repeated the old number (a dupack):
/* net/ipv4/tcp_input.c:3328 */
tcp_fastretrans_alert(struct sock *sk, const u32 prior_snd_una, ...)
There are no Dumb Questions
Q: If TCP numbers every byte, doesn’t the sequence number overflow fast?
A: It’s a 32-bit number that wraps. The kernel never compares sequence numbers with plain
<; it usesbefore()/after()helpers (ininclude/net/tcp.h) that do signed-difference comparisons so wraparound is handled correctly. For today, just think “monotonically increasing byte counter.”Q: Where does the first sequence number come from — is it zero?
A: No. Each direction picks a random Initial Sequence Number during the handshake (Day 15’s SYN/SYN-ACK carried it). Starting from a predictable value would be a security hole.
snd_una,snd_nxt, and friends are all offsets from that ISN.
Background 2: counting “packets in flight”
The chapter is about to keep citing four counters and a derived quantity. Let’s nail down how they relate, because the headline RTO behavior — cwnd = packets_in_flight + 1 — is gibberish until you do.
The four per-socket counters
Each lives in struct tcp_sock and measures segments in the retransmit queue:
/* include/linux/tcp.h */
u32 lost_out; /* Lost packets (:227) */
u32 sacked_out; /* SACK'd packets (:228) */
u32 retrans_out; /* Retransmitted packets out (:244) */
u32 packets_out; /* Packets which are "in flight" (:308) */
packets_out— segments transmitted but not yet cumulatively ACKed. This is the raw size of the retransmit queue in segments.sacked_out— segments the receiver told us — via SACK, a TCP option covered in full below that lets the receiver name byte-ranges it holds past a gap — it did receive, even though they’re past a hole. Known-received; no longer on the wire.lost_out— segments TCP has presumed lost. Presumed-gone; also no longer occupying the bottleneck.retrans_out— segments we’ve resent. A resent segment is genuinely back on the wire.
The accounting identity
The kernel never trusts packets_out alone for congestion decisions, because some of those segments it has other evidence about. It subtracts what it knows left the network and adds back what it put back on:
/* include/net/tcp.h:1483 */
static inline unsigned int tcp_left_out(const struct tcp_sock *tp)
{
return tp->sacked_out + tp->lost_out;
}
/* include/net/tcp.h:1502 */
static inline unsigned int tcp_packets_in_flight(const struct tcp_sock *tp)
{
return tp->packets_out - tcp_left_out(tp) + tp->retrans_out;
}
Read it as the kernel’s own comment does:
“Packets sent once on transmission queue” MINUS “Packets left network, but not honestly ACKed yet” PLUS “Packets fast retransmitted.”
SACKed and lost segments are both “left the network” — one received, one gone — so they come out of the count. Retransmits go back in because they’re really loading the wire again. The result, packets_in_flight, is TCP’s best estimate of bytes currently in the network, and it’s the quantity congestion control compares against cwnd.
This is exactly why an RTO that sets cwnd = packets_in_flight + 1 collapses the sender to ~1 segment. On a timeout the kernel marks the outstanding window lost — all but the most recently sent segments (tcp_timeout_mark_lost spares those sent within the last RTT) — so packets_in_flight collapses toward zero, and cwnd = packets_in_flight + 1 falls to ~1 MSS in the typical case — back to slow start from scratch (recall slow start from Day 16). And it tells you what SACK buys: every segment SACK moves from “still owed” into sacked_out is a segment subtracted from in-flight, which frees window to send something new instead of stalling.

The per-skb backing store
Those per-socket counters are summaries. The ground truth lives on each skb in the retransmit queue, in a bitfield inside its TCP control block:
/* include/net/tcp.h */
TCPCB_SACKED_ACKED = (1 << 0), /* SKB ACK'd by a SACK block (:1082) */
TCPCB_SACKED_RETRANS = (1 << 1), /* SKB retransmitted (:1083) */
TCPCB_LOST = (1 << 2), /* SKB is lost (:1084) */
...
__u8 sacked; /* State flags for SACK (:1117) */
When SACK processing marks an skb TCPCB_SACKED_ACKED, it bumps sacked_out; when RACK marks one TCPCB_LOST, it bumps lost_out; and so on. The socket counters and the per-skb bits are kept in lockstep — tcp_verify_left_out even WARN_ON(tcp_left_out(tp) > tp->packets_out) to catch bookkeeping bugs.
Background 3: the RTT estimator — srtt and rttvar as an EWMA
The first detection mechanism is the Retransmission Timeout. To build a timeout you need a target RTT — but you cannot time out on a single RTT measurement, because RTT is noisy. One sample might be 8 ms, the next 40 ms because an ACK got delayed behind a big packet. Time out on the raw sample and you’ll declare loss constantly on a perfectly healthy path.
Smoothing: the EWMA
TCP keeps a running smoothed average of RTT. Each new sample nudges the estimate a little instead of replacing it:
srtt = 7/8 · srtt + 1/8 · new_sample
This is an EWMA (exponentially-weighted moving average): recent samples weigh more, old ones decay exponentially. It’s the kernel’s tcp_rtt_estimator, lifted essentially verbatim from Jacobson’s 1988 SIGCOMM paper. You can see the shifts directly:
/* net/ipv4/tcp_input.c:1070 tcp_rtt_estimator() */
m -= (srtt >> 3); /* m is now error in rtt est */
srtt += m; /* rtt = 7/8 rtt + 1/8 new */
...
m -= (tp->mdev_us >> 2); /* similar update on mdev */
tp->mdev_us += m; /* mdev = 3/4 mdev + 1/4 new */
Variance: why a mean isn’t enough
A smooth average tells you the typical RTT but not how much it jitters. So TCP also tracks the mean deviation (mdev_us), smoothed into rttvar_us. A stable path (a quiet LAN) has tiny rttvar; a congested or wireless path has large rttvar.
The timeout must clear the jitter, not just the average — otherwise a normally-late ACK on a jittery path gets mistaken for loss. That’s why RFC 6298 sets:
RTO = srtt + 4 · rttvar
The 4·rttvar is an adaptive safety cushion. With only srtt you’d be forced to choose one fixed margin: too tight on jittery paths (spurious retransmits and needless cwnd cuts) or too loose on stable ones (you sit idle for ages after a real loss). The variance term auto-sizes the cushion to the path.

Now the scaling makes sense
RTO = (srtt_us >> 3) + rttvar_us (clamped to [200ms, 120s])
This is the RFC 6298 form RTO = srtt + 4·rttvar: srtt_us is stored ×8 (hence >> 3), and rttvar_us is already maintained on a ×4 scale of the mean deviation — so the classic ×4 weight is baked into rttvar_us and the kernel just adds the two fields. (Don’t multiply by 4 again.)
The kernel’s __tcp_set_rto (include/net/tcp.h:881) is literally that:
static inline u32 __tcp_set_rto(const struct tcp_sock *tp)
{
return usecs_to_jiffies((tp->srtt_us >> 3) + tp->rttvar_us);
}
and tcp_set_rto (net/ipv4/tcp_input.c:1175) calls it and stores the result in icsk_rto. The relevant fields:
/* include/linux/tcp.h */
u32 srtt_us; /* smoothed round trip time << 3 in usecs (:307) */
u32 rttvar_us; /* smoothed mdev_max (:243) */
u32 mdev_us; /* medium deviation (:272) */
The result is clamped to [200ms, 120s] (TCP_RTO_MIN = HZ/5, TCP_RTO_MAX = 120s in include/net/tcp.h) so a tiny LAN RTT still yields a sane minimum and a broken path can’t push RTO to infinity.
Three detection mechanisms
With sequence numbers, in-flight accounting, and the RTT estimator in hand, the three loss-detection mechanisms read cleanly.

1. RTO — Retransmission Timeout
The classical mechanism, built directly on Background 3’s RTO value. If no ACK arrives within RTO of the first unacked segment (the one at snd_una), the timer fires:
- The first unacked segment is retransmitted.
- The kernel enters CA_Loss state.
cwndis reset totcp_packets_in_flight(tp) + 1— effectively ~1 MSS after a timeout (slow start from scratch, as Background 2 explained: most of the window is marked lost — all but the segments sent within the last RTT — so in-flight collapses toward ~0).- RTO is doubled (exponential backoff).
Implementation: tcp_retransmit_timer at net/ipv4/tcp_timer.c:535. Read this once — it’s the canonical loss-recovery entry point.
RTO is the most pessimistic detection: it waits hundreds of ms before declaring loss. Useful as a backstop but expensive in latency. The other two mechanisms react faster.
2. Duplicate ACKs / Fast Retransmit
If a segment is lost but later ones arrived, the receiver keeps ACKing the highest contiguous sequence — producing duplicate ACKs (“dupacks”). This is the stuck-cumulative-ACK signal from Background 1 — the receiver re-sending the same ACK number because it can’t advance past the hole.
Classic Reno rule: 3 dupacks → fast retransmit. The sender retransmits the missing segment immediately, without waiting for RTO.
This works for single-segment losses. For burst losses (multiple gaps), Reno needs SACK to keep going.
3. RACK — Recent ACK (the modern default since 4.4)
net/ipv4/tcp_recovery.c. RACK uses ACK timing instead of duplicate-ACK counts. The intuition:
“If a segment Y was sent before X, and X has been acknowledged, then Y is probably lost.”
Specifically: if some segment with a later send time has been SACKed, and the gap is >= reordering window, the unacked earlier segment is declared lost. This catches loss faster than Reno’s 3-dupack rule, especially with reordering.
RACK is the current default (net.ipv4.tcp_recovery=1). If you ever set tcp_recovery=0, you fall back to legacy 3-dupack detection — slower and more conservative.
Historical note — FACK. You’ll still see “FACK” (Forward Acknowledgment) mentioned in old docs and in kernel comments (
tcp_recovery.c,tcp_vegas.c). FACK was an older SACK-based loss heuristic; it was removed from the kernel around 4.11 in favor of RACK, which generalizes the same idea using ACK timing. Only the comments survive — there’s no FACK code path to read anymore.
SACK — Selective Acknowledgments (RFC 2018)
By default, ACKs are cumulative: “I have everything up to seq N” (Background 1). This is enough for Reno but limits parallelism — the receiver can’t tell the sender about data it holds past a hole. With SACK, ACKs include up to 4 ranges: “I have up to N, and also blocks A-B, C-D, E-F.” The sender uses this to avoid retransmitting segments the receiver already has, and to move those segments into sacked_out (Background 2) so they stop counting against cwnd.
SACK is on by default. The state structures: tcp_sock->sacked_out, tp->lost_out, tp->retrans_out track per-segment status. Each skb in the retransmit queue has a tcb->sacked bitfield (TCPCB_SACKED_ACKED, TCPCB_LOST, TCPCB_SACKED_RETRANS) — the per-skb backing for those counters, exactly as Background 2 described.
The recovery state
When loss is detected (any of the three mechanisms above), the kernel calls tcp_enter_recovery (net/ipv4/tcp_input.c:3177):
- Sets
icsk->icsk_ca_state = TCP_CA_Recovery. - Reduces
cwndtowardssthresh(PRR — Proportional Rate Reduction; reduces gracefully across the RTT). How far depends on the CC: the default CUBIC drops to ≈70% (β = 717/1024), classic Reno halves to 50%. - Calls the CC algorithm’s
set_state(CA_Recovery)— most algorithms record the loss event.
This is also where high_seq is set to the current snd_nxt — the recovery point from Background 1. While in Recovery, the kernel transmits new segments at the reduced rate and selectively retransmits the lost ones. Recovery ends when snd_una advances past high_seq (the “recovery point”) — the kernel calls tcp_end_cwnd_reduction and returns to CA_Open.
For RTO-driven loss, the kernel uses tcp_enter_loss (net/ipv4/tcp_input.c:2554) instead — much more punitive (cwnd=1, slow start).
The recovery state machine: tcp_fastretrans_alert
net/ipv4/tcp_input.c:3328 — entered on every ACK that might require recovery action. It receives prior_snd_una (Background 1) so it can tell a real new acknowledgment from a dupack. This is the full version of the ca_state machine Day 16 sketched and deferred. Walks the state machine:
Open → Disorder (first dupack or SACK)
Disorder → Recovery (loss confirmed; start retransmits)
Recovery → Open (recovery point reached: snd_una crosses high_seq)
Open → Loss (RTO fired)
Loss → Open (after RTO recovery)
Each state has its own logic for what to send and how to count cwnd. ~114 lines; read it once.
ACK clocking and PRR
When the sender enters Recovery, the question is “how fast should I retransmit?”
- Naïve answer: blast all the missing segments immediately. Bad — overshoots and causes more loss.
- PRR answer: pace retransmits at exactly the rate of returning ACKs, gradually working cwnd down toward the new
ssthresh(the reduced target from the previous section).
PRR (RFC 6937) is the kernel’s recovery rate-control strategy. The prr_out, prr_delivered fields in tcp_sock track its state.
Today’s experiment
Use tc netem to inject loss and watch recovery happen.
Prereq: install iperf3 — sudo apt-get install -y iperf3 (Debian/Ubuntu) or sudo dnf install -y iperf3 (Fedora/RHEL); confirm with iperf3 --version. The tc netem step needs the sch_netem module, which auto-loads the moment you run tc qdisc add ... netem and is present in any stock kernel (no manual modprobe).
# Inject 5% loss on loopback
sudo tc qdisc add dev lo root netem loss 5%
# Start the server, then run the client in the BACKGROUND (&) so the shell
# stays free to inspect the socket while the 30s transfer is still live.
iperf3 -s -p 5201 &
sleep 1 # give the server a moment to bind before connecting
iperf3 -c 127.0.0.1 -p 5201 -t 30 &
# A few seconds in — WHILE the transfer is still running — inspect the socket.
# ss must run during the transfer: once the client exits the iperf3 socket is
# gone and you'll only see unrelated connections (e.g. your SSH session).
ss -tin '( dport = :5201 or sport = :5201 )'
# nstat is cumulative, so it's fine to run this even after the transfer ends.
nstat | grep -i Retrans
While the transfer is live, ss lists the iperf3 socket with a reduced cwnd and a retrans:A/B field whose second number climbs as netem drops segments (the field is explained in Per-connection retransmit info below). nstat | grep -i Retrans then reports cumulative counters such as TcpRetransSegs and TcpExtTCPSackRecovery.
Trace recovery entry. This loops every 10s — press Ctrl-C to stop — and only counts while loss recovery is actually happening, so you need a lossy transfer running concurrently: keep the netem qdisc above active and, in a second terminal, run iperf3 -c 127.0.0.1 -p 5201 -t 30. Against an idle system every 10s window prints zeros.
sudo bpftrace -e '
fentry:tcp_enter_recovery { @rec = count(); }
fentry:tcp_enter_loss { @loss = count(); }
fentry:tcp_retransmit_skb { @retx = count(); }
interval:s:10 { print(@rec); print(@loss); print(@retx); clear(@rec); clear(@loss); clear(@retx); }'
Expect: many retransmits, several Recovery entries (each loss event), occasional Loss entries (when RTOs fire).
When you’re done, remove the injected loss and stop the background server:
# Restore
sudo tc qdisc del dev lo root
kill %1 2>/dev/null # stop the background iperf3 server (or: pkill -f 'iperf3 -s')
Per-connection retransmit info
ss -tin | grep -A 1 ESTAB
# look for: cwnd:N retrans:OUTSTANDING/TOTAL
Real output from a connection on the box:
ESTAB 0 0 10.0.0.4:22 ...:53179
cubic wscale:6,10 rto:219 ... cwnd:10 ssthresh:48 ... retrans:0/2 reord_seen:2 ... minrtt:10.33
ss prints retrans:A/B for the connection. A is the number of retransmissions currently outstanding / in flight (tcpi_retrans in uapi/linux/tcp.h) — this is retrans_out from Background 2 surfaced to userspace; B is the cumulative total retransmissions over the connection’s life (tcpi_total_retrans). Above, retrans:0/2 means 0 retransmits outstanding right now and 2 total so far. The two values are not direction-related — there are no RX/TX fields here. (Notice also the rto:219 field — that’s the RTO from Background 3, in milliseconds, clamped above the 200ms floor.)
What to read in the kernel
-
net/ipv4/tcp_input.c:3328—tcp_fastretrans_alert. The state-machine entry called per ACK. Read top to bottom. Notice how it usesprior_snd_una(the ACK before this one) to detect new acknowledgments and dispatch into the right action. -
net/ipv4/tcp_input.c:3177—tcp_enter_recovery. The “we’re in trouble” entry point. ~25 lines. Walk through: cwnd reduction, callingset_stateon the CC algorithm, setting up PRR. -
net/ipv4/tcp_input.c:2554—tcp_enter_loss. The RTO-driven recovery. Much more punitive thantcp_enter_recovery— used only for RTO. Notice cwnd resets totcp_packets_in_flight(tp) + 1(≈1 MSS). -
net/ipv4/tcp_input.c:3602—tcp_clean_rtx_queue. The function that walks thesk->tcp_rtx_queuerbtree (viaskb_rb_first) and removes ACKed segments (advancingsnd_una). The complexity here is computing RTT samples for each ACKed segment (the very samples that feedtcp_rtt_estimator; many SACK edge cases). -
net/ipv4/tcp_recovery.c— RACK implementation. Short (~160 lines). Read all of it. The key function istcp_rack_detect_loss— given the latest ACK time and SACK info, decide which earlier segments are now considered lost. -
net/ipv4/tcp_timer.c:535—tcp_retransmit_timer. The RTO-fires entry. Read top to bottom (~150 lines). Notice the “user timeout” handling (gives up afterTCP_USER_TIMEOUTeven if RTO would keep retrying), and the explicit congestion exit if too many retries. -
net/ipv4/tcp_output.c:3694—tcp_retransmit_skb. How a single skb gets resent. Notice the GSO/TSO splitting if the skb represents multiple segments — only the lost portion is retransmitted. -
net/ipv4/tcp_output.c:3724—tcp_xmit_retransmit_queue. Drives retransmits during recovery: walks the queue, picks segments marked LOST, sends them at the PRR-paced rate. -
include/linux/tcp.h:197—struct tcp_sock. The retransmit-related fields are clustered:packets_out,lost_out,sacked_out,retrans_out,prr_out,prr_delivered,rack. Skim them once to know what each tracks. -
include/net/tcp.h:1502—tcp_packets_in_flightandtcp_left_out(:1483). One-line functions, but they’re the accounting identity from Background 2 — re-read them with the comment above them. -
RFCs to skim if curious: 5681 (Reno + congestion avoidance), 6675 (SACK-based recovery), 6937 (PRR), 8985 (RACK).
Bullet Points
- TCP numbers every byte, not every packet.
snd_una= first unacked byte (left edge),snd_nxt= next byte to send (right edge);[snd_una, snd_nxt)is the retransmit queue. ACKs are cumulative — a stuck ACK number on later arrivals is the duplicate-ACK loss signal. packets_in_flight = packets_out − (sacked_out + lost_out) + retrans_out— TCP’s estimate of bytes on the wire, the thingcwndbounds. An RTO marks most of the window lost (sparing segments sent within the last RTT), so in-flight → ~0 andcwnd = in_flight + 1≈ 1 MSS.- RTO = (srtt_us >> 3) + rttvar_us, an EWMA mean (
srtt = 7/8·srtt + 1/8·sample) plus4·rttvarof jitter, clamped to [200ms, 120s]. The ×4 is baked intorttvar_us; don’t multiply again. - TCP keeps every unacked segment in the
sk->tcp_rtx_queuerbtree until ACKed; unsent data waits insk_write_queue. - Three loss-detection mechanisms: RTO (slow, backstop), 3 dupacks (Reno), RACK (modern default).
tcp_recovery=1enables RACK; the default since 4.4.- SACK lets ACKs include non-contiguous ranges; on by default; moves segments into
sacked_outso they stop counting againstcwnd. - Recovery state: cwnd reduced toward ssthresh (≈70% for CUBIC, 50% for Reno) via PRR, retransmit lost segments at ACK-paced rate, exit when
snd_unareaches the recovery point (high_seq). tcp_retransmit_timeratnet/ipv4/tcp_timer.c:535is the RTO entry.tcp_fastretrans_alertatnet/ipv4/tcp_input.c:3328is the per-ACK recovery state machine.- Inspect:
ss -tin(cwnd, retrans, rto),nstat(TCPRetrans, TCPSackRecovery, etc.). - Use
tc netem loss N%to test loss recovery in a controlled environment.
Check question
Why does losing 1% of packets on a 100 ms RTT path cause throughput to drop much more than 1%?
Click to reveal answer
Answer: Because each loss causes the CC algorithm to cut cwnd (CUBIC to ≈70%, Reno to 50%), and recovering that lost window takes many RTTs of growth. The Mathis formula approximates achievable throughput:
throughput ≈ MSS / (RTT × √loss_rate)
Plug in (in bits): MSS=1448 B = 11584 bits, RTT=0.1 s, √0.01 = 0.1 → 11584 / (0.1 × 0.1) ≈ 1.16 Mbps. Even on a 10 Gbps path, you cap near ~1 Mbps. CUBIC is loss-based — it interprets every loss as congestion and backs off. With one back-off per ~100 packets, cwnd never reaches what the path could actually support.
BBR partially fixes this because it doesn’t use loss as the signal — it uses bandwidth and RTT directly. On the same lossy path, BBR can sustain throughput much closer to the path’s actual capacity.
Tomorrow
Day 18: socket options. The pile of per-socket tuning knobs and what each one actually does.
Day 18 — Socket options: per-socket tuning
Today’s mission: know what each sockopt actually changes about a socket’s behavior, when you’d reach for each, and where in the kernel the change takes effect. Along the way we’ll fill in the four pieces of background the knobs lean on but the book never taught: the capability privilege gate, the
sockptr_tunified-pointer ABI, receive/send buffer auto-tuning and the bandwidth-delay product, and the delayed-ACK timer thatTCP_QUICKACKoverrides. Total time: ~110 minutes.
How sockopts work
A socket option is a per-socket knob set via:
setsockopt(fd, level, optname, value, valuelen);
getsockopt(fd, level, optname, value, &valuelen);
The four parts of that call are a contract, and it’s worth saying each one out loud because the rest of the day is just variations on it:
levelselects a namespace of options:SOL_SOCKET(generic, works on any socket),SOL_IP/SOL_IPV6(IP-layer),SOL_TCP,SOL_UDP(per-protocol). The sameoptnameinteger means different things under different levels, so the level disambiguates.optnamepicks the specific knob inside that namespace (SO_RCVBUF,TCP_NODELAY, …).optvalis a pointer to a typed value — sometimes anint, sometimes astruct linger, sometimes a string like"bbr". The kernel reinterprets the bytes according tooptname.optlenis the size of that value.
For getsockopt there’s one subtlety that trips up everyone the first time: optlen is value-result. You pass in the size of the buffer you’re offering, and the kernel writes back how many bytes it actually filled. That’s why today’s experiment always passes &gl / &cl (the length) by address — the kernel needs to be able to update it. Forget the & and you either truncate the answer or get EFAULT.
The kernel’s dispatch path
When the syscall enters the kernel, the option has to find its way to the one case that implements it. The dispatch is mechanical:
- The syscall lands in
do_sock_setsockopt(net/socket.c:2349). - For
level == SOL_SOCKET(generic), the kernel callssock_setsockopt(net/core/sock.c:1685) — a thin wrapper that immediately callssk_setsockopt(net/core/sock.c:1195), where the actual ~600-line option switch lives. - For other levels, it goes through
sk->sk_prot->setsockopt(or, for AF_INET,sock->ops->setsockopt), which dispatches to per-protocol code:tcp_setsockopt(net/ipv4/tcp.c:4175),udp_setsockopt,ip_setsockopt(net/ipv4/ip_sockglue.c:1409), etc.
That sk->sk_prot->setsockopt in step 3 is the sk_prot vtable from Day 13 — the per-protocol function table hanging off every struct sock. The same indirection that routes sendmsg to tcp_sendmsg vs udp_sendmsg routes setsockopt to the right protocol handler. (Recall the whole struct sock, its sk_prot dispatch, and its sk_lock from Day 13; we lean on all three today.)
Each option is a case in a giant switch statement. When you read tcp_setsockopt, you’re reading the canonical “what does this knob change?” reference.

One function body, two callers: sockptr_t
Look closely at do_sock_setsockopt’s signature in v7.1 and one parameter stands out:
int do_sock_setsockopt(struct socket *sock, bool compat, int level,
int optname, sockptr_t optval, int optlen) /* net/socket.c:2349 */
optval is not a void * and not a void __user * — it’s a sockptr_t. This is the abstraction the “What to read” list tells you to notice, so let’s actually explain it. A sockptr_t is a tagged pointer: a union of a kernel pointer and a userspace pointer plus a one-bit is_kernel flag (include/linux/sockptr.h:14):
typedef struct {
union {
void *kernel;
void __user *user;
};
bool is_kernel : 1;
} sockptr_t;
Why bother? Because the same option-setting code has two completely different callers:
- The syscall path.
__sys_setsockoptwraps the userspace buffer withUSER_SOCKPTR(user_optval)(net/socket.c:2396), sois_kernel = 0and the kernel knows it mustcopy_from_userbefore touching the bytes. - The BPF path. A
sock_opsBPF program (the “BPF can override most of these” paragraph below) callsbpf_setsockopt, which reuses the very samesk_setsockoptby wrapping its argument withKERNEL_SOCKPTR(net/core/filter.c:5349), sois_kernel = 1and the bytes are copied as a plain in-kernelmemcpy.
One function body, two entry points, the is_kernel bit selecting which arm of the union to read. That’s the entire reason sockptr_t exists — and it’s also why the capability-check helpers carry a sockopt_ prefix (next section): a kernel-side BPF caller must be able to skip the privilege check that a userspace caller is held to.

Who’s allowed: the capability privilege gate
Four of today’s options are gated behind a capability — you’ll see “requires CAP_NET_RAW” or “requires CAP_NET_ADMIN” repeated below — so before the catalog, here’s what a capability is.
Historically, a process was either root (uid 0, allowed to do anything) or not. That’s too coarse: a packet-capture tool needs to open raw sockets but has no business rebooting the machine. POSIX capabilities slice root’s omnipotence into ~40 independent privileges, each grantable on its own. A process can hold just one slice without being full root. Networking sockopts gate on two of these slices:
CAP_NET_RAW— “look, or route differently”: open raw/packet sockets, re-bind an already device-bound socket to a different device (binding an unbound socket to a device needs no capability), bind a non-local address.CAP_NET_ADMIN— “reconfigure kernel networking behavior”: force buffer sizes past the sysctl ceiling, change admin-only knobs, set up transparent proxying.
The rule of thumb the chapter relies on: raw access / “look but route my own way” = CAP_NET_RAW; “reconfigure the kernel” = CAP_NET_ADMIN. So SO_*BUFFORCE (override the max) needs ADMIN; SO_BINDTODEVICE needs RAW; IP_TRANSPARENT and SO_MARK accept either (they straddle both worlds — a mark is “route differently,” but it’s also “reconfigure”).
In the source the check is a one-liner. sockopt_capable(CAP_NET_ADMIN) returns -EPERM to the caller if the slice is missing — e.g. the SO_SNDBUFFORCE gate if (!sockopt_capable(CAP_NET_ADMIN)) at net/core/sock.c:1357, and the matching SO_RCVBUFFORCE gate at net/core/sock.c:1379. The helper itself is tiny (net/core/sock.c:1173, declared at include/net/sock.h:1780):
bool sockopt_capable(int cap)
{
return has_current_bpf_ctx() || capable(cap);
}
There’s the sockopt_ prefix paying off: a BPF caller (has_current_bpf_ctx()) skips the check entirely — the kernel trusts its own programs. A userspace caller falls through to the real capable(cap).
One more wrinkle that explains a real-world surprise: the mark check uses sockopt_ns_capable(sock_net(sk)->user_ns, CAP_NET_X), and the device-bound check uses plain ns_capable(net->user_ns, CAP_NET_RAW) — both tested against the socket’s owning user-namespace, not the global one. That’s why an unprivileged container with its own user namespace can sometimes set a SO_MARK or re-bind to a device that looks like it should require root: inside its own userns it legitimately holds the slice. (The SO_BINDTODEVICE early branch is if (sk->sk_bound_dev_if && !ns_capable(net->user_ns, CAP_NET_RAW)) at net/core/sock.c:642 — note the leading sk->sk_bound_dev_if &&: the capability is only required to change the device once a socket is already bound; binding an unbound socket needs none. The SO_MARK gate accepts RAW-or-ADMIN at net/core/sock.c:1527.)

The shape every case shares
You’re about to read a catalog of options, but they all have the same skeleton — the one the “What to read” list calls out. Each case in sk_setsockopt (and its per-protocol cousins):
- copies
optvalin, validatingoptlen(this is thesockptr_tcopy from above); - takes the socket lock —
lock_sockfrom Day 13, which serializes against the data path so a knob change can’t race a concurrentsendmsg; - mutates a field of
struct sock(orstruct tcp_sock) — the same already-known struct from Day 13; - releases the lock.
So a sockopt is, mechanically, “lock the socket, write one field, unlock.” Keep that in mind and the catalog reads as a tour of which field each knob writes.
SOL_SOCKET — generic options
These work on any socket regardless of protocol family. Implementation: sk_setsockopt (net/core/sock.c:1195), reached via the thin sock_setsockopt wrapper (net/core/sock.c:1685).
SO_RCVBUF / SO_SNDBUF — buffer sizes
- What: maximum bytes queued in the socket’s receive (
sk_rcvbuf) or send (sk_sndbuf) queue — the twostruct sockfields you met on Day 13. - Why: larger buffers absorb more burst traffic without dropping or back-pressuring. For high-BDP paths the default may be too small to keep the pipe full — see the BDP box below.
- When: any high-throughput TCP server. Default
tcp_rmem/tcp_wmemtriplets give a baseline;SO_RCVBUF/SO_SNDBUFoverride per-socket. - Gotcha: the kernel doubles the value you set (one half is for kernel data structures); reads return the doubled value. This is in
sock_setsockopt— the SO_SNDBUF case doubles at theset_sndbuflabel (~line 1351). Also: setting these disables the kernel’s automatic buffer auto-tuning (the BDP box explains exactly how). - Where:
net/core/sock.c:1337(SO_SNDBUF case) andnet/core/sock.c:1369(SO_RCVBUF case).
Background: BDP and buffer auto-tuning
Two phrases in the section above — “high-BDP” and “auto-tuning” — are load-bearing and have never been defined. Here they are.
Bandwidth-delay product (BDP) = bandwidth × round-trip time. It is the number of bytes that are “in flight” — already sent but not yet acknowledged — when a pipe is running full. Picture a hose: bandwidth is how fat the hose is, RTT is how long it is; BDP is how much water the hose holds. To keep a link busy, the sender must be allowed to have at least one BDP of unacknowledged data outstanding, which means the send buffer (and the peer’s advertised receive window) must each be at least one BDP. A 10 Gbps link at 100 ms RTT has a BDP of
10e9/8 × 0.1 ≈ 125 MB— vastly larger than any default buffer. That is why the “high-bandwidth × high-latency” phrasing matters: on a fat, long path a too-small buffer caps throughput no matter how fast the link is.Auto-tuning is how Linux copes without making you guess. By default (
tcp_moderate_rcvbuf = 1) the kernel growssk_rcvbufon its own as it measures the connection’s BDP rising, staying within thetcp_rmem[min, default, max]triplet.tcp_wmemis the send-side equivalent triplet. Left alone, a busy long-fat connection’s buffers swell to fit the pipe automatically.The gotcha mechanism, in three beats:
- The userlock opts you out of auto-tuning. Calling
setsockopt(SO_RCVBUF)orSO_SNDBUFsets a “userlock” bit —SOCK_RCVBUF_LOCK/SOCK_SNDBUF_LOCK. You can see it in__sock_set_rcvbuf(net/core/sock.c:967):sk->sk_userlocks |= SOCK_RCVBUF_LOCK;at:975. After that the kernel never grows the buffer for you again.- The arithmetic: clamp first, then double. The value you ask for is first clamped to
sysctl_wmem_max/sysctl_rmem_max(net/core/sock.c:1343clamps SO_SNDBUF; SO_RCVBUF clamps at:1375), and then doubled for kernel-data overhead —sk_rcvbuf = max_t(int, val * 2, SOCK_MIN_RCVBUF)(:987), with the comment spelling out why. So the stored buffer can be up to twice the sysctl ceiling (2 * wmem_max/2 * rmem_max). The SO_SNDBUF path is the mirror image atnet/core/sock.c:1343-1351.- The consequence — an explicit small value can hurt. Because you turned off the autopilot, a value that’s too small throttles throughput worse than the default would have: you set a low ceiling and then forbade the kernel from raising it. On a long-fat path, either don’t set these at all, or set them generously.

SO_REUSEADDR — bind on TIME_WAIT
- What: allow bind() to succeed even when a recent socket on the same port is in TIME_WAIT.
- Why: server restart shouldn’t have to wait for TIME_WAITs to clear. (Recall TIME_WAIT from Day 15: the ~60 s wait the active closer sits in so late duplicate segments can’t be mistaken for a new connection. Without
SO_REUSEADDR, a restarting server’sbind()fails withEADDRINUSEwhile old connections linger there.) - When: every server. Set immediately after
socket(). - Gotcha: does not allow two sockets to bind the same port simultaneously — that’s
SO_REUSEPORT. The names are similar; the semantics are distinct. - Where:
net/core/sock.c:1321.
SO_REUSEPORT — multiple sockets sharing one port
- What: lets N sockets (in the same UID and netns) bind the same
(addr, port). Kernel hashes incoming connections to spread across them. - Why: scale a multi-process/multi-thread server across cores. Each worker has its own listening socket; no contention on the accept queue.
- When: any high-fanout server (nginx, envoy, custom Go/Rust servers). Day 24 covers it in detail.
- Gotcha: all sockets must set this before bind. UID match required (security: don’t let user A snipe user B’s port). The hash is per-flow, so a single client always lands on the same worker.
- Where:
net/core/sock.c:1324. The dispatch logic is ininet_csk_get_portand__inet_lookup_listener.
SO_KEEPALIVE — periodic liveness probes
- What: kernel sends periodic empty TCP segments to keep idle connections alive (and detect when the peer is gone).
- Why: detect dead peers (NAT box rebooted, peer crashed) without application-level heartbeats.
- When: long-lived connections behind NAT or load balancers (whose state may time out). Default off; specific applications turn it on.
- Gotcha: the keepalive intervals are system-wide sysctls (
net.ipv4.tcp_keepalive_time= 7200s,tcp_keepalive_intvl= 75s,tcp_keepalive_probes= 9). For per-socket overrides, useTCP_KEEPIDLE,TCP_KEEPINTVL,TCP_KEEPCNT— see SOL_TCP. - Where:
net/core/sock.c:1390.
SO_LINGER — block close() until data flushed (or RST)
- What: struct linger { l_onoff, l_linger }. If on,
close()blocks until either pending data is sent orl_lingerseconds elapse; in the latter case, RST is sent. - Why: ensure the peer either gets the data or knows the connection abruptly ended.
- When: rare. Useful for testing (RST to force immediate close) or high-stakes data delivery (transactional protocols).
- Gotcha: with
l_onoff=1, l_linger=0,close()immediately sends RST instead of FIN — useful for forced shutdowns but breaks graceful close. Withl_linger>0,close()may block for up tol_lingerseconds. - Where:
net/core/sock.c:1404.
SO_BINDTODEVICE — restrict to one interface
- What: bind socket to a specific net_device (by name). Outbound packets go through that device only; inbound only matches if arrived on it.
- Why: multi-homed hosts where you want a daemon to use a specific path (e.g., always send through the management NIC).
- When: management daemons, VPN clients, custom multipath logic.
- Gotcha: binding an unbound socket to a device needs no capability at all (deliberately enabled for non-root users so unprivileged processes can use VRFs);
CAP_NET_RAWis only required to change the device once a socket is already bound — thesk->sk_bound_dev_if &&guard atnet/core/sock.c:642. Bypasses normal routing — be aware that you’re disabling the kernel’s path selection. The check is tested against the socket’s user-namespace, so a container with its own userns may hold it. - Where:
net/core/sock.c:1210(a special early branch) andnet/core/sock.c:2045.
SO_MARK — set fwmark on outbound packets
- What: sets
skb->markfor every packet sent on this socket. Pairs withip rule fwmark X lookup TABLEfor socket-driven policy routing. - Why: route this app’s traffic differently (VPN, custom gateway) without iptables tagging. (Recall Day 9:
skb->markis theu32scratch field on the sk_buff, andip rule fwmark … lookup TABLEselects a non-default FIB table based on it — that’s the policy-routing machinery this option feeds. We’re just stamping the mark at the socket instead of in iptables.) - When: VPN clients, multi-homed daemons, traffic-engineering tools.
- Gotcha: requires
CAP_NET_RAWorCAP_NET_ADMIN(either suffices in v7.1 — a mark both “routes differently” and “reconfigures”). The mark is applied at socket level — packets generated by this socket carry it; received packets do not get it from this option. - Where:
net/core/sock.c:1527.
SO_PRIORITY — qdisc priority class
- What: sets
skb->priorityfor outbound packets. Used by qdiscs (notablypfifo_fast’s priority bands) for scheduling. - Why: mark interactive traffic as higher priority.
- When: if you control the egress qdisc and want app-controlled scheduling. With modern
fq_codel(default), this matters less. - Where:
net/core/sock.c:1223(SO_PRIORITY case insk_setsockopt).
SOL_IP / SOL_IPV6 — IP-layer options
Implementation: do_ip_setsockopt (net/ipv4/ip_sockglue.c:892).
IP_TOS — set DSCP/ECN bits
- What: sets the IP header’s TOS byte for outbound packets. The byte splits 6 + 2: the top 6 bits are the DSCP field, the low 2 bits are ECN.
- Why: mark traffic for QoS classification (DSCP) or ECN-capable signaling. (Recall DSCP from Day 9’s routing-selector boxes, and ECN from Day 16’s DCTCP, which consumes ECN-CE marks. This option is just the userspace handle that writes those bits into the header.)
- When: QoS-aware applications. Check egress qdisc respects DSCP.
- Gotcha: the value you set must match what your qdisc expects — many networks rewrite or strip DSCP.
- Where:
net/ipv4/ip_sockglue.c:1057(the case comment notes it “sets both TOS and Precedence”).
IP_TTL — outbound TTL
- What: override default TTL (usually 64) for outbound packets.
- When: traceroute (TTL=1), packet experiments. Most apps leave default.
- Where:
net/ipv4/ip_sockglue.c:1026.
IP_PKTINFO — receive auxiliary info per packet
- What: with
IP_PKTINFO=1,recvmsgaux data includes the destination address the packet was sent to and the interface it arrived on. - Why: UDP servers bound to
0.0.0.0need to know which local IP a request was destined to (so the reply uses the same source). WithoutIP_PKTINFO, you’d have to bind one socket per IP. - When: DHCP, DNS, SIP servers — any UDP service on a multi-homed host.
- Where:
net/ipv4/ip_sockglue.c:952.
IP_TRANSPARENT — bind to non-local addresses
- What: allows
bind()to a non-local IP. Combined with iptablesTPROXY, lets you intercept traffic destined elsewhere. - Why: transparent proxies (squid, HAProxy with
transparent, Envoy in some modes). - When: L7 transparent proxies, packet capture tools that re-inject.
- Gotcha: requires
CAP_NET_RAWorCAP_NET_ADMIN(either suffices — see the capability gate: binding a non-local address is “look/route differently,” reconfiguring interception is “admin,” so it straddles both), plus a corresponding TPROXY iptables rule + policy routing. - Where:
net/ipv4/ip_sockglue.c:1010.
IP_FREEBIND — bind before address is configured
- What: allow
bind()to an IP that doesn’t currently exist on any local interface. - Why: services that come up before networking is fully configured (failover daemons, daemons binding to floating IPs).
- Where:
net/ipv4/ip_sockglue.c:988.
SOL_TCP — TCP-specific options
Implementation: do_tcp_setsockopt (net/ipv4/tcp.c:3840).
TCP_NODELAY — disable Nagle
- What: Nagle’s algorithm batches small writes (waits for ACK or full segment before sending).
TCP_NODELAY=1disables this — every write goes immediately. - Why: latency-critical apps (interactive: SSH, X, gaming, real-time messaging) where waiting for a batch is unacceptable. (Recall Nagle from Day 3, the third gate in the TX path: don’t send a new small segment while a previous small one is still unacked — coalesce instead, recorded in
tp->nonagle.TCP_NODELAYflipstp->nonagleso the gate stops holding small writes.) - When: request-response protocols where each request fits in <1 segment and latency matters. Most modern servers.
- Gotcha: if both NODELAY and CORK are off, default Nagle applies. NODELAY beats CORK for any individual
write. - Where:
net/ipv4/tcp.c:3970.
TCP_CORK — buffer until full or uncorked
- What: opposite of NODELAY. Hold writes until either an MSS-sized segment fills up or
TCP_CORK=0. (It’s a stronger, deliberate version of the Nagle batching from Day 3 — instead of “coalesce only while a small segment is unacked,” it says “coalesce everything until I say go.”) - Why: an application building a multi-piece response (header + body) wants to ensure they go in one segment.
- When: static-file servers (
sendfile()followed by uncork), HTTP servers building structured responses. - Gotcha: corked sockets will eventually send (after ~200 ms) even without uncorking. But don’t rely on that — explicitly uncork.
- Where:
net/ipv4/tcp.c:4043.
TCP_QUICKACK — disable delayed ACKs (one-shot)
- What: TCP normally delays ACKs by a short bounded time — typically ~40 ms (
TCP_DELACK_MIN), capped at 200 ms (TCP_DELACK_MAX) in ping-pong mode — hoping to piggyback on outgoing data.TCP_QUICKACK=1forces an immediate ACK on the next received segment, then reverts to normal. - Why: request-response patterns where you’ve just sent a request and the next segment is the response — you want to ACK fast so the server’s CC algorithm sees it.
- When: rarely useful at the application level. The kernel already enters “quickack mode” automatically in some scenarios.
- Gotcha: it’s a one-shot, not a persistent setting.
- Where:
net/ipv4/tcp.c:4062(TCP_QUICKACK case indo_tcp_setsockopt).
Background: what “delayed ACK” actually is
TCP_QUICKACKoverrides a mechanism the book hasn’t introduced (Day 17 covered loss and retransmission, not this timer). Delayed ACK: after receiving data, TCP doesn’t ACK immediately — it waits a short, bounded time (typically ~40 ms —TCP_DELACK_MIN= HZ/25,include/net/tcp.h:154— and capped at 200 ms —TCP_DELACK_MAX= HZ/5,:150— in ping-pong mode, further bounded by the connection’s measured RTT) hoping that either a reply payload comes along so the ACK can ride on it (piggybacking), or a second data segment arrives so one ACK covers both. It trades a little latency for far fewer pure-ACK packets. The timer lives in the TCP output path —tcp_send_delayed_ack(net/ipv4/tcp_output.c:4408) uses the RTT estimate to bound the delay (“use it to bound delayed ack”,net/ipv4/tcp_output.c:4424), and sending real data clears any pending delayed ACK (“Send it off, this clears delayed acks for us”,net/ipv4/tcp_output.c:4499).Why it matters for the request/response case: in strict ping-pong traffic there’s often no return payload to piggyback on and no second segment coming, so the wait (typically ~40 ms, up to 200 ms) is pure dead time on the critical path.
TCP_QUICKACK(or the kernel’s own quickack heuristics) cuts that delay. It reverts afterward because in steady bulk transfer delaying ACKs is the right default.

TCP_CONGESTION — choose CC algorithm
- What: name string of CC algorithm (e.g., “cubic”, “bbr”). Replaces the default for this socket.
- Why: different connections want different algorithms; per-socket override is the cleanest way. (Recall Day 16: the pluggable congestion-control framework and the per-socket
tcp_set_congestion_control. This swaps the CC module for one socket — the congestion windowsnd_cwndand the rest of thestruct tcp_sockstate from Day 13 are then driven by the new algorithm.) - When: specialized workloads (intra-datacenter wants DCTCP; cross-WAN wants BBR).
- Gotcha: the algorithm must be loaded (
modprobe tcp_bbr). Checktcp_available_congestion_control. Switching to some non-default algorithms requiresCAP_NET_ADMIN. - Where:
net/ipv4/tcp.c:3851.
TCP_USER_TIMEOUT — give up after milliseconds
- What: abort the connection if data has been unacked for the given time, regardless of RTO retries.
- Why: keep-alive isn’t enough — you want hard “give up after 30 seconds” semantics.
- When: any application where stale connections are worse than aggressive aborts (RPC clients, request/response protocols with deadlines).
- Gotcha: in milliseconds (other timeouts are seconds — check the docs). 0 = use system default (which is roughly 15 min via RTO retry).
- Where:
net/ipv4/tcp.c:3922.
TCP_FASTOPEN — TCP Fast Open (TFO)
- What: server-side: queue up to N early-data connections (data carried in the SYN). Client-side: cookie-based optimistic data send.
- Why: save one RTT on connection establishment for repeated connections.
- When: services with frequent reconnects (CDN edges, mobile clients).
- Gotcha: middleboxes often strip the TFO cookie. Sysctl
tcp_fastopenconfigures support; bit 1 enables client, bit 2 enables server. - Where:
net/ipv4/tcp.c:4101(TCP_FASTOPEN case indo_tcp_setsockopt). The state machine is more complex than this option suggests.
TCP_INFO — read tcp_info struct
- What: getsockopt-only. Returns
struct tcp_infowith rtt, cwnd, retrans count, state, etc. - Why: observability without parsing
/proc/net/tcp. (Recall Day 16:tcp_get_infofills this struct from the livestruct tcp_sock—snd_cwnd,srtt_us,ca_state. This is the userspace handle on that state.) - When: monitoring agents, performance debugging.
- Where:
net/ipv4/tcp.c— searchtcp_get_info(line 4213). Read this function: it’s the source of truth for whatss -tinshows.
TCP_TX_DELAY — artificial outbound delay (test/emulation only)
- What: delay outgoing packets by a fixed number of microseconds (optname 37). The kernel adds the delay to
skb->skb_mstamp_nsand inflates the connection’ssrtt_us/icsk_rtoso the rest of the stack behaves as if the path RTT were that much larger. - Why: network emulation for testing — let a test on localhost (or a short path) behave like a high-latency WAN link without real distance or
tc netem. Added in commit a842fe1425cb. - When: test harnesses and benchmarks only. Not a production tuning knob.
- Gotcha: this has nothing to do with BBR or pacing. BBR paces via
sk_pacing_rate+ thefqqdisc, not this option;TCP_TX_DELAYonly adds artificial latency. - Where:
net/ipv4/tcp.c:4157(TCP_TX_DELAY case indo_tcp_setsockopt).
BPF can override most of these
A sock_ops BPF program (eBPF Day 19) can call bpf_setsockopt() to set sockopts kernel-side. The genuinely new capability this unlocks is policy by cgroup or flow rather than by application: a single BPF program can stamp a fleet-wide rule the apps never opted into — e.g. “every socket from the high-priority cgroup uses BBR” — overriding whatever each process set itself. (Mechanically it’s exactly the second caller from the sockptr_t section: KERNEL_SOCKPTR into the same sk_setsockopt, has_current_bpf_ctx() sailing past the capability gate.) Day 19 builds one.
There are no Dumb Questions
Q: Why does
getsockopttakeoptlenby pointer whensetsockopttakes it by value?A: Because
getsockopt’soptlenis value-result. On the way in it tells the kernel how big your buffer is (so the kernel won’t overflow it); on the way out the kernel overwrites it with how many bytes it actually wrote.setsockoptonly ever reads, so a plain value is enough. This is exactly why today’s experiment passes&gland&clby address and re-initializescl = sizeof ccbefore the secondTCP_CONGESTIONread — the first call shrank it to the length of"cubic".
Q: I set
SO_RCVBUFto a big number and throughput got worse. How?A: You opted out of auto-tuning (the
SOCK_RCVBUF_LOCKuserlock) and, depending on the value, may have set a ceiling below what the kernel would have grown to on its own for your BDP. The autopilot was doing a better job. Either don’t set it at all on a long-fat path, or set it generously (and remember the kernel doubles it, then clamps atsysctl_rmem_max).
Q: My container sets
SO_MARKwithout being root. Is that a bug?A: No. The check is
sockopt_ns_capable(sock_net(sk)->user_ns, …)— it’s against the socket’s user namespace, not the global one. A container with its own user namespace can legitimately holdCAP_NET_RAW/CAP_NET_ADMINinside that namespace, so marks that look privileged from the host’s view are allowed from inside.
Q: How can one C function (
sk_setsockopt) safely take a pointer that’s sometimes userspace and sometimes kernel?A: That’s the whole job of
sockptr_t— a union plus anis_kernelbit. Userspace callers wrap withUSER_SOCKPTR(copy_from_user), BPF callers wrap withKERNEL_SOCKPTR(plain memcpy). The function reads the bit, not your intentions.
Today’s experiment
# See sockopts on live sockets
ss -tipsm | head -20 # m=memory accounting
# SET options and READ them back via getsockopt — exercises the day's core verb
cat << 'EOF' > /tmp/tcpinfo.c
#include <stdio.h>
#include <stdlib.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <arpa/inet.h>
#include <unistd.h>
int main(int argc, char **argv) {
int s = socket(AF_INET, SOCK_STREAM, 0);
/* (1) SET a buffer size and watch the kernel double it — no connection needed */
int v = 65536, g; socklen_t gl = sizeof g;
setsockopt(s, SOL_SOCKET, SO_RCVBUF, &v, sizeof v);
getsockopt(s, SOL_SOCKET, SO_RCVBUF, &g, &gl);
printf("SO_RCVBUF: set %d, got %d (kernel doubled it)\n", v, g);
/* (2) SET congestion control for THIS socket only (no global sysctl) */
char cc[32]; socklen_t cl = sizeof cc;
getsockopt(s, IPPROTO_TCP, TCP_CONGESTION, cc, &cl);
printf("cc before: %s\n", cc);
setsockopt(s, IPPROTO_TCP, TCP_CONGESTION, "bbr", 3);
/* (3) connect, then READ tcp_info + the now-active CC name */
struct sockaddr_in a = { AF_INET, htons(argc > 2 ? atoi(argv[2]) : 80) };
inet_aton(argc > 1 ? argv[1] : "8.8.8.8", &a.sin_addr);
if (connect(s, (struct sockaddr*)&a, sizeof a) < 0) { perror("connect"); return 1; }
struct tcp_info ti;
socklen_t l = sizeof ti;
getsockopt(s, IPPROTO_TCP, TCP_INFO, &ti, &l);
cl = sizeof cc;
getsockopt(s, IPPROTO_TCP, TCP_CONGESTION, cc, &cl);
printf("rtt %u us, cwnd %u, rwnd %u, retrans %u, ca_state %u, cc %s\n",
ti.tcpi_rtt, ti.tcpi_snd_cwnd, ti.tcpi_snd_wnd,
ti.tcpi_total_retrans, ti.tcpi_ca_state, cc);
return 0;
}
EOF
cc /tmp/tcpinfo.c -o /tmp/tcpinfo && /tmp/tcpinfo
The default target is 8.8.8.8:80, so this needs outbound TCP/80 reachability. On an offline or egress-firewalled box, start a local listener and point the program at it instead (the program takes host port as args):
nc -l 127.0.0.1 18080 & # any high port avoids needing root to bind :80
/tmp/tcpinfo 127.0.0.1 18080
kill %1 # stop the listener
Expected output (numbers vary; loopback gives a tiny rtt):
SO_RCVBUF: set 65536, got 131072 (kernel doubled it)
cc before: cubic
rtt 37 us, cwnd 10, rwnd 65483, retrans 0, ca_state 0, cc bbr
What each line proves:
set 65536, got 131072— the chapter’s headline gotcha (the__sock_set_rcvbufdoubling). The kernel stored twice what you asked; the extra half is bookkeeping overhead. (The doubled value is clamped at2 * net.core.rmem_max, so very large sets get capped.) Thissetsockoptalso silently set theSOCK_RCVBUF_LOCKuserlock — this socket is now opted out of auto-tuning.cc before: cubic→cc bbr— the per-socketsetsockopt(TCP_CONGESTION)took effect, andgetsockoptreads it back.cubicis the typical kernel default, but if your box already defaults tobbr(checksysctl net.ipv4.tcp_congestion_control),cc beforewill readbbrand the override is a visual no-op — set the default tocubicfirst, or read the source line to confirm the per-socket write still happened. This changes only this socket — the system-wide default (net.ipv4.tcp_congestion_control) is never touched, so there is no global state to restore.bbrmust be available (modprobe tcp_bbr; checksysctl net.ipv4.tcp_available_congestion_control). Note the program resetscl = sizeof ccbefore this second read — because the firstgetsockoptshrankclto the value-result length of"cubic".rttistcpi_rtt=srtt_us >> 3in microseconds;cwnd 10is the initial congestion window;ca_state 0isTCP_CA_Open(the normal, no-loss state).
What to read in the kernel
-
net/socket.c:2349—do_sock_setsockopt. The dispatcher. Read it to see how(level, optname, value)flows to the right protocol code. Notice thesockptr_tabstraction that lets BPF (kernel-space caller) and userspace use the same setsockopt path —USER_SOCKPTRwraps the syscall buffer at:2396. -
include/linux/sockptr.h:14— thesockptr_tunion +is_kernelbit itself. Five lines; read them once and the BPF-vs-syscall convergence clicks. -
net/core/sock.c:1685—sock_setsockopt. The SOL_SOCKET entry — but it’s just a thin wrapper. The real work is insk_setsockopt(net/core/sock.c:1195): a long switch (~600 lines). Read the cases for the options you care about — each is its own micro-routine. Notice the consistent pattern: copy from user, validate, take socket lock (lock_sock, Day 13), update field, release lock. The capability one-linersockopt_capableis at:1173. -
net/core/sock.c:967—__sock_set_rcvbuf. TheSOCK_RCVBUF_LOCKuserlock and the “double it for overhead” comment in one place — the mechanism behind both buffer gotchas. -
net/ipv4/tcp.c:4175—tcp_setsockopt. The TCP-specific dispatcher; passes through todo_tcp_setsockopt(line 3840). Readdo_tcp_setsockoptend-to-end if you ever wonder “what does TCP_FOO actually do?” — every option has its case here. -
net/ipv4/tcp.c:3970— TCP_NODELAY case. ~10 lines. The simplest TCP option; useful as a starting reference. -
net/ipv4/tcp.c:4043— TCP_CORK case. Note the interaction with TCP_NODELAY (mutually exclusive in spirit but both can be set). -
net/ipv4/tcp.c:3851— TCP_CONGESTION case. Callstcp_set_congestion_control(Day 16). Notice thecap_net_adminrequirement for some non-default algorithms. -
net/ipv4/tcp.c:4213—tcp_get_info. Fillsstruct tcp_infoforTCP_INFOgetsockopt. Read this to know what fields are intcp_infoand where each comes from (rtt →tp->srtt_us >> 3at:4300, cwnd →tcp_snd_cwnd(tp)at:4241, etc.). -
net/ipv4/ip_sockglue.c:892—do_ip_setsockopt. The IP-level dispatcher. Read the cases forIP_PKTINFO(line 952),IP_FREEBIND(line 988),IP_TRANSPARENT(line 1010) — they’re each illuminating examples of how a single line of userspace code unlocks a whole behavior. -
include/uapi/linux/tcp.h— TCP option constants. Skim to see the full list. There are ~50; you’ll meet most over a career. -
include/uapi/asm-generic/socket.h— generic SO_* constants. -
Documentation/networking/ip-sysctl.rst— sysctls related to TCP/IP behavior (much of which interacts with sockopts).
Bullet Points
- A sockopt is
(level, optname, optval, optlen).levelnames the namespace,optnamethe knob,optvala typed value,optlenits size — and ongetsockopt,optlenis value-result (pass the buffer size in, get the filled size back). - Dispatch:
do_sock_setsockopt(socket.c:2349) → SOL_SOCKET viasock_setsockopt→sk_setsockopt(sock.c:1195); other levels via thesk_protvtable (Day 13). Each case: copy in,lock_sock, write astruct sockfield, unlock. sockptr_tis a union (kernel ptr / user ptr) +is_kernelbit, so onesk_setsockoptserves both the syscall path (USER_SOCKPTR) and BPF (KERNEL_SOCKPTR). Thesockopt_prefix lets BPF skip the privilege check.- Capabilities slice root’s power:
CAP_NET_RAW= raw/packet sockets, re-bind an already device-bound socket, non-local bind;CAP_NET_ADMIN= force buffers, admin knobs, transparent proxy.SO_BINDTODEVICE→nothing to bind an unbound socket, RAW only to change device;SO_*BUFFORCE→ADMIN,SO_MARK/IP_TRANSPARENT→either. Checked against the socket’s user-namespace. SO_RCVBUF/SO_SNDBUF— buffers. The value is clamped tormem_max/wmem_maxfirst, then doubled (so the stored buffer can reach2*rmem_max/2*wmem_max); an explicit set trips the userlock and disables auto-tuning. BDP = bandwidth × RTT = bytes in flight; a too-small buffer caps throughput on high-bandwidth × high-latency paths.SO_REUSEADDR— bind over a port stuck in TIME_WAIT (Day 15).SO_REUSEPORT— multiple sockets, one port (Day 24).TCP_NODELAY— disable Nagle (Day 3; flipstp->nonagle).TCP_CORK— its opposite; batch until uncork.TCP_QUICKACK— one-shot override of delayed ACK (typically a ~40 ms wait to piggyback an ACK on return data, capped at 200 ms in ping-pong mode).TCP_CONGESTION— pick CC algorithm per-socket (Day 16).TCP_USER_TIMEOUT— hard deadline (ms) for unacked data.TCP_INFO(getsockopt) — full TCP stats fromtcp_get_info; equivalent toss -tin.IP_TOS— TOS byte = 6-bit DSCP (Day 9) + 2-bit ECN (Day 16).IP_PKTINFO— UDP servers learn which local IP a packet was sent to.IP_TRANSPARENT— bind to non-local IP for transparent proxies.- BPF (
cgroup_sockops) can override all of these from kernel-side via the samesk_setsockopt.
Check question
Why might TCP_NODELAY and TCP_CORK look like opposites yet sometimes both be useful on the same socket at different times?
Click to reveal answer
Answer: They serve different needs at different points in the connection’s lifecycle. A request/response server might CORK while building a multi-piece response (header + body — the application calls write() multiple times to compose), then uncork — the kernel sends one efficient large segment. Right after the response, the app might set NODELAY so any tail data (a small “200 OK” follow-up, a heartbeat) doesn’t sit waiting for Nagle. Real apps mix both: cork for batching prepared output, nodelay for ad-hoc small writes between batches. The point: the application knows when batching helps (assembling a large response) and when latency matters more (everything else). Sockopts let it switch dynamically. (The kernel even confirms they coexist: net/ipv4/tcp.c:3640 notes “TCP_CORK can be set together with TCP_NODELAY and it is stronger than TCP_NODELAY.”)
Tomorrow
Day 19: epoll and io_uring for sockets. The two ways modern userspace waits on many sockets efficiently.
Day 19 — epoll and io_uring for sockets
Today’s mission: understand how userspace efficiently waits on many sockets, and why io_uring is taking over from epoll for the highest-throughput servers. We’ll first build the two pieces of machinery the whole chapter rests on — blocking vs. non-blocking sockets and the kernel wait queue a socket sleeps on — and only then open epoll, which is nothing more than a clever hook into that wait queue. Total time: ~110 minutes.
The problem they solve
A naive blocking server handles one client at a time. To handle many, you can:
- Fork a process per client (Apache prefork, ~early-2000s style). Heavy.
- Spawn a thread per client (1:1 threading). Lighter than processes but still ~MB per client and lots of context switches.
- Use one thread for many clients with non-blocking I/O. Read/write only when ready; never block on individual sockets. Now you need to know which socket is ready.
That last bullet is what epoll and io_uring solve — but with totally different mechanics.

But before any of that makes sense, we have to be precise about two words in bullet 3 that the rest of the chapter leans on constantly: “block” and “ready.” What does it mean for recv() to block? What machine puts the thread to sleep, and what wakes it? And what does “ready” actually test? Those two questions are the same question, and answering it is the key that unlocks epoll’s internals. So we’ll teach that machinery first — intuition, then the v7.1 structs — and only then walk the epoll API.
Background 1: blocking, non-blocking, and the EAGAIN contract
A blocking recv() puts your thread to sleep
By default, a socket is blocking. Call recv() on a TCP socket that has no data waiting, and your thread does not spin, poll, or return an error — it goes to sleep. The kernel parks the task, schedules something else onto the CPU, and only wakes your task when data arrives. From userspace it looks like one long syscall that “took a while”; underneath, the thread was off the run queue entirely.
That is exactly why bullet 3’s “many clients in one thread” is hard. If your one thread calls recv() on client A and A is quiet, the thread sleeps inside that syscall — and clients B, C, D get no service, even if they have data ready right now. One blocking thread serves one slow client. The whole readiness model only works if syscalls never sleep.
O_NONBLOCK flips the switch: return instead of sleep
Setting O_NONBLOCK on a socket changes the contract. You set it with fcntl(fd, F_SETFL, O_NONBLOCK), or get it atomically at creation with SOCK_NONBLOCK passed to socket() / accept4(). Now recv, accept, and send return immediately:
- If there’s data (or a pending connection, or send-buffer room), they do the work and return normally.
- If there’s nothing to do, instead of sleeping they return
-1witherrno == EAGAIN.
EAGAIN (spelled EWOULDBLOCK on Linux — same value) is not an error. It is the kernel saying “not ready right now; there’s nothing here for you, try again later.” It’s the non-blocking world’s version of “would have slept.”
Why epoll and non-blocking I/O are a matched pair
This is the missing half of the readiness model. epoll_wait (we meet it below) tells you which FDs are ready. You then do the syscall yourself — and EAGAIN is the signal that you’ve drained this FD for now, so you stop and move to the next ready FD.
Without O_NONBLOCK, the design breaks: even after epoll_wait says “FD is readable,” a blocking recv() could still sleep — for instance if a checksum-failed segment got dropped between the readiness notification and your read, leaving the socket empty again. One stalled recv() and your whole event loop freezes. Non-blocking mode is what guarantees the loop keeps spinning. epoll without O_NONBLOCK is a loaded footgun.
Edge-triggered mode makes EAGAIN mandatory
There are two ways epoll can report readiness (we’ll formalize them later):
- Level-triggered (default): epoll re-reports an FD for as long as it still has data. If you read only half the bytes, the next
epoll_waithappily tells you again. Forgiving. - Edge-triggered (
EPOLLET): epoll notifies you on each new data arrival (a fresh wake of the socket’s wait queue), not continuously while data sits unread. It will not re-report unread bytes on its own — only a new wake fires it again.
So under edge-triggered mode you must loop:
/* Edge-triggered drain: read until the kernel says EAGAIN */
for (;;) {
ssize_t n = recv(fd, buf, sizeof buf, 0);
if (n > 0) { consume(buf, n); continue; } /* more queued — keep going */
if (n == 0) { close_peer(fd); break; } /* orderly shutdown */
/* n < 0 */
if (errno == EAGAIN) break; /* drained: nothing left until next arrival */
handle_error(fd); break;
}
If you stop early — say you read one buffer’s worth and trust epoll to remind you — the leftover bytes just sit there. Edge-triggered already fired its one notification for that transition; the next epoll_wait stays silent until brand-new data triggers another edge. Your reader is wedged with data it never picked up. Draining to EAGAIN is the only correct edge-triggered idiom. Level-triggered forgives a partial read precisely because it re-reports while data remains.
The same loop shape shows up for accepting connections under EPOLLEXCLUSIVE (later in the chapter): a woken worker calls accept4() in a loop until EAGAIN to drain the listen backlog, then goes back to sleep.

Background 2: wait queues — the sleep/wake machine under every socket
We just said a blocking recv() “goes to sleep” and “gets woken when data arrives.” Time to make that literal, because this exact mechanism is what epoll hooks into. Get this and epoll’s internals stop being magic.
A wait queue is a list of sleepers plus a wake function
When a thread must wait for an event (“data on this socket”), the kernel needs somewhere to record “this task is sleeping, and here’s how to wake it.” That record-keeper is a wait queue: a wait_queue_head_t is just a list of waiters, and each waiter is a struct wait_queue_entry that points at a sleeping task plus a wake callback function to run when the event fires.
/* include/linux/wait.h:15 */
typedef int (*wait_queue_func_t)(struct wait_queue_entry *wq_entry,
unsigned mode, int flags, void *key);
/* include/linux/wait.h:28 */
struct wait_queue_entry {
unsigned int flags;
void *private; /* usually the task to wake */
wait_queue_func_t func; /* the callback — remember this field */
struct list_head entry;
};
That .func field is the whole story of today’s chapter. For an ordinary sleeper it’s a generic “wake this task” routine. epoll, as we’ll see, swaps in its own function — and that swap is the entire trick.
Every socket carries a wait queue; recv() sleeps on it
A blocking recv() that finds the socket empty adds itself to the socket’s wait queue and sleeps. You can see it plainly in sk_wait_data (net/core/sock.c:3269):
int sk_wait_data(struct sock *sk, long *timeo, const struct sk_buff *skb)
{
DEFINE_WAIT_FUNC(wait, woken_wake_function);
int rc;
add_wait_queue(sk_sleep(sk), &wait); /* park on the socket's queue */
sk_set_bit(SOCKWQ_ASYNC_WAITDATA, sk);
rc = sk_wait_event(sk, timeo,
skb_peek_tail(&sk->sk_receive_queue) != skb, &wait);
/* ... woken: remove and return ... */
remove_wait_queue(sk_sleep(sk), &wait);
return rc;
}
sk_sleep(sk) is the socket’s wait queue head. The thread links a wait_queue_entry onto it and blocks. Hold onto that queue — when the chapter later says epoll registers on “the same wait queue recvmsg would block on,” this sk_sleep(sk) queue is the one it means.
The wake comes from sk_data_ready → sock_def_readable
So who wakes the sleeper? On the receive side, every socket has a callback pointer sk->sk_data_ready, fired whenever the protocol layer has just enqueued a packet for the socket. (Day 14 showed __udp_enqueue_schedule_skb calling sk->sk_data_ready(sk) (net/ipv4/udp.c:1745) to “wake any recvmsg waiters” — that one line is the wake. Here we explain the queue it kicks.)
The default sk_data_ready is sock_def_readable, installed at socket creation (net/core/sock.c:3734). Day 14 showed its full body; the one line that matters here is the wake itself (net/core/sock.c:3614):
wake_up_interruptible_sync_poll(&wq->wait, EPOLLIN | EPOLLPRI |
EPOLLRDNORM | EPOLLRDBAND);
This walks the socket’s wait queue and, for each waiter, calls that waiter’s .func, passing EPOLLIN (and friends) as the key argument. For a blocking recv(), .func wakes the parked task and recv() returns with data. Notice the kernel passes EPOLLIN — the very same bit you’ll put in your epoll_event. That’s not a coincidence; it’s the next idea.
->poll(): one call that both registers and reports readiness
Every pollable file (sockets, pipes, eventfds, even other epoll FDs) exports a ->poll() file operation. The kernel calls it through vfs_poll(file, pt) with a small helper called a poll_table, and ->poll() does two jobs in one shot:
- Register the caller on the file’s wait queue (using the
poll_table), so the caller will be woken on future readiness. - Return a readiness bitmask right now —
EPOLLINif there’s data to read,EPOLLOUTif there’s send-buffer room, etc.
This is the deep reason the EPOLLIN in your epoll_event is the same bit the kernel uses internally: readiness is one bitmask shared by ->poll(), by sock_def_readable’s wake key, and by your userspace event struct. They are literally the same flags.
So an ordinary blocking wait threads through exactly this chain — recv() parks on sk_sleep(sk), a packet’s sk_data_ready walks the queue, your entry’s .func wakes the task, recv() returns (the numbered flow below spells out each hop). epoll changes exactly one thing in that chain: which .func gets registered. That’s the next section.

epoll — the readiness model
The mental model: “Tell me when this FD is ready for I/O. I’ll do the syscall myself.”
int epfd = epoll_create1(0);
struct epoll_event ev = { .events = EPOLLIN | EPOLLET, .data.fd = sock };
epoll_ctl(epfd, EPOLL_CTL_ADD, sock, &ev);
struct epoll_event events[64];
int n = epoll_wait(epfd, events, 64, 100);
for (int i = 0; i < n; i++) {
/* events[i].data.fd is ready — go read/write it (non-blocking, drain to EAGAIN) */
}
Three syscalls:
epoll_create1(fs/eventpoll.c:2200): create an epoll instance (a kernel object referenced by the returned FD).epoll_ctl(fs/eventpoll.c:2385): ADD, MOD, or DEL an FD from the instance.epoll_wait(fs/eventpoll.c:2467): block until one or more registered FDs become ready, or until the timeout.
And note EPOLLIN | EPOLLET and the “drain to EAGAIN” comment in the loop — that’s Background 1 and Background 2 cashing out in the actual API.
Internals
With wait queues in hand, the internals are short. The epoll instance is a struct eventpoll (fs/eventpoll.c:172), and four of its fields carry the whole design:
struct eventpoll {
/* ... mutex ... */
wait_queue_head_t wq; /* where epoll_wait() callers sleep */
wait_queue_head_t poll_wait; /* for an epoll fd nested inside another epoll */
struct list_head rdllist; /* the READY list: FDs that fired */
struct rb_root_cached rbr; /* the registered FDs, an rb-tree */
/* ... */
};
Three lists/queues, and they map one-to-one to what epoll does:
rbr— a red-black tree of every FD you’ve ADDed, keyed for O(log N) ADD/DEL/lookup. This is the registry. Each ADDed FD becomes astruct epitem— the per-FD record — stored inrbr; that epitem is what gets linked ontordllistwhen the FD fires.rdllist— the ready list: FDs that have become ready and are waiting to be reported byepoll_wait.wq— the wait queue yourepoll_waitcall sleeps on when nothing is ready yet (the same wait-queue machinery from Background 2, but now it belongs to the epoll object, not a socket).
Now the wiring. When you ADD an FD, ep_insert (fs/eventpoll.c:1566) calls the FD’s ->poll() and hands it a special poll_table whose “register me” callback is ep_ptable_queue_proc (fs/eventpoll.c:1360). Here’s the twist that makes epoll fast — instead of registering a generic “wake me” entry on the socket’s wait queue, it installs a wait-queue entry whose .func is epoll’s own ep_poll_callback:
/* fs/eventpoll.c:1360, ep_ptable_queue_proc */
init_waitqueue_func_entry(&pwq->wait, ep_poll_callback); /* custom .func! */
pwq->whead = whead;
pwq->base = epi;
if (epi->event.events & EPOLLEXCLUSIVE)
add_wait_queue_exclusive(whead, &pwq->wait); /* see EPOLLEXCLUSIVE below */
/* else add_wait_queue(whead, &pwq->wait); */
That whead is exactly sk_sleep(sk) — the same socket wait queue a blocking recv() would park on (Background 2). epoll just hangs a different kind of waiter on it.
So follow the flow when data arrives, and every step is something you already know:
- A packet lands; the protocol layer enqueues it on the socket and calls
sk->sk_data_ready→sock_def_readable→wake_up_interruptible_sync_poll(&wq->wait, EPOLLIN|...)(Background 2). - That walks the socket’s wait queue and calls each entry’s
.func. For epoll’s entry,.funcisep_poll_callback(fs/eventpoll.c:1249). ep_poll_callbackruns, links this FD’sepitemonto the eventpoll’srdllist(list_add_tail(&epi->rdllink, &ep->rdllist),fs/eventpoll.c:1294), and wakes anyone sleeping onep->wq— i.e. yourepoll_wait.epoll_waitwakes,ep_send_events(fs/eventpoll.c:1765) copies the ready FDs into yourepoll_event[]array, and you get yourn.
That callback-instead-of-plain-wakeup indirection is the entire “magic” — and it’s nothing more than swapping the .func you learned about in Background 2. The same ->poll() that registers the callback also returns the current readiness bitmask (via ep_item_poll → vfs_poll(file, pt), fs/eventpoll.c:1057), which is how an FD that’s already ready at ADD time lands on rdllist immediately.

Level-triggered vs edge-triggered
We met these in Background 1; here’s where they live in the code.
- Level-triggered (LT, default): epoll_wait reports the FD as long as it’s ready. Read what you can, leave the rest; epoll_wait will report it again next time.
- Edge-triggered (ET,
EPOLLETflag): epoll_wait reports the FD only when its readiness changes (e.g., went from “no data” to “data arrived”). You must drain the FD until you getEAGAIN, or you’ll miss subsequent data.
ET is faster (fewer wake-ups) but more error-prone. LT is what you want unless you know exactly why ET helps.
The LT/ET difference is implemented in one branch of ep_send_events. EPOLLET is stored per-epitem (it’s one of the private epoll bits: EP_PRIVATE_BITS = (EPOLLWAKEUP | EPOLLONESHOT | EPOLLET | EPOLLEXCLUSIVE), fs/eventpoll.c:86) — it’s never passed down to the FD’s ->poll(), which is why re-arming is purely an epoll-side decision. After copying an FD’s events to userspace, ep_send_events does (fs/eventpoll.c:1835):
} else if (!(epi->event.events & EPOLLET)) {
/*
* If this file has been added with Level Trigger mode, we need to
* insert back inside the ready list, so that the next call to
* epoll_wait() will check again the events availability.
*/
list_add_tail(&epi->rdllink, &ep->rdllist); /* fs/eventpoll.c:1847 */
ep_pm_stay_awake(epi);
}
For level-triggered, the FD is re-added to rdllist so the next epoll_wait re-checks it — that’s “LT re-reports while data remains.” For edge-triggered, that branch is skipped: the FD leaves rdllist and won’t come back until ep_poll_callback fires again on a fresh readiness transition. That single if is the entire LT-vs-ET behavior, and it’s the source-level reason a partial read under ET strands your data.
EPOLLEXCLUSIVE — solving thundering herd
Default semantics: when an event fires on an FD that has multiple epoll_wait waiters, all of them wake. For an accept socket shared by N workers, this means a newly completed connection (the final ACK lands, the connection joins the accept queue, and tcp_child_process calls the listener’s sk_data_ready, net/ipv4/tcp_minisocks.c:1005) wakes all N — N-1 immediately call accept, get EAGAIN (Background 1 — non-blocking accept on an empty backlog), go back to sleep. Wasted wake-ups. (Note it’s connection completion, not the SYN, that wakes the listener: the SYN path only queues the request and sends SYN-ACK.)
EPOLLEXCLUSIVE (added 4.5) tells epoll: wake only one waiter per event. The implementation is the add_wait_queue_exclusive branch we saw in ep_ptable_queue_proc (fs/eventpoll.c:1360) — an exclusive waiter tells the wait-queue walker to stop after waking the first responsive thread instead of waking the whole list. Modern multi-process servers should use it.
(Aside: SO_REUSEPORT — Day 24 — is a more powerful alternative. Each worker has its own listening socket; the kernel hashes incoming SYNs across them, so each worker’s epoll only ever sees SYNs that were destined for it.)
io_uring — the completion model (preview)
epoll’s model is readiness: “tell me when this FD is ready; I’ll do the syscall myself.” io_uring inverts it to completion: “do this network operation for me — tell me when it’s done.” Instead of learning which FD is ready and then issuing recv/send yourself, you hand the kernel the whole operation through two memory-mapped ring buffers:
- Submission Queue (SQ): userspace pushes Submission Queue Entries (SQEs) describing work to do.
- Completion Queue (CQ): the kernel pushes Completion Queue Entries (CQEs) when the work finishes.
The practical contrast that matters today:
- epoll: 2 syscalls per I/O —
epoll_waitto learn readiness, thenrecv/send. - io_uring: ~1 syscall per batch (
io_uring_entercarrying many SQEs at once), or zero in steady state withIORING_SETUP_SQPOLL(a kernel thread polls the SQ).
For sustained request rates above ~100k/sec per thread that saving is real; below that it’s invisible, and epoll’s smaller, battle-tested API is the right default (nginx, redis, node.js via libuv, and most Python/Go/Rust async runtimes all ride epoll). Hold onto the one distinction — epoll signals readiness; io_uring signals completion — and pick it up in depth on Day 28, which covers the full network op set, multishot recv, provided buffers, and zero-copy send.
There are no Dumb Questions
Q: Is
EAGAINan error I should log and bail on?A: No — it’s the opposite.
EAGAIN(==EWOULDBLOCKon Linux) on a non-blocking socket means “nothing here right now.” On a freshly-reported FD it means “you’ve drained everything queued; stop reading and go handle the next ready FD.” Logging it as an error would flood your logs on every event loop tick. The onlyrecvreturns that are real errors aren < 0with some other errno (e.g.ECONNRESET).
Q: If a blocking
recv()sleeps on the socket’s wait queue, and epoll also registers on that same queue, do they fight?A: They don’t, because they’re different kinds of waiter on the same list. A blocking
recv()adds an entry whose.funcwakes the parked task; epoll adds an entry whose.funcisep_poll_callback. As the 4-step flow showed, whensock_def_readablewalks the queue it calls each entry’s.funcin turn. You normally wouldn’t do both on one FD, but structurally the wait queue happily holds both — that’s the point of the.funcindirection (Background 2).
Q: Why does
EPOLLETrequire non-blocking sockets butEPOLLIN(level-triggered) seems to tolerate blocking ones?A: Neither is truly safe with a blocking socket, but ET makes the bug unavoidable. ET fires one notification per readiness transition, so you must loop
recvuntilEAGAINto consume everything — and that loop requiresEAGAIN, which only a non-blocking socket produces. A blocking socket’s finalrecvin that loop would sleep forever instead of returningEAGAIN, freezing the thread. LT seems to tolerate blocking because it re-reports, so a one-read-per-event style mostly works — until the one read it permits blocks (e.g. data vanished after the notification), and the whole loop stalls. Always useO_NONBLOCKwith epoll.
Q: Where does the
EPOLLINbit I pass actually go?A: It’s one shared bitmask across three places: your
epoll_event.events, the FD’s->poll()return value, and thekeyargumentsock_def_readablepasses towake_up_interruptible_sync_poll(&wq->wait, EPOLLIN|...). epoll stores your interest mask per-epitem, the wake passes the fired bits as the key, andep_poll_callbackchecks the overlap before linking the epitem ontordllist. Same flags, end to end.
Today’s experiment
epoll wakeup tracking
Setup. We need an epoll-based server and a load generator. nginx fits (it uses the readiness model on every connection), and ab (ApacheBench) drives it. Install both, then start nginx — binding port 80 needs root, and nginx daemonizes itself, so no &:
sudo apt-get install -y nginx apache2-utils
sudo nginx
(Any already-running epoll-based server works too — the probe below is system-wide, so it captures every epoll_wait on the box, not just nginx’s.)
Terminal 1 — start the trace. This runs in the foreground and blocks the terminal, so the load must come from a second terminal:
sudo bpftrace -e '
tracepoint:syscalls:sys_enter_epoll_wait,
tracepoint:syscalls:sys_enter_epoll_pwait,
tracepoint:syscalls:sys_enter_epoll_pwait2 { @waits = count(); }
tracepoint:syscalls:sys_exit_epoll_wait,
tracepoint:syscalls:sys_exit_epoll_pwait,
tracepoint:syscalls:sys_exit_epoll_pwait2 /args->ret >= 0/ { @returns = hist(args->ret); }
interval:s:5 { print(@waits); print(@returns); clear(@waits); clear(@returns) }'
Why all three syscalls? nginx on x86_64 issues the bare epoll_wait, but Go and Node/libuv use epoll_pwait, and on arm64/riscv glibc routes epoll_wait() through epoll_pwait — so a single-syscall probe leaves @waits empty and looks broken. epoll_pwait2 exists on 5.11+. The /args->ret >= 0/ filter drops the -1 (EINTR) returns, which hist() would otherwise bin into a confusing negative bucket.
Terminal 2 — generate load:
ab -n 100000 -c 100 http://127.0.0.1/
What you’ll see. Idle (nginx running but no traffic), @returns is dominated by the [0] bucket — every epoll_wait timed out with no FD ready, and @waits is small:
@waits: 8
@returns:
[0] 7 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@|
Once ab runs, @waits climbs into the thousands per interval and the histogram fills the low positive buckets — each epoll_wait now returns one or more ready sockets:
@waits: 438
@returns:
[0] 176 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ |
[1] 252 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@|
[2, 4) 8 |@ |
That batching of several ready FDs into one return is exactly the readiness-model payoff: one epoll_wait syscall amortized over N sockets. A [1]-heavy histogram is the low-concurrency case; the buckets shift right as more sockets become ready between calls. And each of those returns is the tail end of the Background-2 chain firing: a completed connection or a data segment arrived, sk_data_ready walked the socket’s wait queue, ep_poll_callback appended the FD to rdllist and woke ep->wq, and this is the epoll_wait waking up.
Cleanup. When you’re done, stop the server:
sudo nginx -s stop
(For the io_uring side — an async accept loop plus tracing the kernel-side net ops — see Day 28’s experiment.)
Optional: watch the wakeup callback itself
To see the wait-queue callback from Background 2 / the internals section fire in real time, trace ep_poll_callback while load runs:
sudo bpftrace -e 'kprobe:ep_poll_callback { @[comm] = count(); } interval:s:5 { print(@); clear(@); }'
Each count is one FD-readiness transition that moved an epitem onto an rdllist. Under ab load you’ll see it climb in lockstep with @waits above — that’s the sk_data_ready → ep_poll_callback → rdllist → wake path you read in the code, made visible.
What to read in the kernel
-
fs/eventpoll.c:2200—epoll_create1. Tiny wrapper that allocates aneventpollstruct, gets an FD. Read the struct definition above this; that’s the per-instance state (thewq,rdllist,rbryou just learned). -
fs/eventpoll.c:2385—epoll_ctl. The ADD/MOD/DEL dispatcher. Notice howEPOLL_CTL_ADDcallsep_insertwhich registers the wait-queue callback on the target FD. That’s where the magic happens — once the callback is registered, the FD reports readiness to epoll automatically. -
fs/eventpoll.c:1360—ep_ptable_queue_proc. Thepoll_tablecallback that->poll()invokes to register the waiter. This is the exact line (init_waitqueue_func_entry(&pwq->wait, ep_poll_callback)) where epoll hooks its custom.funconto the socket’s wait queue instead of a plain wakeup. -
fs/eventpoll.c:1938—ep_poll. The wait path. Read end to end (~120 lines). Notice how it handles spurious wakes, the busy-loop fast path for low-latency cases, and how it dequeues from the rdllist. -
fs/eventpoll.c:1765—ep_send_events. Copies the ready list to the user’sepoll_eventarray. For LT, re-arms the FD if still ready (list_add_tail(&epi->rdllink, &ep->rdllist)at line 1847). For ET, doesn’t. -
fs/eventpoll.c:1249—ep_poll_callback. The wait-queue callback that gets called when a registered FD becomes ready. Short (~100 lines). This is where readiness gets translated into an epoll event and the epitem is linked ontordllist. -
net/core/sock.c:3614—sock_def_readable, the defaultsk_data_ready(installed at line 3734). Thewake_up_interruptible_sync_poll(&wq->wait, EPOLLIN|...)here is what firesep_poll_callback. Read it alongsidesk_wait_data(line 3269) to see the blockingrecv()side of the same queue. -
io_uring — for the
io_uring/net.candio_uring/io_uring.centry points, the liburing examples, and the man pages, see Day 28’s “What to read.” Today, focus on the epoll path above.
Bullet Points
- Blocking
recv()sleeps by parking await_queue_entryon the socket’s wait queue (sk_sleep(sk), viask_wait_data,net/core/sock.c:3269). One blocking thread serves one slow client — which is why the readiness model needs non-blocking I/O. O_NONBLOCKmakesrecv/accept/sendreturn immediately; “nothing ready” comes back as-1/EAGAIN(==EWOULDBLOCK).EAGAINis not an error — it’s “not ready / drained for now.” epoll + non-blocking sockets are a matched pair;EPOLLETmakes draining toEAGAINmandatory.- Wait queue = a list of sleepers, each a
wait_queue_entrywith a.funccallback (include/linux/wait.h:28). The wake on RX issk_data_ready→sock_def_readable→wake_up_interruptible_sync_poll(&wq->wait, EPOLLIN|...)(net/core/sock.c:3614), which calls each waiter’s.func. ->poll()does two jobs at once: register the caller on the file’s wait queue and return a readiness bitmask. TheEPOLLINin yourepoll_event, in->poll()’s return, and in the wake key are the same flags.- epoll = readiness model. “Tell me when ready, I’ll syscall.” It hooks a custom
.func(ep_poll_callback) onto the socket’s wait queue viaep_ptable_queue_proc(fs/eventpoll.c:1360); when it fires, the epitem is appended tordllistandepoll_wait(sleeping onep->wq) wakes. Three structures:rbr(registered FDs),rdllist(ready FDs),wq(epoll_wait sleepers). - Three syscalls:
epoll_create1,epoll_ctl,epoll_wait. - Level-triggered (default) re-adds the epitem to
rdllistinep_send_events(fs/eventpoll.c:1847); edge-triggered (EPOLLET) skips that branch — ET requires draining toEAGAIN. EPOLLEXCLUSIVE— wake only one waiter per event (add_wait_queue_exclusive). Solves thundering herd.- io_uring = completion model: submit ops, collect completions; ~1 syscall per batch (zero with
SQPOLL). Covered in depth on Day 28. - For most servers, epoll. For sustained > 100k ops/sec per thread or batched workloads, io_uring.
Check question
Why is EPOLLEXCLUSIVE important for accepting connections in a multi-worker server, and what’s a more powerful alternative?
Click to reveal answer
Answer: Without EPOLLEXCLUSIVE, when a connection completes its handshake and lands on the accept queue of a listening socket shared by N workers, all of them get woken from epoll_wait. (The wake fires on connection completion — tcp_child_process calling the listener’s sk_data_ready after the final ACK — not on the SYN.) The first to call accept() succeeds; the rest immediately call accept(), get EAGAIN (non-blocking accept on a now-empty backlog), and go back to sleep. That’s “thundering herd” — wasted CPU, wasted scheduler decisions, lost cache locality. EPOLLEXCLUSIVE (added 4.5) tells the kernel “wake only one waiter per event” — implemented as add_wait_queue_exclusive in ep_ptable_queue_proc, so the wait-queue walker stops after the first responsive thread — eliminating the wasted wake-ups.
The more powerful alternative is SO_REUSEPORT (Day 24). Instead of N workers sharing one listening socket, each worker creates its own listening socket bound to the same (addr, port). The kernel hashes the incoming connection’s 4-tuple and dispatches to one specific socket. Each worker’s epoll only ever sees connections that belong to it — there’s no shared FD, so no thundering herd is even possible. As a bonus, the kernel’s 4-tuple hash spreads connections deterministically across workers (each connection’s packets always reach the same worker — that’s connection affinity, not per-client affinity, since the ephemeral source port is part of the hash), with SO_ATTACH_REUSEPORT_[CE]BPF available to customize the selection.
End of Phase 3
You can now read the L4 layer: socket lifecycles, UDP, TCP states + congestion control + retransmission, sockopts, the modern wait APIs.
Phase 4 (Days 20–26) goes into the kernel’s network subsystems: netfilter, nftables, conntrack, traffic control, SO_REUSEPORT, kTLS, MPTCP.
Next, Day 20 opens the packet-mangling machinery: netfilter’s five hook points and how a packet traverses them.
Day 20 — Netfilter hooks
Today’s mission: see exactly where in the network stack
iptables/nftablesrun, the verdict pipeline that turns a rule decision into a packet action, and the per-protocol/per-netns hook tables that make it all per-namespace. Along the way you’ll learn the two mechanisms the chapter quietly leans on — static keys (why an idle firewall costs literally zero per packet) and NAT (what DNAT/SNAT/masquerade actually rewrite, and why conntrack must pair them). Total time: ~110 minutes.
Phase 4 starts here. Days 20–26 cover the kernel’s network subsystems: netfilter, nftables, conntrack, traffic control,
SO_REUSEPORT, kTLS, MPTCP.
What Netfilter is
Netfilter is the kernel’s framework for inspecting, modifying, and dropping packets at well-defined points along the network stack. iptables, nftables, conntrack, IPVS, ebtables, and arptables all hook into this same framework. Understanding the hook layout means understanding where every firewall/NAT rule actually runs.
The framework is intentionally minimal: it’s just a set of hook points, a registration API, and a verdict pipeline. Specific filtering logic (matching rules, computing actions) lives in the consumers (nf_tables, ip_tables, nf_conntrack).
We met netfilter once already, on Day 2: ip_rcv ends with NF_HOOK(NFPROTO_IPV4, NF_INET_PRE_ROUTING, ...) before routing. Today we open that macro up.
The five hooks (per protocol family)

For IPv4 (NFPROTO_IPV4), defined at include/uapi/linux/netfilter.h:42:
enum nf_inet_hooks {
NF_INET_PRE_ROUTING, // 0 — after IP header validation, before routing decision
NF_INET_LOCAL_IN, // 1 — packets routed to local sockets
NF_INET_FORWARD, // 2 — packets being routed through us
NF_INET_LOCAL_OUT, // 3 — packets we're generating
NF_INET_POST_ROUTING, // 4 — just before TX
NF_INET_NUMHOOKS
};
(The real enum also carries a trailing NF_INET_INGRESS = NF_INET_NUMHOOKS (value 5) — a special-purpose inet ingress hook; the five entries above are the traditional hooks we focus on.)
The five hooks are positioned around the routing decision — two before it, the FORWARD/LOCAL_IN split right after it, and POSTROUTING after that. That’s not arbitrary; the NAT primer below shows why the layout is forced to be exactly this shape.
Where each one fires
-
PRE_ROUTING runs in
ip_rcv(net/ipv4/ip_input.c), right afterip_rcv_corevalidates the header, before the routing decision (this is theNF_HOOKyou saw at the end of Day 2’sip_rcv). This is where you do DNAT (destination NAT — change where the packet is going) because the routing decision uses the post-rewrite destination. -
LOCAL_IN runs in
ip_local_deliverafter the routing decision determines the packet is for us. Last chance to drop before delivery to a socket. iptables’INPUTchain (a chain = the ordered list of rules a tool attaches at one hook; full treatment Day 21). -
FORWARD runs in
ip_forwardafter routing determines the packet is not for us. iptables’FORWARDchain. It is the transit-only filter point, but a forwarded packet also passed through PREROUTING before the route lookup and will pass through POSTROUTING before TX. -
LOCAL_OUT runs in
__ip_local_outfor packets we’re generating, immediately after the IP header is built. Note: by the time this fires the initial route lookup has already run (ip_route_output_flowprecedesip_local_out, soskb_dstis set). If a NAT rule here changes the destination, the original route no longer applies and the packet must be re-routed (ip_route_me_harder,net/ipv4/netfilter.c:22). -
POST_ROUTING runs in
ip_outputafter all routing decisions, just before handing to the device. This is where you do SNAT (source NAT, including masquerading) because the new source IP doesn’t change where the packet goes from here.
These five firing sites are the same ones Day 2 (RX) and Day 8 (routing) walked through; we lean on that routing decision here rather than re-teaching it.
IPv6 has equivalents
Same enum, same five hooks, all with NFPROTO_IPV6. Plus NFPROTO_BRIDGE (a separate set: PREROUTING, LOCAL_IN, FORWARD, LOCAL_OUT, POSTROUTING for bridged frames) and NFPROTO_ARP (3 hooks for ARP packets).
Per-netns, per-priority hook lists
Each (net, protocol_family, hook_id) triple has its own list of registered callbacks. Recall struct net from Day 5 — the network namespace. It holds these lists at net->nf.hooks_ipv4[NF_INET_NUMHOOKS] (include/net/netns/netfilter.h:22, reached via net->nf at include/net/net_namespace.h:149), net->nf.hooks_ipv6[], etc. Registering a hook = appending to that list at a given priority. (We don’t re-teach netns here; Day 5 owns it. Just remember each namespace gets its own independent hook tables.)
Priority determines order. Constants in enum nf_ip_hook_priorities (include/uapi/linux/netfilter_ipv4.h):
NF_IP_PRI_CONNTRACK_DEFRAG = -400, // conntrack defrag (earliest)
NF_IP_PRI_RAW = -300, // raw table
NF_IP_PRI_CONNTRACK = -200, // conntrack itself
NF_IP_PRI_MANGLE = -150, // mangle table
NF_IP_PRI_NAT_DST = -100, // dnat
NF_IP_PRI_FILTER = 0, // filter table
NF_IP_PRI_NAT_SRC = 100, // snat
NF_IP_PRI_LAST = INT_MAX,
Lower numbers run first. So at PRE_ROUTING, conntrack runs before NAT, which runs before user filter rules — because that’s the order priorities give. Hold onto NF_IP_PRI_NAT_DST = -100 and NF_IP_PRI_NAT_SRC = 100; the NAT primer explains why DNAT sits before the filter rules and SNAT sits after.
Background: static keys — how an idle hook costs literally zero
The headline claim of this whole chapter is “netfilter on an idle box has no measurable per-packet cost.” You’ll see it stated again in How a hook gets invoked (“zero overhead”) and in What to read in the kernel (“zero-cost-when-unused machinery”). But that claim leans on a mechanism the chapter never names: the static key (a.k.a. jump label). Let’s make it real, because without it the zero-cost story is just a hand-wave.
The problem with a normal if
The obvious way to make a feature optional is a global flag:
if (netfilter_enabled)
nf_hook_slow(...); // slow path
okfn(skb); // next pipeline stage
That if is cheap but not free. Every single packet — and the RX path is the hottest code in the kernel — must load netfilter_enabled from memory, compare it, and let the branch predictor guess. On an idle box with no firewall rules, that’s pure waste: a load and a branch paid millions of times per second to discover “nope, still disabled.” Worse, it consumes a branch-predictor slot on the hottest path in the kernel.
The trick: patch the machine code, not test a variable
A static key is a runtime-toggleable branch whose cost-when-disabled is literally zero, because the kernel patches the machine code at the branch site instead of testing a variable. The idea:
- When the key is off, the branch site holds a
nop(or an unconditional jump straight past the slow path). The CPU sails through — no load, no compare, no predictor pressure. - When something flips the key on, the kernel rewrites those exact bytes in place (via the
text_pokecode-patching primitive) into a real jump into the slow path.
So the predicate is paid for once, at registration time — not on every packet. The cost moves entirely off the data path. This is the standard kernel idiom for “a feature that is usually off but must be free when off”; tracepoints use it too, and — connecting forward — so does the UDP-tunnel encap_rcv divert you saw on Day 12 (static_branch_unlikely(&udp_encap_needed_key), net/ipv4/udp.c:2364). Day 12 used a static branch without ever defining it; this is the chapter that finally does.
The primitives live in include/linux/jump_label.h — static_key_false(), DEFINE_STATIC_KEY_TRUE/FALSE (lines ~19–24) — and the whole optimization is behind CONFIG_JUMP_LABEL. Without that config the code falls back to a plain if-on-a-variable branch.
How netfilter uses it
Netfilter keeps one static key per (family, hook) pair:
/* include/linux/netfilter.h:212 */
extern struct static_key nf_hooks_needed[NFPROTO_NUMPROTO][NF_MAX_HOOKS];
The guard at the top of nf_hook() (include/linux/netfilter.h:227, fast-out at lines 235–240) is:
#ifdef CONFIG_JUMP_LABEL
if (__builtin_constant_p(pf) &&
__builtin_constant_p(hook) &&
!static_key_false(&nf_hooks_needed[pf][hook]))
return 1; // "1" == ACCEPT: just run okfn, no hook walk
#endif
Two subtleties:
-
Why
__builtin_constant_p? The compiler can only emit one patchable site per(family, hook)ifpfandhookare compile-time constants. That’s exactly why every caller passes literals likeNFPROTO_IPV4, NF_INET_PRE_ROUTINGrather than variables — so the compiler bakes in onenf_hooks_needed[NFPROTO_IPV4][NF_INET_PRE_ROUTING]site it can patch. -
Who flips it? Registration.
__nf_register_net_hookcalls the wrappernf_static_key_inc()(net/netfilter/core.c:447), which doesstatic_key_slow_inc(&nf_hooks_needed[pf][hooknum])at:370; unregister goes throughnf_static_key_dec()(:508→static_key_slow_decat:385). So the first hook to register at a given(family, hook)patches that one site fromnopto a live jump intonf_hook_slow; the last to leave patches it back. (One subtlety: forNFPROTO_INET+NF_INET_INGRESSthe wrapper remaps the key tonf_hooks_needed[NFPROTO_NETDEV][NF_NETDEV_INGRESS]before incrementing.)
The payoff: with no hooks registered, nf_hooks_needed[pf][hook] is off, the NF_HOOK site is a nop, and okfn(skb) — the next pipeline stage — is reached with no function call into nf_hook_slow at all. Register one hook and the kernel live-patches that single (pf, hook) site. That is the concrete meaning of “zero overhead when no hooks.”

How a hook gets invoked
The kernel uses the macro NF_HOOK(pf, hook, net, sk, skb, indev, outdev, okfn). With the static key you just learned: if no hooks are registered (the nf_hooks_needed[pf][hook] key is off), the patched-out site inlines okfn(...) directly — zero overhead. If hooks exist, the key has been flipped on and the macro calls nf_hook_slow (net/netfilter/core.c:612):
- Walks the registered list at
net->nf.hooks_<family>[hook]in priority order. - Calls each hook function with
nf_hook_state(the context) and skb. - Each hook returns a verdict.
- If all return ACCEPT, calls
okfn(skb)— the next stage in the pipeline.
The verdicts
#define NF_DROP 0 // free skb, stop
#define NF_ACCEPT 1 // proceed
#define NF_STOLEN 2 // hook took ownership; don't free
#define NF_QUEUE 3 // queue to userspace via NFQUEUE
#define NF_REPEAT 4 // re-run from the start of this hook
#define NF_STOP 5 // accept, but don't continue to the next hook (rare)
NF_ACCEPTis the common path: continue to the next hook, then tookfn.NF_DROPends the journey; skb is freed (with a drop-reason ifkfree_skb_reasonis used — recall theenum skb_drop_reasonfrom Day 1).NF_STOLENis for hooks that take ownership and will free or forward the skb later (used by IPVS, conntrack defrag).NF_QUEUEsends the skb to a userspace process via the NFQUEUE protocol (libnetfilter_queue). Userspace returns a verdict via netlink.NF_REPEATre-runs the same hook (used by some rule engines for re-evaluation after a state change).NF_STOPremains in the UAPI for compatibility but is deprecated; treat it as historical, not something new code should return.
The verdict can also pack additional data — for NF_QUEUE, the queue number; for NF_DROP, an errno. The high bits of the return value carry these: NF_VERDICT_MASK = 0x000000ff extracts the verdict (used by nf_hook_slow’s verdict & NF_VERDICT_MASK), while NF_VERDICT_QMASK = 0xffff0000 holds the queue number / errno in the top 16 bits. Helpers NF_QUEUE_NR(x) and NF_DROP_ERR(x) pack those high bits; NF_DROP_GETERR() unpacks the errno.
Who registers what
You can see all hooks in a netns via:
sudo nft list hooks
(Requires recent nftables.) Output looks like:
family ip {
hook prerouting {
-0000000400 ipv4_conntrack_defrag [nf_defrag_ipv4]
-0000000200 ipv4_conntrack_in [nf_conntrack]
-0000000100 nf_nat_ipv4_pre_routing [nf_nat]
...
}
hook input {
...
}
}
Each line: priority, function name, owning module. You’ll typically see conntrack at low priorities, the filter chains at 0, and SNAT/DNAT around ±100.
Background: NAT — what DNAT, SNAT, and masquerade actually do
The chapter’s concrete trace below is entirely NAT-driven, and its punchline (“the SNAT mirrors the DNAT so reply traffic comes back symmetrically”) assumes you already understand address rewriting and the symmetric-reply problem. No earlier chapter teaches NAT — Day 5 only name-dropped it. So before the trace pays off, here’s the primer.
The core idea: rewriting addresses at the boundary
NAT (Network Address Translation) = rewriting the source or destination IP/port of a packet as it crosses the box, so a machine can speak to the outside world under a different address than its own. The classic case: a home/office LAN of 192.168.1.0/24 machines, none of which has a public IP, all reaching the internet through one gateway’s single public WAN address.
There are two directions, and the names tell you which field gets rewritten:
- DNAT (destination NAT) rewrites where the packet is GOING — the destination IP/port. Used for port-forwarding: “traffic to my WAN IP:80 should actually go to the internal web server
10.0.0.5:8080.” - SNAT (source NAT) rewrites where the packet appears to come FROM — the source IP/port. Used so a LAN machine’s packets appear to originate from the gateway’s WAN IP.
- Masquerade is just SNAT where the new source IP is auto-picked as the outgoing interface’s current address — the right choice for a gateway whose WAN IP is dynamic (DHCP/PPP), so you don’t have to hard-code it.
Why the hook placement is forced, not arbitrary
Here’s the part that makes the five-hook layout click. The placement of DNAT and SNAT is dictated by the routing decision, not chosen for convenience:
-
DNAT must run at PREROUTING — before routing. The routing decision that follows reads the destination address to decide where the packet goes. If you rewrote the destination after routing, the packet would already be heading to the wrong place. So DNAT has to happen first, at PREROUTING (priority
NF_IP_PRI_NAT_DST = -100, before the filter rules at 0). -
SNAT must run at POSTROUTING — after routing. By the time POSTROUTING fires, routing is done and the source address no longer affects where the packet goes. So it’s safe (and correct) to rewrite the source only at the very end (priority
NF_IP_PRI_NAT_SRC = 100, after the filter rules).
The kernel states this rule in its own words (net/netfilter/nf_nat_core.c:682–684):
/* ... For NF_INET_POST_ROUTING, we change the source to map into the
* range. For NF_INET_PRE_ROUTING and NF_INET_LOCAL_OUT, we change the
* destination ... */
and the source-vs-destination decision keys off exactly that hook. nf_nat_inet_fn computes the manip type from the hook number (net/netfilter/nf_nat_core.c:905):
/* maniptype == SRC for postrouting. */
enum nf_nat_manip_type maniptype = HOOK2MANIP(state->hook);
HOOK2MANIP is defined in include/net/netfilter/nf_nat.h:19 as ((hooknum) != NF_INET_POST_ROUTING && (hooknum) != NF_INET_LOCAL_IN) — its comment reads “SRC manip occurs POST_ROUTING or LOCAL_IN”. So SNAT (SRC manip) happens at POSTROUTING or LOCAL_IN, and everything else is DST manip.
This is the concrete reason there are five hooks around the routing decision rather than one. The routing decision sits in the middle, DNAT must be upstream of it, SNAT downstream.
Why conntrack is mandatory for NAT
Now the symmetric-reply problem. Suppose the gateway rewrites an outbound packet 192.168.1.10 → 1.2.3.4 into WAN_IP → 1.2.3.4 (SNAT). The reply comes back addressed to WAN_IP — because that’s all the remote end ever saw. The gateway must know to rewrite that reply back to 192.168.1.10, or the LAN machine never gets its answer.
How does it remember? It records the flow in the conntrack table the first time it sees the connection (state NEW), and applies the inverse rewrite to every reply packet of that flow. That’s why:
- NAT rules are written as if they apply to a single packet, but they actually govern the whole connection — the rule fires on the first packet, conntrack mirrors the translation onto all the rest.
- Conntrack does its NEW-flow lookup at the two “first sight” hooks, PREROUTING and LOCAL_OUT (priority
NF_IP_PRI_CONNTRACK= -200, i.e. before NAT’s -100), so it sees every new flow before NAT does. You can see it plant at PREROUTING inipv4_conntrack_ops[](net/netfilter/nf_conntrack_proto.c:233):.hooknum = NF_INET_PRE_ROUTING,.priority = NF_IP_PRI_CONNTRACK. The same array also registers confirm hooks at POSTROUTING/LOCAL_IN (:245/:251, priorityNF_IP_PRI_CONNTRACK_CONFIRM) that commit the entry — full story Day 22.
This is a forward dependency. Conntrack gets its full chapter on Day 22 — its table structure, states, and timeouts. Here you need exactly one fact: conntrack records each flow once and mirrors the translation onto the reply. Don’t go looking for conntrack internals today.

A concrete trace
Now the payoff. For a forwarded TCP SYN to port 80 going through a NAT box — read it with the primer above in hand:
ip_rcvis called. CallsNF_HOOK(NFPROTO_IPV4, NF_INET_PRE_ROUTING, ...).nf_conntrack_in(priority -200): create or look up conntrack entry. State = NEW. (This is the “first sight” that lets step 4 mirror the translation.)nf_nat_ipv4_pre_routing(priority -100): apply DNAT rule if any. Maybe rewrites dst from1.2.3.4:80to10.0.0.5:8080. (DNAT before routing, exactly as the primer required.)- User filter at priority 0 (e.g.,
nft add rule ip filter prerouting accept). - All ACCEPT →
okfn = ip_rcv_finish.
ip_rcv_finishdoes the routing lookup using the (now possibly rewritten) destination. Decides this packet goes to10.0.0.5viaeth1.ip_forwardis called (since the packet is for someone else).NF_HOOK(NFPROTO_IPV4, NF_INET_FORWARD, ...).- User filter at priority 0 (e.g.,
iptables -A FORWARD -j ACCEPT).
- User filter at priority 0 (e.g.,
ip_output→NF_HOOK(NFPROTO_IPV4, NF_INET_POST_ROUTING, ...).nf_nat_ipv4_out(priority 100): apply SNAT/MASQUERADE if the NAT rule matched. Maybe rewrites src from192.168.1.10to the gateway’s WAN IP. (SNAT after routing, again as required.)- All ACCEPT → packet leaves on
eth1.
The conntrack entry created in step 1’s PREROUTING is what links steps 1 and 4: the SNAT in step 4 mirrors the DNAT in step 1 so reply traffic comes back symmetrically. (And when the reply arrives, conntrack at its PREROUTING recognizes the flow and applies the inverse rewrites — the symmetric path from the NAT diagram.)

There are no Dumb Questions
Q: If the static key makes a disabled hook a
nop, how is that different from the branch predictor “predicting not-taken” every time?A: Prediction is still work: the CPU fetches the flag, issues the compare, and occupies a predictor slot — and a misprediction (rare, but possible) costs a pipeline flush. A patched-out static key has no branch instruction there at all to predict; the bytes are a
nopor fall-through. Nothing to load, nothing to mispredict, no predictor pressure on the kernel’s hottest path. That’s the whole reason netfilter chose static keys over a global flag.
Q: I added one
nftrule. Did I just slow down every hook?A: No — only the one
(family, hook)site your rule attaches to.static_key_slow_incflips exactlynf_hooks_needed[pf][hooknum]for that hook (e.g. justLOCAL_INfor aninputchain). The other four hooks stay patched-outnops. Per-hook keys are why a box with only aninputfilter pays nothing at PREROUTING/FORWARD/POSTROUTING.
Q: Why does NAT need conntrack, but a plain
droprule doesn’t?A: A
dropdecision is stateless — it looks at one packet and acts. NAT is stateful: it rewrites the outbound packet, and must apply the inverse rewrite to replies it hasn’t seen yet. The only way to do that is to remember the flow. Conntrack is that memory (full story Day 22). That’s also why conntrack registers at the lower priority (-200) — it must record the flow before NAT (-100) rewrites it.
Q: What’s the difference between SNAT and masquerade, really?
A: Mechanically the same rewrite (source address), but SNAT uses a fixed address you specify, while masquerade auto-picks the outgoing interface’s current IP at packet time. Use SNAT when the WAN IP is static (slightly cheaper, no per-packet interface lookup); use masquerade when it’s dynamic (DHCP/PPP) so you never hard-code an address that might change.
Today’s experiment
# See registered hooks (modern nft only)
sudo nft list hooks ip
# Old way: per-table inspection
sudo iptables -L -n -v
sudo nft list ruleset
# Trace which hook fires when
sudo bpftrace -e 'fentry:nf_hook_slow {
printf("hook %d pf %d\n", args->state->hook, args->state->pf);
}' &
sleep 2 # let the fentry probe finish attaching (compile+load+attach takes ~1-2s)
ping -c 3 8.8.8.8
sleep 1 # let the last packet's events flush
sudo killall bpftrace
(That fentry:nf_hook_slow only fires at all because some hook is registered on your box — the static key for that hook is on. On a truly hookless (family, hook) the site is a nop and nf_hook_slow is never entered, so the probe would stay silent for it.)
The output is raw integers, one line per hook traversed — decode them with the enum above (hook is nf_hook_state.hook, pf is nf_hook_state.pf; the struct is at include/linux/netfilter.h:78):
hook 3 pf 2 # LOCAL_OUT, AF_INET — the outgoing echo request
hook 4 pf 2 # POSTROUTING — just before TX
hook 0 pf 2 # PREROUTING — the echo reply arrives
hook 1 pf 2 # LOCAL_IN — delivered to our socket
So hook decodes as 0=PREROUTING, 1=LOCAL_IN, 2=FORWARD, 3=LOCAL_OUT, 4=POSTROUTING, and pf 2 = AF_INET (IPv4; pf 10 = AF_INET6). For the ICMP exchange you’ll see LOCAL_OUT (3) and POSTROUTING (4) outbound, then PREROUTING (0) and LOCAL_IN (1) on the reply.
Add a hook of your own (via nft)
sudo nft add table inet test
sudo nft 'add chain inet test myinput { type filter hook input priority 0 ; policy accept ; }'
sudo nft add rule inet test myinput meta nftrace set 1
# Now any packet going through input gets nftrace logged
sudo nft monitor trace &
ping -c 1 8.8.8.8
sudo pkill -f 'nft monitor' # stop the backgrounded monitor once you've seen the trace
You see each rule evaluation with the verdict.
Drop a specific port and see it work
sudo nft add rule inet test myinput tcp dport 12345 drop
nc -w 2 localhost 12345 # HANGS, then times out after ~2s — DROP silently blackholes the SYN (no RST/ICMP)
nc -w 2 localhost 22 # connects (different port, not matched by the rule)
# Contrast drop with reject: reject *replies* (RST/ICMP) instead of blackholing.
sudo nft add rule inet test myinput tcp dport 12346 reject
nc -w 2 localhost 12346 # instant 'Connection refused' — reject sends an RST, unlike drop
# Cleanup
sudo nft delete table inet test
What to read in the kernel
-
net/netfilter/core.c:612—nf_hook_slow. The dispatcher. Read it end to end (~33 lines). Notice how it walks the per-hook list, dispatches each hook, and handles each verdict. Itsswitchonly handlesNF_ACCEPT/NF_DROP/NF_QUEUE/NF_STOLEN; anything else (includingNF_REPEAT/NF_STOP) falls to adefault: WARN_ON_ONCE(1). So if you go looking forNF_REPEAThandling here you won’t find it — repeat is implemented inside the consumers (e.g. conntrack returns-NF_REPEATto re-run itself), not in the dispatcher. -
net/netfilter/core.c:550—nf_register_net_hook. The registration function. How a module (nftables, conntrack, IPVS) plants its hook callback. Note the per-priority insertion (sorted insertion) — and thestatic_key_slow_inc(&nf_hooks_needed[pf][hooknum])at:370that flips the per-hook static key on, patching theNF_HOOKsite live. -
include/linux/netfilter.h—NF_HOOKmacro and friends, plusnf_hook()(line 227) with the static-key fast-out at lines 235–240, and thenf_hooks_needed[][]key array (line 212).NF_HOOKis conditional on thenf_hooks_needed[pf][hook]static key; if no hooks, it skips the call entirely. This is the zero-cost-when-unused machinery. -
include/linux/jump_label.h— the static-key/jump-label API itself:static_key_false(),DEFINE_STATIC_KEY_TRUE/FALSE(lines ~19–24). The code-patching primitive behind “zero cost when off.” Read the big comment block at the top — it explains the patched-branch model. -
include/uapi/linux/netfilter.h— verdicts, hook IDs, andenum nf_inet_hooks. The vocabulary file. -
net/netfilter/nf_nat_core.c— NAT translation. The comment at:682states the DNAT-at-PREROUTING / SNAT-at-POSTROUTING rule;nf_nat_inet_fnpicks SRC-vs-DST manip viaHOOK2MANIP(state->hook)at:905(macro ininclude/net/netfilter/nf_nat.h:19). Conntrack mirrors these (Day 22). -
net/ipv4/netfilter.c:22—ip_route_me_harder, the re-route after a LOCAL_OUT DNAT changes a self-originated packet’s destination. -
net/ipv4/netfilter/ip_tables.c— legacy iptables backend. Read the top to see howxt_table_info(the rule storage) is consulted; rules are linear, walked top-to-bottom per packet. Compare to nftables (next day), which evaluates compact expression sequences throughnft_do_chain. -
net/netfilter/nf_tables_core.c— modern nftables runtime. The expression interpreter. Read this after tomorrow’s nftables intro. -
net/netfilter/nf_conntrack_core.c— conntrack does its NEW-flow lookup at PRE_ROUTING and LOCAL_OUT (the two “first sight” hooks, priority -200) and registers separate confirm hooks at POSTROUTING/LOCAL_IN; seeipv4_conntrack_ops[]innf_conntrack_proto.c:233. Day 22 covers this. -
Documentation/networking/netfilter-sysctl.rst— netfilter sysctl knobs (the closest in-tree netfilter doc; there is no hook-diagram text file). See alsonf_conntrack-sysctl.rstandnf_flowtable.rst. -
External: nftables wiki at https://wiki.nftables.org — has nice diagrams of where each hook fires.
Bullet Points
- Five IPv4 hooks: PRE_ROUTING, LOCAL_IN, FORWARD, LOCAL_OUT, POST_ROUTING — positioned around the routing decision. Same five for IPv6.
- PREROUTING = before routing (DNAT here, because routing reads the dst); POSTROUTING = after routing (SNAT here, because the src no longer affects routing). The placement is forced by the routing decision, not arbitrary.
- NAT rewrites addresses at the boundary: DNAT changes the destination, SNAT changes the source, masquerade is SNAT with the source auto-picked from the outgoing interface. NAT is stateful — conntrack records each flow once (at the PREROUTING/LOCAL_OUT “first sight” hooks) and mirrors the inverse rewrite onto replies (full story Day 22).
- A static key (jump label) makes a disabled hook cost literally zero: the kernel patches the call site to a
nopand only rewrites it to a real jump when a hook registers (static_key_slow_inc). One key per (family, hook) —nf_hooks_needed[pf][hook]. This is whyNF_HOOKis free on an idle box; same mechanism as Day 12’sudp_encap_needed_key. - Each hook has a per-netns list of callbacks ordered by priority (
net->nf.hooks_ipv4[], recallstruct netfrom Day 5). - Verdicts: ACCEPT, DROP, STOLEN, QUEUE, REPEAT; STOP is deprecated UAPI compatibility.
nf_hook_slowis the dispatcher;NF_HOOKpatches to zero overhead when no hooks registered.- iptables, nftables, conntrack, IPVS — all plug into the same hook system at different priorities.
- Inspect with
nft list hooks(modern) oriptables -L -v(legacy).
Check question
When you write iptables -A INPUT ..., which kernel hook does the rule actually attach to, and at what priority?
Click to reveal answer
Answer: NF_INET_LOCAL_IN, priority 0 (NF_IP_PRI_FILTER). The INPUT chain in iptables/nftables corresponds 1:1 with LOCAL_IN — the kernel hook that fires after routing has determined the packet is for a local socket. The priority is NF_IP_PRI_FILTER = 0, which puts user filter rules after conntrack (NF_IP_PRI_CONNTRACK = -200) but before anything at higher priority. Other iptables chains map similarly: OUTPUT → LOCAL_OUT, FORWARD → FORWARD, PREROUTING → PRE_ROUTING, POSTROUTING → POST_ROUTING. The names line up almost identically with the kernel’s hook IDs because iptables was designed to expose them.
Tomorrow
Day 21: nftables vs iptables. Why nftables exists, what it does better, and how to convert legacy iptables rules.
Day 21 — nftables vs iptables
Today’s mission: see why nftables exists, write a real ruleset that does multiple useful things, and understand how the same hooks expose two completely different rule engines — including the tiny register-based VM, two verdict namespaces, three set backends, and the generation double-buffer that make the headline features real. Total time: ~110 minutes.

Two generations of the same idea
Both iptables and nftables hook into the same Netfilter framework (Day 20). They differ in how rules are stored, matched, and executed — which has cascading effects on syntax, performance, and feature surface.
iptables (legacy, 2001)
- Per-protocol tools:
iptables(IPv4),ip6tables(IPv6),arptables(ARP),ebtables(bridged Ethernet). Four parallel binaries with similar but not identical syntax. - Linear matching: rules in a chain are an array of fixed-shape entries (
struct ipt_entry+ match + target). For each packet, the kernel walks the array top-to-bottom evaluating each rule’s matches. Match modules (xt_*) are kernel modules. - Implementation:
ipt_do_table(net/ipv4/netfilter/ip_tables.c:223). The “fast path” is a manually-unrolled loop over rule entries. - Storage:
xt_table_infoarrays are large pre-allocated buffers; updates require allocating a whole new buffer and atomic swap. Adding one rule rewrites the entire table.
nftables (modern, since 3.13 / 2014)
- One tool:
nft. Single syntax for inet (IPv4+IPv6 unified), arp, bridge, netdev (per-iface). - Expression VM: rules compile to compact expression sequences run by
nft_do_chain(net/netfilter/nf_tables_core.c:250). Each rule is a sequence ofnft_exprops operating on a small register file; common expressions have fast paths, but this is not a BPF-style JIT. (We dissect this VM in detail below — it’s the one idea that makes “rules compile to expressions” click.) - Native sets and maps: hash sets, interval/range sets (red-black tree), concatenated-range sets (pipapo), key→value maps as first-class types. Membership tests are O(1) or O(log N) instead of O(N) linear walks. (These are three separate kernel modules — we’ll see which one the kernel picks and why.)
- Atomic updates: changes are applied transactionally via netlink; no full-table rewrite for one rule add. (Backed by a generation double-buffer + RCU — covered below.)
- Implementation:
nft_do_chain(net/netfilter/nf_tables_core.c:250). Reads the chain’s expression list, runs each.
Performance difference
For a simple “drop traffic from these 100 IPs” rule:
- iptables: a chain of 100
-s IP -j DROPrules. Each packet walks all 100 (until match or end). O(N) per packet. - nftables: one rule using a set of exact IPs:
ip saddr @denylist drop. That set is a hash table. O(1) per packet regardless of size.
For 100 IPs the difference is noticeable; for 100k IPs (e.g., a denylist), iptables is unusable, nftables is fine. The O(1) claim holds for a set of exact /32 keys; a denylist of CIDR ranges instead uses the interval/rbtree backend at O(log N) — see the set-backends section for why a range can’t be a hash. We’ll see exactly which data structure backs that “hash table” claim later in the chapter.
Basic nft ruleset
# Create an inet table (covers both IPv4 and IPv6)
sudo nft add table inet filter
# Add chains for the standard hook positions, with default policies.
# Run this only in a VM/netns lab: policy drop on host input can lock you out.
sudo nft 'add chain inet filter input { type filter hook input priority 0 ; policy drop ; }'
sudo nft 'add chain inet filter forward { type filter hook forward priority 0 ; policy drop ; }'
sudo nft 'add chain inet filter output { type filter hook output priority 0 ; policy accept ; }'
# Allow established + related (conntrack — black box today, explained just below / Day 22)
sudo nft add rule inet filter input ct state established,related accept
# Allow loopback
sudo nft add rule inet filter input iif lo accept
# Allow ICMP (so ping works)
sudo nft add rule inet filter input meta l4proto { icmp, icmpv6 } accept
# Allow specific TCP ports
sudo nft add rule inet filter input tcp dport { 22, 80, 443 } accept
# Drop the rest (covered by chain policy 'drop')
# Inspect
sudo nft list ruleset
The inet family is special — same chain matches both v4 and v6 traffic, so you don’t write rules twice.
About
ct state established,related accept. That very first substantive rule leans on conntrack, the connection-tracking subsystem.ct stateis a stateful match: it asks conntrack “have I seen this flow before, and in what state?” The states you’ll meet arenew(first packet of a flow),established(a flow conntrack already tracks), andrelated(a helper flow, like FTP data or an ICMP error tied to an existing connection) — the kernel names themIP_CT_NEW,IP_CT_ESTABLISHED,IP_CT_RELATEDinenum ip_conntrack_info(include/uapi/linux/netfilter/nf_conntrack_common.h), and thectexpression’s keys live inenum nft_ct_keys(include/uapi/linux/netfilter/nf_tables.h:1159). Treat conntrack as a black box today — we dissect the whole mechanism tomorrow (Day 22). All you need now: this rule lets reply traffic for connections you initiated back in without you having to write a matching reverse rule for every flow.
Sets and maps
Native data structures, not afterthoughts.
Anonymous set inline
sudo nft add rule inet filter input ip saddr { 10.0.0.1, 10.0.0.2, 10.0.0.3 } accept
The {...} creates an anonymous hash set, used by this rule only.
Named set (mutable from CLI)
sudo nft add set inet filter blocked { type ipv4_addr \; flags interval \; }
sudo nft add element inet filter blocked { 1.2.3.4 }
sudo nft add element inet filter blocked { 5.6.7.0/24 }
sudo nft add rule inet filter input ip saddr @blocked drop
The set persists, can be added to/removed from at runtime, and the rule references it by name.
Map (key → value)
sudo nft add map inet filter port_to_action { type inet_service : verdict \; }
sudo nft add element inet filter port_to_action { 22 : accept, 80 : accept, 443 : accept, 25 : drop }
sudo nft add rule inet filter input tcp dport vmap @port_to_action
vmap (verdict map) translates the matched value into a verdict via lookup. Replaces a chain of if dport == X jump Y else....
Interval sets (ranges)
sudo nft add set inet filter trusted_nets { type ipv4_addr \; flags interval \; }
sudo nft add element inet filter trusted_nets { 10.0.0.0/8, 192.168.0.0/16, 172.16.0.0/12 }
Backed by an interval/range tree, not a hash. Lookup is O(log N) but supports CIDR. (Next section explains why it can’t be a hash.)
What actually backs a set: three kernel modules, not one
The chapter keeps saying “hash set,” “interval tree,” “pipapo” and quoting O(1) / O(log N) — but those aren’t hand-waving. They are three distinct kernel modules, and when you add set ... { type ... ; flags ... ; }, the kernel picks one based on the key type and flags. List them on the devbox:
net/netfilter/nft_set_hash.c # exact-match sets
net/netfilter/nft_set_rbtree.c # interval / range sets
net/netfilter/nft_set_pipapo.c # concatenated-range sets
net/netfilter/nft_set_bitmap.c # small key spaces, ≤ 2 bytes (e.g. a 16-bit port)
Ground each one:
-
Exact-match sets (a plain
{ 10.0.0.1, 10.0.0.2 }, noflags interval) use an rhashtable — the kernel’s generic resizable hash table. Opennft_set_hash.cand you’ll seestruct nft_rhash { struct rhashtable ht; ... }(net/netfilter/nft_set_hash.c:24-25), with#include <linux/rhashtable.h>at the top. A hash answers exactly one question — “is this exact key present?” — in O(1). -
Interval sets (
flags interval, used above for CIDRs) use a red-black tree keyed on range endpoints:struct nft_rbtree { struct rb_root root; ... }(net/netfilter/nft_set_rbtree.c:31). Here’s why a hash can’t do this job: a CIDR like5.6.7.0/24must match a whole range of addresses, not one exact key. A hash can only confirm a single key is present; it can’t answer “does this address fall inside one of my stored ranges?” An ordered structure can — you binary-search the endpoints. That ordered tree walk is exactly the O(log N) the chapter quotes, and it’s why CIDR membership is a tree walk, not a hash probe. The bare complexity number now has a reason behind it. -
Concatenated ranges (matching on, say, (saddr range, dport range) at once) use pipapo — “PIle PAcket POlicies,” whose header comment reads “PIPAPO: PIle PAcket POlicies: set for arbitrary concatenations of ranges” (
net/netfilter/nft_set_pipapo.c:3). One sentence is enough today; if you’re curious, that file’s opening comment is one of the best-documented algorithm write-ups in the tree.
This is just general data-structure intuition (ordered tree → log-N range lookup, hash → O(1) point lookup) wearing kernel clothes — nothing exotic. The set-selection logic chooses the backend; you choose the behavior by picking flags and key types.

Counters and quotas
sudo nft add rule inet filter input tcp dport 22 counter accept
# show stats
sudo nft list table inet filter
counter accumulates packets/bytes. Built-in; no separate “counter table” required.
You’ve now built a real inet filter table whose input chain has policy drop, plus several named sets and maps. When you’re done experimenting, tear the whole thing down in one transaction so you don’t leave a drop-policy firewall (and stray sets/maps) loaded — exactly the “you forgot the other ruleset is running” trap the check question warns about:
# Removes all chains, rules, named sets, and maps in this table at once
sudo nft delete table inet filter
That “in one transaction” is not marketing copy. To see why deleting a whole table mid-traffic is safe, we need to understand how nftables makes runtime updates atomic — coming up after the VM.
How nft_do_chain works: a tiny register VM
nft_do_chain (net/netfilter/nf_tables_core.c:250) is the runtime entry. Before we read its loop, we need the one idea everything hinges on: the register file.
What a register actually is
Think of how a CPU works: it has a handful of named scratch slots (registers), and instructions communicate by one instruction writing a register and a later one reading it. nftables borrows that idea in miniature. A register is just a slot in a small per-packet scratch array. Each expression in a rule is like a tiny instruction: it reads some registers, maybe writes one, and the next expression sees what the previous one left behind. That’s the entire model — “rules compile to a sequence of expressions” means “rules compile to a sequence of tiny instructions that pass values through a shared register file.”
In v7.1 that array is struct nft_regs (include/net/netfilter/nf_tables.h:122):
#define NFT_REG32_NUM 20 /* include/net/netfilter/nf_tables.h:112 */
struct nft_regs {
union {
u32 data[NFT_REG32_NUM]; /* 20 × 32-bit scratch slots */
struct nft_verdict verdict; /* aliases the FIRST slots */
};
};
Two things to notice, both load-bearing:
- The scratch array is 20
u32wide, but there are only 16 addressable data registers.struct nft_regsholdsNFT_REG32_NUM = 20u32 slots — but those are not 20 independent data registers. The first 4 slots (data[0..3]) are consumed by the verdict register that aliases them (see point 2), leaving 16 addressable 32-bit DATA registers:NFT_REG32_00..NFT_REG32_15. The UAPI makes this explicit —NFT_REG32_COUNT == 16and the comment reads “The data registers have been changed to 16 registers of size 4” (include/uapi/linux/netfilter/nf_tables.h:16-21,30-55). So 16 is the right count of data registers; the array is 20 wide only because 4 + 16 = 20. - The verdict register overlaps the first data registers. It’s a
union:data[NFT_REG32_NUM]and astruct nft_verdictshare the same storage. The kernel comment spells it out: “The first four data registers alias to the verdict register” (include/net/netfilter/nf_tables.h:119-120).struct nft_verdict { u32 code; struct nft_chain *chain; }(:100) is 16 bytes = 4 u32 slots wide, so it lays overdata[0..3]and the 16 real data registers live atdata[4..19]. This aliasing is why a single loop can carry both the data being compared and the running verdict — they live in the same register file, just viewed two different ways.

Two kinds of verdict — don’t confuse them
Day 20 taught the Netfilter verdicts a hook returns to the framework: NF_ACCEPT, NF_DROP, NF_QUEUE, NF_STOLEN. nftables adds a second, internal verdict namespace used only to steer the chain walk — these never escape nft_do_chain (enum nft_verdicts, include/uapi/linux/netfilter/nf_tables.h:68-73):
enum nft_verdicts {
NFT_CONTINUE = -1, /* keep evaluating */
NFT_BREAK = -2, /* abandon THIS rule, go to the next */
NFT_JUMP = -3, /* push chain, jump to another */
NFT_GOTO = -4, /* jump without pushing */
NFT_RETURN = -5, /* pop the jump stack */
};
So there are two lanes:
- Internal nft verdicts (
NFT_CONTINUE/BREAK/JUMP/GOTO/RETURN) — chain flow control, never returned to the hook. - Terminal Netfilter verdicts (
NF_ACCEPT/NF_DROP/NF_QUEUE/NF_STOLEN) — the values a hook hands back to Netfilter (Day 20).
The bridge between the lanes is nft_immediate: when a rule says accept or drop, the nft_immediate expression writes a Netfilter verdict into the verdict register. Because that value is not in the NFT_* range, the loop’s terminal switch returns it and the chain stops.

The real loop
Now nft_do_chain’s core reads cleanly. Here is the actual v7.1 loop, lightly trimmed (net/netfilter/nf_tables_core.c:273-313):
next_rule:
regs.verdict.code = NFT_CONTINUE; /* :274 — seed each rule with CONTINUE */
for (; !rule->is_last ; rule = nft_rule_next(rule)) {
nft_rule_dp_for_each_expr(expr, last, rule) {
/* fast-path dispatch for the common expressions ... */
if (expr->ops == &nft_cmp_fast_ops)
nft_cmp_fast_eval(expr, ®s);
else if (expr->ops != &nft_payload_fast_ops ||
!nft_payload_fast_eval(expr, ®s, pkt))
expr_call_ops_eval(expr, ®s, pkt); /* generic: expr->ops->eval(...) */
if (regs.verdict.code != NFT_CONTINUE) /* :287 — an expr changed it */
break;
}
switch (regs.verdict.code) {
case NFT_BREAK: /* :291 — abandon this rule */
regs.verdict.code = NFT_CONTINUE;
continue; /* ...move to next rule */
case NFT_CONTINUE:
continue;
}
break; /* anything else: leave the rule loop */
}
switch (regs.verdict.code & NF_VERDICT_MASK) { /* :306 — terminal dispatch */
case NF_ACCEPT:
case NF_QUEUE:
case NF_STOLEN:
return regs.verdict.code;
case NF_DROP:
return NF_DROP_REASON(pkt->skb, SKB_DROP_REASON_NETFILTER_DROP, EPERM);
}
/* NFT_JUMP/GOTO/RETURN handled below ... */
Read it with the two namespaces in mind:
- Every rule is seeded with
regs.verdict.code = NFT_CONTINUE(:274). If no expression touches the verdict, the rule “passes through” and evaluation continues to the next rule. - Each expression runs; right after,
if (regs.verdict.code != NFT_CONTINUE) break;(:287) bails out of the expression loop the instant anything wrote the verdict. - The inner
switchhandles the internal verdicts:NFT_BREAKresets toCONTINUEand moves to the next rule;NFT_CONTINUElikewise. Anything else (a JUMP/GOTO/RETURN, or a terminal NF_ verdict) breaks out of the rule loop entirely. - The final
switch (regs.verdict.code & NF_VERDICT_MASK)(:306) is the terminal dispatch:NF_ACCEPT/NF_QUEUE/NF_STOLENare returned to the hook,NF_DROPbecomes a drop-with-reason. This is the line that makesacceptanddropterminal.
About the if/else ladder at the top: don’t let it spook you. nft_cmp_fast_ops, nft_payload_fast_ops, etc. are just inlined fast paths for the most common expressions. The fallback expr_call_ops_eval(expr, ®s, pkt) is the generic expr->ops->eval(expr, regs, pkt) call — the exact dispatch the simplified pseudocode shows. So the model “every expression is expr->ops->eval(expr, regs, pkt)” is faithful; the ladder is an optimization, not a different design.
Walking tcp dport 22 accept through the real loop
Now the headline claim — “rules compile to a sequence of expressions” — becomes concrete. tcp dport 22 accept compiles to three expressions, each tied to one op:
nft_payloadloads the 16-bit TCP destination-port field from the packet into a data register (say reg 1). (Implementation innft_payload.c; the fast path isnft_payload_fast_eval.)nft_cmpreads that register and compares it against the constant22. On a match, it leaves the verdict atNFT_CONTINUE(fall through to the next expression). On a mismatch, it writesNFT_BREAKinto the verdict register — and becauseverdict.code != NFT_CONTINUE, the loop breaks out of this rule and moves on, never reaching theaccept. (That’s why a non-port-22 packet skips this rule cleanly.)nft_immediatewrites the terminal verdict. Its eval is one line:nft_data_copy(®s->data[priv->dreg], &priv->data, priv->dlen);(net/netfilter/nft_immediate.c:24, insidenft_immediate_evalwhich begins at:18) — it copiesNF_ACCEPTinto the destination register, which (for a verdict) is the verdict register thanks to the aliasing. The terminalswitchthen returnsNF_ACCEPT.
That spine — payload writes a reg, cmp reads it and may set BREAK, immediate sets the verdict — is the model behind the whole “~50 expression types” zoo. A few common ones:
nft_payload— load packet bytes into a register.nft_meta— load skb metadata into a register.nft_cmp— compare a register against a constant (may writeNFT_BREAK).nft_lookup— set membership test (uses the backends above).nft_immediate— write a value or a verdict into a register.nft_counter— bump packet/byte counts; leaves the verdict atNFT_CONTINUE.- …~50 more expression types.
Why ordering matters: counter before the verdict
This control flow directly explains a rule everyone trips over: a counter must come before the terminal verdict, not after.
counter leaves regs.verdict.code == NFT_CONTINUE, so evaluation flows on to the next expression. drop/accept (via nft_immediate) set a terminal NF_* verdict, and the loop returns immediately — any expression after it is never reached. So:
tcp dport 22 counter accept→ counter bumps, then accept returns. ✅ counter fires.tcp dport 22 accept counter→ accept returns; the trailing counter is dead code. ❌ counter never fires.
Newer nft versions reject the second form at parse time with “Statement after terminal statement has no effect” — the parser is encoding the exact control-flow fact you just read in the loop. Same reasoning explains why meta nftrace set 1 (which also leaves the verdict at NFT_CONTINUE) must precede the drop in today’s experiment.
The generation double-buffer: what “atomic update” really means
One headline remains ungrounded: “atomic transactional updates — adding one rule doesn’t rewrite the whole table.” How does an in-flight packet see a consistent ruleset while you’re editing it?
First, the refresher: the config channel is netlink — the AF_NETLINK socket interface we taught in Day 8 (rtnetlink). nft talks to the kernel over netlink to submit a batch of changes. We won’t re-teach netlink; the genuinely new mechanism is what happens to the runtime ruleset during that batch.
Look back at the top of nft_do_chain (net/netfilter/nf_tables_core.c:259-270):
bool genbit = READ_ONCE(net->nft.gencursor); /* :259 — which generation is live? */
...
if (genbit)
blob = rcu_dereference(chain->blob_gen_1); /* :268 */
else
blob = rcu_dereference(chain->blob_gen_0); /* :270 */
Each chain keeps two compiled rule blobs: blob_gen_0 and blob_gen_1. A per-net generation cursor (net->nft.gencursor) says which one is currently live. The packet path reads the cursor and dereferences the active blob via RCU (RCU — the read-mostly scheme from Day 11: readers take no lock; a writer defers freeing until every in-flight reader finishes its grace period). When you submit an update:
- The kernel builds the new ruleset into the inactive generation’s blob (the one packets aren’t using).
- It flips
net->nft.gencursor— a single cursor write — so new packets start reading the new blob. - In-flight packets that already grabbed the old blob keep using it safely, because RCU won’t free the old blob until they finish.
That double-buffer + RCU is “atomic, no full-table rewrite”: no packet ever sees a half-applied ruleset, and adding one rule touches only the off-line generation, never the whole live table. Deleting a table mid-traffic (as the cleanup step does) is safe for the same reason.
Contrast iptables. ipt_do_table reads the whole-table buffer table->private (READ_ONCE(table->private), net/ipv4/netfilter/ip_tables.c:260), a single struct xt_table_info (include/linux/netfilter/x_tables.h:244). To change anything, iptables must allocate a whole new xt_table_info, copy the entire ruleset into it, and atomically swap the pointer (xt_replace_table). One big buffer, reallocated wholesale for every change — versus nftables’ two small per-chain blobs and a cursor flip.

Read net/netfilter/nf_tables_core.c:250 for the actual loop — it’s only ~100 lines, and you now know every moving part: the register file, the two verdict namespaces, and the generation cursor.
There are no Dumb Questions
Q: If nftables is strictly better, why does iptables still exist?
A: Thirty years of muscle memory and shell scripts. Countless firewall configs, container runtimes, and cloud images emit
iptablescommands; you can’t rewrite them all overnight. Theiptables-nftshim bridges the gap — it parses legacyiptablessyntax and programs nftables underneath, so old tooling keeps working while the real engine is the modern one.Q: My anonymous set
{ 10.0.0.1, 10.0.0.2 }— is that the hash backend or the tree?A: The hash (
rhashtable, O(1)). They’re plain exact keys with noflags interval, so the set-selection logic picksnft_set_hash.c. You’d only get the rbtree backend by asking for ranges (flags interval, e.g. a CIDR) — exactly the backend-selection rule from the sets section.Q: Can one rule mix a data register and the verdict if they alias?
A: Yes — that’s precisely what the
unionbuys. A rule freely writes data into registersNFT_REG32_00..15(which live atdata[4..19]) while the verdict register occupiesdata[0..3]. They don’t collide, so a single chain run carries both the values being compared and the running verdict in one register file.
iptables compatibility
Modern systems ship iptables-nft — a compatibility shim that translates legacy iptables commands into nftables rules under the hood. The legacy iptables-legacy (which uses the xt_tables backend) still exists but is deprecated.
update-alternatives --display iptables # shows which backend you have
If iptables-nft is the active alternative, your iptables commands populate nftables tables (visible via nft list tables). If iptables-legacy, they go to the old ip_tables backend.
Today’s experiment
# Inspect what's running
sudo nft list tables
sudo nft list ruleset
# Build a small ruleset
sudo nft add table inet test
sudo nft 'add chain inet test myinput { type filter hook input priority 0 ; }'
# nftrace/counter must precede the terminal drop (see "Why ordering matters" above):
# once drop fires, nft_do_chain returns and any later expression/rule never runs.
sudo nft add rule inet test myinput meta nftrace set 1
sudo nft add rule inet test myinput tcp dport 12345 counter drop
# In another terminal, monitor
sudo nft monitor trace &
# Test (connect-scan + 2s timeout so each command returns on its own — no Ctrl-C)
nc -z -w2 localhost 12345 ; echo "exit=$?" # blocked: hangs ~2s then times out, exit!=0
nc -z -w2 localhost 22 ; echo "exit=$?" # allowed: exit 0 if sshd is listening
# See counters — the dport 12345 rule's counter should show at least one packet
# per nc -z attempt (each SYN to the dropped port is counted before the verdict)
sudo nft list table inet test
# ...
# chain myinput {
# type filter hook input priority filter; policy accept;
# meta nftrace set 1
# tcp dport 12345 counter packets 6 bytes 360 drop
# }
# (packets/bytes scale with how many times you ran nc; 0 here would mean the
# counter was placed after the verdict and never evaluated — see the note above.)
# Clean up — kill the background monitor too, then drop the table
sudo pkill -f 'nft monitor trace'
sudo nft delete table inet test
nftrace causes packets matching the rule to be logged via nft monitor trace. Because the nftrace rule runs before the terminal drop, the trace for nc -z localhost 12345 shows the tcp dport 12345 match followed by the drop verdict — the whole point of the experiment. (Had nftrace been added after the drop rule, the dropped packet would never reach it and only port-22 traffic would be traced.) This is the control flow from the VM section made visible: meta nftrace set 1 leaves the verdict at NFT_CONTINUE, so evaluation continues to the drop, which sets NF_DROP and returns.
Note that nftables drop sends no RST, so a blocked connection shows up as a ~2-second hang (the nc -w2 timeout) and a non-zero exit, not an instant “connection refused”. Port 22 only succeeds if sshd is actually listening; if it isn’t, start it first or expect a fast “refused”.
See iptables-nft conversion
# If iptables is the nft compat shim:
sudo iptables -A INPUT -p tcp --dport 12346 -j DROP
sudo nft list ruleset # see the rule appear in an nft table named 'ip filter'
sudo iptables -D INPUT -p tcp --dport 12346 -j DROP # clean up exactly this rule
What to read in the kernel
-
net/netfilter/nf_tables_core.c:250—nft_do_chain. The runtime VM. Read end to end (~100 lines). Notice thenft_regsstructure — each chain run gets a fresh register file backed by a 20-u32scratch array (NFT_REG32_NUM = 20), whose first four slots alias the verdict register, leaving 16 addressable data registers (NFT_REG32_00..15,NFT_REG32_COUNT == 16). The generic dispatch pattern (expr_call_ops_eval→expr->ops->eval(expr, regs, pkt)) is how each kind of expression contributes; thenft_cmp_fast_ops/nft_payload_fast_opsladder is just an inlined fast path for the common ones. The per-ruleregs.verdict.code = NFT_CONTINUEseed (:274), theif (regs.verdict.code != NFT_CONTINUE) break;(:287), and the terminalswitch (... & NF_VERDICT_MASK)(:306) are the whole control-flow story. The generation cursor (net->nft.gencursor,:259) selectingblob_gen_0/blob_gen_1(:268-270) is the atomic-update mechanism. -
include/net/netfilter/nf_tables.h—struct nft_regs(line 122),#define NFT_REG32_NUM 20(line 112, the scratch-array width),struct nft_verdict(line 100), and the kernel comment “The first four data registers alias to the verdict register” (lines 119–120). The 16 addressable data registers andNFT_REG32_COUNT == 16live in the UAPI header (include/uapi/linux/netfilter/nf_tables.h:30-55). -
net/netfilter/nf_tables_api.c— netlink interface for adding/removing rules. ~12000 lines. Don’t read straight; key entries:nf_tables_newrule,nf_tables_delrule,nf_tables_dump_chains. This is where the transactional, generation-flipping updates are processed. -
net/netfilter/nft_*.c— individual expression implementations. Pick a few short ones to read:nft_immediate.c— sets a register value or a verdict (nft_immediate_evalat line 18 is a one-liner).nft_payload.c— loads packet bytes into a register.nft_cmp.c— compares a register against a constant.nft_lookup.c— set membership test.nft_meta.c— loads skb metadata.
Sizes vary (
nft_lookup.c~290 lines;nft_payload.candnft_meta.cover 1000), but each is self-contained; reading 2-3 teaches you the expression model. -
net/netfilter/nft_set_hash.c/nft_set_rbtree.c/nft_set_pipapo.c— the three set backends.nft_set_hash.cwraps anrhashtable(line 25);nft_set_rbtree.cwraps anrb_root(line 31);nft_set_pipapo.c’s header comment (line 3) documents the concatenated-range algorithm beautifully. Reading the first few lines of each shows the O(1)/O(log N) claims are literally different data structures. -
include/uapi/linux/netfilter/nf_tables.h— UAPI definitions.enum nft_verdicts(lines 68–73:NFT_CONTINUE/BREAK/JUMP/GOTO/RETURN),enum nft_ct_keys(line 1159), expression IDs, etc. -
net/ipv4/netfilter/ip_tables.c:223—ipt_do_table. The legacy iptables runtime. Compare againstnft_do_chain. Notice it has its own micro-loop withxt_matchandxt_targetplugging in, and that it reads the whole-tabletable->privatebuffer (:260) — the singlext_table_info(include/linux/netfilter/x_tables.h:244) that any change must reallocate wholesale. ~140 lines for the hot path. -
man nft— comprehensive but dense. Use the nftables wiki (https://wiki.nftables.org) for examples. -
Documentation/networking/doesn’t have a dedicated nftables doc; the wiki is canonical.
Bullet Points
- nftables = modern (since 3.13); iptables = legacy (still works via
iptables-nftshim). - Single tool
nft; one syntax for v4, v6, ARP, bridge, netdev. - Expression VM (
nft_do_chain) replaces the fixed-shape linear rule walk ofipt_do_table. Each expression is a tiny instruction over a register file backed by a 20-u32scratch array (struct nft_regs,NFT_REG32_NUM = 20); the first four slots alias the verdict register, leaving 16 addressable data registers (NFT_REG32_COUNT == 16). That aliasing is how one loop carries both compared data and the running verdict. - Two verdict namespaces: internal nft verdicts (
NFT_CONTINUE/BREAK/JUMP/GOTO/RETURN) steer the chain walk and never leavenft_do_chain; terminal Netfilter verdicts (NF_ACCEPT/DROP/QUEUE/STOLEN) are returned to the hook.nft_immediateis the bridge. This is whydrop/acceptare terminal andcounter/nftraceare not — and why acountermust precede the verdict. - Native sets and maps are real, distinct kernel modules: exact keys →
rhashtable(O(1));flags interval→ red-black tree (O(log N), because a CIDR is a range a hash can’t answer); concatenated ranges → pipapo. - Atomic transactional updates = a per-chain generation double-buffer (
blob_gen_0/blob_gen_1) selected bynet->nft.gencursor, flipped under RCU. Adding one rule builds the off-line generation and flips a cursor — never the whole-table reallocation iptables’xt_table_inforequires. ct state established,relatedis a stateful conntrack match — a black box today, dissected Day 22.- The same Netfilter hooks (Day 20); different rule engines.
nft list rulesetto inspect;nft monitor traceto debug rule evaluation.
Check question
You write nft add rule inet filter input tcp dport 22 accept and iptables -A INPUT -p tcp --dport 22 -j ACCEPT on the same system. Do they conflict? Do they behave the same?
Click to reveal answer
Answer: Depends on which iptables is installed. If it’s iptables-nft (the compatibility shim, default on modern distros), both commands ultimately add rules to nftables tables — but to different tables (iptables-nft uses its own table named filter in family ip; your nft command added to inet filter). They coexist and both fire — the same packet is evaluated by both rule sets at the LOCAL_IN hook. Whichever has the lower priority (or comes first if same priority) runs first. If both ACCEPT, the packet proceeds; if either DROPs, the packet dies.
If iptables is the legacy binary (iptables-legacy), it uses the entirely separate ip_tables backend (ipt_do_table). Both fire at the same LOCAL_IN hook with priority 0 (filter); both are evaluated. Functionally still coexisting.
To avoid surprises: pick one. Don’t have both your firewall config and your nftables ruleset loaded — debugging “why isn’t my rule working?” gets very hard when you forgot the other one is also running.
Tomorrow
Day 22: conntrack — the stateful firewall machinery that lets ct state established,related accept work.
Day 22 — Conntrack: stateful firewalls
Today’s mission: see how the kernel tracks per-connection state, why “established,related accept” is the most-used firewall rule in the world, and how NAT is built on top of conntrack. Total time: ~110 minutes.
What conntrack does
Along the way we’ll open up the four pieces of machinery the whole subsystem leans on:
- the conntrack tuple and how a reply is recognized,
- the packed
skb->_nfctword that ties a packet to its connection, - the expectation mechanism behind RELATED, and
- the refcount-plus-timeout model that makes entry confirmation and aging concrete.
A stateful firewall decides whether to allow a packet based not just on the packet itself, but on which connection it belongs to. “Allow inbound responses to outbound connections, drop unsolicited inbound” is the canonical use case — it requires the kernel to remember outgoing connections so it can recognize the replies.
The Linux kernel’s connection tracker is conntrack (netfilter/nf_conntrack). For every packet it inspects, conntrack:
- Computes a tuple key from the packet’s L3 + L4 headers.
- Looks up an existing tracked connection (
struct nf_conn). - If found, updates the connection state and tags the skb with an
nf_connpointer and anIP_CT_*info value. - If not found, creates a NEW entry (provisional — confirmed only on ACCEPT).
Subsequent rules can reference state via ct state established,related,new,invalid matchers — those are the ct state matchers from Day 21. NAT rules use conntrack to remember mappings so reply traffic can be reverse-mapped automatically.

Three ideas show up over and over in this chapter, and they all turn out to be the same idea wearing different hats: “looks up an existing tracked connection,” “matches the entry by the reverse 5-tuple,” and “tag the skb with the connection.” Before we walk the state machine, let’s build those ideas properly — because once you see the tuple and the packed _nfct word, the rest of conntrack is mostly bookkeeping.
The conntrack tuple: how a reply is recognized
Recall from Day 13 that the TCP stack stores established sockets in the ehash, keyed by the 4-tuple (saddr, sport, daddr, dport). Conntrack has a richer, bidirectional cousin of that key called the tuple, and understanding it is the spine of everything below.
A conntrack tuple is not the same thing as the TCP 4-tuple. The TCP key identifies one socket in one direction. A conntrack tuple identifies one direction of one flow for any protocol — and crucially, conntrack stores two of them per connection so it can recognize traffic going either way.
Here is the actual struct (include/net/netfilter/nf_conntrack_tuple.h:37):
struct nf_conntrack_tuple {
struct nf_conntrack_man src; /* source addr + an L4 "id" (port/icmp-id/key) */
struct {
union nf_inet_addr u3; /* destination address (v4 or v6) */
union {
__be16 all;
struct { __be16 port; } tcp;
struct { __be16 port; } udp;
struct { u_int8_t type, code; } icmp; /* <-- not a port! */
struct { __be16 port; } dccp;
struct { __be16 port; } sctp;
struct { __be16 key; } gre; /* <-- a GRE key */
} u;
u_int8_t protonum; /* L3 protocol number (TCP=6, UDP=17, ...) */
u_int8_t dir; /* which direction this tuple is */
} dst;
};
Read the union slowly, because it’s the whole point. The “L4 id” is not always a port. TCP/UDP/DCCP/SCTP use a 16-bit port; ICMP uses {type, code}; GRE uses a key. That is why a ping is a trackable “connection” even though ICMP has no ports: the echo’s identifier is the tuple’s L4 id, so conntrack can pair an echo-request with its echo-reply. The src half (struct nf_conntrack_man) carries the source address plus the matching source-side id in its own union nf_conntrack_man_proto.
Two tuples per connection, and the reverse is the inverse
Every nf_conn stores two tuples in an array — this is the tuplehash[2] you’ll see named in the struct:
struct nf_conntrack_tuple_hash tuplehash[IP_CT_DIR_MAX]; /* [ORIGINAL] and [REPLY] */
The direction enum is just three values (include/uapi/linux/netfilter/nf_conntrack_tuple_common.h:12):
enum ip_conntrack_dir {
IP_CT_DIR_ORIGINAL, /* 0 — the way the connection was opened */
IP_CT_DIR_REPLY, /* 1 — the way replies come back */
IP_CT_DIR_MAX /* 2 — array size */
};
The reply tuple is the inverse of the original: swap source and destination addresses, swap the source/destination L4 ids (ports), and for ICMP map the echo-request type to the echo-reply type. The kernel builds it with nf_ct_invert_tuple (net/netfilter/nf_conntrack_core.c:429, EXPORT_SYMBOL_GPL at :466). When a brand-new connection is created, init_conntrack (:1763) computes the reply tuple by inverting the original:
/* net/netfilter/nf_conntrack_core.c:1780, inside init_conntrack() */
if (!nf_ct_invert_tuple(&repl_tuple, tuple))
return NULL;
Both tuples are hashed into the same global table (nf_conntrack_hash). When a packet arrives, conntrack computes its tuple and looks it up:
- If it matches a stored ORIGINAL tuple → this packet is going the forward direction.
- If it matches a stored REPLY tuple → this packet is a reply.
That single mechanism — two tuples, one table — is what lets one entry recognize traffic in both directions. And it is exactly the same machinery NAT reuses for reverse-mapping replies (we’ll get there).
To recover the nf_conn from whichever tuple matched, the kernel uses the dir byte baked into the tuple:
/* include/net/netfilter/nf_conntrack.h */
static inline struct nf_conn *
nf_ct_tuplehash_to_ctrack(const struct nf_conntrack_tuple_hash *hash)
{
return container_of(hash, struct nf_conn,
tuplehash[hash->tuple.dst.dir]); /* dir picks the slot */
}

Direction is folded into the state value
Here’s the elegant part. The per-packet state (NEW/ESTABLISHED/…) and the direction (forward/reply) are not stored separately — the direction is folded into the state value itself. The macro that decodes it (include/uapi/linux/netfilter/nf_conntrack_tuple_common.h:44):
#define CTINFO2DIR(ctinfo) ((ctinfo) >= IP_CT_IS_REPLY ? IP_CT_DIR_REPLY : IP_CT_DIR_ORIGINAL)
Any ctinfo value ≥ IP_CT_IS_REPLY means “this is the reply direction.” That is the structural reason the state enum is ordered the way it is — IP_CT_IS_REPLY is the threshold value 3, and the reply variants are exactly forward_state + IP_CT_IS_REPLY. The state value does double duty as a direction flag, which is why it can live in just a few bits. Which brings us to where that value actually lives.
How an skb carries its conntrack: the packed skb->_nfct word
The chapter keeps saying “tag the skb with the connection.” Recall from Day 1 that the sk_buff carries a pile of metadata pointers alongside the packet bytes. Conntrack adds one more such field — but it pulls a packing trick worth understanding, because that trick is why the state enum is only 3 bits wide.
The field is a single unsigned long (include/linux/skbuff.h:933, documented at :822 as “Associated connection, if any (with nfctinfo bits)”):
unsigned long _nfct;
One machine word holds two things at once:
- the high bits are a
struct nf_conn *pointer, and - the low 3 bits hold the
ip_conntrack_infovalue (the per-packet state).
A pointer to a heap object is always aligned, so its low bits are guaranteed zero and free for reuse. The masks (include/linux/netfilter/nf_conntrack_common.h:24):
#define NFCT_INFOMASK 7UL /* low 3 bits */
#define NFCT_PTRMASK ~(NFCT_INFOMASK) /* everything else = the pointer */
Two accessors unpack the word (include/linux/skbuff.h:4987 and :5005):
static inline struct nf_conntrack *skb_nfct(const struct sk_buff *skb)
{
return (void *)(skb->_nfct & NFCT_PTRMASK); /* mask off low bits -> the pointer */
}
static inline void skb_set_nfct(struct sk_buff *skb, unsigned long nfct)
{
skb->slow_gro |= !!nfct;
skb->_nfct = nfct; /* pointer | ctinfo, packed together */
}
So a conntrack-tagged skb answers two questions with one field: which connection (the nf_conn you get from skb_nfct()), and what this packet is to that connection (the low bits — NEW / ESTABLISHED / … possibly +REPLY). The nftables ct state matcher from Day 21 reads exactly these low bits.
This packing is the reason enum ip_conntrack_info must fit in 3 bits (values 0..7), and the reason IP_CT_IS_REPLY = 3 works as a direction divider: the enum was designed to be stuffed into a pointer’s spare low bits.

Clearing conntrack off an skb is just the inverse: drop the reference and zero the word. That’s nf_reset_ct (include/linux/skbuff.h:5140):
nf_conntrack_put(skb_nfct(skb)); /* drop our reference to the nf_conn */
skb->_nfct = 0; /* pointer AND state cleared in one store */
Keep that two-line teardown in mind — it ties directly into the refcount cleanup we cover at the end of the chapter.
The states
enum ip_conntrack_info (include/uapi/linux/netfilter/nf_conntrack_common.h:7):
IP_CT_ESTABLISHED // 0: packet is part of an existing connection
IP_CT_RELATED // 1: related to an existing connection (e.g., FTP data, ICMP error)
IP_CT_NEW // 2: first packet of a new connection (the SYN, in TCP)
IP_CT_IS_REPLY // 3: flag/threshold: >= this means the reply direction
IP_CT_ESTABLISHED_REPLY // 3: ESTABLISHED in the reply direction (= ESTABLISHED + IS_REPLY)
IP_CT_RELATED_REPLY // 4: RELATED in the reply direction
// (there is no NEW in the reply direction; IP_CT_NEW_REPLY exists only as a
// userspace-compatibility alias. In the kernel value 7 is IP_CT_UNTRACKED.)
Notice how the reply variants are literally forward + IP_CT_IS_REPLY — the very arithmetic CTINFO2DIR exploits. There is no IP_CT_INVALID enumerator — malformed or out-of-state packets (e.g., a TCP segment that isn’t part of any known sequence) simply get no conntrack entry. The INVALID classification that surfaces to userspace is the bitmask NF_CT_STATE_INVALID_BIT (bit 0, in the same header), not an ip_conntrack_info value.
How NEW becomes ESTABLISHED
For TCP:
- Outbound SYN — conntrack creates entry, marks NEW. The reply tuple is computed by inverting the SYN’s tuple (
nf_ct_invert_tuple), so the entry already knows what a reply will look like. - Inbound SYN-ACK — conntrack matches the reply tuple, marks ESTABLISHED (in the reply direction, so the info value is
≥ IP_CT_IS_REPLY). - All subsequent packets in either direction — ESTABLISHED.
For UDP:
- Outbound packet — NEW.
- Inbound reply (matching the inverted reply tuple) — ESTABLISHED.
- Subsequent packets in either direction — ESTABLISHED.
For ICMP echo (ping):
- Outbound echo request — NEW. The tuple’s L4 id is the ICMP
id, and the reply tuple maps echo-request type → echo-reply type. - Echo reply matching that inverted tuple — ESTABLISHED.
This is the payoff of the tuple section: “matches the reverse 5-tuple” is not hand-waving — it’s a literal hash lookup of the packet’s tuple against the entry’s pre-computed, inverted reply tuple.
What “RELATED” means: conntrack expectations
RELATED is one of the three headline states, but its mechanism is the most-skipped. The machinery behind it is called an expectation.
Some protocols negotiate auxiliary connections in their control stream. FTP-active opens a separate data connection from server to client; SIP negotiates RTP media streams; PPTP sets up a GRE tunnel. The data/media flow has a completely different tuple than the control flow, so to a stateless view it would look like an unsolicited NEW inbound connection — and a default-drop firewall would kill it.
A conntrack helper solves this. A helper is a per-protocol module that parses the L7 control stream (e.g. the FTP PORT/PASV command), figures out the tuple of the connection it predicts will soon appear, and pre-registers an expectation — a struct nf_conntrack_expect (include/net/netfilter/nf_conntrack_expect.h:18):
struct nf_conntrack_expect {
struct hlist_node lnode, hnode;
possible_net_t net;
struct nf_conntrack_tuple tuple; /* the tuple we expect to see */
struct nf_conntrack_tuple_mask mask;
refcount_t use;
unsigned int flags;
/* ... and a link back to the master connection ... */
};
When a later packet’s tuple matches a pending expectation, conntrack creates the new entry, sets its ->master pointer to the original (control) connection, and tags the packet IP_CT_RELATED instead of IP_CT_NEW. That master link is the literal meaning of “related” (include/net/netfilter/nf_conntrack.h, in struct nf_conn at :74):
/* If we were expected by an expectation, this will be it */
struct nf_conn *master;
That is why ct state related accept is needed for FTP-active, SIP/RTP, and PPTP/GRE: the second flow has a different tuple and would otherwise look like a NEW unsolicited connection.
ICMP errors are the other RELATED source. An ICMP Destination-Unreachable or Time-Exceeded message embeds the original offending packet’s headers in its payload. Conntrack reads those embedded headers, reconstructs the original tuple, finds the connection it belongs to, and marks the error RELATED (IP_CT_RELATED is documented in the enum as “Like NEW, but related to an existing connection, or ICMP error”). That is exactly why traceroute and PMTUD survive a default-drop firewall — the ICMP errors they depend on come back tagged RELATED.
Expectations are also why helpers are now opt-in — see the Conntrack helpers section below for the security rationale.

The state machine

For every incoming packet, the conntrack machinery in nf_conntrack_in (net/netfilter/nf_conntrack_core.c:2013):
- Build a tuple from the packet’s L3 + L4 headers (
nf_ct_get_tuple). - Look up the tuple in the global conntrack hash (
__nf_conntrack_find_get). The hash is the globalnf_conntrack_hash(the netns is part of the key, not a separate per-netns table). Both the ORIGINAL and REPLY tuples of every entry live in this one table. - Three outcomes:
- Hit (forward direction) — the packet’s tuple matched a stored ORIGINAL tuple. The info value depends on the entry’s
status: onceIPS_SEEN_REPLYis set (traffic seen both ways) it’sESTABLISHED; an expected (helper-predicted) flow isRELATED; otherwise — e.g. a retransmitted SYN before any reply — it’s stillNEW. - Hit (reverse direction) — the packet’s tuple matched a stored REPLY tuple: tag with the entry, set
info = ESTABLISHED + IS_REPLY(i.e. an info value≥ IP_CT_IS_REPLY, soCTINFO2DIRreports reply). - Miss — create a new entry via
init_conntrackand__nf_conntrack_alloc. The reply tuple is computed up front by inverting the original. Markinfo = NEW. The entry is unconfirmed at this point.
- Hit (forward direction) — the packet’s tuple matched a stored ORIGINAL tuple. The info value depends on the entry’s
The unconfirmed entry is not in the global hash yet, and (in modern kernels, including the 7.1 target) it is not parked on any list either — it is held alive solely by the in-flight skb’s reference (skb->_nfct). It’s confirmed only when the packet completes the netfilter pipeline with NF_ACCEPT; the confirm callback (__nf_conntrack_confirm) inserts the entry into the global hash. If the packet is dropped, the unconfirmed entry is freed, so port-scanning probes don’t fill the table. (Older kernels kept a per-CPU “unconfirmed” list; commit 8a75a2c17410 removed it — the skb reference is all that holds an unconfirmed entry now.)
This two-phase commit is critical for performance and security:
- Performance: scans of closed ports don’t blow up the conntrack table.
- Security: an unconfirmed entry survives only as long as its skb, so a bogus-NEW flood can pin at most one entry per in-flight packet; durable (confirmed) entries are capped by
nf_conntrack_max.
We’ll make “the unconfirmed entry is freed” exact once we have the refcount model, just below.
The refcount and timeout model
The two-phase commit and the aging behavior both rest on two fields of nf_conn: an embedded refcount and a timeout. Day 1 introduced the idea of refcounting an skb-adjacent object (recall skb->users / dataref); nf_conn has its own specific counting rule. And Day 7 taught the jiffies/HZ clock and the time_after deadline pattern for neighbour aging — timeout is the same idea.
The refcount rule
nf_conn is refcounted through its embedded ct_general (a struct nf_conntrack). The rule is stated in the struct’s own comment (include/net/netfilter/nf_conntrack.h:74):
struct nf_conn {
/* Usage count in here is 1 for hash table, 1 per skb,
* plus 1 for any connection(s) we are `master' for
*/
struct nf_conntrack ct_general;
spinlock_t lock;
/* jiffies32 when this ct is considered dead */
u32 timeout;
/* ... */
struct nf_conntrack_tuple_hash tuplehash[IP_CT_DIR_MAX];
unsigned long status;
/* ... */
struct nf_conn *master; /* the expectation link from the RELATED section */
struct nf_ct_ext *ext; /* extensions: NAT info, helper data, ... */
};
So the count is: +1 for being in the hash table, +1 per in-flight skb currently referencing it, +1 for each connection it is master of. nf_ct_put drops one reference; the entry is freed when the count hits zero.
Why the two-phase commit is leak-free
Now “the unconfirmed entry is freed on DROP” is exact:
- An UNCONFIRMED entry is not in the hash, so it lacks the
+1 for hash tablereference. It is held alive only by the in-flight skb’s reference (+1 per skb). - If a rule DROPs the packet, the skb is freed; freeing it runs the
nf_reset_ct-style teardown (nf_conntrack_put(skb_nfct(skb))) we saw earlier, dropping that one reference. The count hits zero, and the entry vanishes — no pollution of the global table. - CONFIRM is the moment the entry earns its durable hash reference.
__nf_conntrack_confirm(net/netfilter/nf_conntrack_core.c:1207,EXPORT_SYMBOL_GPLat:1352, registered as the.confirmcallback at:2733) inserts the entry intonf_conntrack_hash. At that instant the entry sets itsIPS_CONFIRMEDstatus bit (IPS_CONFIRMED_BIT = 3,include/uapi/linux/netfilter/nf_conntrack_common.h) and takes the+1 for hash tablereference.

timeout is a deadline, not a duration
ct->timeout is not “seconds remaining.” It is an absolute future timestamp in jiffies — the moment the entry is considered dead. When an entry is confirmed, the per-proto timeout is added to the current time (net/netfilter/nf_conntrack_core.c:1304):
ct->timeout += nfct_time_stamp; /* relative timeout -> absolute jiffies deadline */
“Is this entry dead?” is then the same jiffy comparison Day 7 used for neighbours — compute the remaining time and check its sign (:657):
s32 timeout = READ_ONCE(ct->timeout) - nfct_time_stamp; /* <= 0 means expired */
Two mechanisms act on that deadline:
- The GC worker periodically scans hash buckets and reaps expired entries. It’s a delayed work item (
struct conntrack_gc_workat:66,gc_workerat:1517), andnf_ct_gc_expired(:719) is what tears down an entry it finds past its deadline. early_dropevicts an LRU-ish entry when the table is full, so a flood can’t wedge the box.
Every accepted packet refreshes the deadline by rewriting ct->timeout to a new future jiffies value (the per-proto timeout again). That is why an idle flow ages out only after the full proto-specific timeout elapses with no traffic — exactly the behavior the force-expire lab demonstrates below. The IPS_SEEN_REPLY_BIT = 1 status bit (set once traffic is seen both ways) and IPS_CONFIRMED_BIT = 3 are part of the same status bitfield the entry carries.
Hash table sizing
sysctl net.netfilter.nf_conntrack_max # capacity (entries)
sysctl net.netfilter.nf_conntrack_buckets # hash table size (rounded to power of 2)
Rule of thumb: buckets = max / 4 to max / 8. Defaults are conservative; busy gateways set both higher (millions of entries on big NAT boxes).
When nf_conntrack_max is reached, new connections fail to track, and depending on the policy (nf_conntrack_tcp_loose, etc.) may also be dropped. The per-CPU counters including drop and early_drop (early eviction of LRU when full — the same early_drop from the timeout section) are reported by conntrack -S. They are also exposed in /proc/net/stat/nf_conntrack, but only when the kernel is built with CONFIG_NF_CONNTRACK_PROCFS=y — that procfs view is deprecated and off on many modern kernels (including the 7.1 target), so prefer conntrack -S.
Conntrack helpers
Each L7 protocol with auxiliary connections has a helper module. As we saw in the expectation section, a helper parses the control stream and registers nf_conntrack_expect entries so the predicted data flow gets tagged RELATED instead of NEW:
nf_conntrack_ftp: parses FTP control commands (PORT, PASV) and creates expectation entries for data connections.nf_conntrack_sip: same for SIP (creates RTP stream expectations).nf_conntrack_pptp: PPTP control + GRE tunnel expectations.nf_conntrack_irc: DCC.- …and many more.
Helpers are no longer auto-loaded by default (security: ALG-style helpers were a CVE source — the spoofing risk from the expectation section). You explicitly enable them per-rule in nftables:
sudo nft add table inet ct_lab
sudo nft 'add chain inet ct_lab prerouting { type filter hook prerouting priority 0 ; }'
sudo nft 'add ct helper inet ct_lab ftp { type "ftp" protocol tcp ; }'
sudo nft 'add rule inet ct_lab prerouting tcp dport 21 ct helper set "ftp"'
# cleanup
sudo nft delete table inet ct_lab
That ct helper set "ftp" is the explicit opt-in binding: only flows matching this rule get the FTP helper, so an attacker can’t trigger expectation creation on arbitrary ports.
NAT is built on conntrack
When you write iptables -t nat -A POSTROUTING -s 10.0.0.0/8 -j MASQUERADE, the actual mechanics reuse the two-tuple machinery directly:
- Outbound packet hits PRE_ROUTING. Conntrack creates NEW entry with original tuple
{src=10.0.0.5:1234, dst=8.8.8.8:80}. - POST_ROUTING runs. NAT rule matches; kernel picks an available source IP+port from the egress interface (e.g.,
203.0.113.1:50000). - Conntrack stores the NAT mapping by rewriting the reply tuple: instead of the plain inverse, the “expected reply” tuple becomes
{src=8.8.8.8:80, dst=203.0.113.1:50000}. TheIPS_SRC_NATstatus bit records that this entry needs source-NAT. - When the reply arrives at PRE_ROUTING, conntrack finds the entry by that reply tuple (same hash lookup as always) and automatically reverse-maps the destination back to
10.0.0.5:1234.
This is why you only write the SNAT rule once. The reply-recognition mechanism you already understand — match an incoming packet’s tuple against a stored reply tuple — is precisely what NAT bends to its purpose. The conntrack runs at PRE_ROUTING / LOCAL_OUT priority NF_IP_PRI_CONNTRACK = -200 (before the NAT and filter hooks from Day 20), which is why the entry exists by the time the NAT rule needs it.

Today’s experiment
Required setup: the userspace
conntracktool is not part of a default install. Everyconntrack ...command in this chapter (this experiment and the “Force entries to expire” section below) needs it:sudo apt-get install -y conntrack # Debian/Ubuntu # Fedora/RHEL: sudo dnf install -y conntrack-toolsThe force-expire section also uses
nc(netcat); install it withsudo apt-get install -y netcat-openbsdifwhich nccomes up empty.
# Inspect entries
sudo conntrack -L | head
# Live event monitoring
sudo conntrack -E &
# Generate flows
ping -c 3 8.8.8.8 &
curl -sI https://example.com > /dev/null
# Stats (per-CPU drop / early_drop counters)
sudo conntrack -S
# Same counters via procfs, only if CONFIG_NF_CONNTRACK_PROCFS=y (often off):
[ -r /proc/net/stat/nf_conntrack ] && head -5 /proc/net/stat/nf_conntrack \
|| echo "(no procfs view; needs CONFIG_NF_CONNTRACK_PROCFS=y — use 'conntrack -S')"
# Per-state count — only TCP has a state column ($4 is the state for tcp rows;
# for udp/icmp $4 is 'src=...', which would pollute the histogram), so filter -p tcp:
sudo conntrack -L -p tcp 2>/dev/null | awk '{print $4}' | sort | uniq -c | sort -rn
sudo killall conntrack # stop the -E monitor
In the conntrack -L output, each entry prints both tuples back to back — you are literally looking at tuplehash[ORIGINAL] followed by tuplehash[REPLY]. On a NAT box the reply half shows the rewritten address; on a plain host it’s the clean inverse.
On a busy host the per-state histogram is dominated by ESTABLISHED, with a handful of SYN_SENT/TIME_WAIT/CLOSE_WAIT:
42 ESTABLISHED
3 TIME_WAIT
1 SYN_SENT
(udp and icmp flows have no state column — only a timeout — which is exactly why we filter to -p tcp.)
Watch the table fill and entries age out. Default UDP timeout is 30s; TCP ESTABLISHED is 5 days (yes, days — long-lived connections shouldn’t get garbage-collected). Remember those numbers are the relative timeout that gets added to nfct_time_stamp to form the absolute ct->timeout deadline.
Trace conntrack in BPF
sudo bpftrace -e '
fentry:nf_conntrack_in {
printf("ct_in skb=%p hook=%d pf=%d\n",
args->skb, args->state->hook, args->state->pf);
}
fentry:__nf_conntrack_confirm {
printf("confirm skb=%p\n", args->skb);
}'
You’ll see one nf_conntrack_in per packet (ct lookup happens early at PRE_ROUTING and LOCAL_OUT) and one __nf_conntrack_confirm per accepted packet (at the end of the pipeline). The gap between those two counts is the two-phase commit in action: every packet gets a lookup, but only survivors get confirmed into the hash.
nf_conntrackis a module, sobpftrace -l "fentry:nf_conntrack_in"lists nothing unqualified — use the module formfentry:nf_conntrack:nf_conntrack_in/fentry:nf_conntrack:__nf_conntrack_confirmto list them, or thekprobe:nf_conntrack_in/kprobe:__nf_conntrack_confirmequivalents. The-erun-form above attaches fine either way because bpftrace resolves the module at attach time.
Force entries to expire
# Lower TCP ESTABLISHED timeout temporarily (default 432000s / 5 days)
old_timeout=$(cat /proc/sys/net/netfilter/nf_conntrack_tcp_timeout_established)
trap 'sudo sysctl -w net.netfilter.nf_conntrack_tcp_timeout_established=$old_timeout; kill $srv $cli 2>/dev/null || true' EXIT
sudo sysctl -w net.netfilter.nf_conntrack_tcp_timeout_established=60
# Open a connection, leave idle
nc -l 9999 & srv=$!
nc localhost 9999 & cli=$!
sudo conntrack -L | grep 9999 # see the entry
# The entry only ages out after the (lowered) idle timeout, so actually wait:
echo "waiting ~65s for the idle entry to age out..."; sleep 65
sudo conntrack -L | grep 9999 # now gone (no output)
What you are watching: with no traffic on the idle connection, nothing refreshes ct->timeout, so the GC worker’s READ_ONCE(ct->timeout) - nfct_time_stamp eventually goes negative and nf_ct_gc_expired reaps the entry. Always restore the timeout. It is global state, and lowering it can break legitimate long-lived idle connections on the host.
What to read in the kernel
-
net/netfilter/nf_conntrack_core.c:2013—nf_conntrack_in. The main entry point, registered as a Netfilter hook at PRE_ROUTING and LOCAL_OUT priorityNF_IP_PRI_CONNTRACK = -200. Read top to bottom (~80 lines for the function plus its helpers). Trace: tuple → lookup → either tag-existing or alloc-new → usually return ACCEPT. Conntrack can still drop invalid or stressed packets; filter rules are just the common place where policy drops happen. -
include/net/netfilter/nf_conntrack.h:74—struct nf_conn. The per-connection record. Read the refcount-rule comment at the very top (“1 for hash table, 1 per skb, plus 1 for any connection we are master for”). Only ~10-14 direct fields (a handful gated on config options like zones, NAT, and marks). Important:ct_general(the embedded refcount),timeout(absolute jiffies deadline),tuplehash[2](forward and reverse tuples),status(bitfield withIPS_CONFIRMED,IPS_SEEN_REPLY,IPS_SRC_NAT/IPS_DST_NAT),master(the expectation link), andext(a pointer to dynamically-allocated extensions for NAT info, helper data, etc.). -
include/net/netfilter/nf_conntrack_tuple.h:37—struct nf_conntrack_tuple. The tuple key. Note the union over L3 (IPv4 vs IPv6 addresses) and L4 (port pairs vs ICMP{type,code}vs GRE key). See alsonf_ct_invert_tuple(net/netfilter/nf_conntrack_core.c:429) for how the reply tuple is built by inversion. -
include/uapi/linux/netfilter/nf_conntrack_tuple_common.h:12/:44—enum ip_conntrack_dirand theCTINFO2DIRmacro. The two-line read that explains why direction is folded into the state value. -
include/uapi/linux/netfilter/nf_conntrack_common.h:7—enum ip_conntrack_info. The states. Quick read; note the reply variants areforward + IP_CT_IS_REPLY, and theIPS_*status bits below the enum. -
include/linux/skbuff.h:933—unsigned long _nfct, plusskb_nfct()(:4987) andskb_set_nfct()(:5005). The pointer+state packing trick;NFCT_PTRMASK/NFCT_INFOMASKlive atinclude/linux/netfilter/nf_conntrack_common.h:24. -
include/net/netfilter/nf_conntrack_expect.h:18—struct nf_conntrack_expect. The expectation record behind RELATED. -
net/netfilter/nf_conntrack_proto_tcp.c— TCP-specific state tracking. Implements the proper TCP state machine: SYN, SYN-ACK, ACK, FIN, etc., with sequence-number windowing checks. Read the comments at the top — it’s a paper. -
net/netfilter/nf_conntrack_proto_udp.c— UDP-specific (much simpler — just timestamps). -
net/netfilter/nf_conntrack_helper.c— helper registration. The plumbing for FTP, SIP, etc. -
net/netfilter/nf_nat_core.c— NAT built on conntrack. Reads conntrack entries, applies translations. -
Documentation/networking/nf_conntrack-sysctl.rst— every conntrack sysctl explained. -
External:
man conntrackandconntrack-toolspackage — userspace inspection and manipulation.
There are no Dumb Questions
Q: If a ping has no ports, how is it a “connection”?
A: The conntrack tuple’s L4 id is a union, not always a port. For ICMP the id is
{type, code}plus the echo identifier carried insrc. The reply tuple maps echo-request type → echo-reply type, so the echo and its reply hash to a matched pair just like a TCP SYN/SYN-ACK do. That’s whyconntrack -Llistsicmpflows.Q: How does one entry recognize traffic in both directions if the hash key is one tuple?
A: Every
nf_connregisters two tuples into the same global hash —tuplehash[ORIGINAL]andtuplehash[REPLY], where the reply isnf_ct_invert_tuple()of the original. An incoming packet’s tuple is looked up once; whichever stored tuple it matches tells you both the connection (nf_ct_tuplehash_to_ctrack) and the direction (thedst.dirbyte).Q: Where does the state (NEW/ESTABLISHED) actually live — in the entry or on the packet?
A: The entry holds long-lived state in its
statusbitfield. The per-packet classification lives in the low 3 bits ofskb->_nfct, packed alongside thenf_connpointer.skb_nfct()masks off those bits to get the pointer; the bits themselves are theip_conntrack_infovalue thect statematcher reads.
Bullet Points
- Conntrack tracks per-connection state in a global hash. The key is a tuple (
struct nf_conntrack_tuple): src+dst addresses, an L4 id that is a port for TCP/UDP but{type,code}for ICMP and a key for GRE, and a protocol number. - Every
nf_connstores two tuples —tuplehash[ORIGINAL]andtuplehash[REPLY], the reply being the inverse (nf_ct_invert_tuple). Both go in the same table; matching the reply tuple is how a response is recognized (and how NAT reverse-maps). - An skb carries its connection in one packed word,
skb->_nfct: high bits =nf_conn *, low 3 bits =ip_conntrack_info. That packing is why the state enum fits in 3 bits and whyIP_CT_IS_REPLY = 3doubles as a direction divider (CTINFO2DIR). - States: NEW, ESTABLISHED, RELATED, plus reply-direction variants (
= forward + IP_CT_IS_REPLY) — and INVALID (a matcher classification viaNF_CT_STATE_INVALID_BIT, not anip_conntrack_infoenum value). - RELATED is implemented by expectations (
struct nf_conntrack_expect): a helper parses the control stream, pre-registers the predicted tuple, and the matching flow is born with->masterset. ICMP errors are RELATED too (they embed the offending packet). Helpers are opt-in via nftct helper. - Hooked at PRE_ROUTING (priority -200) and LOCAL_OUT to capture both received and locally-generated traffic.
- Two-phase commit: an entry is created unconfirmed on the first packet, held alive only by the skb’s reference (refcount rule: 1 for hash + 1 per skb + 1 per master). On
NF_ACCEPT,__nf_conntrack_confirminserts it into the hash and takes the durable reference; on DROP the skb frees, refcount hits zero, the entry vanishes. Bogus packets don’t fill the table. ct->timeoutis an absolute jiffies deadline, not a duration. Each accepted packet refreshes it; the GC worker reaps entries whosetimeout - nowgoes negative;early_dropevicts when full.nf_conntrack_maxcaps total entries;nf_conntrack_bucketssizes the hash.- NAT is built on conntrack — it rewrites the entry’s reply tuple, so reply reverse-mapping happens automatically via the normal tuple lookup.
- Inspect:
conntrack -L,conntrack -E(events),conntrack -S(stats).
Check question
A SYN packet arrives. Conntrack creates a NEW entry. Then a netfilter rule decides DROP. What happens to the conntrack entry?
Click to reveal answer
Answer: The unconfirmed entry is freed, not added to the global table. Conntrack entries created in PRE_ROUTING (or LOCAL_OUT) are unconfirmed — they are not in the hash while the rest of the netfilter pipeline runs, so they lack the “+1 for hash table” reference from the nf_conn refcount rule. The entry is held alive by exactly one reference: the in-flight skb’s. When a rule DROPs the packet, the skb is freed, which drops that reference (nf_conntrack_put(skb_nfct(skb)) — the same teardown nf_reset_ct does); the refcount hits zero and the entry is freed. Only when a packet is ACCEPTED at the end does __nf_conntrack_confirm insert the entry into the global hash and take the durable reference (setting IPS_CONFIRMED). This is what prevents a port-scanning attacker from filling your conntrack table with NEW entries that never see a reply: scans of closed ports don’t leave permanent state. Bulk teardown of unconfirmed entries is nf_ct_iterate_destroy.
Tomorrow
Day 23: traffic control. The qdisc subsystem that controls packet pacing on egress.
Day 23 — Traffic control: qdiscs, classes, fq_codel
Today’s mission: understand what sits between IP and the driver, why bufferbloat exists, and how to inspect/configure egress queueing. Along the way you’ll learn the four algorithms the whole subsystem rests on — the token bucket (rate limiting), deficit round robin (fair queueing), CoDel’s control law (latency-bounded dropping), and tc’s classful handle tree (how
1:10-style names and filters work) — plus HTB, which is just two token buckets stacked — so everytccommand in the labs reads like a sentence, not a spell. Total time: ~120 minutes.
What a qdisc is
When IP wants to transmit a packet via dev_queue_xmit (Day 3), the packet doesn’t go straight to the driver. It goes to the device’s qdisc — a queueing discipline. The qdisc decides:
- Which packet leaves next (priority, fairness, scheduling).
- When (rate limiting, pacing).
- Whether to drop a packet (full queue, deliberate AQM — active queue management, see Background 3).
- How to spread packets across multiple flows (per-flow fairness).
Conceptually a qdisc is a function pair:
int enqueue(struct sk_buff *skb, struct Qdisc *q, struct sk_buff **to_free);
struct sk_buff *dequeue(struct Qdisc *q);
Plus management functions (init, destroy, stats, change-config). Per-qdisc-type implementations are in net/sched/sch_*.c.

How qdiscs are driven
Day 3 introduced __dev_queue_xmit and __qdisc_run. The full picture for egress:
__dev_queue_xmit(net/core/dev.c) picks a TX queue, finds the root qdisc, and callsenqueue.__qdisc_run(net/sched/sch_generic.c:440) is the “pump” — it dequeues and transmits. While there are packets and the qdisc is unlocked, it loops:dequeue→sch_direct_xmit(net/sched/sch_generic.c:344) →netdev_start_xmit(driver).- If
__qdisc_runruns too long, it defers the rest toNET_TX_SOFTIRQto avoid hogging the CPU.
The pump is invoked from two places:
- The xmit path (after a successful enqueue, while still in process context).
- The TX softirq (when the device frees skbs and signals “more room”).
That’s the recap. Everything below is the policy layer: the dequeue function gets to be clever, and the cleverness is built from a small handful of algorithms. We’ll teach each one before the lab that leans on it.
Background 1: The token bucket — how rate limiting actually works
Most of today’s labs revolve around one idea, and the word for it appears in the name of two qdiscs (tbf = token bucket filter; HTB = Hierarchical Token Bucket) — yet it’s never defined. Let’s fix that first, because once you have it, rate, burst, ceil, and “why does it drop instead of queue?” all become obvious.
The intuition: a bucket of permission slips
Imagine a bucket that a tap drips coins into at a constant rate — say 1 million coins per second. The bucket has a maximum depth: once full, extra coins overflow and are lost. To send a packet of length L bytes, you must spend L coins from the bucket. If the bucket holds at least L, the packet goes out immediately and L coins are removed. If it doesn’t, the packet has to wait until enough coins have dripped in.
Those coins are called tokens. This gives you three knobs:
rate— how fast the tap drips. This is the long-run steady-state ceiling: averaged over time, you can’t send faster than the tap fills the bucket.burst(a.k.a.buffer, the bucket depth) — how many unused tokens can pile up while you’re idle. After a quiet period the bucket is full, so you can send a short burst faster thanrate— exactly as fast as you can drain a full bucket — before settling back to the drip rate.latency— an iproute2-only knob (there is nolatencyfield in the kernel struct). At configuration timetcconverts it into a backlog byte limit (roughlyrate * latency + burst) and hands the kernel thatlimit, which sizes a bounded inner FIFO. A token-short packet is queued in that FIFO, not dropped; a tightlatencyjust makes the FIFO shallow.
Two consequences fall right out of this picture:
burstmust be ≥ one MTU. A bucket too shallow to ever hold a single packet’s worth of tokens can never send that packet — there’s no amount of waiting that fills a bucket past its own depth. The kernel comment says so literally.- A shallow bucket + tight latency drops sooner under sustained overload. A tight
latencyyields a smalllimit, hence a shallow inner FIFO. Token-short packets are backlogged there and drained as tokens accrue; but once that shallow FIFO fills, further arrivals are dropped at enqueue. So a tightlatencydoesn’t drop “instead of” queuing — it gives you a small queue that overflows quickly. This is the behavior you’ll observe in today’stbflab.

The concrete struct: tbf in v7.1
Open net/sched/sch_tbf.c. The per-qdisc state is exactly the bucket model:
struct tbf_sched_data {
u32 limit;
u32 max_size;
s64 buffer; /* Token bucket depth/rate: MUST BE >= MTU/B (sch_tbf.c:102) */
s64 mtu;
struct psched_ratecfg rate;
struct psched_ratecfg peak;
/* Variables */
s64 tokens; /* Current number of B tokens (sch_tbf.c:108) */
s64 ptokens; /* Current number of P tokens (sch_tbf.c:109) */
s64 t_c; /* Time check-point */
...
};
buffer is the bucket depth (burst), tokens is the current fill level. (There’s a second bucket — ptokens/peak — used when you configure a peakrate, an optional second, faster ceiling. Ignore it for the basic case.)
Now read the dequeue path (sch_tbf.c:285 onward). It’s the bucket model in code:
s64 toks;
s64 ptoks = 0;
...
now = ktime_get_ns();
toks = min_t(s64, now - q->t_c, q->buffer); /* drip: add tokens for elapsed time, cap at depth */
toks += q->tokens;
if (toks > q->buffer)
toks = q->buffer; /* overflow: can't exceed bucket depth */
toks -= (s64) psched_l2t_ns(&q->rate, len); /* spend: this packet costs `len` worth of tokens */
if ((toks|ptoks) >= 0) { /* enough tokens? send it. */
...
q->tokens = toks; /* commit the spend */
return skb;
}
qdisc_watchdog_schedule_ns(&q->watchdog, now + max_t(long, -toks, -ptoks)); /* else: wait */
Tokens are accounted in time units here (nanoseconds of rate), which is just a unit change from “bytes” — psched_l2t_ns converts a packet length to “how long the rate takes to earn that many tokens.” When toks goes negative, the qdisc schedules a watchdog timer for when enough tokens will have accrued and returns NULL; the packet stays backlogged in the inner FIFO until then. It is dropped only at enqueue — when it exceeds max_size/burst (sch_tbf.c:253) or when the inner FIFO is already full (sch_tbf.c:261, qdisc_qstats_drop). Note overlimits (bumped at sch_tbf.c:331 on every token-short dequeue) counts deferred dequeues — rate-limiting events — which is distinct from dropped. On a tight tbf you see all three of Sent/overlimits/dropped climb: overlimits because nearly every dequeue is rate-limited, dropped once the shallow FIFO overflows.
The default: fq_codel
The de-facto default on most Linux distros (systemd sets net.core.default_qdisc=fq_codel; the upstream kernel default is still pfifo_fast, sch_generic.c:37), and a sensible default for almost all workloads. net/sched/sch_fq_codel.c. Combines two ideas — fair queueing and CoDel — each of which is its own algorithm. Let’s teach both before reading the combined qdisc.
Background 2: Deficit Round Robin — fairness measured in bytes
fq_codel hashes each flow’s 5-tuple into one of N buckets (default 1024), each with its own FIFO. The hard part is the scheduler: when N buckets all have packets waiting, whose packet leaves next? “Round robin — one packet from each, in turn” sounds fair but isn’t.
Why plain round-robin is unfair
Suppose flow A sends 1500-byte packets and flow B sends 60-byte packets, both as fast as they can. Plain round-robin gives each one packet per turn — so over 1000 turns, A sends 1,500,000 bytes and B sends 60,000 bytes. A got 25× the bandwidth despite “equal turns.” Round-robin is only fair if every packet is the same size, which on a real link they never are.
The fix is to schedule in bytes, not packets. That algorithm is Deficit Round Robin (DRR).
The deficit counter
Each flow carries a credit called its deficit. The scheduler walks the flows; when it visits a flow it adds quantum bytes to that flow’s deficit, then dequeues packets while deficit > 0, subtracting each packet’s length from the deficit. When the deficit goes ≤ 0, the flow has spent its allowance for this round; it yields its turn and waits to be topped up next time around.
Over many rounds, every backlogged flow gets approximately quantum bytes per round — equal bandwidth, independent of packet size. The big-packet flow simply gets fewer packets per round to stay within the same byte budget. The default quantum is the device MTU, so a flow sends roughly one MTU worth of bytes per round.
The new/old split — the latency trick
DRR alone is fair but not snappy. fq_codel adds a second idea: it keeps two flow lists. A flow that just became active is appended to new_flows and served first; a flow that has exhausted its deficit drops down to old_flows and round-robins there. So a single DNS query, or a TCP SYN, or one HTTP request — a flow with one or two packets — lands in new_flows and jumps ahead of a long-running bulk transfer parked in old_flows. A forced pass back through old_flows prevents the bulk flows from ever starving.

The concrete struct: fq_codel in v7.1
In net/sched/sch_fq_codel.c, each flow holds:
struct fq_codel_flow {
...
int deficit; /* per-flow credit (sch_fq_codel.c:46) */
...
};
and the qdisc holds:
u32 quantum; /* psched_mtu(qdisc_dev(sch)); (sch_fq_codel.c:56) */
struct list_head new_flows; /* list of new flows (sch_fq_codel.c:66) */
struct list_head old_flows; /* list of old flows (sch_fq_codel.c:67) */
On activation a flow is credited (sch_fq_codel.c:213):
WRITE_ONCE(flow->deficit, q->quantum);
and the dequeue loop is the DRR algorithm verbatim (sch_fq_codel.c:290-319, begin: label through the deficit charge):
begin:
head = &q->new_flows; /* serve new flows first */
if (list_empty(head)) {
head = &q->old_flows; /* then old flows */
...
}
flow = list_first_entry(head, struct fq_codel_flow, flowchain);
if (flow->deficit <= 0) { /* out of credit? */
WRITE_ONCE(flow->deficit, flow->deficit + q->quantum); /* top up */
list_move_tail(&flow->flowchain, &q->old_flows); /* demote new -> old */
goto begin;
}
skb = codel_dequeue(...); /* CoDel runs HERE — see below */
if (!skb) {
/* force a pass through old_flows to prevent starvation */
if ((head == &q->new_flows) && !list_empty(&q->old_flows))
list_move_tail(&flow->flowchain, &q->old_flows);
...
goto begin;
}
WRITE_ONCE(flow->deficit, flow->deficit - qdisc_pkt_len(skb)); /* charge the bytes */
q->quantum is set to the device MTU at init (sch_fq_codel.c:481: q->quantum = psched_mtu(qdisc_dev(sch));).
Background 3: CoDel’s control law — latency-bounded dropping
Notice the codel_dequeue(...) call buried in that loop. That’s the AQM — Active Queue Management — running inside each flow’s bucket. Where DRR decides which flow sends, CoDel decides whether to drop to keep latency down.
Recall bufferbloat from Day 16 (Day 16, Background 5): oversized buffers add standing delay because loss-based TCP only backs off when the buffer overflows — so a too-big buffer fills, stays full, and every packet inherits the full queueing delay. CoDel is the answer to that. We won’t re-teach bufferbloat; we’ll teach the control law that fixes it, which is new.
Sojourn time, not queue length
The naive AQM watches queue length and drops when it’s “too long.” CoDel instead measures each packet’s sojourn time — how long it actually sat in the queue — computed at dequeue time. Length is a poor proxy (a long queue draining fast is fine); the time a packet waited is the thing you actually care about.
The two numbers: target and interval
CoDel has exactly two parameters, and they’re set once for all links (include/net/codel_impl.h:56-57):
params->interval = MS2TIME(100); /* 100 ms */
params->target = MS2TIME(5); /* 5 ms */
target(5 ms) is the standing-queue delay CoDel tries to bound. A momentary spike above 5 ms is fine — bursts happen.interval(100 ms) is the patience. CoDel only acts once sojourn time stays abovetargetcontinuously for oneinterval. That’s thefirst_above_timetest (include/net/codel.h:124/ field:134): on the first crossing above target the field is set to a deadline = (the moment sojourn first exceeded target) + oneinterval(codel_impl.h:139); dropping arms only oncenowpasses that deadline (codel_impl.h:140).intervalis roughly one worst-case RTT, giving TCP time to react to each drop before the next.
The escalation: drops that get closer together
Once CoDel enters dropping state, it does not drop everything. It drops on a schedule that escalates. The next drop time is
/* codel_control_law: next drop at t + interval/sqrt(count) (codel_impl.h:97-102) */
return t + reciprocal_scale(interval, rec_inv_sqrt << REC_INV_SQRT_SHIFT);
i.e. next = t + interval / sqrt(count). count increments per drop (codel_impl.h:186: WRITE_ONCE(vars->count, vars->count + 1);). Crucially, for each escalating drop t is the previous drop_next (codel_impl.h:191-194, :210-213), not the current time — only the very first drop after arming is measured from now (codel_impl.h:253). Re-basing each drop on the previous scheduled time is what keeps the spacing steady at interval/sqrt(count) instead of drifting. So the more drops it takes to bring latency back under target, the faster the subsequent drops come — a gentle nudge that ramps into firmer pressure. The kernel avoids an actual sqrt/divide by maintaining 1/sqrt(count) with a Newton-step approximation (codel_Newton_step, codel_impl.h:80, called right after the increment at :187).
This target + interval pair is why CoDel is self-tuning — no per-link configuration. target bounds the standing queue; interval ≈ one RTT paces the drops to TCP’s reaction time. And it complements DRR: DRR isolates flows from each other; CoDel keeps each flow’s own bucket shallow. fq_codel wires the defaults in at sch_fq_codel.c:484 (codel_params_init(&q->cparams);).

Combined
fq_codel runs DRR-based fair queueing (the “FQ”) as the per-flow scheduling — with new/old-flow prioritization for latency — and CoDel as the AQM inside each bucket. You get fairness and latency control. Inspect:
tc qdisc show dev eth0
# qdisc fq_codel 0: root refcnt 2 limit 10240p flows 1024 quantum 1514 target 5ms
limit = max packets across all flows; flows = number of buckets; quantum = the DRR round-robin credit (≈ MTU); target = CoDel’s 5 ms latency target.
fq — for BBR pacing
net/sched/sch_fq.c. Different from fq_codel. Per-flow pacing: each packet has a “send time” computed from the socket’s pacing rate (set by BBR via sk_pacing_rate). The qdisc holds packets back so they emit at exactly that rate.
BBR requires fq (or hardware pacing). Without it, BBR’s bandwidth estimate is corrupted by burstiness. If you sysctl tcp_congestion_control=bbr, also tc qdisc replace dev <dev> root fq. (Pacing and the bufferbloat motivation were taught in Day 16 Background 5 — we lean on them here, not re-teach.)
Background 4: tc’s classful tree — handles, classes, and filters
The HTB and clsact labs below hand you a dense thicket of colon-numbered names — handle 1:, classid 1:1, parent 1:1 classid 1:10, default 30, flowid 1:10 — plus a cryptic u32 match ip dport 22. None of it is hard once you know the naming scheme. Here it is.
Handles: major:minor
Every qdisc and class on a device is named by a 32-bit handle, written major:minor. Both halves are 16-bit (include/uapi/linux/pkt_sched.h:68-72):
#define TC_H_MAJ_MASK (0xFFFF0000U)
#define TC_H_MIN_MASK (0x0000FFFFU)
#define TC_H_MAJ(h) ((h)&TC_H_MAJ_MASK)
#define TC_H_MIN(h) ((h)&TC_H_MIN_MASK)
#define TC_H_MAKE(maj,min) (((maj)&TC_H_MAJ_MASK)|((min)&TC_H_MIN_MASK))
The convention:
- The major number identifies the qdisc.
- Minor 0 (written
1:) is the qdisc itself. - A nonzero minor (
1:10) is a class living inside that qdisc. rootis the special reserved handleTC_H_ROOT=0xFFFFFFFF(pkt_sched.h:75).
Classless vs classful
A classless qdisc (pfifo_fast, fq_codel, tbf) is a single black box: packets in, packets out, no internal structure you can name. A classful qdisc (HTB) is a tree: the root qdisc holds classes, and each class can hold child classes or a leaf qdisc. The tc syntax mirrors the tree:
parent 1: classid 1:1— “create class1:1directly under the root qdisc1:.”parent 1:1 classid 1:10— “create class1:10under parent class1:1.”default 30— “any packet that no filter classifies lands in class1:30.”
Filters: who decides which class a packet enters?
A class doesn’t grab packets by itself — a filter (classifier) routes them. tc filter ... u32 match ip dport 22 0xffff flowid 1:10 reads: “packets whose TCP destination port is 22 → send to class 1:10.” u32 is a classifier that matches raw header bytes by offset and mask — fast and general. The BPF-based classifier is cls_bpf / tcx (that’s what the clsact lab’s bpf da obj prog.o invokes).

Background 5: HTB — two token buckets per class, plus borrowing
Now HTB makes sense. net/sched/sch_htb.c. It divides bandwidth among classes hierarchically. The classic example: “give SSH 30 Mbps reserved, mail 50 Mbps, everything else 20 Mbps; allow each class to burst into unused capacity.”
That “burst into unused capacity” is the whole point, and it’s built from the token bucket you already learned — except HTB gives each class two buckets (sch_htb.c:97-98, 120):
struct psched_ratecfg rate;
struct psched_ratecfg ceil; /* sch_htb.c:97 */
s64 buffer, cbuffer; /* token bucket depth/rate (sch_htb.c:98) */
...
s64 tokens, ctokens; /* current number of tokens (sch_htb.c:120) */
rate+tokens/buffer— the guaranteed rate bucket.ceil+ctokens/cbuffer— the ceiling bucket, the absolute max.
A class is always in one of three modes (sch_htb.c:70-71, field cmode at :136):
enum htb_cmode {
HTB_CANT_SEND, /* class can't send and can't borrow */
HTB_MAY_BORROW,
HTB_CAN_SEND,
};
A class first sends from its own rate tokens (HTB_CAN_SEND). When those run out but the ceil bucket still has room, it enters HTB_MAY_BORROW and borrows unused tokens from its parent, up to ceil. That borrow-up-to-ceil is exactly the “burst into spare capacity” the example promises — a class with light siblings can climb from its guaranteed rate all the way to ceil, because the parent has tokens to lend.
# Use a lab interface, not your real uplink.
sudo ip link add tc-lab type dummy
sudo ip link set tc-lab up
sudo tc qdisc add dev tc-lab root handle 1: htb default 30
sudo tc class add dev tc-lab parent 1: classid 1:1 htb rate 100mbit
sudo tc class add dev tc-lab parent 1:1 classid 1:10 htb rate 30mbit ceil 100mbit
sudo tc class add dev tc-lab parent 1:1 classid 1:20 htb rate 50mbit ceil 100mbit
sudo tc class add dev tc-lab parent 1:1 classid 1:30 htb rate 20mbit ceil 100mbit
sudo tc filter add dev tc-lab parent 1: protocol ip prio 1 u32 \
match ip dport 22 0xffff flowid 1:10
# cleanup
sudo ip link del tc-lab
Reading it with the four backgrounds in hand: handle 1: is the root HTB qdisc; 1:1 is a 100mbit parent class holding all the capacity; 1:10/1:20/1:30 are leaf classes with guaranteed rates that may each borrow up to ceil 100mbit; the u32 filter steers SSH (dport 22) into 1:10; everything unmatched falls to default 30 → class 1:30. rate = guaranteed minimum; ceil = max if there’s spare capacity. Powerful but complex; for most users fq_codel is simpler and as effective.
clsact — the BPF hook scaffold
net/sched/sch_ingress.c (~376 lines, mostly registration). A special qdisc that has no queueing logic — it reuses the classful filter machinery from Background 4 but does no scheduling. All it does is expose the reserved ingress and egress hook minors so tc-bpf classifier programs can attach to both directions of a device. Those reserved minors are (pkt_sched.h:80-81):
#define TC_H_MIN_INGRESS 0xFFF2U
#define TC_H_MIN_EGRESS 0xFFF3U
Day 16/17 of the eBPF book covered classic tc-bpf section names such as SEC("tc_ingress") and the modern tcx replacement.
sudo ip link add tc-lab type dummy
sudo ip link set tc-lab up
sudo tc qdisc add dev tc-lab clsact
sudo tc filter add dev tc-lab ingress bpf da obj prog.o sec tc_ingress
# cleanup
sudo ip link del tc-lab
Modern code uses tcx (bpf link-based) instead, which doesn’t need clsact setup.
Bufferbloat — the problem fq_codel solves
The experiment below makes bufferbloat visible: saturate the uplink and watch ping RTT under fq_codel versus pfifo_fast. The mechanism (loss-based TCP fills an oversized buffer and every packet inherits the standing delay) was taught in Day 16 Background 5; CoDel’s control law in Background 3 above is the per-flow cure.
Test it (needs iperf3: sudo apt install -y iperf3; replace some-server with a host you can saturate):
# Capture the current qdisc so we can put it back exactly.
orig=$(tc qdisc show dev eth0 | head -1); echo "was: $orig"
# Default: fq_codel
ping -c 5 8.8.8.8 # baseline RTT, say 30ms
iperf3 -c some-server -t 60 & # saturate uplink
ping -c 5 8.8.8.8 # should stay close to 30ms — fq_codel keeps queue short
pkill -f 'iperf3 -c' # stop this saturator BEFORE changing qdiscs (clean contrast)
# Force pfifo_fast (drop-tail, no AQM)
sudo tc qdisc replace dev eth0 root pfifo_fast
iperf3 -c some-server -t 60 &
ping -c 5 8.8.8.8 # may shoot up to seconds
pkill -f 'iperf3 -c' # stop the saturator
# Restore the qdisc your interface had before this test (commonly fq_codel).
sudo tc qdisc replace dev eth0 root fq_codel
The contrast is stark on home networks with cable modems. Better routers ship fq_codel (or its variant cake) by default precisely to fix this.
There are no Dumb Questions
Q: If
burstlets me send faster thanrate, can I just set a hugeburstto never be limited?A: No —
burstonly buys you a one-time head start equal to the bucket depth, accumulated while idle. Once you’re sending steadily, the bucket drains as fast as the tap fills it, so your sustained rate is pinned torateregardless ofburst. A hugeburstjust means a longer initial spike before you settle. (It also costs latency: a deep bucket can hold a deep backlog.)Q: DRR gives every flow
quantumbytes per round. Doesn’t a flow sending tiny packets get cheated, since it can’t fill a whole quantum?A: It carries the leftover forward — that’s the deficit. A flow that under-spends its quantum this round starts next round with the unspent credit still on the books, so over time it gets its full byte share. The deficit is precisely the memory that makes “bytes per round” fair even when packets don’t divide evenly.
Q: Why does CoDel measure sojourn time at dequeue and not when the packet arrives?
A: Because the thing that hurts is how long the packet actually waited, which you only know when it leaves. Measuring at enqueue would tell you the queue’s length, not its delay — and a long-but-fast-draining queue has low delay. CoDel deliberately ignores length and acts on realized latency.
Q:
fq_codelis fair and low-latency. Why would anyone use HTB?A: When you need policy, not just fairness.
fq_codelshares bandwidth equally among active flows; it can’t express “SSH is guaranteed 30 Mbps even when the bulk transfer wants everything.” HTB’s classes andrate/ceilencode that contract. Many setups use both: HTB to carve guarantees, withfq_codelas the leaf qdisc inside each class.
Today’s experiment
# Inspect current qdiscs
tc qdisc show
tc -s qdisc show dev eth0 # with stats
# Count qdisc pump invocations (the dequeue/transmit loop, __qdisc_run).
# __qdisc_run is the DEQUEUE side, not enqueue — enqueue is the qdisc's
# ->enqueue op called from __dev_xmit_skb, which this probe does not count.
# Your own SSH egress keeps it firing, so expect a small non-zero count every
# 5s (~6-40 on an idle box). Generate traffic (ping -f, iperf3) to watch it
# climb. Runs until you press Ctrl-C.
sudo bpftrace -e '
fentry:__qdisc_run { @ = count(); }
interval:s:5 { print(@); clear(@) }'
# Add a token-bucket rate limit (lab on lo). lo's default qdisc is `noqueue`,
# so that is what we restore to.
trap 'sudo tc qdisc replace dev lo root noqueue 2>/dev/null || true; pkill -f "iperf3 -s" 2>/dev/null || true' EXIT
sudo tc qdisc replace dev lo root tbf rate 1mbit burst 32kbit latency 50ms
# Test: should be slow (~1 Mbit/s). Needs iperf3: `sudo apt install -y iperf3`.
iperf3 -s -p 5201 &
sleep 0.5 # let the server start listening
iperf3 -c 127.0.0.1 -p 5201 -t 30 & # run long enough to sample the queue
# While it runs, sample the qdisc. This tbf (1mbit / 32kbit burst / 50ms) is
# tight enough that its shallow inner FIFO overflows under load, so the reliable
# observables are the CUMULATIVE counters, not the instantaneous backlog.
# (Background 1 explains WHY: latency 50ms sizes a small byte `limit`, so the
# inner FIFO is shallow; token-short packets queue there briefly, and once it
# fills, further arrivals are dropped at enqueue. `overlimits` climbs on every
# rate-limited dequeue — a deferral, distinct from a drop.)
tc -s qdisc show dev lo
# qdisc tbf 8001: root refcnt 2 rate 1Mbit burst ... lat 50ms
# Sent <bytes> bytes <pkt> pkt (dropped <N>, overlimits <N> requeues 0)
# backlog 0b 0p requeues 0
# `dropped`/`overlimits`/`Sent` all climb as the tbf rate-limits and SURVIVE
# after the transfer ends (`overlimits` counts deferred dequeues, `dropped`
# counts FIFO overflows at enqueue — distinct signals); `backlog` is
# instantaneous and drains back to `0b 0p` once the client stops, so don't
# expect to "watch it grow" on this tight limit.
wait # let the client finish
pkill -f 'iperf3 -s' # stop the background server
# Restore loopback's usual noqueue qdisc
sudo tc qdisc replace dev lo root noqueue
Switch CC to BBR and confirm fq pacing is active
old_cc=$(cat /proc/sys/net/ipv4/tcp_congestion_control)
trap 'sudo sysctl -w net.ipv4.tcp_congestion_control=$old_cc; sudo tc qdisc replace dev lo root noqueue 2>/dev/null || true; pkill -f "iperf3 -s" 2>/dev/null || true' EXIT
sudo modprobe tcp_bbr
sudo sysctl -w net.ipv4.tcp_congestion_control=bbr
# BBR needs fq (or hardware pacing) for its sk_pacing_rate to be honored.
sudo tc qdisc replace dev lo root fq
# Self-contained server for this block (needs iperf3: `sudo apt install -y iperf3`).
iperf3 -s -p 5201 &
sleep 0.5
iperf3 -c 127.0.0.1 -p 5201 -t 20 & # background it so we can sample mid-flight
sleep 2
# Congestion control must be sampled WHILE the transfer is in flight.
# ss prints the cc as a BARE token (`bbr`), NOT `ca:bbr`; cwnd and pacing_rate
# are on the same per-socket info line:
ss -tin dst 127.0.0.1:5201 | grep -E 'bbr|cwnd'
# Example (from a real bbr socket):
# bbr wscale:6,10 ... cwnd:37 ... bbr:(bw:...,pacing_gain:2.88672,cwnd_gain:2.88672) pacing_rate 21003088bps
wait
pkill -f 'iperf3 -s' 2>/dev/null || true
# NOTE: loopback has no bandwidth bottleneck, so this confirms BBR is *active*
# (the `bbr` token + a live pacing_rate) and that `fq` is installed — but you
# cannot measure an fq-vs-fq_codel pacing *difference* here. For a real contrast
# you need an actual NIC or a veth+netem bottleneck (see Day 16).
sudo sysctl -w net.ipv4.tcp_congestion_control=$old_cc
sudo tc qdisc replace dev lo root noqueue # restore
What to read in the kernel
-
net/sched/sch_generic.c:440—__qdisc_run. The pump. Compact (~17 lines): aqdisc_restartloop with budget tracking and the deferred-to-softirq path. Read the helpers it drives (qdisc_restart,sch_direct_xmit) for the full picture. -
net/sched/sch_generic.c:344—sch_direct_xmit. The “actually push to driver” call. Handles the requeue case when the driver returns BUSY. -
net/sched/sch_tbf.c:285— thetbfdequeue path. The token-bucket model in code: drip (now - q->t_c), cap atq->buffer, spendpsched_l2t_ns(&q->rate, len), send iftoks >= 0else schedule the watchdog. The bucket depth isbuffer(:102), the fill leveltokens(:108). -
net/sched/sch_fq_codel.c:185—fq_codel_enqueue. Hash the flow, find the bucket, append (and link the flow intonew_flowsif it just became active). Good warm-up before the DRR dequeue logic. -
net/sched/sch_fq_codel.c:283—fq_codel_dequeue. The interesting one — the DRR new/old-flow loop (:299-318) with the per-flowdeficitaccounting, wrapped aroundcodel_dequeuewhich runs CoDel’s AQM. Walk through to see fairness and latency control in one function. -
include/net/codel_impl.h— CoDel’s control law.codel_params_initsetsinterval = 100 ms,target = 5 ms(:56-57);codel_control_lawreturnst + interval/sqrt(count)(:97-102); the escalation iscount + 1at:186pluscodel_Newton_step(:80) avoiding the divide. -
net/sched/sch_fq.c—fqfor BBR. Read the per-flow pacing logic. Noticef->time_next_packet(sch_fq.c:94) per flow tracks “earliest send time” to honor the pacing rate; the qdisc-wide field isq->time_next_delayed_flow. -
net/sched/sch_htb.c— HTB. Long file (~2000 lines) but the core is clear: classful tree (htb_classifyat:219,htb_lookup_leafat:815), per-class two token buckets (tokens/bufferforrate,ctokens/cbufferforceil,:98/:120), three-mode borrowing (enum htb_cmodeat:70), dequeue picks the highest-priority class with tokens. -
net/sched/sch_ingress.c— clsact. ~376 lines; mostly registration. It exposes the reserved ingress/egress hook minors (TC_H_MIN_INGRESS/TC_H_MIN_EGRESS,include/uapi/linux/pkt_sched.h:80-81). The actual BPF dispatch is viatcx(modern) or the tc-bpf classifier incls_bpf.c. -
include/uapi/linux/pkt_sched.h:68-75— the handle macros (TC_H_MAJ/TC_H_MIN/TC_H_MAKE) andTC_H_ROOT. This is themajor:minornaming scheme behind every1:10-style id. -
include/net/sch_generic.h—struct Qdisc,struct Qdisc_ops. The vtable each qdisc implements. -
Source comments in
net/sched/sch_fq_codel.candinclude/net/codel*.hare short and clear; for background see RFC 8289 and bufferbloat.net (the per-qdiscDocumentation/networking/sch_*.txtfiles no longer exist in the tree). -
External: bufferbloat.net has the canonical writeup of the problem fq_codel solves.
Bullet Points
- qdiscs sit between IP and driver; control queueing, pacing, dropping on egress.
- A token bucket refills
tokensatrateup to depthburst; a packet spends its length in tokens and waits (backlogged in a bounded inner FIFO) if short.burstmust be ≥ MTU. Thelatencyknob is iproute2-only —tcconverts it to that FIFO’s bytelimit(≈rate*latency + burst); a packet is dropped at enqueue only when it exceedsburst/max_sizeor the FIFO is full. This istbf(sch_tbf.c:102/108) and the heart of HTB. - De-facto distro default (via systemd; upstream kernel default is
pfifo_fast):fq_codel= DRR fair queueing + CoDel early-drop AQM. - DRR schedules in bytes: each flow gets
quantum(≈MTU) credit per round and is charged each packet’s length, so packet size doesn’t skew fairness. Thenew_flows/old_flowssplit lets sparse interactive flows jump ahead (sch_fq_codel.c:46/66/299). - CoDel measures per-packet sojourn time, drops only once it stays above
target(5 ms) for oneinterval(100 ms), then escalates drops atinterval/sqrt(count)(codel_impl.h:56/97/186). Self-tuning, no per-link config. fq: per-flow pacing; required for BBR’s bandwidth estimate to work.- Handles are
major:minor: minor 0 = the qdisc, nonzero = a class;root=TC_H_ROOT(pkt_sched.h:68-75). Classful qdiscs (HTB) form a tree; filters (u32, cls_bpf) classify packets into classes. - HTB: per-class two token buckets (
rateandceil); classes borrow up fromratetowardceilviaHTB_MAY_BORROW(sch_htb.c:70/98/120). Hierarchical bandwidth division for classful QoS. clsactis a hook scaffold for tc-bpf (no queueing logic); exposes ingress/egress hook minors (pkt_sched.h:80-81).- The dispatch loop is
__qdisc_run(net/sched/sch_generic.c:440), driven from xmit and fromNET_TX_SOFTIRQ. - Inspect with
tc -s qdisc show dev DEV. Modify withtc qdisc replace dev DEV root <type>. - Bufferbloat is the problem AQMs (CoDel, FQ_PIE, CAKE) solve.
Check question
You set tc qdisc replace dev eth0 root pfifo_fast and saturate the uplink. SSH becomes unresponsive. Why?
Click to reveal answer
Answer: pfifo_fast is drop-tail with three priority bands and no AQM. Bulk transfer fills the buffer (say 1000 packets); SSH packets queue behind the bulk, waiting for it to drain. With ms-per-packet drain rate at typical uplinks, latency goes from ms to seconds. The buffer hides loss from TCP — TCP keeps pushing more bytes because it sees no drops. Only when the buffer overflows does TCP get a signal, and by then the queue has been steady-state full for a long time.
fq_codel solves both halves:
- Per-flow fairness (DRR): SSH and bulk hash into different buckets, dequeued by deficit round robin in bytes — and SSH, being sparse, sits in
new_flowsand is served first. Bulk can’t starve SSH even if it wants to. - Early drop (CoDel): when a packet’s sojourn time stays above the 5 ms target for one 100 ms interval, CoDel drops, escalating at
interval/sqrt(count). TCP sees the drops and backs off; the buffer drains.
Restore with tc qdisc replace dev eth0 root fq_codel.
Tomorrow
Day 24: SO_REUSEPORT and socket steering. Multi-process servers without thundering herd.
Day 24 — SO_REUSEPORT and socket steering
Today’s mission: see how multiple worker processes can share a single listening port without contention, exactly how the kernel decides which worker gets each connection, and how BPF can override that decision for custom load balancing. Along the way we’ll teach the four things the steering story actually rests on — the per-flow hash that drives the pick, the NIC hardware that distributes packets across cores, the BPF program model that lets you take over, and what really happens to in-flight connections when a worker dies. Total time: ~110 minutes.

The single-listener problem
A pre-SO_REUSEPORT multi-worker server typically went one of two ways:
-
Single listening socket, shared via
accept(). Workers all callaccept()on the same FD. A blockingaccept()has been wake-one since ~2.4 (2000):inet_csk_wait_for_connectqueues waiters withprepare_to_wait_exclusive(net/ipv4/inet_connection_sock.c:622), whose comment reads “True wake-one mechanism for incoming connections: only one process gets woken up, not the whole herd.” So a blocking-accept()design does not suffer a thundering herd. The herd that survived into the 2010s was different: workers waiting on the shared listening FD viaepoll_wait/select/poll. A new connection makes that FD readable and wakes all epoll waiters, only one of which then winsaccept().EPOLLEXCLUSIVE(Linux 4.5, 2016) — a flag on epoll, not onaccept()— fixed that case by waking only one waiter. But either way there’s still a single accept queue behind one socket, guarded by one socket lock — the real bottleneck. -
One listener process, push connections to workers via fd-passing or socketpair. Adds a hop (the listener has to accept, then pass), adds complexity (worker management).
Both leave the listener as a bottleneck.
What SO_REUSEPORT does
Linux 3.9 (2013) added the option. setsockopt(fd, SOL_SOCKET, SO_REUSEPORT, &one, sizeof(one)). Set on each worker’s socket before bind(), with the same UID and the same (addr, port). The kernel allows N sockets to bind the same (addr, port) simultaneously — but only if all of them have SO_REUSEPORT set.
int sock = socket(AF_INET, SOCK_STREAM, 0);
int opt = 1;
setsockopt(sock, SOL_SOCKET, SO_REUSEPORT, &opt, sizeof(opt));
struct sockaddr_in addr = { AF_INET, htons(80), INADDR_ANY };
bind(sock, (struct sockaddr*)&addr, sizeof(addr));
listen(sock, 128);
/* Each of N workers does this independently, with their own FD */
Each worker now has its own listening socket and its own accept queue. No shared state in the hot path.
Background 0: the group is a flat array, not a list
All the sockets that bound the same (addr, port) with SO_REUSEPORT are gathered into one kernel object: struct sock_reuseport (include/net/sock_reuseport.h:13). The important thing about it is its shape — it is a flat array of socket pointers, not a linked list:
struct sock_reuseport {
struct rcu_head rcu;
u16 max_socks; /* length of socks[] */
u16 num_socks; /* live elements */
u16 num_closed_socks;
u16 incoming_cpu;
/* ... */
struct bpf_prog __rcu *prog; /* optional BPF sock selector */
struct sock *socks[] __counted_by(max_socks);
};
Two fields drive everything today. socks[] is the flat array of the group’s listening sockets, and num_socks (a u16, so up to 65535 workers) is how many are live. Because it’s an array, “pick a worker” is a single O(1) index — socks[i] — not a list walk. And the optional prog is a single BPF program, held under RCU, that can take over the picking. Keep this array picture in your head: every selection mechanism in this chapter ends in “compute an index i, return socks[i].”
How the kernel picks one
When a SYN arrives, the kernel does a lookup in __inet_lookup_listener (net/ipv4/inet_hashtables.c:467). It finds the lhash2 listener bucket (the per-(addr, port) hash bucket of listening sockets) for (addr, port). If multiple sockets share that slot via SO_REUSEPORT, the lookup funnels through a small wrapper, inet_lookup_reuseport (net/ipv4/inet_hashtables.c:392), which computes a per-flow hash and then calls reuseport_select_sock (net/core/sock_reuseport.c:568) to choose the index.
The one-sentence version everyone repeats is “it hashes the 4-tuple modulo N.” That’s almost right, and almost-right is exactly what trips people up when they try to reason about affinity, reboots, and CPU pinning. So let’s make the hash real before we lean on it.
Background 1: the flow hash that actually drives the pick
The whole “same client → same worker” story rests on one u32. The probe in today’s lab prints it as arg1 of reuseport_select_sock. Where does that number come from, and what does “modulo N” really mean?
The hash is the same hash that keys the connection table
reuseport_select_sock does not compute the hash itself — it’s handed in. The caller, inet_lookup_reuseport, computes it from the SYN’s 4-tuple (net/ipv4/inet_hashtables.c:402):
if (sk->sk_reuseport) {
phash = INDIRECT_CALL_2(ehashfn, udp_ehashfn, inet_ehashfn,
net, daddr, hnum, saddr, sport);
reuse_sk = reuseport_select_sock(sk, phash, skb, doff);
}
For TCP, ehashfn is inet_ehashfn — the same family of hash that keys the established-connection table (the ehash you met on Day 13). The reuseport selector isn’t inventing a new hash; it’s recomputing the connection hash for the incoming SYN’s 4-tuple. That’s why the affinity is “per flow”: the same 4-tuple always produces the same phash.
The hash is seeded by a per-boot random secret
Here’s the nuance the “modulo a 4-tuple” framing hides. inet_ehashfn is not a pure function of the 4-tuple (net/ipv4/inet_hashtables.c:40):
u32 inet_ehashfn(const struct net *net, const __be32 laddr,
const __u16 lport, const __be32 faddr, const __be16 fport)
{
return lport + __inet_ehashfn(laddr, 0, faddr, fport,
inet_ehash_secret + net_hash_mix(net));
}
It mixes in inet_ehash_secret — a random value generated once per boot — plus net_hash_mix(net), a per-network-namespace salt. The consequence is precise and worth stating loudly:
Affinity (“same client → same worker”) holds for the life of one boot, in one network namespace. Across a reboot the secret is regenerated, so the whole mapping reshuffles — a client that hit worker 2 yesterday may hit worker 0 today. Two different netns also map the same 4-tuple differently.
Today’s AFFINITY lab — connect twice from the same source port and watch the same worker answer — works because the secret is fixed while the box stays up, not because the 4-tuple alone determines the worker. That’s an important caveat for anyone building “sticky” per-worker caches on top of this.
“modulo N” is really a multiply-shift
reuseport_select_sock reduces the u32 hash to an array index in reuseport_select_sock_by_hash (net/core/sock_reuseport.c:527):
i = j = reciprocal_scale(hash, num_socks);
and reciprocal_scale (include/linux/math.h:194) is not an arithmetic modulo:
static inline u32 reciprocal_scale(u32 val, u32 ep_ro)
{
return (u32)(((u64) val * ep_ro) >> 32);
}
It multiplies the hash by num_socks as a 64-bit product and takes the top 32 bits — a “multiply-shift” that maps any u32 into [0, num_socks). The statistical spread is the same as hash % N, which is why the prose can keep saying “fans out evenly,” but the exact index a given hash lands on is different from what % would give. So: say “scaled into [0, N),” not “modulo N,” whenever you actually try to predict an index. The kernel’s own comment on the function admits it’s “sort of a modulus, only that the result isn’t that of modulo.”
![Flow-hash pipeline from 4-tuple through inet_ehashfn and reciprocal_scale to a socks[] index](diagrams/day24_hash_pipeline.png)
A second steering knob: SO_INCOMING_CPU
The “pure 4-tuple hash” picture has one more wrinkle. Look back at reuseport_select_sock_by_hash (net/core/sock_reuseport.c:539): after computing the starting index it checks READ_ONCE(reuse->incoming_cpu) (the sk_incoming_cpu == raw_smp_processor_id() compare is at :544). Note this preference is evaluated only in the listener (non-TCP_ESTABLISHED) branch of reuseport_select_sock_by_hash. If any socket in the group set SO_INCOMING_CPU (SO_INCOMING_CPU = 49, include/uapi/asm-generic/socket.h:80), the selector prefers a socket whose sk_incoming_cpu == raw_smp_processor_id() — i.e. it steers the connection to the worker already running on the CPU that processed the packet, overriding the pure-hash pick. The CPU-pinning lab below leans on hash-only distribution; just know that SO_INCOMING_CPU exists as an alternative steering policy that ties worker choice to the receiving CPU rather than the flow hash. We’ll see in Background 2 why that knob is the bridge to hardware RX steering.
Background 2: NIC RSS — distributing packets across cores in hardware
The closing payoff of today’s CPU-pinning lab is “combined with NIC RSS you get end-to-end multi-core scaling.” RSS is the other half of that claim, and nothing earlier in this book has defined it — Day 1 taught a single RX descriptor ring; RSS is what happens when there are many.
RSS = many RX rings, each with its own IRQ, NAPI, and CPU
Recall from Day 1 the RX descriptor ring: a circular array of descriptors, each naming a DMA buffer the NIC fills, with the driver’s NAPI poll (Day 2) draining the done ones. A real multi-queue NIC has N of those rings — one per hardware queue. Crucially, each queue has:
- its own MSI-X interrupt, so different queues can interrupt different CPUs, and
- its own NAPI context (
struct napi_structfrom Day 2), so different CPUs run softirq RX for different queues in parallel.
This is RSS — Receive Side Scaling (Documentation/networking/scaling.rst:33). One CPU is no longer the choke point for receive: the protocol stack for flow A can run on CPU 0 while flow B runs on CPU 3, at the same time.
How the NIC decides which queue
The NIC hashes each incoming frame — typically a Toeplitz hash over the 4-tuple — and uses the low bits of that hash to index an indirection table; the table entry names the RX queue (scaling.rst:42). That is the hardware analogue of the software hash from Background 1: both spread the same flow space, one in silicon at RX time, one in the kernel at listener-lookup time.
Why RSS and SO_REUSEPORT compose — and why it isn’t automatic
The two mechanisms answer different questions:
- RSS decides which CPU runs the protocol stack for a packet (which RX queue → which softirq → which core).
- SO_REUSEPORT decides which listening socket (hence which accept queue, hence which worker) gets the completed connection.
If worker N is pinned to CPU N and RSS happens to land that flow’s softirq on CPU N too, then the SYN is processed, the connection is accepted, and the worker reads it all on the same core — no cross-core cache bouncing of the socket and its data.
But watch the catch: this co-location is not automatic. The NIC’s Toeplitz hash and the kernel’s inet_ehashfn use different algorithms and different seeds, so the RSS queue and the reuseport index are independent — a flow can easily land on CPU 1’s RX queue yet hash to the worker on CPU 3. To actually pin “received on CPU N → accepted by worker on CPU N,” you align them deliberately: SO_INCOMING_CPU (Background 1) makes reuseport prefer the socket on the receiving CPU, and aRFS (accelerated Receive Flow Steering) pushes the NIC to steer a flow to the queue whose CPU owns the socket.
When the NIC lacks RSS, the kernel offers software fallbacks that re-distribute received packets across CPUs after the fact: RPS (Receive Packet Steering) and RFS (Receive Flow Steering) (scaling.rst:17). One sentence is enough here — just know the cross-reference exists if your NIC is single-queue.

BPF-controlled selection
The default hash is sometimes wrong. Examples:
- Sticky sessions by URL hash (need to look at L7).
- Routing by application-defined session ID.
- Forcing connections from a specific source range to a specific worker.
You can attach a BPF program to control selection:
int prog_fd = bpf_program_load(...);
setsockopt(sock, SOL_SOCKET, SO_ATTACH_REUSEPORT_EBPF, &prog_fd, sizeof(prog_fd));
reuseport_attach_prog (net/core/sock_reuseport.c:683) is the kernel-side handler; it stores the program in struct sock_reuseport->prog under RCU.
But “the program returns the chosen socket index” — the way this is usually described — is the old convention, and conflating it with the modern one makes test_select_reuseport_kern.c unreadable. There are genuinely two selection models. Background 3 untangles them.
Background 3: the SK_REUSEPORT program, its context, and two return conventions
You already know from Day 2 what an eBPF program is: verified, kernel-runnable code attached at a hook (Day 2’s example was XDP at the driver). Here only two things are new — the context struct this hook hands the program, and the fact that there are two different conventions for how the program names a winner.
The program type fixes the context
A program type fixes (a) what context struct the program receives and (b) what its result means. For reuseport the type is BPF_PROG_TYPE_SK_REUSEPORT (include/uapi/linux/bpf.h:1082), and the context is struct sk_reuseport_md (include/uapi/linux/bpf.h:6620):
struct sk_reuseport_md {
void *data; /* L4 header onward — for direct packet reads */
void *data_end; /* one past the directly-readable bytes */
__u32 len; /* total length from the L4 header */
__u32 eth_protocol;
__u32 ip_protocol;
__u32 bind_inany;
__u32 hash; /* the same 4-tuple flow hash from Background 1 */
__bpf_md_ptr(struct bpf_sock *, sk); /* any group member */
__bpf_md_ptr(struct bpf_sock *, migrating_sk); /* set only when migrating */
};
So the program can read the flow hash (the very u32 from Background 1), peek at protocol fields, learn the local IP/port via sk, and — for payload-based routing — read packet bytes directly between data and data_end. To reach bytes beyond data_end (e.g. an L7 URL to hash on), it calls the helper bpf_skb_load_bytes_relative; that helper is the bridge between “metadata only” and “look at the payload.”
Two conventions, one dispatch fork
The conflated part is the return convention. The dispatch lives right inside reuseport_select_sock (net/core/sock_reuseport.c:594):
if (prog->type == BPF_PROG_TYPE_SK_REUSEPORT)
sk2 = bpf_run_sk_reuseport(reuse, sk, prog, skb, NULL, hash); /* eBPF: helper model */
else
sk2 = run_bpf_filter(reuse, socks, prog, skb, hdr_len); /* cBPF: index model */
(1) Classic cBPF — attached via SO_ATTACH_REUSEPORT_CBPF (= 51, socket.h:85). The program returns an integer index. run_bpf_filter (net/core/sock_reuseport.c:497) takes that return value and does:
index = bpf_prog_run_save_cb(prog, skb);
if (index >= socks)
return NULL;
return reuse->socks[index];
So “the program returns the chosen socket index” is the cBPF story — a direct index into socks[].
(2) Modern eBPF — attached via SO_ATTACH_REUSEPORT_EBPF (= 52, socket.h:86). This program does not pick by return value. Its return code is just SK_PASS/SK_DROP; the actual selection happens through a helper, bpf_sk_select_reuseport(reuse_md, &map, &key, flags) (include/uapi/linux/bpf.h:3750), which selects a socket out of a REUSEPORT_SOCKARRAY/SOCKMAP the program looks up by key. That’s the model test_select_reuseport_kern.c uses.
Both conventions end where Background 0 said they would: at an index into the flat socks[] array. The flat array is why selection is O(1) regardless of which convention you use.

Per-worker accept queues — concrete benefits
- No accept-queue contention. Each worker drains its own queue.
- Connection affinity. Same client → same worker via the flow hash. Cache locality, session stickiness — within a boot (Background 1).
- Easy worker scaling. Spin up a worker, it joins the group’s array; spin one down, it’s removed.
- CPU pinning. Pin worker N to CPU N; the kernel scales new connections across all workers, and worker N’s CPU handles its share — composing with NIC RSS (Background 2).
Caveats
- All sockets must set
SO_REUSEPORTbefore bind. Mid-stream changes don’t work. - UID match required. All sockets in the group must be owned by the same user. Prevents user A from sniping user B’s port.
- When a worker exits,
num_socksdrops and the surviving sockets shift in the array — so thereciprocal_scale(hash, num_socks)mapping changes. Already-accepted connections are unaffected (they’re no longer in the listener lookup). But in-flight handshakes bound to the dead listener are not silently re-routed by default — see Background 4; this is the single most commonly mis-stated part ofSO_REUSEPORT. SO_REUSEPORT≠SO_REUSEADDR.REUSEADDRlets you re-bind a port that’s in TIME_WAIT;REUSEPORTlets multiple sockets bind simultaneously. The names are similar; the semantics are different.
Background 4: what really happens to in-flight connections when a worker closes
A SYN doesn’t float freely until accept — at SYN time the kernel creates a request sock that is bound to one specific listening socket. The half-open handshake, and later the established-but-not-yet-accepted child, live under that listener. So the natural question is: when that listener closes, what happens to its in-flight request socks and its accept-queue children?
The default is to abort them
By default (net.ipv4.tcp_migrate_req = 0, net/ipv4/sysctl_net_ipv4.c:1048), the kernel aborts them. The documentation is blunt (Documentation/networking/ip-sysctl.rst:990): “When a listener is closed, in-flight request sockets during the handshake and established sockets in the accept queue are aborted.” The client gets a reset. Only brand-new SYNs arriving after the close get hashed to a surviving worker.
This is exactly where the old “they re-route automatically, functionally see no difference” answer was wrong. It’s true for arrivals strictly after the close. It is not true for connections already in flight at close time — by default those are RST, not re-homed.
Migration: tcp_migrate_req = 1 and SELECT_OR_MIGRATE
Setting tcp_migrate_req = 1 enables migrating those orphaned request socks / accept-queue children to another live listener in the same group instead of aborting them. The kernel does this through reuseport_migrate_sock (net/core/sock_reuseport.c:620), which reuses the same hash-and-array machinery as ordinary selection — it picks a survivor from socks[].
For policy control over which survivor, there’s a distinct eBPF expected-attach-type, BPF_SK_REUSEPORT_SELECT_OR_MIGRATE (include/uapi/linux/bpf.h:1138). The same SK_REUSEPORT program runs, but now with reuse->migrating_sk set to the socket that needs a new home (which may be a full established sock or a half-open request sock). The program returns the chosen survivor — or SK_DROP to cancel migration. Without such a program, with tcp_migrate_req = 1, the kernel just randomly picks an alive listener (reuseport_migrate_sock falls into select_by_hash).
So the corrected mental model is:
| Situation | tcp_migrate_req=0 (default) | tcp_migrate_req=1 |
|---|---|---|
| SYN arrives after close | hashed to a survivor (fine) | hashed to a survivor (fine) |
| In-flight reqsk / accept-queue child of the dead listener | RST — client sees reset | migrated to a survivor (random, or per a SELECT_OR_MIGRATE program) |
Connection already accept()ed by the dead worker | RST (worker’s FD closed) | RST (worker’s FD closed) |

Today’s experiment
A trivial reuseport server:
cat << 'EOF' > /tmp/reuseport_srv.py
import socket, os, sys
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
s.bind(("0.0.0.0", 8080))
s.listen(64)
print(f"worker {os.getpid()} listening", flush=True)
while True:
c, addr = s.accept()
c.send(f"hello from {os.getpid()}\n".encode())
c.close()
EOF
# Spawn 3 workers
python3 /tmp/reuseport_srv.py &
python3 /tmp/reuseport_srv.py &
python3 /tmp/reuseport_srv.py &
# Wait until the listener is actually up. Python startup + bind()/listen() is
# racy against the first connect, so without this the first several nc attempts
# hit "Connection refused" before any worker has finished binding.
until nc -z localhost 8080 2>/dev/null; do sleep 0.1; done
# Watch the kernel selector itself fire. arg1 of reuseport_select_sock is the
# per-flow hash from Background 1 — the u32 that reciprocal_scale turns into an
# index: reuseport_select_sock(sk, u32 hash, ...).
sudo bpftrace -e 'kprobe:reuseport_select_sock { printf("reuseport_select_sock hash=%u\n", arg1); }' &
sleep 2
# Hit it 20 times, see different PIDs respond. The </dev/null is required: this
# box ships OpenBSD netcat, whose -q N only quits N seconds after *stdin EOF* —
# in a scripted loop stdin is the terminal and never reaches EOF, so without the
# redirect nc blocks forever even after the worker closes its side.
for i in $(seq 1 20); do echo -n "$i: "; nc -q 1 localhost 8080 </dev/null; done
# Roughly 1/3 of responses from each worker. Each connect uses a fresh ephemeral
# source port, so each is a distinct 4-tuple -> distinct hash -> the picks fan out.
sudo pkill -f bpftrace
# Inspect the bind hash
sudo ss -tlnp | grep :8080
# 3 listeners, all on 0.0.0.0:8080
You should see the three workers spread the load, with one kprobe line per connect:
worker 12345 listening
worker 12346 listening
worker 12347 listening
Attaching 1 probe...
1: hello from 12345
2: hello from 12347
3: hello from 12346
...
reuseport_select_sock hash=2847561234
reuseport_select_sock hash=901233517
reuseport_select_sock hash=3310928844
...
The hash bpftrace prints is inet_ehashfn over (saddr, sport, daddr, dport) (Background 1) — mixed with the per-boot secret. Since each nc here gets a new ephemeral source port, every hash differs and the responding PIDs vary. (The exact hash values and PIDs will differ on your box — and will differ again after a reboot, because the secret is regenerated.)
Verify the kernel-level hash is per-4-tuple — and that an identical tuple is deterministic (the “same client → same worker” affinity, true within this boot):
# Re-attach the selector probe so we can read the hash for each connect:
sudo bpftrace -e 'kprobe:reuseport_select_sock { printf("hash=%u\n", arg1); }' &
sleep 2
# (1) SPREAD: same source IP, different source ports -> different 4-tuple ->
# different hash -> connections fan out across workers.
for p in 50000 50001 50002 50003 50004; do
echo -n "port $p: "
nc -q 1 -p $p localhost 8080 </dev/null || true
done
# Different PIDs respond, and bpftrace prints a different hash for each port.
# (2) AFFINITY: a *fixed* 4-tuple yields a FIXED hash, hence the same worker.
nc -q 1 -p 51000 localhost 8080 </dev/null # note the hash and the responding PID
sleep 61 # let TIME_WAIT on :51000 drain before reuse
nc -q 1 -p 51000 localhost 8080 </dev/null # SAME hash, SAME PID -> affinity confirmed
sudo pkill -f bpftrace
The five fixed source ports produce five distinct hashes (spread); the two
connects from the same -p 51000 produce the same hash and hit the same
PID. That determinism is the connection-affinity property — useful for per-worker
caches. (Don’t try to show this with plain repeated nc without -p: the
ephemeral source port changes each time, so the hash changes too.) And remember
Background 1’s caveat: the determinism is anchored by the per-boot secret, so
this exact hash-to-PID mapping is not stable across a reboot.
Pin workers to CPUs
# Kill the 3 unpinned workers first — otherwise they stay in the reuseport group
# and absorb part of the hash, muddying the cross-core distribution below.
pkill -f reuseport_srv.py
taskset -c 0 python3 /tmp/reuseport_srv.py &
taskset -c 1 python3 /tmp/reuseport_srv.py &
taskset -c 2 python3 /tmp/reuseport_srv.py &
taskset -c 3 python3 /tmp/reuseport_srv.py &
until nc -z localhost 8080 2>/dev/null; do sleep 0.1; done
# Confirm each worker really is pinned to a distinct CPU:
pgrep -f reuseport_srv.py | while read p; do taskset -cp $p; done
# Drive load and watch the per-CPU spread:
( for i in $(seq 1 2000); do nc -q 0 localhost 8080 </dev/null >/dev/null; done ) &
mpstat -P ALL 1 5
Each worker should report a single, distinct CPU in 0-3, and under load the user/softirq time spreads across those four cores:
pid 12345's current affinity list: 0
pid 12346's current affinity list: 1
pid 12347's current affinity list: 2
pid 12348's current affinity list: 3
Linux 7.1.0 (host) 06/12/26 _x86_64_ (4 CPU)
00:24:24 CPU %usr %nice %sys %soft %idle
00:24:25 0 3.00 0.00 9.00 4.00 84.00
00:24:25 1 2.97 0.00 8.91 3.96 84.16
00:24:25 2 3.06 0.00 9.18 4.08 83.67
00:24:25 3 2.94 0.00 8.82 3.92 84.31
(Exact percentages and PIDs vary; the point is all four cores show activity rather than one core carrying everything.)
Now incoming connections are scaled across cores 0-3 by the flow hash. Combined with NIC RSS (Background 2 — the NIC’s own Toeplitz hash RX-distributes flows across queues/cores in hardware) you get end-to-end multi-core scaling without any explicit dispatch logic. Just remember the two hashes are independent (Background 2): for true same-core co-location you’d add SO_INCOMING_CPU or aRFS.
Clean up
The workers loop forever (while True: s.accept()), so they keep holding
:8080 until you stop them — leaving them running blocks any re-run of this lab
or any later TCP lab on port 8080.
sudo pkill -f reuseport_srv.py
rm -f /tmp/reuseport_srv.py
# Confirm the port is free — this should print nothing:
ss -tlnp | grep :8080
There are no Dumb Questions
Q: The prose keeps saying “modulo N,” but the code says
reciprocal_scale. Does it matter which I believe?A: For spread, no —
reciprocal_scale(hash, N) = (u64)hash * N >> 32distributes as evenly ashash % N. For prediction, yes: the actual index a given hash lands on is the top-32-bits-of-a-multiply, not a remainder, so don’t computehash % Nby hand and expect the kernel’s index. Say “scaled into[0, N).”
Q: If affinity is just a 4-tuple hash, why would the same client hit a different worker after I reboot?
A: Because the hash isn’t purely the 4-tuple —
inet_ehashfnmixes ininet_ehash_secret, generated once per boot (andnet_hash_mix(net)per namespace). Same boot, same netns → same mapping. New boot → new secret → reshuffled mapping. Build sticky caches accordingly.
Q: A worker died mid-handshake. The old answer said clients “see no difference” — is that right?
A: Only for SYNs that arrive after the close. By default (
tcp_migrate_req=0) the dead listener’s in-flight request socks and accept-queue children are aborted with RST. Settcp_migrate_req=1(ideally plus aBPF_SK_REUSEPORT_SELECT_OR_MIGRATEprogram) to migrate them to a survivor instead. See Background 4.
Q: I pinned workers to CPUs and enabled RSS, but flows still bounce between cores. Why?
A: RSS’s Toeplitz hash and the kernel’s
inet_ehashfnare different hashes with different seeds (Background 2). RSS picks the RX-queue/CPU; reuseport independently picks the worker. They only co-locate if you align them — viaSO_INCOMING_CPUon the sockets or aRFS on the NIC.
What to read in the kernel
-
net/core/sock_reuseport.c:320—reuseport_add_sock. How a new socket joins an existing reuseport group. ~47 lines. Note the array of socks (the group is a flat array, not a linked list — selection indexes it withreciprocal_scale(hash, num_socks)). -
net/core/sock_reuseport.c:568—reuseport_select_sock. The selector. Theprog->typedispatch fork is here:BPF_PROG_TYPE_SK_REUSEPORT→bpf_run_sk_reuseport(helper model), elserun_bpf_filter(cBPF index model), else fall back toreuseport_select_sock_by_hash. Read it alongsiderun_bpf_filter(:497) andreuseport_select_sock_by_hash(:527). -
net/core/sock_reuseport.c:683—reuseport_attach_prog. How a BPF program gets associated with a reuseport group. The program is held instruct sock_reuseport->progunder RCU. -
net/ipv4/inet_hashtables.c:467—__inet_lookup_listener. The TCP-side listener lookup. WhenSO_REUSEPORTis set it goes throughinet_lookup_reuseport(:392), which computesphashviainet_ehashfn(:40) and callsreuseport_select_sock; otherwise it returns the single listener. -
tools/testing/selftests/bpf/progs/test_select_reuseport_kern.c— example SK_REUSEPORT BPF program. ~183 lines; shows the modern helper model (bpf_sk_select_reuseportinto a sockarray), not the cBPF index return. -
Documentation/networking/scaling.rst— RSS / RPS / RFS, the hardware-vs-software RX-steering trio (Background 2). -
Documentation/networking/ip-sysctl.rst— searchtcp_migrate_reqfor the abort-vs-migrate behavior (Background 4).
Bullet Points
SO_REUSEPORTlets N sockets bind the same(addr, port); they live in onestruct sock_reuseportas a flatsocks[]array (O(1) index, not a list walk).- Each worker has its own accept queue → no contention.
- Default selection:
inet_ehashfnover the 4-tuple →u32hash →reciprocal_scale(hash, N)(a multiply-shift into[0, N), not arithmetic% N). Same client → same worker — but only within one boot/netns, because the hash is seeded by a per-bootinet_ehash_secret. SO_INCOMING_CPUis an alternative steering knob: prefer the worker on the receiving CPU instead of the pure hash.SO_ATTACH_REUSEPORT_EBPFlets a BPF program (BPF_PROG_TYPE_SK_REUSEPORT, contextstruct sk_reuseport_md) override selection — via the helperbpf_sk_select_reuseportreturningSK_PASS/SK_DROP, not by returning an index. (The index-return convention is the older cBPFSO_ATTACH_REUSEPORT_CBPFmodel.)- All sockets must set
REUSEPORTbefore bind, with same UID. - NIC RSS (Background 2) spreads flows across RX queues/CPUs in hardware via a Toeplitz hash; it composes with
SO_REUSEPORTbut uses a different seed, so co-location needsSO_INCOMING_CPU/aRFS. RPS/RFS are the software fallbacks. - Worker death: by default (
tcp_migrate_req=0) the dead listener’s in-flight reqsks and accept-queue children are RST;tcp_migrate_req=1(+ optionalBPF_SK_REUSEPORT_SELECT_OR_MIGRATE) migrates them instead. - Used by nginx, envoy, modern Go/Rust frameworks for multi-process scaling. Combine with CPU pinning + NIC RSS for end-to-end multi-core scaling.
Check question
If a worker process exits while in-flight SYNs were already hashed to it, what happens to those new connections?
Click to reveal answer
Answer: It depends on net.ipv4.tcp_migrate_req, and the common “they re-route, no difference” answer is wrong by default.
- SYNs arriving after the close are hashed to a surviving worker — they genuinely see no difference.
- In-flight request socks and accept-queue children of the dead listener are, by default (
tcp_migrate_req=0), aborted with RST (Documentation/networking/ip-sysctl.rst:990); settcp_migrate_req=1(optionally with aBPF_SK_REUSEPORT_SELECT_OR_MIGRATEprogram) to migrate them to a survivor instead. - Connections already
accept()ed by the dead worker die with its FDs regardless oftcp_migrate_req.
See Background 4 for the full mechanism.
Tomorrow
Day 25: kTLS — encrypting TCP transport in the kernel.
Day 25 — kTLS: in-kernel transport encryption
Today’s mission: see how Linux puts AES-GCM into the kernel data path, why this matters for
sendfile()over HTTPS, and how hardware-offloaded TLS extends the same model — and learn the four pieces of machinery it all rests on (the TCP ULP framework, AEAD/AES-GCM and the TLS record format,sendfile/splicezero-copy, and RX record re-framing) so nothing in the setup dance is a black box. Total time: ~110 minutes.
What kTLS does
Kernel TLS (kTLS) lets the kernel handle TLS record encryption and decryption — but not the handshake. The pattern:
- Userspace performs the TLS handshake — using OpenSSL, BoringSSL, GnuTLS, or any handshake library. The handshake produces a session key (and IV, salt, sequence number).
- Userspace hands the key to the kernel via
setsockopt(SOL_TLS, TLS_TX, ...)andTLS_RX. - Subsequent socket I/O is automatically encrypted/decrypted by the kernel.
write()plaintext → kernel encrypts → ciphertext on wire. Wire ciphertext → kernel decrypts →read()plaintext.

The split is deliberate. The handshake involves certificate validation, X.509 parsing, complex protocol state machines, and a constantly-evolving TLS protocol. None of that belongs in the kernel — it would balloon the attack surface, demand kernel updates with every TLS protocol change, and fight kernel-OpenSSL feature parity. The kernel only does the bulk symmetric encryption — the steady state, where every record is AES-GCM(plaintext + AAD) and nothing else.
That sentence already leans on three things we haven’t taught yet: how a connected TCP socket suddenly starts encrypting your write()s without changing a line of application code, what AES-GCM(plaintext + AAD) actually computes (and what AAD even is), and why the whole exercise is worth it (the sendfile() zero-copy win). And on the receive side there’s a fourth: TLS records don’t line up with TCP segments, so the kernel has to re-frame the byte stream before it can decrypt. We’ll teach each one as a proper background section — intuition first, then the concrete v7.1 struct/function — and only then walk the setup dance, with every step resting on something you already understand.
Background 1: the TCP ULP framework — hot-swapping a socket’s protocol vtable
Step 2 of the setup dance is setsockopt(fd, SOL_TCP, TCP_ULP, "tls", 4), and the chapter used to wave at it with “TCP_ULP = Upper Layer Protocol.” But that single call is the load-bearing trick behind the entire “transparent encryption” claim — so let’s make it real.
The intuition: a pluggable shim on top of TCP
A ULP (Upper Layer Protocol) is a shim that sits on top of an established TCP socket and intercepts its send/receive path. Think of it as a hook the kernel offers: “if you name a registered ULP, I’ll splice its code between your syscalls and TCP.” kTLS is one such ULP; MPTCP (.name = "mptcp") and IPsec-in-TCP (espintcp) use the same mechanism. (Don’t confuse this with SMC — that’s a separate AF_SMC protocol family, not a TCP ULP; it explicitly refuses TCP_ULP with -EOPNOTSUPP.)
The kernel keeps a small registry of ULPs. Each one is a struct tcp_ulp_ops carrying a name string and a handful of callbacks (include/net/tcp.h:2819):
struct tcp_ulp_ops {
struct list_head list;
int (*init)(struct sock *sk); /* called when the ULP is attached */
void (*update)(struct sock *sk, struct proto *p, ...);
void (*release)(struct sock *sk);
int (*get_info)(struct sock *sk, struct sk_buff *skb, bool net_admin);
size_t (*get_info_size)(const struct sock *sk, bool net_admin);
void (*clone)(const struct request_sock *req, struct sock *newsk, ...);
char name[TCP_ULP_NAME_MAX]; /* the string userspace passes */
struct module *owner;
};
A module calls tcp_register_ulp() to add itself to the registry. kTLS registers tcp_tls_ulp_ops (net/tls/tls_main.c:1230) with .name = "tls" (:1231) and .init = tls_init (:1233):
static struct tcp_ulp_ops tcp_tls_ulp_ops __read_mostly = {
.name = "tls",
.init = tls_init,
/* ... */
};
What setsockopt(TCP_ULP, "tls") actually does
When userspace makes that call, the kernel runs tcp_set_ulp (net/ipv4/tcp_ulp.c:157):
__tcp_ulp_find_autoload("tls")(:163) searches the registry by name and, if thetlsmodule isn’t loaded yet, auto-loads it (that’s why you don’t have tomodprobe tlsby hand).__tcp_set_ulp(:130) does the attach. It refuses if a ULP is already set (-EEXIST), and crucially it requires the socket to already be connected: aTCP_LISTENsocket gets-ENOTCONNunless the ULP supplies a.clonecallback (:142). That’s why the handshake must complete first — you attach kTLS to an established connection.- If the checks pass, it calls
ulp_ops->init(sk)(:146) — for kTLS,tls_init.
tls_init (net/tls/tls_main.c:1047) double-checks the state (sk->sk_state != TCP_ESTABLISHED → -ENOTCONN), allocates a tls_context via tls_ctx_create (:1070), sets both directions to the base state (ctx->tx_conf = TLS_BASE; ctx->rx_conf = TLS_BASE), and calls update_sk_prot (:1079).
The transparency trick: replacing the socket’s vtables
Here’s the actual magic. Recall the two dispatch tables from Day 13 (Background 3): every socket has sk->sk_socket->ops (a proto_ops, the BSD-API vtable facing userspace) and sk->sk_prot (a proto, the protocol vtable facing the wire). A write() descends socket->ops->sendmsg → sk->sk_prot->sendmsg, which for plain TCP is tcp_sendmsg.
kTLS simply points those two tables at TLS-aware versions. update_sk_prot (net/tls/tls_main.c:131) does exactly that:
void update_sk_prot(struct sock *sk, struct tls_context *ctx)
{
int ip_ver = sk->sk_family == AF_INET6 ? TLSV6 : TLSV4;
WRITE_ONCE(sk->sk_prot,
&tls_prots[ip_ver][ctx->tx_conf][ctx->rx_conf]);
WRITE_ONCE(sk->sk_socket->ops,
&tls_proto_ops[ip_ver][ctx->tx_conf][ctx->rx_conf]);
}
After this, the same write()/sendmsg() syscall lands in a TLS sendmsg (which encrypts, then calls down to tcp_sendmsg for the wire) instead of going straight to tcp_sendmsg. The application never changed; the vtable underneath it did. That is the whole meaning of “transparent.”

The [tx_conf][rx_conf] state matrix
Notice the index: tls_prots[ip_ver][ctx->tx_conf][ctx->rx_conf]. The installed vtable depends on a 2-D state, one axis per direction. Each axis is one of TLS_BASE, TLS_SW, or TLS_HW (include/net/tls.h:85):
TLS_BASE— kTLS attached but this direction does nothing to traffic yet.TLS_SW— software encryption/decryption (the kernel’scrypto/framework).TLS_HW— hardware offload (the NIC does AES-GCM).
So TX and RX are routed independently: you can run hardware-offloaded TX while RX stays in software, each direction selecting its own row/column of the table. This is why pushing the TX key and the RX key are two separate setsockopts (steps 3 and 4 below): each one upgrades one axis (e.g. TLS_BASE → TLS_SW) and re-runs update_sk_prot to re-point the vtables.
And one subtle but important consequence: setsockopt(TCP_ULP, "tls") alone does nothing to your traffic yet. It installs tls_prots[*][TLS_BASE][TLS_BASE], whose only real job is a setsockopt handler that now accepts SOL_TLS (build_protos sets prot[TLS_BASE][TLS_BASE].setsockopt = tls_setsockopt, net/tls/tls_main.c:1006). Encryption begins only once you push a key (TLS_TX/TLS_RX), which flips that axis to TLS_SW (or TLS_HW).
There are no Dumb Questions
Q: If
TCP_ULP "tls"encrypts nothing until a key is pushed, why is it a separate step at all? Why not push the key in one call?A: Because the two steps do genuinely different things, and separating them keeps each one simple.
TCP_ULP "tls"is the structural change — it attaches the ULP and swaps the socket’s vtables to the[TLS_BASE][TLS_BASE]versions, whose only new ability is to acceptSOL_TLSoptions. Until that swap happens, the socket’ssetsockoptdoesn’t even understandTLS_TX/TLS_RX. So the ULP attach is what unlocks the key-push call. Splitting them also mirrors the protocol reality: you attach to an already-connected socket (the ULP refuses a non-established socket), then feed in keys that the handshake produced moments earlier — and you can upgrade TX and RX independently afterward.
Background 2: AEAD, AES-GCM, and the TLS record on the wire
Step 3 memcpy’s four separate fields — key, iv, salt, rec_seq — into a crypto struct, and the chapter’s “every record is AES-GCM(plaintext + AAD)” rests on you knowing what each of those is. No earlier day taught any cryptography, so here’s the minimum to read the setup honestly.
AEAD: one primitive, two jobs
AEAD = Authenticated Encryption with Associated Data. A single primitive does two things at once:
- Encrypts the plaintext into ciphertext (confidentiality).
- Computes an authentication tag — a short fingerprint that proves the ciphertext (and some header data) wasn’t tampered with (integrity).
AES-GCM is the dominant AEAD: AES in counter mode provides the encryption, and GHASH computes the tag. The killer property: on decrypt, the receiver recomputes the tag and rejects the whole record if it doesn’t match. There is no separate “decrypt, then verify” step you might forget — authentication is welded into the same operation.
AAD: authenticated but not encrypted
AAD (Additional Authenticated Data) is data that is authenticated but not encrypted — it travels in the clear, yet it’s folded into the tag computation so an attacker can’t alter it without breaking the tag. For TLS, the AAD is the record’s framing: the content type, version, and length, plus the record sequence number. That’s the + AAD in “AES-GCM(plaintext + AAD)”: the 5-byte TLS record header (TLS_HEADER_SIZE, include/net/tls.h:59) is authenticated framing and visible on the wire, while only the payload is encrypted. One caveat for the TLS 1.2 struct we’re explaining here: tls_make_aad (net/tls/tls.h:366) builds a 13-byte AAD — an 8-byte record sequence number prepended to the 5-byte type/version/length header. That sequence number is authenticated but is not transmitted (it’s implicit, reconstructed by both ends). So in TLS 1.2 the on-wire 5-byte header is a subset of the AAD, not the whole AAD. (In TLS 1.3 the AAD is exactly the 5-byte record header.)
What a TLS record looks like on the wire
Putting it together, one TLS record is:
[ 5-byte header: content_type(1) | version(2) | length(2) ] ← clear, authenticated framing*
[ ciphertext = AES-GCM-encrypt(plaintext) ] ← encrypted
[ 16-byte authentication tag ] ← integrity check
* In TLS 1.2 the full AEAD AAD is larger than this on-wire header: it is the 8-byte implicit record sequence number followed by these 5 bytes (13 bytes total). Only the 5-byte header is actually transmitted.
So kTLS turns one plaintext write() into a record with fixed framing overhead: a header in front and a tag behind the payload. The kernel reserves exactly that space around each record’s payload — prepend_size for the header and tag_size for the tag (struct tls_prot_info, include/net/tls.h:213-214); for AES-GCM the tag is 16 bytes.
The four fields, and why you need all four
Now the memcpys in step 3 read like a sentence. The struct is tls12_crypto_info_aes_gcm_128 (include/uapi/linux/tls.h:126):
struct tls12_crypto_info_aes_gcm_128 {
struct tls_crypto_info info;
unsigned char iv[8]; /* TLS_CIPHER_AES_GCM_128_IV_SIZE */
unsigned char key[16]; /* TLS_CIPHER_AES_GCM_128_KEY_SIZE */
unsigned char salt[4]; /* TLS_CIPHER_AES_GCM_128_SALT_SIZE */
unsigned char rec_seq[8]; /* TLS_CIPHER_AES_GCM_128_REC_SEQ_SIZE */
};
key(16 bytes) — the AES-128 session key produced by the handshake. This is what actually encrypts.- The GCM nonce (12 bytes) is not a single field — for this TLS 1.2 AES-GCM-128 case the kernel builds it as
salt(4 bytes)||iv(8 bytes):memcpy(cctx->iv, salt, 4); memcpy(cctx->iv + 4, iv, 8)(net/tls/tls_sw.c:2911). Thesaltis a fixed implicit prefix for the whole session; theivis the 8-byte explicit nonce — the per-record changing part. It is sent in the clear at the front of each record (tls_fill_prependcopiesctx->tx.iv + salt_size,net/tls/tls.h:347) and incremented every record (tls_advance_record_snbumpsctx->iv + salt_sizefor the non-1.3/non-ChaCha case,net/tls/tls.h:317). GCM’s security depends on the nonce never repeating under the same key — nonce reuse breaks AES-GCM catastrophically. rec_seq(8 bytes) is a separate counter: the record sequence number. In TLS 1.2 it feeds the AAD, not the nonce —tls_make_aadcopiesrecord_sequenceinto the AAD’s first 8 bytes (net/tls/tls.h:368), where it is authenticated but never transmitted. That is the real reason the struct carries four distinct fields:key, the fixedsalt, the per-record explicitiv(nonce), and the authenticatedrec_seq.
(In TLS 1.3 / ChaCha20-Poly1305 the construction differs: the nonce’s low 8 bytes are iv XOR rec_seq (tls_xor_iv_with_seq, net/tls/tls.h:328) and no explicit iv is sent on the wire.)
The handshake library computed all four during the TLS key exchange; setsockopt just transfers that state so the kernel can continue the record sequence from precisely where userspace left off. kTLS doesn’t hand-roll AES — it calls the kernel’s crypto/ AEAD framework, the same code IPsec uses. Before accepting the key, the kernel validates the struct: get_cipher_desc(crypto_info->cipher_type) then if (optlen != cipher_desc->crypto_info) return -EINVAL (net/tls/tls_main.c:692-702), so a wrong-sized struct for the named cipher is rejected.

There are no Dumb Questions
Q: Why is the nonce split into
salt+ivinstead of just one 12-byte field?A: Because the two halves have different lifetimes and different jobs. The
salt(4 bytes) is fixed for the whole session — it never changes, so there’s no reason to re-send or re-store it per record. Theiv(8 bytes) is the part that must change every record, since GCM’s security collapses if a nonce ever repeats under the same key. Keeping them separate lets the kernel store the constant once and only increment the 8-byte counter — and it matches the wire format, where just the 8-byte explicitivrides in front of each record while the salt stays implicit. (Don’t confuseivwithrec_seq: in TLS 1.2 theivis the nonce’s changing part, while the separaterec_seqis the sequence number folded into the authenticated AAD.)
Background 3: sendfile(), splice, and the page cache — what the “one copy” win is
The whole “Why it matters” section below rests on three ideas no earlier day taught as such: the page cache, sendfile/splice, and why an skb can reference file pages without copying them. (Day 1 taught skb page fragments; here we connect them to the file→socket path.)
The page cache
The page cache is the kernel’s in-RAM cache of file contents. When a file is read, its bytes live in struct pages indexed by that file. A static-file server’s hot files are already resident there, so “reading” them is just locating those pages — no disk I/O, no copy.
sendfile() / splice: skip the userspace round-trip
sendfile(out_socket, in_file, ...) (and the more general splice) tells the kernel to move bytes from a file straight to a socket without routing them through a userspace buffer. Contrast the classic HTTPS send path:
read(file)copies page-cache → user buffer.SSL_write(buffer)encrypts in the user buffer.send(socket)copies user buffer → skb.
That’s three copies plus a userspace crypto pass, with context switches between read and send.
Why no copy is needed: skb frags point at file pages
The zero-copy mechanism reuses the skb page-fragment design from Day 1. Recall that an skb’s payload can live in skb_shinfo(skb)->frags[] — (page, offset, len) references to arbitrary pages, not necessarily contiguous. Instead of memcpy-ing file bytes into the skb’s linear area, splice points the skb’s frag array directly at the page-cache pages (bumping their refcount, sharing them). The bytes never move.
With kTLS, the only remaining copy is the encryption itself: the kernel reads the plaintext file pages and writes ciphertext into the record skb. Crucially the plaintext never crosses the user/kernel boundary, and there’s no context switch between “read” and “send” — one syscall runs the whole file → encrypt → wire pipeline. With NIC hardware offload (below), even that encryption copy moves to the NIC, so after the initial page-cache fill the CPU touches plaintext zero times.
This is why kTLS matters most for static-file / CDN / video workloads: they sendfile() large, already-cached files over TLS. Dynamic content that must be generated in userspace can’t use the zero-copy path and benefits far less. (The kernel-side knob for this TX zero-copy path is TLS_TX_ZEROCOPY_RO, include/uapi/linux/tls.h:42 — “TX zerocopy (only sendfile now)” — handled by do_tls_setsockopt_tx_zc, net/tls/tls_main.c:785.)

Why it matters
sendfile() over HTTPS
Recall the 3-copy classic path vs the 1-copy kTLS sendfile path from Background 3. The payoff is the same comparison applied at scale: for static-file servers (CDN, image servers, video streaming) that sendfile() large, already-cached files over TLS, dropping from three copies plus a userspace crypto pass to a single encryption copy with zero read/send context switches is a massive throughput win. Netflix’s BSD-based servers (and various Linux derivatives) ship hundreds of Gbps from a single box using exactly this pattern.
Hardware offload
NICs that support TLS offload (Mellanox ConnectX-6+, Chelsio T6, recent Intel) can do AES-GCM at the NIC. With kTLS:
- Application writes plaintext via
sendfile(). - Kernel hands plaintext skb to the driver with a per-flow key.
- NIC encrypts inline at the DMA boundary; ciphertext goes out the wire.
- The CPU never touches plaintext for encryption — with
sendfilefrom the page cache it touches plaintext zero times (matching the zero-copy claim in Background 3).
Detected via NIC features NETIF_F_HW_TLS_TX / NETIF_F_HW_TLS_RX (include/linux/netdev_features.h:158-159). Configured via the same kTLS sockopt path; the kernel decides software vs hardware by flipping that direction’s axis to TLS_HW (Background 1) based on the NIC’s capability.
Background 4: why RX must re-frame the byte stream
On the receive side there’s one more prerequisite — and it’s why step 4 of the setup dance (push RX key → read() decrypts) needs more machinery than the TX side. The “What to read” list points at net/tls/tls_strp.c — “the stream parser that re-frames TCP byte stream into TLS records … because TLS records aren’t aligned to TCP segment boundaries.” That’s a fact about TCP you met on Day 17.
One-line refresher (Day 17 Background 1): TCP is a pure byte stream — it numbers every byte and guarantees order, but imposes no message boundaries; an ACK just says “next byte expected.” (See day17 for sequence numbers, snd_una/snd_nxt.) A single read() may hand you half a TLS record or three records glued together.
Because of that, the RX side cannot assume one recv = one record. tls_strp (the kernel’s strparser) accumulates incoming bytes, reads each record’s 5-byte header (TLS_HEADER_SIZE) to learn its declared length, waits until a full record has arrived — possibly spanning several TCP segments — and only then hands exactly one complete record to the AES-GCM decrypt step. This is the mirror image of TX framing from Background 2.
It’s purely an RX concern. On TX the kernel chooses record sizes, so it controls framing. On RX the peer chose them and TCP smeared them across segment boundaries, so reassembly is mandatory before the tag can be verified — you can’t authenticate a partial record.

The setup dance
/* 1. Application opens TCP, performs TLS handshake (OpenSSL or similar).
* After handshake, has access to: session key, IV, salt, current rec_seq. */
/* 2. Activate kTLS on the socket */
setsockopt(fd, SOL_TCP, TCP_ULP, "tls", 4);
/* TCP_ULP = "Upper Layer Protocol"; kTLS is registered as a ULP (Background 1).
* This only attaches the ULP and installs tls_prot[TLS_BASE][TLS_BASE] —
* no traffic is encrypted yet. */
/* 3. Push TX key */
struct tls12_crypto_info_aes_gcm_128 tx = {
.info.version = TLS_1_2_VERSION,
.info.cipher_type = TLS_CIPHER_AES_GCM_128,
};
memcpy(tx.key, session_tx_key, 16); /* AES-128 session key (Background 2) */
memcpy(tx.iv, session_tx_iv, 8); /* explicit nonce, sent on wire, ++/record */
memcpy(tx.salt, session_tx_salt, 4); /* fixed implicit nonce prefix */
memcpy(tx.rec_seq, session_tx_rec_seq, 8); /* seq number → AAD (TLS 1.2) */
setsockopt(fd, SOL_TLS, TLS_TX, &tx, sizeof(tx));
/* This flips tx_conf BASE→SW and re-runs update_sk_prot: write() now encrypts. */
/* 4. Push RX key (mirror struct, RX side) */
setsockopt(fd, SOL_TLS, TLS_RX, &rx, sizeof(rx));
/* Flips rx_conf BASE→SW independently: read() now decrypts. */
/* 5. From now on, write/read produce/consume plaintext.
* The kernel encrypts/decrypts records transparently. */
do_tls_setsockopt in net/tls/tls_main.c:863 is the dispatcher. The TX/RX key pushes go through do_tls_setsockopt_conf (line 635) which calls tls_set_sw_offload (software path) or tls_set_device_offload (hardware path, line 1062 in tls_device.c). Each successful key push ends in update_sk_prot (Background 1), re-pointing the vtables to the new [tx_conf][rx_conf] state.
Cipher support
TLS_CIPHER_AES_GCM_128 (TLSv1.2 + TLSv1.3)
TLS_CIPHER_AES_GCM_256
TLS_CIPHER_AES_CCM_128
TLS_CIPHER_CHACHA20_POLY1305 (TLSv1.2 + TLSv1.3)
TLS_CIPHER_SM4_GCM (chinese national algorithm)
TLS_CIPHER_ARIA_GCM (some kernels)
The cipher type is in crypto_info.cipher_type. Each cipher has its own struct (different IV/salt/key sizes) — which is why the kernel validates optlen against the named cipher’s expected size before accepting the key (Background 2). The kernel uses its crypto/ framework to do the actual AES — same underlying routines as IPsec.
What’s NOT in kTLS
- Handshake (still userspace).
- Renegotiation / rekey (kernel doesn’t track session state beyond the current key; key rotation requires application coordination — though TLS 1.3 key updates are accepted, see the rekey check in
do_tls_setsockopt_conf). - TLS extensions (the kernel handles records, not the higher-level extensions like ALPN).
- TLS 1.0/1.1 (only 1.2 and 1.3 supported).
Today’s experiment
Most modern Linux distros have kTLS support compiled in. Verify:
ls /sys/module/tls
modinfo tls
To use it, an application must explicitly call the setsockopt sequence. Modern OpenSSL (1.1.1+) supports it via SSL_OP_ENABLE_KTLS — pass it to SSL_CTX_set_options and OpenSSL handshakes normally then offloads encryption.
The key push fires the instant the handshake completes, so the tracer must already be running before the client connects. First generate a cert and start the server (pass -ktls so the server offloads too):
# Test with openssl s_server (since OpenSSL 3.0)
openssl genrsa -out /tmp/k.pem 2048
openssl req -new -x509 -key /tmp/k.pem -out /tmp/c.pem -days 1 -subj /CN=test
sudo openssl s_server -accept 4443 -cert /tmp/c.pem -key /tmp/k.pem -ktls &
Now start the bpftrace one-liner and leave it running in this terminal. It prints the key push symbolically — optname 1 and 2 are TLS_TX and TLS_RX from include/uapi/linux/tls.h:
sudo bpftrace -e '
fentry:tls_setsockopt {
printf("%s push on sk=%p\n", args->optname==1?"TLS_TX":"TLS_RX", args->sk);
}'
Then, in another terminal, connect the client. s_client is interactive and blocks on stdin — type Q then Enter (or Ctrl-D) to close it, or drive it non-interactively with echo Q:
echo Q | openssl s_client -connect 127.0.0.1:4443 -ktls
When the offload engages you’ll see the SOL_TLS key pushes, one per direction (the sk value is an opaque kernel pointer that differs every run):
TLS_TX push on sk=0xffff...
TLS_RX push on sk=0xffff...
Two caveats worth knowing, because kTLS offload is genuinely finicky:
- The push only happens if kTLS actually activates, which needs the handshake to negotiate a cipher the kernel can offload (an AES-GCM suite). Default TLS 1.3 on some builds negotiates a suite the kernel declines, so no key is pushed and the tracer stays silent. Pin a known-good suite to force it: add
-tls1_2 -cipher ECDHE-RSA-AES128-GCM-SHA256tos_serverand-tls1_2tos_client. - The probe target varies by kernel. The inner
do_tls_setsockoptisstatic, so on many builds it’s inlined and has nofentrytarget at all (hence the probe above hooks the exportedtls_setsockoptwrapper instead); on others the wrapper attaches but the key push doesn’t traverse it. The reliable, probe-independent confirmation that kTLS engaged is the SNMP counters —TlsTxSw/TlsRxSw(software offload) increment per offloaded session:
grep -E 'TlsTxSw|TlsRxSw' /proc/net/tls_stat # before and after; the count rises when kTLS engages
When done, stop the server and clean up: sudo pkill -f 's_server -accept 4443'; rm -f /tmp/k.pem /tmp/c.pem.
Watch hardware-offload status
ethtool -k eth0 | grep -i tls
# tls-hw-tx-offload: on/off
# tls-hw-rx-offload: on/off
# tls-hw-record: on/off
If on, your NIC has TLS offload available; kTLS will use it transparently when the cipher is supported. These three map to the verified NETIF_F_HW_TLS_TX / NETIF_F_HW_TLS_RX / NETIF_F_HW_TLS_RECORD feature flags (include/linux/netdev_features.h:156-159).
What to read in the kernel
-
include/net/tcp.h:2819—struct tcp_ulp_ops. The ULP registration record (name +init/update/release/clonecallbacks). The whole “TCP_ULP” mechanism (Background 1) lives around here. -
net/ipv4/tcp_ulp.c:157—tcp_set_ulp→__tcp_ulp_find_autoload(line 163, auto-loads the module) →__tcp_set_ulp(line 130). Note the-EEXIST/-ENOTCONNchecks and theulp_ops->init(sk)call at line 146 — this is the pathsetsockopt(TCP_ULP,"tls")takes. -
net/tls/tls_main.c:1230—tcp_tls_ulp_ops(.name = "tls",.init = tls_init). Followtls_init(line 1047) →tls_ctx_create(line 1070) →update_sk_prot(line 131), the vtable-swap that makes encryption transparent. -
net/tls/tls_main.c:863—do_tls_setsockopt. The dispatcher (~32 lines, ending at line 894) for TLS_TX, TLS_RX, TLS_TX_ZEROCOPY_RO, etc. To follow the full setsockopt flow including the per-option handlers, read fromdo_tls_setsockopt_conf(line 635) through line 906 — ~272 lines. -
net/tls/tls_main.c:635—do_tls_setsockopt_conf. The TX/RX key configuration. This is where the kernel validates the crypto_info struct (optlenvs the cipher’s expected size, ~line 692) and decides software (tls_set_sw_offload) vs hardware (tls_set_device_offload). -
net/tls/tls_sw.c— software encryption path. Readtls_sw_sendmsg(line 1288) andtls_sw_recvmsg(line 2060). The flow: receive plaintext from user viatcp_sendmsginterception, accumulate into a record, AES-GCM encrypt with the kernel’s crypto API, send the ciphertext viatcp_sendmsg. -
net/tls/tls_device.c:1062—tls_set_device_offload. The hardware path. Tells the NIC about the per-flow key. The driver implementstls_dev_add(a member ofstruct tlsdev_ops) to register the flow with the hardware. Read this if you want to understand how kernel ↔ NIC TLS coordination works. -
net/tls/tls_device_fallback.c— what happens when hardware offload is partially failing (e.g., a packet has to be retransmitted but the NIC’s key state has advanced). The kernel handles those records in software. -
net/tls/tls_strp.c— the stream parser that re-frames TCP byte stream into TLS records on the receive side (Background 4). Necessary because TLS records aren’t aligned to TCP segment boundaries. -
include/uapi/linux/tls.h— the userspace ABI:TLS_TX/TLS_RX/TLS_TX_ZEROCOPY_RO(lines 40–42),struct tls12_crypto_info_aes_gcm_128(line 126) and the per-cipher size macros. -
Documentation/networking/tls.rst— official doc. Practical examples of setup.
Bullet Points
- kTLS = TLS records produced/consumed by the kernel after handshake done in userspace.
- The ULP framework is the enabling trick:
setsockopt(TCP_ULP,"tls")attaches kTLS to a connected socket andupdate_sk_protswaps the socket’s two vtables (sk->sk_prot+socket->ops, recall Day 13) for TLS-aware ones — so the samewrite()/read()now encrypt/decrypt. - The installed vtable is
tls_prots[ip_ver][tx_conf][rx_conf]; each direction isTLS_BASE/TLS_SW/TLS_HWindependently, which is why TX and RX keys are separate setsockopts.TCP_ULPalone (BASE/BASE) encrypts nothing until a key is pushed. - AEAD / AES-GCM does encryption and an authentication tag in one step. AAD = the record framing, authenticated but sent in clear: the on-wire 5-byte record header, plus (in TLS 1.2) an 8-byte implicit sequence number that is authenticated but not transmitted. A record on the wire =
[5B header][ciphertext][16B tag]. - The crypto struct carries four fields: key (AES-128), the fixed salt (implicit nonce prefix), the iv (8-byte explicit nonce — the per-record changing part, sent on the wire and incremented each record; nonce reuse breaks GCM), and rec_seq (the record sequence number, folded into the authenticated AAD in TLS 1.2). setsockopt transfers the handshake’s crypto state into the kernel.
- Activated via
TCP_ULP=tls+TLS_TX+TLS_RXsockopts; kTLS reuses the kernel’scrypto/framework (shared with IPsec). - Enables zero-copy
sendfile()for HTTPS: page-cache pages become skb frags (recall Day 1) — no userspace copy, noread/sendcontext switch; with HW offload the CPU touches plaintext 0 times. Huge win for static-file CDNs. - RX re-framing: TCP is a byte stream with no message boundaries (Day 17), so
tls_strpreassembles full records across TCP segments before decrypt — you can’t authenticate a partial record. - Hardware offload via
NETIF_F_HW_TLS_TX/RX— NIC encrypts inline. - Supported ciphers: AES-GCM-128/256, AES-CCM-128, ChaCha20-Poly1305, SM4-GCM.
- Only TLSv1.2 and TLSv1.3 (not 1.0/1.1).
- Used by nginx, Netflix’s stream servers, HAProxy in some modes.
- Inspect:
modinfo tls,ethtool -k <dev> | grep tls,/proc/net/tls_stat.
Check question
Why doesn’t Linux just do the TLS handshake in the kernel too? Wouldn’t that simplify the API?
Click to reveal answer
Answer: Three reasons.
(1) Attack surface. TLS handshake involves certificate validation, X.509 parsing (a notoriously bug-prone format), complex state machines, and the negotiation logic for ciphers, extensions, and protocol versions. Every CVE in OpenSSL is one your attackers would now see in the kernel. The kernel community is unwilling to take that maintenance burden.
(2) Velocity. TLS evolves: new ciphers (every few years), protocol revisions (TLS 1.3 changed the handshake fundamentally), extensions (PSK, 0-RTT, ECH, DC). Userspace libraries iterate on these; kernel kernel-versions iterate every 2–3 months but you can’t roll out kernel updates as quickly as you can update OpenSSL.
(3) The win is in the data path, not the handshake. A handshake is ~milliseconds, once per connection. Bulk encryption is microseconds, millions of times per connection. kTLS targets the bulk: AES-GCM streaming. The handshake stays in userspace where it’s well-tested and easy to update.
The split is the right engineering tradeoff: userspace handles the rare-but-complex; kernel handles the common-and-simple.
Tomorrow
Day 26: MPTCP — multipath TCP, the spec for “one connection over many paths.”
Day 26 — MPTCP: multipath TCP
Today’s mission: understand how a single TCP connection can use multiple paths simultaneously, and how Linux implements MPTCP via the “msk” socket holding multiple subflows. Along the way we’ll teach the four mechanisms MPTCP leans on but that no earlier chapter covered — how TCP options carry the protocol in a scarce 40-byte budget, how a token plus an HMAC binds a new subflow to the right connection, the two-layer sequence-number scheme that lets the receiver re-splice the stream, and the msk-level reassembly queue where head-of-line blocking lives — so nothing in the experiment is a black box. Total time: ~120 minutes. End of Phase 4.
What MPTCP is
A TCP connection is normally one socket pair: one client IP, one server IP, one TCP stream. MPTCP (RFC 8684) lets one logical connection carry data over multiple TCP “subflows” simultaneously.
The motivation: mobile devices have both WiFi and cellular. Bulk-transfer hosts have multiple NICs. Today, switching networks requires a new TCP connection — losing in-flight data and sometimes upper-layer state. MPTCP lets the connection survive a network change and use multiple paths concurrently for higher throughput.

The protocol model
An MPTCP connection has:
- One “msk” (master socket) — what the application sees. From the application’s perspective, it’s a regular
SOCK_STREAMsocket. - One or more subflows — each is an actual TCP connection over one path. The first subflow is the “primary”; additional subflows (
MP_JOIN) are added later.
Each subflow is bidirectional. The MPTCP scheduler decides which subflow gets each segment (or, for redundancy, sends to both). Sequence numbers come in two layers: per-subflow (regular TCP), and per-msk (MPTCP-level), so the receiver can reconstruct the original byte stream regardless of which subflow each chunk came from.
Three of those one-line claims — “MPTCP rides in TCP options,” “the join names a connection by token,” and “sequence numbers come in two layers” — are doing a lot of silent work, and the next four Backgrounds make each concrete before we touch the kernel.
Background 1: TCP options are a scarce TLV budget — and MPTCP lives inside it
Every MP_CAPABLE, MP_JOIN, and DSS you will see today is a TCP option. So before “On-wire signaling” can mean anything, you need to know exactly what a TCP option is and why there is so little room for one.
Refresher (Day 15), not re-taught: a TCP segment is a fixed 20-byte header followed by optional options and then payload. A 4-bit field called doff (data offset) in the header counts the header length in 32-bit words, so it tells the receiver where the options end and the payload begins (day15.md:52, day15.md:23). That is the entire base we build on — we will not re-derive the header.
New — every option is a TLV. The options area is not free-form. It is a sequence of TLV records: a 1-byte Kind, a 1-byte Length (covering Kind + Length + Value), then the Value bytes. The receiver walks them by reading Kind, then Length, then skipping Length bytes to the next option. (A couple of one-byte options — End-of-options and No-op padding — are the exception, but every option that carries data is a full Kind/Length/Value triple.)
New — MPTCP is a single Kind, with a subtype nibble inside. MPTCP does not get one option Kind per message type. It gets exactly one:
/* include/net/tcp.h:216 */
#define TCPOPT_MPTCP 30 /* Multipath TCP (RFC6824) */
Which kind of MPTCP message it is — MP_CAPABLE, MP_JOIN, DSS, ADD_ADDR — is encoded as a 4-bit subtype nibble in the first byte of the option’s Value:
/* net/mptcp/protocol.h:42-45 — the subtype nibbles */
#define MPTCPOPT_MP_CAPABLE 0
#define MPTCPOPT_MP_JOIN 1
#define MPTCPOPT_DSS 2
#define MPTCPOPT_ADD_ADDR 3
This is exactly why tcpdump prints lines like mptcp 26 dss: the 30 Kind is decoded to the word mptcp, the 26 is the option Length in bytes, and dss is the decoded subtype nibble. When you read mptcp 8 dss ack, you are reading “an 8-byte option of Kind 30, subtype DSS, carrying only a Data-ACK.” mptcp_write_options() (net/mptcp/options.c:1403) is the function that emits these subtypes into the option area on the send side.
New — the 40-byte ceiling, and why it makes DSS so terse. Because doff is only 4 bits, it can count at most 15 words = a 60-byte header. Subtract the fixed 20 bytes and the entire options region can be at most 40 bytes:
/* include/net/tcp.h:71 */
#define MAX_TCP_OPTION_SPACE 40
That 40 bytes is a hard, shared budget. MPTCP options must fit alongside everything else TCP already wants to put there — the timestamp option (10 bytes), SACK blocks (up to ~34 bytes), window scale, MSS on the SYN. There is no room for a verbose encoding. This scarcity is the entire reason DSS is packed into a tight {data_seq, subflow_seq, data_len} triple (Background 3) and the reason the “Performance characteristics” section warns about MPTCP’s per-segment overhead: every DSS byte spent is a byte unavailable to timestamps or SACK.

New — why a middlebox can silently strip it. A TCP option is optional, and some middleboxes strip ones they do not recognize: a spec-compliant router forwards Kind 30 unchanged, but a non-compliant NAT that does not understand it may drop it while forwarding the rest of the segment. The peer’s TCP still sees a valid stream — just without the MP_CAPABLE handshake. This is the mechanism behind “graceful fallback”: because the MPTCP signaling lives entirely in a strippable, optional header field, a path that mangles it causes the connection to degrade to ordinary single-path TCP rather than break. You cannot understand the fallback paragraph below without first seeing that the protocol’s whole presence on the wire is optional.
On-wire signaling
MPTCP uses TCP options (the variable-length header field from Background 1) to carry its protocol — all under the single Kind 30, distinguished by subtype:
- MP_CAPABLE in the SYN of the primary subflow: “I support MPTCP; do you?”
- MP_JOIN in the SYN of an additional subflow: “I want to join the MPTCP connection identified by token X.” (What that token is and how it routes to the right connection is Background 2.)
- DSS (Data Sequence Signal): per-segment metadata mapping the subflow’s local sequence number to the msk-level sequence number (Background 3).
- ADD_ADDR / REMOVE_ADDR: announce additional endpoints the peer can use to join via MP_JOIN.
If a middlebox strips MP_* options (some old NATs do), MPTCP gracefully falls back to plain TCP — for exactly the reason Background 1 gave: the options are optional, so a NAT that strips an unrecognized Kind 30 leaves a valid ordinary TCP stream behind.
Background 2: the token — binding a new subflow to the right msk
The MP_JOIN above says “join the connection identified by token X.” That sentence raises three questions the chapter otherwise leaves open: where does the token come from, how does the receiving host turn a token back into the right connection, and what stops a stranger from guessing a token and hijacking the connection? Here are all three.
New — the token is a hash of the peer’s key. During the MP_CAPABLE handshake each side exchanges a 64-bit key. The 32-bit token advertised later in MP_JOIN is a cryptographic hash (SHA-256) of the peer’s key — a stable, connection-unique handle that fits the tight option budget without ever putting the key back on the wire:
/* net/mptcp/crypto.c:30 — token = first 32 bits of SHA256(key) */
void mptcp_crypto_key_sha(u64 key, u32 *token, u64 *idsn)
The msk stores its own token so the lookup table can find it:
/* net/mptcp/protocol.h:309 */
u32 token;
New — the receiver demuxes by token, not by 4-tuple. Recall Day 13: ordinary TCP finds the owning struct sock for an incoming segment by hashing its 4-tuple (src IP, src port, dst IP, dst port). But an MP_JOIN SYN arrives on a brand-new TCP 4-tuple — a never-before-seen address pair — so 4-tuple demux would create a fresh, unrelated connection. MPTCP adds a second demux key: the kernel keeps a global token→msk table and looks the token up directly.
/* net/mptcp/token.c:246 — retrieve the owning msk from the token */
struct mptcp_sock *mptcp_token_get_sock(struct net *net, u32 token)
When the lookup succeeds, the new subflow is attached to that existing msk’s subflow list instead of starting a new connection — this is the concrete mechanism behind “the msk owns a list of subflows”. The snippet below is the primary subflow being added at msk creation; an MP_JOIN later appends to this same conn_list from the subflow-receive path (net/mptcp/protocol.c:3609):
/* net/mptcp/protocol.c:103-117 — __mptcp_socket_create() adds the FIRST subflow */
err = mptcp_subflow_create_socket(sk, sk->sk_family, &ssock);
...
list_add(&subflow->node, &msk->conn_list); /* the conn_list the msk owns; a join appends here too, from protocol.c:3609 */
New — the HMAC stops a stranger from forging or replaying a join. A token is visible to anyone who watched the handshake, so a token alone cannot be the credential. MP_JOIN also carries an HMAC that is keyed by both 64-bit keys — which only the two endpoints possess (the keys appeared on the wire only during the initial MP_CAPABLE handshake) — and computed over the two random nonces the ends exchange in the MP_JOIN handshake itself (one nonce per direction). The keys authenticate; the nonces make every join challenge-response fresh:
/* net/mptcp/subflow.c:50 — keys are the HMAC *key*; the *message* is the two nonces */
static void subflow_generate_hmac(u64 key1, u64 key2, u32 nonce1, u32 nonce2,
void *hmac)
/* net/mptcp/crypto.c:43 — the underlying keyed HMAC (msg is supplied by the caller above) */
void mptcp_crypto_hmac_sha(u64 key1, u64 key2, u8 *msg, int len, void *hmac)
So the answer to “can’t anyone with the token inject a subflow?” is no, on two counts: a forger would need both keys to key the HMAC (and the keys are never re-transmitted after MP_CAPABLE), and even an attacker who captured one valid MP_JOIN cannot replay it, because the fresh per-join nonces make each HMAC unique.

Background 3: two layers of sequence numbers, and the DSS mapping that glues them
“Sequence numbers come in two layers” is the line that makes reinjection, dedup, and reassembly possible — but the chapter never shows what the second layer carries or why one layer isn’t enough. Here it is.
Refresher (Day 15), not re-taught: in plain TCP, seq numbers the bytes I send and ack_seq is the next byte I expect (day15.md:48, day15.md:67). Recall that; we build on top of it.
New — why two layers are unavoidable. Each subflow must put ordinary, contiguous TCP sequence numbers on the wire, because the peer’s TCP stack and every middlebox along that path expect a normal, gap-free TCP stream — anything else looks like corruption and gets dropped. But those per-subflow counters are independent across subflows: subflow A’s byte 5000 and subflow B’s byte 5000 have nothing to do with each other. They cannot, by themselves, tell the receiver how to splice two streams back into the single byte order the application sent. So MPTCP adds a second counter: the Data Sequence Number (DSN), a single connection-wide sequence space over the application’s bytes.
New — DSS is the mapping between the two. The DSS option is the per-segment glue. It carries a mapping triple plus an independent Data-ACK:
/* net/mptcp/protocol.h:149-151 — the DSS mapping triple */
u64 data_seq; /* msk-level DSN where this mapping starts */
u32 subflow_seq; /* offset into THIS subflow's stream where it starts */
u16 data_len; /* how many bytes the mapping covers */
The receiver reads it as: “the data_len bytes that arrived at subflow offset subflow_seq actually belong at DSN data_seq in the application stream.” That translation is what lets bytes that travelled different paths be re-ordered into one stream. DSS also carries a separate Data-ACK, so the msk can free send-buffer data at the connection level independently of each subflow’s own ACKs. The subtype is MPTCPOPT_DSS = 2 (net/mptcp/protocol.h:44), and on the send side each application chunk waiting to go out is tracked as a data-level fragment that remembers its DSN:
/* net/mptcp/protocol.h:261-263 */
struct mptcp_data_frag {
struct list_head list;
u64 data_seq; /* the DSN this fragment occupies */
mptcp_write_options() (net/mptcp/options.c:1403) emits the DSS mapping into the option area.
New — this is what makes reinjection cheap. Because the DSN is independent of any subflow’s sequence space, the same application bytes can be sent on two different subflows with two different subflow_seq values but the same data_seq. The receiver sees the duplicate DSN range and delivers it to the application only once. That is the payoff you’ll meet in “Reliability and recovery”: a stalled subflow’s data can be reinjected on a healthy one without the application ever seeing a duplicate.

There are no Dumb Questions
Q: The token is just a public hash of a key anyone could have sniffed. If I know the token, can’t I inject my own subflow into someone else’s connection?
A: No. The token only routes an MP_JOIN to the right msk; it is not the credential. The join must also carry an HMAC that is keyed by both endpoints’ 64-bit keys, and those keys went on the wire only once, inside the initial MP_CAPABLE handshake. Without both keys you cannot produce a valid HMAC, so the token alone gets you nowhere. And because the HMAC is computed over fresh per-join nonces, you cannot even replay a valid join you captured earlier — each one is unique.
Q: Why two sequence numbers per byte? Why not just one big connection-wide counter and skip the per-subflow seq?
A: Because each subflow is a real TCP connection that real middleboxes inspect. The peer’s TCP stack and every NAT/firewall on that path expect an ordinary, gap-free TCP sequence stream — feed them a connection-wide DSN with holes (because other bytes went down a different subflow) and it looks like corruption and gets dropped. So each subflow must carry normal, contiguous TCP seq numbers to look normal, and MPTCP layers the DSN on top via DSS to splice the streams back together.
API
int sk = socket(AF_INET, SOCK_STREAM, IPPROTO_MPTCP);
That’s it. The application sees a regular socket. The kernel handles all subflow management. (IPPROTO_MPTCP = 262.)
net.mptcp.enabled=1 controls whether MPTCP sockets can be created; it does not remap ordinary IPPROTO_TCP sockets to MPTCP. Applications opt in with IPPROTO_MPTCP, or an operator can use a selective mechanism such as mptcpize/LD_PRELOAD or a BPF socket-create hook to change specific sockets before creation.
Endpoint configuration
Subflows aren’t created automatically — you tell MPTCP which addresses can be used.
# Mark an address as an MPTCP endpoint
sudo ip mptcp endpoint add 192.168.2.10 dev eth1 signal
# 'signal' = announce this address to peers via ADD_ADDR
# Or 'subflow' = automatically initiate a subflow from this address
sudo ip mptcp endpoint add 192.168.99.5 dev eth0 subflow
# Inspect
sudo ip mptcp endpoint show
sudo ip mptcp limits show # how many subflows max
Endpoint flags:
signal: announce to peer (peer can join from this address).subflow: initiate a subflow from this address.backup: low-priority subflow (used only when others are unavailable).fullmesh: create subflows from this address to every peer endpoint.
(ip mptcp is a netlink client — see the path-manager note below for how these commands actually reach the kernel.)
Schedulers
net/mptcp/sched.c. The scheduler decides which subflow gets the next segment:
- default (
mptcp_sched_default): choose an available subflow using the in-kernel send-time estimate, queued data, pacing rate, window space, and backup status.
The scheduler framework is pluggable (struct mptcp_sched_ops), but the set available on your system is exactly what the kernel has registered. Check before configuring:
cat /proc/sys/net/mptcp/available_schedulers
cat /proc/sys/net/mptcp/scheduler
sudo sysctl -w net.mptcp.scheduler=default
Do not assume redundant, round-robin, or bpf exists unless it appears in available_schedulers for the kernel you are running.
Path manager
net/mptcp/pm_*.c — decides when to add/remove subflows. Two flavors:
- In-kernel: the kernel’s path manager uses the configured endpoints to add subflows on its own.
- Userspace: an application drives subflow lifecycle via the Netlink API (netlink sockets). Used by tools like
mptcpd.
How config reaches the kernel (refresher, Day 8). Recall from Day 8’s Background 4 (day08.md:257) that ip route is just a netlink client: it opens an AF_NETLINK socket and sends structured messages that per-type handlers turn into FIB changes. ip mptcp and daemons like mptcpd program kernel MPTCP state the same way — except MPTCP registers its own generic-netlink family (the pm_netlink / pm_userspace path) rather than reusing rtnetlink. Every ip mptcp endpoint add and ip mptcp limits set you run below is a generic-netlink message into that family. We don’t re-teach the netlink socket model here — see Day 8.
Reliability and recovery
Each subflow is a real TCP connection — it has its own RTT, cwnd, retransmit logic. The msk-level adds:
- Reinjection on failure: if a subflow’s RTO fires and it can’t deliver, the msk reinjects the data on another subflow (so peer receives it via a different path). This is exactly the reinjection Background 3 set up: same DSN, different subflow_seq.
- DSN-based deduplication: receiver sees msk-level sequence numbers; even if data arrives twice (once on each subflow), the receiver delivers it to the application only once.
- Connection migration: if all current subflows fail (e.g., WiFi + cellular both lose coverage briefly), the msk waits; new subflows can join when connectivity returns. The application’s connection is preserved.
Background 4: the msk reassembly queue and head-of-line blocking
The “Performance characteristics” warning (“receive buffer ≥ BDP × N,” “head-of-line blocking on the slow path”) and the entire Check answer hinge on one structure: the queue where the msk holds out-of-order data while it waits. Here is what it is and why its size scales with the number of subflows.
Refresher (Days 17–18), not re-derived: Day 18 taught the bandwidth-delay product (BDP) — bytes in flight on a full pipe = bandwidth × RTT — and receive-buffer auto-tuning within the tcp_rmem[min, default, max] triplet, plus the SOCK_RCVBUF_LOCK userlock that opts you out of it (day18.md:116-130). Day 17 taught in-flight accounting (day17.md:138). Recall those; the Check answer’s “bump tcp_rmem to the sum of the BDPs” only makes sense on top of them.
New — what head-of-line (HoL) blocking is. The term appears nowhere earlier in the book, so define it precisely. The application must receive bytes in DSN order — byte N before byte N+1, no gaps. Suppose a fast subflow has already delivered a chunk at a high DSN, but the low DSN bytes that come before it are still in flight on a slow subflow. The high-DSN bytes cannot be handed to the application yet — they have to wait for the missing low-DSN bytes at the head of the line. The fast path stalls on the slow path. That is HoL blocking, and on MPTCP it happens across subflows, not within one.
New — the structure that holds the waiting data. The early-arriving high-DSN skbs go into the msk’s out-of-order reassembly queue, an rbtree keyed by DSN:
/* net/mptcp/protocol.c:266-271 — insert an out-of-order skb into the msk's DSN rbtree */
p = &msk->out_of_order_queue.rb_node;
...
rb_insert_color(&skb->rbnode, &msk->out_of_order_queue);
msk->ooo_last_skb = skb;
A fast-path coalesce check uses ooo_last_skb to avoid an O(log N) lookup when data arrives right after the last queued skb (net/mptcp/protocol.c:278-285). Each queued skb remembers its DSN range in its control block:
/* net/mptcp/protocol.h:128-136 */
struct mptcp_skb_cb {
u64 map_seq; /* first DSN this skb covers */
u64 end_seq; /* one past the last DSN */
...
};
That rbtree is what consumes receive buffer while the slow subflow’s head-of-line bytes are outstanding. If the buffer cannot hold roughly one BDP’s worth of data per subflow, the kernel must stall or drop, and the path diversity MPTCP exists to exploit is wasted. That is the concrete grounding for the “BDP × N” claim: the requirement scales with the number of subflows because every subflow can have up to its own BDP in flight, and the receiver may have to buffer all of it at once.
New — contrast with single-path TCP. Plain TCP has a single per-socket out_of_order_queue sized to one path’s BDP. MPTCP’s reassembly queue lives at the msk level, above all subflows, which is exactly why its buffer requirement multiplies by N rather than staying fixed at one path’s worth.

Performance characteristics
MPTCP is at its best when:
- Multiple paths exist with similar RTTs and bandwidths.
- Single-path failure rate is non-trivial (mobile, lossy paths).
- The bulk transfer is large enough that the per-subflow setup cost is amortized.
It’s worse than plain TCP when:
- Only one path is available (overhead of MPTCP options, slower handshake).
- Paths have very asymmetric RTTs (head-of-line blocking on the slow path — Background 4).
- Buffer is too small to coordinate (msk-level reordering needs receive buffer ≥ BDP × N — Background 4).
Today’s experiment
# Verify MPTCP support; save the current value if you change it.
old_mptcp_enabled=$(cat /proc/sys/net/mptcp/enabled)
sudo sysctl net.mptcp.enabled
sudo sysctl -w net.mptcp.enabled=1
trap 'sudo sysctl -w net.mptcp.enabled=$old_mptcp_enabled; pkill -f /tmp/mptcp_demo 2>/dev/null; rm -f /tmp/mptcp_demo /tmp/mptcp_demo.c' EXIT
# Use ip mptcp tooling
sudo ip mptcp endpoint show
cat /proc/sys/net/mptcp/available_schedulers
# 'endpoint show' is normally empty on a single-host test until you add
# endpoints — that's expected, not a failure. 'available_schedulers' prints
# at least 'default'.
# Quick test over loopback. Stock `nc` has no `--mptcp` flag and the `mptcpize`
# LD_PRELOAD wrapper needs the mptcpd package — neither is guaranteed present —
# so we use ONE self-contained binary that is both the MPTCP server and client.
# It holds the connection open for a few seconds so `ss -M` and tcpdump can
# observe the live msk and its subflow instead of catching nothing after exit.
cat << 'EOF' > /tmp/mptcp_demo.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/wait.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#ifndef IPPROTO_MPTCP
#define IPPROTO_MPTCP 262
#endif
int main(void) {
struct sockaddr_in a = { .sin_family = AF_INET, .sin_port = htons(9999) };
inet_aton("127.0.0.1", &a.sin_addr);
int srv = socket(AF_INET, SOCK_STREAM, IPPROTO_MPTCP);
if (srv < 0) { perror("socket(server)"); return 1; }
int one = 1; setsockopt(srv, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one);
if (bind(srv, (struct sockaddr*)&a, sizeof a) < 0) { perror("bind"); return 1; }
if (listen(srv, 1) < 0) { perror("listen"); return 1; }
if (fork() == 0) { // child = client
int c = socket(AF_INET, SOCK_STREAM, IPPROTO_MPTCP);
if (c < 0) { perror("socket(client)"); _exit(1); }
if (connect(c, (struct sockaddr*)&a, sizeof a) < 0) { perror("connect"); _exit(1); }
write(c, "hello\n", 6);
sleep(6); // hold the connection open to observe
close(c); _exit(0);
}
int cs = accept(srv, NULL, NULL); // parent = server
if (cs < 0) { perror("accept"); return 1; }
char buf[64]; int n = read(cs, buf, sizeof buf);
if (n > 0) write(1, buf, n); // prints "hello"
sleep(6);
close(cs); close(srv);
wait(NULL);
return 0;
}
EOF
cc /tmp/mptcp_demo.c -o /tmp/mptcp_demo
# Run it in the background so we can watch the connection while it is still up.
/tmp/mptcp_demo &
sleep 1
# 'M' = MPTCP. Shows the msk and its subflow while the connection is live.
ss -M | head
On loopback you see the single subflow as a pair of ESTAB rows (the client end
and the server end of the one path; your ephemeral port will differ):
State Recv-Q Send-Q Local Address:Port Peer Address:Port
ESTAB 0 0 127.0.0.1:48902 127.0.0.1:9999
ESTAB 0 0 127.0.0.1:9999 127.0.0.1:48902
If ss -M prints only the header, the connection already closed before you
looked — the sleep(6) in both ends is what keeps it alive long enough to
observe, so re-run ss -M while /tmp/mptcp_demo is still in the background.
Verify via tcpdump. Start the capture first (line-buffered with -l,
self-terminating with timeout), then drive traffic into it. tcpdump decodes
MPTCP TCP options in lowercase (mptcp ... capable, mptcp ... dss), so
match those — the uppercase MP_CAPABLE/DSS tokens never appear in its output,
and -X only dumps payload bytes where the binary option fields are not literal
strings.
sudo timeout 8 tcpdump -l -i lo -nn 'tcp port 9999' 2>/dev/null | grep -i mptcp &
sleep 1
/tmp/mptcp_demo
You should see mptcp ... capable on the SYN/SYN-ACK (the MP_CAPABLE handshake)
and mptcp ... dss on the data and ACK segments:
IP 127.0.0.1.9999 > 127.0.0.1.48902: Flags [.], ..., options [...,mptcp 26 dss fin ack ... seq ... subseq 0 len 1,...], length 0
IP 127.0.0.1.48902 > 127.0.0.1.9999: Flags [F.], ..., options [...,mptcp 8 dss ack ...], length 0
(Now you can read those lines: mptcp is Kind 30, the number after it is the
option Length in bytes, dss is the subtype, and seq/subseq/len are the
DSS mapping triple from Background 3.)
Seeing real multipath (optional)
The loopback test above only ever has one path — a single address pair — so
the connection completes the MP_CAPABLE handshake plus DSS on exactly one
subflow. ss -M lists that single subflow and MP_JOIN never appears:
there is no second path to join. To observe genuine multipath on a single host,
give the kernel a second address it can open an additional subflow from. This
changes persistent kernel MPTCP state, so undo it afterward (verify the exact
ip mptcp syntax on your kernel — it has shifted across releases):
# Announce a second loopback address and allow one extra subflow.
sudo ip addr add 127.0.0.2/8 dev lo
sudo ip mptcp limits set subflow 2 add_addr_accepted 2
sudo ip mptcp endpoint add 127.0.0.2 dev lo signal subflow # ADD_ADDR so the peer can join + initiate a subflow from it
# Re-run the transfer, then look for the second subflow and the MP_JOIN exchange:
/tmp/mptcp_demo & sleep 1; ss -M | head
sudo timeout 8 tcpdump -l -i lo -nn 'tcp port 9999' 2>/dev/null | grep -iE 'join|add'
# Cleanup
sudo ip mptcp endpoint flush
sudo ip mptcp limits set subflow 2 add_addr_accepted 0
sudo ip addr del 127.0.0.2/8 dev lo
With the second endpoint configured you should see mptcp ... join in the
capture and an extra subflow in ss -M — the multipath behavior that is the
whole point of MPTCP. (The join you see carries the token and HMAC from
Background 2; the token is what routes that brand-new 4-tuple onto the existing
msk.)
What to read in the kernel
-
net/mptcp/protocol.c— main file. Read__mptcp_socket_createto see how an msk is built. The msk owns a list of subflows. -
net/mptcp/subflow.c— subflow lifecycle.subflow_finish_connect,mptcp_subflow_create_socket. How a TCP subflow becomes part of an MPTCP connection. -
net/mptcp/sched.c:130—mptcp_init_sched. The scheduler entry. Read the default scheduler path to see how the kernel picks “best” subflow per send. -
net/mptcp/pm_kernel.c— in-kernel path manager. Reads MPTCP endpoint config, opens new subflows when announced. -
net/mptcp/pm_userspace.c— userspace path-manager hooks via netlink. -
net/mptcp/options.c— TCP-option encoding/decoding for MPTCP. Readmptcp_write_optionsto see how MP_CAPABLE, MP_JOIN, DSS are emitted. -
Documentation/networking/mptcp.rst— official guide. Has examples and a compatibility matrix. -
mptcpd(userspace daemon) — a reference path-manager implementation; useful to study real-world configuration patterns.
Bullet Points
- MPTCP = one TCP connection, multiple TCP subflows on different paths.
- API:
socket(AF_INET, SOCK_STREAM, IPPROTO_MPTCP). Application code rarely needs to change. - Subflows are real TCP connections; the msk (master) coordinates them.
- All MPTCP signaling rides in TCP options under one Kind,
TCPOPT_MPTCP = 30, sharing the hardMAX_TCP_OPTION_SPACE = 40byte budget; the message type is a 4-bit subtype nibble (MP_CAPABLE/MP_JOIN/DSS/ADD_ADDR). Because options are optional and some NATs strip ones they do not recognize, a middlebox that drops Kind 30 forces graceful fallback to plain TCP. - Key TCP options: MP_CAPABLE (handshake, exchanges 64-bit keys), MP_JOIN (add subflow, carries token = SHA256(peer key) + HMAC keyed by both keys, computed over the two per-join nonces), DSS (maps subflow_seq→msk DSN + Data-ACK), ADD_ADDR (announce endpoints).
- A new MP_JOIN is demuxed by token→msk lookup (
mptcp_token_get_sock), not by 4-tuple, so a packet from a never-seen address still finds its connection; the HMAC stops forged joins. - Two sequence layers: per-subflow TCP seq (so each path looks normal) and the connection-wide DSN; DSS is the glue, and a shared DSN with different subflow_seq is what enables reinjection and dedup.
- The receiver buffers out-of-order data in the msk-level
out_of_order_queuerbtree (keyed by DSN); head-of-line blocking across subflows means the requirement is roughly BDP × N of receive buffer. - Endpoints configured via
ip mptcp endpoint(a generic-netlink client, likeip routeover rtnetlink — Day 8). Flags:signal,subflow,backup,fullmesh. - Schedulers are pluggable, but current choices are whatever appears in
net.mptcp.available_schedulers; the in-tree default isdefault. - Mobile / multi-NIC workloads benefit; single-path low-latency workloads see MPTCP overhead.
- In-tree since 5.6 (2020); substantial improvements every release through 7.x.
Check question
If one subflow’s RTT spikes severely (e.g., cellular degrades during a transfer), what does MPTCP do, and what’s the limit of how gracefully it recovers?
Click to reveal answer
Answer: The default scheduler steers new segments to a better available subflow using its send-time estimate, queued data, pacing rate, window space, and backup status. A path with a much worse RTT or no usable window naturally gets less traffic. Already-in-flight segments on the slow subflow stay there until ACKed or retransmitted; MPTCP-level retransmit can also reinject them on the better subflow if the slow one’s RTO fires (same DSN, different subflow_seq — Background 3).
The limit is buffering. The msk reassembles data in order at the receiver, in the out_of_order_queue rbtree keyed by DSN (Background 4). If subflow A is fast (low RTT, current data) and subflow B is slow (high RTT, older data), the receiver has to buffer A’s high-DSN data while waiting for B’s older low-DSN data to arrive. If the receive buffer is too small (tcp_rmem), MPTCP can’t take advantage of the path diversity — head-of-line blocking on the slow path stalls the application. Tuning: bump tcp_rmem to at least the sum of the BDPs across subflows.
For sudden complete failure (a subflow’s RTO breaks): MPTCP can mark it backup and reinject; the application sees a brief stall but no error.
End of Phase 4
You’ve covered the kernel’s network subsystems: netfilter for packet filtering, nftables for the modern filter API, conntrack for state tracking, traffic control for queueing, SO_REUSEPORT for socket scaling, kTLS for transport crypto, MPTCP for multipath. That’s the bulk of “kernel networking infrastructure beyond the basic stack.”
Phase 5 (Days 27–30) covers modern features and the capstone.
Day 27 — XDP and the rest of the stack
Today’s mission: see exactly where the XDP hook sits in the receive path, why it can do things faster than tc-bpf, and how XDP cooperates with (rather than replacing) the regular Linux network stack. Along the way we’ll fill in the four pieces of background the rest of the book never taught: what a BPF map actually is, what
struct xdp_mdlooks like to your program, howXDP_REDIRECTreally fires, and what an AF_XDP socket is. Total time: ~110 minutes.
Phase 5 starts here. The last four days cover the modern hooks layered on top of everything you’ve learned, plus a capstone day where you trace one real packet end-to-end.
What XDP is, kernel-side
You already met XDP on Day 2, so we won’t re-derive it from scratch. The one-line refresher:
Recall from Day 2: native XDP is an eBPF program the driver runs on the raw RX frame before any
sk_buffexists — the earliest software hook in the kernel. It looks at anxdp_buff(four stack pointers over the DMA page, no slab allocation, nousers/datarefrefcounts) and returns one of five action codes.XDP_PASSis the only one that proceeds to build an skb; the other four consume the frame in the driver. Generic XDP (do_xdp_generic→netif_receive_generic_xdp,net/core/dev.c:5656/:5576) is the slower fallback that runs later, after an skb already exists.
So the five actions, in the order the kernel defines them (enum xdp_action, include/uapi/linux/bpf.h:6548 — note XDP_ABORTED is 0, not last):
XDP_ABORTED(0) — same effect as DROP plus a tracepoint fires (debugging signal).XDP_DROP— packet freed in-driver, no kernel work done.XDP_PASS— kernel allocates skb and continues with normal RX (Day 2).XDP_TX— sends it back out the same NIC, reflecting the packet without ever touching skb.XDP_REDIRECT— sends it to a different netdev, a CPU map (cpumap), or an AF_XDP socket.

This is the earliest hook in the kernel for incoming packets. There’s nothing before XDP except the NIC and the driver code that just received the frame.
Why this matters
Recall from Day 1: allocating an
sk_buffis not free — it’s a slab allocation of the descriptor plus a separate data-buffer allocation, refcount setup, and cache-line dirtying (~500 ns of fixed cost). Routing, conntrack (Days 20–22), and netfilter each add more on top.
For high-rate packet filtering or load balancing, you don’t want to pay any of that for packets you’ll just drop or redirect. XDP runs at ~10 ns of fixed overhead plus your program’s logic. A drop in XDP is the cheapest packet operation Linux can do. Cilium’s load balancer, Cloudflare’s DDoS scrubbing, Facebook’s Katran — all use XDP to decide “drop / pass / redirect / mangle” in the few hundred nanoseconds before the kernel commits to processing the packet.
Three modes
XDP has three execution modes, picked at attach time:
Native XDP (default, fastest)
The driver implements XDP support: it calls bpf_prog_run_xdp directly from its NAPI poll, before allocating skb. The packet is in the driver’s RX buffer; XDP gets a pointer to it and the data length. ~10 ns overhead for an empty program.
Major drivers with native XDP: ixgbe, i40e, mlx5, mlx4, virtio_net, veth (yes, veth supports XDP — useful for testing).
Generic XDP (XDP_FLAGS_SKB_MODE)
Works on any driver. The kernel implements XDP as a hook after the driver has done some skb-related setup. Slower than native (~half the speed) because it duplicates work.
Use generic when your NIC doesn’t support native XDP. Or for development on virtio in older VM setups.
Hardware-offloaded XDP (XDP_FLAGS_HW_MODE)
The BPF program is JITed to NIC firmware (Netronome NFP, some Mellanox SKUs). Runs on the NIC, not on the host CPU. Insanely fast for simple programs but very limited — the offload target supports only a subset of maps and helpers (the NFP, for example, offloads array/hash maps plus map_lookup/update/delete_elem and xdp_adjust_head/tail, but rejects anything outside that subset). Exactly what works depends on the NIC.
Practically rare; most production XDP runs in native mode.
Background 1: what a BPF map actually is
Everything interesting XDP does — redirect to a device, steer to a CPU, hand off to a userspace socket, export a drop counter — leans on a thing this book has been quietly assuming: a BPF map. The whole “XDP_REDIRECT — where it shines” section below is built on three map types, and the tc-bpf cooperation note says XDP and tc “communicate via shared maps.” Time to make a map real.
The problem a map solves: a BPF program is stateless and forgets everything
An XDP program runs, returns an action code, and is done — there is no skb to stash state in (it runs before the skb exists), no socket to hang data off. Each invocation starts with a blank slate. So how does a load balancer remember its backend list across millions of packets? How does a DDoS filter export “I dropped 4 billion packets” to a dashboard? How does the program even know which device to redirect a frame to?
The answer is the BPF map: a typed key→value table that lives in the kernel, outside any single program invocation. It is the durable state that an otherwise stateless per-packet program reads and writes.
- A map is created from userspace via the
bpf()syscall, which returns a file descriptor. The map persists as long as someone holds a reference to it (an fd, or a pin in the bpffs filesystem) — it survives across packets, across program invocations, even across the program being detached and re-attached. - A BPF program references a map by fd/id and does lookups/updates inside the kernel with no syscall.
- Both sides touch the same table. A userspace “control plane” process populates and reads entries (config in, stats out); the in-kernel program reads them on the hot path. For XDP — which has no skb and no socket — this shared map is the only way to keep configuration and export statistics. It is also exactly how XDP and tc-bpf “communicate”: they each hold an fd to the same map.
Contrast with what you already know. Day 1’s two
sk_buffrefcounts and Day 2’s thrown-awayxdp_buffare the opposite of a map: they are per-packet, ephemeral, gone the moment the packet leaves. A map is the durable counterpart — the state that survives between packets.

Redirect maps: the value is a forwarding target, not plain data
Maps come in many types — the v7.1 enum bpf_map_type (include/uapi/linux/bpf.h) lists dozens. The three this chapter uses are redirect maps, where each value is not a number or a struct you read, but a place to send the packet:
Map type (enum at include/uapi/linux/bpf.h) | Key | Value is… |
|---|---|---|
BPF_MAP_TYPE_DEVMAP (line 1014) | array slot (0..max_entries-1) | a netdev (the real ifindex lives in the value, + optional egress XDP program) |
BPF_MAP_TYPE_CPUMAP (line 1016) | CPU id | a per-CPU queue |
BPF_MAP_TYPE_XSKMAP (line 1017) | queue id | an AF_XDP socket |
The DEVMAP value layout is worth seeing, because it proves a map value can be “a device plus an optional program” (struct bpf_devmap_val, include/uapi/linux/bpf.h:6575, just after struct xdp_md):
struct bpf_devmap_val {
__u32 ifindex; /* device index */
union {
int fd; /* prog fd on map write */
__u32 id; /* prog id on map read */
} bpf_prog; /* optional egress XDP program */
};
The in-kernel entry that this becomes is struct bpf_dtab_netdev (kernel/bpf/devmap.c:67); the CPUMAP equivalent is struct bpf_cpu_map_entry (kernel/bpf/cpumap.c:60). Userspace writes a bpf_devmap_val into the map; the kernel turns it into a bpf_dtab_netdev holding a real net_device *.
The program stages a redirect with a single helper:
long bpf_redirect_map(struct bpf_map *map, __u64 key, __u64 flags);
It looks up map[key] and stages the redirect (we’ll see exactly what “stages” means in Background 3). The helper is allowed for XDP programs via BPF_FUNC_redirect_map (net/core/filter.c:8528), backed by bpf_xdp_redirect_map_proto (net/core/filter.c:4674).
Background 2: struct xdp_md and mandatory bounds checking
Today’s experiment hands you int xdp_drop(struct xdp_md *ctx). Day 2 taught the kernel-side struct xdp_buff (four pointers into the DMA page) — but a BPF program never sees an xdp_buff. It sees struct xdp_md, the verifier-exposed context. You cannot write any non-trivial XDP program — parse Ethernet, look at an IP header, the whole point of XDP filtering — without understanding it.
What the program sees
struct xdp_md is the context the verifier presents to an XDP program (include/uapi/linux/bpf.h:6559):
struct xdp_md {
__u32 data; /* start of packet bytes */
__u32 data_end; /* one past the last byte */
__u32 data_meta; /* custom metadata region */
/* Below access go through struct xdp_rxq_info */
__u32 ingress_ifindex; /* rxq->dev->ifindex */
__u32 rx_queue_index; /* rxq->queue_index */
__u32 egress_ifindex; /* txq->dev->ifindex */
};
The trick: data and data_end are declared __u32, but the verifier rewrites them into real pointers at load time. After the rewrite, the packet bytes live in [data, data_end). Same DMA bytes Day 2’s xdp_buff pointed at — the verifier just presents them as a bounds-checked, program-safe window.
Relate to Day 2: the kernel builds an
xdp_buffover the DMA page; for your program the verifier presents that same window asxdp_mdwith boundeddata/data_end. Same bytes, program-safe view.
The other fields tell a redirect program where the frame came from: ingress_ifindex (= rxq->dev->ifindex) and rx_queue_index (= rxq->queue_index) are how a steering program decides what to do; egress_ifindex is set for devmap egress programs. data_meta is a scratch region for XDP→XDP or XDP→tc handoff.
The cardinal rule: prove every access in-bounds, or the verifier rejects you
This is the #1 thing that trips up a first XDP program. Before you dereference any header at data, you must prove to the verifier that the bytes are actually there:
SEC("xdp")
int xdp_parse(struct xdp_md *ctx)
{
void *data = (void *)(long)ctx->data;
void *data_end = (void *)(long)ctx->data_end;
struct ethhdr *eth = data;
if ((void *)(eth + 1) > data_end) /* MANDATORY: data + 14 <= data_end? */
return XDP_DROP; /* not enough bytes — bail */
if (eth->h_proto == bpf_htons(ETH_P_IP))
return XDP_PASS;
return XDP_DROP;
}
Skip that if and the verifier rejects the program at load time — it cannot prove the access is safe, so it refuses to load it. This is the concrete meaning of the “limited mutation / can’t reach inside arbitrarily” limitation noted below: every packet access is gated on a bounds check against data_end.
There’s headroom in front of data to make pushes cheap: XDP_PACKET_HEADROOM is 256 bytes (include/uapi/linux/bpf.h:6541), reserved so bpf_xdp_adjust_head (prepend/strip bytes at the front) rarely has to do anything expensive.

XDP and the rest of the stack
XDP doesn’t replace the network stack — it sits in front of it. For traffic that returns XDP_PASS:
- XDP returns PASS.
- Driver allocates skb (
build_skb/napi_build_skb— recall the zero-copy wrap from Day 1). - Normal RX path (Day 2): GRO,
__netif_receive_skb_core, tc-bpf ingress, IP, conntrack, sockets.
XDP is “the fast path for the easy cases.” Everything else still flows through the regular stack.
Cooperating with tc-bpf
Many production setups use both. XDP for high-rate fast-path drops/redirects, tc-bpf for everything skb-aware. Cilium, for example:
- XDP for L3 service load balancing (a few hot services with millions of pps).
- tc-bpf for L4 connection tracking, NetworkPolicy enforcement, encryption negotiation.
- The two work in sequence: XDP runs first, returns PASS for traffic tc-bpf needs to see.
You can attach both to the same interface. They don’t interfere — XDP runs at packet boundary; tc-bpf runs at skb boundary; the only shared resource is the BPF map ecosystem from Background 1, where they can communicate via shared maps (each side holds an fd to the same key→value table).
XDP_REDIRECT — where it shines
bpf_redirect_map() is the highest-throughput mechanism XDP exposes. Three target map types (the redirect maps from Background 1):
BPF_MAP_TYPE_DEVMAP
{ array slot → netdev *, optional egress XDP program } — the key is just the slot index you chose at map-update time; the target netdev’s real ifindex is stored in the value (bpf_devmap_val.ifindex). XDP_REDIRECT to this map sends the packet to the named netdev. Used for L3 forwarding, container networking (redirect from physical NIC into a veth), gateway boxes.
BPF_MAP_TYPE_CPUMAP
{ cpu_id → cpu queue }. Redirects the packet to a different CPU’s per-CPU queue, which then runs the kernel RX path on that CPU. Useful for steering: “RSS landed this on CPU 0 but the destination socket is pinned to CPU 4 — redirect.”
BPF_MAP_TYPE_XSKMAP
{ queue_id → AF_XDP socket }. Sends the packet directly to userspace via AF_XDP — zero-copy if the NIC supports it. The basis of high-throughput userspace packet processing (DPDK-on-Linux-without-DPDK). Background 4 explains what’s on the other end.
Background 3: how XDP_REDIRECT actually fires
Here’s the part that’s usually just asserted: redirect is “the highest-throughput path.” Why? The mechanism is a two-phase, batched model — and the batching is the whole reason it’s fast. bpf_redirect_map() does not transmit the packet.
Phase 1 — the helper records intent, the program just returns a code
When your program calls bpf_redirect_map(&map, key, flags), the helper looks up the target and stashes it into a struct bpf_redirect_info (include/linux/filter.h:774). In v7.1 this lives inside a struct bpf_net_context hung off current->bpf_net_context — task-local scratch on the softirq stack, fetched via bpf_net_ctx_get_ri() (include/linux/filter.h:815). (It used to be a literal per-CPU variable; it’s still effectively one-per-CPU because NAPI runs in BH-disabled softirq context that can’t migrate, but the data structure itself is no longer a DEFINE_PER_CPU.) Then it returns XDP_REDIRECT. Your program does nothing else — it just returns that action code. No packet has moved.
Phase 2 — the driver enqueues, then flushes once per poll
Seeing XDP_REDIRECT, the driver calls xdp_do_redirect() (net/core/filter.c:4519). That reads the bpf_redirect_info and enqueues the frame onto the target’s bulk queue — dispatching to __xdp_do_redirect_xsk (:4424) for an AF_XDP socket, or __xdp_do_redirect_frame (:4449) for a devmap/cpumap target. Still no transmit: the frame is sitting on a bulk queue (devmap bulk queue / cpumap ptr_ring / xsk ring).
Then, once per NAPI poll — at the end of the driver’s ->poll() call, after that call has drained up to its per-poll weight (Day 2’s inner budget, default 64; not the outer netdev_budget of 300, which is spread across all NAPI instances in the softirq) — the driver calls xdp_do_flush() (net/core/filter.c:4358), which flushes all the bulk queues at once:
- devmap →
ndo_xdp_xmit(batched TX of many frames), - cpumap → wake the remote CPU’s kthread,
- xskmap → notify the AF_XDP socket.
Batching across the whole poll is the throughput win. N frames redirected during the poll cost one flush, not N transmits. This is exactly why XDP_REDIRECT beats XDP_TX for forwarding to other devices, and why it rides the same per-CPU, per-poll batching that makes NAPI efficient (Day 2). The kernel even guards against a driver forgetting to flush: WARN_ONCE(missed, "Missing xdp_do_flush() invocation after NAPI by %ps\n", ...) at net/core/filter.c:4392.
The driver call site that proves the whole pattern is the ixgbe one you’ll read below: ixgbe_run_xdp’s case XDP_REDIRECT: calls xdp_do_redirect(adapter->netdev, xdp, xdp_prog) (drivers/net/ethernet/intel/ixgbe/ixgbe_main.c:2435, inside the function at line 2400).

Background 4: AF_XDP sockets and the UMEM
The XSKMAP bullet promised “zero-copy … the basis of DPDK-on-Linux-without-DPDK.” Nothing earlier in this book teaches AF_XDP (Day 25’s “zero-copy” was kTLS sendfile; Day 28 is io_uring) — so here’s the model behind what XSKMAP delivers to.
A socket whose buffer is shared memory
AF_XDP is a socket family: socket(AF_XDP, SOCK_RAW, 0). Its receive endpoint is not a kernel skb queue — it’s a UMEM: a userspace-allocated array of equal-size frames that both the kernel and the app map into their address spaces. The packet bytes land directly in this shared region.
Four rings move descriptors, not bytes
Around the UMEM sit four single-producer/single-consumer rings that carry frame descriptors (essentially UMEM frame indices), never payload:
| Direction | Rings |
|---|---|
| Receive | Fill (app → kernel: “here are empty frames to fill”) and RX (kernel → app: “these frames now hold packets”) |
| Send | TX (app → kernel: “transmit these frames”) and Completion (kernel → app: “these are free again”) |
Because what moves between kernel and app is a frame index, the bytes never get copied when the driver supports zero-copy. The packet stays put in the UMEM; only the descriptor travels.
Where XSKMAP fits
An XSKMAP value is an AF_XDP socket bound to a specific (netdev, queue_id). In XDP you write:
return bpf_redirect_map(&xskmap, ctx->rx_queue_index, 0);
That steers the frame into that socket’s RX ring (__xdp_do_redirect_xsk, the Background-3 dispatch). Userspace then reads the packet straight out of the UMEM in place. This is the DPDK-class userspace fast path — while staying entirely inside the kernel’s normal driver model.
Contrast with Day 1’s sk_buff path: AF_XDP bypasses skb allocation and the socket-buffer copy entirely. It’s the same “don’t build an skb” motivation that justifies XDP itself, extended all the way out to a userspace application.
The official model lives in Documentation/networking/af_xdp.rst (UMEM + four rings); the ring implementation is in net/xdp/ (xsk.c, xsk_queue.h).

Limitations
- No fragmentation handling. XDP sees the raw frame as the NIC delivered it; it can’t reassemble IP fragments (would require buffering).
- No GRO. GRO happens after XDP (Day 2). If you want coalesced superpackets, see them in tc-bpf, not XDP.
- Limited mutation. You can
bpf_xdp_adjust_headto add/remove bytes at the front (that’s what the 256-byteXDP_PACKET_HEADROOMis for),bpf_xdp_adjust_tailfor the back. But you can’t reach inside arbitrarily without thedata_endbounds checking from Background 2. - No skb metadata. No conntrack info, no netfilter mark, no socket lookup (until kernel 5.0 added
bpf_sk_lookup_tcp/udpto the XDP hook).
Today’s experiment
# See if your driver supports native XDP. Find your NIC first — on most modern
# distros and cloud VMs the primary NIC is enp0s3/ens5/eno1, not eth0.
ip -br link # list interfaces, pick your NIC
ethtool -i <iface> | grep driver # e.g. virtio_net, mlx5_core, ixgbe
# virtio_net and veth support native XDP; many cloud NICs only do generic mode.
# Quick test on veth (always supports XDP). Put the peer in its OWN netns so the
# frame is forced across the wire and is actually *received* on veth0's RX path.
# If both ends share the root namespace, the kernel short-circuits via the
# loopback fast-path, the frame never crosses the link, and the XDP program on
# veth0 never runs (the ping would simply succeed and teach you nothing).
sudo ip link add veth0 type veth peer name veth1
sudo ip netns add ns1
sudo ip link set veth1 netns ns1
sudo ip addr add 10.99.0.1/24 dev veth0
sudo ip link set veth0 up
sudo ip netns exec ns1 ip addr add 10.99.0.2/24 dev veth1
sudo ip netns exec ns1 ip link set veth1 up
sudo ip netns exec ns1 ip link set lo up
# Tiny XDP program: drop everything
cat << 'EOF' > /tmp/xdp_drop.bpf.c
#include <linux/bpf.h>
#include <bpf/bpf_helpers.h>
SEC("xdp")
int xdp_drop(struct xdp_md *ctx) { return XDP_DROP; }
char _license[] SEC("license") = "GPL";
EOF
clang -O2 -target bpf -c /tmp/xdp_drop.bpf.c -o /tmp/xdp_drop.o
# If clang errors with "'asm/types.h' file not found", add your arch include
# path, e.g.: clang -O2 -target bpf -I/usr/include/x86_64-linux-gnu -c ...
# Attach to veth0
sudo ip link set veth0 xdp obj /tmp/xdp_drop.o sec xdp
# Send traffic FROM ns1 so it is received on veth0's RX/XDP path. XDP_DROP kills
# every frame — even the ARP request — so the echo requests never reach the
# stack and get no replies. Expect 100% packet loss. Without -W 1, ping would
# hang ~10 s on each unanswered probe before reporting the loss.
sudo ip netns exec ns1 ping -c 3 -W 1 10.99.0.1
# 3 packets transmitted, 0 received, 100% packet loss
# Inspect while the program is STILL attached — before the detach/cleanup below.
# Once you detach and delete veth0 there is nothing left for bpftool to show.
sudo bpftool net show
sudo bpftool prog show
# Detach and ping again to confirm the contrast: now it succeeds.
sudo ip link set veth0 xdp off
sudo ip netns exec ns1 ping -c 3 -W 1 10.99.0.1
# 3 packets transmitted, 3 received, 0% packet loss
# Cleanup
sudo ip link del veth0
sudo ip netns del ns1
That xdp_drop program is the minimal struct xdp_md consumer from Background 2 — it never touches ctx->data, so it needs no bounds check; it unconditionally returns XDP_DROP. The moment you want it to parse anything, the Background-2 data + sizeof(hdr) <= data_end check becomes mandatory.
The two bpftool commands (run above, while the program is still attached) confirm the attachment. bpftool net show lists per-interface BPF attachments — look for an xdp entry under veth0, which confirms the program is bound at the driver RX hook (not tc/ingress):
xdp:
veth0(N) driver id M
bpftool prog show lists every loaded program; find the one of type xdp named xdp_drop whose id matches the M shown by net show:
M: xdp name xdp_drop tag <hex> gpl
loaded_at ... uid 0
xlated ...B jited ...B memlock 4096B
Your N and M will differ — the point is that the same id appears in both outputs, proving the loaded program is the one bound to veth0’s RX path. Run these before the xdp off / ip link del step: once veth0 is gone, net show lists no XDP attachment for it.
There are no Dumb Questions
Q: A BPF program forgets everything between packets — so where does a load balancer keep its backend list?
A: In a BPF map (Background 1). The map lives in the kernel, outside any single program run, and survives across packets and even across detach/re-attach. A userspace control plane populates it (backend IPs, weights); the XDP program looks entries up on the hot path. The map is also how the program exports stats out and how XDP and tc-bpf share state — both hold an fd to the same table.
Q: I see
struct xdp_mdin my program but Day 2 talked aboutstruct xdp_buff. Which is it?A: Both, at different altitudes. The kernel builds an
xdp_buff(real pointers into the DMA page). The verifier exposes that same window to your program asstruct xdp_md, whosedata/data_endare declared__u32but rewritten into pointers at load time. Same bytes;xdp_mdis the bounds-checked, program-safe view.
Q: Does
bpf_redirect_map()send the packet?A: No. It only records the target in a
bpf_redirect_info(task-local scratch offcurrent->bpf_net_contextin v7.1) and returnsXDP_REDIRECT(Background 3). The driver then callsxdp_do_redirect()to enqueue the frame on a bulk queue, and onexdp_do_flush()per NAPI poll actually fans everything out. The batching is why redirect is the highest-throughput path.
Q: What’s actually on the other end of an XSKMAP redirect?
A: An AF_XDP socket bound to one
(netdev, queue_id), backed by a shared-memory UMEM and four descriptor rings (Background 4). The frame lands in the UMEM and userspace reads it in place — no skb, no copy. That’s the “DPDK without DPDK” claim made concrete.
What to read in the kernel
-
net/core/dev.c— searchbpf_prog_run_xdp(the generic-XDP dispatch from driver to BPF). For theXDP_REDIRECTimplementation (xdp_do_redirect), seenet/core/filter.c. -
include/net/xdp.h—struct xdp_buff(line 86), the kernel-side view. The program-sidestruct xdp_mdand the action constants live ininclude/uapi/linux/bpf.h(xdp_mdat line 6559,enum xdp_actionat line 6548). Quick read. -
include/uapi/linux/bpf.h—enum bpf_map_type(DEVMAP at line 1014, CPUMAP 1016, XSKMAP 1017),struct bpf_devmap_val(line 6575). The map-value layout that proves a value can be “a device plus an egress program.” -
net/core/filter.c—xdp_do_redirect(line 4519),xdp_do_flush(line 4358), the missed-flushWARN_ONCE(line 4392),bpf_xdp_redirect_map_proto(line 4674), theBPF_FUNC_redirect_mapallowance (line 8528). Also searchxdp_func_protofor the full helper-allowance table for XDP programs. -
include/linux/filter.h—struct bpf_redirect_info(line 774), held instruct bpf_net_contextand fetched viabpf_net_ctx_get_ri()(line 815) offcurrent->bpf_net_context. The task-local scratch that Phase 1 of redirect writes. -
kernel/bpf/devmap.c—BPF_MAP_TYPE_DEVMAPimplementation; the in-kernel entrystruct bpf_dtab_netdev(line 67). How abpf_redirect_mapto a devmap entry results in xmit to that netdev. -
kernel/bpf/cpumap.c—BPF_MAP_TYPE_CPUMAP; the entrystruct bpf_cpu_map_entry(line 60). How packet → CPU queue → kernel RX on that CPU. -
drivers/net/ethernet/intel/ixgbe/ixgbe_main.c— concrete native-XDP implementation. Look atixgbe_run_xdp(line 2400) to see how a driver calls into BPF in its NAPI poll, and itsXDP_REDIRECTcase callingxdp_do_redirect(line 2435). -
drivers/net/veth.c— searchveth_xdp. veth’s XDP support; useful because it’s simpler than NIC drivers. -
Documentation/networking/af_xdp.rst— the official UMEM + four-ring description (Background 4). The ring structs live innet/xdp/(xsk.c,xsk_queue.h). -
tools/testing/selftests/bpf/progs/test_xdp_*.c— example programs.
Bullet Points
- XDP runs in the NIC driver’s NAPI poll, before skb allocation. Earliest hook in the kernel for RX. Five actions: ABORTED (0), DROP, PASS, TX, REDIRECT — only
XDP_PASSbuilds an skb (recall Day 2). - A BPF map is a kernel-resident key→value table, created from userspace via
bpf(), that outlives any single program run. It’s the durable state a stateless per-packet program reads, the way it exports stats, and how XDP and tc-bpf “communicate” (shared fd to one table). - A program sees
struct xdp_md, notxdp_buff:data/data_endare__u32rewritten into pointers by the verifier. You must provedata + sizeof(hdr) <= data_endbefore every access or the program won’t load. - Three modes: native (driver-supported, fastest), generic (any driver, slower), HW-offloaded (NIC firmware, rarest).
XDP_REDIRECTis a two-phase, batched model:bpf_redirect_map()only records the target in abpf_redirect_info(task-local scratch offcurrent->bpf_net_context) and returns the code; the driver’sxdp_do_redirect()enqueues onto a bulk queue; onexdp_do_flush()per NAPI poll fans out (devmap xmit / cpumap kthread wake / xsk notify). The per-poll batching is the throughput win.- Three redirect maps: DEVMAP (→ netdev), CPUMAP (→ per-CPU queue), XSKMAP (→ AF_XDP socket).
- AF_XDP = a socket family whose buffer is a shared-memory UMEM with four SPSC rings (Fill/RX, TX/Completion). Descriptors (frame indices) move; bytes stay put. XSKMAP steers a frame into a socket’s RX ring for zero-copy userspace processing.
- Cooperates with the rest of the stack: PASS routes through normal RX. XDP doesn’t replace anything. Cilium / Katran / Cloudflare use XDP for the hot path; tc-bpf for skb-aware logic.
- Limitations: no IP fragment reassembly, no GRO, limited mutation (bounds-checked).
Check question
You attach an XDP program that returns XDP_DROP for some packets and XDP_PASS for others. Does iptables/nftables ever see the dropped packets?
Click to reveal answer
Answer: No. XDP runs before skb allocation; the dropped packets never reach netfilter — they don’t even reach __netif_receive_skb_core. iptables/nftables only see what XDP passed through. This is why XDP is preferred for high-rate DDoS mitigation: drops at this layer never pay the cost of skb alloc + netfilter rule walk + conntrack lookup. For 10M-pps DDoS traffic, that cost difference is the difference between staying online and falling over.
If you want both XDP filtering and netfilter visibility on dropped packets (e.g., for forensics), you need to log/sample at XDP and emit metadata to userspace via a perf or ringbuf map — and notice that this is exactly Background 1’s point: the only way an skb-less XDP program can tell userspace anything is through a BPF map. Netfilter cannot see what XDP dropped because the packet never existed as an skb.
Tomorrow
Day 28: io_uring networking. The completion-based I/O model applied to sockets, with zero-copy send.
Day 28 — io_uring networking: zero-copy send/recv
Today’s mission: see how io_uring fundamentally inverts the syscall model for sockets — and understand the machinery that makes that inversion work: a lock-free shared-memory ring, a three-tier async execution ladder, and a refcounted page-pin that tells you exactly when a zero-copy buffer is free again. Then see why zero-copy send is a big deal for high-throughput servers, and how multishot recv lets you submit once and receive many. Total time: ~120 minutes.
The two I/O paradigms
Day 19 introduced epoll (readiness model) and io_uring (completion model). Today we go deep into io_uring’s networking-specific abilities.

Recall:
- epoll: “tell me when this FD is ready; I’ll syscall myself.” Each I/O =
epoll_wait(1 syscall) +recv/send(1 syscall). Two syscalls per I/O. - io_uring: “do this for me; tell me when done.” Each batch of I/Os = 1 syscall (
io_uring_enter) covering many ops. Or zero in steady state withIORING_SETUP_SQPOLL(kernel-thread polling).
For workloads doing > 100k ops/sec per thread, the syscall savings alone are significant. For zero-copy send, the gains scale with payload size.
But that whole pitch — “one syscall per batch,” “zero in steady state” — rests on a mechanism the marketing slides never show: how do userspace and the kernel agree on a queue of work without a syscall per item? That is the first thing we have to teach, because everything else today is built on it.
How the ring actually works: a lock-free shared-memory queue
Here’s the puzzle. A syscall is the normal way userspace asks the kernel to do something — and a syscall is exactly what we’re trying to avoid. So how can userspace “submit” an operation without calling into the kernel at all? The answer is shared memory: userspace and the kernel literally read and write the same physical pages, so handing over a piece of work is just a memory store, not a mode switch.
The setup: one fd, three mmap’d regions
io_uring_setup(2) is the one syscall that bootstraps everything. It returns a file descriptor, and then userspace mmaps that fd at a few fixed magic offsets to map the shared regions into its own address space (include/uapi/linux/io_uring.h):
#define IORING_OFF_SQ_RING 0ULL /* :551 — the SQ ring header + index array */
#define IORING_OFF_CQ_RING 0x8000000ULL /* :552 — the CQ ring + CQE array */
#define IORING_OFF_SQES 0x10000000ULL /* :553 — the SQE array itself */
Three regions, one shared mapping each:
- The SQ ring — a small header plus an array of indices into the SQE array.
- The CQE array — where completions land (the CQ ring).
- The SQE array — the actual Submission Queue Entries (the op descriptions: opcode, fd, buffer, length, …).
After the mmap, those pages are visible to both sides. When userspace fills in an SQE, the kernel sees the bytes with no copy. When the kernel writes a CQE, userspace sees it with no copy. liburing’s io_uring_queue_init does the io_uring_setup + three mmaps for you, which is why the basic API below looks so innocent.
How does userspace know where the head/tail fields live inside those mapped pages? io_uring_setup fills in a struct io_uring_params (:614) and copies it back. Two sub-structs carry the byte offsets of every field within the rings:
struct io_sqring_offsets { /* :561 — head, tail, ring_mask, ring_entries, ... */ };
struct io_cqring_offsets { /* :580 — head, tail, ring_mask, ring_entries, ... */ };
/* and inside io_uring_params: */
struct io_sqring_offsets sq_off; /* :623 */
struct io_cqring_offsets cq_off; /* :624 */
So userspace maps the regions, then uses sq_off.head, sq_off.tail, etc. to find the indices inside the shared pages. The kernel side of all this is io_uring_setup (io_uring/io_uring.c:3111) and its syscall entry SYSCALL_DEFINE2(io_uring_setup) (:3150), which allocate the rings and populate those offsets.
The circular buffer: head, tail, and why no lock is needed
Each ring is a single-producer / single-consumer (SPSC) circular buffer, governed by two free-running 32-bit counters:
- a tail, advanced by the producer when it adds an entry, and
- a head, advanced by the consumer when it removes one.
Index into the array with counter & ring_mask (the mask is ring_entries - 1, so the size is always a power of two and the wrap is a cheap AND). The gap between head and tail is the pending work. Empty when head == tail; full when the gap equals the ring size.
The crucial property: only one side ever writes each counter. For the SQ, userspace owns the tail and the kernel owns the head. For the CQ, the kernel owns the tail and userspace owns the head. Because no counter has two writers, you never need a lock — only a memory barrier so the other side sees your index update after it sees the entry you wrote. That barrier is the only subtlety, and liburing hides it inside io_uring_get_sqe / io_uring_cqe_seen. The magic is the shared SPSC ring; the library is a thin, correct wrapper around it.
Put the two rings together and you get the full producer/consumer picture:
| Ring | Producer (writes entries, bumps tail) | Consumer (reads entries, bumps head) |
|---|---|---|
| SQ (submissions) | userspace — fills SQE, bumps SQ tail | kernel — reads SQE, bumps SQ head |
| CQ (completions) | kernel — writes CQE, bumps CQ tail | userspace — reads CQE, bumps CQ head |
That last cell is what io_uring_cqe_seen() does: it bumps the CQ head to tell the kernel “I’ve consumed this completion; the slot is free again.”

Where the syscall went
Now the thesis makes mechanical sense. When you “submit,” you have already written the SQE into shared memory and bumped the SQ tail — no syscall happened. The only thing the kernel still needs is a notification that new SQEs are present so it goes and drains them. That notification is the single io_uring_enter syscall (SYSCALL_DEFINE6(io_uring_enter), io_uring/io_uring.c:2600), whose submission path calls io_submit_sqes(ctx, to_submit) (:2651, defined at :2026) to walk the SQ and run each op. One io_uring_enter can announce hundreds of SQEs at once — that’s the “one syscall per batch.”
And IORING_SETUP_SQPOLL (include/uapi/linux/io_uring.h:175) removes even that. With SQPOLL the kernel spawns a poll thread that watches the SQ tail itself; when it sees the tail move, it drains the queue without anyone calling io_uring_enter. That is the literal source of “zero syscalls in steady state” — the doorbell rings itself.
So when the rest of today says an op is “submitted,” hold this picture: a store into a shared page and a tail bump. The expensive part — the mode switch — happens at most once per batch, and with SQPOLL not at all.
The basic API
#include <liburing.h>
struct io_uring ring;
io_uring_queue_init(256, &ring, 0);
/* Submit a recv */
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_recv(sqe, sock_fd, buf, sizeof(buf), 0);
io_uring_sqe_set_data(sqe, /* user pointer */);
io_uring_submit(&ring);
/* Wait for completion */
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);
/* cqe->res = number of bytes received (or -errno) */
io_uring_cqe_seen(&ring, cqe);
Read this with the ring picture in mind:
io_uring_get_sqehands you the next free slot in the shared SQE array (it bumps an internal “sqe tail” liburing tracks for you).io_uring_prep_recvfills that SQE: opcodeIORING_OP_RECV, the fd, the buffer, the length.io_uring_submit()publishes the SQE (bumps the SQ tail) and rings the doorbell — oneio_uring_enter, not one syscall per op. It is not copying the SQE into the kernel; the SQE was already in shared memory.io_uring_wait_cqewaits for the kernel to push a CQE onto the CQ ring.io_uring_cqe_seenbumps the CQ head so the kernel can reuse that completion slot.
Remember: io_uring_submit() publishes SQEs and rings the doorbell (one io_uring_enter for the whole batch, or none under SQPOLL) — it does not syscall per op.
What “async” actually means: the three-tier execution ladder
Every op table row below says “Each is async: submit → kernel does the work in the background → CQE posted when done.” That’s true, but “in the background” hides a graceful three-step ladder that’s worth understanding, because it explains why io_uring is cheap when data is ready and only expensive when it isn’t — and it reuses machinery you already met on Day 19.
Think about what “do this recv for me” really involves. The data might already be sitting in the socket buffer, or it might not have arrived yet. A naive design would spin up a thread for every op and let it block — but threads are expensive, and most of the time the data is right there. So io_uring tries the cheapest thing first and only escalates when forced.
Tier 1 — inline, non-blocking try. When io_uring issues a network op, it ORs in MSG_DONTWAIT (the per-call “do not block” flag) if it’s allowed to not block. You can see it in io_send (io_uring/net.c:667) and in the sendmsg path (:568):
if (issue_flags & IO_URING_F_NONBLOCK)
flags |= MSG_DONTWAIT;
If the data is already available, the op completes immediately, inline, and the CQE is posted right there — no background work at all. This is the common case for a busy server, and it costs essentially nothing.
Tier 2 — arm a poll waiter. If the non-blocking try returns -EAGAIN (socket not ready), io_uring does not immediately burn a thread. The send path bails with:
if (ret == -EAGAIN && (issue_flags & IO_URING_F_NONBLOCK))
return -EAGAIN; /* io_uring/net.c:578 */
and the core then calls io_arm_poll_handler(req, issue_flags) (io_uring/poll.c:705). This registers an internal poll waiter on the socket — on the exact same sk_sleep(sk) wait queue that Day 19 showed blocking recv() and epoll hanging waiters on. When the socket becomes readable, the poll callback re-issues the op via task work and posts the CQE. The decision lives in the issue path:
if (io_arm_poll_handler(req, issue_flags) == IO_APOLL_OK) /* io_uring/io_uring.c:1563 */
This is why io_uring didn’t have to invent a new wakeup path: it leans on the kernel’s existing ->poll() + wait-queue infrastructure. The recv that “would block” just becomes a waiter on the socket, costing one small registration instead of a thread.
Tier 3 — punt to an io-wq worker. Only if poll-arming is impossible (the file type doesn’t support ->poll(), reported as IO_APOLL_ABORTED) does io_uring fall back to the heavyweight path: hand the request to a kernel worker-thread pool that’s allowed to block. The fallback is in io_queue_async (io_uring/io_uring.c:1621):
switch (io_arm_poll_handler(req, 0)) { /* :1634 */
...
case IO_APOLL_ABORTED: /* :1638 */
... /* punt: */
}
io_wq_enqueue(tctx->io_wq, &req->work); /* :432 */
io_wq (io_uring/io-wq.c:116) is the bounded/unbounded worker pool that runs ops in a context where blocking is fine. This is the expensive tier — a real thread blocks on your behalf — and it’s why io_uring degrades gracefully rather than falling off a cliff: you only pay for a thread when the op genuinely can’t be done any cheaper.

Keep this ladder in mind for two things below: multishot recv is just tier 2 made permanent (the poll waiter stays armed and re-fires), and the Check question at the end asks you to compare this path against a plain blocking recv().
Network-specific ops
In io_uring/net.c, where the actual io_send, io_recv, etc. functions live.
Basic ops
| Op | Userspace prep | Equivalent syscall |
|---|---|---|
IORING_OP_RECV | io_uring_prep_recv | recv() |
IORING_OP_SEND | io_uring_prep_send | send() |
IORING_OP_RECVMSG | io_uring_prep_recvmsg | recvmsg() (msghdr — supports cmsg, scatter-gather) |
IORING_OP_SENDMSG | io_uring_prep_sendmsg | sendmsg() |
IORING_OP_ACCEPT | io_uring_prep_accept | accept4() |
IORING_OP_CONNECT | io_uring_prep_connect | connect() |
IORING_OP_CLOSE | io_uring_prep_close | close() |
IORING_OP_SHUTDOWN | io_uring_prep_shutdown | shutdown() |
Each is async: submit → kernel does the work in the background (via the three-tier ladder above) → CQE posted when done.
Multishot recv (6.0+)
IORING_RECV_MULTISHOT flag on io_uring_prep_recv: one submission, many CQEs. The kernel keeps the recv “armed” — every time data arrives, a CQE is posted. The recv stays in flight until the socket closes or you cancel it.
io_uring_prep_recv(sqe, sock, buf, sizeof(buf), 0);
sqe->ioprio |= IORING_RECV_MULTISHOT;
io_uring_submit(&ring);
/* Now process CQEs as they come — no more submit per recv */
for (;;) {
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);
/* cqe->res = bytes received this time */
io_uring_cqe_seen(&ring, cqe);
}
Mechanically, multishot is tier 2 of the async ladder made permanent. A normal recv disarms its poll waiter after one completion; a multishot recv leaves the request registered on the socket’s sk_sleep(sk) wait queue and re-fires a CQE — tagged IORING_CQE_F_MORE — every time data arrives, until you cancel it or the socket closes. (IORING_CQE_F_MORE is the kernel saying “this SQE will produce more completions; don’t retire it yet.”)
For high-rate receivers, this eliminates the per-recv submission cost entirely: one armed recv, a stream of CQEs.
Provided buffers (5.19+)
There’s a subtle waste in multishot recv as described so far: each recv still needs a buffer to land data in. If you hand the kernel a fixed buffer per recv, you’re back to per-recv bookkeeping. Provided buffer rings fix this by handing the kernel a whole pool of buffers up front and letting it pick one only when data actually arrives.
What gets registered
A provided buffer ring is a second shared-memory ring, entirely separate from the SQ/CQ. Userspace fills it with descriptors, each naming a buffer’s address, length, and a buffer id (bid) (include/uapi/linux/io_uring.h:857):
struct io_uring_buf {
__u64 addr; /* where the buffer is */
__u32 len; /* how big */
__u16 bid; /* :860 — the buffer id you'll get back */
__u16 resv;
};
struct io_uring_buf_ring { /* :864 — the ring of the above */ };
You register this ring with the IORING_REGISTER_PBUF_RING opcode (:685, value 22) — that’s what liburing’s io_uring_register_buf_ring wraps:
io_uring_register_buf_ring(&ring, ®, 0); /* IORING_REGISTER_PBUF_RING */
You’ve handed the kernel a pool, not a per-op pointer.
How the kernel tells you which buffer it used
When you submit a recv with the IOSQE_BUFFER_SELECT flag (:167, bit at :149), the kernel defers the buffer choice until data is ready, then consumes one descriptor from the pool ring. The recv path imports that selected buffer into the message iterator — iov_iter_ubuf(&kmsg->msg.msg_iter, ITER_DEST, sel.addr, len) (io_uring/net.c:1061), with the longer mapping path at :1158/:1170. The chosen bid is stashed in the request’s buf_index slot (include/linux/io_uring_types.h:726, whose comment literally says “it points to the selected buffer ID”).
But the part that makes the API usable is how you learn which buffer was used. The completion encodes the bid in the upper bits of cqe->flags, shifted by IORING_CQE_BUFFER_SHIFT (:546, value 16), and sets the IORING_CQE_F_BUFFER flag so you know it’s there. Userspace decodes it:
if (cqe->flags & IORING_CQE_F_BUFFER) {
unsigned bid = cqe->flags >> IORING_CQE_BUFFER_SHIFT;
/* the bytes landed in the buffer you registered with this bid */
/* ...process them, then re-publish that descriptor to the ring */
}
Process the bytes, then re-publish that descriptor so the kernel can reuse it. Without decoding the bid, you’d have data but no idea where it landed — which is why this detail is the whole point of the section.

This pairs naturally with multishot recv: one armed recv + a buffer ring means every arriving datagram lands in the next free pool buffer and the CQE carries its bid — a fully submission-free, buffer-assignment-free, steady-state receive.
Zero-copy send (6.0+)
IORING_OP_SEND_ZC / IORING_OP_SENDMSG_ZC. This is the day’s headline op, so it deserves the full mechanism, not a hand-wave.
Why there’s a buffer-lifetime problem at all
A normal send() copies your bytes out of userspace into kernel skb memory. That copy is the reason send() can return immediately and you can scribble over your buffer the next instant — the kernel already has its own copy. Zero-copy send skips that copy: the outgoing skb’s page fragments point directly at your userspace pages (recall the linear-head + page-frags split from Day 1). The kernel pins those pages — locks them in memory so they can’t be swapped or moved — and the NIC DMAs straight out of them.
That’s faster, but it creates a new obligation: your pages must stay valid and unchanged until the NIC has finished DMAing them. If you reused the buffer too early, you’d transmit garbage. So zero-copy send needs a way to tell you “the hardware is done; your pages are free again” — and that is the entire reason for the deferred second CQE.
How the kernel knows the pages are free: ubuf_info and its refcount
The skb carries a shared-info flag, SKBFL_ZEROCOPY_ENABLE (include/linux/skbuff.h:505), and a pointer to a tiny refcounted kernel object, struct ubuf_info (:546):
struct ubuf_info {
const struct ubuf_info_ops *ops;
refcount_t refcnt; /* the page-pin reference count */
u8 flags;
};
struct ubuf_info_msgzc { struct ubuf_info ubuf; ... }; /* :552 */
Every skb that references the pinned pages holds one reference on this ubuf_info. That matters because the TCP stack clones skbs constantly — a retransmit keeps a copy, segmentation splits one skb into many — and each clone that still points at your pages bumps the refcount. (Some of these paths use SKBFL_MANAGED_FRAG_REFS, :524, tested by skb_zcopy_managed() at :1804.)
io_uring wires this up by allocating a notification request whose embedded ubuf_info is the skb’s uarg. The io_uring side is struct io_notif_data { struct file *file; struct ubuf_info uarg; ... } (io_uring/notif.h:13), reached via io_notif_to_data (:30). At prep time it allocates the notif — notif = zc->notif = io_alloc_notif(ctx) inside io_send_zc_prep (io_uring/net.c:1353, prep at :1336) — and the send path hands that uarg to the skb:
kmsg->msg.msg_ubuf = &io_notif_to_data(sr->notif)->uarg; /* io_uring/net.c:1518 */
Now follow the refcount. As the stack finishes with each skb fragment, it drops a reference on the ubuf_info. When the last reference goes away, the completion callback fires (io_uring/notif.c:43):
void io_tx_ubuf_complete(struct sk_buff *skb, struct ubuf_info *uarg, ...)
{
...
if (!refcount_dec_and_test(&uarg->refcnt)) /* :57 */
return;
/* refcount hit zero → post the F_NOTIF CQE */
}
So the IORING_CQE_F_NOTIF CQE is not a timer and not a guess. It is, precisely, “the last skb referencing your pages was freed.” This is the same ubuf_info machinery the TCP stack already uses for plain SO_ZEROCOPY sockets (the SO_ZEROCOPY/MSG_ZEROCOPY path) — io_uring just routes the completion to a CQE instead of the socket error queue. And it’s a third lifetime layer stacked on what Day 1 taught: skb->users counts descriptor references, dataref counts data-buffer references, and now ubuf_info.refcnt counts page-pin references.

The two CQEs
Two CQEs per send:
- Send result (
IORING_CQE_F_MORE): carries the actual send outcome — bytes sent (cqe->res). The data has been handed to the stack, but the NIC may not have DMAed it yet. This is the request’s own final completion:io_sendmsg_zcends withio_req_set_res(req, ret, IORING_CQE_F_MORE); return IOU_COMPLETE;(io_uring/net.c:1556), so the core posts it withres= bytes sent and theF_MOREflag set (defined atinclude/uapi/linux/io_uring.h:539). It is not anio_req_post_cqe()aux CQE. - Notification of completion (
IORING_CQE_F_NOTIF,:541): user pages are no longer in use; the buffer is safe to free or reuse. This is the refcount-hit-zero moment above.
sqe = io_uring_get_sqe(&ring);
io_uring_prep_send_zc(sqe, sock, buf, len, 0, 0);
io_uring_submit(&ring);
/* CQE 1: send started */
io_uring_wait_cqe(&ring, &cqe);
/* CQE 2: buffer no longer in use (e.g., NIC DMA done) */
io_uring_wait_cqe(&ring, &cqe);
Why split them? Because the two facts become true at very different times. The byte count / errno is known as soon as the data is queued to the stack — you want that promptly. But reuse-safety must wait for DMA completion, which can be much later. Decoupling the CQEs lets you learn the result immediately while still being told precisely when the pages are free. Reusing the buffer between CQE 1 and CQE 2 would corrupt the in-flight transmit.
There are no Dumb Questions
Q: Why not just post one CQE after the DMA completes?
A: Because the two facts you care about become known at very different times. The byte count (or errno) is settled the instant the data is queued to the stack — you want to act on it right away. But the DMA-done moment, when your pages are finally free, can be much later. Folding them into one CQE would force you to wait for the slow fact before learning the fast one. Splitting lets you handle the result immediately while still being told precisely when the buffer is safe to reuse.
For large transfers (> ~4 KB), zero-copy send approaches the limits of what the NIC and PCIe bus allow. Userspace processes (databases, HTTP servers serving large files) see significant throughput improvement.
Zero-copy receive (experimental 6.x)
Day 29 mentions net_iov and page pool memory provider — the infrastructure to extend zero-copy semantics to RX. Not as mature as ZC send; check kernel notes.
When to choose which
For most servers, epoll is enough. Battle-tested, simple API, performs well into the millions of QPS for typical web workloads.
io_uring shines when:
- Sustained > 100k ops/sec per thread and you can pay the development complexity.
- You’re already doing batched I/O across multiple kinds (file I/O alongside network).
- You want zero-copy send for large transfers (CDN edges, large file serving).
- You want to use
IORING_SETUP_SQPOLLto skip the syscall path entirely (reduces context switches under heavy load).
io_uring’s complexity tax:
- Bigger API surface (~50 ops, lots of flags).
- Subtle ordering rules (when the SQE order matters; when CQE delivery is guaranteed).
- More failure modes (incorrect ring sizing → drops; misuse of ZC buffers → crashes).
Adoption is real but slower than the engineering benefits suggest. Notable users: ScyllaDB, parts of QEMU for storage I/O, experimental nginx branches, some Kubernetes runtimes for high-volume pod ingest.
Today’s experiment
# Verify io_uring support (grep the file directly — /proc/kallsyms is a
# regular file, so `ls | grep` would only ever match the path string).
# The syscall wrappers show up as __x64_sys_io_uring_enter / __do_sys_io_uring_enter.
grep -q io_uring_enter /proc/kallsyms && echo "io_uring: supported" || cat /proc/version
# Install liburing if not present
sudo apt install liburing-dev liburing2
# Look at the examples (the -dev package installs them under liburing-dev/)
ls /usr/share/doc/liburing-dev/examples/
# A trivial async-accept loop
cat << 'EOF' > /tmp/iour_accept.c
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <liburing.h>
int main() {
int s = socket(AF_INET, SOCK_STREAM, 0);
int one = 1;
setsockopt(s, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one);
struct sockaddr_in a = { AF_INET, htons(7777) };
bind(s, (struct sockaddr*)&a, sizeof a);
listen(s, 64);
struct io_uring ring;
io_uring_queue_init(8, &ring, 0);
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_accept(sqe, s, NULL, NULL, 0);
io_uring_submit(&ring);
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe); /* CQE for the accept */
int conn = cqe->res;
io_uring_cqe_seen(&ring, cqe);
if (conn >= 0) {
/* Reply through io_uring with a ZERO-COPY send so this experiment
* actually exercises the day's headline op (IORING_OP_SEND_ZC).
* io_uring_prep_send_zc(sqe, sockfd, buf, len, msg_flags, zc_flags). */
const char msg[] = "hi via io_uring\n";
sqe = io_uring_get_sqe(&ring);
io_uring_prep_send_zc(sqe, conn, msg, sizeof msg - 1, 0, 0);
io_uring_submit(&ring);
/* CQE 1: send result, carries IORING_CQE_F_MORE (more to come) */
io_uring_wait_cqe(&ring, &cqe);
printf("send res=%d more=%d\n", cqe->res,
!!(cqe->flags & IORING_CQE_F_MORE));
io_uring_cqe_seen(&ring, cqe);
/* CQE 2: notification, carries IORING_CQE_F_NOTIF — buffer is now
* free to reuse (the NIC is done with the pinned pages). */
io_uring_wait_cqe(&ring, &cqe);
printf("notif flag=%d\n", !!(cqe->flags & IORING_CQE_F_NOTIF));
io_uring_cqe_seen(&ring, cqe);
close(conn);
}
io_uring_queue_exit(&ring);
close(s);
return 0;
}
EOF
cc /tmp/iour_accept.c -o /tmp/iour_accept -luring && /tmp/iour_accept &
# Connect:
nc localhost 7777
The server handles exactly one accept (one io_uring_prep_accept + one CQE) and then exits. You should see the single greeting line, after which nc exits because the server closes the connection:
hi via io_uring
If nothing prints, the accept op never completed. The server’s own stdout shows the two-CQE zero-copy pattern from the prose — the send result (with F_MORE) followed by the buffer-free notification (with F_NOTIF, i.e. the moment ubuf_info.refcnt hit zero):
send res=16 more=1
notif flag=1
Cleanup: the server self-exits after one connection. If you start the bpftrace watch first and skip (or fail) the nc step, the backgrounded server stays blocked in io_uring_wait_cqe holding port 7777 — stop it with pkill -f iour_accept.
Watch the kernel side. Trace the ops this program actually submits — the async accept and the zero-copy send — and bound it with an exit() so it does not run forever (the original probes traced io_send/io_recvmsg, ops this workload never issues, so they would stay permanently blank):
sudo bpftrace -e 'fentry:io_accept { @accept = count(); }
fentry:io_sendmsg_zc { @zc = count(); }
interval:s:5 { print(@accept); print(@zc); exit(); }'
After running /tmp/iour_accept and connecting once with nc localhost 7777, expect one of each (connect again for higher counts):
@accept: 1
@zc: 1
Probe names must match the submitted op: a plain recv goes through io_recv, recvmsg through io_recvmsg, send through io_send, and ZC send through io_sendmsg_zc (both IORING_OP_SEND_ZC and SENDMSG_ZC issue through the same function).
What to read in the kernel
-
io_uring/net.c— networking ops. ~1900 lines. Key entries:io_send_setup(line 350),io_sendand friends — the send paths.io_sendmsg_setup(line 396),io_recvmsg.io_send_zc_prep/io_sendmsg_zc— zero-copy paths (bothIORING_OP_SEND_ZCandSENDMSG_ZCissue throughio_sendmsg_zc).- The
io_kiocbstruct holds per-op state — including thebuf_indexslot that carries the selected provided-bufferbid.
-
io_uring/io_uring.c— main entry points:io_uring_setup(line 3111),io_uring_register,io_uring_enter(line 2600). Readio_submit_sqes(line 2026) for the submission walk andio_iopoll_checkfor the polled-IO completion path (the general completion wait isio_cqring_wait, now inio_uring/wait.c). The async ladder lives here too:io_arm_poll_handlerdecision (line 1563),io_queue_async(line 1621), and the io-wq puntio_wq_enqueue(line 432). -
io_uring/poll.c— multishot infrastructure.io_arm_poll_handler(line 705) registers the internal poll waiter on the socket’s wait queue; multishot recv keeps requests in a “ready” state and re-fires CQEs. -
io_uring/notif.c/io_uring/notif.h— the zero-copy notification object.struct io_notif_data(notif.h line 13) embeds theubuf_info uarg;io_tx_ubuf_complete(notif.c line 43) is where the page-pin refcount hits zero and theF_NOTIFCQE is born. -
include/uapi/linux/io_uring.h— UAPI. Operation IDs (IORING_OP_*), flags (IOSQE_*), the mmap offsets (line 551),io_uring_params+ ring offsets (line 614), provided-buffer structs (io_uring_bufline 857), and the CQE flags (F_MOREline 539,F_NOTIFline 541,IORING_CQE_BUFFER_SHIFTline 546). -
include/linux/skbuff.h—struct ubuf_info(line 546) and theSKBFL_*zero-copy flags (line 505) that connect the io_uring notif to the skb page-pin lifetime. -
liburing repo (https://github.com/axboe/liburing) — userspace API. The
examples/directory has annotated code for every common pattern. -
io_uring man pages (
io_uring_setup(2),io_uring_enter(2), and the liburingman/pages) — the primary reference docs; the kernel tree has no networking io_uring.rst. -
External: Jens Axboe’s “io_uring: efficient io” papers/talks; the libuv issue tracker for nuanced behavior comparisons.
Bullet Points
- The ring is shared memory, not a syscall queue.
io_uring_setupreturns an fd; userspacemmaps three regions (SQ ring, SQE array, CQ ring) at fixed offsets. Submitting = writing an SQE into shared pages + bumping a tail index. No copy, no per-op syscall. - Each ring is a lock-free SPSC circular buffer. Producer bumps the tail, consumer bumps the head; only one writer per index ⇒ no lock. SQ: userspace produces, kernel consumes. CQ: kernel produces, userspace consumes (
io_uring_cqe_seenbumps the CQ head). - One syscall per batch (
io_uring_enter) is just a doorbell announcing new SQEs. Zero syscalls in steady state withIORING_SETUP_SQPOLL(a kernel thread watches the SQ tail itself). - Async = a three-tier ladder: (1) inline
MSG_DONTWAITtry — data ready ⇒ instant CQE; (2)-EAGAIN⇒io_arm_poll_handlerhangs a waiter on the samesk_sleep(sk)queue Day 19 uses; (3) can’t poll ⇒ punt to an io-wq worker that’s allowed to block. - Multishot recv (6.0+): tier 2 made permanent — one submission, many CQEs (each tagged
F_MORE) as data arrives. - Provided buffers (5.19+): register a pool ring of
{addr,len,bid}; withIOSQE_BUFFER_SELECTthe kernel picks a buffer on data arrival and returns thebidpacked intocqe->flags >> IORING_CQE_BUFFER_SHIFT. Pairs with multishot for fully submission-free receive. - Zero-copy send (6.0+):
IORING_OP_SEND_ZC. Pins your pages so the NIC DMAs them directly. Two CQEs:F_MORE(send result, posted at enqueue) andF_NOTIF(pages free — fired exactly when the skb’subuf_info.refcnthits zero inio_tx_ubuf_complete). - For most servers, epoll is fine. io_uring is for sustained > 100k ops/sec or batched workloads.
- The complexity tax is real; adoption is slow but growing.
Check question
If you submit IORING_OP_RECV and immediately io_uring_wait_cqe, what’s the difference compared to a blocking recv()?
Click to reveal answer
Answer: Functionally similar — both block until data arrives. The mechanism is different.
For blocking recv(): the syscall enters the kernel and blocks on the socket’s sk_sleep(sk) wait queue; when data arrives, sk->sk_data_ready() (sock_def_readable) is invoked and wakes that queue, and recv returns with the data copied to user.
For IORING_OP_RECV + immediate wait: the op runs the three-tier ladder. The kernel first tries the recv inline with MSG_DONTWAIT; if data is already there, the CQE is posted immediately. If not (-EAGAIN), io_arm_poll_handler registers a poll waiter on the same socket wait queue blocking recv() would have used, and you wait at io_uring_wait_cqe. When data arrives, the poll callback re-issues the recv and posts a CQE, waking you.
For a single recv this is more overhead than blocking recv — you pay the io_uring framework cost (the ring, the poll-arm) without getting the batching benefit. The win is when you have hundreds of operations in flight: one io_uring_enter syscall to submit them all, one to wait for any to complete. epoll would need one syscall per recv() plus one per epoll_wait(). At scale, io_uring’s amortized syscall cost is much lower.
For a server doing 1 op/sec, blocking recv beats io_uring. For one doing 100k ops/sec, io_uring wins. The crossover depends on workload.
Tomorrow
Day 29: recent additions — PSP encryption, drop_monitor improvements, devlink, NETLINK YAML.
Day 29 — Recent additions: PSP, drop_monitor, devlink, NETLINK YAML
Today’s mission: know what’s new in the network stack as of kernel 7.1 — PSP encryption, categorized drop attribution, generic device control, and the YAML-driven netlink toolchain — what each subsystem is for, and where it fits alongside everything you’ve already learned. Along the way we’ll teach the two pieces of plumbing the whole chapter rests on but no earlier day covered: tracepoints (the static instrumentation hooks that make
kfree_skbobservable) and generic netlink (the family layer PSP, devlink, and ethtool all sit on). Total time: ~110 minutes.
This is a survey day. It is less “here is one struct, dissected to the field” and more “here are four recent subsystems, and here is exactly enough of the machinery underneath each that none of them is a black box.” Two of those machines — tracepoints and generic netlink — are load-bearing for the whole chapter, so we teach them properly, intuition first, before the subsystem that leans on them.
Background 1: what a tracepoint actually is
The entire observability story today — perf trace, dropwatch, eBPF watching drops — rests on one mechanism the book has used but never explained: the tracepoint. Before we can say “drop_monitor listens on the skb:kfree_skb tracepoint,” you need to know what that hook is.
The problem: instrument the kernel without paying for it when nobody’s watching
You want to be able to ask “every time the kernel frees a packet, tell me where and why.” The naive way — a printk at the free site — is a disaster: it fires on every single freed skb whether or not anyone is listening, drowning the box. What you want is a hook that is completely free when no one is attached and only does work when a consumer turns it on.
That is a tracepoint: a named, statically-placed hook compiled into the kernel at a fixed source location, costing essentially nothing when off (the call site is patched out to a no-op via a static key — a runtime-patched branch), and dispatching to every registered listener when on.
TRACE_EVENT: declaring the hook and its payload
A tracepoint is declared with the TRACE_EVENT macro, which defines two things: the hook’s argument list (TP_PROTO) and the fixed payload record copied to every listener (TP_STRUCT__entry). Here is the real declaration for the one this chapter cares about (include/trace/events/skb.h:24):
TRACE_EVENT(kfree_skb,
TP_PROTO(struct sk_buff *skb, void *location,
enum skb_drop_reason reason, struct sock *rx_sk), /* skb.h:26 */
TP_STRUCT__entry(
__field(void *, skbaddr) /* skb.h:32 */
__field(void *, location)
__field(void *, rx_sk)
__field(unsigned short, protocol)
__field(enum skb_drop_reason, reason)
),
TP_printk("skbaddr=%p rx_sk=%p protocol=%u location=%pS reason: %s",
__entry->skbaddr, __entry->rx_sk, __entry->protocol,
__entry->location,
__print_symbolic(__entry->reason,
DEFINE_DROP_REASON(FN, FNe))) /* skb.h:47 */
);
Read that payload carefully, because it is the whole reason this chapter exists:
skbaddr— which skb died.location— the kernel call site that freed it, printed as a symbol (%pSturns the raw address intotcp_v4_rcv+0x2a).protocol— the EtherType (recallskb->protocolfrom the Day 2 demux).rx_sk— the receiving socket, if any.reason— the drop-reason enum, rendered to a human string by__print_symbolicoverDEFINE_DROP_REASON.
That reason is a structured field in the payload, not a free-text log string. That single fact is the chapter’s whole thesis: because the reason is an enum that travels in the record, “why was this packet dropped?” becomes machine-aggregatable — you can count drops by reason, the way you’ll do in today’s experiment.
Who fires it: kfree_skb_reason
You met this on Day 1. Every time the kernel frees a packet on the drop path it calls kfree_skb_reason(skb, reason) (include/linux/skbuff.h:1322), which is what trips the kfree_skb tracepoint. And the plain old kfree_skb(skb) you’ve seen for years is now literally just the reason’d version with the reason left blank (skbuff.h:1333):
static inline void kfree_skb(struct sk_buff *skb)
{
kfree_skb_reason(skb, SKB_DROP_REASON_NOT_SPECIFIED); /* skbuff.h:1333 */
}
So a call site that still uses bare kfree_skb does fire the tracepoint — but its reason field is the useless NOT_SPECIFIED. Converting it to kfree_skb_reason(skb, SKB_DROP_REASON_X) costs one argument and turns a “something died here” event into a categorized one (the structured-enum property from Background 1). (Hold that thought — it is exactly today’s Check question.)
Many consumers, one hook
The payoff the chapter keeps asserting — “perf, dropwatch, and BPF all see the same drops” — falls straight out of how tracepoints dispatch. Multiple consumers attach to the same hook independently, and the kernel fans the payload out to all of them:
perf tracesubscribes and prints each event live.- The in-kernel
drop_monitorregisters a probe withregister_trace_kfree_skb(ops->kfree_skb_probe, NULL)(net/core/drop_monitor.c:1164); the registered probe istrace_kfree_skb_hit(drop_monitor.c:485), which dispatches to the shared aggregatortrace_drop_common(drop_monitor.c:216) and forwards events to userspace over netlink — which is what thedropwatchtool reads. - eBPF programs can attach to
tracepoint:skb:kfree_skband run arbitrary aggregation in-kernel.
One kfree_skb_reason call site, three independent subscribers, zero cost when none are attached. That is why this chapter’s observability all comes back to a single hook.

Background 2: generic netlink — the family layer PSP, devlink, and ethtool share
Three of today’s subsystems (PSP config, devlink, and the ethtool the YAML toolchain talks to) speak generic netlink. Day 8 already taught you the netlink substrate; here we teach only the one new layer on top.
Recall from Day 8 (Background 4, the rtnetlink path): netlink is a socket-based control-plane protocol — userspace opens an
AF_NETLINKsocket and exchanges structured messages built from TLV (type-length-value) attributes, with a dump form (list everything) and a do form (one operation). Day 8’sip/ss/tctraffic all rodeNETLINK_ROUTE, a fixed, statically-numbered protocol. We do not re-teach any of that.
Here is the new problem. A netlink socket is opened against one of a small, fixed set of NETLINK_* protocol numbers — NETLINK_ROUTE, NETLINK_NETFILTER, and so on. There are only 32 such slots, and they are a scarce, static resource. If every new subsystem — PSP, devlink, ethtool, DPLL, conntrack-over-netlink — claimed its own NETLINK_* number, the space would have been exhausted years ago.
So modern subsystems do not each grab a protocol number. Instead they register a generic netlink family: a named multiplexed channel living under the single shared protocol NETLINK_GENERIC. The family is identified by a string name — "psp", "devlink", "ethtool" — not a compiled-in number:
/* net/psp/psp-nl-gen.c:129 */
struct genl_family psp_nl_family __ro_after_init = {
.name = PSP_FAMILY_NAME, /* "psp" — include/uapi/linux/psp.h:10 */
/* .version, .ops table, ... */
};
/* net/devlink/netlink.c:398 */
struct genl_family devlink_nl_family __ro_after_init = {
.name = DEVLINK_GENL_NAME, /* "devlink" — include/uapi/linux/devlink.h:18 */
/* ... */
};
(ethtool’s family name is ETHTOOL_GENL_NAME "ethtool", include/uapi/linux/ethtool_netlink_generated.h:10 — that’s the family the working pyynl rings-get dump at the end of this chapter talks to.)
Name → ID resolved at runtime
The family’s numeric ID is allocated dynamically at registration. genl_register_family(struct genl_family *family) (net/netlink/genetlink.c:775, EXPORT_SYMBOL at :840) hands out an ID at boot/module-load time — PSP comes online via exactly this call (net/psp/psp_main.c:377: return genl_register_family(&psp_nl_family);).
So how does a userspace tool find that runtime ID? It asks the one family with a fixed ID — the controller family nlctrl (.id = GENL_ID_CTRL, .name = "nlctrl", genetlink.c:1805-1806). The tool sends nlctrl a “resolve this name” request, gets back the dynamic family ID, and then talks to the subsystem. This runtime name resolution is precisely why pyynl/cli.py takes a --spec and a family name and “just works” with no hand-assigned protocol number.
Why this makes the YAML codegen possible
Look at the struct again: a genl family is just a name, a version, and an ops table. That regularity is what lets the YAML story (next-to-last section) work — a YAML spec describes exactly those fields, so a generator can emit both the kernel-side family (net/.../netlink_gen.c) and the userspace binding from one file. A statically-numbered protocol with hand-rolled message parsing (Day 8’s rtnetlink) couldn’t be generated that mechanically; a genl family can.

PSP — Packet Security Protocol
A datacenter-scale L4 encryption protocol developed by Google, contributed to Linux in 2025 (in-tree by 7.x).
- What: symmetric authenticated encryption applied to L4 payloads inside a UDP encapsulation (
PSP_DEFAULT_UDP_PORT1000); the in-tree Linux socket integration upgrades TCP connections. Per-flow keys, designed for hardware offload (NICs with PSP-aware crypto). - Why: datacenter operators want confidentiality and integrity on internal traffic without IPsec’s complexity (IKE, SA database, kernel SADB) or TLS’s per-connection handshake. PSP is lightweight: per-flow shared secret negotiated out of band, then plain symmetric crypto on every packet.
- When: datacenter East-West traffic between trusted hosts running compatible PSP stacks. Not for internet-facing.
- Where:
net/psp/—psp_main.c(registration),psp_sock.c(socket integration),psp_nl.c(netlink config interface),psp.h(UAPI). - Status: in-tree but new; tooling (
ip psp ...via iproute2) is being established. Practical use is mostly inside Google for now; broader adoption pending.
You already have every prerequisite for PSP from earlier days — PSP is mostly a recombination of things you’ve built:
The UDP encapsulation (recall Day 12). PSP rides UDP destination port 1000 — the same encapsulate-then-demux-by-port pattern you built for VXLAN in Day 12. On TX,
psp_dev_encapsulate(net/psp/psp_main.c:224) callspsp_write_headers, which setsuh->dest = htons(PSP_DEFAULT_UDP_PORT)(psp_main.c:171); on RX,psp_dev_rcvdemuxes onuh->dest == htons(PSP_DEFAULT_UDP_PORT)(psp_main.c:313). No new tunnel mechanism — reuse Day 12’s mental model.
The crypto (recall Day 25). “Symmetric authenticated encryption” and “per-flow shared secret negotiated out of band, then plain symmetric crypto on every packet” is the same AEAD-after-out-of-band-handshake split you saw in kTLS on Day 25 — bulk symmetric crypto in the steady state, key negotiation done elsewhere. The contrast worth one sentence: kTLS keys are per-TCP-connection (a ULP) and aimed at the software/offload boundary, whereas PSP keys are per-flow and designed specifically for hardware crypto offload in the NIC. The per-flow key material lives in a PSP association created by
psp_assoc_create(net/psp/psp_sock.c:47); installing it for offload ispsp_dev_tx_key_add→psd->ops->tx_key_add(psp_sock.c:80), which hands the key down to the NIC.psp_key_size(version)(psp_main.c:149) returns how much key material a given PSP version needs.
So PSP is genuinely new as a protocol, but for a reader who did Day 12 and Day 25 there is no new background to learn — only a new place those two ideas combine, registered as a generic-netlink family (psp, Background 2) and brought online by genl_register_family(&psp_nl_family) at psp_main.c:377.
drop_monitor + kfree_skb_reason
Observability infrastructure for “where do packets get dropped?” — and now (Background 1) you know exactly what it’s built on.
net/core/drop_monitor.c. It registers a probe on the skb:kfree_skb tracepoint (register_trace_kfree_skb, drop_monitor.c:1164) and, in its default summary mode, aggregates the events by call-site location inside the kernel (trace_drop_common, drop_monitor.c:216 — the summary record struct net_dm_drop_point carries only pc + count, no reason field, so trace_kfree_skb_hit passes the reason argument straight through to trace_drop_common without keying on it), forwarding the aggregate to userspace over netlink. The drop reason is carried per-event only in drop_monitor’s optional packet mode (net_dm_packet_trace_kfree_skb_hit stashes cb->reason and emits NET_DM_ATTR_REASON); the live histogram you build later aggregates by reason in userspace (perf trace | awk | sort | uniq), not in the drop_monitor summary path. Combined with kfree_skb_reason (Day 1), that gives you a categorized dropwatch.
The kfree_skb_reason API:
kfree_skb_reason(skb, SKB_DROP_REASON_TCP_INVALID_SEQUENCE);
Replaces the older kfree_skb(skb) (which, recall from Background 1, is now just this with NOT_SPECIFIED). The reason is one of ~125 categories — the core enum in include/net/dropreason-core.h runs up to SKB_DROP_REASON_MAX (dropreason-core.h:613), and subsystems add their own on top:
enum skb_drop_reason { /* include/net/dropreason-core.h:138; values/order abridged */
SKB_DROP_REASON_NOT_SPECIFIED, // :146 legacy callers (what plain kfree_skb emits)
SKB_DROP_REASON_NO_SOCKET, // :154 no listener
SKB_DROP_REASON_PKT_TOO_SMALL, // :173 truncated
SKB_DROP_REASON_TCP_CSUM, // bad TCP checksum
SKB_DROP_REASON_SOCKET_FILTER, // BPF socket filter dropped
SKB_DROP_REASON_UDP_CSUM,
SKB_DROP_REASON_NETFILTER_DROP, // iptables/nftables rule
SKB_DROP_REASON_TC_INGRESS,
/* ... up to SKB_DROP_REASON_MAX at :613, plus per-subsystem reasons ... */
};
The names are real; the order/values are abridged for illustration. The list is generated from the DEFINE_DROP_REASON table — the same table TP_printk uses (Background 1) to render reason into a string.
Why the change: old kfree_skb gave no signal beyond “a packet died here.” With reasons, dropwatch tells you SOCKET_FILTER (a BPF filter dropped) vs NETFILTER_DROP (a firewall rule did) vs IP_INHDR (the packet was malformed). Differentiating those is the difference between “fix my firewall rules” and “fix my broken sender” — and the structured-enum property from Background 1 is what makes those categories countable in the histogram you’re about to build.
Inspect drops:
An idle box drops almost nothing, so provoke some NO_SOCKET drops first. In another terminal, hit a closed port a few times — each attempt is dropped with no listener:
for i in $(seq 1 50); do curl -s --max-time 1 http://localhost:1 >/dev/null; done
While that runs, watch the live event stream. perf trace is one of the tracepoint subscribers from Background 1 — it attaches to skb:kfree_skb and prints each payload record as it fires:
# Live event stream with reasons (timeout so it terminates cleanly)
sudo timeout 10 perf trace --no-syscalls -e skb:kfree_skb 2>&1 | head -50
Each line names the call site that freed the skb plus the reason category — these are the location and reason fields of the payload record we read in Background 1:
0.104 curl/506057 skb:kfree_skb(skbaddr: 0xffff..., location: 0xffff..., rx_sk: 0xffff..., protocol: 2048, reason: SKB_DROP_REASON_NO_SOCKET)
dropwatch is the second subscriber from Background 1 — it reads the drop_monitor netlink stream and aggregates the same tracepoint by call-site location (and, in packet mode, surfaces the per-event reason). It ships in its own dropwatch package (apt install dropwatch / dnf install dropwatch) — skip this block if it isn’t installed:
sudo dropwatch -l kw # 'kw' = kallsyms based
That drops you at dropwatch’s interactive prompt (dropwatch>); type start, generate drops, then stop. The parenthesized reason below appears only when dropwatch is run in packet mode — the -l kw summary mode shown here emits call-site location + count only (Background 1’s net_dm_drop_point), so a real summary line carries no reason string:
dropwatch> start
1 drops at tcp_v4_rcv+0x2a (SKB_DROP_REASON_NO_SOCKET) # reason shown only in packet mode
dropwatch> stop
To aggregate reasons over a window, bound perf trace with timeout before the pipe — perf trace streams forever, and sort/uniq only print after the input stream ends, so they need a clean EOF:
# Aggregate by reason
sudo timeout 10 perf trace --no-syscalls -e skb:kfree_skb 2>&1 | awk '{print $NF}' | sort | uniq -c | sort -rn | head
With the closed-port loop running, one category dominates the histogram. Which one is path- and environment-dependent: the loopback RST path may yield SKB_DROP_REASON_NO_SOCKET, but you’ll also commonly see SKB_DROP_REASON_QUEUE_PURGE or SKB_DROP_REASON_NOT_SPECIFIED depending on how the closed-port reset is handled:
160 SKB_DROP_REASON_NO_SOCKET)
That histogram is only possible because reason is a structured enum field, not a log message — the mechanism Background 1 spelled out.
Adopting kfree_skb_reason is an ongoing kernel project. Many call sites still use plain kfree_skb; new code is expected to use _reason.
devlink — generic device control
net/devlink/. A netlink-based generic interface for device-level configuration that doesn’t fit ethtool. Concretely (Background 2) it’s the generic-netlink family named "devlink" (net/devlink/netlink.c:398) — which is why a brand-new device knob doesn’t need its own NETLINK_* number.
What ethtool does: per-NIC counters, ring sizes, offload flags. It’s been growing for years and getting sloppy at the edges.
What devlink does: more abstract device knobs.
- SR-IOV management: configure virtual functions, switchdev mode, port representors.
- Health reporting: hardware/firmware telemetry, recovery actions.
- Resource control: device-internal table sizes (bridge FDB, IPv4 routes, ACLs).
- DPLL (Digital PLL): time-synchronization for 5G and HFT applications.
- Per-port congestion-control profiles: configure NIC-level CC behavior.
That first bullet packs four terms no earlier day taught. Here’s the one-paragraph demystifier so the bullet stops being four black boxes:
Refresher: SR-IOV, PF/VF, switchdev, port representors
SR-IOV (Single-Root I/O Virtualization) lets one physical NIC present itself as many lightweight PCIe devices: one Physical Function (PF) — the full NIC — plus N Virtual Functions (VFs), each assignable to a VM or container so it gets near-bare-metal NIC access without the host’s software switch sitting on its data path. switchdev mode means the NIC’s built-in hardware switch — the thing that steers traffic between those VFs and the wire — is exposed to Linux so you can program it with ordinary
tc/bridgetooling instead of vendor-proprietary knobs. A port representor is a host-side netdev that stands in for one VF (or one physical port), so the host can attach counters,tcrules, and policy to a VF’s traffic it otherwise couldn’t see. devlink is the channel that enumerates and configures all of this — which is why SR-IOV lives under devlink, not ethtool. (Kernel side:net/devlink/port.cholds the port objects behinddevlink port show, including port-flavour/representor handling; the per-port rate and shared-buffer knobs behind the “per-port congestion-control” and “resource control” bullets live innet/devlink/rate.candnet/devlink/sb.c.)

Tools: devlink dev show, devlink port show, devlink dev info, devlink resource show. The iproute2 package ships the devlink binary.
Run devlink dev show first and copy one of the listed handles into the next two commands — pci/0000:01:00.0 below is a placeholder that almost certainly won’t match your NIC:
devlink dev show
# Substitute a handle from the line above, e.g.:
devlink dev info pci/0000:01:00.0
devlink resource show pci/0000:01:00.0
On virtio-net / hv_netvsc / most cloud-VM NICs devlink dev show prints nothing — those drivers don’t register a devlink instance, so there is no handle to inspect. You need an mlx5 / ice / nfp device (some cloud VMs expose an mlx5 SR-IOV VF) to see real output. Even then resource show often returns Operation not supported — it’s driver-specific (see below).
devlink is driver-specific — each driver opts in by registering its capabilities. mlx5, ice (Intel), nfp (Netronome) have rich devlink support; many older drivers don’t.
NETLINK YAML and libynl
Netlink is the kernel’s preferred control-plane protocol — ip, ss, nft, tc, all of devlink, and most modern subsystems use it (and, as Background 2 showed, the newer ones via generic-netlink families). Adding new netlink ops historically required:
- Define the protocol’s UAPI structs/enums.
- Implement the kernel-side handler.
- Write the userspace parsing/formatting code (often in libnl, iproute2, or a custom binding).
The third step was the bottleneck — every binding had to be written by hand for every new netlink subsystem. So the kernel community now writes netlink protocols as YAML specifications, and tooling auto-generates bindings. This is exactly the regularity Background 2 pointed at: a genl family is just a name + version + ops table, so a YAML file can describe it completely and a generator can emit both ends.
The YAML specs live at Documentation/netlink/specs/:
Documentation/netlink/specs/
├── conntrack.yaml
├── devlink.yaml
├── dpll.yaml
├── ethtool.yaml
├── handshake.yaml
├── nlctrl.yaml # the controller family from Background 2
├── psp.yaml # PSP's spec
└── ...
Each spec describes the protocol’s messages, attributes, types. From these, tools generate:
- C bindings for the kernel side (in
net/.../netlink_gen.c— e.g. devlink’snet/devlink/netlink_gen.c). - Python bindings (
tools/net/ynl/pyynl/). - C library code (
tools/net/ynl/lib/). - Test scaffolding.
The generator is tools/net/ynl/. Adding a new netlink protocol is now: write the YAML, run the generator, implement only the actual logic.
Practical impact: new features land with bindings on day one. Less drift between kernel UAPI and userspace tooling.
Other 2024–2026 highlights
A grab bag of features that aren’t dedicated days but matter:
Resilient nexthop groups (Day 9)
ECMP that survives nexthop changes without re-hashing every flow.
tcx and netkit (Day 17 of eBPF book / Day 23 of network book)
Modern tc-bpf attach with bpf_link lifecycle and link-based multi-program ordering. Replaces classic tc filter add bpf for new code.
bigtcp
TCP segments larger than 64 KB on the local stack (up to ~512 KB, GSO_MAX_SIZE). Useful for very fast NICs (200/400 Gbps) where the per-segment overhead becomes the bottleneck. Configurable via ip link set <dev> gso_max_size <bytes> and matching gro_max_size.
Page Pool memory provider
Improved zero-copy receive (in progress). Lets receivers get packet payloads without copying from kernel page-cache pages. Integrated with io_uring’s zero-copy recv work.
net_iov and skb fragments
Unifies the “skb frags hold pages” model with io_uring’s iovec model. Gives a single representation that both kernel networking and io_uring’s I/O batching can use. Reduces translation overhead. (You met net_iov already on Day 1 — it’s the non-page memory a frag’s netmem_ref can encode.)
Smart NIC offload progress
Per-flow TLS offload (Day 25) is well-established. Inline IPsec offload landed in 2024. Per-flow QoS offload (some NICs support arbitrary classification rules) keeps maturing.
There are no Dumb Questions
Q: If a tracepoint is “zero cost when off,” how does turning it on not require recompiling the kernel?
A: The call site is patched at runtime. A tracepoint compiles to a static key — a branch that the kernel rewrites in place (a no-op when no consumer is attached, a real call when one is). Registering a probe (
register_trace_kfree_skb,perf, or a BPF attach) flips the key on; the last consumer detaching flips it back off. So “off” is genuinely a no-op in the instruction stream, and “on” needs no rebuild — only a live code patch.
Q: Why does PSP get a generic-netlink family instead of its own
NETLINK_*number like rtnetlink?A: Because the 32
NETLINK_*protocol slots are a scarce static resource (Background 2). Every modern subsystem — PSP, devlink, ethtool, DPLL — registers a named generic-netlink family under the single sharedNETLINK_GENERICprotocol instead, getting a dynamically-allocated ID that userspace resolves at runtime by name vianlctrl. That’s also what makes the YAML codegen possible: a family is a uniform name+version+ops record a generator can describe.
Q:
perf trace,dropwatch, and a BPF program are all watching drops at once. Do they interfere?A: No — they’re independent subscribers to the same
skb:kfree_skbtracepoint (Background 1). The kernel fans each fired payload record out to every registered listener.perfprints,dropwatch(via the drop_monitor probe and netlink) aggregates, BPF runs its own program — none of them consumes the event so the others miss it.
Today’s experiment
The drop_monitor section already walked you through the full perf trace histogram (provoke NO_SOCKET drops with the closed-port curl loop, then aggregate by reason) — re-run that histogram now if you skipped it, then move on to the two probes the body only described but never ran hands-on: devlink enumeration and a YAML-generated python tool.
# Probe devlink
which devlink && devlink dev show
Prints one device handle per registered device — or nothing on virtio/cloud NICs that register no devlink instance.
# Look at the YAML netlink specs
cd ~/code/linux # your kernel source tree
ls Documentation/netlink/specs/
Lists the per-protocol .yaml spec files, including devlink.yaml, ethtool.yaml, and psp.yaml.
# Try a YAML-generated python tool. Install the ynl deps first:
pip install -r tools/net/ynl/requirements.txt # or: pip install jsonschema pyyaml
cd tools/net/ynl
# ethtool's spec decodes cleanly against a stock NIC — dump the ring sizes:
python3 ./pyynl/cli.py --spec ../../../Documentation/netlink/specs/ethtool.yaml \
--dump rings-get 2>&1 | head # may need root
This prints a JSON object per interface — the kernel’s ethtool ring config, fetched over netlink with zero hand-written C (the nlctrl name-resolution from Background 2 does the wiring):
[{'header': {'dev-index': 2, 'dev-name': 'eth0'}, 'rx': 9362, 'rx-max': 18139, ...}]
The genuinely new lesson here is two gotchas the body’s YAML section didn’t cover. Op naming: ops are get/rings-get, not dev-get — --do dev-get raises KeyError: 'dev-get'. Use --dump <op> (lists every instance, like devlink dev show) since the --do form needs a specific id. Spec-vs-kernel skew: the YAML specs track mainline, so dumping a spec whose attributes are newer/older than your running driver can raise YnlException: Space '...' has no attribute with value 'N' — e.g. devlink.yaml --dump get against an mlx5 device on this kernel. That’s a spec/kernel version mismatch, not a bug in your invocation; the stabler specs like ethtool.yaml decode cleanly. Without the deps you’ll instead see ModuleNotFoundError: No module named 'jsonschema'.
What to read in the kernel
-
include/trace/events/skb.h— theTRACE_EVENT(kfree_skb)declaration (line 24). ReadTP_PROTO(line 26),TP_STRUCT__entry(line 31), and theTP_printk(line 47) to see the exact payload every subscriber receives. -
net/psp/— PSP. Readpsp_main.cfirst (~380 lines — confirmed 380) for the registration model (genl_register_family(&psp_nl_family)at line 377), thenpsp_sock.cfor the socket-side integration (psp_assoc_createat line 47,psp_dev_tx_key_addat line 80). -
include/net/dropreason-core.h— theenum skb_drop_reasonlist (line 138, up toSKB_DROP_REASON_MAXat line 613). Skim. Tells you what categories of drops dropwatch can report. -
net/core/drop_monitor.c— the drop-monitor implementation. Readtrace_drop_common(line 216) to see how the tracepoint is dispatched to userspace via netlink, andregister_trace_kfree_skb(line 1164) to see the probe attach. -
net/netlink/genetlink.c— generic netlink core.genl_register_family(line 775) for dynamic ID allocation, and the controller familynlctrl(.id = GENL_ID_CTRL, lines 1805-1806) that userspace queries to resolve a family name. -
net/devlink/— devlink core. ~15000 lines across multiple files. Readcore.cfor the registration model,dev.cfor device lifecycle,health.cfor the health-reporter framework, andport.c/rate.c/sb.cfor the SR-IOV port and per-port-resource knobs. -
Documentation/netlink/specs/— the YAML protocol specs. Opendevlink.yamlorethtool.yaml; the structure is self-descriptive. -
tools/net/ynl/— the YAML processing toolchain.pyynl/cli.pyis a runnable example. -
Documentation/networking/devlink/— devlink user-facing docs. Subsystem-specific writeups. -
External: the netdev mailing list (
netdev@vger.kernel.org) is where these things land. Subscribe if you want to track future features in real time.
Bullet Points
- A tracepoint is a named, static, zero-cost-when-off hook declared with
TRACE_EVENT;kfree_skb_reasonfiresskb:kfree_skb, whose payload carriesskbaddr/location/protocol/rx_sk/reason. Thereasonbeing a structured enum (not a log string) is what makes drops machine-aggregatable. - Generic netlink lets PSP/devlink/ethtool register named families under the single
NETLINK_GENERICprotocol (vs. Day 8’s fixedNETLINK_ROUTE); IDs are allocated atgenl_register_familyand resolved by name at runtime vianlctrl. This regularity is what makes the YAML codegen possible. - PSP — Google’s lightweight datacenter L4 encryption. UDP-encapsulated (port 1000, recall Day 12) with per-flow AEAD keys for NIC offload (recall Day 25). In-tree 7.x;
net/psp/. kfree_skb_reason+ drop_monitor — categorized drop attribution over theskb:kfree_skbtracepoint. Replacekfree_skbin new code.- devlink — netlink-based generic device control. Replaces ad-hoc ethtool extensions for SR-IOV (PF/VF/switchdev/representors), DPLL, health reporting, resource control.
- NETLINK YAML + libynl (
tools/net/ynl/) — YAML-driven binding generation. New protocols ship with bindings. - bigtcp — segments > 64 KB locally for very fast NICs.
- Resilient nexthop groups — ECMP without re-hash on nexthop change (Day 9).
- tcx + netkit — modern tc-bpf with link-based lifecycle.
Check question
Why is kfree_skb_reason strictly better than kfree_skb in new code, and what infrastructure depends on it?
Click to reveal answer
Answer: It feeds the drop monitor with a category enum, making “where do my drops come from?” answerable. Plain kfree_skb just disposes of the skb; kfree_skb_reason(skb, SKB_DROP_REASON_X) does the same plus emits a tracepoint skb:kfree_skb whose payload includes the reason (Background 1). Tools that depend on this:
dropwatchaggregates drops by category and source location, reporting top contributors over time (via the drop_monitor probe → netlink).perf trace --no-syscalls -e skb:kfree_skbprints each drop with the category in real time.- eBPF programs can attach to the tracepoint (e.g.,
tracepoint:skb:kfree_skb) and apply custom aggregation.
All three are independent subscribers to the same hook — that’s the tracepoint fan-out from Background 1.
Without _reason, you can still see that a drop happened (the tracepoint fires for plain kfree_skb too, since kfree_skb is literally kfree_skb_reason(skb, NOT_SPECIFIED)) but you can’t tell why — and the most common reason of all becomes SKB_DROP_REASON_NOT_SPECIFIED, which is useless for diagnosis.
In a kernel with full _reason adoption, you can do meaningful “why are my packets being dropped?” analysis without strace, ftrace, or guesswork. The conversion of every legacy kfree_skb call site is an ongoing project; new code is required to use the reason’d variant.
Tomorrow
Day 30: capstone — pick a real packet and trace it end to end through every kernel layer you’ve learned.
Day 30 — Capstone: trace one packet end to end
Mission: pick one real packet from your system. Trace it through every kernel layer you’ve learned over the last 29 days. Write up what you saw, with kernel function names and rough timings. Along the way you’ll learn the two last pieces of background the capstone leans on — how to read raw
trace-cmdoutput, and the per-CPU “backlog” device that loopback’s receive side runs on. ~4–5 hours.
Why this exercise
Twenty-nine days have given you a vocabulary: sk_buff, NAPI, qdisc, fib_lookup, conntrack, netfilter hook, sk_prot, congestion control, retransmit timer, MPTCP, kTLS. Each in isolation. The capstone is to see them cooperate on one real, observable packet.
When you can describe a single ping or HTTP request as a sequence of kernel operations with names, you’ve internalized the model. That’s the goal.
There are two small bits of machinery you’ve leaned on implicitly but never had to read directly, and the capstone is the first place you stare at them up close:
- Raw
trace-cmd reportoutput. Days 2 and 3 showed you the symbolic call chain (netif_receive_skb → __netif_receive_skb_one_core → ip_rcv). Today you’ll paste literal multi-column trace lines and pull a call tree and a duration out of them. We decode that format once, below. - The per-CPU backlog (software NAPI) device. Day 2 taught the hardware NAPI path — a driver’s
->polldraining an RX ring. Loopback has no ring and no driver; its receive side runs on a second, software NAPI instance per CPU called the backlog. That’s why the experiment graphs-g tcp_v4_rcvinstead of-g netif_receive_skb, and we teach exactly why below.
Both are short. Then you trace your packet.
The exercise
Pick a packet — your choice of:
- A TCP request/response over a real network:
curl https://example.com, watching the SYN go out and the response come back. - A simple ICMP exchange:
ping -c 1 8.8.8.8. - A UDP service interaction:
dig @8.8.8.8 example.com. - A bridged packet: traffic between two namespaces over a Linux bridge.
- A NAT’d packet: outgoing traffic via a masquerade rule, tracking conntrack state.
Trace it through, identify each kernel layer it traverses, and write up the journey.

Tools you’ll use
trace-cmd record -p function_graph: full function-call tree.perf trace: event-level visibility across syscalls and tracepoints.bpftraceone-liners: targeted measurements for specific functions.tcpdump: wire-level view (what actually went out vs what kernel state thinks).tsharkworks too if installed (apt install tshark), buttcpdumpalone is sufficient here.ss -tipsm: socket-level state at any moment./proc/net/*and/proc/sys/net/*: kernel state and tunables.bpftool: BPF program inspection if you’ve got any attached.
Reading raw function_graph output
For 29 days you’ve read the symbolic chain — netif_receive_skb → __netif_receive_skb_one_core → ip_rcv — drawn for you with arrows. The capstone’s deliverable asks you to produce that chain yourself from a literal trace-cmd report, plus a total elapsed time. So before anything else, decode one real line.
Why function_graph can draw a tree at all
There are two relevant ftrace tracers. The plain function tracer probes each function only on entry — it can tell you a function ran, but not when it returned or how long it took. function_graph “traces on both entry and exit of the functions” and “then provides the ability to draw a graph of function calls similar to C code source” (Documentation/trace/ftrace.rst ~line 828). Because it sees both ends, it “calculates the timings of when the function starts and returns internally.”
That is the whole reason Day 2’s experiment (and today’s) uses -p function_graph: entry and exit means the tracer can open a { brace when a function is called and close the matching } when it returns, and the indentation depth equals the call-stack depth. A nested { ... } literally is “this function called that one.” That nesting is what you’ll transcribe into your tcp_sendmsg → tcp_sendmsg_locked → ... arrow chain.
Decoding one line, column by column
Here is one real line from the send side (we’ll meet the whole block again below):
nc-506899 [001] ...1. 245526.239429: funcgraph_entry: | tcp_sendmsg() {
Reading left to right — and the column legend is right there in the kernel docs (Documentation/trace/ftrace.rst:961-964):
nc-506899— the process name and PID that was running when this line was recorded. Here, ourncgenerator, PID 506899.[001]— the CPU number, in brackets. This event happened on CPU 1....1.— the four context-flag positions (ftrace.rst:1063-1083). Decode them one at a time:- position 1 — irqs-off:
dif interrupts are disabled,.otherwise. - position 2 — need-resched:
N/n/.etc. — whether the scheduler wants to switch tasks. - position 3 — hardirq/softirq:
h= a hard IRQ is running,s= a soft IRQ is running,.= normal (process) context. - position 4 — preempt-depth: the digit is the level of
preempt_disablenesting (here1). - The trailing
.is the delay marker column.
- position 1 — irqs-off:
245526.239429— the timestamp in seconds.microseconds since boot (six fractional digits, astrace-cmd reportrenders it; ftrace’s underlying clock is nanosecond-resolution but the report rounds it to microseconds). This is what you subtract to get durations.funcgraph_entry:— this line is a function entry (the other kind isfuncgraph_exit:).| tcp_sendmsg() {— the depth-indented function, with a{opening a frame. The matching}appears later on afuncgraph_exit:line at the same indentation.
The position-3 flag is the one to care about for packet tracing. . (normal context) means the function ran in process context — the send side, running off a syscall like send(). s (soft irq running) means softirq context — the receive side, running off NAPI / net_rx_action. So just by glancing at the third flag column you can tell whether you’re looking at the transmit half or the receive half of your flow. That’s the send-vs-receive distinction this whole chapter draws, visible in one character.

Computing the “total time elapsed” the deliverable asks for
The walk-through deliverable demands “total time elapsed (the displayed timestamps are seconds.microseconds; you can compute this).” Here’s the arithmetic, on the pasted example. Each frame’s duration is the timestamp of its closing funcgraph_exit: minus the timestamp of its opening funcgraph_entry:. For the whole tcp_sendmsg() subtree, find the first tcp_sendmsg() { entry timestamp and the matching outer } exit timestamp:
funcgraph_entry: 245526.239429 tcp_sendmsg() { <- first entry
...
funcgraph_exit: 245526.239XYZ } <- matching outer exit
Subtract: 239XYZ − 239429 microseconds is your total (the displayed timestamps are sec.usec, so the difference is in microseconds — don’t mislabel it ns). You don’t even have to do it by hand — when funcgraph-tail/duration is enabled (Day 2 and Day 3 both record with -O funcgraph-tail), trace-cmd report prints a duration column on each funcgraph_exit line (also in us units), so you can read the elapsed time straight off the outer }.
One trap to expect (don’t think your trace is broken)
Some frames you “expect” won’t appear, for two reasons you already met in Day 2:
- Inlined wrappers vanish.
ip_rcv_finishis inlined, so the trace showsip_rcv_finish_coreinstead;deliver_skbis inlined into__netif_receive_skb_coreand never appears as its own node. - Compiler suffixes rename symbols. You’ll see
__netif_receive_skb_core.constprop.0or names with.isra.N— same function, decorated by the compiler.
This is the same caveat Day 2 spelled out — we don’t re-explain it, just remember it so a missing or renamed frame doesn’t make you think your trace.dat is wrong.
The backlog device: how loopback’s receive side works
Day 2 taught the hardware RX path in full: a NIC raises an IRQ, the driver’s top half calls napi_schedule, net_rx_action walks the per-CPU poll_list, and each NIC queue’s ->poll (e.g. e1000_clean, weight 64) drains the RX ring, building skbs and feeding them to GRO. Modern GRO then flushes them through the batched list path: gro_normal_list() (include/net/gro.h:519) → netif_receive_skb_list_internal() (net/core/dev.c:6406) → __netif_receive_skb_list() → __netif_receive_skb_list_core(), which calls the per-skb core. (Recall the RX descriptor ring + DMA from Day 1.)
Loopback has none of that. There is no NIC, no IRQ, no DMA ring, no driver poll routine. So how does a packet you nc localhost ever “arrive”? Through a second, software NAPI instance that exists per CPU — the backlog.
One backlog NAPI per CPU, hanging off softnet_data
Every CPU’s struct softnet_data carries its own embedded backlog NAPI plus the two queues that feed it (include/linux/netdevice.h:3551):
struct softnet_data {
struct sk_buff_head process_queue; /* :3553 drained by the poll */
/* ... */
struct sk_buff_head input_pkt_queue; /* :3597 where new skbs land */
struct napi_struct backlog; /* :3599 the software NAPI */
};
At boot, each CPU’s backlog is wired to a poll routine called process_backlog (net/core/dev.c:13256):
sd->backlog.poll = process_backlog;
So the backlog is a NAPI device — it sits on the same per-CPU poll_list, gets polled by the same net_rx_action, under the same budgets — but it is a degenerate one: there’s no hardware ring to drain, just a software queue of skbs that something netif_rx’d.
Following the loopback TX→RX hop concretely
When you send to localhost, the loopback device’s transmit routine runs and immediately hands the skb to the receive side of the same CPU. Trace it:
loopback_xmit()(drivers/net/loopback.c:70) doesskb->protocol = eth_type_trans(skb, dev)and then, at line ~89,if (likely(__netif_rx(skb) == NET_RX_SUCCESS)). There’s the hop: TX calls straight into the RX entry point.__netif_rx()(net/core/dev.c:5732) →netif_rx_internal()(:5692) →enqueue_to_backlog()(:5373).enqueue_to_backlogdoes__skb_queue_tail(&sd->input_pkt_queue, skb)(net/core/dev.c:5405) — parking the skb on this CPU’s backlog input queue — and, if the queue was empty, setsNAPI_STATE_SCHEDonsd->backlogand callsnapi_schedule_rps(sd)to put the backlog on the poll list and raiseNET_RX_SOFTIRQ.- Later,
net_rx_actionpolls the backlog:process_backlog()(net/core/dev.c:6644) splicesinput_pkt_queueintoprocess_queueand, for each skb, calls__netif_receive_skb(skb).
That is the entire loopback receive path: loopback_xmit → __netif_rx → enqueue_to_backlog → sd->input_pkt_queue → process_backlog → __netif_receive_skb. Any non-NAPI or software path — loopback, the veth/tun slow paths, legacy non-NAPI drivers — feeds this same backlog.

Why -g netif_receive_skb comes up empty on loopback
Look closely at step 4. process_backlog calls the internal __netif_receive_skb (two underscores), not the exported netif_receive_skb (one underscore). And here’s the subtler point worth getting right: a real NIC doesn’t reach the exported scalar netif_receive_skb either. Modern NAPI GRO flushes through the batched list path (netif_receive_skb_list_internal → __netif_receive_skb_list → __netif_receive_skb_core), and the exported single-skb netif_receive_skb() (net/core/dev.c:6454) is a legacy entry point used by a few non-GRO callers — notably the software bridge via br_handle_frame_finish — not by NIC GRO.
So -g netif_receive_skb is an unreliable doorway on both paths: loopback enters via __netif_receive_skb, and a real NIC enters via the list path. That is exactly why the experiment graphs -g tcp_v4_rcv instead: it sits below the list-receive, scalar-receive, and backlog paths alike, so it is reliably reached by loopback nc, real curl, and ping traffic. Same packet, dependable doorway.
Tying it back to “what loopback skips”
Because loopback’s RX is the backlog, it has no driver, no hardware IRQ, no real NAPI ring drain, and no GRO coalescing — process_backlog just dequeues and delivers. That is why the worked example’s NIC layers are simply absent on lo.
A worked example: curl http://example.com
Let’s pre-walk what your trace might look like.
Step 1: DNS lookup (UDP)
curl calls getaddrinfo("example.com") → glibc → DNS query.
- Userspace:
socket(AF_INET, SOCK_DGRAM, 0)→sendto(...)to your DNS server’s port 53. - Kernel:
udp_sendmsg(net/ipv4/udp.c:1233) builds an skb, hands to IP. - Outbound:
ip_send_skb→ routing →dev_queue_xmit→ qdisc (fq_codel) → driver → wire. - Wait for response.
- Inbound: NIC RX → NAPI poll → driver allocates skb → GRO →
__netif_receive_skb_core→ip_rcv→ routing →udp_rcv(net/ipv4/udp.c:2588) → 4-tuple lookup → enqueue onsk_receive_queue→ wakerecvfrom. - Userspace:
recvfromreturns the DNS response.
Step 2: TCP connect (SYN)
curl calls socket(AF_INET, SOCK_STREAM) → connect(example.com:80).
- Userspace: connect syscall.
- Kernel:
tcp_v4_connect(net/ipv4/tcp_ipv4.c:221).- Route lookup → fib_lookup →
fib_table_lookup(net/ipv4/fib_trie.c). - Pick source port →
inet_csk_get_port→ ehash insert. - Build SYN segment →
tcp_transmit_skb(net/ipv4/tcp_output.c).
- Route lookup → fib_lookup →
- IP layer:
ip_queue_xmit→ip_local_out→NF_INET_LOCAL_OUTnetfilter hook → conntrack creates NEW entry →dst_output→ip_output→NF_INET_POST_ROUTINGhook → conntrack possibly NATs →ip_finish_output2→ neighbor resolution (ARP if not cached) →dev_queue_xmit→ qdisc → driver. - Sock state:
TCP_SYN_SENT.
Step 3: TCP handshake completion (SYN-ACK + ACK)
Inbound SYN-ACK:
- NIC → NAPI → driver → skb → GRO (probably no coalesce for 1 packet) →
ip_rcv→NF_INET_PRE_ROUTING→ conntrack matches →ip_rcv_finish→ routing →ip_local_deliver→NF_INET_LOCAL_INhook →tcp_v4_rcv(net/ipv4/tcp_ipv4.c:2068) → ehash lookup finds our sock in SYN_SENT →tcp_rcv_state_process(net/ipv4/tcp_input.c:7119) sees SYN+ACK → callstcp_set_state(sk, TCP_ESTABLISHED)and queues outgoing ACK.
Outbound ACK: same path as the SYN, just smaller and through a now-EST sock.
Step 4: HTTP request (TCP send)
curl calls send(fd, "GET / HTTP/1.1\r\n...", n, 0).
tcp_sendmsg(net/ipv4/tcp.c:1447) →tcp_sendmsg_locked→ copy to skb → append tosk_write_queue→tcp_push→tcp_write_xmitdecides to send (cwnd open, snd_wnd open, Nagle satisfied) →tcp_transmit_skb→ IP → … → wire.
Step 5: HTTP response (TCP recv)
Inbound packets arrive: NIC → NAPI → driver → skb → GRO (coalesce up to 64 KB!) → ip_rcv → tcp_v4_rcv → tcp state machine:
- ACKs advance
snd_una, free skbs fromsk_write_queue, possibly grow cwnd via the CC algorithm’scong_avoid. - DATA segments append to
sk_receive_queue, wakerecvmsg.
curl calls recv(fd, buf, n, 0) → tcp_recvmsg → copies from sk_receive_queue to user buffer.
Step 6: TCP close
curl finishes, calls close(fd). tcp_close (net/ipv4/tcp.c:3310) builds FIN, sends it, transitions to TCP_FIN_WAIT_1, waits for peer’s ACK, transitions to TCP_FIN_WAIT_2, waits for peer’s FIN, transitions to TCP_TIME_WAIT. ~60s later: state CLOSED, sock freed.
What you saw
A single web fetch involves: 2 DNS packets (UDP), 7 TCP packets minimum (SYN, SYN-ACK, ACK, request, response, FIN, FIN-ACK), routing lookups, neighbor resolution, GRO coalescing, congestion control, the netfilter PREROUTING/LOCAL_IN/LOCAL_OUT/POSTROUTING hooks ×N, conntrack state, qdisc scheduling. That’s the kernel networking stack in action.
Suggested concrete experiment
# 0. Remove any stale trace.dat. trace-cmd writes it as root, and a leftover
# file from a prior run makes `report` silently read OLD data instead of
# erroring.
sudo rm -f trace.dat
# 1. Set up tracing — records for 8s as a background job in THIS shell
sudo trace-cmd record -p function_graph \
-g tcp_sendmsg \
-g tcp_v4_rcv \
-e net:* \
-e tcp:* \
-e skb:kfree_skb \
sleep 8 &
# 2. Generate one packet exchange in this same shell
nc -l 9999 >/dev/null &
sleep 0.5
echo "test" | nc -q 1 localhost 9999
# 3. Wait for the background trace-cmd recorder (sleep 8) to exit, so
# trace.dat is fully finalized before we read it. `wait` (no args) blocks
# on the recorder; it also reaps the nc listener, which has already exited
# once the client closed the connection.
wait
# 4. Generate the report
sudo trace-cmd report > /tmp/packet_trace.txt
# 5. Walk through the report
less /tmp/packet_trace.txt
# 6. Clean up — trace-cmd dropped a (potentially large) trace.dat here
rm -f trace.dat
pkill -f 'nc -l 9999' 2>/dev/null # usually already gone after the single connection
Why loopback, and what it skips. This experiment uses
nc localhostfor reproducibility — no external network needed. Be aware that loopback is a degenerate path: it uses thenoqueueqdisc (nofq_codel), has no driver/NAPI, no GRO coalescing, no ARP/neighbor resolution, and no routing to a gateway — i.e. most of the layers the worked example above emphasizes. Loopback’s RX is the backlog (see the backlog section above), so-g netif_receive_skbis empty here; we graph-g tcp_v4_rcvinstead. To see the full stack — qdisc, GRO, neighbor, routing, driver — re-run while driving real off-box traffic, e.g.curl -s http://example.com >/dev/nullorping -c1 8.8.8.8, on a real interface.
The send side comes out looking like this (function_graph nesting; CPU/timestamp columns trimmed for width):
nc-506899 [001] ...1. 245526.239429: funcgraph_entry: | tcp_sendmsg() {
nc-506899 [001] ...1. 245526.239432: funcgraph_entry: | tcp_sendmsg_locked() {
nc-506899 [001] ...1. 245526.239452: funcgraph_entry: | tcp_push() {
nc-506899 [001] ...1. 245526.239453: funcgraph_entry: | tcp_write_xmit() {
nc-506899 [001] ...1. 245526.239456: funcgraph_entry: | __tcp_transmit_skb() {
nc-506899 [001] ...1. 245526.239498: funcgraph_entry: | ip_queue_xmit() {
nc-506899 [001] ...2. 245526.239500: funcgraph_entry: | ip_local_out() {
nc-506899 [001] ...2. 245526.239512: funcgraph_entry: | ip_output() {
nc-506899 [001] ...3. 245526.239515: funcgraph_entry: | ip_finish_output2() {
Now you can read every column of that block. The ...1. flags say: irqs on, no resched pending, normal/process context (position 3 is .), preempt-depth 1 — confirming this is the send side running off the send() syscall, exactly as the context-flags section predicted. (Notice the preempt-depth digit climbing 1→2→3 as locks nest deeper toward ip_finish_output2.) The indentation/brace nesting is the call tree: tcp_sendmsg calls tcp_sendmsg_locked calls tcp_push … — which is precisely the arrow chain you’ll write in the deliverable. And the timestamps (239429 → 239515 µs so far) are what you subtract for durations.
The receive side opens with tcp_v4_rcv() { → tcp_inbound_hash(). On the receive lines, expect position-3 of the flags to read s (soft irq running), since tcp_v4_rcv runs out of the backlog poll inside net_rx_action, in softirq context — the visible counterpart to the send side’s .. The indented nesting above is the raw trace-cmd report format — it maps onto the arrow chain you’ll write below. Confirm your trace.dat actually contains this tcp_sendmsg/tcp_v4_rcv subtree before you invest 1–2 hours on the write-up.
The report will be long — hundreds to thousands of lines. Pick one TCP segment (the SYN you sent, or the response you received) and follow it through the kernel:
- Find the entry point (e.g.,
tcp_sendmsgfor an outgoing segment,tcp_v4_rcvfor incoming). - Note every function called in sequence (read the brace nesting as call depth).
- For each, look up which file/line it’s at (click any
path:Nreference to open that file/line on GitHub at the pinned kernel tag). - Write the sequence as: “tcp_sendmsg → tcp_sendmsg_locked → ip_queue_xmit → ip_local_out → …”.
Annotated walk-through deliverable
Your final write-up should be ~1–2 pages covering:
- The packet you traced and its purpose.
- Each kernel function it touched, in order.
- For each function: the file/line, what it did, what data structure it touched.
- Total time elapsed (the displayed timestamps are sec.usec, so the difference is in microseconds; you can compute this — subtract the first
funcgraph_entrytimestamp from the matching outerfuncgraph_exit, or read the duration column when-O funcgraph-tailis on). - One surprise you found (“I didn’t realize netfilter ran twice for forwarded traffic”).
That document is the artifact that proves you understood the system. Save it; use it as a reference.
There are no Dumb Questions
Q: My
trace.datis 50,000 lines. How do I isolate just my flow?A: A few quick filters.
trace-cmd report -F 'sched:*'won’t help here, buttrace-cmd report | grep -A40 'tcp_sendmsg() {'jumps straight to your send subtree. To cut by task, record with-P <pid>or filter the report by thenc-<pid>column. To cut by CPU, grep the[001]bracket. Easiest of all: graph a narrow-g tcp_sendmsg -g tcp_v4_rcv(as the experiment does) so the capture only contains those two subtrees in the first place.
Q:
perf tracevstrace-cmd— when do I reach for each?A:
trace-cmd record -p function_graphgives you the in-kernel call tree with timings — the right tool for “what functions did this packet traverse, in order, and how long did each take.”perf traceis closer to anstraceacross syscalls and tracepoints — the right tool for “what syscalls and net events fired, with arguments, across the whole system.” Usetrace-cmdfor the capstone’s function-by-function deliverable; reach forperf tracewhen you want the syscall-and-tracepoint event stream instead.
Q: Why does
tcp_v4_rcvshow up undernet_rx_actionand not under myncPID?A: Receive runs in softirq context, decoupled from the process. The skb is delivered by
net_rx_action(theNET_RX_SOFTIRQhandler) draining the backlog/NAPI poll — that’s whatever task happened to be running on the CPU when the softirq fired, not yournc. That is also why the receive lines carrysin the flags’ third position. Yournconly re-enters the picture later, whenrecvmsgcopies the queued data out in process context.
Q: Some functions from the worked example don’t appear in my trace. Is it incomplete?
A: Probably not.
ip_rcv_finishis inlined (you’ll seeip_rcv_finish_core),deliver_skbis inlined into__netif_receive_skb_core, and the compiler renames others with.constprop.N/.isra.Nsuffixes. Same caveat as Day 2 — a missing or renamed frame is a compiler artifact, not a lost packet.
What to read in the kernel
Documentation/trace/ftrace.rst— thefunction_graphtracer description (~line 828: traces entry and exit, draws the call graph, computes timings internally), the column legend (:961-964), and the per-flag meanings (:1063-1083: irqs-off, need-resched, hardirq/softirqh/s/., preempt-depth).drivers/net/loopback.c—loopback_xmit(line 70); noteeth_type_transthen__netif_rx(skb)at line ~89 — the TX→RX hop with no driver in between.net/core/dev.c— the backlog machinery:__netif_rx(line 5732) →netif_rx_internal(line 5692) →enqueue_to_backlog(line 5373), which does__skb_queue_tail(&sd->input_pkt_queue, skb)(line 5405);process_backlog(line 6644), the backlog poll that calls the internal__netif_receive_skb; andsd->backlog.poll = process_backlog(line 13256).include/linux/netdevice.h—struct softnet_data(line 3551):process_queue(3553),input_pkt_queue(3597), and the embeddedstruct napi_struct backlog(3599) — the per-CPU software-NAPI state.- The seven protocol entry points the worked example cites:
udp_sendmsg(net/ipv4/udp.c:1233),udp_rcv(net/ipv4/udp.c:2588),tcp_v4_connect(net/ipv4/tcp_ipv4.c:221),tcp_v4_rcv(net/ipv4/tcp_ipv4.c:2068),tcp_rcv_state_process(net/ipv4/tcp_input.c:7119),tcp_sendmsg(net/ipv4/tcp.c:1447),tcp_close(net/ipv4/tcp.c:3310).
What’s not covered
In 30 days you skipped:
- rxrpc (AFS-style transport —
net/rxrpc/). - SCTP — interesting alternate transport (
net/sctp/). - DCCP — mostly historical; the in-tree implementation was removed in 6.16, so there’s no longer a
net/dccp/. - RDS, TIPC, Sun RPC — niche transports.
- Bluetooth (
net/bluetooth/) — entirely different stack with its own protocols. - CAN bus (
net/can/) — automotive networking. - NFC (
net/nfc/). - L2TP, PPP, X.25 — legacy/specialized protocols.
- Phonet, QRTR, etc. — single-application stacks.
If your work touches one of these, apply the same methodology — read source, trace with tools, observe — to learn it. The patterns repeat.
Bullet Points
function_graphtraces both entry and exit, so it can draw a call tree ({/}braces, indentation = call depth) and compute per-function timings internally. Plainfunctiononly probes entry.- A raw
trace-cmd reportline is: process-PID, CPU[NNN], the four context-flag positions (irqs-off / need-resched / hardirq-softirq / preempt-depth), the sec.usec timestamp,funcgraph_entry:/funcgraph_exit:, then the depth-indented function. - The third flag is the send/receive tell:
.= process context (send, off a syscall),s= softirq (receive, off NAPI/backlog),h= hardirq. - Total elapsed time = outer
funcgraph_exittimestamp − firstfuncgraph_entrytimestamp (displayed as sec.usec, so the result is microseconds); or read the duration column printed when-O funcgraph-tailis on. - Inlined wrappers (
ip_rcv_finish,deliver_skb) and compiler suffixes (.constprop.N/.isra.N) make some frames vanish or rename — not a broken trace. - The backlog is a per-CPU software NAPI (
sd->backlog, poll =process_backlog) used by loopback and any non-NAPI path. Loopback RX:loopback_xmit → __netif_rx → enqueue_to_backlog → sd->input_pkt_queue → process_backlog → __netif_receive_skb. process_backlogcalls the internal__netif_receive_skb, not the exportednetif_receive_skb— so-g netif_receive_skbis empty on loopback. Graph-g tcp_v4_rcvinstead (works on both paths).- Loopback is degenerate (
noqueueqdisc, no driver/IRQ/NAPI ring, no GRO, no ARP, no routing) precisely because its RX is the backlog. Drive real off-box traffic to exercise the full stack. - The capstone deliverable — packet + purpose, each function in order with file/line and data structure, total time in microseconds, one surprise — is the artifact that proves you understood the system.
After Day 30
Real fluency comes from working on the stack, not just reading it. Pick one of:
- Submit a fix. Look at the netdev mailing list, find a
Reported-byyou can verify, propose a fix. - Write a tool. Build a tracer for something you want to know — a kernel-side per-flow latency histogram, a custom drop-categorizer, a cgroup-aware bandwidth tracker.
- Optimize a workload. Take a real performance problem you face — high latency, drops, packet reordering — and use what you learned to diagnose and fix.
- Read the eBPF book (the companion to this one). It builds on the kernel-networking foundation laid here, showing you how to write the BPF programs that hook into all these places.
You now know the Linux kernel network stack from first principles. That’s a durable skill. Welcome to the community.