ch07_3
7.3 The ARP protocol (IPv4)
上一节结尾时,我们留下了一个悬念:当内核准备发出数据包,却发现邻居表里空空如也时,究竟发生了什么?
现在我们正式进入 IPv4 的世界来回答这个问题。在 IPv4 里,这个问题的名字叫 ARP(Address Resolution Protocol)。
虽然 ARP 协议本身早在 1982 年的 RFC 826 就定义好了,但在 Linux 内核里,它和邻居子系统纠缠在一起,演化出了一套相当微妙的机制。这不只是「发送请求,接收应答」那么简单——这中间混杂了缓存策略、设备兼容性、甚至是为了防止网络风暴而设计的延迟逻辑。
让我们先从最基础的 ARP 协议本身说起,看看它是如何填补 IP 地址和 MAC 地址之间那道巨大的鸿沟的。
ARP 协议基础与头部结构
在以太网的世界里,每个设备都有两个身份:一个是 Layer 3 的 IP 地址,一个是 Layer 2 的 MAC 地址。
当内核想发包时,手里握着的是目的地 IP(dst_ip),但把它塞到以太网帧里之前,必须先知道对方的 MAC 地址。如果不知道,这包数据就发不出去。
这就是 ARP 存在的理由:它负责大喊一声「谁拥有 IP 地址 X?」,然后等待 X 的主人举手回答「我,我的 MAC 是 Y」。
这个过程在协议层面非常直观:
- 请求:源主机发送一个 广播 包,里面包含了目标 IP 地址。
- 响应:如果某台主机发现这个 IP 是自己的,它就发一个 单播 包回来,里面带上自己的 MAC 地址。
在 Linux 内核眼里,ARP 的所有信息都打包在一个叫 arphdr 的结构体里。这个结构体是 ARP 协议的通用格式定义:
struct arphdr {
__be16 ar_hrd; /* 硬件地址格式 (Hardware type) */
__be16 ar_pro; /* 协议地址格式 (Protocol type) */
unsigned char ar_hln; /* 硬件地址长度 (e.g., MAC is 6 bytes) */
unsigned char ar_pln; /* 协议地址长度 (e.g., IPv4 is 4 bytes) */
__be16 ar_op; /* ARP 操作码 (Command: Request/Reply) */
#if 0
/* 下面这些字段紧跟在头部后面,但不包含在 arphdr 结构体本身里 */
unsigned char ar_sha[ETH_ALEN]; /* sender hardware address */
unsigned char ar_sip[4]; /* sender IP address */
unsigned char ar_tha[ETH_ALEN]; /* target hardware address */
unsigned char ar_tip[4]; /* target IP address */
#endif
};
注意:那个 #if 0 块里的字段并不是 arphdr 的成员。它们在逻辑上紧跟在 ARP 头部后面,但在内核代码里,需要通过手动偏移指针来读取。这个细节稍后在 arp_process() 里会看到。
为了快速翻译,这里有几个关键字段:
- ar_hrd:硬件类型。以太网是
0x01。内核里有一堆ARPHRD_XXX宏定义这个。 - ar_pro:协议类型。对于 IPv4,这里是
0x0800(也就是ETH_P_IP)。 - ar_hln:硬件地址长度。以太网 MAC 地址是 6 字节。
- ar_pln:协议地址长度。IPv4 地址是 4 字节。
- ar_op:操作码。
ARPOP_REQUEST(1) 表示请求,ARPOP_REPLY(2) 表示响应。
图 7-1 展示了一个典型的以太网 ARP 包长什么样:
(此处示意 ARP Header layout:Hardware/Protocol type, lengths, opcode, followed by SHA/SIP/THA/TIP)
不同的邻居,不同的命运
在内核代码里,ARP 并不是对所有设备一视同仁的。因为并不是所有的网络设备都需要 ARP(比如纯点对点的 PPP 链路),也不是所有设备都支持硬件头部缓存。
为了处理这种差异,内核定义了四种 neigh_ops 实例,它们决定了邻居条目的行为模式:
- arp_direct_ops:用于不需要 ARP 的设备。这种设备的
header_ops通常是 NULL。发包时直接调用neigh_direct_output(),它本质上是dev_queue_xmit()的封装。 - arp_generic_ops:用于不支持硬件头部缓存的设备。
- arp_hh_ops:这是大多数以太网设备的标配。它使用
eth_header_cache()来缓存 L2 头部,加速发包。 - arp_broken_ops:用于某些老旧或特殊的非标准设备(如 ROSE, AX25, NETROM)。
具体选哪一个,是在 arp_constructor() 里根据 net_device 的特征决定的。对于标准的以太网卡,ether_setup() 会把 header_ops 设为 eth_header_ops,这就意味着内核会初始化 cache 回调,最终匹配到 arp_hh_ops。
发起请求:当内核不知道 MAC 地址时
现在,让我们把镜头切到发送路径。
当数据包走到网络层(L3)的出口 ip_finish_output2() 时,它必须要跨过 L3 到 L2 的界限。此时,内核手里只有下一跳的 IP 地址。
它首先会去 ARP 表(arp_tbl)里查:
/* net/ipv4/ip_output.c */
static inline int ip_finish_output2(struct sk_buff *skb)
{
struct dst_entry *dst = skb_dst(skb);
struct rtable *rt = (struct rtable *)dst;
struct net_device *dev = dst->dev;
struct neighbour *neigh;
u32 nexthop;
/* ... 省略无关代码 ... */
/* 获取下一跳 IP */
nexthop = (__force u32) rt_nexthop(rt, ip_hdr(skb)->daddr);
/* 尝试在邻居表里查找 */
neigh = __ipv4_neigh_lookup_noref(dev, nexthop);
/* 如果没找到,创建一个新的邻居条目 (此时状态可能是 NUD_NONE) */
if (unlikely(!neigh))
neigh = __neigh_create(&arp_tbl, &nexthop, dev, false);
if (!IS_ERR(neigh)) {
/* 尝试通过邻居输出 */
int res = dst_neigh_output(dst, neigh, skb);
/* ... */
}
/* ... */
}
这里有个关键点:如果没找到邻居,内核会创建一个新的邻居条目。但此时,这个条目还没有绑定 L2 地址。它只是一个空壳。
接下来的重头戏在 dst_neigh_output() 里。如果是第一次发包,邻居的状态(nud_state)肯定不是 NUD_CONNECTED。这意味着内核不能直接发数据包,它必须先停下来解析地址:
/* include/net/dst.h */
static inline int dst_neigh_output(struct dst_entry *dst, struct neighbour *n,
struct sk_buff *skb)
{
const struct hh_cache *hh;
/* ... 一些时间戳更新逻辑 ... */
hh = &n->hh;
/* 只有当状态是 NUD_CONNECTED 且有缓存头部时,才直接发 */
if ((n->nud_state & NUD_CONNECTED) && hh->hh_len)
return neigh_hh_output(hh, skb);
else
/* 否则,调用 output 方法(对于 ARP 通常是 neigh_resolve_output) */
return n->output(n, skb);
}
对于 ARP 协议,此时的 n->output 指向的是 neigh_resolve_output()。这个函数会做一件很重要但常被忽略的事:把数据包暂存起来。
它调用 neigh_event_send(),最终通过 __skb_queue_tail(&neigh->arp_queue, skb) 把你的数据包挂到邻居结构的 arp_queue 队列里。
⚠️ 别让队列爆了 这个
arp_queue是有长度限制的。如果 ARP 一直没解析出来,后续来的包会一直往里塞。填满了之后,内核就开始丢包。你会发现 ping 不通,也没报错,包就是在这个黑洞里消失了。
暂存完数据包后,内核会触发一个解析动作。这通常是由定时器回调 neigh_timer_handler 调用 neigh_probe() 完成的:
/* net/core/neighbour.c */
static void neigh_probe(struct neighbour *neigh)
__releases(neigh->lock)
{
struct sk_buff *skb = skb_peek(&neigh->arp_queue);
/* ... */
/* 调用协议特定的 solicit 方法,对于 ARP 就是 arp_solicit */
neigh->ops->solicit(neigh, skb);
atomic_inc(&neigh->probes);
/* 释放队列头部的包(因为已经发过探针了,通常是保留第一个包用于重传逻辑) */
kfree_skb(skb);
}
构造 ARP 请求:arp_solicit()
现在我们来到了 arp_solicit(),这是真正构建 ARP 请求包的地方。这里有几个关于源地址选择的有趣细节,很多网络故障的根源就在这里。
发送 ARP 请求时,我们需要填写源 IP(saddr)。选哪一个呢?
内核提供了一个 sysctl 参数 arp_announce 来控制这个逻辑:
- 0 (默认):可以使用本地任何接口上的任何地址。
- 1:尽量使用目标子网内的地址。如果没有,回退到 level 2。
- 2:总是使用主地址。
这个参数可以分别在 /proc/sys/net/ipv4/conf/all/arp_announce 和特定网卡下设置。
代码里是这样处理的:
/* net/ipv4/arp.c */
static void arp_solicit(struct neighbour *neigh, struct sk_buff *skb)
{
__be32 saddr = 0;
struct net_device *dev = neigh->dev;
__be32 target = *(__be32 *)neigh->primary_key;
/* ... */
switch (IN_DEV_ARP_ANNOUNCE(in_dev)) {
default:
case 0: /* 默认:任何本地 IP 都行 */
if (skb && inet_addr_type(dev_net(dev), ip_hdr(skb)->saddr) == RTN_LOCAL)
saddr = ip_hdr(skb)->saddr;
break;
case 1: /* 尽量用同子网地址 */
if (!skb)
break;
saddr = ip_hdr(skb)->saddr;
if (inet_addr_type(dev_net(dev), saddr) == RTN_LOCAL) {
/* 检查 saddr 和 target 是否在同链路 */
if (inet_addr_onlink(in_dev, target, saddr))
break;
}
saddr = 0; /* 没找到合适的,清零,下面会重新选 */
break;
case 2: /* 只用主地址 */
break;
}
/* 如果上面没定下来 saddr,让内核帮我们选一个最合适的 */
if (!saddr)
saddr = inet_select_addr(dev, target, RT_SCOPE_LINK);
选定源 IP 后,还有一个问题:这是广播请求,还是单播请求?
通常情况下,我们不知道对方的 MAC,只能广播。但是,内核支持一种「单播探针」机制。如果邻居表里有旧的条目(状态无效但有 MAC 记录),内核可能会先尝试单播探测一下,这能减少局域网里的广播风暴。
probes -= neigh->parms->ucast_probes;
if (probes < 0) {
/* 如果配置了单播探测次数,且此时状态不对,尝试用已知 MAC 发单播 */
if (!(neigh->nud_state & NUD_VALID))
pr_debug("trying to ucast probe in NUD_INVALID\n");
neigh_ha_snapshot(dst_ha, neigh, dev);
dst_hw = dst_ha;
}
/* ... */
最后,调用 arp_send() 把包发出去。如果是广播,dst_hw 参数就是 NULL:
arp_send(ARPOP_REQUEST, ETH_P_ARP, target, dev, saddr,
dst_hw, dev->dev_addr, NULL);
}
发送封装:arp_send()
arp_send() 负责分配 SKB 并填充头部,然后扔给驱动层。
这里有个检查非常关键:IFF_NOARP。
/* net/ipv4/arp.c */
void arp_send(int type, int ptype, __be32 dest_ip,
struct net_device *dev, __be32 src_ip,
const unsigned char *dest_hw, const unsigned char *src_hw,
const unsigned char *target_hw)
{
struct sk_buff *skb;
/* 检查设备是否禁止了 ARP */
if (dev->flags & IFF_NOARP)
return;
/* 分配 SKB 并填充 ARP 头部 */
skb = arp_create(type, ptype, dest_ip, dev, src_ip,
dest_hw, src_hw, target_hw);
if (skb == NULL)
return;
/* 通过 Netfilter 过滤后发送 */
arp_xmit(skb);
}
哪些设备会打上 IFF_NOARP 标记?
- 管理员手动关掉的:
ip link set eth0 arp off。 - 隧道设备(如 IPIP)、PPP 设备等。因为这些链路是点对点的,或者根本不跑以太网,不需要 ARP。
接收与处理:arp_process()
包发出去还得收回来。ARP 的接收入口是 arp_rcv()。
它首先做一些合法性检查(设备是否支持 ARP、包长度是否够、是不是发给 loopback 的),然后调用 arp_process() 进入核心处理逻辑。
arp_process() 的工作量很大,它要处理三种情况:
- 针对本机的请求:需要回复。
- 针对本机的响应:需要更新邻居表。
- 需要转发的请求(Proxy ARP):需要代替别人回复。
第一步:解析头部
由于 arphdr 结构体不包含变长部分的地址数据,内核必须手动算偏移量来提取数据。这是网络协议栈编程的典型风格:
static int arp_process(struct sk_buff *skb)
{
struct net_device *dev = skb->dev;
struct arphdr *arp;
unsigned char *arp_ptr;
__be32 sip, tip; /* Source IP, Target IP */
unsigned char *sha; /* Source Hardware Addr */
arp = arp_hdr(skb);
arp_ptr = (unsigned char *)(arp + 1); /* 跳过 arphdr */
/* 提取 SHA (Sender MAC) */
sha = arp_ptr;
arp_ptr += dev->addr_len;
/* 提取 SIP (Sender IP) */
memcpy(&sip, arp_ptr, 4);
arp_ptr += 4;
/* 跳过 THA (Target MAC),虽然我们这里不用它 */
arp_ptr += dev->addr_len;
/* 提取 TIP (Target IP) */
memcpy(&tip, arp_ptr, 4);
/* 丢弃组播或 loopback 相关的非法请求 */
if (ipv4_is_multicast(tip) || ...)
goto out;
第二步:处理 DAD (重复地址检测) 请求
在处理正常业务前,内核先看了一眼源 IP:sip。
如果 sip 是 0,这是一个特殊的 ARP Probe(RFC 2131),用于 DAD(Duplicate Address Detection)。
虽然 IPv6 强制要求 DAD,但在 IPv4 里这是可选的(由 arping 工具发起)。如果你用 arping -D 来检测 IP 冲突,发出的包 SIP 就是 0。
如果内核收到这种包,且自己是 Target IP 的拥有者,它必须回复。注意:回复时,内核通常不会把请求者加入自己的邻居表,因为请求者的 IP 显然还没确定下来。
第三步:处理发给本机的请求
这是最常见的场景:tip 是我的 IP。
内核会先查路由表确认 tip 是本地地址(RTN_LOCAL)。然后,它面临两个决策点:
- 忽略策略 (
arp_ignore):我是否应该回复? - 过滤策略 (
arp_filter):回复是否合法?
arp_ignore 参数决定了内核有多「高冷」:
- 0:只要 IP 是本地的,就回。
- 1:只有请求的目的 IP 是接收接口的地址时,才回(防止 IP 别名乱响)。
- 更严格的值:甚至要求来源和目的在同一子网。
if (addr_type == RTN_LOCAL) {
int dont_send;
dont_send = arp_ignore(in_dev, sip, tip);
/* 如果没被 ignore 策略拦住,再看 filter 策略 */
if (!dont_send && IN_DEV_ARPFILTER(in_dev))
dont_send = arp_filter(sip, tip, dev);
if (!dont_send) {
/* 先把对方 (Sender) 加到邻居表,状态设为 NUD_STALE
* 这叫被动学习
*/
n = neigh_event_ns(&arp_tbl, sha, &sip, dev);
if (n) {
/* 发送回复 */
arp_send(ARPOP_REPLY, ETH_P_ARP, sip, dev, tip, sha,
dev->dev_addr, sha);
neigh_release(n);
}
}
/* ... */
}
这里有个很有意思的机制叫 被动学习。 通常我们觉得发请求才会学 MAC,但其实只要收到 ARP 包(无论 Request 还是 Reply),内核顺手就把发送者的信息记下来了。这就好比有人敲门问路,你回答他的同时,也记住了他的长相,下次找他就方便了。
第四步:处理代理 ARP (Proxy ARP)
如果 tip 不是我的 IP,但我的机器开启了转发功能,并且路由查表结果显示这个包应该经由我转发,那么我就有机会成为一个 代理。
arp_fwd_proxy() 和 arp_fwd_pvlan() 就是用来判断这种场景的。如果你配置了 Proxy ARP,内核会代替目标主机回复 ARP 请求,把自己伪装成目标。
这在家里用得少,但在一些老旧的网络迁移场景或某些特殊的网关部署中非常有用。
第五步:更新邻居表
无论刚才是不是回复了请求,内核最后都要根据收到的包更新一下邻居表。
/* 查找发送者的邻居条目 */
n = __neigh_lookup(&arp_tbl, &sip, dev, 0);
/* ... 各种判断逻辑,包括是否接受非请求的回复 ... */
if (n) {
int state = NUD_REACHABLE;
int override;
/* 如果在 locktime 内收到多个不同的回复,只认第一个,防止 ARP 飘忽 */
override = time_after(jiffies, n->updated + n->parms->locktime);
/* 只有单播的 Reply 才能把状态设为 REACHABLE,广播包只能算 STALE */
if (arp->ar_op != htons(ARPOP_REPLY) ||
skb->pkt_type != PACKET_HOST)
state = NUD_STALE;
/* 执行更新 */
neigh_update(n, sha, state,
override ? NEIGH_UPDATE_F_OVERRIDE : 0);
neigh_release(n);
}
小结
到这里,ARP 在内核里的完整生命周期就闭环了。
从发送路径的「找不到人就广播喊话」,到接收路径的「听到喊话顺便记住脸」,再到处理各种边缘情况(DAD、Proxy、Filter)。
这就是 IPv4 时代的邻居发现。它简单、高效,但也充满隐患——没有任何验证机制,谁都可以说自己是网关。
这就是为什么下一章我们要看 IPv6 的 NDISC。你会发现,虽然它做的事情大体相同,但它把这套简单粗暴的喊话机制,升级成了一套严密得多的协议——同时,也复杂得多。
But before we go there, let's make sure we really understand how this "simple" shouting match keeps our IPv4 networks running every day.