7.2 用户空间与邻居子系统的交互
上一节我们聊了邻居子系统内部的那些「家务事」:内存怎么分、引用计数怎么管、条目什么时候销毁。如果你是内核开发者,这些就是你的砖瓦和水泥。
但如果你是系统管理员,或者只是在调试网络问题的开发者,你其实并不直接跟这些结构体打交道。你手里拿的是 iproute2 工具箱,你敲的是 ip neigh。这之间隔着一层 API。这一节就是关于这层 API 的——我们如何从用户空间去窥探、修改、甚至强行「教唆」内核的邻居表。
命令行工具:从 net-tools 到 iproute2
管理 ARP 表(或者说邻居表)的方式主要有两种,代表了两个时代的风格:
- 老派做法:使用
net-tools包里的arp命令。 - 新派做法:使用
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() 被注册为网络事件的回调函数。它主要盯着两类事:
- MAC 地址变了:比如你在网卡上敲了
ifconfig eth0 hw ether ...。此时内核会触发事件,arp_netdev_event()会调用通用的neigh_changeaddr()方法来刷掉相关的邻居条目,同时调用rt_cache_flush()清空路由缓存。因为 L2 地址变了,之前的缓存可能全都不对了。 - 标志位变了(从 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 协议是如何作为第一个居民,住进这个准备好的房子里的——尤其是那个最核心的问题:当内核发现「我不知道隔壁老王是谁」时,它到底发出了什么信号?