上一篇《高性能网络包处理三板斧:XDP、DPDK 与 AF_XDP》讲的是「三条 kernel bypass 路径怎么选」。这篇落到代码:拿一个真实的 DPDK 开源示例项目 The-DPDK-Examples,把概念、封装层、收发包主循环、三个数据面示例、LRU 实现和哈希基准测试一次性拆透。

但在拆代码之前,得先把 DPDK 里那一堆名词讲清楚——否则代码里每一行都看不懂。所以本文前半部分用拓扑图把概念讲透,后半部分逐文件拆代码。如果你已经熟悉 DPDK,可以直接跳到第五节

一、为什么需要「内核旁路」

先建立基准:标准 Linux 收一个网络包,要走的路是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
┌─────────────────────────────────────────────────────────────────┐
│ 传统内核网络栈 │
│ │
│ ┌────────┐ DMA ┌─────────┐ 硬中断 ┌──────────────┐ │
│ │ 网卡 │─────────▶│ 内存 │────────▶│ 内核驱动 │ │
│ │ (NIC) │ │ (DMA区) │ │ (硬中断处理) │ │
│ └────────┘ └─────────┘ └──────┬───────┘ │
│ ▼ │
│ ┌──────────────┐ │
│ 软中断 │ 软中断 │ │
│ NET_RX_SOFTIRQ │ (协议栈入口) │ │
│ └──────┬───────┘ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 协议栈逐层处理: │ │
│ │ netif_receive_skb → IP 层 → TCP/UDP 层 → socket 队列 │ │
│ │ (每层都可能 alloc/free sk_buff、做拷贝) │ │
│ └────────────────────────────┬────────────────────────┘ │
│ ▼ │
│ ┌────────────┐ 上下文切换 │
│ │ 用户态 │◀─────── (内核态→用户态) │
│ │ read() │ │
│ └────────────┘ │
└─────────────────────────────────────────────────────────────────┘

这条路径有四个「慢点」:

  1. 上下文切换:硬中断 → 软中断 → 内核态 → 用户态,每个包多次跨越特权级。
  2. sk_buff 分配/释放:每个包都 alloc_skb/kfree,热点里的内存分配抖动。
  3. 数据拷贝:内核缓冲区 → 用户态 buffer,至少一次 copy。
  4. 中断风暴:pps(每秒包数)一高,CPU 全在处理中断,没空算业务。

对几十万 pps 的普通服务无所谓;但 L4 负载均衡、DDoS 清洗、高频量化行情这些千万级 pps 场景,内核栈就是天花板。于是有了 kernel bypass(内核旁路)——让用户态程序直接碰网卡,绕开整个内核协议栈:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
┌──────────────────────────────────────────────────────────┐
│ DPDK 内核旁路路径 │
│ │
│ ┌────────┐ DMA ┌──────────────┐ │
│ │ 网卡 │────────▶│ 内存(mbuf池) │ │
│ │ (NIC) │ │ │ │
│ └────┬───┘ └──────┬───────┘ │
│ │ ⚡ 不走硬中断 │ ⚡ 不走协议栈 │
│ │ ⚡ 不进 sk_buff │ ⚡ 不做内核拷贝 │
│ ▼ ▼ │
│ ┌──────────────┐ ┌──────────────────────┐ │
│ │ UIO/VFIO │ │ 用户态进程 (你的程序) │ │
│ │ 内核模块 │──▶│ 直接轮询网卡收包队列 │ │
│ │ (只做一件事: │ │ 直接读写包内存 │ │
│ │ 把网卡交给 │ │ 自己处理业务 │ │
│ │ 用户态) │ │ │ │
│ └──────────────┘ └──────────────┼───────────┘ │
│ ▼ │
│ 自己发回去 / 丢弃 / 转发 │
└──────────────────────────────────────────────────────────┘

UIO/VFIO 是一个很「薄」的内核模块,它不处理包,只做一件事:把网卡的寄存器和中断映射到用户态,让用户态程序能直接操控网卡。真正的收发包逻辑全部跑在用户态——这就是「旁路」的含义。

二、DPDK 的核心概念:一张图看清

DPDK 有一堆术语,初学者很容易被绕晕。下面这张总图把它们的关系画出来,然后逐个解释。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
                     ┌─────────────────────────────────────────────┐
│ 用户态进程 (你的 DPDK 程序) │
│ │
┌─────┐ EAL初始化 │ ┌───────────────────────────────────────┐ │
│main()│─────────────▶│ │ EAL (环境抽象层) │ │
└─────┘ │ │ - 解析命令行 (-l 0-1 -n 1) │ │
│ │ - 初始化 hugepage / 内存 / PCI │ │
│ │ - 把网卡绑定到 UIO/VFIO 驱动 │ │
│ └───────────────────────────────────────┘ │
│ │ │
│ ┌─────────────┼─────────────┐ │
│ ▼ ▼ ▼ │
│ ┌───────┐ ┌───────┐ ┌───────┐ │
│ │lcore 0│ │lcore 1│ │lcore 2│ ... │ 每个 lcore
│ │(逻辑核)│ │(逻辑核)│ │(逻辑核)│ │ 跑一个
│ │ │ │ │ │ │ │ │ 独立的
┌─────┐hugepage │ │ │ PMD │ │ PMD │ │ PMD │ │ 轮询线程
│大页 │─────────────▶│ │ │ 轮询 │ │ 轮询 │ │ 轮询 │ │
│内存 │ │ │ ▼ │ ▼ │ ▼ │ │
└─────┘ │ │ mempool │ mempool │ mempool │ │
│ │ (mbuf池)│ (mbuf池) │ (mbuf池) │ │
│ └────┬────┘ └────┬────┘ └────┬────┘ │
└───────┼──────────────┼──────────────┼───────────┘
│ │ │
┌─────────┐ VFIO/UIO │ │ │
│ 网卡0 │◀────────────────┘──────────────┘──────────────┘
│ (NIC0) │ 用户态 PMD 直接读写网卡寄存器,不走系统调用
└─────────┘

2.1 EAL(环境抽象层)—— DPDK 的「启动器」

EAL 全称 Environment Abstraction Layer。你写的每个 DPDK 程序,第一步都是 rte_eal_init(argc, argv),它负责:

  • 解析 EAL 参数:-l 0-1(用哪几个 CPU 核)、-n 1(内存通道数)、--socket-mem(每个 NUMA 节点分多大内存)等;
  • 初始化 hugepage 大页内存;
  • 扫描 PCI,把网卡绑定到 UIO/VFIO 驱动;
  • 启动各 lcore 上的线程。

可以把 EAL 理解成 DPDK 的「操作系统启动器」——它把硬件资源抽象好、内存铺好、线程拉起来,你的业务代码才能跑。

2.2 lcore(逻辑核)—— DPDK 的并行单位

lcore = logical core,是 DPDK 对 CPU 逻辑核的抽象。EAL 初始化时,你用 -l 0-3 指定用哪几个核,DPDK 就在每个核上各起一个线程。

关键点:每个 lcore 跑一个独立的轮询循环,彼此不共享收包队列。一个网卡可以有多个 RX 队列,分别「绑」到不同 lcore 上,这样多核就能并行收包、互不干扰、无需加锁。这就是 DPDK 横向扩展性能的基本手段。

1
2
3
4
5
6
7
  网卡 NIC0
┌──────────┐
│ RX 队列0 │──── 绑定 ────▶ lcore 0 (独立轮询线程)
│ RX 队列1 │──── 绑定 ────▶ lcore 1 (独立轮询线程)
│ RX 队列2 │──── 绑定 ────▶ lcore 2 (独立轮询线程)
└──────────┘
每个队列只被一个 lcore 读 → 无锁 → 线性扩展

2.3 hugepage(大页内存)—— 为什么 DPDK 要用它

普通 Linux 内存页是 4KB。DPDK 处理百万级 pps 时,4KB 页会引发大量 TLB miss(地址翻译 cache 未命中),CPU 要去查页表,很慢。

hugepage 是 2MB 或 1GB 的大页,一个页覆盖大片连续内存,TLB 命中率大幅提升。DPDK 把所有收发包用的内存都从 hugepage 里分配,避免运行时的页表抖动。EAL 初始化时会把它准备好。

2.4 mempool 和 mbuf —— 包的「内存池」和「容器」

这是理解 DPDK 收发包的核心。两个概念配套出现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
┌─────────────────────────────────────────────┐
│ mempool (内存池) │
│ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │
│ │ mbuf │ │ mbuf │ │ mbuf │ │ mbuf │ ... │ ← 预分配好的一大片
│ │ #0 │ │ #1 │ │ #2 │ │ #3 │ │ mbuf 容器
│ └──────┘ └──────┘ └──────┘ └──────┘ │
└─────────────────────────────────────────────┘
│ 拿一个 │ 用完还回去
▼ ▼
┌──────────────┐ ┌──────────────┐
│ 网卡收到包 │ │ 处理完, │
│ DMA 把包数据 │ │ free 回池子 │
│ 写进 mbuf │ │ 供下次复用 │
└──────────────┘ └──────────────┘
  • mempool:程序启动时一次性预分配好的、固定大小的对象池。里面的对象就是 mbuf。运行时不再 malloc/free,只从池里取、用完还回——这是 DPDK 避免内存分配抖动的核心机制。
  • mbuf:message buffer,一个网络包的「容器」。它内部有一段缓冲区存包数据,还带元数据(包长度、引用计数、时间戳等)。网卡收包时,DMA 直接把包写进某个 mbuf 的缓冲区;程序处理完,调 rte_pktmbuf_free 把它还回池子。

代码里你看到的 pckt->buf_addr + pckt->data_off 就是「拿到这个 mbuf 里包数据的起始地址」——buf_addr 是缓冲区起点,data_off 是数据在里面的偏移(因为包头可能没从缓冲区最开头开始)。

2.5 PMD(轮询模式驱动)—— 用 CPU 换吞吐的关键

PMD = Poll Mode Driver,轮询模式驱动。传统驱动靠中断通知「来包了」;PMD 完全不用中断,而是让 lcore 在一个死循环里不停调 rte_eth_rx_burst 查「队列里有没有包」。

代价是:这个 lcore 的 CPU 会被 100% 占满(因为它在空转轮询)。收益是:没有中断开销、没有上下文切换,吞吐拉满。这就是为什么 DPDK 程序跑起来 top 看到相关核心 100%——这是设计如此,不是 bug。

2.6 哈希表(rte_hash)—— 数据面的「精确匹配」

DPDK 的 rte_hash 是一个为数据面优化的哈希表,常用场景:以某个 key(比如目的 IP)查一个对应的 value(比如下一跳 MAC)。它和普通哈希表(如 std::unordered_map、GLib 的 GHashTable)的区别:

特性 rte_hash (DPDK) 通用哈希表
内存分配 启动时从 mempool 预分配,运行时不 malloc 每插一个 key 可能 malloc
cache 友好 桶结构按 cache line 对齐 通常不保证
线程模型 设计为 per-lcore 或带细粒度锁 通常带全局锁
用途 千万级 pps 的逐包查表 通用业务逻辑

代码里建表的套路:

1
2
3
4
5
6
7
8
9
10
struct rte_hash_parameters hparams = {
.name = "route_table",
.entries = 1024, // 最多存 1024 条
.hash_func = rte_jhash, // 用 jhash 算哈希
.key_len = sizeof(__u32), // key 是 4 字节(IPv4 地址)
.socket_id = rte_socket_id() // 内存分配在哪个 NUMA 节点
};
void *tbl = rte_hash_create(&hparams);
// 插: rte_hash_add_key_data(tbl, &ip, &mac);
// 查: rte_hash_lookup_data(tbl, &ip, (void **)&mac);

JHashrte_jhash)是 DPDK 内置的一个快速哈希函数,专为这种场景设计。后面的基准测试就是拿它和 GLib 的哈希比性能。

2.7 LRU —— 当表「装不下」时怎么办

LRU = Least Recently Used,最近最少使用。它是一种淘汰策略:当一个固定大小的表满了,需要腾位置插新条目时,淘汰那个最久没被访问过的条目

为什么数据面需要它?比如限速场景,你要给每个源 IP 存计数器。但互联网上的源 IP 可能上千万,你不可能全存下——表大小固定(比如 10 万),满了就得踢人。踢谁?LRU 说:踢那个「最久没来包」的 IP,因为它大概率不再是活跃流量。

1
2
3
4
5
6
7
8
9
10
11
固定容量的哈希表 (容量=4), 满了要插新 key:

状态: [A] [B] [C] [D] (A 最老, D 最新)

来了个新 key E │ 表已满, 必须淘汰一个

LRU 淘汰最老的 A:
[B] [C] [D] [E] ← A 被踢, E 进来

(注意: 真正的 LRU 要在"每次访问"时把该条目挪到"最新"端,
本项目实现的其实是 FIFO 循环淘汰, 见第十节)

概念讲清楚了,下面进入代码。


五、项目是什么

The-DPDK-Examples 是开发者 Christian Deacon(gamemann)在学习 DPDK 过程中积累的一组示例与测试程序。它的定位很明确:用尽量薄的代码把 DPDK 数据面开发的几个典型场景跑通——包过滤、三层转发、限速、LRU 哈希、哈希性能对比。

项目本身不复杂,但好处正在于此:它没有淹没在 DPDK 官方 example 那种「为了通用而庞杂」的细节里,而是把每个示例都压到一个 .c 文件内,非常适合作为学习样本。

整体由两部分组成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
dpdk-examples/
├── The-DPDK-Common/ # 封装层(子模块, 独立项目)
│ └── src/
│ ├── dpdk_common.h # 全局变量 + 辅助函数声明
│ └── dpdk_common.c # EAL 初始化 / 端口队列 / lcore 启动 / LRU 辅助
├── src/
│ ├── cmdline.{c,h} # 共用的命令行参数解析
│ ├── dropudp8080.c # 过滤 UDP 8080 + 反射转发
│ ├── simple_l3fwd.c # 哈希路由表三层转发
│ ├── ratelimit.c # 按源 IP 的 PPS/BPS 限速(带 LRU 回收)
│ ├── lrutest.c # 手动 LRU 验证
│ ├── lru_table_test.c # 尝试 DPDK 内置 LRU table(作者没跑通)
│ └── bench_jhash_ghash.c # DPDK JHash vs GLib GHash 基准测试
└── Makefile # pkg-config 链接 DPDK, 一把编译全部

先讲封装层,因为后面所有示例都站在它肩膀上。

六、The-DPDK-Common:把 DPDK 的样板代码收拢成一层

写过 DPDK 的人都知道一个痛点:每个程序的 main 前 80 行几乎一模一样——EAL 初始化、数端口、配端口对、建 mempool、初始化 RX/TX 队列、映射 lcore、检查链路状态……这些样板代码来自 DPDK 官方的 l2fwd 示例,gamemann 把它重写、精简、加了详细注释,做成了这个 Common 库。

6.1 一个自定义返回值结构

Common 库的所有函数都不返回裸 int,而是返回一个统一结构:

1
2
3
4
5
6
7
8
9
10
struct dpdkc_ret
{
char *gen_msg;
int err_num;
int port_id;
int rx_id;
int tx_id;
__u32 data;
void *dataptr;
};

配套有 dpdkc_check_ret(&ret)——检查 err_num,非零就直接打印错误并 rte_exit 退出。于是每个示例的 main 里,初始化步骤读起来像一条流水线:

1
2
3
4
5
6
7
8
9
10
11
ret = dpdkc_eal_init(argc, argv);          dpdkc_check_ret(&ret);
ret = dpdkc_get_nb_ports(); dpdkc_check_ret(&ret);
ret = dpdkc_check_port_pairs(); dpdkc_check_ret(&ret);
ret = dpdkc_ports_are_valid(); dpdkc_check_ret(&ret);
dpdkc_reset_dst_ports();
dpdkc_populate_dst_ports();
ret = dpdkc_create_mbuf(); dpdkc_check_ret(&ret);
ret = dpdkc_ports_queues_init(promisc, 1, 1); dpdkc_check_ret(&ret);
ret = dpdkc_ports_queues_mapping(); dpdkc_check_ret(&ret);
ret = dpdkc_ports_available(); dpdkc_check_ret(&ret);
dpdkc_check_link_status();

这一串把「EAL → 端口 → 队列 → mempool → lcore 映射 → 链路」整套启动流程封装掉了,示例代码因此能聚焦在包处理逻辑本身。对应到前面概念图,它干的事是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
main()


┌─────────────────────────────────────────────────┐
│ dpdkc_eal_init() ← EAL 初始化(大页/PCI/lcore)
│ dpdkc_get_nb_ports() ← 数有几个网卡
│ dpdkc_ports_queues_init() ← 给每个网卡建 RX/TX 队列
│ dpdkc_create_mbuf() ← 建 mempool(包的内存池)
│ dpdkc_ports_queues_mapping() ← 把队列绑到 lcore
│ dpdkc_check_link_status() ← 看链路通不通
└─────────────────────────────────────────────────┘


一切就绪, 可以收发包了

6.2 暴露的全局状态

Common 库还导出一组全局变量(extern 在头文件里,链接 .o 即可用),省去了示例间反复传参:

1
2
3
4
5
volatile __u8 quit;                         // 信号处理置 1, 退出主循环
struct port_conf ports[RTE_MAX_ETHPORTS]; // 每个端口的 MAC / TX buffer / 对端端口
struct lcore_port_conf lcore_port_conf[RTE_MAX_LCORE]; // 每个 lcore 负责哪些 RX/TX 端口
struct rte_mempool *pcktmbuf_pool; // 收包用的 mbuf 内存池
unsigned int packet_burst_size; // 一次 burst 收多少包(默认 32)

这套设计的好处是示例极薄——每个 .c 文件只实现两个函数:一个处理单个包(inspect_pckt / fwd_pckt),一个跑在 lcore 上的主循环(pckt_loop)。其余全交给 Common。

6.3 lcore 启动

1
2
3
4
5
6
7
8
9
void dpdkc_launch_and_run(void *f)
{
rte_eal_mp_remote_launch((int (*)(void *))f, NULL, CALL_MAIN);
RTE_LCORE_FOREACH_WORKER(lcore_id)
{
if (rte_eal_wait_lcore(lcore_id) < 0)
break;
}
}

rte_eal_mp_remote_launch 把传入的函数在每个 lcore 上各跑一份(CALL_MAIN 表示主 lcore 也参与),然后主线程 rte_eal_wait_lcore 等所有 worker 退出。这是 DPDK 的标准并行模型——每个 lcore 一个收包轮询线程,彼此独立

七、收发包主循环:所有示例的骨架

三个数据面示例(dropudp8080 / simple_l3fwd / ratelimit)的 pckt_loop 几乎逐行相同,是理解整个项目的钥匙。先用图看清它的数据流:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
                      lcore 上的 pckt_loop 死循环
┌──────────────────────────────────────────────────────────────┐
│ while(!quit) │
│ { │
│ ┌───────────────────────┐ │
│ │ ① TSC 定时: 到点了吗? │──否─────────────────┐ │
│ └──────────┬────────────┘ │ │
│ │ 是 │ │
│ ▼ │ │
│ ┌───────────────────────┐ │ │
│ │ ② flush: 把 TX buffer │ │ │
│ │ 里攒的包真正发出去 │ │ │
│ └──────────┬────────────┘ │ │
│ │ │ │
│ ▼◀───────────────────────────────────┘ │
│ ┌───────────────────────┐ │
│ │ ③ rx_burst: 从网卡 RX │ 一次最多拿 32 个包 │
│ │ 队列 burst 收一批包 │ │
│ └──────────┬────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────┐ │
│ │ ④ 逐包: 预取下一个包 │ ┌─ inspect_pckt(pckt) ──┐ │
│ │ 调业务函数处理当前包 │ │ (各示例的差别全在这) │ │
│ └──────────┬────────────┘ └────────────────────────┘ │
│ │ │
└───────────────┴───────────── 回到循环顶部 ──────────────────────┘

摘其核心代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// 每个 lcore 各自一份配置: 它负责收哪些端口
struct lcore_port_conf *qconf = &lcore_port_conf[lcore_id];

// 用 TSC 算出一个"冲刷阈值": 每隔约 100us 强制 flush 一次 TX buffer
const __u64 draintsc = (rte_get_tsc_hz() + US_PER_S - 1) / US_PER_S * BURST_TX_DRAIN_US;

while (!quit)
{
curtsc = rte_rdtsc();
difftsc = curtsc - prevtsc;

// ① 定时把缓冲里的包真正发出去
if (unlikely(difftsc > draintsc))
{
for (i = 0; i < qconf->num_tx_ports; i++)
{
port_id = ports[qconf->rx_port_list[i]].tx_port;
buffer = ports[port_id].tx_buffer;
for (j = 0; j < tx_queue_pp; j++)
rte_eth_tx_buffer_flush(port_id, j, buffer);
}
prevtsc = curtsc;
}

// ② 从每个 RX 端口 burst 收包
for (i = 0; i < qconf->num_rx_ports; i++)
{
port_id = qconf->rx_port_list[i];
nb_rx = rte_eth_rx_burst(port_id, 0, pckts_burst, packet_burst_size);

// ③ 逐包预取 + 处理
for (j = 0; j < nb_rx; j++)
{
pckt = pckts_burst[j];
rte_prefetch0(rte_pktmbuf_mtod(pckt, void *)); // 预取下一个包的数据
inspect_pckt(pckt, port_id); // 业务逻辑在这里
}
}
}

这套循环体现了 DPDK 数据面的几个关键设计:

设计点 代码体现 为什么这么做
轮询而非中断 while(!quit) 死循环 rte_eth_rx_burst 高 pps 下中断本身是瓶颈,轮询用 CPU 换吞吐
批量收发 rte_eth_rx_burst 一次拿最多 32 个 摊薄每次调用的固定开销,发挥 DMA 预取
TX 聚合 + 定时冲刷 rte_eth_tx_buffer 攒包,TSC 到点 flush 不必每包一次 rte_eth_tx_burst,减少系统调用
预取 rte_prefetch0 下一个包 收包和处理重叠,隐藏内存访问延迟
每 lcore 独立 qconflcore_id 取配置 无锁,端口/队列在 lcore 间静态划分

这里有几个名词再点一下:

  • TSC(Time Stamp Counter):CPU 内置的时间戳计数器,每个时钟周期加 1。rte_rdtsc() 读它,rte_get_tsc_hz() 拿频率。比系统调用 gettimeofday 快一两个数量级,DPDK 用它做定时。
  • burst(突发):一次收/发多个包,而不是一个一个来。packet_burst_size 默认 32。批量操作是 DPDK 性能的关键。
  • 预取(prefetch):CPU 取内存很慢(上百周期)。rte_prefetch0 提前把下一个包的数据「拉」进 CPU cache,等真要处理时它已在 cache 里了——用「处理当前包」的时间掩盖「取下一个包」的延迟。

理解了这个骨架,三个示例的差别就只剩 inspect_pckt / fwd_pckt 里的几十行业务逻辑了。

八、示例一:dropudp8080 —— 包过滤与「反射」转发

这是最简单的示例。先看它对每个包做了什么:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
        收到一个包 (mbuf)


┌─────────────────┐
│ 解析以太网头 │ 看 ether_type
└────────┬────────┘

┌────────┴────────┐
▼ ▼
IPv4/VLAN? 其他类型
│ │
│ └──▶ 丢弃(free mbuf)

解析 IPv4 头, 看协议号


是 UDP? ──否──▶ 丢弃
│ 是

┌─────────────────┐
│ UDP 目的端口=8080?│
└────────┬────────┘

┌──────┴──────┐
▼ ▼
是 8080 不是 8080
│ │
▼ ▼
丢弃 交换 MAC/IP/端口
(计数) 重算校验和


发回 TX (反射)

对应代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
static void inspect_pckt(struct rte_mbuf *pckt, unsigned port_id)
{
void *data = pckt->buf_addr + pckt->data_off;
struct rte_ether_hdr *eth = data;

// 只处理 IPv4 或带 VLAN 的包
if (eth->ether_type != htons(ETH_P_IP) && eth->ether_type != htons(ETH_P_8021Q))
{ rte_pktmbuf_free(pckt); return; }

// VLAN 头多 4 字节,偏移要跳过
unsigned int offset = sizeof(struct rte_ether_hdr);
if (eth->ether_type == htons(ETH_P_8021Q)) offset += 4;

struct rte_ipv4_hdr *iph = data + offset;
if (iph->next_proto_id != PROTOCOL_UDP) { rte_pktmbuf_free(pckt); return; }

offset += (iph->ihl * 4);
struct rte_udp_hdr *udph = data + offset;

// 命中 UDP 8080 就丢
if (udph->dst_port == htons(8080))
{ rte_pktmbuf_free(pckt); pckts_dropped++; return; }

// 否则: 交换 MAC / IP / UDP 端口, 把包"弹"回去
swap_eth(eth); swap_iph(iph); swap_udph(udph);
// ...重算校验和、送 TX buffer...
}

注意它的转发方式不是「从端口 A 收、从端口 B 发」,而是交换源/目的地址后从同一个端口发回去——相当于一个「反射器」。这种模式适合做旁路清洗、探针回注。

几个细节值得学习:

  • 包头解析靠指针强转 + 偏移data = buf_addr + data_off 拿到包数据起点,之后逐层 struct rte_ether_hdr * / rte_ipv4_hdr * / rte_udp_hdr * 强转。DPDK 不给你抽象,包头就是裸内存,你得自己算偏移。下面是包在内存里的样子:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
mbuf 缓冲区
┌──────────────────────────────────────────────────────┐
│ buf_addr │
│ │ │
│ ▼ │
│ ┌───────────┬───────────────┬──────────────────────┐ │
│ │ <padding> │ 以太网头 │ IP头 │ UDP头 │ 载荷 │ │
│ │ │(ether_hdr) │(iph) │(udph) │ │ │
│ └───────────┴───────────────┴───────┴──────┴──────┘ │
│ ▲ ▲ ▲ │
│ │data_off │offset │offset │
│ data指针 ────────────┘ │ │
│ (=buf_addr+data_off) │ │
│ iph指针 ──────────────────────┘ │
└──────────────────────────────────────────────────────┘
  • VLAN 处理ETH_P_8021Q(0x8100)时多跳 4 字节。带 802.1Q 标签的以太网帧在以太网头和 IP 头之间多了 4 字节的 VLAN tag,解析时必须跳过,否则 IP 头指针就指错了。
  • iph->ihl * 4:IP 头长度是可变的(带 options 时 >20 字节),IHL 字段以 4 字节为单位,所以乘 4 得到实际字节数。不能写死 20。

九、示例二:simple_l3fwd —— 哈希路由表三层转发

这个示例展示 DPDK 最经典的「精确匹配 + 转发」范式。它在启动时建一张哈希表,键是目的 IP,值是对应的下一跳 MAC:

1
2
3
4
5
6
7
8
9
10
11
12
┌────────────────────────────────────────────────┐
│ route_table (rte_hash) │
│ key = 目的IP(4字节) value = 下一跳MAC(6字节) │
│ │
│ 10.50.0.4 ──────────▶ ae:21:14:4b:3a:6d │
│ 10.50.0.5 ──────────▶ d6:45:f3:b1:a4:3d │
│ 10.50.0.6 ──────────▶ ... │
│ ... (最多 1024 条) │
└────────────────────────────────────────────────┘
▲ 查表

从 /etc/l3fwd/routes.txt 启动时读入

建表代码:

1
2
3
4
5
6
7
8
9
struct rte_hash_parameters hparams =
{
.name = "route_table",
.entries = 1024,
.hash_func = rte_jhash,
.key_len = sizeof(__u32), // IPv4 地址 4 字节
.socket_id = rte_socket_id()
};
void *route_tbl = rte_hash_create(&hparams);

路由从 /etc/l3fwd/routes.txt 读入,格式 <ip> <mac>

1
2
10.50.0.4 ae:21:14:4b:3a:6d
10.50.0.5 d6:45:f3:b1:a4:3d

scan_route_table_and_add 逐行解析:strtok 切 IP 和 MAC,inet_aton 把 IP 转成 32 位整数,sscanf 把 MAC 字符串拆成 6 字节,最后 rte_hash_add_key_data 插表。

转发时的处理流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
        收到一个包 (mbuf)


┌─────────────────┐
│ 解析到 IPv4 头 │
└────────┬────────┘


┌─────────────────────┐
│ 用目的IP查 route_table│ rte_hash_lookup_data
└────────┬────────────┘

┌────────┴────────┐
▼ ▼
查不到 查到了
│ │
▼ ▼
丢弃 改 MAC: 源MAC←本端口MAC
(计数) 目的MAC←查到的下一跳MAC


从对端端口 TX 发出

对应代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
static void fwd_pckt(struct rte_mbuf *pckt, unsigned port_id, struct rte_hash *route_tbl)
{
// ...解析到 IPv4 头...
struct rte_ether_addr *dmac;

// 用目的 IP 查表
int is_routable = rte_hash_lookup_data(route_tbl, &iph->dst_addr, (void **)&dmac);

if (is_routable < 0) // 查不到 → 丢
{ pckts_dropped++; rte_pktmbuf_free(pckt); return; }

// 查到了 → 改 MAC: 源 MAC 换成本端口 MAC, 目的 MAC 换成路由表里的下一跳
rte_ether_addr_copy(&ports[port_id].mac, &eth->src_addr);
rte_ether_addr_copy(dmac, &eth->dst_addr);

// 送 TX buffer
rte_eth_tx_buffer(dst_port, 0, buffer, pckt);
}

这就是一个最小可用的 L3 转发器:查目的 IP → 改两层 MAC → 从对端端口发出去。注意它只改 MAC 不改 IP(IP 转发不修改 IP 地址本身,TTL 递减在这个简化示例里也省了),是纯「交换 MAC 头」式的转发。

rte_hash 之所以适合这个场景,是因为它rte_jhash 做哈希、桶与条目 cache 对齐、查找是纯内存读——千万级 pps 下,每次查表就是纳秒级。这正是 DPDK l3fwd 官方示例的核心思路,这个项目把它剥离到了最简形态。

十、示例三:ratelimit —— 哈希表 + LRU 的按源 IP 限速

这个示例最有看头,它把「状态跟踪」和「容量回收」两个数据面难题都碰到了。逻辑:对每个源 IP 维护一个计数器,超过 PPS/BPS 阈值就丢包。

10.1 整体数据流

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
             收到一个包 (mbuf)


┌────────────────────────┐
│ 提取源 IP (src_addr) │
└───────────┬────────────┘


┌────────────────────────┐
│ 用源IP查 rate_limit 表 │ rte_hash_lookup_data
└───────────┬────────────┘

┌──────────┴──────────┐
▼ ▼
已有记录 新 IP (查不到)
│ │
▼ ▼
┌──────────────┐ ┌───────────────────┐
│ 同一秒内? │ │ 表满了? │
│ 是: pps++ │ │ 是: LRU踢一个旧的 │
│ bps+=len │ │ 否: 直接插 │
│ 超限→丢 │ └─────────┬─────────┘
│ 否: 计数清零 │ │
└──────┬───────┘ ▼
│ 插入新计数器 {pps=1,bps=len}

不超限 → 交换地址,转发

10.2 状态结构

1
2
3
4
5
6
struct rate_limit
{
__u64 pps; // 这一秒内的包数
__u64 bps; // 这一秒内的字节数
__u64 lastupdate; // 上次更新的秒时间戳
};

每个源 IP 一条记录,存在哈希表里,键是源 IP(4 字节)。

10.3 限速判定

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
__u64 ts = (rte_rdtsc() / rte_get_tsc_hz());   // TSC 转秒

int ret = rte_hash_lookup_data(rl_tbl, &iph->src_addr, (void **)&rl);

if (ret >= 0) // 这个 IP 已有记录
{
if ((ts - rl->lastupdate) > 1) // 跨秒 → 计数器清零
{ rl->pps = 0; rl->bps = 0; rl->lastupdate = ts; }
else
{
rl->pps++; rl->bps += pckt->pkt_len;
if ((pps > 0 && rl->pps >= pps) || (bps > 0 && rl->bps >= bps))
{ rte_pktmbuf_free(pckt); pckts_dropped++; return; } // 超限 → 丢
}
}
else // 新 IP
{
// 表满了? 用 LRU 踢掉最老的, 再插入
if (check_and_del_lru_from_hash_table(rl_tbl, MAX_TABLE_SIZE) == 0)
{
struct rate_limit newrl = { .pps = 1, .bps = pckt->pkt_len, .lastupdate = ts };
rte_hash_add_key_data(rl_tbl, &iph->src_addr, &newrl);
}
}

时间戳用 rte_rdtsc() / rte_get_tsc_hz()——直接读 CPU 时间戳计数器再除以频率,得到秒。这比 clock_gettime 快得多,是 DPDK 里取时间的标准姿势。

10.4 LRU 回收

check_and_del_lru_from_hash_table 是 Common 库提供的辅助函数,逻辑后面单独讲。这里它的作用是:当哈希表条目数超过上限(10 万),踢掉一个「最老」的条目腾位置,避免表无限膨胀。

限速这个示例因此同时演示了 DPDK 数据面的两个高频需求:带状态的逐包处理(每个包都要查表 + 更新计数器)和有限状态空间的回收(表不能无限大)。

十一、LRU 的曲折故事

这是整个项目里最「真实」的部分——作者在 README 里坦承:他想用 DPDK lib/table 里的 LRU 表,但死活初始化不成功,最后自己手搓了一个。

11.1 失败的尝试:lru_table_test.c

1
2
3
4
5
6
7
8
9
10
11
struct rte_table_ops lru = rte_table_hash_lru_ops;
struct rte_table_hash_params params = {0};
params.f_hash = (rte_table_hash_op_hash)rte_jhash;
params.key_size = sizeof(__u64);
params.n_buckets = 1024;
params.n_keys = 1; // ← 这里 n_keys=1 很可疑
params.name = "lru_table";
params.seed = 0;

void *tbl = lru.f_create(&params, rte_socket_id(), sizeof(__u32));
if (tbl == NULL) printf("Table is NULL.\n\n");

rte_table_hash_lru_ops 是 DPDK lib/table 提供的内置 LRU 表算子集(f_create / f_add / f_lookup / f_delete)。这套 API 面向的是 DPDK 的软件流水线(pipeline)框架,接口比 rte_hash 复杂得多:要传 rte_table_opsrte_table_hash_paramsf_add 的签名带 key_found 和 entry 指针的指针。

作者卡在了初始化——tbl 返回 NULL。具体原因 README 没细说,但从 n_keys = 1 这个参数看,很可能参数配置本身就有问题(LRU 表至少要能容纳多个 key 才有意义)。lib/table 这套 API 文档稀少、示例罕见,新手踩坑非常正常。这个「失败的尝试」文件被保留在仓库里,本身就是一种诚实的工程记录。

11.2 手搓的 LRU:lrutest.c + Common 库

既然内置 LRU 表用不了,作者退回到 rte_hash(非 LRU 的普通哈希表),自己实现淘汰逻辑。核心思路是用 rte_hash_get_key_with_position 按位置取 key——普通的 rte_hash 不直接给你「最老的 key」,但它内部有槽位顺序,可以按位置(0, 1, 2, …)取出每个槽位的 key:

1
2
3
4
5
6
7
8
9
10
11
12
13
rte_hash 内部槽位 (逻辑视图):

pos: 0 1 2 3 4 ... N-1
┌─────┬─────┬─────┬─────┬─────┐ ┌─────┐
key: │ IP0 │ IP1 │ IP2 │ IP3 │ IP4 │ ... │IPN-1│
└─────┴─────┴─────┴─────┴─────┘ └─────┘

│ pos 指针从 0 开始, 删一个就 +1,
│ 到 N-1 后绕回 0, 循环扫描整个表

策略: 表满了要插新 key 时,
按 pos 取出该位置的 key → 删除 → pos++
(新 key 会插在被删的位置)

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int pos = 0;
for (int i = 1; i < 1000; i++)
{
if (i > MAX_TABLE_SIZE) // 表满了
{
if (pos > (MAX_TABLE_SIZE - 1)) // 位置指针绕回
pos = 0;

int *key;
rte_hash_get_key_with_position(rl_tbl, pos, (void **)&key); // 按位置取 key
pos++;
rte_hash_del_key(rl_tbl, key); // 删掉
}
rte_hash_add_key_data(rl_tbl, &i, &val); // 插新的
}

Common 库把这个逻辑封装成 check_and_del_lru_from_hash_table,给 ratelimitbench 复用。作者的观察很关键:新条目会插在最近被删除的位置上,所以位置指针 pos 要单调递增、到顶绕回 0,循环扫描整个表。

这套方案能跑,但有几个点要清醒认识(下一节展开):它不是真正的 LRU

十二、代码里几个值得注意的坑

把别人的代码读细一点,往往比读「正确示例」收获更大。这个项目里有几处真实的瑕疵,正好可以作为 DPDK 编程的反面教材。

12.1 校验和算了但不赋值

dropudp8080.c 在交换地址后要重算校验和,代码是这样写的:

1
2
3
4
5
6
7
// 重算 IP 头校验和
iph->hdr_checksum = 0;
rte_ipv4_cksum(iph);

// 重算 UDP 校验和
udph->dgram_cksum = 0;
rte_ipv4_udptcp_cksum(iph, udph);

问题在于:rte_ipv4_cksum()rte_ipv4_udptcp_cksum() 都是返回 uint16_t 的函数,它们不会把结果写回头部。这里调用了、却把返回值丢了,包头里校验和依然是 0。正确写法是:

1
2
iph->hdr_checksum = rte_ipv4_cksum(iph);
udph->dgram_cksum = rte_ipv4_udptcp_cksum(iph, udph);

ratelimit.c 里有同样的模式。在「反射」场景下,对端如果严格校验校验和就会丢这些包;之所以示例「能跑」,多半是因为测试环境没开严格校验,或者收端 NIC 硬件没拦。这是写 DPDK 包处理代码时最容易犯的一类错——API 返回值没接住。

先说清楚「校验和(checksum)」是什么:网络包的每个头部(IP 头、UDP/TCP 头)都带一个 16 位的校验和字段,它是「头部所有字节按特定算法求和」的结果。收端会重算一遍来验证包在传输中有没有损坏。一旦你改了包头的任何字段(比如交换了 IP 地址),就必须重新计算校验和并写回那个字段,否则收端一算发现对不上,就会把包当坏包丢弃。

12.2 栈变量的地址存进了哈希表

ratelimit.c 插入新条目时:

1
2
struct rate_limit newrl = { .pps = 1, .bps = pckt->pkt_len, .lastupdate = ts };
rte_hash_add_key_data(rl_tbl, &iph->src_addr, &newrl);

newrl 是栈上的局部变量,rte_hash_add_key_data 存的是它的指针。但 inspect_pckt 一返回,这块栈内存就失效了——之后别的包查到这个 IP,拿到的 rl 指针指向一段已被覆盖的栈,读出来的 pps/bps/lastupdate 全是垃圾

这个坑的核心是「栈」和「堆」的生命周期区别,用图说明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
函数 inspect_pckt() 执行时:
┌─────────── 栈内存 (函数返回即失效) ───────────┐
│ newrl = {pps:1, bps:1200, lastupdate:5} │
│ ▲ │
│ │ 存进哈希表的是这个地址 │
└──────┼─────────────────────────────────────────┘

哈希表: src_ip=1.2.3.4 ──▶ 指针──┐

函数返回后: ▼
┌─────────── 栈内存 (已被覆盖!) ──────────────┐
│ 这里现在是别的局部变量/垃圾值 │
│ 但哈希表还指着这里! │
└─────────────────────────────────────────────┘

下一个包查 1.2.3.4:
rl = (struct rate_limit *)指针 ──▶ 读到的是垃圾!

正确做法是从 rte_malloc 或 mempool 里分配 struct rate_limit,再把那块堆内存的地址存进表——堆内存不会随函数返回失效,要等你主动释放。这个 bug 会导致限速计数器在多包/多 lcore 场景下完全失准,是个典型的「能编译、能跑、结果不对」的内存生命周期错误。

12.3 static 变量让 LRU 辅助函数非线程安全

Common 库里的 LRU 回收函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
int check_and_del_lru_from_hash_table(void *tbl, __u32 max_entries)
{
static __u32 cnt = 0; // ← static
static __s32 pos = 0; // ← static

if (cnt > max_entries)
{
if (pos >= (max_entries - 1)) pos = 0;
// ...按 pos 取 key、删除、pos++...
}
if (cnt <= max_entries) cnt++;
return 0;
}

cntposstatic,意味着所有 lcore 共享这两个变量,且无锁。DPDK 的模型是每个 lcore 一个独立线程并行跑 pckt_loop,多个 lcore 同时调这个函数会踩 cnt/pos,导致计数错乱、位置指针打架、甚至删错 key。

1
2
3
4
5
6
7
8
9
10
11
lcore 0 线程 ─┐                     ┌─ lcore 1 线程
│ │
▼ ▼
┌─────────────────────────────────────┐
│ static cnt ← 两个线程同时读写! │
│ static pos ← 两个线程同时读写! │ ← 数据竞争
│ (无锁) │
└─────────────────────────────────────┘
│ │
▼ ▼
可能删错 key pos 被互相覆盖

在单 lcore 跑没问题;一旦 -l 0-3 多核并行,这里就是数据竞争。正解是把这些状态放进 per-lcore 结构,或用原子操作/锁保护。「DPDK 里能用 per-lcore 变量就别用全局/static」是一条重要原则。

12.4 这是 LRU 还是 FIFO?

严格说,这套「按位置 0→N 循环删除」的策略不是 LRU(Least Recently Used),而是 FIFO/循环淘汰rte_hash_get_key_with_position(tbl, pos, ...) 取的是哈希表内部槽位第 pos 个 key,这个顺序是插入顺序/哈希分布决定的,跟「最近是否被访问」无关。

真正的 LRU 需要维护访问时间序(比如每个条目带时间戳,淘汰时找最旧的;或用双向链表把访问过的挪到队头,淘汰队尾)。这套实现省掉了这个开销,代价是可能淘汰掉刚刚访问过的热 key。对限速场景影响不大(源 IP 流量通常持续),但如果拿它当通用 LRU 用,命中率会不如真 LRU。理解这一层区别,才不会把这套方案误用到需要严格 LRU 语义的地方。

两种策略的对比:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
真正的 LRU (按"最近访问时间"淘汰):

访问顺序队列 (队头最老, 队尾最新):
[A] [B] [C] [D] 假设 B 刚被访问过

访问 B 后, B 挪到队尾:
[A] [C] [D] [B]
满了要淘汰 → 踢队头 A (A 最久没被访问)

─────────────────────────────────────────

本项目的 FIFO/循环淘汰 (按"插入位置"淘汰):

槽位: [0:A] [1:B] [2:C] [3:D]

pos 指针始终从 0 开始递增,
满了就删 pos=0 的 A, 不管 B 刚被访问没:
[0:新] [1:B] [2:C] [3:D], pos→1

上面这几条不是在挑刺——能把这些坑写进公开仓库让人学习,本身就是贡献。对读者而言,「为什么这里会错」比「教科书正确代码」信息量大得多

十三、基准测试:DPDK JHash vs GLib GHash

bench_jhash_ghash.c 是项目里唯一带 bench 的文件,它对比了四种哈希方案的插入/查询耗时:

  1. DPDK rte_hash + rte_jhash(10 万条目)
  2. GLib GHashTable + g_int_hash(10 万条目)
  3. DPDK rte_hash + 手动 LRU 回收(5 万条目,插入 10 万次触发淘汰)
  4. DPDK 内置 rte_table_hash_lru(5 万条目)

测法很朴素——clock() 计时,循环插/查,打印 clock_t 差值:

1
2
3
4
5
6
7
8
start = clock();
for (i = 0; i < MAX_TABLE_SIZE; i++)
{
__u32 key = i; int val = 1;
rte_hash_add_key_data(jhash_tbl, &key, &val);
}
end = clock();
printf("JHash Insert => %lu.\n", end - start);

GLib 那边因为 GHashTable 要求 key 是堆分配的,每次插入要 g_new0 一个 key 对象:

1
2
3
__u32 *key = g_new0(__u32, 1); *key = i;
int *val = g_new0(int, 1); *val = 1;
g_hash_table_insert(ghash_tbl, key, val);

这本身就让 GLib 吃亏——多两次堆分配。但这也恰恰是结论的关键:DPDK 的 rte_hash 在设计上就是为数据面优化的——条目内存批量预分配、桶结构 cache 对齐、key 内联存储不用每次 malloc;而通用哈希表(GLib、甚至 std::unordered_map)为了通用性付出了堆分配和 cache 不友好的代价。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
DPDK rte_hash 插一个条目:
┌──────────────────────────────────┐
│ 预分配好的表 (mempool, 启动时建好) │
│ [ ] [ ] [ ] [ ] [ ] ... │
│ ▲ │
│ 直接填进去, 0 次 malloc │
└──────────────────────────────────┘

GLib GHashTable 插一个条目:
┌──────────────────────────────────┐
│ 1. g_new0(key) ←── malloc! │
│ 2. g_new0(val) ←── malloc! │
│ 3. g_hash_table_insert │
└──────────────────────────────────┘
每条多两次堆分配 → cache 不友好 → 慢

这个 benchmark 虽然粗糙(clock() 测的是 CPU 时间而非墙钟,没预热,没多次取均值),结论方向是对的:在高频插入/查询的数据面场景,专用的 rte_hash 显著优于通用哈希表。它也顺带演示了第三种方案(手动 LRU 的 rte_hash)和第四种(内置 LRU table)的用法——后者正好接上第十一节那个「作者没跑通」的故事,这里 f_create 同样可能返回 NULL。

十四、收尾:从一个学习项目能学到什么

把这个小项目从头读到尾,相当于走了一遍 DPDK 用户态数据面开发的「最小闭环」:

  • 启动流程:EAL → 端口 → 队列 → mempool → lcore 映射,全被 Common 层封装;
  • 收发骨架:轮询 + burst + TX 聚合 + 预取,是所有 DPDK 数据面的通用模板;
  • 三种业务范式:无状态过滤(dropudp8080)、精确匹配转发(simple_l3fwd)、有状态跟踪 + 回收(ratelimit);
  • 工程现实:内置 LRU 表文档匮乏、API 返回值容易漏接、多核下 static 变量是雷、栈指针生命周期……这些是真正写代码才会撞到的墙。

它不完美——校验和、栈指针、线程安全都有可挑的毛病——但作为「一个学习者在公开仓库里记录自己踩坑过程」的产物,它的教学价值反而高于一份精致但抽象的官方示例。读带瑕疵的真实代码,往往比读打磨过的示例成长得更快。

如果你也想上手 DPDK,建议照着这个项目的路径走:先把 l2fwd 官方示例跑通,再照着 Common 层把启动流程封装一遍,最后在 pckt_loop 里塞自己的 inspect_pckt——从丢一个端口、到查一张表、再到带状态限速,每一步都能在几十行 C 里看到效果。DPDK 的学习曲线陡,但落到这种「一个文件一个示例」的粒度上,其实也就那么回事。

参考