7.5 快速参考
走到这一步,我们已经拆解了邻居子系统的骨架(核心结构)、血液(协议交互)和肌肉(状态机)。
现在,是时候把手术刀放一边,拿出一本沉甸甸的解剖图谱了。
本节不是用来「读」的,是用来「查」的。我们会把本章讨论过的重要方法、宏定义和结构体摊开来,省得你以后在内核源码里满世界找那一行调用。你会发现,很多刚才在代码流里一闪而过的名字,在这里都有了明确的归位。
注意
核心邻居代码躲在:
net/core/neighbour.cinclude/net/neighbour.hinclude/uapi/linux/neighbour.hARP (IPv4) 的老巢在:
net/ipv4/arp.cinclude/net/arp.hinclude/uapi/linux/if_arp.hNDISC (IPv6) 的地盘在:
net/ipv6/ndisc.cinclude/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)
每个邻居条目都有自己的倒计时器。这个定时器处理函数就是听到闹铃声后的反应——通常是用来检测邻居是不是挂了(比如从
REACHABLE变STALE,或者开始发送探测包)。
发送与探测逻辑
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()函数。它在系统启动时被调用,干了一系列杂活:
- 初始化 ARP 表 (
arp_tbl)。- 注册
arp_rcv,告诉内核“收到 ARP 包交给我”。- 在
/proc下创建各种入口。- 注册 sysctl 参数(就是你
/proc/sys/net/ipv4/下看到的那些开关)。- 注册网络设备通知器
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是否开启。它不仅看具体设备的配置,还会检查all和default的设置。
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)时看到,内核必须时刻怀疑邻居是不是还活着。从 REACHABLE 到 STALE,再到 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 头部存储在 hh(struct 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 状态,随着时间推移和未被使用,会老化至 STALE、DELAY 甚至 PROBE 状态,内核会根据这些状态决定是直接发送数据还是先进行地址验证。这种“信任但验证”的策略,配合定时器和随机超时机制,确保了在高并发和拓扑动态变化的网络中,链路层映射既能保持高效,又能迅速失效并恢复,防止网络黑洞的产生。