跳到主要内容

ch06_4

6.4 IPv4 Multicast Rx Path

我们在上一节把 IGMP 协议和主机加入组播组的机制理清楚了,也看到了那个「最多 20 个组」的历史遗留限制。

现在,视角要从「主机」切换到「路由器」。

当一个组播数据包抵达网卡时,如果这台机器不仅是主机,还是一个配置好的组播路由器(启用了 CONFIG_IP_MROUTE 并且跑着 pimdmrouted),它的旅程会和普通主机截然不同。

这一节我们要深入 IPv4 Multicast Rx Path(接收路径)。这不是走马观花,而是要盯着每一个函数调用,看内核是如何一步步把数据包「端」到正确的队列里的。


切入点:路由查找的第一次转折

还记得在第 4 章「接收 IPv4 组播包」那一节我们简单提过,组播包会由 ip_route_input_mc() 来处理。当时我们只关心「怎么找到路由表项」。

现在我们要加一点细节:当内核启用了组播路由功能时,这个函数会做一个关键的微调。

在分配并初始化 rtable 对象(路由表项)时,它不仅设置标志位,还会把该对象的 input 回调函数——也就是决定数据包下一步去哪的指针——指向 ip_mr_input()

这行代码就在那里,平时你不注意它,但一旦你是路由器,数据包的命运就从此转手:

/* 在 ip_route_input_mc() 中的逻辑示意 */
if (CONFIG_IP_MROUTE)
rth->dst.input = ip_mr_input;

这意味着什么? 意味着这个数据包不再仅仅是「接收并本地交付」那么简单,它现在是一个待转发的候选者。


核心函数:ip_mr_input()

让我们进入 ip_mr_input() 的世界。这个函数是组播路由器接收数据包的总入口。

首先,它要做一些基础检查。

int ip_mr_input(struct sk_buff *skb)
{
struct mfc_cache *cache;
struct net *net = dev_net(skb->dev);

它先判断这个包是不是送给本地机器的。 注意,ip_mr_input() 是个双重角色:它既负责转发,也负责本地投递。因为路由器本身可能也是某个组播组的成员。

int local = skb_rtable(skb)->rt_flags & RTCF_LOCAL;
struct mr_table *mrt;

接下来是一个非常重要的安全检查,专门针对转发逻辑。

/* Packet is looped back after forward, it should not be
* forwarded second time, but still can be delivered locally.
*/
if (IPCB(skb)->flags & IPSKB_FORWARDED)
goto dont_forward;

这句话翻译成人话就是:「这个包已经被我转发过一次了,别转发了,否则会死循环。」 组播路由器会把包发出去,有时候这个包会从另一个接口又转回来(比如因为网桥或 VLAN 的配置)。如果内核没标记 IPSKB_FORWARDED,它看到这个包又会查表、又转发,那就没完没了了。

只有没被标记过的包,才有资格进入下面的转发逻辑。


寻找路由表:ipmr_rt_fib_lookup()

现在我们要查表了。虽然我们在上一节看到内核里有很多张表(策略路由),但在普通配置下,ipmr_rt_fib_lookup() 通常就直接返回 net->ipv4.mrt 这张默认的组播路由表。

mrt = ipmr_rt_fib_lookup(net, skb);
if (IS_ERR(mrt)) {
kfree_skb(skb);
return PTR_ERR(mrt);
}
if (!local) {

如果查表出错,包直接扔掉。如果是发给本地的包(local 为真),后续还会有一套单独的处理流程,但现在我们专注于转发逻辑。


路由告警选项:IGMP 的「特快通道」

这部分代码处理的是 IGMP 协议的一个细节,但它是路由器和用户空间守护进程(pimd/mrouted)通信的关键。

IGMPv3 以及部分 IGMPv2 的实现,在发送 JOIN(加入)或 LEAVE(离开)消息时,会在 IPv4 头里设置一个 Router Alert Option(IPOPT_RA)。这就像是在信封上盖了个「 Urgent 」章,告诉内核:「别按普通流程走了,直接给路由器守护进程看。」

内核检查这个选项:

if (IPCB(skb)->opt.router_alert) {

如果设置了,ip_call_ra_chain() 就会被调用。这个函数会遍历注册的 Raw Socket 链表,找到那个属于组播路由守护进程的 socket,然后把包塞进去。

if (ip_call_ra_chain(skb))
return 0;
}

但世界是残酷的,不是所有设备都守规矩。

代码里有一段非常精彩的注释,直指一些历史遗留的「坑」:

} else if (ip_hdr(skb)->protocol == IPPROTO_IGMP) {
/* IGMPv1 (and broken IGMPv2 implementations sort of
* Cisco IOS <= 11.2(8)) do not put router alert
* option to IGMP packets destined to routable
* groups. It is very bad, because it means
* that we can forward NO IGMP messages.
*/

有些老设备(比如 Cisco IOS 11.2.8 之前的版本)或者 IGMPv1 的设备,根本不设 Router Alert。 这就很麻烦了。如果不设这个选项,内核可能就把这些 IGMP 报文当普通数据处理了,路由守护进程根本收不到,也就无法维护组播树。

所以内核必须「夹带私货」:

struct sock *mroute_sk;

mroute_sk = rcU_dereference(mrt->mroute_sk);
if (mroute_sk) {
nf_reset(skb); /* 清除 Netfilter 跟踪,避免干扰 */
raw_rcv(mroute_sk, skb);
return 0;
}
}
}

它直接去拿 mrt->mroute_sk——那个我们在初始化时建立好的、内核保留的 socket 副本——然后强行把包通过 raw_rcv() 发给用户空间。

这一段逻辑保证了:无论设备多老、无论标准执行得多么不彻底,路由守护进程一定要收到 IGMP 消息。


缓存查找:ipmr_cache_find()

IGMP 的消息处理完了,现在回到数据包本身。

数据包来了,我们要决定往哪转。组播路由的核心是一张缓存表(MFC, Multicast Forwarding Cache)。这张表告诉内核:「源 S 发往组 G 的包,应该从接口 A、B、C 转出去。」

现在我们手头只有一个包,得去表里查:

cache = ipmr_cache_find(mrt, ip_hdr(skb)->saddr, ip_hdr(skb)->daddr);
if (cache == NULL) {

这个查找函数的哈希键是源地址 + 组地址。这在组播路由里是黄金搭档:一个组播流是由 (S, G) 唯一确定的。

如果找到了(cache != NULL),皆大欢喜,直接转发生发。但如果没有找到呢?

这就是组播路由中最复杂、也最有趣的部分:Cache Miss(缓存未命中)处理。


未命中时的 Proxy 机制

如果精确匹配没找到,内核不死心。它还要做一次模糊匹配。

这是为了支持 Multicast Proxy(组播代理)功能(虽然原书不展开细讲,但代码逻辑在这里)。

它先看看进来的物理接口(skb->dev)在我们的虚拟接口表(vif_table)里有没有位置:

int vif = ipmr_find_vif(mrt, skb->dev);

if (vif >= 0)
cache = ipmr_cache_find_any(mrt, ip_hdr(skb)->daddr, vif);
}

如果这个物理接口确实是个合法的组播虚拟接口(VIF),那就查一下有没有「通配源」的缓存规则(*_any)。

如果这样还没找到:

if (cache == NULL) {
int vif;

这时候内核才不得不面对现实:我真的不知道这包该往哪发。


处理未知流量:缓存未解析队列

当路由表里没有记录时,内核不能把包直接扔进垃圾桶。它得指望那个坐在用户空间的路由守护进程(mrouted)能赶紧学会这条路由。

但在守护进程反应过来之前,包已经在网上了。

内核的策略是:先收留,再求助。

首先,如果这个包本来就是发给本地机器的,那必须先投递给本地,不能耽误:

if (local) {
struct sk_buff *skb2 = skb_clone(skb, GFP_ATOMIC);
ip_local_deliver(skb);
if (skb2 == NULL)
return -ENOBUFS;
skb = skb2;
}

这里用了 skb_clone(),因为原始的 skb 可能还要拿去转发或者触发后续流程。克隆失败就直接报错。

接着,内核开始准备创建一个「未解析条目」:

read_lock(&mrt_lock);
vif = ipmr_find_vif(mrt, skb->dev);
if (vif >= 0) {

如果进来的接口是合法的 VIF,就调用 ipmr_cache_unresolved()

这个函数干了三件大事:

  1. 申请一个空壳缓存条目。 它调用 ipmr_cache_alloc_unres(),分配一个 mfc_cache 结构体。

    看看这个分配函数:

    static struct mfc_cache *ipmr_cache_alloc_unres(void)
    {
    struct mfc_cache *c = kmem_cache_zalloc(mrt_cachep, GFP_ATOMIC);

    if (c) {
    skb_queue_head_init(&c->mfc_un.unres.unresolved);

    初始化了一个队列。这个队列干嘛用的?用来存那些因为不知道路而被扣留的数据包。

    然后是生死时限:

    c->mfc_un.unres.expires = jiffies + 10*HZ;
    }
    return c;
    }

    10 秒。 这是内核给用户空间守护进程的最后通牒。如果在 10 秒内,守护进程没有通过 setsockopt() 告诉内核这条路怎么走,这个缓存条目就会被内核自动销毁。

    定时清理工作是由 ipmr_expire_timer 完成的,它会定期扫描 mfc_unres_queue 队列,把过期的条目清理掉。

  2. 把包塞进拘留所。 创建好条目后,内核会把当前这个触发了「未命中」的数据包挂到这个条目的 unresolved 队列里。 注意,这个队列不能无限长,通常内核只会允许暂存 3 个左右的包(代码逻辑在 ipmr_cache_unresolved 中有所体现,超过限制旧的包会被丢弃),防止内存被攻击者打爆。

  3. 向用户空间报警。 这是最关键的一步。内核调用 ipmr_cache_report(),构建了一个特殊的消息:IGMPMSG_NOCACHE

    这不是真正的 IGMP 协议报文,而是内核内部定义的一种消息类型,通过之前预留的那个 mroute_sk socket,发送给用户空间的守护进程:

    「喂,我看到一个从源 S 到组 G 的流量,但我不知道怎么转。这是数据包的头部信息,赶紧查表,然后回来填坑!」

    用户空间的守护进程收到这个消息后,会去查自己的组播路由表(比如通过 DVMRP 或 PIM 协议学到的路由)。如果它查到了,就会调用 setsockopt(),带上 MRT_ADD_MFC 命令,把这个路由填回内核。

    这就回到了我们在上一节提到的配置流程。

    一旦内核收到 MRT_ADD_MFCipmr_mfc_add() 就会被调用,那个「未解析」的条目会瞬间变成「已解析」的有效路由,队列里被扣留的包会被立刻释放出来进行转发。


命中缓存:ip_mr_forward()

如果前面的 ipmr_cache_find() 成功找到了缓存条目,或者用户空间已经把坑填好了,代码就会走到这里:

read_lock(&mrt_lock);
ip_mr_forward(net, mrt, skb, cache, local);
read_unlock(&mrt_lock);

ip_mr_forward() 是下一节的主角,它负责根据 cache 里的信息,把数据包复制多份,扔到对应的虚拟接口上去。

如果这个包同时也需要本地投递(local 为真),转发完之后还要调用 ip_local_deliver() 把它送给上层协议栈:

if (local)
return ip_local_deliver(skb);

return 0;

总结一下:流动的真相

把这一串流程串起来看,你会发现内核处理组播接收路径其实非常像一位经验丰富但有点严格的交通指挥员:

  1. 看资格:你带没带 Router Alert?带了直接走特快通道给路由守护进程。
  2. 查黑名单:是不是已经转过一圈了?是的话直接停手。
  3. 查地图:在 MFC 缓存里找 (S, G)。
  4. 没找到?
    • 如果是给我的,先收下。
    • 如果是给我转的,先扣留,给后台(用户空间)发个加急电报,等 10 秒看回不回来填坑。
  5. 找到了:交给 ip_mr_forward() 去分发。

这套机制保证了内核不必在内核空间跑复杂且臃肿的路由协议(如 PIM),把这些沉重的决策逻辑甩给用户空间,自己只负责快速转发和缓存管理。

下一节,我们会深入那个被调用的 ip_mr_forward(),看看「分发」这个动作具体是怎么实现的,以及 TTL 阈值是如何防止组播流量泛滥成灾的。