第 7 章 Linux 邻居子系统
本章探讨 Linux 内核如何维护链路层地址的映射,以及 ARP/NDISC 协议在内核中的实现细节。
章节引子:当 IP 遇到 MAC
有一类问题,表面上看是「路由可达」,实际上是「地址未知」。
我们在这一章要处理的,正是这层网络协议栈中最容易被忽略的「翻译层」。
想象一下这个场景:你的机器刚发完一个 SYN 包,试图建立 TCP 连接。路由表查完了,下一跳 IP 明明就在隔壁网段,网卡却死活不肯把包发出去。它在等什么?它在等一个答案——那个 IP 地址对应的 MAC 地址到底是什么?
这就是邻居子系统存在的全部理由。无论是 IPv4 里的 ARP,还是 IPv6 里的 NDISC,本质上都是在做同一件事:在一个充满不确定性的二层网络上,建立 L3 到 L2 的信任关系。
这种信任是脆弱的。早期以太网就像一个充满了骗子的房间,任何人都可以站起来大喊「我是网关」(这就是著名的 ARP 欺骗)。内核必须时刻保持警惕,不仅要建立映射,还要不断验证、更新、并在连接失效时迅速清理垃圾。
本章的任务,就是打开这个黑盒子。我们会看到内核如何分配邻居条目、如何处理来自协议栈的压力(GC)、以及它是如何小心地处理那些特殊的地址(多播、广播)的。这不仅仅是关于 ARP——这是一场关于缓存策略、状态机和并发管理的综合演出。
7.1 创建和销毁邻居
The Neighbouring Subsystem Core
让我们从最基础的动作开始:当内核决定「我需要和这个 IP 对话」时,它实际上做了什么?
这就涉及到了 neighbour 结构体的创建。在内核代码里,这个动作的核心入口是 __neigh_create()。
核心方法 __neigh_create()
这个方法的签名不长,但每一个参数都至关重要:
struct neighbour *__neigh_create(struct neigh_table *tbl,
const void *pkey,
struct net_device *dev,
bool want_ref)
tbl:指向邻居表(比如 ARP 表arp_tbl)。pkey:协议层的关键字(在 IPv4 下就是目标 IP 地址)。这是查找邻居的唯一索引。dev:出站网卡接口。want_ref:是否需要增加引用计数的标志。
首先,内核需要分配一个 neighbour 对象。这听起来像是一个简单的 kmalloc,但实际上,它在调用 neigh_alloc() 的同时,还在进行一场紧张的资源谈判。
第一步:neigh_alloc() 与垃圾回收的博弈
当你试图申请一个新的邻居条目时,neigh_alloc() 方法第一件事就是把当前邻居表的条目计数加一:
static struct neighbour *neigh_alloc(struct neigh_table *tbl, struct net_device *dev)
{
struct neighbour *n = NULL;
unsigned long now = jiffies;
int entries;
entries = atomic_inc_return(&tbl->entries) - 1;
这一行 atomic_inc_return 是一把双刃剑。计数器一旦增加,就必须判断是否越界。内核这里设置了两道防线:gc_thresh2 和 gc_thresh3。
- gc_thresh3(硬限制):默认 1024。这是条目数的绝对天花板。
- gc_thresh2(软限制):默认 512。这是触发垃圾回收(GC)的阈值。
接下来的这段逻辑,决定了你的新邻居是「生」还是「死」:
if (entries >= tbl->gc_thresh3 ||
(entries >= tbl->gc_thresh2 &&
time_after(now, tbl->last_flush + 5 * HZ))) {
if (!neigh_forced_gc(tbl) &&
entries >= tbl->gc_thresh3)
goto out_entries;
}
这段代码值得停下来细读。它触发同步垃圾回收(neigh_forced_gc)的条件有两个:
- 条目数达到或超过了
gc_thresh3(硬限制)。 - 或者,条目数达到了
gc_thresh2(软限制),且距离上次清理已经过去了 5 秒(5 * HZ)。
这里有一个微妙的时序问题:如果触发了 GC,但 GC 清理完之后,条目数依然高于 gc_thresh3,那么内核会放弃分配,直接跳转到 out_entries 标签(通常返回 NULL 和错误)。这意味着在高负载下,如果你不调整这些阈值,新的连接可能会直接因为「邻居表满了」而失败。
第二步:协议特定的初始化
假设我们通过了 GC 的考验,拿到了一块干净的 neighbour 内存。接下来,内核必须根据协议类型(IPv4 或 IPv6)来填充这个结构的细节。这是通过调用邻居表中注册的 constructor 回调完成的。
对于 IPv4,这是 arp_constructor();对于 IPv6,则是 ndisc_constructor()。
这两个构造函数有几个极其重要的职责:处理不需要 ARP 的特殊情况。
1. 多播地址处理
多播流量不需要 ARP 解析。如果你要往 224.0.0.1 发包,你不需要问「谁是 224.0.0.1」,因为那是按照 L2 多播 MAC 地址处理的。
在 arp_constructor() 中:
/* 伪代码逻辑展示 */
if (IS_MULTICAST(key)) {
arp_mc_map(key, n->ha, dev, 0);
n->nud_state = NUD_NOARP;
}
它调用 arp_mc_map() 算出对应的 MAC 地址填入 ha 字段,并把状态设为 NUD_NOARP(无需 ARP)。
IPv6 的逻辑类似,由 ndisc_constructor() 调用 ndisc_mc_map() 处理。
2. 广播地址处理
广播地址(如 255.255.255.255)也不需要 ARP。在 arp_constructor() 中:
/* 伪代码逻辑展示 */
if (type == RTN_BROADCAST) {
memcpy(n->ha, dev->broadcast, dev->addr_len);
n->nud_state = NUD_NOARP;
}
它直接把网卡的广播地址抄录到邻居结构里,同样标记为 NUD_NOARP。
⚠️ 注意:IPv6 没有传统的广播概念。虽然它有
ff02::1(所有节点多播)和ff02::2(所有路由器多播),但在内核实现中,它们被归类为多播,而不是广播。因此,IPv6 的邻居子系统里没有这段广播逻辑。
3. 设备层面的特殊处理
构造函数还有两个特殊的调用点,虽然不常见,但关键时刻很有用:
ndo_neigh_construct():这是net_device操作集中的回调。目前几乎只有在clip(Classical IP over ATM)驱动中才实现了它。ATM 这种古老技术的邻居发现逻辑极其复杂,需要驱动介入。neigh_parms->neigh_setup():这是给 Bonding(网卡绑定)驱动用的。在 Bond 模式下,一个虚拟网卡对应多个物理口,邻居参数需要特殊配置。
第三步:扩容哈希表
邻居条目是存在哈希表里的。当网络极其繁忙,比如你同时处理成千上万个并发连接时,哈希表可能会撞车严重或者满了。在创建条目时,内核会检查是否需要扩容:
struct neighbour *__neigh_create(struct neigh_table *tbl, const void *pkey,
struct net_device *dev, bool want_ref)
{
. . .
/* hash_shift 决定了哈希表的大小:1 << hash_shift */
if (atomic_read(&tbl->entries) > (1 << nht->hash_shift))
nht = neigh_hash_grow(tbl, nht->hash_shift + 1);
. . .
}
如果 entries 数量超过了当前哈希桶的大小(1 << shift),neigh_hash_grow() 就会被调用,分配一个更大的哈希表,并把旧数据迁移过去。这是一个可能阻塞的昂贵操作。
第四步:收尾
最后,__neigh_create() 进入收尾阶段。
如果调用者传入了 want_ref == true,引用计数会被加一。同时,内核会初始化 confirmed 字段:
n->confirmed = jiffies - (n->parms->base_reachable_time << 1);
这行代码乍一看很奇怪:为什么要把确认时间设为过去?
如果你把 base_reachable_time 理解为「我们认为邻居能活多久」,那么把它减去两倍的时间,就是为了告诉内核:「这个邻居虽然是新的,但我现在就需要验证它」。这样做是为了安全性——我们不希望一个刚创建的条目因为还没过期就被当成「可信」的长期使用。我们需要它尽快进入状态机验证流程。
最后,dead 标志被设为 0,表示对象存活,然后这个 neighbour 对象被挂到全局哈希表中。
销毁:neigh_release() 和 neigh_destroy()
邻居对象是基于引用计数管理的。当内核某个模块不再关心这个邻居时,它会调用 neigh_release()。
当引用计数归零,neigh_destroy() 就会被调用来清理内存。但这里有一个死锁保护机制:neigh_destroy() 会检查 dead 标志。
如果 dead 标志为 0,内核会拒绝销毁。
这通常发生在某些异步路径中:一个对象正在被使用,突然收到了一个删除事件(比如用户空间删除了 ARP 条目)。此时内核会先标记 dead = 1,但真正释放内存要等到所有人都放手(refcnt 归零)的那一刻。这是一种典型的延迟销毁策略。
本章回响
这一节我们只是搭建了舞台。分配内存、设置标志、处理哈希表——这些看起来枯燥的初始化代码,其实定义了邻居子系统的物理形态。gc_thresh 决定了系统的吞吐上限,而 constructor 决了不同协议(IPv4/IPv6)如何在这个通用框架下安插自己的特殊逻辑。
但内存有了,条目建了,它还是空的。谁把真正的 MAC 地址填进去?谁在什么时候发起那个广播的「WHO HAS」请求?
下一节,我们将把这些静态的数据结构「激活」,看看 ARP 请求是如何被构建、发送,并最终让这个条目状态从 NUD_INCOMPLETE 走向 NUD_REACHABLE 的。