4. 报文收发基础

在 DPDK 中,报文不再以“一个字节流”的形式被处理,而是被封装成固定结构的缓冲区对象——mbuf(memory buffer)。理解 mbuf 的字段布局,是理解后续收发包 API、批量处理、零拷贝与 DMA 机制的前提。本节从 mbuf 的内存结构出发,逐步展开到 rte_mbuf 的元数据、收发包 API 的调用方式、批量收发的收益,以及零拷贝与 DMA 如何共同减少数据搬运。

一、mbuf 的结构与内存布局

一个 mbuf 由两部分组成:头部元数据区(struct rte_mbuf)和数据缓冲区(data buffer)。头部元数据区保存报文长度、端口、队列、哈希、校验和、下一段指针等信息;数据缓冲区则真正存放报文内容。二者在内存中通常连续分配,但逻辑上分离。

关键字段包括:

  • buf_addr:数据缓冲区起始地址;
  • data_off:数据起始位置相对于 buf_addr 的偏移;
  • pkt_len:整个报文的总长度(多段时是所有段之和);
  • data_len:当前 mbuf 段内的数据长度;
  • next:指向下一个 mbuf 段,用于链式存储;
  • port、vlan_tci、hash、ol_flags:端口、VLAN、哈希与卸载标志。

当报文长度超过单个 mbuf 的数据区容量时,DPDK 会用 next 指针把多个 mbuf 串成一条链,形成多段报文。此时 pkt_len 表示整条链的总长度,而每个段的 data_len 只表示该段自身的数据长度。收发时,网卡和驱动按段处理,应用层则通过 rte_pktmbuf_read 或遍历 next 来访问完整报文。

二、收发包 API 与批量处理

DPDK 的收发包 API 以“批量”为核心设计。最常用的两个函数是:

uint16_t rte_eth_rx_burst(uint16_t port_id, uint16_t queue_id,
                          struct rte_mbuf **rx_pkts, const uint16_t nb_pkts);

uint16_t rte_eth_tx_burst(uint16_t port_id, uint16_t queue_id,
                          struct rte_mbuf **tx_pkts, uint16_t nb_pkts);

它们一次最多处理 nb_pkts 个 mbuf,返回值是实际收/发的报文数量。批量处理的意义在于:把“每次系统调用/每次函数调用”的固定开销摊薄到多个报文上,同时让 CPU 的预取、分支预测和缓存行利用更充分。典型工程中,nb_pkts 常取 32、64 或 128,具体取决于队列深度、缓存大小和业务时延要求。

一个典型的收包循环如下:

struct rte_mbuf *bufs[BURST_SIZE];
while (1) {
    uint16_t nb_rx = rte_eth_rx_burst(port, 0, bufs, BURST_SIZE);
    if (nb_rx == 0) continue;
    for (uint16_t i = 0; i < nb_rx; i++) {
        process(bufs[i]);
        rte_pktmbuf_free(bufs[i]);
    }
}

这里 rte_pktmbuf_free 负责把 mbuf 归还到内存池。若报文是多段链,它会沿 next 逐段释放。注意:收包后必须释放,否则内存池会耗尽,后续收包直接失败。

三、零拷贝与 DMA 机制

传统内核网络栈中,报文从网卡到用户态通常要经历“DMA 到内核缓冲区 → 拷贝到 socket 缓冲区 → 拷贝到用户缓冲区”的多次搬运。DPDK 通过零拷贝与DMA把这条路径压缩为一次 DMA 写入加一次指针传递。

具体来说,网卡通过 DMA 把报文直接写入预先分配好的 mbuf 数据缓冲区,CPU 不参与数据搬运;驱动只更新 mbuf 的 data_off、data_len、pkt_len 等元数据,然后把 mbuf 指针交给应用。应用拿到的是指向同一块内存的指针,没有发生数据复制,这就是“零拷贝”的含义。

零拷贝成立的前提是:mbuf 缓冲区在物理上连续(或至少对网卡 DMA 可见),且生命周期由内存池统一管理。DPDK 的 rte_mempool 在初始化时就把大页内存注册给网卡,保证 DMA 地址与虚拟地址的映射关系稳定。因此,应用不能随意 free 或 realloc mbuf 数据区,只能通过 rte_pktmbuf_alloc / rte_pktmbuf_free 成对操作。

零拷贝并不等于“完全没有内存操作”。它消除的是 CPU 参与的数据复制,而 mbuf 元数据的读写、指针传递、内存池的分配与回收仍然存在。理解这一点,才能正确评估 DPDK 的性能收益。

四、mbuf 生命周期与内存池

mbuf 不是用 malloc 逐个申请的,而是从 rte_mempool 中批量获取。内存池在启动时一次性申请大页内存,并预先切分成固定大小的 mbuf。收包时驱动从池中取 mbuf,应用处理完后归还。池的容量决定了系统能同时“在途”的报文数量,通常按 队列深度 × 队列数 × 安全系数 估算。

如果应用忘记释放 mbuf,池会逐渐枯竭,rte_eth_rx_burst 返回 0,表现为“收不到包”。排查时可以先看内存池的 rte_mempool_count 是否持续下降,再检查释放路径是否覆盖了所有分支。

DPDK 报文收发:mbuf、DMA 与零拷贝数据路径 网卡 NIC RX/TX DMA 引擎 描述符环 DMA 写入 mbuf 数据缓冲区 报文内容(DMA 直接写入) 无 CPU 数据复制 mbuf 元数据 data_off / data_len pkt_len / next port / hash / ol_flags 指针 应用层 处理 mbuf 零拷贝访问 rte_mempool 内存池(大页内存) 预分配 mbuf · 批量获取 · 批量归还 · 池耗尽则收包失败 alloc free 多段报文链(pkt_len = 各段 data_len 之和) mbuf #1 data_len=512 next mbuf #2 data_len=512 next mbuf #3 data_len=256 pkt_len = 1280 释放时沿 next 逐段归还

五、批量处理的参数选择与观察

批量大小 BURST_SIZE 不是越大越好。取值过小,函数调用开销占比高;取值过大,单次处理时延上升,且 mbuf 数组占用更多栈空间。工程上常从 32 起步,结合 rte_eth_rx_burst 的返回分布来调整:如果长期返回接近 0 或远小于 BURST_SIZE,说明队列深度或内存池容量需要重新评估。

观察收包是否健康,可以关注三个量:rx_burst 返回值的均值、内存池剩余 mbuf 数、丢包计数。若均值明显低于 BURST_SIZE 且内存池充足,通常是流量本身稀疏;若内存池持续下降,则优先检查释放路径。把这些量放在同一张监控面板上,比单独看某一个指标更容易定位问题。

不要在收包循环中长时间阻塞或执行耗时操作。DPDK 的轮询模式要求 CPU 持续调用 rx_burst,一旦某个报文处理过慢,后续报文会在网卡描述符环中堆积,最终导致丢包。必要时把重处理卸载到其他 lcore 或使用无锁队列传递 mbuf 指针。

总结来看,mbuf 是 DPDK 报文处理的统一载体,收发包 API 以批量方式操作 mbuf 指针,零拷贝与 DMA 让数据只搬一次,内存池则保证 mbuf 的分配与回收可控。把这四者串起来,就形成了 DPDK 高吞吐数据路径的基础。