yuqi-zheng

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;
    // ...
};

值得注意的设计决策:

  1. 快速路径指针在偏移 0——第一个缓存行包含 rx_pkt_bursttx_pkt_burst,每次收发包都访问。无需额外指针追踪。
  2. __rte_cache_aligned——整个结构体从缓存行边界开始,防止与相邻数据产生 false sharing。
  3. 分离 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 有意跨缓存行布局:

  • cacheline0buf_addrbuf_iovarefcnt:每次 RX/TX 操作都访问
  • cacheline1data_lendata_offpkt_len:处理包内容时访问
  • cacheline2portpacket_typedynfield1:应用逻辑访问

这种布局确保最频繁访问的字段不会把对方从 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;
}

代码中可见的关键优化技术:

  1. 批量出队virtqueue_dequeue_burst_rx 一次拉取多个描述符
  2. 缓存行对齐 — 调整批量大小使描述符读取对齐到缓存行边界
  3. likely/unlikely — 编译器分支预测提示
  4. 批量 mbuf 分配rte_pktmbuf_alloc_bulk 一次从内存池分配多个 mbuf
  5. 条件通知 — 只在必要时写设备门铃寄存器(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;
}

源码阅读指南

优先级文件你能学到什么
1lib/ethdev/rte_ethdev.h公开 API 文档
2lib/ethdev/ethdev_driver.hPMD 接口、rte_eth_dev 结构
3lib/mbuf/rte_mbuf.hmbuf 布局、跨缓存行拆分设计
4drivers/net/virtio/virtio_rxtx.cRX/TX burst 实现
5drivers/net/virtio/virtio_ethdev.c运行时路径选择
6examples/l2fwd/main.c最小完整应用

从 VirtIO 开始——它是最简单的 PMD。理解描述符环和 burst API 后,再看硬件 PMD 如 ixgbe(Intel 82599)或 ice(Intel E810),了解物理网卡寄存器访问的工作原理。


核心要点

  1. 轮询用 CPU 换延迟 —— 专用一个核,获得纳秒级响应
  2. Burst API 摊薄开销 —— 始终以 32+ 的批量处理包
  3. 函数指针在偏移 0 —— 快速路径除初始调用外无额外间接寻址
  4. 运行时路径选择 —— 一个二进制同时支持标量、SIMD、packed 和 inorder 路径
  5. 大页 + NUMA + 每核缓存 —— 内存层次就是性能
  6. 无锁设计 —— 每核一个队列,热路径上无共享状态

理解 PMD 内部机制对于任何在 DPDK 上构建低延迟交易系统的人来说都至关重要。同样的原理——缓存对齐、批量处理、零拷贝 DMA、运行时分发——适用于从 VirtIO 到 Solarflare EFVI 的每个 PMD 实现。