ch05_4
5.4 最后一公里:Nexthop (fib_nh)
上一节我们像解剖标本一样,把 fib_info 这个路由条目的「中枢神经」理了一遍。
但你有没有觉得少了点什么?
我们有目的地(fib_info 包含了几乎所有元数据),也有地图(路由表本身),但还缺指路的「路标」。
当内核真正决定把一个包发出去的时候,它不需要知道复杂的 TOS、协议优先级或者路由作用域。在那一纳秒里,它只关心两件事:从这个设备发,发给谁。
这就是本节的主角——fib_nh(Next Hop,下一跳)。它是路由决策动作在数据结构层面的最终落点,也是我们通往实际发送路径前最后的一块拼图。
结构体拆解:fib_nh 里有什么?
先别急着看代码,让我们在脑子里建立这个模型。
上一节说到,fib_info 是路由条目的「母亲」。那么 fib_nh 就是这个母亲手里牵着的「孩子」。对于大多数简单的路由(比如 ip route add 192.168.1.0/24 dev eth0),一个 fib_info 只包含一个 fib_nh;但对于多路径路由(Multipath Routing),fib_info 会持有一个 fib_nh 数组,就像母亲牵着一排孩子。
现在,让我们看看这个「孩子」长什么样:
struct fib_nh {
struct net_device *nh_dev;
struct hlist_node nh_hash;
struct fib_info *nh_parent;
unsigned int nh_flags;
unsigned char nh_scope;
#ifdef CONFIG_IP_ROUTE_MULTIPATH
int nh_weight;
int nh_power;
#endif
#ifdef CONFIG_IP_ROUTE_CLASSID
__u32 nh_tclassid;
#endif
int nh_oif;
__be32 nh_gw;
__be32 nh_saddr;
int nh_saddr_genid;
struct rtable __rcu * __percpu *nh_pcpu_rth_output;
struct rtable __rcu *nh_rth_input;
struct fnhe_hash_bucket *nh_exceptions;
};
这里面有几个字段是必须立刻搞懂的,否则后面的路走不通:
nh_dev:这是出口设备。内核抓着这个指针才能找到net_device,进而调用驱动把数据包扔出去。nh_oif:这是nh_dev的接口索引(Interface Index)。有时候我们手里还没拿到设备指针,只有 ID,就需要靠这个字段去反查。nh_gw:这是网关 IP 地址(Nexthop Gateway)。如果目的地是直连的,这个字段是 0;如果还要经过路由器跳一下,这里就是路由器的 IP。nh_parent:回指fib_info的指针。这是个双向链表结构,方便子节点反向查找父节点。
剩下的字段——nh_saddr(首选源地址)、nh_pcpu_rth_output(Per-CPU 的路由缓存)——我们在讲到具体发送路径时再细聊。
当设备「罢工」时:fib_nh 的生命周期
设备不是永远在线的。
当我们敲下 ip link set eth0 down,或者在拔掉网线的瞬间,内核必须做出反应。如果某个 fib_nh 指向的设备没了,那这条路由也就废了。
内核通过 通知链 机制来处理这件事。
补充知识:通知链(Notifier Chain) 内核里有个「八卦网」,当某个子系统发生大事(比如设备注册、设备注销),它就会站在广播台上喊一嗓子。所有关心这件事的模块只要提前在这个链条上注册了回调函数,就能收到消息。
在这里,FIB 模块就是那个「吃瓜群众」,它注册了
fib_netdev_notifier。
具体的调用链是这样的:
- 触发:用户关闭网卡或网卡物理断开。
- 通知:内核网络设备核心发出
NETDEV_DOWN事件。 - 回调:FIB 的回调函数
fib_netdev_event()被触发(定义在net/ipv4/fib_frontend.c)。 - 处理:
fib_netdev_event()调用fib_disable_ip()。
到了 fib_disable_ip(),事情就开始变得严肃了。这里有三步「杀人诛心」的操作:
第一步:标记死亡 (fib_sync_down_dev)
首先,内核调用 fib_sync_down_dev()。这个函数干了一件很绝的事:它会遍历所有使用这个设备的 fib_nh,把它们身上的 RTNH_F_DEAD 标志位给打上。
同时,它还会把这些 fib_nh 的父级 fib_info 上的标志位也修改掉。
这就像是医生给病人下达病危通知书。 路由条目本身还在内存里(
fib_info没有被释放),但是它已经被打上了「死亡」标记。后续的数据包如果查到了这条路由,看到RTNH_F_DEAD,就会知道这条路不通,进而丢弃数据包或者触发其他查找逻辑。
第二步:清扫战场 (fib_flush)
标记完了,就该删了。
fib_flush() 方法会被调用,用来真正清理掉那些「已经死亡且没人用」的路由条目。这会触发 fib_info 的引用计数减少,如果计数归零,fib_info 结构体才会被真正释放。
第三步:刷新缓存 (rt_cache_flush)
虽然路由表(FIB)里的路由已经标记为死掉了,但之前的查找结果可能还缓存在别的地方(比如 rtable 缓存)。
为了防止内核继续使用旧的、无效的缓存路径,rt_cache_flush() 被调用来强制刷新这些缓存。同时,arp_ifdown() 也会被调用来清理与此设备相关的 ARP 邻居条目——毕竟设备都没了,记着它的 MAC 地址也没用了。
例外总是存在的:FIB Nexthop Exceptions
如果你以为路由是一条铁律,一旦配置就雷打不动,那你就低估了网络的复杂性。
想象一下这个场景:
你配置了一条默认路由 via 192.168.1.1。突然,旁边的路由器(192.168.1.2)发来一个 ICMP Redirect 消息,说:「兄弟,别找 1.1 了,我这里是近道,找我更快。」
或者,路径上的 MTU 变了(比如从以太网进了 PPPoE 隧道),数据包需要 fragmentation。
这时候,如果你去修改全局的路由表(FIB),代价太大了,而且这只是针对这一个特定目的地址的临时修正。
内核 3.6 引入了一个优雅的解决方案:FIB Nexthop Exceptions。
你可以把它理解为 fib_nh 身上的「便签本」。
全局路由表写着「去往 10.0.0.0/24 的包走网关 A」,但便签本上写着「如果去的是 10.0.0.5,那个 MTU 得改成 1400,或者网关换成 B」。
这个便签本就是一个哈希表(nh_exceptions),存放在每一个 fib_nh 结构体中。
- 哈希表的 Key:目标 IP 地址 (
fnhe_daddr)。 - 大小:2048 个条目。
- 回收机制:如果某个哈希桶的链表深度超过 5,就开始清理旧的条目。
我们来看看这张「便签」的数据结构 (fib_nh_exception):
struct fib_nh_exception {
struct fib_nh_exception __rcu *fnhe_next;
__be32 fnhe_daddr; // 目标地址(Key)
u32 fnhe_pmtu; // 修正后的 PMTU
__be32 fnhe_gw; // 修正后的网关
unsigned long fnhe_expires; // 过期时间
struct rtable __rcu *fnhe_rth; // 指向修正后的路由缓存
unsigned long fnhe_stamp; // 时间戳
};
谁来写这张便签?
谁会在 fib_nh 的便签本上写东西?主要有两个场景。
场景一:收到 ICMP Redirect(ICMP 重定向)
当内核收到一个 ICMPv4 Redirect 消息(代码为 ICMP_REDIR_HOST)时,__ip_do_redirect() 函数会被调用。
这个函数从 ICMP 报文中提取出新的网关地址(new_gw),然后调用 update_or_create_fnhe(),在 fib_nh 的哈希表里创建或更新一个 exception 条目。
static void __ip_do_redirect(struct rtable *rt, struct sk_buff *skb, struct flowi4 *fl4,
bool kill_route)
{
...
__be32 new_gw = icmp_hdr(skb)->un.gateway;
...
// 核心:在 nh_exceptions 里记一笔
update_or_create_fnhe(nh, fl4->daddr, new_gw, 0, 0);
...
}
以后发往这个 daddr 的包,内核查路由时,除了看全局 FIB,还会顺手查一下这个便签本。如果发现这里有条目,它就会优先使用这里的 fnhe_gw,而不是全局配置的网关。
场景二:PMTU 更新(路径 MTU 发现)
当内核发现去往某地的 MTU 变小了(比如收到 ICMP "Fragmentation needed" 报错),__ip_rt_update_pmtu() 就会被触发。
同样的,它会调用 update_or_create_fnhe(),把新的 MTU 值记录在 fnhe_pmtu 字段里。
static void __ip_rt_update_pmtu(struct rtable *rt, struct flowi4 *fl4, u32 mtu)
{
. . .
if (fib_lookup(dev_net(dst->dev), fl4, &res) == 0) {
struct fib_nh *nh = &FIB_RES_NH(res);
// 核心:更新 PMTU,并设置过期时间
update_or_create_fnhe(nh, fl4->daddr, 0, mtu,
jiffies + ip_rt_mtu_expires);
}
. . .
}
这里有一个重要的细节:有效期。
PMTU 的信息是有时效性的。默认情况下,如果 10 分钟(600 秒)没有更新,这个 PMTU 条目就会过期。这个时间由 /proc/sys/net/ipv4/route/mtu_expires 控制。
每当 dst_mtu() 被调用时(通常在发送路径上),内核都会通过 ipv4_mtu() 检查一下时间戳,看看这个便签是不是已经过期了。如果是,就扔掉,重新走全局路径。
本节小结
到这里,我们对 fib_nh 的剖析就完成了。
回顾一下:
fib_nh是动作的终点:它包含了出设备nh_dev和网关地址nh_gw,是数据包离开内核前最后依赖的导航信息。- 设备状态联动:通过通知链机制,
fib_nh能迅速感知到设备的NETDEV_DOWN事件,通过标记RTNH_F_DEAD和fib_flush,实现了「人走茶凉」的清理逻辑。 - FIB Exceptions 机制:内核不需要为了个例去修改庞大的全局路由表。通过挂在每个
fib_nh头上的哈希表,内核实现了针对特定目的地的微调——无论是改网关还是改 MTU,一张小纸条就够了。
但这又引出了一个新的问题:如果我有 255 张不同的地图(255 个路由表),内核该听谁的?
这就是下一章要讲的 Policy Routing(策略路由)。在那之前,我们先把眼下的这块基石放稳。