001 Notes
Packet Flow: NIC, DMA, Rings, Kernel, XDP, and DPDK
“The application receives a packet.”
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
1. Start with what the CPU actually sees
[CPU cores] <----- coherent interconnect -----> [Memory controller] --- [DRAM]
| |
+------------- PCIe root complex ----------------+
|
+-----------+-----------+
| |
[NIC] [NVMe SSD]
|
[PHY] -- copper/fiber -- [switch]
2. What is a NIC, really?
/* 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));
3. Why DMA at all?
4. How does the NIC know where to DMA?
“Here is an array of N descriptors at physical address
D. Each descriptor points to a buffer of sizeB. 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.”
/* 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;
};
/* 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 */
};
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
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)
5. The ring is the universe
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
Completion Queue (CQ) NIC writes completion events
driver reads them and learns
which descriptors are done
RX: NIC may write to descriptors in [head, tail)
TX: NIC may read from descriptors in [head, tail)
6. How Linux tells the NIC what to do
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 */
Allocate descriptor rings and buffers
/* simplified */
ring->size = ring->count * sizeof(union ixgbe_adv_rx_desc);
ring->desc = dma_alloc_coherent(dev, ring->size, &ring->dma, GFP_KERNEL);
Program the hardware
/* 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_alloc_rx_buffers(ring, ring->count - 1);
/* inside that: */
writel(tail, ring->tail); /* doorbell — the only sync handshake */
7. RSS: why multiple rings exist
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
8. Interrupts, NAPI, and why polling won
struct napi_struct {
struct list_head poll_list;
unsigned long state;
int weight;
int (*poll)(struct napi_struct *, int budget);
...
};
/* 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;
}
9. From a descriptor to an sk_buff
sk_buffstruct 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];
...
};
/* 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);
10. GRO: making the stack do less work
11. Up the stack
__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
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)
12. Where the time goes
| Stage | Latency (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 |
13. XDP: making decisions before the skb exists
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;
};
14. AF_XDP: the kernel-assisted shortcut to userspace
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")
15. DPDK: skip the kernel entirely
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);
}
16. The big diagram
+-------------------------------+
| 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]
Comments