写网络协议栈这件事,很多新手一开始是奔着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_type | 2字节 | 硬件类型 | 1 = 以太网 |
| proto_type | 2字节 | 协议类型 | 0x0800 = IPv4 |
| hw_len | 1字节 | MAC地址长度 | 6 |
| proto_len | 1字节 | IP地址长度 | 4 |
| opcode | 2字节 | 操作码 | 1 = 请求,2 = 应答 |
| sender_mac | 6字节 | 发送方MAC | 本机MAC |
| sender_ip | 4字节 | 发送方IP | 本机IP |
| target_mac | 6字节 | 目标MAC | 请求时为全0 |
| target_ip | 4字节 | 目标IP | 要解析的IP |
C语言定义起来也很直接,我的习惯是直接用__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应答 | REACHABLE |
| INCOMPLETE | 重传超时且未达上限 | 保持INCOMPLETE并触发重发 |
| INCOMPLETE | 重传超限 | FAILED,丢弃等待队列 |
| REACHABLE | 老化超时 | STALE |
| STALE | 有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,直接丢弃即可;如果上层是TCP,TCP逻辑会感知到发送失败,从而进入重传路径。
4.3 GNS3双路由器接主机:抓包看完整ARP交互链路
光在代码层面理解了ARP还不够,我建议你搭一个GNS3环境,把整个转发链路抓包看一遍。这里用的拓扑很主流:两台路由器中间互联,每台路由器各接一台PC。
具体地址规划:
| 设备 | 接口 | IP地址 |
|---|---|---|
| PC1 | eth0 | 192.168.1.10/24,网关192.168.1.1 |
| R1 | E0/0 | 192.168.1.1/24 |
| R1 | E0/1 | 10.0.1.1/30 |
| R2 | E0/0 | 10.0.1.2/30 |
| R2 | E0/1 | 192.168.2.1/24 |
| PC2 | eth0 | 192.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之间的链路,抓包又会看到另一组ARP:
- R1查询自己的路由表,发现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本身就没有内置认证,对等的处理就是“以最新收到的信息为准”。真要防御这类问题,需要做DAI(Dynamic 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协议里找原因。