跳到主要内容

7.2 用户空间与邻居子系统的交互

上一节我们聊了邻居子系统内部的那些「家务事」:内存怎么分、引用计数怎么管、条目什么时候销毁。如果你是内核开发者,这些就是你的砖瓦和水泥。

但如果你是系统管理员,或者只是在调试网络问题的开发者,你其实并不直接跟这些结构体打交道。你手里拿的是 iproute2 工具箱,你敲的是 ip neigh。这之间隔着一层 API。这一节就是关于这层 API 的——我们如何从用户空间去窥探、修改、甚至强行「教唆」内核的邻居表。


命令行工具:从 net-tools 到 iproute2

管理 ARP 表(或者说邻居表)的方式主要有两种,代表了两个时代的风格:

  1. 老派做法:使用 net-tools 包里的 arp 命令。
  2. 新派做法:使用 iproute2 包里的 ip neigh(或 ip neighbour)命令。

虽然它们干的是同一件事,但底层走的路径完全不同。

如果你运行经典的 arp 命令:

$ arp

这个请求最终会由内核里的 arp_seq_show() 方法处理,代码在 net/ipv4/arp.c 里。注意,这是 IPv4 专用的——它只看得到 ARP 表。

如果你运行现代的 ip neigh show

$ ip neigh show

这回调用的是通用的 neigh_dump_info() 方法,位于 net/core/neighbour.c

这两者输出最大的区别在于信息量。arp 命令给的是一个简略的列表,而 ip neigh show 会把邻居条目的 NUD 状态(Neighbour Unreachability Detection,邻居不可达检测状态)一股脑儿倒给你——比如 NUD_REACHABLE(可达)、NUD_STALE(陈旧)、NUD_DELAY(延迟)等。这些状态其实是邻居子系统的「体温计」,我们在后面几章会重点讲。

此外,ip 命令是双模的。加上 -6 参数:

$ ip -6 neigh show

它就会去显示 IPv6 的邻居表(NDISC 表)。这是 arp 命令做不到的。

除了命令行,内核还通过 procfs 暴露了这些数据。如果你是一个习惯了 cat 文件的古老管理员,你依然可以这样操作:

  • 看 ARP 表cat /proc/net/arp。这背后其实还是调用的 arp_seq_show(),跟敲 arp 命令没本质区别。
  • 看统计信息cat /proc/net/stat/arp_cache(ARP 统计)或 cat /proc/net/stat/ndisc_cache(NDISC 统计)。这两个文件由 neigh_stat_seq_show() 方法统一处理。

手动增删:教内核做规矩

通常情况下,内核自己会动态学习邻居的 MAC 地址(被动学习)。但有时候,我们想强行告诉内核某件事——比如「192.168.0.121 永远在 00:30:48:5b:cc:45」,不管它实际在不在。这就是静态邻居条目

你可以用 ip neigh add 来做这件事:

$ ip neigh add 192.168.0.121 dev eth0 lladdr 00:30:48:5b:cc:45 nud permanent

这条命令会触发 neigh_add() 方法。注意最后那个 nud permanent,我们在上一节提到的 NUD_PERMANENT 状态就在这里派上用场了。这个状态告诉内核:「这条条目别删,别改,别过期,它是铁律。」

要删掉它,用 ip neigh del,这背后是 neigh_delete() 方法:

$ ip neigh del 192.168.0.121 dev eth0

还有一个很有用的场景是 Proxy ARP(代理 ARP)

简单回忆一下这个概念:主机 A 询问「谁有 IP B?」,主机 C(路由器)虽然不是 IP B,但它可以代替 B 回答「我有,MAC 是 xxx」,然后把流量收过来再转发给 B。这在某些特殊网络拓扑里很有用。

要在内核里配置一个代理 ARP 条目,用 proxy 关键字:

$ ip neigh add proxy 192.168.2.11 dev eth0

虽然命令看起来差不多,但内核内部走的路径完全不一样。neigh_add() 方法会发现用户传进来的数据里带了一个 NTF_PROXY 标志(存储在 ndm 对象的 ndm_flags 字段里)。一旦看到这个标志,内核就不会去查普通的邻居表,而是调用 pneigh_lookup() 方法去查代理邻居哈希表phash_buckets)。如果没查到,它就会在这个表里新增一个条目。

删除代理条目也是同理:

$ ip neigh del proxy 192.168.2.11 dev eth0

neigh_delete() 方法检测到 NTF_PROXY 标志后,会调用 pneigh_delete() 来清理代理表。

除了操作条目,你还可以操作整个表的参数。ip ntable 命令就是干这个的:

  • ip ntable show:显示所有邻居表的参数配置。

  • ip ntable change:动态修改参数。比如,把 eth0 接口上的 ARP 缓存队列长度改为 20:

    $ ip ntable change name arp_cache queue 20 dev eth0

    这会调用 neightbl_set() 方法。这对于调优大量邻居场景(比如数据中心网关)非常有用。

最后提一句老古董 arp 命令。你也可以用它来加静态条目:

$ arp -s <IPAddress> <MacAddress>

效果跟 ip neigh add ... nud permanent 类似,但千万别指望静态条目能在重启后保留——它们只活在内核内存里,一断电就没了。如果想要持久化,得写进启动脚本里。


网络事件的回调:内核的耳朵

搞定了用户空间怎么敲门,我们再看看内核内部怎么听「广播」。

这里的「广播」指的是网络事件通知。

有意思的是,邻居核心子系统本身是不注册网络事件通知的。它是一个安静的核心,不直接关心网卡的插拔或 MAC 变更。真正操心这些事的是具体的协议模块——ARP 和 NDISC。

在 IPv4 的 ARP 模块里,arp_netdev_event() 被注册为网络事件的回调函数。它主要盯着两类事:

  1. MAC 地址变了:比如你在网卡上敲了 ifconfig eth0 hw ether ...。此时内核会触发事件,arp_netdev_event() 会调用通用的 neigh_changeaddr() 方法来刷掉相关的邻居条目,同时调用 rt_cache_flush() 清空路由缓存。因为 L2 地址变了,之前的缓存可能全都不对了。
  2. 标志位变了(从 3.11 内核开始):如果网卡的 IFF_NOARP 标志位发生了变化,也会触发 NETDEV_CHANGE 事件。同样的,neigh_changeaddr() 会介入处理。

而在 IPv6 的 NDISC 模块里,ndisc_netdev_event() 承担了类似的责任,但它关注的信号更多样:NETDEV_CHANGEADDR(地址变更)、NETDEV_DOWN(网卡停机)、以及 NETDEV_NOTIFY_PEERS(通知邻居,通常用于网卡 Failover 场景)。


本章回响

这一节我们站在了用户空间和内核空间的边界线上。

我们看到了同一份数据——邻居表——是如何被不同的视角审视的。对于 iproute2,它是一组可以被 Show、Add、Del 的对象;对于内核,它是一组哈希表、引用计数和状态机。

更重要的是,我们引入了静态配置动态事件这两个概念。虽然手动添加条目看起来很方便,但真正的网络世界是流动的——MAC 会变,网卡会断,流量会迁移。

(承接下一节) 现在,我们已经有了容器,也有了管理容器的手段。但容器里依然是空的。下一节,我们将把镜头切入到 IPv4 的世界,看看 ARP 协议是如何作为第一个居民,住进这个准备好的房子里的——尤其是那个最核心的问题:当内核发现「我不知道隔壁老王是谁」时,它到底发出了什么信号?