4.3 接收 IPv4 组播数据包
上一节我们最后提到,当 ip_rcv_finish() 完成了路由查找和选项处理后,数据包的命运主要分三条路:本地投递、转发,或者——组播。
对于普通的单播,逻辑很直白:要么是给我的,要么是帮人转的。但组播不一样。组播包的命运判决充满了“如果你……或者如果……”的曲折条件。在内核的源码里,你会发现为了判断一个组播包到底该往哪走,逻辑层层嵌套。
让我们把镜头拉近到 ip_rcv_finish() 调用 ip_route_input_noref() 的那一刻。这时候,内核已经知道目的地址是一个组播地址(224.0.0.0/4),但这就引发了一个新问题:我是这个组的成员吗?还是说,我只是个负责搬运的快递员(组播路由器)?
路由查找中的组播分支
在 ip_route_input_noref() 方法里,内核首先会通过 ipv4_is_multicast(daddr) 确认这确实是个组播包。如果是,接下来的第一件事是检查本地设备是否在这个组里。
这是通过 ip_check_mc_rcu() 完成的。它的作用非常直接:拿着目的组播地址去查这个网卡接口的订阅列表。如果我们在列表里找到了匹配项,变量 our 就会被置为 1。
但这里有个“二选一”的逻辑:
- 我是成员(
our == 1):这包我有份收。 - 我是路由器(
CONFIG_IP_MROUTE开启 +IN_DEV_MFORWARD置位):不管我有没有加入这个组,只要我开启了组播转发功能,我就得负责把这包转发给别人。
只要满足上面任意一个条件,内核就会调用 ip_route_input_mc() 来为这个包构建路由缓存项(dst_entry)。
看看这段代码(net/ipv4/route.c):
int ip_route_input_noref(struct sk_buff *skb, __be32 daddr, __be32 saddr,
u8 tos, struct net_device *dev)
{
int res;
rcu_read_lock();
...
if (ipv4_is_multicast(daddr)) {
struct in_device *in_dev = __in_dev_get_rcu(dev);
if (in_dev) {
int our = ip_check_mc_rcu(in_dev, daddr, saddr,
ip_hdr(skb)->protocol);
if (our
#ifdef CONFIG_IP_MROUTE
||
(!ipv4_is_local_multicast(daddr) &&
IN_DEV_MFORWARD(in_dev))
#endif
) {
int res = ip_route_input_mc(skb, daddr, saddr,
tos, dev, our);
rcu_read_unlock();
return res;
}
}
...
}
...
}
注意这里的条件判断。如果不是本地组播(!ipv4_is_local_multicast,即非 224.0.0.x 这种只能在本地网段晃悠的地址),并且设备配置了组播转发(IN_DEV_MFORWARD),那内核就认为这是一个需要转发的组播包。
分道扬镳:收下还是转发?
进入 ip_route_input_mc() 后,根据刚才查到的结果,内核会把这个数据包的“最终归宿”挂在 dst_entry 的 input 回调函数上。
逻辑是这样的(net/ipv4/route.c):
static int ip_route_input_mc(struct sk_buff *skb, __be32 daddr, __be32 saddr,
u8 tos, struct net_device *dev, int our)
{
struct rtable *rth;
struct in_device *in_dev = __in_dev_get_rcu(dev);
...
if (our) {
rth->dst.input = ip_local_deliver;
rth->rt_flags |= RTCF_LOCAL;
}
#ifdef CONFIG_IP_MROUTE
if (!ipv4_is_local_multicast(daddr) && IN_DEV_MFORWARD(in_dev))
rth->dst.input = ip_mr_input;
#endif
...
}
这里有两个关键分支:
-
如果
our为真: 内核把dst.input设为ip_local_deliver。这意味着这包会被当作发往本地的数据处理,最终可能会送到监听该组播 Socket 的应用程序手里(比如你的视频会议软件)。同时,标志位RTCF_LOCAL会被置位。 -
如果开启了组播转发(
IN_DEV_MFORWARD): 这时候内核是个中继站。dst.input会被设为ip_mr_input。这名字起得很直白——IP Multicast Receive Input。这包接下来不会给上层应用,而是进组播转发逻辑。
⚠️ 注意:mc_forwarding 的只读陷阱
这里有一个很容易让新手抓狂的细节。
那个 IN_DEV_MFORWARD(in_dev) 宏检查的是 /proc/sys/net/ipv4/conf/all/mc_forwarding 这个 sysctl 开关。但你会发现,你不能像开启普通转发那样直接 echo 1 > ... 去设置它。
它是只读的。
这是内核有意为之的设计。内核不允许你手动拨这个开关,因为这个开关的状态必须由一个具体的组播路由守护进程(Multicast Routing Daemon)来管理。最常见的实现就是 pimd(PIM-SM v2 daemon)。
- 当
pimd启动并准备好工作时,它会通知内核把mc_forwarding设为 1。 - 当
pimd停止,内核会自动把它设回 0。
如果你对这个底层机制感兴趣,可以去 pimd 的源码里看它是怎么跟内核交互的(https://github.com/troglobit/pimd/)。它负责维护复杂的组播路由状态,而内核只负责听指挥搬运数据包。
组播转发:MFC 的幕后工作
如果数据包走了 ip_mr_input() 这条路,事情还没完。组播层维护着一张叫做 Multicast Forwarding Cache (MFC) 的表。
你可以把 MFC 理解为组播转发的“路由表”,但它比普通路由表复杂得多。普通的路由表是“目的地 -> 出口”,而 MFC 是“”的映射关系。
虽然本书第 6 章才会深挖 MFC 的细节,但这里的 ip_mr_input() 逻辑很简单:它拿着包头里的信息去查 MFC 表。
- 查到了:说明这是一个已知的组播流,内核知道该往哪几个接口转发。
- 没查到:内核可能会把这个包上交给
pimd,让它去决定怎么建立转发路径。
如果 MFC 里命中了,内核会调用 ip_mr_forward(),进而调用 ipmr_queue_xmit()。
这之后的流程,其实和我们在下一节要讲的单播转发惊人地相似。
重新计算 TTL:转发前的必修课
组播转发也是转发,既然是转发,TTL(Time To Live)就必须衰减。这是 IP 网络防止环路永恒真理的一部分。
在 ipmr_queue_xmit() 里,你会看到熟悉的身影:
static void ipmr_queue_xmit(struct net *net, struct mr_table *mrt,
struct sk_buff *skb, struct mfc_cache *c, int vifi)
{
...
ip_decrease_ttl(ip_hdr(skb));
...
NF_HOOK(NFPROTO_IPV4, NF_INET_FORWARD, skb, skb->dev, dev,
ipmr_forward_finish);
return;
}
没错,就是 ip_decrease_ttl()。
它在做两件事:
- 把 IPv4 头里的 TTL 字段减 1。
- 重新计算 IPv4 头的校验和(Checksuming)。
这里也可以看到 Netfilter 的钩子 NF_INET_FORWARD 被触发了。这意味着你的 iptables FORWARD 规则链同样会经过组播转发的包。
最后,ipmr_forward_finish() 被调用。这个函数非常短,它主要做三件事:
- 更新统计计数器(
/proc/net/snmp里的OutMcastPkts和OutOctets就是在这里涨的)。 - 如果有 IP 选项,调用
ip_forward_options()处理。 - 调用
dst_output(skb)把包送到网卡驱动发送队列。
static inline int ipmr_forward_finish(struct sk_buff *skb)
{
struct ip_options *opt = &(IPCB(skb)->opt);
IP_INC_STATS_BH(dev_net(skb_dst(skb)->dev), IPSTATS_MIB_OUTFORWDATAGRAMS);
IP_ADD_STATS_BH(dev_net(skb_dst(skb)->dev), IPSTATS_MIB_OUTOCTETS, skb->len);
if (unlikely(opt->optlen))
ip_forward_options(skb);
return dst_output(skb);
}
至此,一个组播数据包的内核旅途就结束了。它经过 ip_rcv 的洗礼,在路由系统中分道扬镳,如果你是接收者,它就到了 Socket;如果你是转发者,它就修改 TTL 后奔向下一个网络。
下一节,我们将转向 IPv4 协议中一个古老而有时会带来麻烦的特性:IP 选项(IP Options)。虽然现在很少见,但在某些追踪路由或记录时间戳的场景下,它们依然会冒出来,而且处理它们需要特别的代码路径。