DPDK网络编程实战:手把手实现ARP回显及避坑指南

发布时间:2026/10/18 16:23:57
DPDK网络编程实战:手把手实现ARP回显及避坑指南 干我们这行的人手里只要有几台物理机、一个小交换机总会忍不住想碰一碰 DPDK。尤其是在做用户态协议栈、网关、负载均衡这类东西的时候第一道绕不过去的坎就是 ARP内核不帮你应答所有邻居发现都要自己处理。最近我在一个模拟项目X里实现了一套基于 DPDK 的收包转发逻辑其中“ARP 回显”这个看起来很小的功能实际落地时牵扯到的细节一点都不少。把这套代码案例拆开来讲对刚接触 DPDK 的开发者会很有帮助也能给已经在写网络转发面的人一些踩坑参考。1. 为什么要在 DPDK 里亲自处理 ARP1.1 ARP 回显不是“多此一举”打开一个普通的 Linux 服务器内核协议栈会默默处理 ARP 请求隔壁机器发一个 “谁是 192.168.1.10”网卡驱动的应答包就自动回去了。但凡是用了 DPDK网卡收上来的包默认就不再进内核协议栈了rte_eth_rx_burst 从网卡队列里拿走的是裸的以太网帧。这时候如果不对 ARP 做应答同一广播域里的其他设备根本学不到你的 MAC 地址后续所有发送给你的 IP 流量都会被扼杀在二层。说得直白一点不管你是要做透明代理、GRE 解封装、四层负载均衡还是自己写一个轻量级交换芯片验证环境别管业务逻辑多漂亮抢先把这个“谁在问我就在哪答”的 ARP 处理写对后面的调试才会顺畅。1.2 用户态协议栈为什么偏爱“自己动手”内核虽然自带 ARP 表但 DPDK 应用的环境经常有特殊需求。比如一个物理端口上要挂几十个虚拟 IP响应哪个 IP、向哪个 VLAN 回包完全由用户态逻辑决定再比如某些接入交换机开启 DHCP Snooping 后对 ARP 报文做过滤你需要在用户态对报文做额外标记和统计。这种情况下依赖内核行为反而束手束脚不如直接在收包入口处做一个 fast path 的 ARP 应答。1.3 两种实现思路新手容易选错一种思路是“抠报文”收到 ARP 请求解析出目标 IP查本地 IP 列表若匹配则直接重新写报文头发回响应。另一种是“表项学习回显”二合一先把请求者的 IP/MAC 记录到哈希表再做应答。初级场景适合第一种代码量小、逻辑清晰生产环境通常需要第二种因为网关设备不能只应答自己的 IP还要替下游主机响应 ARP否则下一条三层路由无法建立。下面的案例先聚焦第一种再延伸第二种。2. 前置基础拿准 mbuf、帧格式与初始化细节2.1 rte_mbuf 是整块逻辑的“血脉”DPDK 收上来的包都放在 rte_mbuf 结构里它不像普通内存块那样只塞一个指针而是沿用了类似 BSD 的 mbuf 设计。rte_pktmbuf_mtod(m, struct rte_ether_hdr *) 返回的是数据区起点正常情况下这个起点就是以太网帧头rte_pktmbuf_data_len(m) 表示实际数据长度。发送的时候我们只要修改数据区里的字节再调用 rte_eth_tx_burst 交回去就行。新手最容易在这上面犯迷糊的是“头部预留空间”。默认创建的 memory pool每个 mbuf 的头部和尾部都留了足够空间让你做内联协议头修改时不会越界。比如 ARP 请求是 42 字节14 字节以太网头 28 字节 ARP 头应答也是 42 字节所以原地改写完全没问题。唯一要小心的是如果报文是从某个隧道接口收进来的可能带着两层以太网头这时候必须先用 rte_pktmbuf_adj 把外层的头剥掉再定位到里面的 ARP 头。2.2 一眼认出 ARP 请求与应答以太网帧头的 ether_type 字段为 0x0806 是 ARP判断时要用 rte_cpu_to_be_16(RTE_ETHER_TYPE_ARP) 转成大端序去比较。再往下看ARP 头里最关键的是硬件类型hrd、协议类型pro、硬件地址长度hln、协议地址长度pln、操作码op。请求包的操作码是 1应答包是 2。具体到字段请求里 sender prot addr 是发起方 IPtarget prot addr 是“我要找谁”应答里 sender MAC/IP 是被问的人的信息target 则是请求者。实现回显的时候最偷懒的改法就是把操作码从 1 改成 2然后把 sender 和 target 字段整体互换同时把以太网头里的源 MAC、目的 MAC 也互换最后用我们自己的 MAC 填进源 MAC。当然这样写有个前提只回我们绑定的 IP。2.3 初始化 DPDK 环境别在入口翻车写 ARP 回显之前常规的 rte_eal_init 和内存池创建是必须的。内存池我习惯直接复用 rte_pktmbuf_pool_create每块 mbuf 大小给 2048缓冲区数量至少给 8192太少的话在突发流量下容易无包可收。端口初始化时有一个关键操作是把网卡的混杂模式打开否则部分交换机口发的广播包或者目的 MAC 不是本机的包可能被网卡自动过滤掉这对 ARP 广播尤其重要。3. 核心实现从收包到回包一条链路打通3.1 主循环轮询收包的架子DPDK 的主循环很朴素就是在每个 lcore 上不停跑 rte_eth_rx_burst。一个最小可用的 ARP 回显程序不聊多队列、不聊 RTE_ETH_RX_OFFLOAD_UDP_CKSUM先看收包数量for (;;) { uint16_t nb_rx rte_eth_rx_burst(port, queue_id, bufs, BURST_SIZE); if (nb_rx 0) { for (i 0; i nb_rx; i) { struct rte_mbuf *m bufs[i]; if (is_arp_request(m)) { handle_arp_request(m); rte_eth_tx_burst(port, queue_id, m, 1); } else { rte_pktmbuf_free(m); } } } }这里有个每次都会重复踩的坑给 rte_eth_tx_burst 传的是一个 rte_mbuf 指针数组不要忘了取地址。我刚写第一版的时候直接传了 bufs结果一次只能发出一个包回调包还和后续收包混在一起排查了半天。3.2 识别并校验 ARP 请求解析函数先做长度校验。如果 rte_pktmbuf_pkt_len(m) 小于 RTE_ETHER_HDR_LEN 28直接丢包。然后判断以太网类型接着读 ARP 头里的 op。标准的做法是static bool is_arp_request(struct rte_mbuf *m) { struct rte_ether_hdr *eth rte_pktmbuf_mtod(m, struct rte_ether_hdr *); struct rte_arp_hdr *arp (struct rte_arp_hdr *)(eth 1); if (rte_pktmbuf_pkt_len(m) (RTE_ETHER_HDR_LEN RTE_ARP_HRD_LEN)) return false; if (eth-ether_type ! rte_cpu_to_be_16(RTE_ETHER_TYPE_ARP)) return false; if (arp-arp_op ! rte_cpu_to_be_16(1)) return false; return true; }注意 rte_arp_hdr 并不是所有 DPDK 版本里都有老版本可能只有 rte_arp 结构字段名不同。我建议在代码里用宏包一层比如#ifdef RTE_ARP_HRD_LEN这种这样移植版本时切换成本低。另一个容易看走眼的是arp_hrd类型以太网是 1arp_pro是 0x0800。校验一下能避免被人塞进带 VLAN tag 的奇怪帧导致内存错乱。3.3 构造应答看似简单实际上要盯住字节序构造应答我习惯直接原地改写收上来的 mbuf省去新分配 mbuf 的消耗。但原地写必须按顺序操作免得后面要用前面已经被改掉的内容。正确顺序是先记录 arp-arp_sha请求者的 MAC和 arp-arp_sip请求者的 IP。交换 ARP 头里的 sender 和 target 字段把 arp_sha 填入 arp_thaarp_sip 填入 arp_tip再把本机 MAC 填入 arp_sha本机 IP 填入 arp_sip。交换以太网头的 src 和 dst MAC再把 src MAC 填成本机 MAC。把 arp_op 从 1 改成 2。涉及 16 位和 32 位字段的全部保持网络字节序。代码片段类似static void fill_arp_reply(struct rte_mbuf *m, const struct rte_ether_addr *my_mac, uint32_t my_ip, const struct rte_ether_addr *my_bcast) { struct rte_ether_hdr *eth rte_pktmbuf_mtod(m, struct rte_ether_hdr *); struct rte_arp_hdr *arp (struct rte_arp_hdr *)(eth 1); struct rte_ether_addr sha arp-arp_sha; uint32_t sip arp-arp_sip; arp-arp_op rte_cpu_to_be_16(2); arp-arp_tha sha; arp-arp_tip sip; rte_ether_addr_copy(my_mac, arp-arp_sha); arp-arp_sip my_ip; rte_ether_addr_copy(eth-s_addr, eth-d_addr); rte_ether_addr_copy(my_mac, eth-s_addr); eth-ether_type rte_cpu_to_be_16(RTE_ETHER_TYPE_ARP); }这里注意eth-s_addr 和 eth-d_addr 这两个字段的交换也必须在读完原值之后做。C 语言里结构体赋值不会自动帮你保存旧值。我当初图省事先把 eth-d_addr 改了后面又拿改完的 d_addr 去填 s_addr结果回包的目的 MAC 变成了自己。还好是在模拟环境里测的不然线上网关当场就要断一片。还有一个容易被忽略的字段如果原请求里带了一个非全零的 padding以太网最小帧长 60 字节但通常是 42 字节 ARP 18 字节 padding应答时不需要刻意清 padding因为它不会影响对端解析。但出于强迫症有人在测试机上开了分片检查导致网卡 DMA 循环校验失败最后发现是 padding 里有随机值。最稳的做法是在初始化内存池时用 rte_pktmbuf_pool_create 的mz参数把内存清零或者干脆收到包之后用 rte_pktmbuf_trim 把多余尾部剃掉再加 18 字节 pad 到 60。大部分用例不需要这一步但如果你要给硬件校验网关做验证就得认真对待。3.4 发送链路回包也得带对队列回包的时候可以用原来的 mbuf 直接 rte_eth_tx_burst。有一个前提你的应用最好关闭网卡的 RTE_ETH_RX_OFFLOAD_SCATTER或者确保收包时一个 mbuf 能装下整个帧。默认 2048 的 mbuf 对于普通 MTU 1500 的 ARP 帧完全够用。发送函数里可以顺带统计一下 tx_ok 和 tx_err方便后续压测分析。ARP 回显本身非常轻量单核单队列在千兆线速下能跑到满线速真正拖后腿的是你打印日志太多或者在主循环里额外做了 per-packet printf。我见过有人把日志写成 syslog瞬间把收包能力拉低一个数量级。static inline void send_packet(uint16_t port, uint16_t queue, struct rte_mbuf *m) { uint16_t nb_tx rte_eth_tx_burst(port, queue, m, 1); if (nb_tx 0) { rte_pktmbuf_free(m); tx_errs; } else { tx_oks; } }3.5 完整精简代码可以直接拿去跑通的版本把上面这些拼起来一块比较清晰的骨架长这样#include rte_eal.h #include rte_ethdev.h #include rte_mbuf.h #include rte_arp.h #define BURST_SIZE 32 #define NUM_MBUFS 8191 #define RING_SIZE 1024 static uint16_t port_id; static struct rte_ether_addr my_mac; static uint32_t my_ip; static bool is_arp_request(struct rte_mbuf *m) { struct rte_ether_hdr *eth rte_pktmbuf_mtod(m, struct rte_ether_hdr *); if (rte_pktmbuf_pkt_len(m) RTE_ETHER_HDR_LEN sizeof(struct rte_arp_hdr)) return false; if (eth-ether_type ! rte_cpu_to_be_16(RTE_ETHER_TYPE_ARP)) return false; struct rte_arp_hdr *arp (struct rte_arp_hdr *)(eth 1); if (arp-arp_op ! rte_cpu_to_be_16(1)) return false; return true; } static void reply_arp(struct rte_mbuf *m) { struct rte_ether_hdr *eth rte_pktmbuf_mtod(m, struct rte_ether_hdr *); struct rte_arp_hdr *arp (struct rte_arp_hdr *)(eth 1); struct rte_ether_addr src_mac eth-s_addr; uint32_t src_ip arp-arp_sip; eth-d_addr src_mac; eth-s_addr my_mac; eth-ether_type rte_cpu_to_be_16(RTE_ETHER_TYPE_ARP); arp-arp_op rte_cpu_to_be_16(2); arp-arp_tha src_mac; arp-arp_tip src_ip; arp-arp_sha my_mac; arp-arp_sip my_ip; } int main(int argc, char **argv) { rte_eal_init(argc, argv); struct rte_mempool *mp rte_pktmbuf_pool_create(arp_pool, NUM_MBUFS, 256, 0, RTE_MBUF_DEFAULT_BUF_SIZE, rte_socket_id()); port_id 0; rte_eth_dev_info_get(port_id, dev_info); rte_eth_dev_configure(port_id, 1, 1, port_conf); rte_eth_rx_queue_setup(port_id, 0, RING_SIZE, rte_eth_dev_socket_id(port_id), NULL, mp); rte_eth_tx_queue_setup(port_id, 0, RING_SIZE, rte_eth_dev_socket_id(port_id), NULL); rte_eth_macaddr_get(port_id, my_mac); rte_eth_dev_start(port_id); rte_eth_promiscuous_enable(port_id); struct rte_mbuf *bufs[BURST_SIZE]; while (1) { uint16_t n rte_eth_rx_burst(port_id, 0, bufs, BURST_SIZE); for (int i 0; i n; i) { if (is_arp_request(bufs[i])) { reply_arp(bufs[i]); rte_eth_tx_burst(port_id, 0, bufs[i], 1); } else { rte_pktmbuf_free(bufs[i]); } } } }这段代码尽量精简了异常处理和 rte_eth_dev_info 的填充跑通没问题。真要上生产至少要加上 VLAN 处理、多端口映射、do-while 重试发送以及把 my_ip 改成从指定的虚拟 IP 列表中查找。4. 扩展场景从单点回显升级成“类网关”学习表4.1 为什么不能只回自己如果你手里只有一块网卡、绑一个 IP那上面那套代码就是全部。可一旦要做成网关形态替后端子网应答 ARP就必须先知道“这个 IP 后面挂的 MAC 是哪个”。应答者不再是本机而是一张动态学习来的表先从广播域里抓到请求把请求者 IP 和 MAC 记录到本地表项然后对请求做代理应答。这样主机 A 第一次 ping 网关时网关能直接替主机 B 回复 ARP避免广播风暴也减少主机 B 的中断唤醒。4.2 用 rte_hash 做瘦身版表项管理DPDK 自带 lib/hash可以拿来快速保存 ARP 表。常见设计是一个 rte_hash 表key 是 4 字节 IPvalue 是一条记录MAC 端口 剩余寿命。收到 ARP 请求时先查表项如果命中了就把对应 MAC 填进应答如果没命中就把请求者信息作为新的表项插入。这样网关不仅能应答自己还能应答学习到的远端主机。写 hash 时有个细节ARP 请求里的 sender IP 有可能是 0.0.0.0这种包通常是无状态自动配置期间的探测最好不要往表里插。还有表项过期时间要单独做老化线程否则长时间不通信的设备学不到新 MAC。用 rte_timer 或者单独 lcore 轮询都可以重点是不在收包路径里做 sleep。4.3 设计上的一个取舍代答还是透传有些场景希望网关只学习不应答让请求直接广播出去目标是让主机 B 自己回。有些场景则希望网关直接代答减少广播域内噪声。两种模式不是互斥的我一般把代答开关放到配置里测试时用透传模式去验证对端行为。注意透传模式下 ARP 请求需要原封不动转发包括以太网头里的 dst MAC 是广播地址 ff:ff:ff:ff:ff:ff不能顺手改成单播否则部分交换机会因为源端口学习机制把广播出口关闭。5. 实操避坑清单我在真实环境踩过的几个坑5.1 直接改 mbuf 会导致把同一个包发给多个队列吗不会但你会遇到另一种诡异的“丢包”在多队列 RSS 模式下同一个 ARP 请求可能被哈希到不同队列而你的应用只在某个 lcore 上处理一个队列。比如发送方用源 MAC 0a:00:27:00:00:01 解析网关RSS 将包打到了队列 2你的主循环却只 poll 队列 0那这个 ARP 请求永远看不到。调试时可以先用 rte_eth_dev_rss_reta_query 看 RETA 表或者干脆在测试阶段把所有队列指向同一个 lcore做熟了再拆多核。5.2 字节序问题比想象中更阴险ARP 里的 IP 地址是大端序MAC 不存在字节序问题但字段间赋值很容易颠倒。我在代码里吃过一次亏拿arp-arp_sip直接和目标 IP 比较结果用内存方式打印看是正常的用数值方式看却差了几亿。后来才意识到 rte_arp_hdr 里 IP 存储本来就是网络序想比较本机 IP 应该把本机地址也用rte_cpu_to_be_32(inet_addr(...))预先转好而不是每次收包都转。提前把本机 IP 的字节序缓存到一个变量里是一个值得养成的好习惯。5.3 内存池数量不是越大越好有同事一上来就把 NUM_MBUFS 配成百万级结果大页内存直接爆炸。每块 mbuf 2048 字节一百万块就是 2GBCPU 上可能还吃得消但虚拟化环境里物理内存经常不够。先试 81912^13 - 1这种经典值再压测看丢包率最后按峰值 buffer 占用量乘以 1.5 倍留余量。如果发现收包一直拿到空指针多半是队列里的 buffer 数量推太高DMA 写越界这时优先减小 burst 和 ring 大小而不是加大池子。5.4 关了 TX checksum 反而更稳ARP 帧没有 IP 校验和需要硬件卸载但网卡初始化时你大概率会设置rte_eth_txconf里的 per-port offloads。如果某个网卡驱动对 TX descriptor 做了 UDP/TCP 校验和重新计算遇到 ARP 这种既不是 IP 也不是 UDP 的包可能会有异常。最省心的是在初始化里只用DEV_TX_OFFLOAD_MBUF_FAST_FREE等基础 offload不碰 checksum offloadreliable 优先。手上有一批旧型号网卡开了 TCP checksum offload 后ARP 回包偶尔会被网卡丢弃关了之后现象立刻消失。5.5 测试时要对网关的一侧先做静态 ARP刚开始验证 DPDK 程序对端主机如果是 Linux它会在自己内核的 ARP 表里缓存上一次的结果。你改了程序里的源 MAC 重新启动对端 ARP 表可能还是旧条目。建议在测试主机上用ip neigh先删除对应表项或者把目标 IP 配置成静态 ARP。这个小细节能省掉你排查“为什么我程序没收到包”的半小时。6. 问题排查速查表现象最可能原因排查手段收不到任何 ARP 请求端口没 enable promiscuous或 RSS 队列选错查端口配置RETA 表是否覆盖所有队列收到请求但回包被丢对端 ARP 缓存了旧 MAC清对端缓存或用另一台全新机器测试回包发出但对端 ping 不通ARP 应答 MAC 填错、IP 字节序颠倒打印 eth-d_addr 和 arp-arp_sip 原始值在大流量下时序丢包mbuf 池不够或 ring size 不够看 rte_eth_stats_reset 后的 imissed/ierrors 统计队列 0 看到包队列 1 也看到部分RSS 对 ARP 载荷不均匀确认响应函数是否只在单个 lcore 内处理程序偶发重启后窗口期不通对端 TCP 会话的 ARP 表缓存未失效程序启动时主动发一段免费 ARP 广播这些坑没有一个是文档里写透的基本都要靠实际抓包才能定位。我现在每次调这类功能都会在测试拓扑里挂一个镜像端口让 tcpdump 能看到完整收发的 ARP 包这样是请求方向的问题还是应答方向的问题一眼就看得出来。另外一个比较顺手的调试技巧在 ARP 处理函数里加一个rte_pktmbuf_dump(stdout, m, 64)打印前 64 字节能覆盖整个 ARP 头。别打太长否则日志量会把状态冲乱。这个案例做完之后我的体会是 DPDK 的难点其实不在轮询和零拷贝而在你对协议细节是否真的理解。一个 ARP 回显表面上是改四个字段实际牵扯到字节序、队列归属、网卡 offload、表项生命周期。很多人觉得 ARP 太简单不值得认真学但恰恰是这种看似简单的基础功能最能暴露一个人对数据包路径的感知盲区。把这一段打通了后面做三层转发、隧道终结、智能卸载都会顺手很多。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询