跳到主要内容

6.3 Multicast Router

上一节我们拆解了内核里的 mr_tablemfc_cache——那是组播转发的骨架

但这骨架是死的。它躺在那儿,等着谁来推它一把,告诉它哪些接口应该参与转发,哪些数据包该往哪走。

这一节,我们要聊那个「推手」:Multicast Router(组播路由器)

你会看到它是如何在用户空间和内核之间建立连接的,以及那个关键的握手协议——setsockopt


配置组播路由器

要把一台 Linux 机器变成组播路由器,光有内核代码是不够的。

首先,你得在编译内核时把 CONFIG_IP_MROUTE 这个选项打开。这就像给车装上四驱套件——没这个,后面的事都免谈。

其次,你得在用户空间跑一个懂行的守夜人

前面我们提过 pimd 或者 mrouted。这些守护进程不是瞎跑的,它们干的第一件事,就是跟内核建立一条「热线」。

这条热线的建立方式非常经典:创建一个 Raw Socket。

pimd 为例,它在启动时会调用这样一个系统调用:

socket(AF_INET, SOCK_RAW, IPPROTO_IGMP);

这行代码的意思是:「内核,给我开一个直通 IGMP 协议层的后门,我不想要 TCP/UDP 那套封装,我要直接发 IGMP 消息。」

拿到这个 socket 文件描述符后,真正的控制才刚刚开始。


握手:MRT_INIT

守护进程有了 socket,它怎么告诉内核「我想启用组播路由」?

答案是:setsockopt()

这不是普通的参数设置,这是一次身份声明

当守护进程调用 setsockopt(sock, IPPROTO_IP, MRT_INIT, ...) 时,信号直接传到了内核的 ip_mroute_setsockopt() 方法里。

内核在处理 MRT_INIT 命令时,会做两件极其重要的事:

  1. 保存接头暗号:内核会把当前这个 socket 的引用,小心翼翼地存到 mr_table 结构体的 mroute_sk 字段里。这意味着,从这一刻起,内核认定了这个 socket 是唯一的「总指挥」。
  2. 拉下电闸:内核会自动将 /proc/sys/net/ipv4/conf/all/mc_forwarding 这个 procfs 条目设为 1(通过宏 IPV4_DEVCONF_ALL(net, MC_FORWARDING)++)。

等等,这里有个细节值得玩味。

那个 mc_forwarding 文件,你在 /proc 里能看到它是只读的。你没法用 echo 1 > ... 去改它。

为什么?因为它是状态,不是配置

它是内核根据「有没有组播路由守护进程在运行」自动判断出来的。只有在 MRT_INIT 发生时,它才会翻转;守护进程一退出,它就会自动归零。这种设计防止了你在没有守护进程的情况下强行开启转发,导致内核行为错乱。


独占性:一次只能有一个老大

既然提到了 mroute_sk,这里有一个硬性规定。

同一时间,只能有一个组播路由守护进程存在。

你可以想象一下,如果两个 pimd 同时在运行,一个说「往左转」,一个说「往右转」,内核听谁的?

所以,在 ip_mroute_setsockopt() 处理 MRT_INIT 的时候,第一件事就是检查 mroute_sk 是否已经被占用:

if (mrt->mroute_sk)
return -EADDRINUSE;

如果已经有 socket 占了坑,你试图启动第二个守护进程,内核会直接甩你一个 -EADDRINUSE(Address already in use)。这是内核在说:「这地儿有人了,一边凉快去。」


添加接口:MRT_ADD_VIF

一旦初始化完成,守护进程接下来的任务就是把物理网卡注册到组播路由表里。

这不再是干喊口号,而是要给每个网卡办一张「入场券」。

入场券的办理方式,还是靠 setsockopt(),不过这次命令换成了 MRT_ADD_VIF(Virtual Interface Add)。

你需要填一张表——struct vifctl

这就是那张「表」的样子:

struct vifctl {
vifi_t vifc_vifi; /* 虚拟接口的索引 ID */
unsigned char vifc_flags; /* 各种标志位 */
unsigned char vifc_threshold; /* TTL 阈值限制 */
unsigned int vifc_rate_limit; /* 流量限速值(未实现) */
union {
struct in_addr vifc_lcl_addr; /* 本地接口 IP 地址 */
int vifc_lcl_ifindex;/* 本地接口索引 */
};
struct in_addr vifc_rmt_addr; /* 隧道远端地址(如果是隧道的话)*/
};

这张表里的字段非常精简,但每一个都直指要害。我们来拆几个关键的:

  • vifc_vifi:这是你给这个接口起的「代号」。内核不关心你叫它什么,只要它是 0 到 31 之间的唯一整数。
  • vifc_flags:这里决定了这张入场券的性质。
    • VIFF_TUNNEL:如果你打上这个标志,说明你要把数据包封装在另一个 IP 包里传输(IPIP 隧道)。这常用于跨越不支持组播的公网。
    • VIFF_REGISTER:这是 PIM-SM(稀疏模式)专用的标志位,用来注册一个特殊的接口。
    • VIFF_USE_IFINDEX:默认情况下,我们用 IP 地址来指代接口。但现在的机器网卡多,IP 变得不可靠(有可能一个网卡没配 IP)。如果你设了这个标志,union 里就会改用 vifc_lcl_ifindex(网卡索引)来定位设备。这是 2.6.33 内核以后才有的高级特性。
  • vifc_lcl_addr vs vifc_lcl_ifindex:这是一个 union。也就是说,你要么用 IP 地址找设备,要么用网卡索引找设备,二选一。推荐用索引,更稳。
  • vifc_rmt_addr:如果是隧道模式,这里填的是隧道那头的机器 IP。

守护进程填好这个结构体,通过 setsockopt 扔给内核,内核就会调用 vif_add() 把这个设备挂到路由表里。

如果要删除呢?

把命令换成 MRT_DEL_VIF,填上 vifc_vifi,内核就会调用 vif_delete() 把它摘下来。


收尾:MRT_DONE

当守护进程决定退休(无论是正常退出还是被 kill 掉),它必须做一个有始有终的人。

最后一次调用 setsockopt,命令是 MRT_DONE

这会触发内核的 mrtsock_destruct() 方法。

这个名字很有意思——destruct(析构)。

它会干两件事:

  1. mr_table 里的 mroute_sk 清空(设为 NULL),表示「老大走了,现在群龙无首」。
  2. mc_forwarding 的状态改回 0,关掉组播转发。

到这一步,这台机器就从「路由器」退化回了一台普通的「主机」。


The Vif Device(虚拟接口设备)

上一节我们提到的 vifctl,是用户空间填的「申请表」。

而内核真正用的,是 struct vif_device。这才是那个活在内核里、干活的「对象」。

组播路由支持两种模式:直连组播隧道组播

无论是哪种,内核都用同一个结构体——vif_device 来表示。

你可以把它想象成一个高级网卡驱动抽象。如果它是隧道模式(VIFF_TUNNEL 被设置),那它的 dev 指针指的可能就是那个虚拟的 tunl0 设备。

让我们看看它的真面目:

struct vif_device {
struct net_device *dev; /* 正在使用的真实网卡设备 */
unsigned long bytes_in, bytes_out;
unsigned long pkt_in, pkt_out; /* 统计信息:收发了多少包,多少字节 */
unsigned long rate_limit; /* 流量整形(暂未实现)*/
unsigned char threshold; /* TTL 阈值 */
unsigned short flags; /* 控制标志位 */
__be32 local, remote; /* 地址:local 是本地地址,remote 是隧道远端地址 */
int link; /* 底层物理接口的索引 */
};

大部分字段都一目了然——它就是一个增强版的网卡描述符。

这里有两个细节要注意:

  1. dev_set_allmulti(dev, 1): 当调用 vif_add() 添加接口时,内核不仅分配这个 vif_device 结构体,还会调用 dev_set_allmulti(dev, 1)。 这一行代码的作用是:把底层真实网卡的 allmulti 计数器加 1。 这就像告诉网卡驱动:「兄弟,从现在开始,别光帮我收单播包了,把路过的所有组播包都往上递,我要自己挑。」 如果没有这一步,网卡会在硬件层把不属于本机的组播包直接丢掉,内核根本没机会转发。

  2. 清理工作: 当调用 vif_delete() 时,必须调用 dev_set_allmulti(dev, -1) 把计数器减回去。这是一种礼貌——「我不干组播了,你别再傻乎乎地收所有垃圾包了,恢复节电模式吧。」


主机视角:加入与离开组

上面说的都是「路由器」干的事——转发数据包。

但如果这台机器自己也想看组播流(比如它是个视频服务器,既转发流,自己也得解码看看画面),它就得变回「主机」身份,加入一个组播组。

这个过程是应用层发起的。

你在 C 语言里写网络编程,这是标准操作:

  1. 创建一个 socket。
  2. 调用 setsockopt(socket, IPPROTO_IP, IP_ADD_MEMBERSHIP, ...)

这里的 IP_ADD_MEMBERSHIP 就是一个申请单。

应用层会准备一个 struct ip_mreq 结构体(定义在 <netinet/in.h> 里),填上两个东西:

  • imr_multiaddr:你想加入的那个组播组的地址(比如 239.1.1.1)。
  • imr_interface:你本地网卡的身份(IP 地址),告诉内核「我想用这张网卡去听」。

这个系统调用进入内核后,最终会落到 net/ipv4/igmp.c 里的 ip_mc_join_group() 方法。

ip_mc_join_group() 的核心逻辑很简单: 它会把你要加入的那个组播地址,挂到网卡结构体(in_device)的 mc_list 链表上。

同时,内核会通过 IGMP 协议,向网络里发一个「报告」,告诉路由器:「嘿,我在这儿,我对 239.1.1.1 感兴趣,有流量往这发。」

当然,有进就有出。

当你不想看了,调用 setsockopt 并指定 IP_DROP_MEMBERSHIP。内核会调用 ip_mc_leave_group(),把那个地址从 mc_list 里摘下来,并通知路由器「我退群了」。


限额:20 个上限

这里有一个你可能不太注意的限制。

一个 socket,最多只能加入 20 个组播组。

这个限制硬编码在内核的 sysctl_igmp_max_memberships 里。

如果你贪心,试图用同一个 socket 加入第 21 个组,ip_mc_join_group() 会毫不留情地返回 -ENOBUFS(No buffer space available)。

为什么是 20?

这是历史包袱和资源平衡的产物。每个成员资格都要占内核内存,还要维护定时器。对于普通应用来说,20 个绰绰有余;如果你真的需要加入几百个组(比如做组播网关),那你应该开多个 socket 分担压力,或者去改 /proc/sys/net/ipv4/igmp_max_memberships 的默认值。

到这里,我们清楚了: 内核里有一张路由表,用户空间有个守护进程,中间用 setsockopt 串起来。

但光配置好有什么用?数据包真的来了,它是怎么流动的?

下一节,我们会切入到 Rx Path,看看当一个组播数据包抵达网卡时,内核是如何一步步把它「端」到正确的输出队列里的。