The Network Receive Path
The receive (RX) path is the chain of kernel code that turns a frame sitting in a Network Interface Card’s (NIC’s) memory ring into bytes a reader can pull out of a socket with
recv(). Its defining shape is interrupt-then-poll: a hardware interrupt tells the kernel “frames have arrived,” but the real per-packet work is deferred to a software interrupt (softirq) where the driver polls its ring under a budget. That deferred work climbs the stack one layer at a time — driver → NAPI poll → optional Generic Receive Offload (GRO) coalescing →__netif_receive_skb_core(taps, eXpress Data Path (XDP), therx_handler, and protocol dispatch through theptypetables) → the protocol handlerip_rcv, which is where the netfilterPREROUTINGhook fires → the transport layer, which finally drops the data onto astruct sock’s receive queue and wakes any sleeping reader. This note traces that path function by function against Linux 6.12 LTS (net/core/dev.c,net/core/gro.c,net/ipv4/ip_input.c; first verified 2026-06-05, extended and re-verified against source 2026-09-04). The sibling NAPI and Polled Receive zooms into the poll loop itself; The IP Layer takes over where this note hands off toip_rcv. The version pin is the 6.12 long-term-support branch; mainline is in the 7.x series as of 2026-09-04, and anything read at a different tag is dated where it appears.
Scope — and the boundary with the sibling notes
The receive path is large enough that five notes in this vault touch it, and they divide it deliberately:
- This note owns the spine: the end-to-end journey of one frame from Direct Memory Access (DMA) into a ring descriptor, through the hard interrupt, the
NET_RX_SOFTIRQ, the driver poll,__netif_receive_skb_core’s ordering of hooks,ptypedispatch,ip_rcv, and the socket receive queue. It also owns the RX drop taxonomy — what is dropped at each stage and which counter records it.- NAPI and Polled Receive owns the poll loop:
struct napi_struct, the budget and weight rules,napi_complete_done(), threaded NAPI, busy polling, and software interrupt coalescing.- Receive Side Scaling and Packet Steering owns steering: the Toeplitz hash and indirection table,
rps_cpus, the RFS flow tables, accelerated RFS, and Transmit Packet Steering (XPS).- Generic Receive Offload owns coalescing: the merge predicates, the per-protocol
gro_receivecallbacks, and the interaction with segmentation.- XDP Express Data Path owns XDP itself: the verdicts, the eBPF program model,
xdp_buff/xdp_frame, and the redirect map machinery. This note owns only the question of where in the path XDP runs, because the placement is the whole reason XDP is fast.Where the notes must meet — the hard interrupt’s hand-off to the softirq, or the fact that steering hooks in between the poll and the protocol handler — both sides state it and link across. Nothing here re-derives a sibling.
Mental Model
Think of the RX path as a bucket brigade with one deliberate hand-off from the interrupt world to the softirq world. The NIC’s hard interrupt is loud but does almost nothing — it just rings a bell (napi_schedule) and goes quiet. The actual hauling of packets up the stack happens later, in NET_RX_SOFTIRQ context, where the driver’s poll() function drains its receive ring in batches. This decoupling is the entire performance story of modern Linux networking: under a flood, the kernel stops taking one interrupt per packet and instead polls, amortizing the cost of crossing the interrupt boundary across many frames.
flowchart TB WIRE["Frame on the wire"] --> DMA["NIC DMA: write frame<br/>into RX ring descriptor"] DMA --> IRQ["Hard IRQ handler (driver)<br/>mask IRQ, napi_schedule()"] IRQ -->|"raises NET_RX_SOFTIRQ"| SOFTIRQ["net_rx_action()<br/>per-CPU softirq, budget=300"] SOFTIRQ -->|"calls driver"| POLL["driver poll(): drain RX ring<br/>build sk_buff per frame"] POLL --> GRO["napi_gro_receive()<br/>coalesce same-flow segments"] GRO -->|"flush"| NRSKB["netif_receive_skb (core)<br/>__netif_receive_skb_core()"] NRSKB --> TAPS["taps (AF_PACKET / tcpdump)<br/>generic XDP, tc ingress, rx_handler"] TAPS -->|"ptype_base dispatch"| IPRCV["ip_rcv()<br/>NF_INET_PRE_ROUTING hook"] IPRCV --> ROUTE["ip_rcv_finish: route lookup<br/>local deliver vs forward"] ROUTE -->|"local"| L4["ip_local_deliver -> tcp_v4_rcv / udp_rcv"] L4 --> RECVQ["enqueue on sk->sk_receive_queue<br/>sk_data_ready() wakes reader"] RECVQ -->|"recv() returns"| APP["Application"]
The receive path from wire to socket in Linux 6.12. What it shows: the single hand-off point (the dashed transition from the hard IRQ to net_rx_action) splits the path into an “interrupt half” that does almost nothing and a “softirq half” that does all the real work — allocating struct sk_buffs, coalescing them with GRO, running every inspection hook, and dispatching to the protocol handler. The insight to take: every box from __netif_receive_skb_core downward runs in softirq context on one CPU, holds rcu_read_lock(), and is where packet-steering, firewalling, and protocol demultiplexing all live — so latency and CPU cost on RX are dominated by what happens after the poll, not by the interrupt itself.
The DMA Ring and the Descriptor Model
Everything above the driver is software the kernel controls. Everything below it is a contract with a device that runs asynchronously, writes into host memory without asking, and cannot be told to wait. That contract is the receive descriptor ring, and almost every property of the receive path — why there is a budget, why interrupts are masked during a poll, what “ring buffer full” means, why ethtool -G is the fix for one class of drop and useless for another — is a consequence of its shape.
A receive ring is a fixed-size circular array of descriptors, allocated once at ndo_open time with dma_alloc_coherent() so that both the CPU and the device can see it coherently. Each descriptor is a small fixed-size record — 16 bytes is typical — containing, before the frame arrives, the DMA address of a buffer the driver has already allocated and mapped; and, after the frame arrives, a write-back describing what landed there. Two indices track ownership: next_to_use, the slot the driver will next hand to the device, and next_to_clean, the slot the driver will next harvest. The device has its own pair in hardware registers. Nothing is ever allocated or freed in the fast path; the ring is a rendezvous, not a queue of objects.
RX descriptor ring — a circular array in DMA-coherent memory
(Realtek r8169 on this machine: NUM_RX_DESC = 256 entries)
next_to_clean next_to_use
(driver harvests here) (driver refills here)
| |
v v
+------+------+------+------+------+------+------+------+------+
... | DONE | DONE | DONE | DONE | HW | HW | HW | free | free | ...
+------+------+------+------+------+------+------+------+------+
^ ^ ^ ^
| | | |
+-- written back by | +-- owned by the NIC: address posted,
the NIC: length, | waiting for a frame
status, checksum,|
RSS hash, VLAN +-- the boundary the poll loop walks
each entry, before the frame arrives (the "read" format):
+---------------------------+---------------------------+
| pkt_addr (64-bit DMA) | hdr_addr (64-bit DMA) | 16 bytes
+---------------------------+---------------------------+
the same 16 bytes, after the NIC writes back (the "wb" format):
+--------------+--------------+---------------------------+
| pkt_info(16) | hdr_info(16) | rss(32) or ip_id+csum |
+--------------+--------------+---------------------------+
| status_error (32) | length(16) | vlan(16) |
+--------------+--------------+---------------------------+
The receive ring and the two faces of one descriptor, transcribed from union e1000_adv_rx_desc in drivers/net/ethernet/intel/igb/e1000_82575.h, v6.12. ASCII rather than mermaid here because the essential idea is a union — the same bytes read two different ways depending on which side last wrote them — and no mermaid diagram type expresses “these are the same 16 bytes.” What it shows: the driver’s whole job in the poll loop is to walk the boundary between DONE and HW-owned entries, converting write-backs into sk_buffs and posting fresh buffers behind itself. The insight to take: the ring has no back-pressure. If next_to_clean does not advance, the device runs out of driver-owned descriptors and drops frames in hardware, before any kernel code has a chance to see them — which is why that class of loss appears only in ethtool -S and never in any /proc/net counter.
The write-back format is where hardware offloads surface into software. length is what the driver uses to size the skb. status_error carries the per-packet bits that decide skb->ip_summed (see Checksum Offloads), whether this is the end of a multi-descriptor frame, and whether a timestamp was captured. rss is the hash the card computed while steering the frame to this queue — the kernel reuses it as skb->hash rather than recomputing it, which is why hardware Receive Side Scaling (RSS) makes software Receive Packet Steering (RPS) cheaper as well as unnecessary. And vlan is a stripped 802.1Q tag, which is why __netif_receive_skb_core has a VLAN-untag step at all: the tag is not in the frame any more.
Three concrete numbers make the model less abstract, taken from this machine on 2026-09-04 (Linux 7.1.8, Realtek RTL8126 5 Gigabit Ethernet, r8169 driver):
$ ethtool -g enp191s0
Ring parameters for enp191s0:
Pre-set maximums:
RX: 256
TX: 256
Current hardware settings:
RX: 256
TX: 256
256 descriptors, and 256 is also the maximum — ethtool -G cannot grow this ring, because the driver hard-codes #define NUM_RX_DESC 256 and reports it as both rx_max_pending and rx_pending (drivers/net/ethernet/realtek/r8169_main.c, v6.12). At 5 Gbit/s with 1,500-byte frames that is roughly 600 microseconds of buffering — a real constraint, and one that no amount of tuning will relax. Server-class parts are far more generous: Intel’s igb defaults to IGB_DEFAULT_RXD = 256 but permits IGB_MAX_RXD = 4096, sixteen times the depth (drivers/net/ethernet/intel/igb/igb.h, v6.12). The lesson for capacity planning is that “increase the ring size” is advice that silently does nothing on a large fraction of hardware, and ethtool -g is the one-line check for whether it applies.
Refilling is batched, and the reason is a bus round-trip. igb_clean_rx_irq() posts fresh buffers only once it has accumulated IGB_RX_BUFFER_WRITE = 16 consumed slots, with the comment saying exactly why: “return some buffers to hardware, one at a time is too slow.” Each refill ends in a doorbell — a write to a device register telling the NIC how far the driver-owned region now extends — and a Peripheral Component Interconnect Express (PCIe) posted write costs far more than the sixteen descriptor stores it amortises.
Finally, the ordering. A descriptor write-back is not atomic from the CPU’s point of view: the device may make length visible before status_error, or the CPU may speculatively load a field before the write-back lands. The driver therefore reads the length, checks it is nonzero, and only then issues a barrier:
rx_desc = IGB_RX_DESC(rx_ring, rx_ring->next_to_clean);
size = le16_to_cpu(rx_desc->wb.upper.length);
if (!size)
break;
/* This memory barrier is needed to keep us from reading
* any other fields out of the rx_desc until we know the
* descriptor has been written back
*/
dma_rmb();if (!size) break; is the loop’s real termination condition — the ring is drained when the next descriptor has not been written back yet, not when some counter says so. And dma_rmb() is a DMA-specific read barrier that orders subsequent loads after the length load. Without it, a compiler or an out-of-order core could hoist the read of status_error above the read of length and act on a half-written descriptor. This one barrier is the seam between the coherency model of the CPU and the coherency model of a bus-mastering device, and it is the reason receive paths are written by people who read the architecture manual.
Mechanical Walk-through
1. DMA and the hard interrupt — the driver does almost nothing
When a frame arrives, the NIC uses Direct Memory Access (DMA) to copy it straight into a buffer the driver pre-posted in its receive ring (a circular array of descriptors, each pointing at a DMA-mapped page). The NIC then raises a hardware interrupt. The driver’s hard-IRQ handler is deliberately tiny: it acknowledges and masks further RX interrupts for that queue, then calls napi_schedule() (which expands to __napi_schedule). That, in turn, calls ____napi_schedule to add the driver’s struct napi_struct to the current CPU’s softnet_data.poll_list and raises NET_RX_SOFTIRQ (net/core/dev.c). Then the hard IRQ returns. No sk_buff has been allocated yet; no protocol code has run. This is the core of the NAPI (“New API”) design — see NAPI and Polled Receive for the full state machine.
Here is a real one, from the driver on the machine this note was written on (drivers/net/ethernet/realtek/r8169_main.c, v6.12):
static irqreturn_t rtl8169_interrupt(int irq, void *dev_instance)
{
struct rtl8169_private *tp = dev_instance;
u32 status = rtl_get_events(tp);
if ((status & 0xffff) == 0xffff || !(status & tp->irq_mask))
return IRQ_NONE; /* not ours, or the device fell off the bus */
...
rtl_irq_disable(tp); /* (1) mask further RX interrupts */
napi_schedule(&tp->napi); /* (2) queue the poll, raise NET_RX_SOFTIRQ */
out:
rtl_ack_events(tp, status);
return IRQ_HANDLED;
}Intel’s igb is even terser, because its MSI-X vectors auto-mask in hardware: igb_msix_ring() writes an interrupt-throttle value and calls napi_schedule(), and that is the entire handler (drivers/net/ethernet/intel/igb/igb_main.c, v6.12). Either way, the two lines that matter are the same two lines, and their order is mandated. The kernel’s NAPI document spells out both the ordering and the reason:
“Drivers should keep the interrupts masked after scheduling the NAPI instance - until NAPI polling finishes any further interrupts are unnecessary.” — and, in the code sketch it supplies,
mydrv_mask_rxtx_irq(v->idx);carries the comment “schedule after masking to avoid races” (Documentation/networking/napi.rst, v6.12).
Why the interrupt is masked while polling
This is the single most important design decision on the receive path and it is worth being explicit about, because “disable interrupts for performance” sounds like a hack and is not.
An unmasked interrupt during a poll would be pure cost with zero information content. The poll loop is already going to drain every descriptor the device has written back; a mid-poll interrupt announcing “more frames arrived” tells the poll loop nothing it will not discover on its next iteration, while costing a full interrupt entry and exit — pipeline flush, register save, irq_enter(), the handler, irq_exit() — on the order of a microsecond on typical hardware. Worse, under a genuine flood the arrival rate can exceed the service rate, and a system that takes one interrupt per frame spends all its time entering and leaving interrupt context and none of it processing packets. That pathology is receive livelock: throughput does not level off at saturation, it collapses toward zero, and the machine becomes unresponsive because interrupt context outranks every scheduler decision. Masking converts the device from an interrupt source into a polled source for exactly as long as there is work, and back again the moment there is not.
The switch back is napi_complete_done(), and the driver must re-enable the interrupt only after it returns true — never before, or a frame arriving in the window between “I think I am done” and “I have released the instance” would set the hardware interrupt with nobody scheduled to service it, and the queue would stall until the next arrival. The kernel’s rule is a strict ordering, not a convention.
stateDiagram-v2 direction LR [*] --> IRQ_ARMED IRQ_ARMED: <b>Interrupt-armed</b><br/>device IRQ unmasked<br/>NAPI_STATE_SCHED clear<br/><i>ring is empty or idle;<br/>cost is 0 CPU</i> POLLING: <b>Polling</b><br/>device IRQ masked<br/>NAPI_STATE_SCHED set<br/><i>driver poll() drains the ring<br/>in NET_RX_SOFTIRQ context</i> REPOLL: <b>Repoll queued</b><br/>IRQ still masked<br/>instance back on poll_list<br/><i>budget or time limit hit</i> MISSED: <b>Missed</b><br/>NAPI_STATE_MISSED set<br/><i>a schedule attempt arrived<br/>while already scheduled</i> IRQ_ARMED --> POLLING: hard IRQ fires<br/>mask IRQ, then napi_schedule()<br/><b>napi_schedule_prep() sets SCHED,<br/>returns true</b> POLLING --> POLLING: poll() returned == weight<br/><i>"more to do" — stay scheduled</i> POLLING --> REPOLL: net_rx_action budget ≤ 0<br/>or jiffies past time_limit<br/><b>sd->time_squeeze++</b> REPOLL --> POLLING: NET_RX_SOFTIRQ re-raised POLLING --> MISSED: napi_schedule() while SCHED set<br/><i>prep returns false, sets MISSED</i> MISSED --> POLLING: napi_complete_done() sees MISSED,<br/>leaves SCHED set and re-schedules<br/><b>returns false — do NOT unmask</b> POLLING --> IRQ_ARMED: poll() returned < weight<br/>AND napi_complete_done() returned true<br/><b>only now unmask the device IRQ</b>
The interrupt-armed / polling handshake, as a state machine. What it shows: there are exactly two steady states — interrupt-armed and idle, or interrupt-masked and polling — and every transition between them is guarded by an atomic operation on napi->state. The MISSED state exists because napi_schedule() can race with a poll that is finishing; napi_schedule_prep() sets NAPI_STATE_MISSED in the same try_cmpxchg() that would have set NAPI_STATE_SCHED, and napi_complete_done() checks for it and refuses to release the instance (net/core/dev.c, v6.12). The insight to take: the transition back to interrupt-armed is the only one the driver may act on to unmask, and it is conditional on napi_complete_done() returning true — which it does not do if MISSED was set, if busy polling owns the instance, or if software interrupt coalescing has armed a repoll timer instead. A driver that unmasks unconditionally after a short poll has written a latent stall. The time_squeeze transition on the right is the one you can observe from userspace; it is unpacked below.
Note that [[NAPI and Polled Receive]] owns this machine’s finer details — the full napi->state bit list, threaded NAPI, napi_defer_hard_irqs, and busy polling. What matters here is only the shape: a frame’s journey begins with a hardware interrupt whose sole job is to switch the device out of interrupt mode, and everything else happens later, on a different stack, in a different context.
There is a legacy fallback for non-NAPI drivers and for software devices: netif_rx(). It calls netif_rx_internal(), which (optionally consulting Receive Packet Steering, RPS) calls enqueue_to_backlog() to push the skb onto a per-CPU backlog queue (softnet_data.input_pkt_queue) and schedules the special per-CPU backlog NAPI (sd->backlog, whose poll is process_backlog). The kernel comment on netif_rx is blunt: “Modern NIC driver should use NAPI and GRO” (net/core/dev.c, v6.12). So netif_rx is the slow door; real NICs go through napi_gro_receive.
2. net_rx_action — the softirq poll loop with a budget
NET_RX_SOFTIRQ runs net_rx_action() (net/core/dev.c). It splices the CPU’s poll_list into a local list and loops, calling napi_poll(n, &repoll) on each scheduled NAPI instance. Two limits bound how long it runs: a packet budget (net_hotdata.netdev_budget, default 300) decremented by the work each poll reports, and a time limit of netdev_budget_usecs (default 2000 µs) converted to jiffies. When either is exhausted, the loop bumps sd->time_squeeze, re-raises NET_RX_SOFTIRQ, and bails — deferring the rest so the CPU is not monopolized:
if (unlikely(budget <= 0 ||
time_after_eq(jiffies, time_limit))) {
sd->time_squeeze++;
break;
}That time_squeeze counter, visible in /proc/net/softnet_stat (column 3), is the canonical signal that RX softirq processing is being throttled — a classic symptom under heavy load. Each NAPI gets a per-poll weight (NAPI_POLL_WEIGHT = 64, include/linux/netdevice.h): the driver’s poll() must process at most weight packets and return the count. Returning less than the weight means “ring drained” and lets NAPI re-enable the NIC’s interrupt (via napi_complete_done); returning exactly the weight means “more to do,” so the instance stays on the poll list and is polled again. This is the heart of interrupt-vs-poll switching.
Reading the budget from userspace: /proc/net/softnet_stat
time_squeeze is the one piece of NAPI’s internal state that userspace can see directly, and /proc/net/softnet_stat is where it lives. The file is one line of fifteen hexadecimal columns per online CPU, and it has an unhelpful reputation because it is undocumented, unlabelled, and half of it is zeros preserved for backward compatibility. The authoritative definition is the single seq_printf() that produces it (net/core/net-procfs.c, softnet_seq_show(), v6.12):
seq_printf(seq,
"%08x %08x %08x %08x %08x %08x %08x %08x %08x %08x %08x %08x "
"%08x %08x %08x\n",
sd->processed, atomic_read(&sd->dropped),
sd->time_squeeze, 0,
0, 0, 0, 0, /* was fastroute */
0, /* was cpu_collision */
sd->received_rps, flow_limit_count,
input_qlen + process_qlen, (int)seq->index,
input_qlen, process_qlen);| Col | Field | Meaning |
|---|---|---|
| 1 | sd->processed | packets this CPU took off a NAPI or backlog queue |
| 2 | sd->dropped | packets dropped because this CPU’s backlog was full |
| 3 | sd->time_squeeze | times net_rx_action ran out of budget or time with work left |
| 4–9 | 0 | dead: “was fastroute” (removed) |
| 10 | sd->received_rps | packets this CPU received via an RPS inter-processor interrupt |
| 11 | flow_limit_count | packets dropped by RPS flow limit |
| 12 | input_qlen + process_qlen | current total backlog depth |
| 13 | (int)seq->index | the CPU id — added precisely because offline CPUs are skipped |
| 14 | input_qlen | packets waiting on input_pkt_queue |
| 15 | process_qlen | packets already spliced onto process_queue |
The fifteen columns of /proc/net/softnet_stat. What it shows: only five columns carry information; columns 4–9 are hard-coded zeros kept so that decades-old parsers do not break. The insight to take: column 13 is the CPU id and it is not a coincidence that it is buried in the middle — it was added late, and the kernel comment explains why: “the index is the CPU id owing this sd. Since offline CPUs are not displayed, it would be otherwise not trivial for the user-space mapping the data a specific CPU.” Never assume line n is CPU n−1; read column 13.
Real output from this machine — 32 CPUs, a single-queue Realtek NIC, uptime two weeks, read on 2026-09-04 under Linux 7.1.8:
$ head -4 /proc/net/softnet_stat
013dc4af 00000000 00000002 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000
008f77d5 00000000 00000001 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001 00000000 00000000
013e8c88 00000000 00000003 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000002 00000000 00000000
00d1375f 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000003 00000000 00000000
Decoded, and summed across all 32 lines:
cpu=0 processed=20,945,470 dropped=0 time_squeeze=2 received_rps=0 backlog_len=0
cpu=1 processed= 9,496,161 dropped=0 time_squeeze=1 received_rps=0 backlog_len=0
cpu=2 processed=21,178,351 dropped=0 time_squeeze=3 received_rps=0 backlog_len=0
cpu=3 processed=14,022,698 dropped=0 time_squeeze=0 received_rps=0 backlog_len=0
---
totals: processed=312,247,058 dropped=0 time_squeeze=58 received_rps=0
Four things to read out of that, and they are the four questions this file answers.
time_squeeze = 58 in 312 million packets is background noise, not a problem. The counter increments once per net_rx_action invocation that hit its limit, not once per packet, so the right comparison is against the number of softirq invocations, and a ratio this small (roughly one squeeze per five million packets) means the budget was essentially never the binding constraint. The failure signature people actually care about is time_squeeze climbing while you watch — take two readings a minute apart and subtract. A static nonzero value accumulated over a fortnight tells you nothing.
dropped = 0 everywhere is the good answer, and it is a different question from time_squeeze. Column 2 counts only backlog-queue overflows in enqueue_to_backlog(). Because RPS is not configured here (received_rps = 0 on every CPU), the backlog is used only by netif_rx() — the legacy path taken by software devices such as the veth pairs and bridges this machine runs for containers — so a zero here says those virtual devices never overran a CPU. It says nothing whatsoever about the physical NIC, whose drops happen in hardware and appear only in ethtool -S.
received_rps = 0 confirms the steering configuration without having to read sysfs. If RPS were enabled, packets would be arriving on CPUs other than the interrupting one via inter-processor interrupts, and this column would be nonzero on those CPUs. It is the fastest check for “is RPS actually doing anything,” which matters because writing a bitmap into rps_cpus is easy to get wrong.
The processed spread is the load-balance picture. CPU 1 handled 9.5 million and CPU 2 handled 21.2 million — a better than 2:1 imbalance. On a machine with one hardware receive queue this is expected: the queue’s single interrupt has an affinity mask, and only irqbalance moving that affinity over two weeks produced any spread at all. A wide spread with a multi-queue NIC, by contrast, would mean the RSS indirection table or the interrupt affinities are misconfigured.
The relevant sysctls, read on the same machine, are all at their compiled-in defaults — netdev_budget = 300 and netdev_budget_usecs = 2000 match net_hotdata’s initialisers exactly (net/core/hotdata.c, v6.12):
$ sysctl net.core.netdev_budget net.core.netdev_budget_usecs \
net.core.netdev_max_backlog net.core.dev_weight
net.core.netdev_budget = 300
net.core.netdev_budget_usecs = 2000
net.core.netdev_max_backlog = 1000
net.core.dev_weight = 64
3. The driver poll() builds skbs and calls napi_gro_receive
Inside its poll(), the driver walks completed RX descriptors, and for each one allocates a struct sk_buff (typically via napi_build_skb/napi_alloc_skb), sets skb->protocol from the L2 header with eth_type_trans(), and hands the skb up by calling napi_gro_receive(napi, skb) (net/core/gro.c). This is the single most common entry into the generic stack from a modern driver. napi_gro_receive does:
gro_result_t napi_gro_receive(struct napi_struct *napi, struct sk_buff *skb)
{
skb_mark_napi_id(skb, napi);
skb_gro_reset_offset(skb, 0);
ret = napi_skb_finish(napi, skb, dev_gro_receive(napi, skb));
return ret;
}dev_gro_receive() is Generic Receive Offload: it tries to merge this skb into an existing skb on the NAPI’s GRO list if they belong to the same flow (same 4-tuple, consecutive TCP sequence numbers, compatible options). Merging means appending the new payload as a page fragment to the held skb, so the upper stack later processes one large skb instead of many small ones — the per-packet cost of routing, netfilter, and socket lookup is paid once for a coalesced super-segment. GRO is the receive-side inverse of TSO; its own leaf is Generic Receive Offload. When a merged skb is complete (or napi_gro_flush is called at the end of the poll because it is too old), napi_gro_complete → gro_normal_one queues it onto napi->rx_list, and gro_normal_list flushes that batch into netif_receive_skb_list_internal() — the list-based variant of the next stage. If GRO decides not to hold the skb (GRO_NORMAL), napi_skb_finish sends it on immediately the same way.
Where XDP sits: before the sk_buff exists
The step that has not been described yet happens between “walk the descriptor” and “allocate a struct sk_buff”, and its position in that sentence is the entire point of it. Native eXpress Data Path (XDP) runs an extended Berkeley Packet Filter (eBPF) program against the raw DMA buffer, inside the driver’s poll loop, before any sk_buff is allocated. Read the loop body in order and the placement is unmistakable (drivers/net/ethernet/intel/igb/igb_main.c, igb_clean_rx_irq(), v6.12):
/* retrieve a buffer from the ring */
if (!skb) {
unsigned char *hard_start = pktbuf - igb_rx_offset(rx_ring);
unsigned int offset = pkt_offset + igb_rx_offset(rx_ring);
xdp_prepare_buff(&xdp, hard_start, offset, size, true);
xdp_buff_clear_frags_flag(&xdp);
skb = igb_run_xdp(adapter, rx_ring, &xdp); /* (A) XDP runs HERE */
}
if (IS_ERR(skb)) {
/* ... XDP consumed the frame: DROP, TX or REDIRECT ... */
total_packets++;
total_bytes += size;
} else if (skb)
igb_add_rx_frag(rx_ring, rx_buffer, skb, size);
else if (ring_uses_build_skb(rx_ring))
skb = igb_build_skb(rx_ring, rx_buffer, &xdp, timestamp); /* (B) */
else
skb = igb_construct_skb(rx_ring, rx_buffer, &xdp, timestamp);/* (B) */Line (A) precedes both branches at (B). If the program returns anything other than XDP_PASS, igb_run_xdp() returns an ERR_PTR and the IS_ERR(skb) branch is taken — no igb_build_skb(), no igb_construct_skb(), and therefore no sk_buff at all. The frame is dropped, bounced back out, or redirected using only the struct xdp_buff descriptor, which is a handful of pointers on the stack rather than a heap allocation.
flowchart TB DESC["descriptor write-back<br/>read length, dma_rmb()"] --> XDPQ{"XDP program<br/>attached to this ring?"} XDPQ -->|"no"| ALLOC XDPQ -->|"yes"| RUN["bpf_prog_run_xdp(prog, &xdp)<br/><i>operates on the raw DMA page</i>"] RUN --> V{"verdict"} V -->|"XDP_DROP"| DROP["free / recycle the page.<br/><b>no skb was ever allocated</b>"] V -->|"XDP_TX"| TXB["igb_xdp_xmit_back()<br/>straight back out this NIC"] V -->|"XDP_REDIRECT"| RED["xdp_do_redirect()<br/>→ another NIC, a CPUMAP,<br/>or an AF_XDP socket"] V -->|"XDP_ABORTED"| ABRT["trace_xdp_exception()<br/>then treated as DROP"] V -->|"XDP_PASS"| ALLOC ALLOC["<b>igb_build_skb() / igb_construct_skb()</b><br/>— the sk_buff is allocated HERE —"] --> FIELDS["igb_process_skb_fields()<br/>checksum, timestamp, VLAN,<br/>hash, eth_type_trans()"] FIELDS --> GRO2["napi_gro_receive()"] GRO2 --> CORE["__netif_receive_skb_core()<br/><i>taps, generic XDP, tc ingress,<br/>rx_handler, ptype dispatch</i>"] CORE --> UP["ip_rcv → netfilter → routing<br/>→ transport → socket"] DROP -.->|"work avoided"| UP style ALLOC fill:#2d5016,color:#fff style DROP fill:#5c1a1a,color:#fff
Where XDP sits relative to sk_buff allocation. What it shows: the dashed line measures what an XDP_DROP skips — not just the protocol stack, but the allocation and initialisation of the packet buffer itself, plus every hook in __netif_receive_skb_core. The insight to take: “XDP is fast” is usually explained as “it runs early,” which is true but misses the mechanism. What makes it fast is that it makes a verdict on a data structure that already exists — the DMA page the NIC wrote into — and thereby avoids constructing the one that does not. Allocating and initialising a struct sk_buff is a kmem_cache_alloc plus roughly 200 bytes of metadata to zero and fill; at 14.88 million packets per second on a 10-Gigabit link that dominates the cost of dropping a packet. A drop that happens one function later, after the skb exists, is a fundamentally more expensive drop.
Three consequences follow from the placement, and they are the practical content of “XDP is a driver-level hook”:
- XDP cannot see anything the skb provides, because the skb does not exist. No
skb->protocol, no computed flow hash, no conntrack state, no netfilter marks, no VLAN de-tagging. An XDP program parses raw bytes and nothing else. This is not an oversight; it is the same trade that makes it cheap. - XDP runs per hardware queue and per driver, so it must be supported by the driver — and the fallback for drivers that do not support it is generic XDP, which the existing walk-through covers at step 2 of
__netif_receive_skb_core. Generic XDP runs the same program at a much later point, after the skb has been allocated, purely so the program is functionally testable everywhere. It gives you the semantics without the performance, and confusing the two is the most common reason an XDP benchmark disappoints. - A drop at XDP is invisible to almost every counter you would think to check. It is not
rx_dropped, it is notSKB_DROP_REASON_XDP(that reason is for the generic path, “dropped by XDP in input path”, perinclude/net/dropreason-core.h), and it does not appear in/proc/net/snmp. Native-XDP drops are counted only by whatever the program itself increments into a BPF map, plus per-driverethtool -Sstatistics where the driver bothers. Deploying XDP without deploying its own accounting produces a system that silently discards traffic with no evidence.
XDP Express Data Path takes over from here for the program model, the verdicts and the redirect machinery.
4. __netif_receive_skb_core — the grand central station of RX
Everything below the driver funnels into __netif_receive_skb_core() (net/core/dev.c), the function that decides who gets to see the packet and in what order. It runs under rcu_read_lock() and proceeds, in order:
- Timestamp and reset headers. It resets the network header offset (
skb_reset_network_header) so L3 parsing starts at the right place. - Generic XDP. If a generic-XDP program is attached (
generic_xdp_needed_key), it runsdo_xdp_generic(). A non-XDP_PASSverdict drops the packet here, before any tap sees it. (Native XDP runs earlier still, inside the driver before the skb exists — this generic path is the slow fallback.) - VLAN untag for hardware-accelerated VLAN tags.
- Taps (
ptype_all). It walks two lists ofpacket_typeentries registered for all protocols: the globalnet_hotdata.ptype_alland the device’sskb->dev->ptype_all. This is whereAF_PACKETsockets andtcpdump/libpcapreceive their copy — every sniffer hooksptype_all. Thedeliver_skbhelper clones the skb to each tap. - tc ingress and netfilter ingress. If
ingress_needed_keyis set,sch_handle_ingress()runs theclsact/ingressqdisc and any tc-BPF programs (Queueing Disciplines qdisc, Linux eBPF MOC), thennf_ingress()runs the netfilter ingress hook. rx_handler.rcu_dereference(skb->dev->rx_handler)— if the device has a receive handler registered (a Linux bridge, a bond/team, macvlan, or Open vSwitch datapath all install one), it is called. A handler can returnRX_HANDLER_CONSUMED(it took the packet — e.g. the bridge forwarded it),RX_HANDLER_ANOTHER(re-loop, e.g. the packet was redirected to a differentskb->dev),RX_HANDLER_EXACT, orRX_HANDLER_PASS. This is the kernel primitive that makes software bridging work transparently.- Protocol dispatch via
ptype_base. Finally, the exact protocol handler is chosen:
type = skb->protocol;
if (likely(!deliver_exact)) {
deliver_ptype_list_skb(skb, &pt_prev, orig_dev, type,
&ptype_base[ntohs(type) & PTYPE_HASH_MASK]);
}ptype_base is a hash table of packet_type handlers keyed by EtherType. ETH_P_IP (0x0800) resolves to the packet_type whose .func is ip_rcv; ETH_P_IPV6 (0x86DD) resolves to ipv6_rcv; ETH_P_ARP to arp_rcv. Each protocol registers its handler at init with dev_add_pack(). The dispatch defers the last matched handler into *ppt_prev and the one-core wrapper __netif_receive_skb_one_core calls it via an indirect-call-wrapper that the compiler can devirtualize to ip_rcv/ipv6_rcv for speed:
ret = INDIRECT_CALL_INET(pt_prev->func, ipv6_rcv, ip_rcv, skb,
skb->dev, pt_prev, orig_dev);If no handler matches, the skb is freed with reason SKB_DROP_REASON_UNHANDLED_PROTO and rx_dropped is bumped.
The ordering of those seven steps is not arbitrary and is the most consequential thing in this note for anyone debugging a packet that “disappeared.” Drawn out, with what each stage can do to the packet:
flowchart TB IN["skb from napi_gro_receive()<br/>or the backlog"] --> HDR["1. skb_reset_network_header()<br/><i>timestamp; L3 parsing starts here</i>"] HDR --> GXDP{"2. generic_xdp_needed_key ?"} GXDP -->|"program attached"| DOX["do_xdp_generic()<br/><i>the SAME eBPF program as native XDP,<br/>run far too late to be fast</i>"] DOX -->|"not XDP_PASS"| D1(["dropped<br/><b>SKB_DROP_REASON_XDP</b><br/>— before any tap sees it —"]) DOX -->|"XDP_PASS"| VLAN GXDP -->|"none"| VLAN["3. VLAN untag<br/><i>hardware-accelerated 802.1Q tag<br/>moved from the descriptor into the skb</i>"] VLAN --> TAPS["4. <b>ptype_all</b> taps<br/>net_hotdata.ptype_all + dev->ptype_all<br/><i>AF_PACKET, tcpdump, libpcap</i><br/>deliver_skb() <b>clones</b> to each"] TAPS --> TC{"5. ingress_needed_key ?"} TC -->|"yes"| SCH["sch_handle_ingress()<br/>clsact / ingress qdisc, tc-BPF"] SCH -->|"TC_ACT_SHOT"| D2(["dropped<br/><b>SKB_DROP_REASON_TC_INGRESS</b>"]) SCH --> NFI["nf_ingress()<br/>netfilter ingress hook"] NFI --> RXH TC -->|"no"| RXH["6. <b>rx_handler</b><br/>rcu_dereference(dev->rx_handler)<br/><i>bridge, bond/team, macvlan, OVS</i>"] RXH -->|"RX_HANDLER_CONSUMED"| C1(["consumed — e.g. the bridge<br/>forwarded it out another port.<br/><b>never reaches ip_rcv</b>"]) RXH -->|"RX_HANDLER_ANOTHER"| AGAIN["skb->dev was changed;<br/>goto another_round"] AGAIN --> HDR RXH -->|"PASS / EXACT"| PT["7. <b>ptype_base</b> dispatch<br/>hash bucket = ntohs(type) & PTYPE_HASH_MASK"] PT -->|"no handler"| D3(["dropped<br/><b>SKB_DROP_REASON_UNHANDLED_PROTO</b><br/>rx_nohandler++"]) PT -->|"ETH_P_IP / ETH_P_IPV6"| OUT["INDIRECT_CALL_INET(…, ipv6_rcv, ip_rcv, …)"]
The seven stages of __netif_receive_skb_core(), in execution order, with every exit. What it shows: the taps at stage 4 sit before tc ingress, netfilter, the rx_handler and protocol dispatch, but after generic XDP. The insight to take: this ordering is the answer to two questions that come up constantly. First, “tcpdump shows the packet, so why didn’t my application get it?” — because tcpdump attaches at stage 4 and at least four stages that can discard the packet run afterwards. Second, and less well known, “tcpdump shows nothing, so the packet never arrived” — which is false if generic XDP or a native XDP program is attached, since both run before the tap and a drop there is invisible to every capture tool. A capture is evidence about stage 4, and about nothing above or below it.
The whole function runs under a single rcu_read_lock(), which is what makes the rcu_dereference(skb->dev->rx_handler) at stage 6 and the lockless ptype list walks at stages 4 and 7 safe without any per-packet locking — a device can be reconfigured concurrently and the in-flight packet keeps a coherent view. It is also why nothing in this path may sleep.
5. Where RPS/RFS steers — netif_receive_skb_internal
There are two ways into the core function. The list path used by GRO is netif_receive_skb_list_internal; the single-skb public API is netif_receive_skb() → netif_receive_skb_internal(). The latter is where Receive Packet Steering (RPS) and Receive Flow Steering (RFS) intervene:
static int netif_receive_skb_internal(struct sk_buff *skb)
{
...
if (static_branch_unlikely(&rps_needed)) {
int cpu = get_rps_cpu(skb->dev, skb, &rflow);
if (cpu >= 0) {
ret = enqueue_to_backlog(skb, cpu, &rflow->last_qtail);
...
}
}
ret = __netif_receive_skb(skb);
...
}get_rps_cpu() hashes the packet’s flow and, if RPS is configured, returns a different CPU to process it on. The skb is then enqueued on that CPU’s backlog queue (enqueue_to_backlog), which schedules the remote CPU’s backlog NAPI; process_backlog() there eventually calls __netif_receive_skb. This is how a single hardware RX queue’s interrupts can be fanned out across cores in software. The full mechanism — RPS, RFS, and the hardware Receive Side Scaling (RSS) that distributes interrupts across queues at the NIC — is the subject of Receive Side Scaling and Packet Steering. The key point for this note is where it hooks: between the GRO/driver layer and the protocol handler, by redirecting onto another CPU’s backlog.
Three mechanisms compete for that job and they are routinely conflated, so it is worth laying them side by side at the point where each one acts:
flowchart TB WIRE(["frame arrives"]) --> RSSQ{"<b>RSS</b> — in the NIC<br/>Toeplitz hash over the 4-tuple,<br/>low 7 bits index a 128-entry<br/>indirection table"} RSSQ --> Q0["RX queue 0<br/>→ MSI-X vector 0<br/>→ IRQ affinity → CPU a"] RSSQ --> Q1["RX queue 1<br/>→ MSI-X vector 1<br/>→ CPU b"] RSSQ --> QN["RX queue n …"] Q0 --> POLL["hard IRQ → NAPI poll<br/><i>on whichever CPU the IRQ landed</i>"] Q1 --> POLL QN --> POLL POLL --> GRO3["GRO"] GRO3 --> NRSI["netif_receive_skb_internal()"] NRSI --> RPSQ{"<b>rps_needed</b> static key ?"} RPSQ -->|"no"| LOCAL["__netif_receive_skb()<br/><b>on this CPU</b>"] RPSQ -->|"yes"| GET["get_rps_cpu(dev, skb, &rflow)"] GET --> RFSQ{"is an RFS flow table<br/>configured?"} RFSQ -->|"no — plain <b>RPS</b>"| HASH["cpu = rps_cpus[hash % len]<br/><i>hash only: even spread,<br/>ignores where the app runs</i>"] RFSQ -->|"yes — <b>RFS</b>"| TBL["compare rps_sock_flow_table[h]<br/>(CPU the app last called recvmsg on)<br/>against rps_dev_flow_table[h]<br/>(CPU currently processing this flow)"] TBL --> SAFE{"safe to move?<br/>queue head ≥ recorded tail,<br/>or old CPU unset/offline"} SAFE -->|"no"| KEEP["keep the current CPU<br/><i>moving now would reorder packets</i>"] SAFE -->|"yes"| MOVE["steer to the app's CPU<br/><i>cache-warm delivery</i>"] HASH --> ENQ KEEP --> ENQ MOVE --> ENQ["enqueue_to_backlog(skb, cpu, …)<br/>+ IPI to wake that CPU's backlog NAPI"] ENQ -->|"queue full or flow limit"| DROPB(["dropped<br/><b>SKB_DROP_REASON_CPU_BACKLOG</b><br/>sd->dropped++ (softnet_stat col 2)"]) ENQ --> PB["process_backlog() on the target CPU<br/>→ __netif_receive_skb()"] LOCAL --> DONE(["ip_rcv …"]) PB --> DONE
RSS, RPS and RFS, positioned at the exact points where each acts. What it shows: RSS chooses a queue, and therefore an interrupt, and therefore the CPU that runs the poll — it happens in silicon, before the kernel is involved at all. RPS and RFS choose a CPU for protocol processing, in software, after GRO, by moving the packet onto a different CPU’s backlog and sending an inter-processor interrupt. The insight to take: they operate on different resources, so they are complements rather than alternatives, and the kernel’s scaling guide is direct about the consequence: “For a multi-queue system, if RSS is configured so that a hardware receive queue is mapped to each CPU, then RPS is probably redundant and unnecessary” (Documentation/networking/scaling.rst, v6.12). RPS earns its keep precisely when RSS cannot help — one hardware queue, or fewer queues than cores.
The trade-offs are as different as the mechanisms. RSS costs nothing per packet because the hash is computed by hardware that was going to look at the headers anyway, but it is bounded by the number of queues the device has and steers only by hash, with no idea where the consuming thread is running. RPS costs an inter-processor interrupt and a queue hand-off per packet — the scaling guide lists this honestly among RPS’s properties, “it does not increase hardware device interrupt rate (although it does introduce inter-processor interrupts (IPIs))” — but it works on any NIC and can hash over protocols the silicon has never heard of. RFS costs everything RPS costs plus two table lookups, and buys data-cache locality by steering “kernel processing of packets to the CPU where the application thread consuming the packet is running.” Its subtle part is the reordering guard: rps_dev_flow_table records the backlog tail at the moment a flow was last enqueued, and the flow is only allowed to migrate once the old CPU’s queue head has passed that mark — otherwise packets already queued on the old CPU and packets newly queued on the new one would be processed concurrently and delivered out of order. RFS is the only one of the three that has to reason about time, and that is where its complexity lives. The full treatment, including accelerated RFS and the transmit-side counterpart XPS, is in Receive Side Scaling and Packet Steering.
6. ip_rcv — the protocol handler and the PREROUTING hook
ip_rcv() (net/ipv4/ip_input.c) is the registered ptype_base handler for IPv4. It first calls ip_rcv_core(), which validates the IP header — version is 4, header length ≥ 5 words, the IP header checksum via ip_fast_csum, and that skb->len is at least the declared total length — and trims the skb to the true IP length, orphaning any stale socket reference. Then comes the pivotal line:
return NF_HOOK(NFPROTO_IPV4, NF_INET_PRE_ROUTING,
net, NULL, skb, dev, NULL,
ip_rcv_finish);This is where the netfilter PREROUTING hook fires. NF_HOOK runs every registered hook function at the NF_INET_PRE_ROUTING chain point — this is where Destination NAT (DNAT) rewrites a packet’s destination, where connection tracking (conntrack) first sees an inbound flow, and where iptables -t nat -A PREROUTING / nft prerouting rules execute (see The Netfilter Framework and Hooks, Connection Tracking conntrack, Network Address Translation NAT). Only if the hooks return NF_ACCEPT does ip_rcv_finish run. The crucial architectural fact is that PREROUTING runs before the routing decision, so DNAT can change where the packet is routed.
ip_rcv_finish calls ip_rcv_finish_core (which does the FIB route lookup, caching the result in skb_dst) and then dst_input(skb), an indirect call to the route’s input function — ip_local_deliver for packets destined to this host, or ip_forward for packets that must be routed elsewhere. That local-deliver-versus-forward branch is the subject of IP Routing Decision and Forwarding; the L3 mechanics belong to The IP Layer.
7. Local delivery to L4 and the socket receive queue
For a locally destined packet, ip_local_deliver() runs the NF_INET_LOCAL_IN (INPUT) netfilter hook, reassembles IP fragments if needed, then hands the skb to the transport handler registered in the inet_protos array — tcp_v4_rcv for TCP, udp_rcv for UDP. The transport layer performs the socket lookup: it hashes the 4-tuple (source IP, source port, destination IP, destination port) to find the matching struct sock. For TCP, an established connection is found in the ehash; a SYN to a listener is found in the lhash and may go through SYN-cookie / accept-queue logic. Once the owning sock is found, the payload skb is appended to sk->sk_receive_queue (out-of-order data first lands in the out-of-order queue), accounted against the socket’s receive buffer (sk_rmem_alloc vs sk_rcvbuf), and the socket’s data-ready callback sk->sk_data_ready — normally sock_def_readable — is invoked. That wakes any thread blocked in recv() and fires the wait-queue callback that makes the socket readable for epoll. The reader’s subsequent recv() copies bytes out of sk_receive_queue into userspace, and the journey is complete. The transport-layer detail lives in The TCP Protocol in Linux and The UDP Protocol in Linux; the wakeup machinery in Socket Wait Queues and Wakeups.
Because this last stretch is where a packet stops being a packet and becomes bytes in a buffer, it is worth drawing as a timeline rather than a flowchart — the interesting content is who is running when, and in particular the fact that the reader thread and the softirq are two different execution contexts that meet at a queue:
sequenceDiagram autonumber participant NF as netfilter<br/>LOCAL_IN hook participant L4 as tcp_v4_rcv /<br/>udp_rcv participant SK as struct sock participant WQ as sk_wq<br/>(wait queue) participant APP as application thread<br/>blocked in recv() Note over NF,L4: still NET_RX_SOFTIRQ context, on the steering CPU NF->>L4: NF_ACCEPT → inet_protos[IPPROTO_TCP]->handler L4->>L4: __inet_lookup_skb(): hash the 4-tuple<br/>ehash for established, lhash for listeners alt no matching socket L4-->>L4: kfree_skb_reason(SKB_DROP_REASON_NO_SOCKET)<br/>+ send RST / ICMP port-unreachable end L4->>L4: tcp_checksum_complete() — skipped entirely<br/>if the NIC reported the checksum verified L4->>SK: bh_lock_sock_nested() alt sock_owned_by_user(sk) L4->>SK: tcp_add_backlog() → sk_add_backlog()<br/>queued for the lock holder to drain else socket free L4->>SK: tcp_v4_do_rcv() → tcp_rcv_established()<br/>→ skb_queue_tail(&sk->sk_receive_queue) end Note over SK: charge sk->sk_rmem_alloc against sk->sk_rcvbuf;<br/>over the limit → SKB_DROP_REASON_SOCKET_RCVBUFF SK->>WQ: sk->sk_data_ready(sk) [= sock_def_readable] WQ->>APP: if skwq_has_sleeper(wq):<br/>wake_up_interruptible_sync_poll(&wq->wait,<br/>EPOLLIN | EPOLLPRI | EPOLLRDNORM | EPOLLRDBAND) Note over WQ,APP: also fires the epoll callback that makes<br/>the fd readable for an epoll_wait() sleeper Note over APP: — scheduler boundary: process context resumes — APP->>SK: recv() → tcp_recvmsg(): copy out of<br/>sk_receive_queue, then tcp_cleanup_rbuf()<br/>→ may send a window update SK-->>APP: bytes
The final hand-off, from protocol handler to sleeping reader. What it shows: the softirq’s last act is a callback, sk->sk_data_ready, not a copy — the data stays in the socket’s queue and the payload is copied to userspace later, by the reader’s own thread, on whatever CPU the scheduler picks. The insight to take: this is the seam where receive processing stops being accounted to %softirq and starts being accounted to the application, and it is also where the two most common receive-side drops live. SKB_DROP_REASON_SOCKET_RCVBUFF means the application was not reading fast enough — a purely local problem, invisible to ethtool and to softnet_stat. SKB_DROP_REASON_NO_SOCKET means nothing was listening, and unlike almost every other drop on this path it is answered: TCP sends a reset, UDP an ICMP port-unreachable, so the sender learns about it immediately. The sk_add_backlog() branch is the one people forget: if a user thread holds the socket lock when a packet arrives, the softirq does not block — it parks the skb on a per-socket backlog that the lock holder drains on release_sock(), which is why a slow recvmsg() can delay processing of packets that have already been fully received.
The ptype_base table — protocol demultiplexing in detail
The kernel cannot hard-code “if EtherType is IPv4 call ip_rcv” because protocols are pluggable (IPv6, ARP, MPLS, AF_PACKET taps, and even IPv4 itself are all registered modules/subsystems). Instead each protocol calls dev_add_pack() at init with a struct packet_type:
static struct packet_type ip_packet_type __read_mostly = {
.type = cpu_to_be16(ETH_P_IP),
.func = ip_rcv,
.list_func = ip_list_rcv,
};dev_add_pack() files this into one of four lists, and the selection is done entirely by a four-line helper (net/core/dev.c, v6.12):
static inline struct list_head *ptype_head(const struct packet_type *pt)
{
if (pt->type == htons(ETH_P_ALL))
return pt->dev ? &pt->dev->ptype_all : &net_hotdata.ptype_all;
else
return pt->dev ? &pt->dev->ptype_specific :
&ptype_base[ntohs(pt->type) & PTYPE_HASH_MASK];
}Two questions, two bits, four answers: is .type the wildcard, and is .dev set? A wildcard registration with no device goes on the global net_hotdata.ptype_all (a tcpdump -i any); a wildcard registration with a device goes on that device’s own ptype_all (a tcpdump -i eth0, which is why capturing on one interface does not cost anything on the others). A specific EtherType with a device goes on dev->ptype_specific and is reached only via the deliver_exact path; without a device it goes into the global hash.
The wildcard value is ETH_P_ALL, which is 0x0003, not zero (include/uapi/linux/if_ether.h, v6.12, where it is commented “Every packet (be careful!!!)”). This is worth stating precisely because a plausible-sounding “.type == 0 means all protocols” is wrong, and a packet_type registered with .type = 0 would land in ptype_base[0] and receive nothing. The hash itself is small: PTYPE_HASH_SIZE is 16 and the mask is therefore 15, so ptype_base[] is sixteen buckets indexed by the low four bits of the host-order EtherType (include/linux/netdevice.h, v6.12). With ETH_P_IP = 0x0800 and ETH_P_IPV6 = 0x86DD landing in buckets 0 and 13, collisions are irrelevant in practice — there are only a couple of dozen registered EtherTypes on a normal system. At RX time, __netif_receive_skb_core walks ptype_all first (so sniffers see frames before protocol processing, including frames that will later be dropped), then indexes ptype_base by EtherType to find the protocol owner. The list_func (ip_list_rcv) is the batched variant invoked when GRO delivered a list of skbs, amortizing the route lookup across the batch. This table is the concrete mechanism behind the abstract phrase “the stack demultiplexes by protocol.”
flowchart TB REG["dev_add_pack(&pt)<br/>called at protocol init"] --> Q{"pt.type == htons(ETH_P_ALL) ?<br/><i>ETH_P_ALL is <b>0x0003</b>, not 0</i>"} Q -->|"yes, pt.dev == NULL"| ALLG["<b>net_hotdata.ptype_all</b><br/><i>every frame on every device</i>"] Q -->|"yes, pt.dev set"| ALLD["<b>dev->ptype_all</b><br/><i>every frame on ONE device</i>"] Q -->|"no, pt.dev set"| SPEC["<b>dev->ptype_specific</b><br/><i>this protocol, this device only</i>"] Q -->|"no, pt.dev == NULL"| BASE["<b>ptype_base[ntohs(type) & 15]</b><br/><i>global 16-bucket hash</i>"] ALLG --> U1["AF_PACKET sockets<br/>tcpdump -i any"] ALLD --> U1b["tcpdump -i eth0"] BASE --> U2["ETH_P_IP (0x0800) → ip_rcv / ip_list_rcv"] BASE --> U3["ETH_P_IPV6 (0x86DD) → ipv6_rcv"] BASE --> U4["ETH_P_ARP (0x0806) → arp_rcv"] BASE --> U5["ETH_P_8021Q, ETH_P_MPLS_UC, …"] RX["__netif_receive_skb_core()"] -->|"stage 4: walk both ALL lists first"| ALLG RX -->|"stage 4"| ALLD RX -->|"stage 7: index by EtherType"| BASE RX -->|"stage 7, deliver_exact"| SPEC
What dev_add_pack() does with a struct packet_type, and where the receive path reads each list. What it shows: four destinations, chosen by two bits of the registration — whether .type is the wildcard ETH_P_ALL (0x0003) and whether .dev is set. The insight to take: ptype_all and ptype_base are read at different stages of the same function, which is exactly why a sniffer sees frames that the protocol stack later drops. It is also why registering an AF_PACKET socket on a busy interface is expensive in a way that a protocol handler is not: every ptype_all entry gets a deliver_skb() clone of every frame, so the cost is per-tap-per-packet, whereas ptype_base is a single hash lookup no matter how many protocols are registered.
Failure Modes and How to Diagnose Them
The receive path drops packets at seven distinct places, each with its own counter and its own fix, and no single command shows all seven. That is the whole difficulty: a report of “we are losing packets” is not actionable until you know which stage lost them, and the stages do not share an accounting system. The taxonomy first, then the individual failures.
flowchart TB W(["frame on the wire"]) --> S1["<b>1. NIC RX ring</b>"] S1 -->|"no driver-owned descriptor"| L1(["<b>hardware drop</b><br/>counter: ethtool -S → rx_missed_errors,<br/>rx_no_buffer_count, rx_fifo_errors<br/>fix: ethtool -G, more queues, IRQ coalescing"]) S1 --> S2["<b>2. driver poll → skb alloc</b>"] S2 -->|"alloc_failed"| L2(["<b>allocation drop</b><br/>counter: driver-specific ethtool -S<br/>fix: memory pressure, page-pool tuning"]) S2 --> S3["<b>3. native XDP</b>"] S3 -->|"XDP_DROP / XDP_ABORTED"| L3(["<b>invisible drop</b><br/>counter: <b>none by default</b><br/>only what the eBPF program records<br/>fix: instrument the program"]) S3 --> S4["<b>4. RPS backlog enqueue</b>"] S4 -->|"qlen > netdev_max_backlog<br/>or flow limit"| L4(["<b>SKB_DROP_REASON_CPU_BACKLOG</b><br/>counter: softnet_stat col 2 (sd->dropped)<br/>fix: raise netdev_max_backlog, spread RPS"]) S4 --> S5["<b>5. __netif_receive_skb_core</b>"] S5 -->|"generic XDP / tc ingress /<br/>nf ingress / no ptype handler"| L5(["<b>SKB_DROP_REASON_XDP</b>,<br/><b>_TC_INGRESS</b>, <b>_UNHANDLED_PROTO</b><br/>counter: rx_dropped, rx_nohandler<br/>fix: depends entirely on which"]) S5 --> S6["<b>6. ip_rcv → netfilter</b>"] S6 -->|"bad header, rp_filter,<br/>iptables/nft DROP"| L6(["<b>_IP_INHDR</b>, <b>_IP_CSUM</b>,<br/><b>_IP_RPFILTER</b>, <b>_NETFILTER_DROP</b><br/>counter: /proc/net/snmp Ip:InHdrErrors,<br/>nft counters, iptables -vnL<br/>fix: firewall or routing config"]) S6 --> S7["<b>7. transport → socket</b>"] S7 -->|"no listener"| L7a(["<b>_NO_SOCKET</b> — RST or ICMP sent<br/>counter: Tcp:AttemptFails, Udp:NoPorts"]) S7 -->|"checksum bad"| L7b(["<b>_TCP_CSUM</b> / <b>_UDP_CSUM</b><br/>counter: Tcp/Udp:InCsumErrors"]) S7 -->|"receive buffer full"| L7c(["<b>_SOCKET_RCVBUFF</b><br/>counter: Udp:RcvbufErrors,<br/>TcpExt:TCPRcvQDrop<br/>fix: read faster, or raise SO_RCVBUF"]) S7 --> APP2(["recv() returns"])
The seven drop stages, their reason codes and their counters. Reason names are from include/net/dropreason-core.h, v6.12. What it shows: the counters live in four unrelated places — ethtool -S (device), /proc/net/softnet_stat (per-CPU softirq), /proc/net/snmp and /proc/net/netstat (protocol MIBs), and BPF maps (XDP) — and a packet lost at stage 1 or stage 3 appears in none of the /proc files at all. The insight to take: the standard reflex of checking ip -s link and finding dropped 0 proves almost nothing, because that counter aggregates dev->rx_dropped and misses hardware ring overruns, XDP verdicts, and every socket-level drop. Work the stages in order; do not start in the middle.
The unifying instrument is the skb:kfree_skb tracepoint, which carries the reason code as a string. One command covers stages 4 through 7 at once:
# every dropped skb, with the reason and the call site
perf trace -e skb:kfree_skb
# or, counted by reason
bpftrace -e 'tracepoint:skb:kfree_skb { @[args->reason] = count(); }'
That the kernel has a named enumeration of drop reasons at all is relatively recent, and it is the single largest improvement to receive-path debuggability in years: before it, every drop was kfree_skb() and the only way to tell them apart was to read the source and set a kprobe on the specific function. SKB_DROP_REASON_NOT_SPECIFIED still exists for paths that have not been converted, so a trace dominated by it means the packet died somewhere nobody has annotated yet.
Softirq starvation / time_squeeze. Under a packet flood, net_rx_action repeatedly hits its budget/time limit, bumping time_squeeze (column 3 of /proc/net/softnet_stat, per CPU). Symptoms: high %softirq CPU on a few cores (mpstat -P ALL), ksoftirqd threads pegged, latency spikes, and RX drops. Mitigations are exactly the RPS/RFS/RSS levers in Receive Side Scaling and Packet Steering (spread the load across cores), raising net.core.netdev_budget / netdev_budget_usecs, or enabling threaded NAPI (/sys/class/net/<dev>/threaded) so poll runs in a dedicated kthread the scheduler can balance, rather than in softirq context.
Backlog drops. When RPS or netif_rx enqueues to a remote CPU’s backlog and that queue exceeds net.core.netdev_max_backlog (net_hotdata.max_backlog), enqueue_to_backlog drops with reason SKB_DROP_REASON_CPU_BACKLOG and increments sd->dropped (column 2 of /proc/net/softnet_stat). A nonzero column-2 count means a CPU’s backlog overflowed — raise netdev_max_backlog or rebalance RPS.
Ring overruns (rx_missed/rx_fifo). If the driver cannot drain its RX ring fast enough (softirq throttled, or too few ring descriptors), the NIC drops frames at the hardware ring and reports them in ethtool -S <dev> counters like rx_missed_errors/rx_no_buffer. This is upstream of the kernel path entirely — the fix is more ring descriptors (ethtool -G), more RX queues, or interrupt coalescing tuning. Distinguish these from netif_receive_skb-level drops, which show as SKB_DROP_REASON_* in dropwatch / perf trace/tracepoints.
Unhandled-protocol drops. Frames whose EtherType has no ptype_base handler are freed with SKB_DROP_REASON_UNHANDLED_PROTO and bump rx_nohandler. Seeing this for an expected protocol usually means the relevant module is not loaded.
Diagnosing the path. The receive path is densely instrumented with tracepoints — trace_netif_receive_skb, trace_napi_gro_receive_entry/exit, trace_napi_poll, and per-skb kfree_skb with a drop reason. perf trace, bpftrace, and the dropwatch tool turn these into a precise picture of where a packet died.
Alternatives and When to Choose Them
The standard path described here allocates an sk_buff per (coalesced) frame and pays for the full protocol stack. Three escape hatches trade generality for speed:
- Native XDP runs an eBPF program in the driver before
__netif_receive_skb_core, before an skb is even allocated. It returnsXDP_DROP(line-rate DDoS scrubbing),XDP_TX(bounce back out),XDP_REDIRECT(to another NIC or an AF_XDP socket), orXDP_PASS(fall through to the normal path above). Choose XDP when you must make a verdict at line rate and can express it in eBPF. - AF_XDP delivers raw frames into a userspace-shared memory ring (
UMEM), bypassing the entire stack from__netif_receive_skb_coreonward. Choose it for a userspace data plane (DPDK-style) that wants kernel driver support but not the kernel’s protocol processing. - The standard
napi_gro_receivepath (this note) is the right default for everything that needs the kernel’s TCP/IP stack, netfilter, routing, and socket semantics — i.e. ~all normal applications. GRO already recovers most of the per-packet overhead for streaming workloads.
| standard path | native XDP | AF_XDP | generic XDP | |
|---|---|---|---|---|
| Runs at | after the skb is built | in the driver poll, before the skb | driver poll, via XDP_REDIRECT | __netif_receive_skb_core stage 2, after the skb |
sk_buff allocated? | yes, always | no unless XDP_PASS | no | yes — already allocated |
| Sees netfilter / routing / conntrack | yes | no | no | no |
| Programmable in | C (kernel), tc-BPF at stage 5 | eBPF | userspace, any language | eBPF |
| Needs driver support | no | yes | yes (zero-copy); falls back to copy | no — works everywhere |
| Typical use | everything | line-rate drop, load balance, redirect | userspace data plane | testing an XDP program, not production |
| Drop is counted by | SKB_DROP_REASON_* + MIBs | nothing by default | n/a | SKB_DROP_REASON_XDP |
| Cost of a drop | full skb alloc + hooks | a few pointer derefs | n/a | full skb alloc, then the program |
The four ways a frame can be handled on receive. What it shows: the column that actually distinguishes them is “is an sk_buff allocated,” and it lines up exactly with the performance ordering. The insight to take: generic XDP is in this table as a warning, not an option. It runs the same program with the same semantics and none of the speed, because by the time it runs the allocation it was supposed to avoid has already happened. Benchmarking an XDP program on a driver without native support measures generic XDP and will understate the real thing by roughly an order of magnitude.
Production Notes
The interrupt-then-poll design and the softnet_stat counters are the bread and butter of high-throughput tuning. Netflix, Cloudflare, and others have documented that on busy edge servers the dominant RX cost is not the interrupt but the per-packet work in and above __netif_receive_skb_core, which is precisely why GRO (fewer trips up the stack) and RPS/RFS (spread those trips across cores) matter so much. The kernel’s own scaling guide (docs.kernel.org/networking/scaling.html) is the primary reference for RSS/RPS/RFS tuning, and the NAPI document (docs.kernel.org/networking/napi.html) for poll/threaded-NAPI/busy-poll. A recurring real-world gotcha: a single hardware RX queue (common on cheap or virtualized NICs) funnels all interrupts to one CPU, so one core saturates while others idle — the fix is enabling RPS to fan the post-poll work out, since you cannot add hardware queues you do not have. Another: forgetting that tcpdump attaches at ptype_all, before netfilter and routing, so a packet captured by tcpdump may still be dropped by an iptables PREROUTING rule a microsecond later — the capture proves the frame reached the host, not that it was accepted.
The numeric defaults quoted here are confirmed against net/core/hotdata.c at v6.12, which initializes net_hotdata with .netdev_budget = 300, .netdev_budget_usecs = 2 * USEC_PER_SEC / HZ (= 2000 µs at the common HZ = 1000), .max_backlog = 1000, and .dev_rx_weight = 64; NAPI_POLL_WEIGHT = 64 is in include/linux/netdevice.h. All are runtime-tunable via sysctl net.core.netdev_budget, netdev_budget_usecs, netdev_max_backlog, and dev_weight (the last scaled by dev_weight_rx_bias into dev_rx_weight), so a running system may show different values.
A worked reading of one real machine
Abstract tuning advice is much easier to hold onto against a concrete configuration, so here is the whole receive-side picture of the machine this note was revised on, read on 2026-09-04 (Fedora, Linux 7.1.8, 32 logical CPUs, Realtek RTL8126 5 Gigabit Ethernet on r8169).
$ ethtool -l enp191s0
netlink error: Operation not supported # driver exposes no channel API
$ grep enp191s0 /proc/interrupts
153: ... IR-PCI-MSIX-0000:bf:00.0 0-edge enp191s0
$ ethtool -k enp191s0 | grep -E 'receive-hashing|generic-receive'
generic-receive-offload: on
receive-hashing: off [fixed]
$ ip -s -s link show enp191s0
RX: bytes packets errors dropped missed mcast
4566493163 3478978 0 0 0 12393
Read that as a configuration diagnosis and every tuning decision follows.
One MSI-X vector, and no channel API. There is a single interrupt line, 153, for the whole interface, and ethtool -l returns Operation not supported because the driver never implements get_channels. So there is exactly one hardware receive queue, one NAPI instance, and one CPU running the poll at any moment. RSS is not available on this machine at any price.
receive-hashing: off [fixed] confirms it from the other direction: NETIF_F_RXHASH is absent, so the card does not even compute a flow hash into the descriptor. Any hashing that happens will be done by the CPU in skb_get_hash().
Therefore RPS is the only lever that exists. This is precisely the case the kernel’s scaling guide singles out — “For a single queue device, a typical RPS configuration would be to set the rps_cpus to the CPUs in the same memory domain of the interrupting CPU” — and it is why the “one hardware queue funnels all interrupts to one CPU” gotcha above is not a hypothetical. Note also the guide’s warning that applies directly here: “At high interrupt rate, it might be wise to exclude the interrupting CPU from the map since that already performs much work.”
errors 0 dropped 0 missed 0 after 3.5 million packets is a healthy interface, and missed is the column that matters: rx_missed_errors is the hardware ring overrun of stage 1 in the drop taxonomy, the one that no /proc file records. A nonzero value here, on a 256-descriptor ring that cannot be grown, would leave only three remedies — interrupt coalescing, threaded NAPI to get the poll scheduled more predictably, or a better NIC.
The full ip -s -s link output also lists rx_nohandler among the sysfs statistics (/sys/class/net/<dev>/statistics/rx_nohandler), which is the counter for SKB_DROP_REASON_UNHANDLED_PROTO — frames whose EtherType found no ptype_base entry. It is worth knowing that it exists and is separate from rx_dropped, because a protocol module that failed to load produces a rising rx_nohandler and a completely quiet rx_dropped.
One anomaly is worth recording rather than explaining away. The software interrupt coalescing knobs on this interface are not at their defaults:
$ cat /sys/class/net/enp191s0/gro_flush_timeout # 20000 (20 us)
$ cat /sys/class/net/enp191s0/napi_defer_hard_irqs # 1
$ cat /sys/class/net/lo/gro_flush_timeout # 0
Both are zero on every other interface on the machine, so something set them specifically for this one — meaning NAPI is configured to arm a 20-microsecond repoll timer instead of unmasking the hardware interrupt, for one deferral, before giving up and going back to interrupt mode. That is a real latency-versus-interrupt-rate trade being made without anyone having asked for it.
Uncertain
Verify: what sets
gro_flush_timeout = 20000andnapi_defer_hard_irqs = 1on thisr8169interface. Reason: it is not the in-tree driver —grepfor both identifiers indrivers/net/ethernet/realtek/r8169_main.creturns nothing at v6.12, v6.13, v6.14 or v6.18, and both values are 0 on the machine’s other interfaces, so it is neither a driver default nor a system-wide sysctl. Candidates not ruled out: atunedprofile (throughput-performanceis active here), audevrule, or NetworkManager — agrepacross/etc,/usr/lib/tunedand/usr/lib/udevfor either identifier found no match, so the source is genuinely unidentified. To resolve:udevadm monitoracross a link cycle, orbpftraceon the sysfs store handler. The observation is verified (read directly from sysfs on 2026-09-04); only its cause is not.#uncertain
See Also
- NAPI and Polled Receive — the poll loop, the NAPI state machine, threaded NAPI and busy-poll in depth
- NET_RX and NET_TX Softirqs — the softirq machinery
net_rx_actionruns under - Receive Side Scaling and Packet Steering — RSS/RPS/RFS, the steering point in
netif_receive_skb_internal - Generic Receive Offload — how
dev_gro_receivecoalesces same-flow segments - The IP Layer — what
ip_rcv/ip_rcv_finish/dst_inputdo at L3 - IP Routing Decision and Forwarding — the local-deliver-vs-forward branch
- The Netfilter Framework and Hooks — the
PREROUTING/INPUThooks fired on the way up - struct sk_buff / sk_buff Memory Layout and Headroom — the packet buffer every stage manipulates
- DMA Coherency and Bounce Buffers — why the descriptor ring is
dma_alloc_coherent()memory and whatdma_rmb()orders - Message-Signaled Interrupts MSI and MSI-X — how one RX queue gets its own interrupt vector and CPU affinity
- Interrupt Handling from a Driver’s View / IRQ Stacks and Per-CPU Interrupt Handling — the hard-IRQ context the receive path deliberately spends almost no time in
- ksoftirqd and Softirq Load — where
net_rx_actionruns when softirq processing is pushed to a kernel thread - Checksum Offloads — the
status_errorbits in the descriptor that decideskb->ip_summed, and what the stack does with them - Network Device Drivers and net_device — the driver-side view:
net_device_ops, NAPI registration, ring setup atndo_open - bpftrace / Tracepoints — the
skb:kfree_skbdrop-reason instrumentation this note leans on - The Network Transmit Path — the mirror-image egress path
- XDP Express Data Path / AF_XDP Zero-Copy Sockets — the fast-path bypasses
- MOC: Linux Networking Stack MOC