☰
DPDK上实现简易UDP协议栈:收发包、ARP/ICMP与校验和实战
2026/10/16 3:27:24 网站建设 项目流程

做网络后端的人,迟早会撞上DPDK这三个字母。数据包从网卡进来,中断通知内核,内核再拷贝到用户态,socket 一层层剥协议头——这条路在万兆甚至更高速率下会明显吃力,更别说现在动不动就几百G的业务场景。DPDK 的核心思路很简单:把网卡数据直接收入用户态进程,绕过内核协议栈,用轮询代替中断,代码自己说了算。但代价也直观——你到手的是一个接一个的mbuf,里面是裸的以太网帧,TCP/IP栈的每一步都得自己写。这篇博文就用来解决这个问题:在 DPDK 上实现一套“能收、能发、能应答 ARP/ICMP、能把 UDP 校验和算对”的简易 UDP 协议栈。适合刚接触 DPDK、被收发包搞到头疼的开发者,也适合需要把某类 UDP 业务从内核协议栈搬进用户态的从业者参考。

为什么我会强调“算对校验和”而不是“实现解析”?因为我在这个项目上踩的坑,几乎都出在细节上:字节序、伪头部、ARP 缓存的超时处理、mbuf 池容量不够导致的收包静默失败。这些东西没有现成文档写得清楚,几乎全靠实测和读源码一点点抠。我希望这篇记录能帮你少走弯路,把核心逻辑一次理透。

1. 思路拆解:为什么要在 DPDK 上重写一套 UDP 栈

1.1 内核协议栈慢在哪,DPDK 又改了什么

我们先说个生活类比。内核协议栈处理数据包的过程,像一个窗口排队办事:网卡收到一个包就触发一个中断,相当于按一次铃,CPU 放下手头的活去窗口处理;处理完再回到原来的工作。流量小的时候没问题,但流量一大,CPU 大部分时间都在“按铃—处理—按铃”的切换里,真正用来算业务的时间反而变少。这还没算上数据要从内核态拷贝到用户态、socket 层排队、协议栈逐层校验这些开销。

DPDK 做的事情,相当于把这个窗口直接租给你一个人用,来了数据你自己取,不需要按铃通知,也不需要排队等内核处理。它有几个关键手段:

  • UIO/VFIO 驱动:把网卡设备映射到用户态,应用可以直接读写网卡的收发队列。
  • 轮询模式:PMD(Poll Mode Driver)线程不停地从 RX 队列里取包,没有中断开销。
  • 大页内存:使用 HugePage 减少 TLB miss,内存访问更快更稳。
  • 内存池:rte_mempool预先分配好一批 mbuf 节点,收发包时从池里取、用完归还,没有频繁内存分配。

于是“实现 UDP 协议栈”这个任务,核心就变成:我们自己写一个数据包的解析和构造逻辑,替代内核里那段被绕过的代码。

1.2 简易协议栈的边界:做什么,不做什么

这里必须泼一盆冷水。凡是带着“简易”二字的用户态协议栈,一定是有边界的。我给自己定的范围很具体:

  • 必须支持:以太网帧收发、IPv4 基本解析、UDP 收发、ARP 请求/响应、ICMP Echo 响应、UDP 校验和计算。
  • 不做:IPv4 分片重组不完整、TCP 状态机不做、多队列 RSS 只做最简单的 rss 配置、IPv6 整体放弃、路由表只保留默认网关。

这个边界是我踩过几次坑之后才想明白的。一开始我雄心勃勃地想塞进 ICMP、IGMP、甚至把 TCP 的握手也写了,结果调试 ARP 和校验和就花了一周。所以后来的项目我都坚持一个原则:协议栈从需求出发,能跑通业务闭环就够了,后期加功能只做加法不做重构。

把这个模块拆分来看,大概是这样的结构:

模块职责涉及的核心内容
收包引擎从网卡批量拉取数据rte_eth_rx_burst、mbuf 回收
解析层逐层剥离头部以太网头、IPv4 头、UDP 头的偏移与长度检查
协议分支识别并响应不同类型ARP、ICMP、UDP 业务分发
发包引擎构造报文并发送mbuf 申请、头部填充、rte_eth_tx_burst
校验与统计正确性校验与状态监控IP/UDP 校验和、收发包计数、错误统计

1.3 为什么实现 UDP 会比实现 TCP 更“容易踩坑”

说实话,UDP 的协议本身确实比 TCP 简单。UDP 头只有 8 字节,没有序列号、没有状态机、没有拥塞控制,这听起来很轻松。但如果要做性能,UDP 有些地方反而更别扭:

一是校验和。IPv4 的 UDP 校验和是可选字段,但如果你置 0,很多网络工具和接收端会直接丢包或显示很奇怪的现象。所以实际实现时应该算,而且必须把伪头部算进去。

二是无状态。TCP 有连接,至少你能通过状态机确认对方还在不在。UDP 是发完就走,你的协议栈要自己考虑 ARP 表项何时失效、终端何时离线,不然就会出现“包发出去了,没人认账”的情况。

三是不分片处理。大包超过 MTU 时 IP 层可能分片,简易栈不做分片重组,对端收到的是碎片拼不起来的“尸体”。这不算协议栈的错,只是边界问题,提前心里有数。

这些“坑”不是协议复杂,而是隐藏在对细节的无知里。后面我会把每个关键点都展开讲。

2. 环境准备:让 DPDK 先跑起来再谈协议

2.1 大页内存与网卡绑定的操作细节

DPDK 环境初始化会直接卡死没有经验的人。你需要 root 权限,需要预留大页内存,还需要把网卡从内核驱动摘下来。我习惯把这套步骤写成一个可重复执行的脚本,避免每次手工操作出错。

首先预留大页:

echo 1024 > /sys/kernel/mm/hugepages/hugepages-2048kB/nr_hugepages mkdir -p /mnt/huge mount -t hugetlbfs nodev /mnt/huge

这会在系统里准备 1024 个 2MB 大小的大页,相当于 2GB 内存。如果你的机器内存紧张,可以减半,但低于 512 个页面时,DPDK 的 mempool 会显得很局促。

然后是网卡绑定。先看当前网卡 PCI 地址,再把它从内核驱动切换到 DPDK 支持的驱动(比如igb_uio或vfio-pci):

# 查看所有网卡的 PCI 标识和现有驱动 dpdk-devbind.py --status # 假设目标网卡位于 0000:03:00.0,绑定到 vfio-pci modprobe vfio-pci dpdk-devbind.py --bind=vfio-pci 0000:03:00.0

这里有个很实用的经验:绑定前确认网卡上没有在跑的 IP 配置,否则你 SSH 会瞬间断开,而且整个网卡当前连接的所有 socket 都会失效。我在某次物理机上调试时远程连接中断过几次,后来都改成调试机上操作或者带外管理口连接了。

2.2 EAL 初始化的正确姿势

EAL(Environment Abstraction Layer)是 DPDK 里负责 CPU、内存、设备管理的抽象层。程序里的第一步必须是初始化 EAL:

#define MAX_CORES 4 static int init_eal(int argc, char **argv) { int ret = rte_eal_init(argc, argv); if (ret < 0) { printf("EAL init failed: %d\n", ret); return -1; } return 0; }

启动参数里一般要指定大页内存的通道数量和每个核的工作模式。比如:

./udp_stack -l 0-3 -n 4 --file-prefix=udp_test

-l 0-3表示使用 0 到 3 号逻辑核,-n 4表示 4 个内存通道。file-prefix 的作用是避免多进程同时使用同一份大页内存映射时互相干扰。如果你在调试中遇到 "EAL: Cannot create lock" 这类报错,大部分是上一次进程没退干净或者 file-prefix 冲突导致的。

2.3 mbuf 池设计与容量估算

mbuf 是 DPDK 里承载数据包的内存节点。初始化时我们创建的不是单个 mbuf,而是一个池子(mempool)。池子的大小要用一个简单的公式估算:

  • 网卡 RX 队列深度:128 或 256,表示驱动内最多缓存的包数。
  • 并发收包线程数:每个线程独立消费一个队列。
  • 应用处理时占用的临时 mbuf 数:比如从 RX 到 TX 之间,包要先入业务队列再出队,队列长度就是额外占用。

我常用的经验公式是:池子大小 = 端口数 × 队列深度 × 4。例如单端口、队列深度 256,就把池子开成 1024 个 mbuf。如果跑业务后发现丢包,先用rte_eth_stats看是否 rx_missed 疯狂增长,是的话优先加大池子和队列深度,而不是怀疑协议逻辑。

struct rte_mempool *create_mbuf_pool(unsigned int socket_id) { return rte_pktmbuf_pool_create("UDP_POOL", MBUF_POOL_SIZE, /* 池内 mbuf 节点数 */ 256, /* 缓存大小,通常取整数 */ 0, /* 私有数据大小,这里为 0 */ MBUF_DATA_SIZE, /* 每个 mbuf 的数据区大小 */ socket_id); }

MBUF_DATA_SIZE要仔细想。9000 字节的巨型帧需要 9KB 以上,但如果你只跑普通 UDP 小包,建议仍然留到 2048 字节以上,防止后续扩展 VLAN 或隧道头时放不下。我一般直接设成 2048。

3. 收包路径:从网线上到 UDP 负载

3.1 从rte_eth_rx_burst到业务分发

DPDK 的收包是一个循环:不断调用rte_eth_rx_burst,每次尽量从 RX 队列拿出一批包,然后逐个处理。

static void rx_loop(uint8_t port_id, uint16_t queue_id) { struct rte_mbuf *bufs[RX_BURST_SIZE]; while (1) { uint16_t nb_rx = rte_eth_rx_burst(port_id, queue_id, bufs, RX_BURST_SIZE); if (nb_rx == 0) { continue; } for (uint16_t i = 0; i < nb_rx; i++) { handle_packet(bufs[i]); } rte_pktmbuf_free_bulk(bufs, nb_rx); } }

注意一个细节:从rx_burst拿到的每个 mbuf,用完必须释放回池子,否则池子耗尽后收包会直接静默失败。rte_pktmbuf_free_bulk是批量释放接口,比逐个释放高效很多。

这里的handle_packet就是协议栈解析入口。对每个包,我们只需要读头部字段,不做数据拷贝,所以性能很高。

3.2 按偏移量解析以太网/IP/UDP 头部

我解析头部时,喜欢定义一组结构体去“描述”帧格式,而不是直接定义 C 结构体映射数据。原因在于结构体对齐、编译器 padding 可能带来意想不到的偏移问题,特别是发送包时,你填完结构体之后用memcpy到数据区,稍有差错就会错位。

更稳妥的做法是直接用字节偏移和偏移宏:

#define ETH_TYPE_IPV4 0x0800 #define ETH_TYPE_ARP 0x0806 #define IP_PROTO_UDP 17 static int parse_packet(struct rte_mbuf *mbuf, struct udp_hdr **udp) { char *data = rte_pktmbuf_mtod(mbuf, char *); /* 以太网头长度固定为 14 字节 */ uint16_t eth_type = ntohs(*(uint16_t *)(data + 12)); if (eth_type == 0x8100) { /* 如果打了 VLAN 标签,实际协议类型在额外 4 字节后面 */ return -EINVAL; // 简易实现先不支持 VLAN } if (eth_type == ETH_TYPE_IPV4) { char *ip = data + 14; uint8_t ihl = (*ip) & 0x0F; char *udp_hdr_pos = ip + (ihl * 4); /* 再取 IP 头里的协议字段判断是否为 UDP */ ... } else if (eth_type == ETH_TYPE_ARP) { /* 走 ARP 处理 */ } return -1; }

这里有个非常容易忽视的 IFG:检查包长。rte_mbuf的pkt_len字段反映整个帧的长度。直接从偏移处读取要警惕越界,尤其碰到畸形包、被截断的包时,容易读取到旧内存或别的包的数据。简易栈不求完全健壮,但至少要做一个长度下限判断。

3.3 校验和验证:不只是算一遍

IP 头和 UDP 头都有校验和。IP 校验和覆盖整个 IP 头部,UDP 校验和则覆盖伪头部(伪 IP 头 + UDP 头 + 负载)。

IPv4 校验和的算法如下:

  • 把头部按 16 位一组对齐相加
  • 如果结果超过 16 位,把高 16 位加到低 16 位(进位折叠)
  • 最后取反即得校验和

UDP 校验和则必须包含伪头部。伪头部结构是:

字段大小值
源 IP4 字节IP 头里的源地址
目的 IP4 字节IP 头里的目的地址
协议1 字节17(UDP)
保留1 字节0
UDP 长度2 字节UDP 头 + 负载长度

核心代码可以这样写:

static unsigned short checksum(void *addr, int count, uint32_t init) { uint32_t sum = init; uint16_t *ptr = addr; while (count > 1) { sum += *ptr++; count -= 2; } if (count > 0) { sum += *(uint8_t *)ptr; } while (sum >> 16) { sum = (sum & 0xffff) + (sum >> 16); } return ~sum; } static uint32_t udp_checksum(uint32_t src, uint32_t dst, uint16_t udp_len, void *udp_data) { uint32_t sum = 0; uint16_t *srcp = (uint16_t *)&src; uint16_t *dstp = (uint16_t *)&dst; sum += *srcp++; // 伪头部源 IP 低 16 位 sum += *srcp++; // 伪头部源 IP 高 16 位 sum += *dstp++; // 伪头部目的 IP 低 16 位 sum += *dstp++; // 伪头部目的 IP 高 16 位 sum += htons(IPPROTO_UDP); // 协议字段 sum += htons(udp_len); // UDP 长度 return checksum(udp_data, udp_len, sum); }

一个实际中非常容易犯的错:算 UDP 校验和时把udp_len直接用了主机字节序,这会导致所有包在抓包工具里看起来校验和错误。伪头部里的长度、协议都必须转成网络字节序(大端)。我调试时用 Scapy 构造测试包和原生包对照,才定位到这个错误。

4. 发包路径:构造 UDP 数据报

4.1 填写头部时最容易错的位置

发包比收包更考验细节,因为收包错了顶多丢包,发包错了就是对端也收不到。UDP 报文构造顺序我建议按传输层 → 网络层 → 链路层自下而上填,这样逻辑最清晰:

static int build_udp_pkt(struct rte_mbuf *mbuf, uint32_t src_ip, uint32_t dst_ip, uint16_t src_port, uint16_t dst_port, char *payload, uint16_t payload_len) { char *p = rte_pktmbuf_mtod(mbuf, char *); /* 1. UDP 头,偏移 14 + 20 = 34 */ struct udp_hdr *udp = (struct udp_hdr *)(p + 14 + 20); udp->src_port = htons(src_port); udp->dst_port = htons(dst_port); udp->len = htons(payload_len + 8); // 校验和暂缓,最后再填 /* 2. 拷贝负载 */ memcpy(p + 14 + 20 + 8, payload, payload_len); /* 3. IP 头 */ struct ipv4_hdr *ip = (struct ipv4_hdr *)(p + 14); ip->version_ihl = 0x45; ip->total_length = htons(20 + 8 + payload_len); ip->src_addr = src_ip; ip->dst_addr = dst_ip; ip->next_proto_id = 17; ip->time_to_live = 64; /* 4. 以太网头 */ struct ether_hdr *eth = (struct ether_hdr *)p; // 填目的 MAC,源 MAC eth->ether_type = htons(0x0800); /* 5. 最后计算并填充 IP 和 UDP 校验和 */ ip->hdr_checksum = htons(checksum(ip, 20, 0)); udp->dgram_cksum = htons(udp_checksum(src_ip, dst_ip, payload_len + 8, udp)); return 0; }

为什么 IP 头放在地址偏移 14 处?因为以太网头正好 14 字节(目的 MAC 6 + 源 MAC 6 + 类型 2)。IPv4 标准头长度 20 字节,没有可选字段的话,UDP 头就贴在偏移 34 处。这个 34 是计算出来的,不是拍脑袋写的。

4.2 查 ARP 表决定目的 MAC

发包必须填充以太网头的目的 MAC。那 MAC 地址哪里来?必须通过 ARP 查询。简易做法是维护一张静态表 + 一个超时时间:

struct arp_entry { uint32_t ip; uint8_t mac[6]; time_t last_seen; };
  • 收到 ARP 请求时,如果目标 IP 是自己,就回 ARP 应答,同时记录对方 IP 和 MAC。
  • 发送 UDP 包前,先在表里查目的 IP;查不到就发 ARP 请求,并且丢队列等 ARP 响应,最多等几百毫秒,收到响应再发包。

这个等待逻辑很关键,否则你第一次给某个新 IP 发 UDP 包时,会因为没有 MAC 地址而白白丢包。很多简易协议栈“能收不能第一时间发送”的 bug 都出在这一步:第一次发包没查 ARP 表就直接发,结果被网卡拦住。

4.3 用rte_eth_tx_burst发送的注意事项

mbuf 在发送后会被驱动释放回池子。所以如果你的业务线程想要保留 payload,必须提前把数据拷出去,不能继续引用 mbuf。此外,rte_eth_tx_burst发送失败时,一般返回少于你传入的数量,这时应该把失败的包重新入队或者释放,否则会造成内存泄漏。

static uint16_t send_pkt(uint8_t port_id, uint16_t queue_id, struct rte_mbuf *mbuf) { uint16_t sent = rte_eth_tx_burst(port_id, queue_id, &mbuf, 1); if (sent < 1) { rte_pktmbuf_free(mbuf); return 0; } return 1; }

我看到过一种容易忽略的优化:如果你在循环里逐包调用rte_eth_tx_burst,一次只发一个,性能会非常差,因为整个驱动流水线和网卡的发送描述符没有被批量利用。正确做法是先把一批包收集到数组里,等凑够一批或超时,再一次性rte_eth_tx_burst全部发送。

5. ARP 与 ICMP 处理:让协议栈有“回应”

5.1 ARP 表维护与超时机制

一个稳定的 UDP 协议栈,应该有 ARP 学习和老化机制。我实现了一个非常简单但有效的方案:

  • 定时器每 30 秒扫一遍 ARP 表,超过 120 秒没有流量命中的表项直接清除。
  • 收到任何源 IP 的 ARP 请求或应答时,立即更新或插入表项。
  • 发包时查 ARP 表,如果没有对应项,则填充广播 MACFF:FF:FF:FF:FF:FF作为临时目的 MAC,先发 ARP 请求,再重试发送 UDP 包。

注意,底层的网卡并不会因为你填了广播 MAC 就把 UDP 发到所有主机。以太网交换机看到广播域目的 MAC 会泛洪,但你的 UDP 包还是会被所有收到的主机的协议栈丢弃(因为目的 IP 不是它们自己)。所以这个做法只用于“先发个广播包把 ARP 请求带出去”,真正的业务包必须等 ARP 响应到达后再发。

5.2 回复 ICMP Echo 请求

很多网络诊断工具依赖 ICMP,如果你不处理,ping永远不通,会给调试造成很大的误导。ICMP Echo 处理逻辑非常简单:

static void handle_icmp(struct rte_mbuf *mbuf, char *ip_hdr) { struct ipv4_hdr *ip = (struct ipv4_hdr *)ip_hdr; char *icmp = ip_hdr + (ip->ihl * 4); uint8_t type = icmp[0]; if (type == 8) { /* Echo Request */ // 替换 type 为 0,重新计算 ICMP 校验和,换源目的地址,发回 icmp[0] = 0; ... } }

这里有一个很有意思的细节:ICMP 校验和覆盖的范围是 ICMP 头加数据,不包含 IP 头。你修改 type 字段后必须重新计算校验和,否则对端 ping 工具会认为响应无效。我见过有人直接在原请求包上改 type 就发回去,结果抓包显示请求发出去了,就是等不到响应,最后发现是校验和没重算。

6. 常见问题与排查实录

6.1 收包计数一直为零

如果你发现rte_eth_rx_burst永远返回 0,先从下面几个方向查:

现象可能原因处理方式
启动时设备绑定失败网卡被内核驱动占用先dpdk-devbind.py --bind=vfio-pci重新绑定
能启动但收不到包RX 队列配置不正确确认rte_eth_rx_queue_setup里的 socket_id 与 mbuf 池一致
收包数量为 0 且无中断网卡 links state 未 up调用rte_eth_dev_set_link_up或检查网线/交换机端口
流量进来但业务没反应网卡多队列 RSS,包分发到其他队列确认队列绑定的核都启动了收包循环

有一次我在虚拟机里测,网卡类型是 virtio,结果 DPDK 需要给 QEMU 加 msi-x 参数才能看到中断和 rx 包。这类环境问题不是代码问题,但排查起来最浪费时间。

6.2 UDP 校验和常见错误

这是最高频的 bug 集中区。我总结出三种典型情况:

  1. 长度字段没转htons:表现为抓包工具显示“UDP length 0x2800 而不是 0x0028”,非常明显。
  2. 伪头部字节序混乱:源 IP 和目的 IP 必须以 32 位网络字节序加入校验和,但如果你已经用ntohl转成主机序再算,就会出错。
  3. CPU 的 checksum offload:如果自己的驱动支持 TX checksum offload,而你又在软件里算了校验和,可能会被网卡二次计算覆盖。简易方案统一在软件里算,然后关闭 offload,或者在填 IP/UDP 头时显式配置mbuf->ol_flags关闭 offload。

6.3 mbuf 池耗尽导致的“诡异丢包”

当池子里没有空闲 mbuf 时,收包接口会直接把新包丢掉,并且不会产生非常显眼的错误日志。如果你发现业务速率高时丢包上升,同时rte_eth_stats的rx_missed在涨,大概率是池子太小。

我的实践经验是:池子大小不要只按 “够用就好” 去设。设为业务峰值包的 2 倍最稳妥,并配合rte_mempool_full和rte_mempool_count做监控,在代码里定期打印余量,这样丢包发生时能立刻定位。

7. 验证与性能观察

7.1 用 Scapy 构造对端测试

我用了某台普通 PC 作为测试机,网卡直连同一个交换机。对端只需要一个能发 UDP 包的脚本,我用 Python 的 socket 就能完成一部分,但更需要构造畸形包来测试协议栈的容错性时,Scapy 才是主力:

from scapy.all import Ether, IP, UDP, ARP, ICMP, sendp # 构造一个带 VLAN 标签的包,测试接收端能否识别 sendp(Ether(dst="00:11:22:33:44:55")/Dot1Q(vlan=100)/IP(dst="192.168.1.100")/UDP(dport=9999)/b"hello", iface="eth0") # 构造一个校验和错误的包,测试协议栈是否拒绝 sendp(Ether(...)/IP(...)/UDP(chksum=0xFFFF)/b"bad", iface="eth0")

测试结果表明:协议栈能正确处理标准 ARP 请求、能够回应 ICMP Echo,业务侧 UDP 接收延迟大概在几微秒级别。

7.2 和内核协议栈的对比

我用本机内核 socket 也接收同样的流量,做了一组粗对比:24 核机器、单核跑 DPDK 收包线程,内核协议栈用多线程并发收。DPDK 单核吞吐约是内核多核的 2~3 倍,延迟中位数更低。考虑到 DPDK 只用了单核,如果启用多队列 RSS 多核并发,差距会更夸张。

但我不建议上来就拿这个数字到处炫耀。简单协议栈只解决了“让包能通”的问题,真正的高性能还需要考虑无锁队列、批量处理、NUMA 亲和、指令优化。普通场景下内核协议栈可能已经足够满足需求,DPDK 的收益主要在极端流量延迟敏感的业务里体现。

8. 最后的经验分享与扩展方向

在实现这个简易栈的过程中,我最大的几个感悟是:

第一个是关于“校验和”的态度。它看起来是最基础的安全保障,却是整个栈里最容易出错的环节。我强烈建议在开发初期就写好一个“抓包对照测试脚本”,每实现一个头部就抓包对比一次,不要等全部写完再统一验证。

第二个是关于调试效率。DPDK 项目不像普通用户态程序可以随时gdb停在一个断点上观察一切——你一旦停止轮询,网卡队列就堆积了。我习惯在关键路径埋性能计数器和状态计数器,先用日志确认逻辑路径,再在必要的地方用条件断点。

第三个是关于扩展。这套 UDP 协议栈留了几个自然的扩展方向:

  • 加入 IPv4 分片重组模块,支持更大的 UDP 负载。
  • 加入简单的 TCP 状态机,做一个能处理 SYN/ACK 的最小实现。
  • 启用多队列 RSS 配合多核轮询,提升吞吐。
  • 与用户态协议栈框架对接,把流量无缝交给业务层。

如果你能在我这份基础上,把校验和、ARP 老化、发包批量优化这三件事做扎实,再往 TCP 上走就不会太痛苦。我后续把代码清理干净后,也会继续分享扩展版本的实现细节。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询