How the Linux Network Receive Path Works: NAPI Polling, sk_buffs, Softirqs, and Why the Hard IRQ Never Copies the Payload

A NIC that has just finished DMA-ing a frame into a host ring buffer does not copy the payload in its hard interrupt handler. The handler does almost nothing: it schedules a NAPI poll, masks further interrupts from that queue, and returns. The work of building an sk_buff, running Generic Receive Offload, and handing the packet to the IP input path happens later, in softirq context, under a budget.

That split is the contract of the Linux receive path. It keeps the hard IRQ short, batches work under load, and still delivers packets into the same socket receive queues that read and epoll already understand. This article is about that path: ring descriptors, NAPI scheduling, the softirq poll loop, sk_buff construction, GRO, and the hand-off into the protocol stack. It is not a second tour of DMA address translation, of the hard-IRQ entry path, or of TCP sequence numbers.

Related reading: How DMA and the IOMMU Work, How Linux Interrupts Work, How TCP Works, and How UDP Works.

The contract

A modern Ethernet driver owns one or more RX rings. Each ring is a circular array of descriptors that the NIC and the driver share. The driver pre-allocates buffers (often pages or fragments), maps them for DMA, and writes the bus addresses into the descriptors. The NIC writes frames into those buffers and sets a done bit. When the done bit appears, the driver is notified by a hardware interrupt (or by a completion queue that a poll later examines).

From that moment the kernel promises three things:

  • The hard IRQ handler will not walk the whole ring or allocate sk_buffs. It only schedules further processing and disables the interrupt source.
  • Softirq context (specifically NET_RX_SOFTIRQ) will poll the ring, build sk_buffs, and push them upward, limited by a budget so one device cannot monopolize the CPU.
  • Once an sk_buff reaches netif_receive_skb (or its GRO wrapper), the rest of the stack treats it like any other received packet: netfilter, routing, protocol demux, and finally a socket receive queue.

The payload itself stays in the DMA buffer until the protocol stack or a socket copies it (or until zero-copy paths such as splice or io_uring consume it). The hard IRQ never copies those bytes.

Hard IRQ: schedule and mask

When the NIC raises its interrupt, the driver's IRQ handler runs in hard-IRQ context. Typical work:

irqreturn_t my_nic_irq(int irq, void *data)
{
    struct my_nic *nic = data;
    napi_schedule(&nic->napi);   /* set NAPI_STATE_SCHED, add to poll_list */
    my_nic_mask_irq(nic);        /* disable further interrupts from this queue */
    return IRQ_HANDLED;
}

napi_schedule marks the napi_struct as scheduled and adds it to the per-CPU softnet poll list, then raises NET_RX_SOFTIRQ. The interrupt is masked so a flood of frames does not generate a flood of hard IRQs. The handler returns. No packet data has been copied; no sk_buff has been allocated.

This is the same interrupt machinery described in the interrupts article, used for a deliberately minimal purpose. The heavy lifting is deferred.

Softirq and the NAPI poll loop

On the way back from the hard IRQ (or later from ksoftirqd), the kernel runs pending softirqs. For networking that means net_rx_action, which walks the per-CPU poll list and calls each scheduled NAPI instance's poll method.

The driver's poll function looks roughly like this:

static int my_nic_poll(struct napi_struct *napi, int budget)
{
    struct my_nic *nic = container_of(napi, struct my_nic, napi);
    int processed = 0;

    while (processed < budget) {
        struct sk_buff *skb = my_nic_receive_one(nic); /* build from ring */
        if (!skb)
            break;
        napi_gro_receive(napi, skb);  /* GRO + hand to stack */
        processed++;
    }

    if (processed < budget) {
        napi_complete_done(napi, processed);
        my_nic_unmask_irq(nic);       /* re-enable interrupts */
    }
    return processed;
}

The budget argument is the fairness limit. The kernel's netdev_budget sysctl (default 300) caps the total packets processed in one softirq invocation across all NAPI instances on the CPU; each instance also has its own weight. There is also a time limit (netdev_budget_usecs, default 2000 µs). If the poll returns exactly the budget, the instance stays scheduled and the softirq will be re-raised. If it returns less, napi_complete_done clears the scheduled state and the driver re-enables the hardware interrupt.

Under light load the path is almost interrupt-driven: one IRQ, a short poll, interrupts re-enabled. Under heavy load the interrupt stays masked and the softirq keeps polling until the ring drains or the budget expires. That is NAPI's classic interrupt-mitigation behavior.

Building the sk_buff and GRO

Inside the poll loop the driver turns a ring entry into an sk_buff. Modern drivers prefer napi_alloc_skb (or page-pool helpers) because the allocation happens in softirq context where per-CPU caches are safe and cheaper. The driver sets skb->dev, calls eth_type_trans to strip the Ethernet header and set skb->protocol, and records checksum status if the NIC validated it.

Instead of calling netif_receive_skb directly, most drivers call napi_gro_receive. Generic Receive Offload (GRO) looks for consecutive packets that belong to the same flow and can be merged into a larger sk_buff. For TCP this reduces the number of packets the rest of the stack must process, lowering per-packet overhead. The merged packet is still delivered through the normal receive path; it simply carries more data.

After GRO (or immediately if GRO is not used), the packet reaches netif_receive_skb. That function runs the ingress traffic-control hooks, optional XDP, and then dispatches by protocol type to the L3 handler (ip_rcv for IPv4, the IPv6 equivalent, etc.). From there the packet follows the ordinary input path: netfilter PREROUTING / INPUT, routing lookup, protocol demux, and finally enqueue onto a socket receive buffer (or a drop if no socket matches).

Why the budget and ksoftirqd exist

Softirqs can run on the return from a hard IRQ or from the ksoftirqd kernel thread. If a softirq keeps re-raising itself, the kernel eventually schedules ksoftirqd so that the work is subject to the normal scheduler and cannot permanently starve user processes. The budget and time limits exist for the same reason: a 100 Gbit/s NIC should not be allowed to consume an entire core in one softirq invocation while other CPUs sit idle or while a latency-sensitive process waits.

You can observe the effect in /proc/net/softnet_stat (the "time squeeze" counter increments when the budget or time limit is hit) and in /proc/softirqs. Raising netdev_budget increases throughput under load at the cost of longer softirq runs; the right value is workload-dependent.

Misconceptions

The hard IRQ copies the packet into an sk_buff. It does not. The payload is already in a DMA buffer. The hard IRQ only schedules work and masks the interrupt. Allocation and protocol processing happen in softirq context.

NAPI means the driver never uses interrupts. Interrupts are still used to wake the poller. Under load the interrupt is masked and polling continues until the ring is empty or the budget is exhausted; then interrupts are re-enabled. Busy-polling (via the socket option or epoll) is a separate, application-driven path that can also call the same poll method.

Every packet generates its own softirq. One hard IRQ schedules one NAPI instance. The poll loop then processes many packets (up to the budget) before returning. The softirq cost is amortized.

GRO changes the protocol. GRO merges packets that already belong to the same flow. The resulting sk_buff is still subject to the same checksum, routing, and socket delivery rules. It simply reduces the number of times those rules run.

Where this sits next to other machinery

The DMA article explains how the NIC obtained the bus addresses of the ring buffers and why those pages must stay pinned. The interrupts article explains the hard-IRQ entry and the softirq deferral mechanism that NAPI rides on. Once the packet reaches the TCP or UDP input function, the protocol articles take over: sequence numbers, checksum verification, socket lookup, and the receive buffer. The page-cache and VFS articles are not on this path; a received packet does not become a file until an application writes it.

Takeaways

The Linux receive path keeps the hard IRQ tiny: schedule NAPI and mask the interrupt. Softirq context then polls the ring under a budget, builds sk_buffs (preferably with NAPI-aware allocators), optionally merges them with GRO, and hands them to the protocol stack. The budget and ksoftirqd exist so that a busy NIC cannot monopolize a CPU. The payload is never copied by the hard IRQ; it stays in the DMA buffer until the stack or the application decides otherwise.

That is the mechanism that turns a completed DMA descriptor into a packet sitting in a socket receive queue.

Previous Post