上一篇《高性能网络包处理三板斧: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() │ │ │ └────────────┘ │ └─────────────────────────────────────────────────────────────────┘
这条路径有四个「慢点」:
上下文切换 :硬中断 → 软中断 → 内核态 → 用户态,每个包多次跨越特权级。
sk_buff 分配/释放 :每个包都 alloc_skb/kfree,热点里的内存分配抖动。
数据拷贝 :内核缓冲区 → 用户态 buffer,至少一次 copy。
中断风暴 :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 , .hash_func = rte_jhash, .key_len = sizeof (__u32), .socket_id = rte_socket_id() }; void *tbl = rte_hash_create(&hparams);
JHash (rte_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; struct port_conf ports [RTE_MAX_ETHPORTS ]; struct lcore_port_conf lcore_port_conf [RTE_MAX_LCORE ]; struct rte_mempool *pcktmbuf_pool ; unsigned int packet_burst_size;
这套设计的好处是示例极薄 ——每个 .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 struct lcore_port_conf *qconf = &lcore_port_conf[lcore_id];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; } 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 独立
qconf 按 lcore_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; if (eth->ether_type != htons(ETH_P_IP) && eth->ether_type != htons(ETH_P_8021Q)) { rte_pktmbuf_free(pckt); return ; } 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; if (udph->dst_port == htons(8080 )) { rte_pktmbuf_free(pckt); pckts_dropped++; return ; } swap_eth(eth); swap_iph(iph); swap_udph(udph); }
注意它的转发方式不是「从端口 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), .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) { struct rte_ether_addr *dmac ; 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 ; } rte_ether_addr_copy(&ports[port_id].mac, ð->src_addr); rte_ether_addr_copy(dmac, ð->dst_addr); 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()); int ret = rte_hash_lookup_data(rl_tbl, &iph->src_addr, (void **)&rl);if (ret >= 0 ) { 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 { 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 ; params.name = "lru_table" ; params.seed = 0 ; void *tbl = lru.f_create(¶ms, 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_ops、rte_table_hash_params,f_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); 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,给 ratelimit 和 bench 复用。作者的观察很关键:新条目会插在最近被删除的位置上 ,所以位置指针 pos 要单调递增、到顶绕回 0,循环扫描整个表。
这套方案能跑,但有几个点要清醒认识(下一节展开):它不是真正的 LRU 。
十二、代码里几个值得注意的坑
把别人的代码读细一点,往往比读「正确示例」收获更大。这个项目里有几处真实的瑕疵,正好可以作为 DPDK 编程的反面教材。
12.1 校验和算了但不赋值
dropudp8080.c 在交换地址后要重算校验和,代码是这样写的:
1 2 3 4 5 6 7 iph->hdr_checksum = 0 ; rte_ipv4_cksum(iph); 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 __s32 pos = 0 ; if (cnt > max_entries) { if (pos >= (max_entries - 1 )) pos = 0 ; } if (cnt <= max_entries) cnt++; return 0 ; }
cnt 和 pos 是 static,意味着所有 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 的文件,它对比了四种哈希方案的插入/查询耗时:
DPDK rte_hash + rte_jhash (10 万条目)
GLib GHashTable + g_int_hash (10 万条目)
DPDK rte_hash + 手动 LRU 回收 (5 万条目,插入 10 万次触发淘汰)
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 的学习曲线陡,但落到这种「一个文件一个示例」的粒度上,其实也就那么回事。
参考