尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

手写网络协议栈:ARP模块从缓存设计到收发流程完整实现

手写网络协议栈:ARP模块从缓存设计到收发流程完整实现 写网络协议栈这件事很多新手一开始是奔着IP层去的结果真正动手后才发现第一个让你卡住的往往是ARP。这个协议看着简单实际写起来牵扯的细节特别多缓存老化、字节序、等待队列、免费ARP、重传策略每一项处理不好都会让你的IP转发看起来“偶尔好用、经常玄学”。本系列写到第四篇我打算把ARP模块单独拎出来从数据结构设计、请求/应答收发流程到和IP层的联动再到最后在GNS3里搭两个路由器、接两台主机跑一轮ping把报文一帧一帧抓出来对照完整过一遍。这篇比较适合正在写自己的协议栈、或者一直没搞明白“IP层到底怎么找到MAC地址”的读者看完你可以直接照着这套思路去实现一个能用的ARP模块。1. 动手前先搞清楚ARP模块在协议栈里到底处于什么位置1.1 一个总被低估的问题IP地址到MAC地址怎么映射先理顺一个概念。两台主机通过以太网通信时网卡只认MAC地址不认IP地址。IP层做完路由查找知道自己要把包发给“192.168.1.1”但真正把帧放到网线上之前必须把这个IP翻译成对应的MAC地址。ARP就是管这个翻译的。这里面有个很现实的麻烦翻译结果不是永远不变的。网卡可以换虚拟机可以迁移同一个IP地址在不同时间可能对应不同的MAC。所以ARP模块不能只做一次静态映射就完事它必须是一个“带缓存的动态学习”过程先查缓存缓存没有就发广播去问问完记住过一段时间再忘掉然后再问。很多人在写协议栈时会把ARP当成一个独立的小工具路由转发模块需要MAC就直接调一下拿到就发拿不到就丢包。这种设计的问题是IP层丢了一个包TCP还能靠重传兜底但UDP基本就永久丢了而且你连“什么时候该重新尝试解析”的机制都没有。真正的ARP模块在协议栈里的位置应该是“IP层和设备驱动层之间的一个服务层”它向上给IP层提供可靠的地址解析能力向下负责以太网帧的封装和收发。1.2 这层模块该提供什么样的接口给IP层先别急着写报文收发先把接口定义清楚。我的经验是网络协议栈最容易烂尾的地方就是模块之间没有清晰边界后面想调一个功能要翻遍全局变量。我在自己的协议栈里给ARP模块设计了四个对外接口// 查询ARP缓存成功返回0并把MAC拷贝到mac int arp_lookup(uint32_t ip, uint8_t mac[6]); // 尝试解析目标IP若缓存未命中则将skb入队并触发ARP请求 int arp_resolve(struct netdev *dev, uint32_t ip, struct sk_buff *skb, uint8_t *mac); // 以太网帧接收入口ARP模块在这里处理请求/应答 void arp_input(struct netdev *dev, struct sk_buff *skb); // 周期扫描处理缓存老化与重传定时器 void arp_tick(void);arp_lookup是同步查询适合那些“查得到就发、查不到就另想办法”的场景arp_resolve是真正的转发路径要用的接口它会帮忙处理“缓存未命中”的善后工作arp_input是协议栈收包线程回调的入口arp_tick由一个周期性定时器驱动每秒调用一次。接口想清楚之后后面写代码就有了边界。IP层不需要知道ARP请求怎么构造、重传怎么调度它只会傻乎乎地调用arp_resolve剩下的全交给ARP模块。1.3 报文格式速览你现在要打交道的28字节ARP报文格式必须烂熟于心。以太网帧头之后紧跟的是28字节的ARP内容结构如下字段长度含义典型值hw_type2字节硬件类型1 以太网proto_type2字节协议类型0x0800 IPv4hw_len1字节MAC地址长度6proto_len1字节IP地址长度4opcode2字节操作码1 请求2 应答sender_mac6字节发送方MAC本机MACsender_ip4字节发送方IP本机IPtarget_mac6字节目标MAC请求时为全0target_ip4字节目标IP要解析的IPC语言定义起来也很直接我的习惯是直接用__attribute__((packed))避免填充对齐struct arp_hdr { uint16_t hw_type; uint16_t proto_type; uint8_t hw_len; uint8_t proto_len; uint16_t opcode; uint8_t sender_mac[6]; uint32_t sender_ip; uint8_t target_mac[6]; uint32_t target_ip; } __attribute__((packed));注意ARP报文里头的IP地址是4字节并且在线路上的表示是“网络字节序”。我在协议栈内部统一用网络字节序存储IP地址这样从报文里解出来的sender_ip字段可以直接和路由表、ARP缓存里的IP做比较完全不用来回转换。这个设计决策后面会反复提到它是一个能帮你少踩很多坑的细节。还有一个容易被忽略的小知识点以太网II帧要求整个帧至少64字节含FCS实际payload至少46字节。ARP自己的长度只有28字节所以发ARP报文时发送函数必须把payload部分填充到46字节也就是要在ARP头后面补18字节的0。很多刚写的协议栈会在这里直接翻车网卡把小于64字节的帧直接丢掉你会看到ARP请求明明“发出去”了对端却怎么都收不到。2. 第一步不是发请求而是先把ARP缓存表设计好2.1 缓存表条目的字段到底该放哪些动手写ARP我建议的顺序是先实现缓存表再实现报文收发最后再写和IP层的联动。因为只要缓存表设计得够好后面写起请求/应答来会顺利很多。ARP缓存不只是一个IP, MAC这么简单的二元组在实际运行中你还需要知道这个条目是什么时候建立的、最后一次使用是什么时候、当前处于什么状态、解析重试到第几次了。我的缓存条目长这样enum arp_state { ARP_INCOMPLETE, // 正在解析还没收到应答 ARP_REACHABLE, // 可用状态 ARP_STALE, // 过期但还没被清理 ARP_FAILED, // 重试超限解析失败 }; struct arp_entry { struct list_head list; // 挂在全局hash链上 uint32_t ip; // 网络字节序 uint8_t mac[6]; int state; time_t last_used; // 上次被IP层用到的时间 time_t last_update; // 上次被ARP报文更新的时间 int retries; // 当前重试次数 };last_used和last_update这两个时间戳看起来冗余实际各有各的用途last_used用于判断“这个缓存条目还在不在被使用”如果长时间没有流量经过这个条目该清理就清理last_update用于记录“这个MAC地址是多久前从网络上学习到的”如果一个条目的MAC来源很陈旧即使状态显示可用你也应该对其保持怀疑。这两个字段在调试“缓存怎么老不更新”“缓存怎么老被误删”的时候特别管用。2.2 为什么用状态机而不是简单的在/不在很多简化版本的ARP实现只用“在缓存/不在缓存”两种状态我认为这个设计过于粗糙。稍微复杂一点的场景就会露馅你发了一个ARP请求还没收到应答此时IP层又有新的包要发你到底怎么判断“这次会不会重复触发请求”如果你用状态机来管理答案就很清晰。我参考了Linux邻居子系统的思路但做了一个精简版状态只有四个ARP_INCOMPLETE请求已发出等待应答。此时后续到达的IP包应该排队等待同一个IP只允许一个活跃请求。ARP_REACHABLE已解析成功缓存可用。IP层可以放心直接用。ARP_STALE缓存条目超过了老化时间但还没被清除。当有流量需要发送时如果碰到STALE状态的条目最简单的做法是重新发ARP请求再确认一次。ARP_FAILED重试次数超过上限短时间内认为这个IP不可达。状态迁移的触发条件对应如下当前状态事件迁移目标INCOMPLETE收到ARP应答REACHABLEINCOMPLETE重传超时且未达上限保持INCOMPLETE并触发重发INCOMPLETE重传超限FAILED丢弃等待队列REACHABLE老化超时STALESTALE有IP包需要使用它进入INCOMPLETE并重新解析FAILED收到主动ARP请求对端还在线REACHABLE这套状态机虽然简单但已经能覆盖“不会重复触发请求”“缓存过期后还能自救”“解析失败后重新学习”这三个关键场景了。新手实现时很容易漏掉“STALE状态下重新解析”这条路径结果就是一个缓存条目要么永远有效要么被删掉之后完全忘了重新学习。2.3 老化策略怎么避免ARP缓存成为内存里的僵尸缓存必须老化原因我相信大家都理解网络拓扑是会变的。但老化策略怎么定这里头有讲究。最简单的方案是固定时间超时比如超过60秒就删。但这个方案有一个问题一个一直在被高频使用的条目比如网关的MAC明明每秒钟都有流量经过它你为什么要把人家删掉如果删了再重新解析虽然网络还能跑但平白无故多了一次广播请求而且广播对局域网里所有主机都是打扰。我采用的策略是“懒清理”last_used记录最后一次被流量使用的时间周期性的arp_tick扫描时只清理那些“长时间没被使用”的条目。对于一直在使用的条目每次查询都会刷新last_used它们永远也不会被清理。有一部分条目虽然最近没用但网络状态其实没变删除后还要重新请求于是我用STALE状态作为中间过渡条目不立即删保留MAC地址等下一次需要发送到这个IP时再发起重新解析顺便把那个等待的包一起发出去。这样做的效果是局域网内稳定通信的场景下ARP缓存几乎不会产生多余请求而IP地址真正发生变化时最多只需要一次重新解析就能切换到新MAC整个协议栈的行为既有缓存收益又有纠错能力。2.4 并发访问的取舍如果你的协议栈跑在单线程事件循环里那么发送线程、收包线程、定时器回调都在同一个上下文ARP缓存表和等待队列不需要加锁。这是我推荐新手先采用的方式先把功能做出来把逻辑理顺再去考虑并行优化。但如果你的协议栈是多线程架构比如收包中断在A线程、上层协议处理在B线程、定时器在C线程那ARP缓存表就必须加锁保护。这里的取舍是查询频率远高于更新频率所以用读写锁比用普通互斥锁更合适。查找路径持有读锁收到ARP应答更新缓存时持有写锁。不过我要强调一点不管加不加锁都要保证同一个IP只有一个活跃的ARP请求在途。这个约束是逻辑层面的不是锁层面的如果你的逻辑设计成了“只要有包来就能触发新请求”那你加再多的锁也挡不住广播风暴。3. 核心流程ARP请求与应答的收发实现3.1 主动发起当IP层问我要MAC而我手上没有假设IP层决定要把一个包发给下一跳IP地址192.168.1.1它调用arp_resolve结果缓存里没有这个条目或者条目已经进入STALE状态。此时ARP模块要做的是把这个IP标记为INCOMPLETE把当前IP包放入等待队列然后立刻构造一个ARP请求发出去。这里有一个很容易写错的细节多个IP包同时等待解析同一个IP时只需要发一个ARP请求就够了。如果每个包都触发一次请求局域网里就会同时出现好几条一模一样的ARP广播纯属浪费。我处理的方式是等待队列按照目标IP分组如果这个IP已经有一个在途请求了后续的包只需加入对应的等待队列不需要再触发新请求。static int arp_queue(struct netdev *dev, uint32_t ip, struct sk_buff *skb) { struct arp_waiter *pos, *waiter NULL; list_for_each_entry(pos, waiters, list) { if (pos-ip ip) { waiter pos; break; } } if (!waiter) { waiter calloc(1, sizeof(*waiter)); waiter-ip ip; waiter-retries 0; init_list_head(waiter-skb_list); list_add_tail(waiter-list, waiters); arp_request(dev, ip); // 只有第一个等待者才触发请求 } list_add_tail(skb-list, waiter-skb_list); return 0; }重传策略我一开始用的是最简单的1秒一次最多3次3次没应答就把等待队列里的包全部丢弃同时把缓存条目状态改为FAILED。这个参数对有线以太网足够了无线环境可以放宽到5次后头等你实现TCP模块之后会发现IP层丢几个包根本无所谓TCP重传会兜底。3.2 以太网帧封装与字节序处理构造一个ARP请求帧的完整代码各个协议栈都大同小异static void arp_request(struct netdev *dev, uint32_t ip) { struct sk_buff *skb alloc_skb(ETH_HDR_LEN ARP_HDR_LEN); struct eth_hdr *eth (struct eth_hdr *)skb-data; struct arp_hdr *arp (struct arp_hdr *)(skb-data ETH_HDR_LEN); // 以太网头 memset(eth-dst_mac, 0xff, 6); // ARP请求是广播帧 memcpy(eth-src_mac, dev-mac, 6); eth-ether_type htons(0x0806); // ARP内容 arp-hw_type htons(1); arp-proto_type htons(0x0800); arp-hw_len 6; arp-proto_len 4; arp-opcode htons(1); memcpy(arp-sender_mac, dev-mac, 6); arp-sender_ip dev-ip; // 网络字节序直接赋值 memset(arp-target_mac, 0, 6); // 请求时目标MAC填0 arp-target_ip ip; // 网络字节序 skb-len ETH_HDR_LEN ARP_HDR_LEN; // 填充到最小帧长 while (skb-len 60) skb-data[skb-len] 0; netdev_transmit(dev, skb); }这个函数里容易出错的一个是target_mac必须清零很多新写的协议栈在这里直接不初始化导致发出去的请求里面带着垃圾MAC一些实现严格的对端收到这种报文会直接丢弃另一个就是skb-len必须做填充以太网帧的最小长度限制在真实网络里是强制的。另外提醒一句arp-sender_ip dev-ip和arp-target_ip ip这两个赋值前提是你在协议栈内部已经把IP地址统一存成网络字节序。如果你内部用的是192.168.1.1这样的主机字节序整数那就必须在这里做htonl。我之所以一开始就在协议栈内部统一用网络字节序就是为了让这里变成直接的内存复制少一层转换也更不容易搞混。3.3 被动应答收到请求后必须答谁收包路径是ARP模块里逻辑最密集的地方。每收到一个以太网帧先看ether_type是0x0806就交给ARP模块处理否则按协议类型往上层分发。进入arp_input之后流程是这样的校验帧长度确保至少包含完整的以太网头和ARP头。校验hw_type、proto_type、hw_len、proto_len任何一种不匹配都直接丢。无论这个报文是请求还是应答先提取sender_ip和sender_mac更新自己的缓存表。这是ARP的“免费学习”特性你即使没有主动问别人只要局域网里有人在问你也能顺便学到一堆映射关系。如果opcode是请求判断target_ip是不是自己。若是构造一个应答报文把目标MAC设为请求方的sender_mac把应答的sender_mac设为自己的MAC单播回去。如果opcode是应答说明有人回复我们之前发出去的请求更新缓存状态为REACHABLE然后唤醒等待队列里所有目标IP等于sender_ip的包。这段逻辑用一个伪代码就能看清void arp_input(struct netdev *dev, struct sk_buff *skb) { struct eth_hdr *eth; struct arp_hdr *arp; if (skb-len ETH_HDR_LEN ARP_HDR_LEN) return; eth (struct eth_hdr *)skb-data; arp (struct arp_hdr *)(skb-data ETH_HDR_LEN); if (arp-hw_type ! htons(1) || arp-proto_type ! htons(0x0800)) return; if (arp-hw_len ! 6 || arp-proto_len ! 4) return; // 学习发送方的地址映射 arp_cache_update(arp-sender_ip, arp-sender_mac); if (arp-opcode htons(1) arp-target_ip dev-ip) { // 是请求且目标IP指向自己回一个应答 arp_reply(dev, arp-sender_mac, arp-sender_ip); } else if (arp-opcode htons(2)) { // 是应答唤醒等待队列 arp_wakeup_waiters(dev, arp-sender_ip, arp-sender_mac); } }这里必须注意一个字节序陷阱arp-target_ip dev-ip这个比较操作因为两边都用网络字节序存储所以是直接整数比较。如果你在某处手滑先调用了ntohl那么比较结果永远不相等你的协议栈就会“能学别人、应答不了自己”这是非常隐蔽的bug。3.4 收到应答后的处理更新、校验、唤醒收到ARP应答之后除了把缓存状态从INCOMPLETE改为REACHABLE还需要处理一个我之前提过无数次的场景等待队列里的那些IP包这会儿终于可以发出去了。static void arp_wakeup_waiters(struct netdev *dev, uint32_t ip, uint8_t *mac) { struct arp_waiter *pos, *tmp; struct sk_buff *skb, *n; list_for_each_entry_safe(pos, tmp, waiters, list) { if (pos-ip ! ip) continue; // 把这个IP对应的所有等待包都发送出去 list_for_each_entry_safe(skb, n, pos-skb_list, list) { list_del(skb-list); eth_output(dev, skb, mac, ETH_P_IP); // 填充目标MAC并发送 } list_del(pos-list); free(pos); } }这里有个现实问题要说明IP包进入ARP等待队列之后在ACK回来之前实际上是什么时候答案取决于你协议栈里其他模块的实现。如果包是被引用计数的那可以安全地在队列里多存活一会儿如果包在网络栈里是“谁发谁负责释放”的资源那就要特别小心不要让上层模块在包还在排队时就把它释放掉。我的建议是等待队列里的sk_buff必须有自己的生命周期管理不能依赖上层。3.5 免费ARP的接入点免费ARP也就是gratuitous ARP是指主机在接口启动或者IP地址变更时主动广播的一个ARP请求目标IP写自己的IP。它有两个作用一是告诉局域网里其他主机“我的IP和MAC对应关系变了”二是探测地址冲突。在你的协议栈里实现免费ARP其实很便宜总共就几步在网卡初始化并且拿到IP之后调用一下之前写好的arp_request函数把目标IP设为自己的IP广播出去。然后等待一段时间如果收到对应的ARP应答说明局域网里已经有别的主机在使用这个IP地址这时候就要打日志甚至考虑把接口地址置为不可用。免费ARP这个功能新手协议栈常常漏做但是你不做就会遇到一个实际困扰协议栈跑在真实网络里时网关或其他设备缓存里关于你的MAC地址信息可能已经过期了你不主动广播一下别人要过好一阵子才能发现“原来你已经换了网卡”。4. 和IP转发联动从查询缓存到真正的发包4.1 给IP层暴露两个接口而不是一个写到这里ARP模块已经能独立工作了。但要让整个协议栈跑起来还差临门一脚IP层如何正确地使用ARP。IP层发送一个数据包的完整路径是路由表查找决定下一跳IP和出口网卡然后拿着下一跳IP去问ARP模块。这里要注意下一跳IP不一定等于目的IP。比如PC1要访问另一个网段的IP路由决策给出的下一跳是网关IP帧头目的MAC就必须是网关的MAC而不能直接拿目的IP去查ARP。我设计两个接口的原因就在这里arp_lookup用于“查询并直接得到MAC”适合那些能接受失败的场景arp_resolve用于“解析附带排队和重传逻辑”是IP层默认使用的接口。IP层不应该自己去处理“解析失败”这件事它只需要把包交给arp_resolve然后期待三种结果成功立即返回一个MAC包被排队、稍后自动发出解析失败、包被丢弃。三种结果对IP层来说都是明确的。我建议IP层调用的逻辑写成这样int ip_output(struct netdev *dev, uint32_t next_hop, uint32_t daddr, uint8_t proto, struct sk_buff *skb) { uint8_t dst_mac[6]; if (arp_lookup(next_hop, dst_mac) 0) { // 缓存命中直接发送 eth_output(dev, skb, dst_mac, ETH_P_IP); return 0; } // 缓存未命中交给ARP模块排队触发请求 return arp_resolve(dev, next_hop, skb, dst_mac); }4.2 缓存未命中的包排队一个容易漏掉的需求包排队这个需求很多人写协议栈时会觉得“麻烦”而省掉查不到MAC那我直接把包丢弃不行吗如果你的目标只是做一个玩具协议栈确实可以。但一旦你要在上面跑TCP频繁丢包带来的结果就是TCP永远建立不了连接。因为TCP握手的前两个包多半会被这个“查不到就丢”的路由给丢掉连接根本握不起来。所以我的建议很清楚IP层发出的第一个包如果遇到ARP缓存未命中必须排队等待至少要等1秒等到ARP请求重传一次。这是TCP连接能在冷启动后正常建立的基础。实现排队时前面那个arp_queue函数已经给出了核心逻辑不过还要补充一条排队包不能无限等。ARP重试上限到达之后要把这个IP对应的所有排队包全部清掉并给IP层一个“发送失败”的回执。如果上层是UDP直接丢弃即可如果上层是TCPTCP逻辑会感知到发送失败从而进入重传路径。4.3 GNS3双路由器接主机抓包看完整ARP交互链路光在代码层面理解了ARP还不够我建议你搭一个GNS3环境把整个转发链路抓包看一遍。这里用的拓扑很主流两台路由器中间互联每台路由器各接一台PC。具体地址规划设备接口IP地址PC1eth0192.168.1.10/24网关192.168.1.1R1E0/0192.168.1.1/24R1E0/110.0.1.1/30R2E0/010.0.1.2/30R2E0/1192.168.2.1/24PC2eth0192.168.2.10/24网关192.168.2.1在GNS3里用Wireshark分别在PC1、R1-R2互联链路、R2的E0/1口抓包然后在PC1上执行ping 192.168.2.10。PC1侧的抓包顺序会非常典型P1发出ARP广播“谁是192.168.1.1告诉192.168.1.10。”目标MAC全FF这是以太网广播。R1的E0/0回ARP应答“192.168.1.1在00:xx:xx:xx:xx:xx。”这是单播帧目标MAC是PC1的MAC。PC1发出ICMP echo请求以太网帧的目的MAC为R1 E0/0的MAC。PC1收到ICMP echo回复源MAC为R1 E0/0的MAC。注意一个细节PC1发给192.168.2.10的ICMP包帧头的目的MAC是192.168.1.1的MAC而不是192.168.2.10的MAC。因为IP层路由决策已经判定192.168.2.10不在本地链路要交给网关转发。这就是“路由决策决定下一跳ARP解析下一跳”的分工关系。再看R1和R2之间的链路抓包又会看到另一组ARPR1查询自己的路由表发现192.168.2.0/24要从E0/1出接口走下一跳是10.0.1.2。R1的E0/1对应缓存里没有10.0.1.2的MAC于是R1发ARP广播“谁是10.0.1.2告诉10.0.1.1。”R2的E0/0回ARP应答“10.0.1.2在R2的E0/0 MAC。”R1把ICMP包封装成以太网帧目的MAC填R2的E0/0 MAC发出。R2收到后再走自己的路由表发现目的IP192.168.2.10在本地网段然后开始对自己E0/1网段做ARP解析查询192.168.2.10的MAC。所以在R2的E0/1抓包会看到R2主动发起“谁是192.168.2.10告诉192.168.2.1”PC2应答之后ICMP包才被R2转发到PC2。这个顺序非常清晰地展示了ARP是逐跳执行的不是端到端的。你的PC1只知道网关的MAC不知道远端PC2的MAC也不需要知道。4.4 对照你自己的协议栈验证结果如果你把GNS3里这套抓包结果和你自己写的协议栈跑出来的抓包做对照可以很客观地评估你的ARP模块是否合格。我通常检查这几个点第一确认第一次ping之前会先出现ARP解析过程如果第二次ping时又出现相同的ARP广播说明你的STALE状态设置得太激进缓存刚学完就被删了。第二确认PC1发出的ICMP帧目的MAC是网关MAC而不是目标IP的MAC如果目的MAC直接用了目标IP说明IP层调ARP的入参有问题把目的IP当成了下一跳。第三确认整个链路中每一跳的ARP请求都是逐段出现的不会出现“PC1直连发送ARP问192.168.2.10”的奇怪报文。我自己调试时最常用的命令组合是在PC1上连续ping三次然后用Wireshark过滤arp正常情况下只有第一次ping前后有1到2个ARP帧后两次ping应该完全看不到ARP。如果每次ping都有ARP那一定是你缓存老化设置过短或者缓存更新逻辑有bug。5. 实操排错那些年我踩过的ARP坑5.1 请求没响应先别怪对端ARP请求发出去对方就是不回很多人第一反应是“对端协议栈有问题”。但多数时候问题出在自己这一侧最常见的原因按概率排序大概是这样帧长度不够。以太网帧小于64字节会被网卡或交换机直接丢弃。如果你的ARP请求只发了42字节那对端根本看不到。排查方法很简单抓包看帧长度Wireshark如果标记“短帧”就是在提醒你这个问题。目标MAC写成全0。ARP请求必须是广播目标MAC要写ff:ff:ff:ff:ff:ff。有些交换机会把目标MAC为全0的帧当垃圾丢弃。查这个特别快抓包看帧头目的MAC是不是全FF。IP字节序不一致。这条最隐蔽。如果你内部存的是主机字节序构造报文时忘了转网络字节序收到的请求里面对端IP就完全错掉了对端判断target_ip不等于自己自然不会回应。这种问题抓包看的是“IP地址在Wireshark里看起来不对”。还有一个容易忽略的点ARP请求的源IP必须是发送接口的IP不能是“本机任意一个IP”。Linux的arp_ignore规则就明确要求只有当ARP请求的目标IP是接收接口所配置的IP时主机才回应该请求。如果你从eth0发出一个源IP为eth1地址的ARP请求对端即使收到了也可能出于安全配置不回应。5.2 缓存不更新的几种奇怪现象我调试时遇到过一种场景缓存里明明没有某个IP的条目但它的等待队列里已经堆了一堆包怎么都不发。查到最后发现是这个IP的ARP_INCOMPLETE状态被缓存表记录了但重传定时器和等待队列却是分离的两个结构发完一次ARP请求后定时器没有正确启动所以后面的包一直在排队请求却不重发。解决方案有两个层面。逻辑上等待队列的存活期必须和重传定时器绑定一旦建立INCOMPLETE条目就必须同时启动一个1秒后触发的重传定时器定时器触发时检查条目还在不在、重试次数有没有超限。代码结构上我建议把“重传计数”放在缓存的条目里而不是放在独立的定时器结构里这样arp_tick扫描缓存时可以直接判断所有INCOMPLETE条目该重发还是该置为FAILED不用再翻独立队列的状态。还有一种情况是缓存确实更新了但last_used字段没有刷新导致一个一直在用的条目被老化逻辑误删。这个bug很傻但确实容易犯更新缓存时只刷新last_update忘了last_used应该跟随查询一起刷新。我后来直接用一条规则约束自己任何路径只要某个条目被IP层读取并用于发送就必须更新它的last_used。5.3 收到重复MAC地址怎么办局域网里偶尔会出现两台设备抢同一个IP的情况这时候你的ARP缓存会反复收到同一个IP对应不同MAC的学习。在没有安全防护需求的普通协议栈里我的处理策略是如果收到的新映射和现有映射不同先用日志记录下“IP x.x.x.x 之前对应MAC A现在对应MAC B”这条信息同时接受新映射更新缓存。这个决策不是没有代价的如果旧的MAC代表一台仍然存在的问题设备而新MAC是恶意构造的那缓存学习就会导致流量被劫持。但站在一个基础协议栈的角度ARP本身就没有内置认证对等的处理就是“以最新收到的信息为准”。真要防御这类问题需要做DAIDynamic ARP Inspection或者静态ARP绑定这些已经超出这里讨论的范围了。有一点值得做当发现同一个IP短时间内切换了两次不同MAC时及时打印告警方便线上调试。5.4 Wireshark里看ARP的关键过滤和观察点最后分享几个我平时用Wireshark分析ARP的习惯非常实用。过滤器的正确姿态是这样的# 只看ARP协议 arp # 只看ARP请求 arp.opcode 1 # 只看ARP应答 arp.opcode 2 # 只看某个IP相关的ARP arp.addr.proto_ipv4 192.168.1.1 # 只看某个MAC地址发出来的ARP arp.src.proto_ipv4 192.168.1.10 eth.src.addr aa:bb:cc:dd:ee:ff抓包时最需要盯住的关键列是源MAC、目标MAC、源IP、目标IP、操作码。多接口抓包对比的时间轴也很重要我一般会把PC1和R2 E0/1的抓包文件各自导出CSV然后用时间戳对齐看延迟能直观看到每一跳ARP解析耗时。如果你的实验里同时开着多个抓包点建议给每个抓包点单独保存文件别都放在同一个接口上否则后期会分不清某个ARP请求到底是哪段链路上发出来的。最后说一个我个人写协议栈时踩过最深的一个坑整个ARP模块看起来完全正常缓存能学请求能发应答能收但是只要一连接到真实交换机就偶尔出现“第一次ping不通第二次开始通”的现象。查了很久最后发现问题不在ARP而在交换机的MAC地址表老化——交换机第一次看到PC发出的未知目的MAC帧会做泛洪但第一次帧到达时间点正好在MAC表更新之前于是被丢弃。这也算是调试协议栈时最常见的“假性ARP bug”之一。如果哪天你遇到类似的诡异现象建议先看看是不是交换机的学习机制在起作用而不是一头扎进ARP协议里找原因。
返回列表