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:
- Allocates a batch of memory buffers usable for receiving data packets
- Maps these memory regions into DMA (Direct Memory Access) addresses accessible by the NIC
- Creates an RX Descriptor Ring
- Writes the DMA addresses into each RX descriptor
- Notifies the NIC that these descriptors are available
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 |
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:
Interrupt Vector: The entry number used by the CPU to index the interrupt handler function. On x86, typically 0–255.
IRQ Number: A logical number assigned by the Linux kernel to an interrupt source, visible in
/proc/interrupts.MSI-X Vector: An MSI-X interrupt channel of the NIC. Linux creates an IRQ for it and ultimately maps it to a CPU interrupt vector.
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:
Message Addressspecifies which interrupt controller and target CPU the interrupt message is sent to.Message Datacontains information such as the interrupt vector the CPU needs to receive.Vector Controlis used to mask or enable the Entry.
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:
- RX and TX share one MSI-X Entry.
- Multiple queues share one MSI-X Entry.
- RX queues and TX queues use different Entries respectively.
- Reserve one Entry specifically for handling management events.
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:
- Masks the interrupt notification of the current RX queue.
- 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:
- Passes the packet to all handlers registered by
ptype_all(tcpdump captures packets here, earlier than iptables firewall filtering, so it can capture packets dropped by iptables) - Dispatches to the corresponding L3 handler based on the packet's
protocolfield (IPv4 packets go toip_rcv) Then enters the IP layer:ip_rcvfirst performs basic checks (is the IP version correct, is the header checksum correct). After passing the check, it reaches a key point: Netfilter's PRE_ROUTING hook. iptables' PREROUTING rules take effect here, and conntrack connection tracking is also established here.
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.
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.