跳到主要内容

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。

但这里有个“二选一”的逻辑:

  1. 我是成员our == 1):这包我有份收。
  2. 我是路由器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_entryinput 回调函数上。

逻辑是这样的(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
...
}

这里有两个关键分支:

  1. 如果 our 为真: 内核把 dst.input 设为 ip_local_deliver。这意味着这包会被当作发往本地的数据处理,最终可能会送到监听该组播 Socket 的应用程序手里(比如你的视频会议软件)。同时,标志位 RTCF_LOCAL 会被置位。

  2. 如果开启了组播转发(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()

它在做两件事:

  1. 把 IPv4 头里的 TTL 字段减 1。
  2. 重新计算 IPv4 头的校验和(Checksuming)。

这里也可以看到 Netfilter 的钩子 NF_INET_FORWARD 被触发了。这意味着你的 iptables FORWARD 规则链同样会经过组播转发的包。

最后,ipmr_forward_finish() 被调用。这个函数非常短,它主要做三件事:

  1. 更新统计计数器(/proc/net/snmp 里的 OutMcastPktsOutOctets 就是在这里涨的)。
  2. 如果有 IP 选项,调用 ip_forward_options() 处理。
  3. 调用 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)。虽然现在很少见,但在某些追踪路由或记录时间戳的场景下,它们依然会冒出来,而且处理它们需要特别的代码路径。