DPDK Poll Mode Driver 深度解析:从架构到实现
DPDK(Data Plane Development Kit)让用户态应用能以线速处理数据包——10 Gbps、40 Gbps 甚至 100 Gbps——完全绕过 Linux 内核。这个架构的核心是轮询模式驱动(Poll Mode Driver, PMD):用紧密轮询循环取代中断驱动 I/O,实现纳秒级延迟和每核每秒百万包的吞吐。
本文基于 DPDK 26.03 源码的深入阅读,全文标注具体文件路径和行号。我们将从 rte_eth_rx_burst() 一直追踪到硬件描述符环,以 VirtIO PMD 作为参考实现——它是最简单的 PMD,但展示了所有主要优化技术。
为什么轮询?
传统网络 I/O 的流程:
网卡收到包 → 硬件中断 → 中断处理 → 软中断 → 内核调度 → 应用被唤醒 → 处理包
每个环节都增加延迟。上下文切换耗时微秒级,中断合并增加更多延迟,内核的逐包开销(skb 分配、netfilter、socket 缓冲)更是让高 PPS 成为不可能。
PMD 反转了模型:
应用在紧密循环中轮询 → 直接读取硬件描述符 → 零中断、零内核开销 → 处理包
| 特性 | 中断模式 | 轮询模式(PMD) |
|---|---|---|
| 延迟 | 微秒级(中断 + 调度) | 纳秒级(直接读寄存器) |
| CPU 利用率 | 事件驱动,空闲时低 | 100% 占用专用核 |
| 上下文切换 | 频繁(内核态 ↔ 用户态) | 无(纯用户态) |
| 批量处理 | 不友好(逐包中断) | 天然支持(32/64/128 包) |
| 数据拷贝 | 多次(内核 → 用户) | 零拷贝(DMA 到用户态) |
取舍很明确:牺牲一个 CPU 核,换取确定性、超低延迟的包处理。在每微秒都至关重要的交易系统中,这笔账永远划算。
面向 Burst 的 API
PMD 以 burst(批次) 为单位处理数据包,而非逐包:
#define BURST_SIZE 32
struct rte_mbuf *pkts[BURST_SIZE];
uint16_t nb_rx;
while (1) {
// 每次尝试接收最多 BURST_SIZE 个包
nb_rx = rte_eth_rx_burst(port, queue, pkts, BURST_SIZE);
for (i = 0; i < nb_rx; i++) {
process_packet(pkts[i]);
}
// 每次尝试发送最多 nb_pkts 个包
nb_tx = rte_eth_tx_burst(port, queue, pkts_to_send, nb_pkts);
}
批量处理摊薄了函数调用开销、提高缓存局部性,并使 SIMD 向量化成为可能。典型 burst 大小为 32 或 64——大到足以摊薄开销,小到足以放入 L1 缓存。
三层架构
┌─────────────────────────────────────────────────┐
│ 应用层 │
│ rte_eth_rx_burst() / rte_eth_tx_burst() │
└──────────────────┬──────────────────────────────┘
│
┌──────────────────▼──────────────────────────────┐
│ ethdev 库(lib/ethdev) │
│ 统一 API 抽象 + 函数指针分发 │
└──────────────────┬──────────────────────────────┘
│
┌──────────────────▼──────────────────────────────┐
│ PMD 层(drivers/net/*) │
│ 具体硬件驱动实现 │
│ — virtio, ixgbe, i40e, ice, bnxt ... │
└──────────────────┬──────────────────────────────┘
│
┌──────────────────▼──────────────────────────────┐
│ 硬件(NIC + PCIe) │
└─────────────────────────────────────────────────┘
关键洞察:rte_eth_rx_burst() 本身不包含任何收包逻辑,它只是调用存储在 rte_eth_dev.rx_pkt_burst 中的函数指针。实际实现在设备启动时根据硬件能力选择——标准路径、inorder 路径、packed ring 路径或向量化(SIMD)路径。
这就是快速路径——无分支、无锁,仅一次函数指针间接调用。慢速路径(配置、链路状态、统计)通过 eth_dev_ops 访问,这是一张独立于热路径的函数表。
核心数据结构
eth_rx_burst_t / eth_tx_burst_t
文件: lib/ethdev/rte_ethdev_core.h(第 28–37 行)
typedef uint16_t (*eth_rx_burst_t)(void *rxq,
struct rte_mbuf **rx_pkts,
uint16_t nb_pkts);
typedef uint16_t (*eth_tx_burst_t)(void *txq,
struct rte_mbuf **tx_pkts,
uint16_t nb_pkts);
简洁高效:一个队列指针、一个 mbuf 指针数组、一个数量。返回实际处理的包数。
rte_eth_dev — 设备结构
文件: lib/ethdev/ethdev_driver.h(第 72–117 行)
struct __rte_cache_aligned rte_eth_dev {
// 快速路径函数指针 — 放在结构体开头,缓存友好
eth_rx_burst_t rx_pkt_burst;
eth_tx_burst_t tx_pkt_burst;
eth_tx_prep_t tx_pkt_prepare;
// ... 更多快速路径指针
// 设备数据(跨进程共享)
struct rte_eth_dev_data *data;
// PMD 私有数据
void *process_private;
const struct eth_dev_ops *dev_ops; // 慢速路径函数表
// 设备句柄
struct rte_device *device;
// ...
};
值得注意的设计决策:
- 快速路径指针在偏移 0——第一个缓存行包含
rx_pkt_burst和tx_pkt_burst,每次收发包都访问。无需额外指针追踪。 __rte_cache_aligned——整个结构体从缓存行边界开始,防止与相邻数据产生 false sharing。- 分离
dev_ops——慢速路径表(配置、启动、停止、统计)在不同的缓存行。热路径代码永远不会触碰它。
rte_eth_dev_data — 共享设备状态
文件: lib/ethdev/ethdev_driver.h(第 128–214 行)
struct __rte_cache_aligned rte_eth_dev_data {
char name[RTE_ETH_NAME_MAX_LEN];
void **rx_queues; // RX 队列指针数组
void **tx_queues; // TX 队列指针数组
uint16_t nb_rx_queues;
uint16_t nb_tx_queues;
void *dev_private; // PMD 私有数据
uint16_t port_id;
int numa_node; // NUMA 亲和性
// 位域状态标志(节省空间)
uint8_t promiscuous : 1,
scattered_rx : 1,
dev_started : 1,
// ...
};
这个结构设计为可以放入共享内存,供多进程 DPDK 使用。队列指针数组、配置和状态标志都在这里,辅助进程可以直接访问。
rte_mbuf — 数据包缓冲区
文件: lib/mbuf/rte_mbuf.h
struct rte_mbuf {
MARKER cacheline0;
void *buf_addr; // 缓冲区虚拟地址
rte_iova_t buf_iova; // 物理/IOVA 地址(DMA 用)
RTE_ATOMIC(uint16_t) refcnt; // 引用计数
struct rte_mbuf *next; // 下一段(scatter/gather)
uint64_t ol_flags; // 卸载标志
MARKER cacheline1 __rte_cache_aligned;
uint16_t data_len; // 当前段的数据长度
uint16_t data_off; // 数据起始偏移
struct rte_mempool *pool; // 来源内存池
uint16_t pkt_len; // 整包长度(所有段之和)
MARKER cacheline2 __rte_cache_aligned;
uint16_t port; // 入口端口
uint16_t packet_type; // L2/L3/L4 分类
uint64_t dynfield1[10]; // 应用自定义字段
};
rte_mbuf 有意跨缓存行布局:
- cacheline0 —
buf_addr、buf_iova、refcnt:每次 RX/TX 操作都访问 - cacheline1 —
data_len、data_off、pkt_len:处理包内容时访问 - cacheline2 —
port、packet_type、dynfield1:应用逻辑访问
这种布局确保最频繁访问的字段不会把对方从 L1 缓存中挤出。
VirtIO PMD:运行时路径选择
PMD 最有趣的特点是运行时选择最优实现。VirtIO 注册代码:
文件: drivers/net/virtio/virtio_ethdev.c(第 1350–1420 行)
static void
virtio_set_rxtx_funcs(struct rte_eth_dev *eth_dev)
{
struct virtio_hw *hw = eth_dev->data->dev_private;
// 根据硬件特性选择 TX burst 函数
if (virtio_with_packed_queue(hw)) {
if (hw->use_vec_tx)
eth_dev->tx_pkt_burst = virtio_xmit_pkts_packed_vec; // SIMD
else
eth_dev->tx_pkt_burst = virtio_xmit_pkts_packed;
} else {
if (hw->use_inorder_tx)
eth_dev->tx_pkt_burst = virtio_xmit_pkts_inorder;
else
eth_dev->tx_pkt_burst = virtio_xmit_pkts; // 标准
}
// 根据硬件特性选择 RX burst 函数
if (virtio_with_packed_queue(hw)) {
if (hw->use_vec_rx)
eth_dev->rx_pkt_burst = &virtio_recv_pkts_packed_vec; // SIMD
else if (virtio_with_feature(hw, VIRTIO_NET_F_MRG_RXBUF))
eth_dev->rx_pkt_burst = &virtio_recv_mergeable_pkts_packed;
else
eth_dev->rx_pkt_burst = &virtio_recv_pkts_packed;
} else {
if (hw->use_vec_rx)
eth_dev->rx_pkt_burst = virtio_recv_pkts_vec; // SIMD
else if (hw->use_inorder_rx)
eth_dev->rx_pkt_burst = &virtio_recv_pkts_inorder;
else
eth_dev->rx_pkt_burst = virtio_recv_pkts; // 标准
}
}
函数指针在设备启动时设置一次。之后每次 rte_eth_rx_burst() 调用都直接分发到选定的实现——热路径上零特性检查分支。
VirtIO 支持多达 8 种 RX/TX 路径组合,取决于:
- Packed ring vs. split ring(VirtIO 1.1 特性)
- 向量化(SIMD)vs. 标量
- In-order vs. out-of-order 描述符完成
- Mergeable buffers 用于大包
RX Burst 逐行解读
文件: drivers/net/virtio/virtio_rxtx.c(第 992–1092 行)
uint16_t
virtio_recv_pkts(void *rx_queue, struct rte_mbuf **rx_pkts,
uint16_t nb_pkts)
{
// 1. 设备未启动则快速退出
if (unlikely(hw->started == 0))
return 0;
// 2. 查询设备已使用的描述符数量
nb_used = virtqueue_nused(vq);
// 3. 计算本次批量大小
num = likely(nb_used <= nb_pkts) ? nb_used : nb_pkts;
if (unlikely(num > VIRTIO_MBUF_BURST_SZ))
num = VIRTIO_MBUF_BURST_SZ;
// 缓存行对齐优化
if (likely(num > DESC_PER_CACHELINE))
num = num - ((vq->vq_used_cons_idx + num) % DESC_PER_CACHELINE);
// 4. 从 virtqueue 批量出队
num = virtqueue_dequeue_burst_rx(vq, rcv_pkts, len, num);
// 5. 处理每个 mbuf
for (i = 0; i < num; i++) {
rxm = rcv_pkts[i];
if (unlikely(len[i] < hdr_size + RTE_ETHER_HDR_LEN)) {
virtio_discard_rxbuf(vq, rxm);
continue;
}
rxm->port = hw->port_id;
rxm->data_off = RTE_PKTMBUF_HEADROOM;
rxm->pkt_len = (uint32_t)(len[i] - hdr_size);
rxm->data_len = (uint16_t)(len[i] - hdr_size);
if (hw->has_rx_offload && virtio_rx_offload(rxm, hdr) < 0) {
virtio_discard_rxbuf(vq, rxm);
continue;
}
rx_pkts[nb_rx++] = rxm;
}
// 6. 用新 mbuf 填充已消费的描述符
if (likely(!virtqueue_full(vq))) {
if (likely(rte_pktmbuf_alloc_bulk(rxvq->mpool, new_pkts, free_cnt) == 0))
virtqueue_enqueue_recv_refill(vq, new_pkts, free_cnt);
}
// 7. 仅在必要时通知设备
if (likely(nb_enqueued)) {
vq_update_avail_idx(vq);
if (unlikely(virtqueue_kick_prepare(vq)))
virtqueue_notify(vq);
}
return nb_rx;
}
代码中可见的关键优化技术:
- 批量出队 —
virtqueue_dequeue_burst_rx一次拉取多个描述符 - 缓存行对齐 — 调整批量大小使描述符读取对齐到缓存行边界
likely/unlikely— 编译器分支预测提示- 批量 mbuf 分配 —
rte_pktmbuf_alloc_bulk一次从内存池分配多个 mbuf - 条件通知 — 只在必要时写设备门铃寄存器(
virtqueue_kick_prepare),避免昂贵的 MMIO 写操作
TX Burst 逐行解读
文件: drivers/net/virtio/virtio_rxtx.c(第 1859–1939 行)
uint16_t
virtio_xmit_pkts(void *tx_queue, struct rte_mbuf **tx_pkts,
uint16_t nb_pkts)
{
// 1. 如果描述符环快满,先清理已完成的 TX 描述符
nb_used = virtqueue_nused(vq);
if (likely(nb_used > vq->vq_nentries - vq->vq_free_thresh))
virtio_xmit_cleanup(vq, nb_used);
// 2. 逐包入队
for (nb_tx = 0; nb_tx < nb_pkts; nb_tx++) {
struct rte_mbuf *txm = tx_pkts[nb_tx];
int can_push = 0, use_indirect = 0;
// 优化:header push — 避免单独的描述符
if (virtio_with_feature(hw, VIRTIO_F_VERSION_1) &&
rte_mbuf_refcnt_read(txm) == 1 &&
RTE_MBUF_DIRECT(txm) &&
txm->nb_segs == 1 &&
rte_pktmbuf_headroom(txm) >= hdr_size)
can_push = 1;
// 优化:间接描述符用于多段包
else if (virtio_with_feature(hw, VIRTIO_RING_F_INDIRECT_DESC) &&
txm->nb_segs < VIRTIO_MAX_TX_INDIRECT)
use_indirect = 1;
// 描述符不足时清理已完成的
if (unlikely(need > 0)) {
virtio_xmit_cleanup(vq, need);
if (need > 0) break;
}
virtqueue_enqueue_xmit(txvq, txm, slots, use_indirect, can_push, 0);
}
// 3. 批量发送后只通知一次
if (likely(nb_tx)) {
vq_update_avail_idx(vq);
if (unlikely(virtqueue_kick_prepare(vq)))
virtqueue_notify(vq);
}
return nb_tx;
}
两个 VirtIO 特有优化:
Header Push:不用单独的描述符存 VirtIO 头部,驱动检查 mbuf 的 headroom 是否够用,够的话直接把头部写入 headroom——省掉一个描述符和一次 DMA。
间接描述符:多段包(scatter/gather)不用消耗 N 个描述符存 N 个段,而是用一个”间接”描述符指向内存中的描述符表,减少环空间消耗。
Virtqueue:描述符环
VirtIO 使用驱动和设备之间的共享内存环结构:
┌──────────────────────────────────────┐
│ Available Ring(可用环) │
│ - 待处理/发送的描述符索引 │
│ - 驱动写入,设备读取 │
├──────────────────────────────────────┤
│ Used Ring(已用环) │
│ - 已处理/接收的描述符索引 │
│ - 设备写入,驱动读取 │
├──────────────────────────────────────┤
│ Descriptor Table(描述符表) │
│ - 地址、长度、标志、next 指针 │
│ - 描述 DMA 缓冲区 │
└──────────────────────────────────────┘
RX 方向:驱动预先填充描述符(指向空 mbuf 地址)。设备收到包后 DMA 写入 mbuf,在 Used Ring 中标记完成。驱动在下次 rx_burst() 中出队。
TX 方向:驱动填充描述符(指向待发数据地址)。设备 DMA 读出、发送、标记 used。驱动在下次 tx_burst() 中清理。
性能优化技术
1. SIMD 向量化
DPDK 使用 SSE/AVX/AVX-512/NEON 单指令处理多个描述符:
// virtio_ethdev.c 中的运行时检测(第 2363-2400 行)
#if defined(RTE_ARCH_X86_64) && defined(CC_AVX512_SUPPORT)
if (hw->use_vec_rx &&
(!rte_cpu_get_flag_enabled(RTE_CPUFLAG_AVX512F) ||
!virtio_with_feature(hw, VIRTIO_F_IN_ORDER) ||
rte_vect_get_max_simd_bitwidth() < RTE_VECT_SIMD_512)) {
hw->use_vec_rx = 0; // 回退到标量
}
#endif
向量化路径(virtio_recv_pkts_vec)每次迭代处理 4-8 个描述符,大幅减少指令数。
2. 大页与 TLB 效率
// DPDK 所有内存都从大页分配(2MB 或 1GB)
rte_eal_init(argc, argv); // 启动时预留大页
// 在正确的 NUMA 节点上创建 mbuf 池
struct rte_mempool *mp =
rte_pktmbuf_pool_create("mbuf_pool",
NB_MBUF, MBUF_CACHE_SIZE, 0,
RTE_MBUF_DEFAULT_BUF_SIZE, numa_node);
2MB 大页将 TLB 条目减少到 4KB 页的 1/512。对于 2GB mbuf 池,TLB 条目从 524,288 降到 1,024——大幅降低 TLB 未命中率。
3. NUMA 感知内存布局
// 查询网卡 NUMA 节点
rte_eth_dev_info_get(port, &dev_info);
int numa_node = dev_info.device->numa_node;
// 在同一节点分配 mbuf 池
struct rte_mempool *mp =
rte_pktmbuf_pool_create("mbuf_pool", NB_MBUF,
MBUF_CACHE_SIZE, 0,
RTE_MBUF_DEFAULT_BUF_SIZE,
rte_socket_id_by_device(numa_node));
跨 NUMA 内存访问在典型 x86 服务器上增加 40-80ns 额外延迟。始终在与网卡相同的 NUMA 节点上分配 mbuf 池。
4. 每核 Mempool 缓存
struct rte_mempool *mp =
rte_pktmbuf_pool_create("mbuf_pool",
NB_MBUF, // 总 mbuf 数
MBUF_CACHE_SIZE, // 每核缓存(如 256)
0,
RTE_MBUF_DEFAULT_BUF_SIZE,
socket_id);
每个核有 256 个 mbuf 的本地缓存。分配/释放先走本地缓存——无全局锁竞争。只有缓存耗尽时才访问共享池环。
5. 无锁多队列处理
┌─────────────────────────────────────────┐
│ 物理网卡 │
│ 4 RX 队列 + 4 TX 队列 │
└──────────────────┬──────────────────────┘
│ PCIe
┌──────────┬───┴───┬──────────┐
│ │ │ │
Core 0 Core 1 Core 2 Core 3
Queue 0 Queue 1 Queue 2 Queue 3
│ │ │ │
└──────────┴───────┴──────────┘
无锁竞争
每个核只处理一个 RX 队列和一个 TX 队列。无互斥锁、无原子操作、无 false sharing。这就是为什么 RSS(接收方缩放)至关重要——它根据五元组哈希将包分配到不同队列,保证同一条流的包落在同一个核上。
完整初始化流程
1. EAL 初始化
└─> rte_eal_init()
├─> 枚举 PCI 设备
├─> 预留大页
└─> 设置 CPU 亲和性
2. PMD 探测
└─> rte_eal_pci_probe()
└─> 调用驱动 .probe()
├─> rte_eth_dev_allocate()
├─> 填充 eth_dev_ops
└─> 设置 rx_pkt_burst / tx_pkt_burst
3. 设备配置
└─> rte_eth_dev_configure()
└─> dev_ops->dev_configure()
4. 队列建立
├─> rte_eth_rx_queue_setup()
│ └─> dev_ops->rx_queue_setup()
└─> rte_eth_tx_queue_setup()
└─> dev_ops->tx_queue_setup()
5. 设备启动
└─> rte_eth_dev_start()
└─> dev_ops->dev_start()
└─> 最终确定 rx_pkt_burst / tx_pkt_burst 选择
步骤 2-5 只在启动时执行一次。之后应用进入紧密轮询循环,不再触碰慢速路径。
实战示例:L2 转发
一个最小 DPDK 应用:
#include <rte_eal.h>
#include <rte_ethdev.h>
#include <rte_mbuf.h>
#define BURST_SIZE 32
#define RX_RING_SIZE 1024
#define TX_RING_SIZE 1024
#define NUM_MBUFS 8191
#define MBUF_CACHE_SIZE 250
static volatile bool force_quit;
static void
lcore_main(void *arg)
{
unsigned port_id = (unsigned)(uintptr_t)arg;
struct rte_mbuf *pkts[BURST_SIZE];
uint16_t nb_rx, nb_tx;
while (!force_quit) {
nb_rx = rte_eth_rx_burst(port_id, 0, pkts, BURST_SIZE);
if (unlikely(nb_rx == 0))
continue;
nb_tx = rte_eth_tx_burst(port_id, 0, pkts, nb_rx);
// 释放未发送的包
if (unlikely(nb_tx < nb_rx)) {
for (uint16_t buf = nb_tx; buf < nb_rx; buf++)
rte_pktmbuf_free(pkts[buf]);
}
}
}
int main(int argc, char *argv[])
{
// 1. 初始化 EAL
rte_eal_init(argc, argv);
// 2. 创建 mbuf 池
struct rte_mempool *mbuf_pool =
rte_pktmbuf_pool_create("MBUF_POOL",
NUM_MBUFS, MBUF_CACHE_SIZE, 0,
RTE_MBUF_DEFAULT_BUF_SIZE, rte_socket_id());
// 3. 配置并启动端口
struct rte_eth_conf port_conf = {0};
uint16_t port_id = 0;
rte_eth_dev_configure(port_id, 1, 1, &port_conf);
rte_eth_rx_queue_setup(port_id, 0, RX_RING_SIZE,
rte_eth_dev_socket_id(port_id), NULL, mbuf_pool);
rte_eth_tx_queue_setup(port_id, 0, TX_RING_SIZE,
rte_eth_dev_socket_id(port_id), NULL);
rte_eth_dev_start(port_id);
rte_eth_promiscuous_enable(port_id);
// 4. 在每个 lcore 上启动 worker
RTE_LCORE_FOREACH_WORKER(lcore_id) {
rte_eal_remote_launch(lcore_main, (void *)0, lcore_id);
}
// 5. 等待信号后清理
rte_eal_mp_wait_lcore();
rte_eth_dev_stop(port_id);
rte_eth_dev_close(port_id);
rte_eal_cleanup();
return 0;
}
源码阅读指南
| 优先级 | 文件 | 你能学到什么 |
|---|---|---|
| 1 | lib/ethdev/rte_ethdev.h | 公开 API 文档 |
| 2 | lib/ethdev/ethdev_driver.h | PMD 接口、rte_eth_dev 结构 |
| 3 | lib/mbuf/rte_mbuf.h | mbuf 布局、跨缓存行拆分设计 |
| 4 | drivers/net/virtio/virtio_rxtx.c | RX/TX burst 实现 |
| 5 | drivers/net/virtio/virtio_ethdev.c | 运行时路径选择 |
| 6 | examples/l2fwd/main.c | 最小完整应用 |
从 VirtIO 开始——它是最简单的 PMD。理解描述符环和 burst API 后,再看硬件 PMD 如 ixgbe(Intel 82599)或 ice(Intel E810),了解物理网卡寄存器访问的工作原理。
核心要点
- 轮询用 CPU 换延迟 —— 专用一个核,获得纳秒级响应
- Burst API 摊薄开销 —— 始终以 32+ 的批量处理包
- 函数指针在偏移 0 —— 快速路径除初始调用外无额外间接寻址
- 运行时路径选择 —— 一个二进制同时支持标量、SIMD、packed 和 inorder 路径
- 大页 + NUMA + 每核缓存 —— 内存层次就是性能
- 无锁设计 —— 每核一个队列,热路径上无共享状态
理解 PMD 内部机制对于任何在 DPDK 上构建低延迟交易系统的人来说都至关重要。同样的原理——缓存对齐、批量处理、零拷贝 DMA、运行时分发——适用于从 VirtIO 到 Solarflare EFVI 的每个 PMD 实现。