跳到主要内容

7.5 快速参考

走到这一步,我们已经拆解了邻居子系统的骨架(核心结构)、血液(协议交互)和肌肉(状态机)。

现在,是时候把手术刀放一边,拿出一本沉甸甸的解剖图谱了。

本节不是用来「读」的,是用来「查」的。我们会把本章讨论过的重要方法、宏定义和结构体摊开来,省得你以后在内核源码里满世界找那一行调用。你会发现,很多刚才在代码流里一闪而过的名字,在这里都有了明确的归位。

注意

核心邻居代码躲在:

  • net/core/neighbour.c
  • include/net/neighbour.h
  • include/uapi/linux/neighbour.h

ARP (IPv4) 的老巢在:

  • net/ipv4/arp.c
  • include/net/arp.h
  • include/uapi/linux/if_arp.h

NDISC (IPv6) 的地盘在:

  • net/ipv6/ndisc.c
  • include/net/ndisc.h

方法

我们先从核心方法开始——这是邻居子系统的心跳。

表的管理与初始化

void neigh_table_init(struct neigh_table *tbl)

这是启动引擎的第一把钥匙。它调用 neigh_table_init_no_netlink() 完成邻居表的所有初始化工作,然后顺手把这个表 (tbl) 挂到全局的邻居表链表 (neigh_tables) 上。没有这一步,内核根本不知道这张表的存在。

void neigh_table_init_no_netlink(struct neigh_table *tbl)

neigh_table_init 的苦力弟弟。它负责脏活累酒——分配内存、初始化哈希表、设置参数——唯独不负责把自己挂到全局链表上。那是大哥 (neigh_table_init) 的事。

int neigh_table_clear(struct neigh_table *tbl)

这是打扫卫生的阿姨。当一张邻居表不再被需要时(比如模块卸载),这个方法负责释放掉所有相关的资源。

邻居条目的生与死

struct neighbour *neigh_alloc(struct neigh_table *tbl, struct net_device *dev)

分配一个新的 neighbour 对象。别以为这只是简单的 kzalloc——在内部,它会盯着 gc_thresh 的脸色看。如果表太挤了,它可能会先触发一轮垃圾回收再给你分配内存。我们在「创建与释放」那一节详细说过这个机制。

struct neighbour *__neigh_create(struct neigh_table *tbl, const void *pkey, struct net_device *dev, bool want_ref)

真正的造物主。neigh_alloc 只给你弄个空壳,__neigh_create 会根据 pkey(L3 地址)和 dev(设备)把邻居对象完整地造出来,并插入哈希表。如果你传了 want_ref=true,它还会贴心地把引用计数加一,防止你还没来得及用就被别人回收了。

struct neighbour *__neigh_lookup(struct neigh_table *tbl, const void *pkey, struct net_device *dev, int creat)

侦探。去哈希表里查 pkey 对应的邻居在不在。creat 参数决定了如果找不到该怎么办:如果是 1,它会直接调用 neigh_create 现场编造一个;如果是 0,它就两手一摊返回 NULL。

与用户空间的对话

int neigh_add(struct sk_buff *skb, struct nlmsghdr *nlh, void *arg)

Netlink 服务的接待员。当你敲 ip neigh add ... 时,内核最终会走到这里。它处理 RTM_NEWNEIGH 消息,把用户空间的想法(比如加一个静态 ARP 条目)变成内核里的数据结构。

int neigh_delete(struct sk_buff *skb, struct nlmsghdr *nlh, void *arg)

同上,不过它是处理 RTM_DELNEIGH 的——对应你敲的 ip neigh del ...

垃圾回收与定时器

int neigh_forced_gc(struct neigh_table *tbl)

强制拆迁队。这是一个同步的垃圾回收方法。它会无情地把所有非永久状态 (NUD_PERMANENT) 且引用计数为 1 的邻居条目踢出去。

它的流程很决绝:先把邻居的 dead 标志设为 1,然后调用 neigh_cleanup_and_release() 送它上路。记得我们在前面提过吗?当内存紧张到一定程度,neigh_alloc 会叫醒这个拆迁队来腾地方。如果它成功拆掉了至少一家,返回 1,否则返回 0。

void neigh_periodic_work(struct work_struct *work)

这是一个异步的清洁工,周期性地打扫卫生。它不像 forced_gc 那么暴力,而是温和地把过期的条目清理掉。

static void neigh_timer_handler(unsigned long arg)

每个邻居条目都有自己的倒计时器。这个定时器处理函数就是听到闹铃声后的反应——通常是用来检测邻居是不是挂了(比如从 REACHABLESTALE,或者开始发送探测包)。

发送与探测逻辑

void neigh_probe(struct neighbour *neigh)

“喂,有人在吗?” 这个方法从邻居的 arp_queue 队列里捞出一个数据包(如果有的话),然后调用对应的 solicit() 方法(比如 ARP 的 arp_solicit)发送探测。它还会顺便增加探测计数器,并把那个用来探测的包给释放掉。

neigh_hh_init(struct neighbour *n, struct dst_entry *dst)

为了性能,内核需要缓存 L2 头部。这个方法根据路由缓存条目 (dst) 来初始化邻居 (n) 的硬件头缓存 (hh_cache)。有了这个,后续发包就不需要每次都推算头部了,直接 memcpy 完事。

static int neigh_blackhole(struct neighbour *neigh, struct sk_buff *skb)

黑洞。这并不是天体物理里的黑洞,而是网络死胡同。它直接丢弃数据包,并返回 -ENETDOWN(网络挂了)。这通常被用作邻居 output 回调的一个兜底策略——当一切都失败时,至少别让内核 panic。


ARP 专用方法

下面这些是 IPv4 领域特有的“方言”。

void __init arp_init(void)

ARP 模块的 main() 函数。它在系统启动时被调用,干了一系列杂活:

  1. 初始化 ARP 表 (arp_tbl)。
  2. 注册 arp_rcv,告诉内核“收到 ARP 包交给我”。
  3. /proc 下创建各种入口。
  4. 注册 sysctl 参数(就是你 /proc/sys/net/ipv4/ 下看到的那些开关)。
  5. 注册网络设备通知器 arp_netdev_event,以便网卡状态变化时 ARP 能做出反应。

int arp_rcv(struct sk_buff *skb, struct net_device *dev, struct packet_type *pt, struct net_device *orig_dev)

收包入口。虽然我们在逻辑上讲的是“处理 ARP 请求”,但在内核里,一切都是从 arp_rcv 抓到一个类型为 0x0806 的以太网帧开始的。它负责一些合法性检查(比如包长度够不够),然后交给 arp_process 处理。

int arp_process(struct sk_buff *skb)

真正的大脑。我们在前文花大篇幅拆解的就是它。它解析 ARP 头,决定是发回复还是更新本地缓存。这里发生的故事包括:被动学习、处理 ARP 请求、处理 ARP 响应、以及触发 neigh_update

int arp_constructor(struct neighbour *neigh)

构造函数。当一个新的邻居条目被创建时(专门针对 ARP 表),内核会调用这个函数来初始化 ARP 特定的部分,比如设置合适的 neigh_ops 回调。

void arp_solicit(struct neighbour *neigh, struct sk_buff *skb)

“我去问问。” 这个方法负责发送 ARP 请求 (ARPOP_REQUEST)。在发之前,它会做一堆心理建设(检查各种标志位、决定是用单播探测还是广播探测),最后调用 arp_send 把包扔出去。

void arp_send(...)

这是一个便利封装函数。它调用 arp_create 根据 IP 地址、MAC 地址等参数捏出一个 ARP 包,然后调用 arp_xmit 发送。

void arp_xmit(struct sk_buff *skb)

最后的一脚。它调用 NF_HOOK(Netfilter 钩子),如果防火墙放行,就交给 dev_queue_xmit 驱动发送。

struct arphdr *arp_hdr(const struct sk_buff *skb)

懒人宏。它帮你算偏移量,直接把 skb 里的 ARP 头部指针 (struct arphdr) 交给你。别自己在那算 skb->data + sizeof(struct ethhdr) 了,用这个。

int arp_mc_map(__be32 addr, u8 *haddr, struct net_device *dev, int dir)

多播地址转换。IP 地址到多播 MAC 地址的映射不是简单的算术题。对于以太网,它调 ip_eth_mc_map;对于 InfiniBand,它调别的。这个函数就是那个翻译官。

int arp_fwd_proxy(...) / int arp_fwd_pvlan(...)

判官。它们用来判断当前设备是否应该开启 Proxy ARP(代理 ARP)或者 Proxy ARP PVLAN。我们在 NDISC 那一节提过代理 NDP,ARP 这里也有类似的逻辑——路由器代替目标主机回答“我就是他”。


NDISC 专用方法

到了 IPv6,事情变得稍微现代一点。

int ndisc_rcv(struct sk_buff *skb)

NDISC 的总入口。虽然名字叫 ndisc,但它实际上是在 ICMPv6 协议之上的。当你收到一个 ICMPv6 包,类型是邻居请求(NS)或通告(NA)时,icmpv6_rcv 会把包转交给 ndisc_rcv

static void ndisc_recv_ns(struct sk_buff *skb)

专门处理 NS(Neighbour Solicitation)包。这里面的逻辑对应 ARP 的 arp_process 里的“处理请求”分支,但复杂得多——还要考虑 DAD(重复地址检测)。

static void ndisc_recv_na(struct sk_buff *skb)

专门处理 NA(Neighbour Advertisement)包。接收邻居的回复,更新状态机。

static void ndisc_recv_rs(struct sk_buff *skb)

处理 RS(Router Solicitation)。这是主机在喊“有没有路由器?我要上网”。

static void ndisc_router_discovery(struct sk_buff *skb)

处理 RA(Router Advertisement)。路由器说“我在,我是这么配置的……”,这里处理这些参数。

int ndisc_constructor(struct neighbour *neigh)

对应 ARP 的 arp_constructor。初始化 IPv6 邻居条目时的回调,设置 ndisc 专属的操作集。

void ndisc_solicit(struct neighbour *neigh, struct sk_buff *skb)

“我去问问(IPv6 版)。” 它调用 ndisc_send_ns 发送邻居请求。

int ndisc_mc_map(const struct in6_addr *addr, char *buf, struct net_device *dev, int dir)

arp_mc_map 一样,不过是针对 IPv6 多播地址的。在以太网上,它调 ipv6_eth_mc_map


内核很喜欢用宏来封装那些看起来很繁琐但逻辑固定的判断。以下是你在阅读邻居子系统代码时会经常撞见的家伙。

ARP 行为控制

这些宏通常用来检查 /proc/sys/net/ipv4/conf/ 下的开关状态。

IN_DEV_PROXY_ARP(in_dev)

检查 proxy_arp 是否开启。它不仅看具体设备的配置,还会检查 alldefault 的设置。

IN_DEV_ARPFILTER(in_dev)

检查 arp_filter。这个开关决定了内核是否严格根据子网匹配来过滤 ARP 响应。开启后,如果 ARP 请求的地址不在接收接口的子网内,内核可能会假装没听见。

IN_DEV_ARP_ACCEPT(in_dev)

检查 arp_accept。这决定了内核是否接受“ gratuitous ARP”(免费 ARP,也就是没人问就主动发的通告)。通常用于负载均衡或高可用切换场景。

IN_DEV_ARP_IGNORE(in_dev)

这是一个决定性很强的宏。它返回 arp_ignore 的值。这个值控制了内核对 ARP 请求的响应级别:

  • 0: 只要是本机拥有的 IP,不管是哪个接口的,都回应(默认)。
  • 1: 只有请求的目标 IP 配置在接收接口上时,才回应(防止多接口混乱)。
  • 更高的值则更严格,甚至完全不回。

IN_DEV_ARP_ANNOUNCE(in_dev)

对应 arp_announce。它控制我们在发送 ARP 请求时,源 IP 地址该怎么选:

  • 0: 使用本机任何接口上的地址。
  • 1: 尽量使用该网络接口所在子网的地址。
  • 2: 永远使用该接口的主地址。 这是为了避免在复杂的路由环境下,邻居因为源 IP 诡异而把你拉黑。

IN_DEV_SHARED_MEDIA(in_dev)

检查 shared_media。如果开启了,内核认为不同媒体类型(比如以太网和 PPP)共享同一个 IP 空间,这会影响到子网掩码的计算逻辑。

通用操作

neigh_hold()

这是“抓紧点”的宏。它把邻居对象的引用计数 (refcnt) 加一。防止你在操作这个邻居的时候,它突然被别人释放了。这在并发环境下至关重要。


neigh_statistics 结构体

最后,我们来看看邻居子系统的“记分牌”。

正如我们在本章开头提到的,ARP 和 NDISC 都会通过 procfs 导出统计数据(分别在 /proc/net/stat/arp_cache/proc/net/stat/ndisc_cache)。这些数据就是从内核里的 neigh_statistics 结构体里读出来的。

让我们看看这些字段都代表什么,以及在内核的哪里会给它们加分。

struct neigh_statistics {
unsigned long allocs; /* 已分配的邻居数量 */
unsigned long destroys; /* 已销毁的邻居数量 */
unsigned long hash_grows; /* 哈希表扩容次数 */
unsigned long res_failed; /* 解析失败的次数 */
unsigned long lookups; /* 查询次数 */
unsigned long hits; /* 查询命中次数(在 lookups 中)*/
unsigned long rcv_probes_mcast; /* 接收到的多播探测 (IPv6) */
unsigned long rcv_probes_ucast; /* 接收到的单播探测 (IPv6) */
unsigned long periodic_gc_runs; /* 周期性 GC 执行次数 */
unsigned long forced_gc_runs; /* 强制 GC 执行次数 */
unsigned long unres_discards; /* 因解析失败而丢弃的包数 */
};

字段详解

  • allocs: neigh_alloc() 成功分配一次,这个数就加一。如果这个值疯涨,说明你的网络上有大量新主机在疯狂通信,或者是有人在攻击。

  • destroys: neigh_destroy() 被调用一次加一。正常情况下,它应该和 allocs 保持某种动态平衡。

  • hash_grows: 当哈希表太挤了(链表过长),内核会调用 neigh_hash_grow() 扩容。这个数记录了扩容次数。如果它很高,说明你的网络环境非常庞大或活跃。

  • res_failed: 解析失败。neigh_invalidate() 会在彻底放弃某个邻居时增加这个计数。

  • lookups / hits: 这是一对性能指标。lookups 是调用 neigh_lookup 的总次数,hits 是直接在哈希表里找到的次数。hits / lookups 越接近 1,说明缓存命中率越高,网络越顺滑。

  • rcv_probes_mcast / ucast: 这两个是 IPv6 专用的。它们记录了收到了多少 NS(Neighbor Solicitation)消息。分开统计单播和多播是为了帮你排查网络状态——比如如果全是单播探测且没有回复,那可能网络单向不通。

  • periodic_gc_runs / forced_gc_runs: 垃圾回收的活跃度。如果 forced_gc_runs 很高,说明内存压力大,内核在不断地为了腾地方而暴力清理。

  • unres_discards: 最惨的一个统计。当 __neigh_event_send 发现邻居没解析好,而且队列也满了或者包本来就是不能等的,它只能把包扔掉并记录在这里。


7.6 本章回响

这一章走完,Linux 网络栈在 L3 层之下那层“神秘面纱”终于被揭开了。

很多网络教程会告诉你,“网络是用 IP 寻址的”,这没错。但它们通常忘了一件至关重要的事:在最后那一跳,IP 地址其实毫无用处。网卡根本不认 IP,它只认 MAC。

邻居子系统就是那座翻译“地址语言”的桥梁。

我们在本章建立的第一个认知是 “发现”的代价。当你 ping 一个 IP 时,你实际上是在进行两次对话:一次是广播问“谁有这个 IP?”,另一次是单播说“我要发数据给你”。如果这步出了问题,表象是“网络不通”,但底层可能只是 ARP 表里某个条目老化了。

第二个认知是 “信任”的脆弱性。 我们在讨论 NUD(Neighbor Unreachability Detection)时看到,内核必须时刻怀疑邻居是不是还活着。从 REACHABLESTALE,再到 PROBE,这个状态机就像一个疑神疑鬼的守门人。这种疑神疑鬼是必须的——因为在局域网里,拔网线不需要发申请,插网线也不需要打招呼,内核只能靠不断的试探来维持现实的一致性。

还记得开头那个问题吗——为什么 IPv4 的 ARP 和 IPv6 的 NDISC 虽然做的是同一件事,后者却复杂那么多? 现在答案很清晰了:因为 ARP 是为了一个简单的互联网络设计的,而 NDISC 是为了一个充满路由器、自动配置和安全顾虑的复杂网络设计的。 NDISC 里的每一个 Flag(Router, Override, Solicited),其实都是为了修补 ARP 协议在早期互联网中暴露出来的各种安全和逻辑漏洞。

而最终,所有这些复杂的机制——查询、缓存、超时、探测、垃圾回收——都被封装在那两个最简单的系统调用里:connect()sendmsg()。作为用户,你只需写下目标 IP,剩下的脏活累酒,全由本章描述的这套庞大而精密的机制在幕后默默替你完成。

理解了邻居子系统,你才真正理解了“局域网”是如何在内核眼里运作的。

下一章,我们将把目光投向更远的地方——路由。如果说邻居子系统解决了“下一跳是谁”的问题,那么路由系统就要解决“该往哪个方向走”的问题。那是网络栈真正的决策中心。


练习题

练习 1:understanding

题目:在 Linux 内核的邻居子系统初始化过程中,创建一个新的邻居条目时,会触发一个同步的垃圾回收机制。请问当邻居表中的当前条目数满足什么条件时,内核会强制触发同步垃圾回收(neigh_forced_gc)?

答案与解析

答案:当邻居条目数大于 gc_thresh3(默认1024),或者条目数大于 gc_thresh2(默认512)且距离上次刷新时间超过5秒时。

解析:根据 neigh_alloc() 方法中的逻辑,内核在分配新邻居前会检查条目数量。代码逻辑为:if (entries >= tbl->gc_thresh3 || (entries >= tbl->gc_thresh2 && time_after(now, tbl->last_flush + 5 * HZ)))。如果触发垃圾回收后条目数仍超过 gc_thresh3,则分配失败。这确保了邻居表在内存压力下能自动清理旧条目,同时也限制了频繁扫描带来的性能开销。

练习 2:understanding

题目:内核中使用 struct neighbour 结构体来管理邻居节点。当数据包需要发送但邻居的链路层地址(如 MAC 地址)尚未解析时,待发送的数据包会被暂存到结构体中的哪个成员队列中?解析完成后,为了加速后续数据包的封装,内核会将解析到的 L2 头部缓存到哪个成员中?

答案与解析

答案:数据包暂存于 arp_queue;L2 头部缓存在 hh (hh_cache)。

解析:在地址解析期间(状态通常为 NUD_INCOMPLETE),邻居条目使用 arp_queue(SKB 队列)来缓存等待发送的数据包,防止丢包。一旦解析成功,内核会将构建好的 L2 头部存储在 hhstruct hh_cache)中。下次发送时,内核可以直接复制该头部,而无需重新解析或查找,从而显著提升转发性能。

练习 3:application

题目:假设你正在排查一个复杂的网络故障:服务器 A 有两个接口 eth0 (192.168.1.10/24) 和 eth1 (192.168.2.10/24)。在 eth0 上开启 Proxy ARP,让 Server B (192.168.1.20) 访问 Server C (192.168.2.20)。此时,大量 ARP 请求可能导致 Server A 的 ARP 表迅速膨胀。为了减少 ARP 处理对正常流量的影响,内核采用了哪种机制(涉及 proxy_timer 和 proxy_queue)来优化 Proxy ARP 的处理?

答案与解析

答案:内核使用延迟处理机制,通过 proxy_queue 缓存 Proxy ARP 请求包,并利用 proxy_timer 在一段随机延迟(最长为 proxy_delay)后批量处理它们。

解析:在 Proxy ARP 场景中,主机可能会收到大量的 ARP 请求。如果立即处理每一个请求,可能会消耗大量 CPU 资源并导致队列溢出。内核通过 neigh_proxy_process()proxy_timer 实现了一种延迟策略:将请求包放入 proxy_queue,等待一个随机时间后再发送回复。这种机制给予真实 IP 所有者(拥有该 IP 的主机)优先响应的机会,同时也平滑了 Proxy ARP 的处理负载,这在高流量的网关或负载均衡场景中非常关键。

练习 4:thinking

题目:在 IPv6 邻居发现协议(NDISC)中,当主机配置一个新的 IPv6 地址时,必须执行“重复地址检测”(DAD)。在内核中,DAD 过程启动后,该 IPv6 地址会被赋予一个特定的状态标志(如 IFA_F_TENTATIVE)。如果在 DAD 完成前(即该地址仍处于 Tentative 状态)必须发送数据包,且内核启用了“Optimistic DAD (乐观 DAD)”功能,此时内核的行为与未启用该功能时有什么本质区别?

答案与解析

答案:未启用 Optimistic DAD 时,Tentative 状态的地址不能用于通信,数据包发送会被阻止或受限;启用 Optimistic DAD(RFC 4429)后,内核允许在 DAD 完成前就使用 Tentative 地址作为源地址发送数据包,虽然这违反了严格的无重复保证,但能显著缩短网络启动时的连接建立延迟。

解析:此题考察对 IPv6 地址状态机与性能优化的深度理解。标准 DAD 要求等待验证通过后才能使用地址,这会导致连接建立的双向延迟(尤其是 RTT 较大时)。addrconf_dad_start() 会设置 IFA_F_TENTATIVE 标志。Optimistic DAD 假设冲突概率极低,从而冒险提前使用地址。这需要在协议栈中特殊处理(例如限制对邻居请求的响应),是对“可靠性优先”原则的一种工程权衡,反映了内核在高性能场景下的设计考量。


要点提炼

Linux 内核通过邻居子系统统一管理 IPv4 ARP 和 IPv6 NDISC 协议,核心职责是将三层的 IP 地址动态解析为二层的 MAC 地址。为了平衡解析效率与系统资源,内核采用哈希表存储邻居条目,并设置 gc_thresh 阈值(软限制触发垃圾回收,硬限制直接拒绝新连接)来防止表项溢出。创建邻居条目时,内核会根据协议类型(IPv4/IPv6)调用特定的构造函数,自动识别并处理广播、多播等无需解析的特殊地址,同时配合 neigh_ops 接口适配不同类型网络设备(如以太网或点对点设备)的发送行为。

数据包在发送路径上(如 ip_finish_output2)会查询邻居表,若条目不存在或状态不可达,内核不会直接丢弃包,而是将其暂存到 arp_queue 队列中,并触发解析机制。对于 IPv4,这通过广播或单播 ARP Request 实现;对于 IPv6,则通过 ICMPv6 的邻居请求(NS)消息实现。为了优化网络环境,内核在发送请求时会依据 arp_announce 参数慎重选择源 IP,并支持先进行单播探针以减少广播流量,而在接收路径上,内核会利用“被动学习”机制,即无论收到请求还是响应,都会顺手记录发送者的 MAC 地址以更新邻居表。

IPv6 的 NDISC 协议比 ARP 更加严谨,它通过 ICMPv6 实现了包括路由器发现、前缀发现和地址解析在内的多种功能。为了保证地址的唯一性,IPv6 强制要求在配置地址(即标记为 Tentative 状态)时进行重复地址检测(DAD),通过发送源地址为空的特殊 NS 消息来确认无冲突后才正式启用。NDISC 的邻居通告(NA)消息携带 Router、Solicited 和 Override 等标志位,不仅告知解析结果,还能精确控制邻居缓存的状态更新策略(如是否强制覆盖旧缓存),从而构建了一个比单纯 ARP 更安全、可控的邻居发现体系。

为了应对不可靠的网络环境,邻居子系统引入了一套完善的状态机(NUD 状态)来管理条目的生命周期。条目会从初始的 INCOMPLETE 状态经过解析后进入 REACHABLE 状态,随着时间推移和未被使用,会老化至 STALEDELAY 甚至 PROBE 状态,内核会根据这些状态决定是直接发送数据还是先进行地址验证。这种“信任但验证”的策略,配合定时器和随机超时机制,确保了在高并发和拓扑动态变化的网络中,链路层映射既能保持高效,又能迅速失效并恢复,防止网络黑洞的产生。