001 Notes

Packet Flow: NIC, DMA, Rings, Kernel, XDP, and DPDK

There is a sentence we all say without thinking:

“The application receives a packet.”

It is the polite fiction networking books are built on. It is also, on close inspection, completely wrong in the same way “food appears in the fridge” is wrong. Something put it there. Several somethings, actually, coordinating across hardware, firmware, drivers, the kernel, and possibly a userspace polling loop, with strict rules about who owns which bytes when, who is allowed to write to which memory, and who gets to wake whom up.

This post follows one Ethernet frame from the wire to a userspace read(). Then it follows the same frame down two alternative paths — XDP and DPDK — and explains what they skip, why they can skip it, and what they pay for the privilege.

The goal is not a feature comparison. The goal is a mental model: by the end, you should be able to draw, on a napkin, the chain of ownership transfers between the NIC and the CPU, point at every place a byte is copied (or pointedly not copied), and explain why every queue, descriptor, and doorbell exists.

We will be reading actual Linux source along the way. The relevant trees:

drivers/net/ethernet/intel/{e1000,e1000e,igb,ixgbe,ice}/...
drivers/net/ethernet/mellanox/mlx5/core/en_rx.c
net/core/dev.c
net/core/skbuff.c
net/core/page_pool.c
net/core/gro.c
net/ipv4/ip_input.c
net/ipv4/tcp_ipv4.c
include/linux/netdevice.h
include/linux/skbuff.h
include/net/page_pool/types.h
include/net/xdp.h

If you have a kernel checkout handy, open it. Half of this article is just pointing at code.


1. Start with what the CPU actually sees

If you don’t start at the hardware, every layer above it ends up feeling like magic. Let’s not.

Forget Linux for a moment. Forget sockets, forget sk_buff, forget even Ethernet. Here is what a modern x86-64 server actually looks like, in caricature:

[CPU cores] <----- coherent interconnect ----->  [Memory controller] --- [DRAM]
    |                                                |
    +------------- PCIe root complex ----------------+
                          |
              +-----------+-----------+
              |                       |
           [NIC]                  [NVMe SSD]
              |
           [PHY] -- copper/fiber -- [switch]

Three things matter here:

  1. The CPU can read and write DRAM through the memory controller. From its point of view, memory is a flat array of bytes addressed by physical address.
  2. The NIC is a PCIe device. It is, electrically, just another bus master on the same fabric. It can also read and write DRAM, through the PCIe root complex, without asking the CPU first. That last clause is the entire reason high-speed networking exists.
  3. The PHY is a tiny piece of silicon on the NIC card that knows about voltages, line codes, clock recovery, and serialization. Above it lives the MAC, which knows about Ethernet frames. Above that lives the rest of the NIC — descriptor parsers, RSS hashers, hardware offload engines, queue managers — and a small amount of register space exposed to the host.

A NIC is not a magic mailbox. It is a peripheral that:

  • Reads bytes from a wire.
  • Writes bytes into host DRAM.
  • Tells the host when it has done so.
  • Reads bytes from host DRAM the host wants to transmit.
  • Notifies the host when transmission completes.

Everything else — sk_buffs, sockets, TCP, RSS, GRO, XDP, DPDK — is software invented on top of those four primitives to make them tractable.


2. What is a NIC, really?

If you peer at a NIC datasheet (say, the Intel 82599 for 10 GbE, or the Mellanox ConnectX-5 for 100 GbE), you find that almost all of the “NIC” is just a programmable controller exposing two things to the host:

  1. A bank of memory-mapped registers (the MMIO region, also called the BAR space).
  2. A description of what the NIC will do if you, the driver, configure those registers a certain way.

PCI/PCIe devices advertise their MMIO regions via Base Address Registers (BARs). At enumeration time, the kernel reads each device’s PCI configuration space and learns: “this device wants 128 KiB of MMIO mapped somewhere”. The kernel picks a physical address range, programs the BARs, and then maps that range into kernel virtual address space with ioremap(). From then on, when the driver writes to a particular kernel virtual address, the write actually goes out over PCIe as a Memory Write Transaction Layer Packet (TLP) addressed to the NIC.

You can see this in any driver. The ixgbe probe path does roughly:

/* drivers/net/ethernet/intel/ixgbe/ixgbe_main.c, simplified */
err = pci_request_mem_regions(pdev, ixgbe_driver_name);
hw->hw_addr = ioremap(pci_resource_start(pdev, 0),
                      pci_resource_len(pdev, 0));

After this, hw->hw_addr is a pointer into kernel virtual memory. Writes through it land in the NIC’s register file. The NIC sees those writes the same way a CPU “sees” a normal memory write: a transaction shows up on its internal fabric and updates a register.

Two things to internalize:

  • MMIO is host-to-device. When the driver wants to tell the NIC something — “here are buffers for you”, “please start”, “process the queue at index N” — it writes to an MMIO register.
  • DMA is device-to-host. When the NIC wants to write packet data into RAM, it issues a PCIe write directly to a host physical address. The CPU is not involved per byte.

That asymmetry is the spine of the whole story. Everything that follows is the choreography to make MMIO and DMA cooperate.


3. Why DMA at all?

The naïve alternative to DMA is programmed I/O (PIO): the CPU reads bytes from the NIC, one word at a time, into CPU registers, then writes them to RAM. Some early Ethernet cards did exactly this. The 10 Mb/s NE2000, for example, exposed a data port; the driver did insw() in a loop.

PIO has a property that ruins it for modern networking: every byte costs CPU cycles, and every read of a PCIe register has long latency. A PCIe read transaction takes hundreds of nanoseconds because it has to go out as a non-posted TLP, traverse the fabric, hit the device, the device generates a completion, and the completion travels back. The CPU stalls the entire time. At 10 Gb/s — 14.88 million packets per second of 64-byte frames — the CPU has approximately 67 nanoseconds per packet total, and a single PCIe read alone eats 200–500 ns. PIO is dead at line rate.

DMA flips the asymmetry. The NIC writes packet bytes into host RAM directly, as posted PCIe writes. Posted writes don’t wait for completions. The NIC fires and forgets. The CPU is never on the critical path for data movement. The CPU’s only job is to tell the NIC where to put the bytes (descriptors) and to be told when bytes have arrived (interrupts or polled completions).

So: DMA exists because the CPU cannot afford to touch every byte of a fast packet stream. It needs to be a coordinator, not a courier.

That immediately raises the next question: how does the NIC know where in host RAM it is allowed to write?


4. How does the NIC know where to DMA?

A NIC is, by itself, completely ignorant about the host’s memory layout. From its point of view, RAM is a 64-bit address space, and it can write anywhere it has been given an address for. The driver must explicitly hand it addresses. Without that, the NIC has no business poking at host memory and the IOMMU (or the lack of one) will catch it if it tries.

There are three relevant pieces:

  1. Buffers. Plain chunks of physical memory the kernel has allocated, big enough to hold a packet.
  2. Descriptors. Small fixed-size structures, also in host memory, that contain a DMA address pointing at a buffer plus a few control/status bits. One descriptor per buffer.
  3. A ring of descriptors. A contiguous array of descriptors, with head/tail pointers, treated as a circular queue.

The driver’s bargain with the NIC is:

“Here is an array of N descriptors at physical address D. Each descriptor points to a buffer of size B. When you receive a frame, write it into the buffer pointed to by the next descriptor I haven’t reclaimed yet, then mark that descriptor done.”

The descriptor itself is a tiny on-the-wire data structure that both parties agree on. For Intel’s e1000 family, the legacy RX descriptor is in drivers/net/ethernet/intel/e1000/e1000_hw.h:

/* simplified */
struct e1000_rx_desc {
    __le64 buffer_addr;     /* DMA address of buffer */
    __le16 length;          /* set by NIC */
    __le16 csum;            /* set by NIC */
    u8     status;          /* DD, EOP, VP, etc. — set by NIC */
    u8     errors;          /* set by NIC */
    __le16 special;
};

Sixteen bytes. The driver fills in buffer_addr (and writes zeros to the rest). The NIC fills in everything else when a packet lands.

For the 82599 (ixgbe), an “advanced” receive descriptor is also 16 bytes, but the layout is reused for two purposes: before the NIC writes to it, it’s a read format (the driver wrote a DMA address); after, it’s a write-back format (the NIC wrote length, RSS hash, checksum, vlan tag, status):

/* drivers/net/ethernet/intel/ixgbe/ixgbe_type.h */
union ixgbe_adv_rx_desc {
    struct {
        __le64 pkt_addr;       /* read format: buffer DMA addr */
        __le64 hdr_addr;       /* optional header buffer       */
    } read;
    struct {
        struct {
            union {
                __le32 data;
                struct { __le16 pkt_info, hdr_info; } hs_rss;
            } lo_dword;
            union {
                __le32 rss;
                struct { __le16 ip_id; __sum16 csum; } csum_ip;
            } hi_dword;
        } lower;
        struct {
            __le32 status_error;
            __le16 length;
            __le16 vlan;
        } upper;
    } wb;  /* write-back format */
};

The two layouts overlap in the same 16 bytes. It is a tagged union by convention: the status_error field, specifically the Descriptor Done (DD) bit, tells you which view is current. If status_error & RXD_STAT_DD, the NIC has written this descriptor and the write-back layout is valid.

The NIC’s descriptor-eating loop is, in pseudocode:

ring head H, ring tail T (in NIC's internal state)
loop:
    if H == T: drop or buffer the frame (no descriptors available)
    desc = RAM[ring_base + H * desc_size]
    write packet bytes to RAM[desc.buffer_addr]
    fill in length, status, checksum, RSS hash at desc
    set desc.status_error |= DD
    H = (H + 1) mod N
    maybe raise interrupt

The driver’s loop is the mirror image:

loop:
    desc = RAM[ring_base + driver_next * desc_size]
    if not (desc.status_error & DD): nothing new yet, return
    consume packet at desc.buffer_addr (length = desc.length)
    allocate a fresh buffer, write its DMA address into desc
    clear status fields (so the NIC can use it again)
    driver_next = (driver_next + 1) mod N
    tell the NIC: tail is now driver_next (via MMIO write to RDT)

Notice the symmetry. The descriptor ring is the interface. Both sides advance pointers in it. Neither side is allowed to touch a descriptor the other side currently owns.

If you’ve ever read about lock-free single-producer/single-consumer ring buffers, this should look extremely familiar. That’s exactly what it is, except the producer is silicon and the consumer is a kernel thread (or vice versa for TX). The ring is the contract that allows two asynchronous parties to share work without locks.


5. The ring is the universe

The word “ring” gets used so much in networking that it stops meaning anything. Let’s break it down: what rings exist, who owns them, and what they’re for.

In Linux RX paths, you typically have, per queue:

RX descriptor ring   driver writes addresses,  NIC writes packets
TX descriptor ring   driver writes addresses,  NIC reads packets
                       and reports completion in the same descriptors

In modern designs, especially Mellanox (mlx5) and similar, you also have:

Completion Queue (CQ)  NIC writes completion events
                       driver reads them and learns
                       which descriptors are done

This is the separation of work and completion — the device may consume work from one ring (the work queue, WQ) and post completions to another (the CQ). It’s especially common in RDMA-style hardware. The mlx5 driver does exactly this: see mlx5e_handle_rx_cqe() in drivers/net/ethernet/mellanox/mlx5/core/en_rx.c. The descriptor ring is called a Work Queue (WQ), and the CQ is consulted to find which entries are ready.

Why rings, specifically, and not lists or stacks?

  • They are contiguous. The base address plus an index gives you the descriptor. No pointer chasing, no cache misses on linked-list traversal.
  • They are bounded. Memory usage is fixed at setup time. The NIC can prefetch.
  • They are circular. Producer and consumer move forward forever. The “wrap” is just modulo arithmetic — actually free, because ring sizes are powers of two and & (N-1) is one instruction.
  • They are lock-free in the SPSC case. As long as the producer only advances head and the consumer only advances tail, and both are word-sized writes, no locks are needed. Only memory ordering primitives (dma_wmb(), smp_rmb()).
  • They are friendly to hardware. Hardware engineers like fixed-size, sequentially-accessed structures because they can prefetch and pipeline.

Each ring has two pointers, usually called head and tail. The convention varies, but the standard mapping in Intel docs is:

  • Head (RDH for RX, TDH for TX) — owned by the NIC. For RX, this is “the next descriptor the NIC will write into.” For TX, this is “the next descriptor the NIC will read.”
  • Tail (RDT, TDT) — owned by the driver. For RX, this is “the last descriptor the driver has handed to the NIC.” For TX, this is “the last descriptor the driver has prepared for the NIC.”

So the rule is:

RX: NIC may write to descriptors in [head, tail)
TX: NIC may read from descriptors in [head, tail)

Anything outside that window belongs to the driver and is off-limits to hardware.

When the driver wants the NIC to know “I added more buffers”, it writes the new tail value to an MMIO register. That MMIO write is the doorbell.

That word — doorbell — keeps recurring because it’s the only mechanism by which the driver actively talks to the NIC. Otherwise, the driver writes to RAM, and the NIC discovers things by polling or DMA. The doorbell is the explicit, synchronous “hey, look at this” knock.


6. How Linux tells the NIC what to do

Let’s now actually trace, in source, how a freshly-loaded driver sets all this up. We’ll use ixgbe because it’s representative and the code reads cleanly.

When you ip link set eth0 up, the kernel calls the driver’s ndo_open function, which for ixgbe is ixgbe_open() in drivers/net/ethernet/intel/ixgbe/ixgbe_main.c. Inside that:

ixgbe_setup_all_rx_resources(adapter);   /* allocate descriptor rings + buffers */
ixgbe_configure(adapter);                /* program the hardware */
ixgbe_request_irq(adapter);              /* hook up interrupt handlers */
ixgbe_up_complete(adapter);              /* enable RX/TX */

Two function calls are doing the interesting work.

Allocate descriptor rings and buffers

ixgbe_setup_all_rx_resources() walks the per-queue array and calls ixgbe_setup_rx_resources() for each. That function asks the kernel for a chunk of DMA-coherent memory:

/* simplified */
ring->size = ring->count * sizeof(union ixgbe_adv_rx_desc);
ring->desc = dma_alloc_coherent(dev, ring->size, &ring->dma, GFP_KERNEL);

dma_alloc_coherent() (defined in include/linux/dma-mapping.h, implemented in kernel/dma/) returns two things: a kernel virtual address (so the driver can read/write descriptors normally) and a “DMA address” that the device can use to address the same memory. On a system without IOMMU these are usually equal to the physical address; with an IOMMU, the DMA address is a translated address the IOMMU will resolve when the NIC issues PCIe writes.

That distinction matters: the NIC does not know about virtual addresses, and on IOMMU-enabled systems, the NIC does not even know about real physical addresses. It knows about I/O virtual addresses (IOVAs). The DMA API’s job is to make those addresses exist and to keep the mappings alive as long as the device might use them. dma_map_single() and dma_map_page() set up the mapping; dma_unmap_single() tears it down. If you skip the unmap, you leak IOMMU entries; if you DMA outside a mapping, the IOMMU faults.

For the buffers themselves, modern Linux uses a page_pool rather than allocating one buffer at a time. We’ll return to page_pool later. For now: each descriptor’s pkt_addr field points to a DMA-mapped page (or part of one) that the NIC is allowed to write into.

Program the hardware

ixgbe_configure_rx_ring() is where it gets concrete. The driver writes to MMIO registers to tell the NIC about the ring:

/* drivers/net/ethernet/intel/ixgbe/ixgbe_main.c, condensed */
rdba = ring->dma;                               /* DMA base of descriptor ring */
IXGBE_WRITE_REG(hw, IXGBE_RDBAL(reg_idx),       /* base low  */
                rdba & 0xFFFFFFFF);
IXGBE_WRITE_REG(hw, IXGBE_RDBAH(reg_idx),       /* base high */
                rdba >> 32);
IXGBE_WRITE_REG(hw, IXGBE_RDLEN(reg_idx),       /* length in bytes */
                ring->count * sizeof(union ixgbe_adv_rx_desc));
IXGBE_WRITE_REG(hw, IXGBE_RDH(reg_idx), 0);     /* head = 0 */
IXGBE_WRITE_REG(hw, IXGBE_RDT(reg_idx), 0);     /* tail = 0 */

IXGBE_WRITE_REG ultimately reduces to writel(value, hw_addr + offset). Recall that hw_addr points into the BAR, so writel issues a PCIe Memory Write TLP to the NIC.

After these writes, the NIC knows:

  • where the descriptor ring lives,
  • how big it is,
  • where the head is (initially zero),
  • where the tail is (initially zero — so the NIC has no buffers yet).

Then the driver allocates RX buffers, fills descriptors with their DMA addresses, and writes the new tail value:

ixgbe_alloc_rx_buffers(ring, ring->count - 1);
/* inside that: */
writel(tail, ring->tail);    /* doorbell — the only sync handshake */

That last writel is the doorbell. The NIC, on receiving that write, notices that its head != tail and starts treating the ring as armed.

After this, the receive path is live. From the NIC’s perspective, every Ethernet frame it accepts will be DMA’d into one of those buffers, and the corresponding descriptor will be marked done.

What about MAC filtering? The NIC has a separate set of MMIO registers — RAL/RAH for the unicast MAC table, MTA for the multicast hash table, VFTA for VLAN filters, FCTRL for promiscuous mode, MRQC for RSS. The driver programs these too, but they’re orthogonal to the ring mechanics. They determine which frames the NIC will accept and which queue a given frame goes to.

RSS deserves a brief mention because it is the reason you have multiple rings in the first place.


7. RSS: why multiple rings exist

A 100 Gb/s NIC handing every packet to one CPU core is a CPU bottleneck dressed up as a network bottleneck. To scale, the NIC must spread work across cores. It does this with Receive Side Scaling (RSS).

RSS works as follows. For each incoming frame, the NIC computes a hash — typically Toeplitz over the 4-tuple (src IP, dst IP, src port, dst port for TCP/UDP, or just the IP pair for other protocols) — and uses the low bits to index a redirection table (often 128 entries) that maps to a queue index. The queue index picks which ring the frame lands in.

You can see and tune this with ethtool:

ethtool -x eth0           # show redirection table and RSS key
ethtool -X eth0 equal 8   # set indirection to 8 equal buckets
ethtool -n eth0 rx-flow-hash tcp4    # show what fields hash for TCP/IPv4

Each queue is then mapped, via /proc/irq/<n>/smp_affinity, to a particular CPU. Set up correctly, every flow lands consistently on one queue (preserving packet ordering within a flow) and one CPU (preserving cache locality). A 16-queue NIC with proper RSS can saturate a 16-core machine. A 1-queue NIC cannot, no matter how fast the wire.

This is why “ring” is plural in real systems. A modern NIC like an Intel E810 or Mellanox ConnectX-6 can have 64+ RX rings, 64+ TX rings, and (for Mellanox) 64+ completion queues. Each is a fully independent SPSC channel, programmed identically to what we just walked through, but with its own MMIO base and its own interrupt vector via MSI-X.

So when someone says “the NIC has multi-queue”, they specifically mean: there are N independent descriptor rings, each handed off to a different CPU, and a hardware classifier (RSS, or a more general Flow Director / RFS-style classifier) routes each frame to one of them.


8. Interrupts, NAPI, and why polling won

Now we have buffers in the ring, the NIC is armed, and frames are arriving from the wire. The NIC DMAs each into the next descriptor’s buffer, marks the descriptor with DD, and … then what? How does the driver actually learn that a packet arrived?

There are two options. Both are used.

Interrupts. The NIC raises an interrupt — historically via INTx pin, modernly via MSI-X — which the CPU dispatches to a registered ISR. The ISR runs in the driver, sees a bit set in the NIC’s interrupt cause register, and goes off to process the ring.

Polling. Software periodically reads the ring’s descriptors and looks at their DD bits. No interrupts.

A pure interrupt model is what early Ethernet drivers did, and it works fine at 10 Mb/s. It catches fire at 10 Gb/s. At 14 Mpps you would take 14 million interrupts per second, each of which costs maybe 1–5 µs of CPU time for the context switch, the interrupt handler, the EOI, and the cache pollution from jumping into the kernel. That alone would consume more CPU than you have. Worse, under sustained load the CPU spends so much time servicing interrupts that user processes never run — a phenomenon called livelock, famously described in Mogul & Ramakrishnan’s 1996 paper “Eliminating Receive Livelock in an Interrupt-Driven Kernel”. The system is busy, but doing no work.

Pure polling is the opposite. You burn a CPU on a tight loop reading descriptors. Cheap at high load, ridiculous at low load. A mostly-idle server should not pin a core.

Linux’s solution, in place since 2003 or so, is NAPI (“New API”). The idea is hybrid: interrupt-driven on entry, polling-driven on the body, with adaptive switching.

The mechanism, codified in include/linux/netdevice.h and net/core/dev.c:

struct napi_struct {
    struct list_head poll_list;
    unsigned long    state;
    int              weight;
    int              (*poll)(struct napi_struct *, int budget);
    ...
};

Every driver registers one napi_struct per RX queue. Its poll callback knows how to drain that queue.

Here is the actual flow:

  1. A frame arrives. The NIC raises an MSI-X interrupt for that queue.
  2. The driver’s hardirq handler runs. It does almost nothing: it masks further interrupts for this queue (so it doesn’t keep getting re-interrupted while it works), and calls napi_schedule().
  3. napi_schedule() adds this queue’s napi_struct to the per-CPU softnet_data.poll_list and raises the NET_RX_SOFTIRQ.
  4. The softirq runs (either tail-of-interrupt or in ksoftirqd if backed up). It walks poll_list and calls each napi_struct’s poll(napi, budget).
  5. The driver’s poll drains up to budget packets from the ring (typically 64) and returns how many it processed.
  6. If poll processed < budget, the queue is drained; the driver re-enables interrupts and removes the napi_struct from the list with napi_complete_done().
  7. If poll processed == budget, the queue is still full; the softirq does not re-enable interrupts. It loops back and polls again next time around, or schedules ksoftirqd to keep going.

You can see this exact shape in any driver. ixgbe_clean_rx_irq() is the meat of ixgbe’s poll routine. The skeleton:

/* drivers/net/ethernet/intel/ixgbe/ixgbe_main.c, condensed */
static int ixgbe_clean_rx_irq(struct ixgbe_q_vector *q_vector,
                              struct ixgbe_ring *rx_ring,
                              const int budget)
{
    unsigned int total_rx_packets = 0;

    while (likely(total_rx_packets < budget)) {
        union ixgbe_adv_rx_desc *rx_desc;
        struct sk_buff *skb;

        rx_desc = IXGBE_RX_DESC(rx_ring, rx_ring->next_to_clean);
        if (!ixgbe_test_staterr(rx_desc, IXGBE_RXD_STAT_DD))
            break;

        dma_rmb();  /* ensure we read DD before the rest of the descriptor */

        skb = ixgbe_run_xdp(adapter, rx_ring, rx_desc);
        if (!skb) { /* XDP consumed it */ ... continue; }

        ixgbe_process_skb_fields(rx_ring, rx_desc, skb);
        ixgbe_rx_skb(q_vector, skb);

        total_rx_packets++;
    }

    ixgbe_alloc_rx_buffers(rx_ring, cleaned_count); /* replenish */
    return total_rx_packets;
}

Three things to note:

  • The dma_rmb() is a real-deal memory barrier. The NIC writes the body of the descriptor before setting DD. The driver must read DD first; if it sees DD, the dma_rmb() ensures subsequent reads of the descriptor body see the NIC’s writes and not stale values. Drop this barrier and you will, on some platforms, occasionally process garbage.
  • The poll replenishes buffers. Every consumed descriptor must be given a fresh buffer and its tail updated, or the NIC will run out and drop frames. The doorbell write at the end of ixgbe_alloc_rx_buffers tells the NIC the tail has advanced.
  • The poll budget is a fairness mechanism. Without it, a single queue could starve all others. With a budget of 64, one poll call processes at most 64 frames before yielding.

That’s it. That’s the whole interrupt-vs-polling story. NAPI is interrupt-driven at low rates (rare interrupts, no wasted polling cycles) and polling-driven at high rates (interrupts off, the softirq just keeps draining). Real systems run somewhere in between, and modern hardware adds interrupt moderation — registers like EITR on ixgbe that let the NIC delay interrupts by a configurable number of microseconds — to further coalesce work.


9. From a descriptor to an sk_buff

So a NAPI poll has noticed DD, knows there’s a packet sitting at desc.pkt_addr, length desc.length. It now needs to turn that into something the kernel networking stack understands. That thing is an sk_buff.

struct sk_buff (include/linux/skbuff.h) is the universal kernel packet container. It is famous for being big and complicated. A heavily simplified view:

struct sk_buff {
    /* linked list pointers */
    struct sk_buff *next, *prev;

    /* the actual buffer */
    unsigned char *head;     /* start of the allocated buffer */
    unsigned char *data;     /* current "headroom" pointer    */
    sk_buff_data_t tail;     /* end of data                   */
    sk_buff_data_t end;      /* end of the allocation         */
    unsigned int len;        /* length of data                */
    unsigned int data_len;   /* length in nonlinear (frag) part */

    /* metadata */
    __u16 protocol;
    __u16 transport_header;
    __u16 network_header;
    __u16 mac_header;
    __u32 hash;              /* RSS hash from the NIC */
    __u32 priority;
    ...
    struct net_device *dev;
    struct sock *sk;
    skb_frag_t frags[MAX_SKB_FRAGS];
    ...
};

There are roughly two ways to materialize one of these from a NIC buffer.

Copy-style. Allocate a new sk_buff with netdev_alloc_skb(), copy the packet bytes from the DMA buffer into the skb’s linear data area, then recycle the DMA buffer. Simple, but every packet is memcpy’d. Older drivers did this for small packets.

Page-flip / build-skb-style. The DMA buffer is itself a page (or fraction thereof). The driver calls build_skb() (or napi_build_skb()) on the page, which constructs an sk_buff whose head points directly into that page. No copy. The page travels up the stack as the packet body; later, when the skb is freed, the page is recycled back into a pool for future RX buffers.

Modern drivers use the page-flip style with page_pool. The page_pool API (include/net/page_pool/types.h, net/core/page_pool.c) is a per-driver, per-queue allocator that:

  • Allocates pages once, pre-maps them for DMA, and hands them out to the driver.
  • Recycles pages directly back to its own freelist when an skb referencing them is freed.
  • Avoids the global page allocator’s expense on the hot path.

A driver using page_pool looks roughly like:

/* allocation path inside the driver's alloc_rx_buffers */
page = page_pool_dev_alloc_pages(rx_ring->page_pool);
dma  = page_pool_get_dma_addr(page);
desc->pkt_addr = cpu_to_le64(dma + offset);

/* receive path */
skb = napi_build_skb(page_address(page) + offset, frag_size);
skb->dev = netdev;
__skb_put(skb, length);
skb_record_rx_queue(skb, rx_ring->queue_index);
skb->hash = le32_to_cpu(rx_desc->wb.lower.hi_dword.rss);
skb_set_hash_from_sw_hash_state(...);
napi_gro_receive(napi, skb);

The point: the bytes the NIC DMA’d into the page are the same bytes the skb hands to the kernel stack. No copy, no allocation of a separate skb buffer.

That’s an important mental model upgrade. The kernel does not copy the packet from NIC memory to “kernel memory”. The NIC writes into pages that are kernel memory, and the skb is just a header wrapped around those pages. The first byte-level copy happens later, if at all, when the application calls recv() and the data is copied into the userspace buffer.

(Even that copy can be skipped with MSG_ZEROCOPY on TX or recvmsg-style tricks with mmap’d AF_XDP or io_uring, but in the classical socket path, the kernel → user copy is real.)


10. GRO: making the stack do less work

Before the freshly-built sk_buff even reaches netif_receive_skb, the driver typically hands it to napi_gro_receive(). GRO — Generic Receive Offload — is a software trick that merges adjacent TCP segments into one large sk_buff before the rest of the stack sees them.

Here is why it matters. The kernel’s per-packet overhead is non-trivial: traversing the protocol layers, looking up the socket, accounting, locking. If you’re receiving a TCP bulk transfer at 10 Gb/s, each MTU-sized segment going through the full stack individually is expensive. GRO notices that many consecutive segments belong to the same TCP flow with consecutive sequence numbers, identical options, and identical flags, and coalesces them into a single 64 KiB super-segment with skb_shinfo->frags[] holding pointers to the underlying pages.

The implementation, dev_gro_receive() in net/core/gro.c, tries each registered protocol-specific GRO callback (tcp4_gro_receive, udp_gro_receive, etc.). A match adds the new segment to an existing in-progress super-segment; a mismatch flushes the current super-segment up the stack.

Net effect: at 10 Gb/s, GRO routinely cuts the kernel’s per-packet cost by an order of magnitude because the stack sees one logical packet per ~40 wire packets.

There is a TX-side mirror called TSO/GSO that does the opposite for outbound traffic. They are the two halves of the same trick: keep the kernel doing the math, but let it do the math once per segment train instead of once per segment.


11. Up the stack

A GRO-merged sk_buff eventually gets passed to __netif_receive_skb_core() in net/core/dev.c. From here, the path is purely kernel networking, no more hardware:

__netif_receive_skb_core
    -> deliver to ptype_all listeners (e.g. tcpdump)
    -> XDP generic hook (if enabled)
    -> tc ingress hook
    -> deliver to ptype_base[skb->protocol] e.g. ip_rcv

For an IPv4 TCP packet:

ip_rcv (net/ipv4/ip_input.c)
  - sanity check version, length, header checksum
  - netfilter PRE_ROUTING hook
ip_rcv_finish
  - routing lookup (ip_route_input_noref)
  - either forwarded (ip_forward) or local (ip_local_deliver)
ip_local_deliver
  - reassemble fragments if needed
  - netfilter LOCAL_IN hook
ip_local_deliver_finish
  - dispatch to L4 by protocol number
    -> tcp_v4_rcv (net/ipv4/tcp_ipv4.c)
       - find socket by 4-tuple (__inet_lookup_skb)
       - if established: tcp_rcv_established
         - update sequence numbers, ACK accounting
         - place data on sk->sk_receive_queue
         - call sk->sk_data_ready (default: sock_def_readable)

sock_def_readable() in net/core/sock.c is the wakeup point. It calls wake_up_interruptible_sync_poll() on the socket’s wait queue, which wakes whichever process is blocked in read() / recv() / select() / epoll_wait() on that socket.

The application’s recv() syscall, when it eventually runs, walks sk->sk_receive_queue, copies bytes from the skb into the userspace buffer with skb_copy_datagram_iter(), frees the skb, and returns.

That is “the app received the packet.” It is, as you can see, the very last thing that happens — long after the bytes have already been sitting in DRAM, traveling through several abstractions, being merged with siblings, threading the protocol stack, and parking in a per-socket queue.


12. Where the time goes

It helps to attach order-of-magnitude latency numbers to each step, on a modern server with a 25 GbE NIC. These are rough but useful:

StageLatency (order of magnitude)
Wire to NIC ingress (PHY, MAC)~250 ns
NIC DMA write (RAM landed, descriptor done)~500 ns–1 µs
MSI-X interrupt to CPU dispatched~1–3 µs
napi_schedule -> softirq -> driver poll~500 ns–1 µs
Driver poll: descriptor read + skb build~200–500 ns
GRO + netif_receive_skb + IP + TCP~1–3 µs
Wake up process, schedule it~3–10 µs (worst case, depends)
recv() copy to userspace~100 ns per packet, more per byte

So a single packet, kernel path, blocking recv(), on a healthy system, lands in the application about 5–15 microseconds after it arrived at the NIC. NAPI batching hides this by amortizing the interrupt and softirq cost over many packets — at line rate, those one-time costs disappear into per-packet costs of a few hundred nanoseconds.

The biggest variable is the wakeup. If the process is already polling (epoll, busy poll, io_uring), the wakeup cost is essentially zero. If it has to be woken from sleep, possibly migrated to a different core, the cost can balloon. This is why latency-sensitive applications (HFT, RPC infrastructure) often pin processes to cores and busy-poll instead of sleeping.

For throughput-oriented workloads, none of this matters: GRO + NAPI + page_pool can saturate 100 Gb/s on a handful of cores. For latency, every microsecond matters, and that’s where XDP and DPDK earn their keep.


13. XDP: making decisions before the skb exists

The kernel path’s biggest costs are not in the wire or the DMA. They are in sk_buff allocation and stack traversal. The reasoning: an sk_buff is a couple hundred bytes of metadata, allocated and initialized for every packet. The stack traversal touches many cache lines: socket tables, route caches, netfilter tables, the destination socket. For packets you intend to drop (DDoS filtering, ACLs), you’ve paid all of that just to throw the packet away. For packets you intend to forward to another interface (a load balancer, a sidecar), you’ve paid most of it just to re-emit.

XDP — eXpress Data Path — solves this by inserting an eBPF program at the earliest possible point in the driver, before skb allocation. The hook is called inside the driver’s NAPI poll, right after the descriptor is consumed and before build_skb. You can find it in ixgbe_run_xdp() and equivalents.

The eBPF program receives a struct xdp_buff (include/net/xdp.h):

struct xdp_buff {
    void *data;
    void *data_end;
    void *data_meta;
    void *data_hard_start;
    struct xdp_rxq_info *rxq;
    struct xdp_txq_info *txq;
    u32 frame_sz;
    u32 flags;
};

It points directly into the page the NIC DMA’d into. The program reads packet bytes (with bounds-checked accesses, verified by the eBPF verifier), then returns one of:

  • XDP_DROP — release the page back to the pool, do nothing else. Roughly a few hundred CPU cycles per packet. This is the DDoS primitive: drop a hundred million pps without ever building an skb. Cloudflare and friends use this in production.
  • XDP_PASS — fall through into the normal kernel stack. The driver builds the skb and continues as usual.
  • XDP_TX — bounce the packet straight back out the same interface. The driver constructs a TX descriptor pointing at the same DMA buffer and rings the TX doorbell. Useful for trivial load balancers and ping-respond style microservices.
  • XDP_REDIRECT — send the packet to another interface, a CPU, or an AF_XDP socket. The destination is looked up in an eBPF map.
  • XDP_ABORTED — programmer error, packet dropped, tracepoint fired.

XDP is fast for two reasons. First, it runs before skb allocation, so it pays none of the kernel’s per-packet metadata cost. Second, it’s eBPF: the verifier ensures the program is loop-free and terminates, so it can run inline in the NAPI poll without compromising the kernel.

XDP is constrained for the same reasons. No sk_buff, no kernel stack, no TCP, no syscalls, no unbounded loops, no arbitrary memory access. It is not “Linux networking, but faster”. It is “a programmable decision point in the driver”. Get the model right and it’s a superpower. Confuse it for a TCP stack and you’ll be sad.


14. AF_XDP: the kernel-assisted shortcut to userspace

If XDP can redirect a packet to “an AF_XDP socket”, what is that?

AF_XDP is a socket family that connects a userspace process to an XDP program via four shared-memory rings:

UMEM (shared between kernel and user, a huge contiguous region):
    a pool of equally-sized frames

FILL ring:  user -> kernel  ("here are free frames you can fill")
RX ring:    kernel -> user  ("here are descriptors of received frames")
TX ring:    user -> kernel  ("here are descriptors of frames to send")
COMP ring:  kernel -> user  ("here are frames I'm done transmitting")

This is exactly the descriptor-ring pattern from earlier, but between the kernel driver and a userspace process instead of between the driver and the NIC. The kernel acts as the intermediary: XDP redirects into the UMEM, the driver writes a descriptor into the RX ring, userspace reads from the RX ring with recvmsg or busy-polling, processes the packet, and either returns the frame to the FILL ring or queues it on the TX ring.

In zero-copy mode (XDP_FLAGS_SKB_MODE off, with driver support), the NIC DMAs directly into UMEM frames. The userspace process sees the packet bytes in shared memory with no copy. The cost compared to a normal socket: no sk_buff, no protocol stack, no recvmsg copy. The cost compared to DPDK: you still pay the driver’s NAPI poll and the XDP execution, you still go through the kernel for interrupts and configuration.

The kernel docs in Documentation/networking/af_xdp.rst are the canonical reference. The implementation is in net/xdp/.

The mental shift: AF_XDP is a socket flavor that takes the descriptor ring all the way to userspace. Once you see that, the API stops feeling exotic. It is just rings, all the way down.


15. DPDK: skip the kernel entirely

DPDK takes the most radical position: don’t involve the kernel networking stack at all.

The setup is:

  1. The administrator unbinds the NIC from its kernel driver and binds it to vfio-pci (or uio_pci_generic on older systems). The NIC is now exposed to userspace as a raw PCIe device with MMIO and DMA capability.
  2. A DPDK Poll Mode Driver (PMD) running entirely in userspace maps the NIC’s BARs into its address space, allocates hugepages for descriptors and packet buffers, and programs the NIC’s rings the same way an in-kernel driver would. Same registers, same descriptors, same rings. The only difference is which process owns them.
  3. The application spins on the rings in tight loops, with no syscalls and no interrupts.

The receive loop, conceptually:

while (running) {
    nb_rx = rte_eth_rx_burst(port, queue, bufs, BURST_SIZE);
    process_packets(bufs, nb_rx);
    nb_tx = rte_eth_tx_burst(port, queue, out, nb_processed);
}

rte_eth_rx_burst() is the userspace equivalent of the driver’s poll: it reads up to BURST_SIZE descriptors from the RX ring, returns pointers to rte_mbuf structures (DPDK’s sk_buff), and replenishes the ring.

DPDK is fast for a stack of reasons:

  • No syscalls in the hot path.
  • No interrupt handling cost. Interrupts are off; the CPU just polls.
  • No kernel sk_buff machinery. rte_mbuf is leaner and tailored for packet processing.
  • Hugepage-backed buffers reduce TLB pressure.
  • Cache-friendly batch processing.
  • The application has full control over CPU affinity, NUMA placement, and queue layout.

It is also operationally heavy:

  • The NIC is gone from ip/ethtool — the kernel does not know it exists.
  • You typically need to dedicate CPU cores to the polling loop. They show 100% busy at idle.
  • You inherit responsibility for everything: ARP, IP, TCP, congestion control, you name it. Either implement it (or use a library like F-Stack or VPP) or design your application not to need it.
  • Hugepage allocation, NIC binding, and IOMMU/VFIO setup are real ops work.

The mental model: DPDK is just a userspace driver. It uses the same ring/descriptor/doorbell/RSS machinery we’ve been discussing. It just runs in a different address space.

Once you internalize that, you stop thinking of “kernel networking” and “DPDK” as two different worlds. They’re the same engine with different cabins.


16. The big diagram

Put it all together and you get something like:

                                  +-------------------------------+
                                  |   Application                 |
                                  |   recv() / epoll              |
                                  +---------------^---------------+
                                                  |
                                       wake + copy to user
                                                  |
+----------------------+         +----------------+----------------+
| Kernel stack         |         |     Socket receive queue        |
| ip_rcv, tcp_v4_rcv   |-------->|     (per-socket)                |
| (per-CPU softirq)    |         +----------------+----------------+
+----------+-----------+                          ^
           ^                                      |
           |                                      |
+----------+-----------+    XDP_PASS              |
|  napi_gro_receive    |--------------+           |
|  (GRO coalescing)    |              |           |
+----------+-----------+              |           |
           ^                          v           |
           |                +---------+--------+  |
           |                |  XDP program     |  |
           |                |  DROP/PASS/TX/   |  |
           |                |  REDIRECT        |  |
           |                +---------+--------+  |
           |                          |           |
           |                          v           |
+----------+-----------+        +-----+------+    |
| Driver NAPI poll     |        |  AF_XDP    |    |
| reads RX descriptors |        |  UMEM/rings|----+--> userspace fastpath
| build_skb / page_pool|        +------------+
+----------+-----------+
           ^
           | DMA write of packet + status
           |
+----------+-----------+
|        NIC           |
|   RX ring (RDBAL/RDH/|        +---- doorbell (MMIO write) <---- driver
|    RDT/RDLEN)        |
|   RSS hashing -> Q   |
|   DMA engine         |
+----------+-----------+
           ^
        wire bytes
           |
       [Ethernet]

Every arrow is either a memory transaction or a control-plane handshake. Every box is either a producer or a consumer in some ring. The kernel path is the long left road; XDP is a fork off it; AF_XDP and DPDK are progressively more aggressive shortcuts into userspace.


17. What to measure

The reason to know all of this is so you can tell, when a system misbehaves, where the time or drops are going. Suggestions:

  • ethtool -S eth0 — per-queue counters, including rx_no_dma_resources, rx_missed, rx_csum_offload_errors, queue-level packet/byte counts. If rx_missed is non-zero, the NIC ran out of descriptors. That is a software bug (buffer replenishment is too slow) or a CPU starvation problem (the NAPI poll isn’t getting scheduled).
  • cat /proc/interrupts and /proc/softirqs — which CPUs are taking the interrupts and softirqs? Is NET_RX skewed to one CPU? That’s an RSS/affinity problem.
  • mpstat -P ALL 1 — per-CPU %soft. If one CPU is at 100% soft, it’s drowning in NAPI work.
  • perf top and perf record on ksoftirqd — where is the time actually going? napi_complete_done? tcp_v4_rcv? __netif_receive_skb_core?
  • bpftrace-based one-liners on napi_poll, xdp_*, kfree_skb to count drops, measure poll counts per call, etc.
  • For DPDK, dpdk-procinfo, dpdk-pdump, and the application’s own counters. Without drop counters in the app, DPDK throughput claims are unverifiable.

A throughput number without a drop number is a number, not a measurement.


18. The mental model, in one paragraph

A NIC is a PCIe peripheral with a register file and a DMA engine. The driver hands it descriptor rings — circular arrays of structs containing DMA addresses — through MMIO writes. The NIC eats descriptors from one end (writing packets into the buffers they point to and marking them done), the driver eats them from the other end (reading completed descriptors and replenishing them with fresh buffers). Doorbell registers are the only synchronous handshake; everything else is RAM-mediated ownership transfer. The kernel path turns those buffers into sk_buffs, runs them through GRO and the protocol stack, and finally copies them into a userspace buffer when an application asks. XDP runs an eBPF program at the earliest moment in the driver so you can make decisions without paying skb overhead. AF_XDP extends the descriptor-ring pattern all the way into userspace. DPDK skips the kernel entirely by running the driver in userspace. Performance is about removing unnecessary work, batching necessary work, keeping memory hot in cache, avoiding unnecessary wakeups, and remembering that bytes you don’t have to touch are the fastest bytes of all.

The packet doesn’t “arrive in the application.” It is handed up through a sequence of producers and consumers, each of which exists to spare the next one from work it can’t afford to do. Now when you read a driver, an XDP program, or a DPDK loop, you’ll see the same shapes everywhere: rings, descriptors, doorbells, polls. That’s the language. Everything else is dialect.

Comments