跪拜 Guibai
← Back to the summary

From Wire to Userland: The Full Lifecycle of a Packet Inside Linux

1. DMA Writes Data into the Ring Buffer

The ring buffer is a memory region shared with the operating system kernel.

First, when the NIC is started, the driver prepares a "receiving area" for the NIC:

Then, a data packet arrives at the NIC. The NIC hardware completes information decoding, Ethernet frame reception, CRC checksum verification, MAC address filtering, RSS queue selection, etc.

Preamble | SFD | Destination MAC | Source MAC | Type/Length | Data and Padding | FCS

7 bytes 1 byte 6 bytes 6 bytes 2 bytes 46~1500 4 bytes

If RSS is enabled, the NIC calculates a hash based on the packet's five-tuple (source IP, destination IP, source port, destination port, protocol) and then selects a specific RX queue. Modern multi-queue NICs typically map different RX queues to different CPUs for parallel processing. Later, masking hardware interrupts can mask only the interrupt of a specific RX queue.

Finally, the NIC writes to memory via DMA.

Destination MAC | Source MAC | Type/Length | Data and possible Padding

Field Typically DMA'd to memory? Reason
Preamble No Only used for PHY/MAC synchronization
SFD No Only used to mark frame start
Destination MAC Yes Linux needs to handle Layer 2 protocols
Source MAC Yes Linux, bridges, packet capture, etc. need it
Type/Length Yes Used to determine IPv4, IPv6, ARP, etc.
VLAN Tag Depends on hardware offload Can be retained or stripped into descriptor metadata
Data Payload Yes This is the main data content
Padding Usually yes Usually cannot be completely distinguished from valid payload
FCS/CRC Usually no NIC typically strips it after verification
Interframe Gap IFG No It is not part of the Ethernet frame content at all

image.png

2. NIC Hardware Interrupt Notifies the CPU

After DMA completes, the data is already in memory, but the CPU does not know that a specific RX descriptor has been filled by the NIC. Then the NIC notifies the CPU via a hardware interrupt. Modern multi-queue NICs typically use MSI-X, giving each RX/TX queue a relatively independent interrupt vector. Which CPU handles the interrupt depends on the IRQ Affinity configuration:

The relationship between MSI-X, IRQ, and Interrupt Vector The three exist at different layers:

Concept Layer Role
MSI-X PCIe device interrupt notification mechanism Device triggers interrupt via memory write message
IRQ Linux kernel logical number Identifies and manages an interrupt source
Interrupt Vector CPU-level entry number Determines which low-level interrupt entry the CPU enters

The overall relationship can be simplified as:

NIC Queue
   ↓
MSI-X Table Entry
   ↓
Linux IRQ
   ↓
Interrupt Vector on target CPU
   ↓
Linux low-level interrupt entry
   ↓
NIC driver interrupt handler function

1. What is MSI-X

MSI-X is an interrupt notification mechanism used by PCI/PCIe devices.

Traditional devices notify the interrupt controller by asserting an interrupt pin, while MSI-X notifies the CPU by performing a special memory write operation:

Traditional Interrupt:

Device → Interrupt Pin → IO-APIC → CPU

MSI-X:

Device → Write MSI-X Message → Local APIC → CPU

The "Memory" in MSI-X refers to the fact that this interrupt manifests as a memory write transaction, not that the device actually writes ordinary business data into some RAM.

MSI-X supports multiple independent Table Entries. Each Entry stores a set of interrupt message configurations:

MSI-X Table Entry
├── Message Address
├── Message Data
└── Vector Control

Where:

The NIC does not decide these values on its own. Linux calculates the corresponding configuration when initializing the device and allocating IRQs, then writes them into the NIC's MSI-X Table.

2. How a NIC Queue Corresponds to MSI-X

Taking a NIC with four receive queues as an example, the driver might request five MSI-X Entries:

MSI-X Entry 0 → RX/TX Queue 0
MSI-X Entry 1 → RX/TX Queue 1
MSI-X Entry 2 → RX/TX Queue 2
MSI-X Entry 3 → RX/TX Queue 3
MSI-X Entry 4 → Link status, errors, and other management events

When Queue 2 needs to notify the CPU, the NIC sends an MSI-X message using the address and data configured in Entry 2:

RX Queue 2 receives data
        ↓
DMA writes data buffer
        ↓
Update RX Descriptor
        ↓
Read configuration of MSI-X Entry 2
        ↓
Send MSI-X write message
        ↓
Target CPU receives interrupt

An RX queue exclusively owning one MSI-X Entry is a common configuration, but not mandatory. Actual NICs may:

3. MSI-X Entry and Linux IRQ

The driver requests MSI-X interrupt resources from Linux:

pci_alloc_irq_vectors(
    pdev,
    min_vectors,
    max_vectors,
    PCI_IRQ_MSIX
);

Linux creates corresponding logical IRQs for successfully allocated MSI-X Entries:

MSI-X Entry 0 → Linux IRQ 120
MSI-X Entry 1 → Linux IRQ 121
MSI-X Entry 2 → Linux IRQ 122
MSI-X Entry 3 → Linux IRQ 123
MSI-X Entry 4 → Linux IRQ 124

The driver then registers handler functions for these IRQs:

request_irq(120, nic_queue_irq, 0, "eth0-q0", &queue0);
request_irq(121, nic_queue_irq, 0, "eth0-q1", &queue1);
request_irq(122, nic_queue_irq, 0, "eth0-q2", &queue2);
request_irq(123, nic_queue_irq, 0, "eth0-q3", &queue3);

request_irq(124, nic_admin_irq, 0, "eth0-admin", adapter);

Different queues can use the same handler function but carry different context parameters:

IRQ 120 ── nic_queue_irq(120, &queue0)
IRQ 121 ── nic_queue_irq(121, &queue1)
IRQ 122 ── nic_queue_irq(122, &queue2)
IRQ 123 ── nic_queue_irq(123, &queue3)

So the IRQ is responsible for expressing:

Which interrupt source managed by Linux is this?

4. Linux IRQ and CPU Interrupt Vector

Linux also needs to assign each IRQ to a target CPU and allocate a CPU interrupt vector for it.

Assume the assignment result is as follows:

Linux IRQ 120 → CPU 0 → Interrupt Vector 0x51
Linux IRQ 121 → CPU 1 → Interrupt Vector 0x52
Linux IRQ 122 → CPU 2 → Interrupt Vector 0x53
Linux IRQ 123 → CPU 3 → Interrupt Vector 0x54

Based on this result, Linux generates the MSI-X message configuration and writes it to the NIC:

MSI-X Entry 0
├── Target CPU: CPU 0
└── CPU Vector: 0x51

MSI-X Entry 1
├── Target CPU: CPU 1
└── CPU Vector: 0x52

When the NIC sends a message using MSI-X Entry 0:

NIC sends MSI-X message
        ↓
Message is sent to CPU 0
        ↓
CPU 0 receives Vector 0x51
        ↓
Enter the low-level entry corresponding to Vector 0x51
        ↓
Linux locates IRQ 120
        ↓
Calls nic_queue_irq(120, &queue0)

5. Complete Example

Assume a data packet is assigned to RX Queue 1 by RSS:

Data packet arrives at NIC
        ↓
RSS calculation result selects RX Queue 1
        ↓
DMA writes to Queue 1's receive buffer
        ↓
NIC updates RX Descriptor
        ↓
NIC sends interrupt message using MSI-X Entry 1
        ↓
CPU 1 receives interrupt vector 0x52
        ↓
CPU enters the corresponding low-level interrupt entry
        ↓
Linux identifies IRQ 121
        ↓
Calls nic_queue_irq(121, &queue1)
        ↓
Masks the queue interrupt corresponding to MSI-X Entry 1
        ↓
Schedules Queue 1's NAPI
        ↓
NAPI batch processes RX Queue 1

It can be condensed to:

RX Queue 1
    ↓
MSI-X Entry 1
    ↓
MSI-X Message: Target CPU 1, Vector 0x52
    ↓
CPU 1's Interrupt Vector 0x52
    ↓
Linux IRQ 121
    ↓
nic_queue_irq(..., &queue1)
    ↓
NAPI corresponding to Queue 1

3. NAPI Scheduling, Soft Interrupt Processing

After the NIC triggers a hardware interrupt via MSI-X, the driver does not process all data packets in the hard interrupt context. Instead, it schedules the corresponding NAPI instance, deferring the main work to the NET_RX_SOFTIRQ soft interrupt for completion.

The NIC may receive thousands of data packets per second. If every packet triggered a hardware interrupt, it would form an interrupt storm, and CPU time slices would be fully occupied by interrupts, because the interrupt context cannot be preempted for scheduling. For example: A 10G NIC running at full speed receives 15 million packets per second, triggering 15 million interrupts, paralyzing the CPU directly.

The hard interrupt handler mainly accomplishes two things:

  1. Masks the interrupt notification of the current RX queue.
  2. Adds the NAPI corresponding to that queue to the current CPU's polling list.

The CPU's polling list is a kernel data structure maintained by the Linux kernel for each CPU. Each CPU has a softnet_data, and softnet_data contains a poll_list.

For example:

CPU 0's softnet_data
└── poll_list
    ├── NAPI of eth0 Queue 0
    └── backlog NAPI

CPU 1's softnet_data
└── poll_list
    └── NAPI of eth0 Queue 1

When does polling occur?

Timing Executor
When hard interrupt exits Current CPU executes directly
When re-enabling Bottom Half Current process context executes incidentally
When soft interrupts backlog or exceed budget ksoftirqd/N kernel thread executes
When actively calling soft interrupt processing interface in process context Current CPU executes directly
When subsequent other hard interrupts exit Current CPU supplements execution of previously leftover soft interrupts

Soft interrupt poll processing flow:

After the NIC interrupt schedules NAPI, the corresponding NAPI is added to the current CPU's softnet_data.poll_list, and the NET_RX_SOFTIRQ pending flag is set simultaneously.

Subsequently, during the hard interrupt exit phase, the kernel checks and executes the pending NET_RX_SOFTIRQ. The soft interrupt calls NAPI's poll() function via net_rx_action(), batch processing data packets in the RX Ring. To prevent the CPU from being occupied by soft interrupts for too long, NAPI Poll has a single processing quota, and NET_RX_SOFTIRQ overall also has packet count and execution time limits. If the count or time limit is reached while data still remains in the RX Ring, NAPI remains in the scheduled state, the kernel re-triggers NET_RX_SOFTIRQ, and processing continues in subsequent rounds. If soft interrupts continue to backlog, subsequent work may be handled by the ksoftirqd/N kernel thread corresponding to the current CPU. Only when the RX Ring is emptied does the driver complete NAPI and re-enable the hardware interrupt for that queue.

Can be simplified into a flow:

Hard interrupt exit
    ↓
Execute NET_RX_SOFTIRQ
    ↓
net_rx_action() calls NAPI Poll
    ↓
Batch process RX Ring
    ↓
Packet count or time limit reached?
    ├── Yes, Ring still has data
    │      ↓
    │   Keep NAPI scheduled state
    │      ↓
    │   Re-trigger NET_RX_SOFTIRQ
    │      ↓
    │   If necessary, ksoftirqd/N continues processing
    │
    └── No, Ring has been emptied
           ↓
        Complete NAPI
           ↓
        Re-enable queue interrupt

4. GRO Merging, Protocol Stack Processing

In the poll function, sk_buff is allocated from the per-CPU cache (faster than the global memory allocator), and GRO merging is attempted in advance before handing up; The sk_buff data structure allows each layer of the protocol stack to process by moving pointers rather than copying data. Essence: skb_pull = skb -> data += len

sk_buff structure:

struct sk_buff {
    struct sock *sk;         // Associated socket
    struct net_device *dev;  // Source NIC

    // [ARCH] Protocol stack layers move these pointers
    unsigned char *head;     // Buffer start
    unsigned char *data;     // Current layer data start
    unsigned char *tail;     // Data end
    unsigned char *end;      // Buffer end

    // [ARCH] L2/L3/L4 header position offsets
    __u16 transport_header;  // L4 header
    __u16 network_header;    // L3 header
    __u16 mac_header;        // L2 header

    __be16 protocol;         // Ethernet protocol
    unsigned int len;        // Total data length
};

GRO is a receive-side merging optimization. If several consecutive small packets received belong to the same TCP flow and are contiguous, GRO merges them into one sk_buff, optimizing multiple protocol stack processing operations into one. Next, it enters protocol stack processing:

The __netif_receive_skb_core function does two things:

Netfilter inserts two interception points in the IP layer: PRE_ROUTING is before route lookup, all incoming packets pass this checkpoint; ip_route_input_noref queries the routing table to find a matching route entry (there is an important optimization here — route cache). The route lookup determines whether this packet is destined for the local machine or needs to be forwarded. After the routing decision, local packets go to ip_local_deliver, reaching the second interception point LOCAL_IN — this is the location of the iptables INPUT chain, where most of the firewall rules you usually write take effect. Each hook point means linked list traversal and function calls; the more rules, the slower it is. This is also why XDP and eBPF emerged — to make decisions before Netfilter.

There is an important optimization here — route cache. The first packet needs a full routing table lookup, but subsequent packets of the same flow hit the cache, reducing lookup time from microseconds to nanoseconds. For server packet reception, the vast majority of packets are destined for the local machine, going through ip_local_deliver to continue upward delivery.

image.png

At the transport layer, tcp_v4_rcv is the entry point for TCP packet reception. > The first step is to perform a hash lookup based on the four-tuple of source IP, destination IP, source port, and destination port to find the corresponding socket. If not found, an RST is sent to reject; if found, the socket state is checked. If it is in the ESTABLISHED state, it takes the fast path tcp_rcv_established. The vast majority of data transmission packets take this fast path. The fast path of tcp_rcv_established has two conditions: there are no extra options in the TCP header, and the sequence number exactly matches the expected next sequence number. Meeting these two conditions means this is a normal, in-order data packet, allowing all complex logic like out-of-order processing and retransmission detection to be skipped, directly placing the data into the receive queue — this optimization minimizes TCP overhead during normal transmission. Looking at the code, after the fast path is hit, skb_pull is first used to strip the TCP header, then tcp_queue_rcv attaches the sk_buff to the socket's receive queue, and immediately after, tcp_data_ready wakes up all processes waiting for data on this socket — whether you are using blocking read or epoll, you are awakened at this moment.