跳到主要内容

6.6 The ipmr_queue_xmit() Method

上一节我们看到,ip_mr_forward() 像个尽职的调度员,决定把包发往哪个虚拟接口(VIF)。但它并不真正负责「发货」。真正的发货工作——包括查找路由、处理隧道封装、把包交给网卡驱动——是由 ipmr_queue_xmit() 接管的。

这一节,我们把这个包的「最后一段旅程」走完。这里有一个反直觉的设计决策在等着我们,先别急,我们一步步拆。

函数签名与 VIF 校验

首先看函数签名:

static void ipmr_queue_xmit(struct net *net, struct mr_table *mrt,
struct sk_buff *skb, struct mfc_cache *c, int vifi)
{
const struct iphdr *iph = ip_hdr(skb);
struct vif_device *vif = &mrt->vif_table[vifi];
struct net_device *dev;
struct rtable *rt;
struct flowi4 fl4;
int encap = 0;

这里的 vifiip_mr_forward() 算出来的出口索引。mrt->vif_table[vifi] 拿到对应的虚拟接口设备。

第一个逻辑门槛来了:

if (vif->dev == NULL)
goto out_free;

如果你在配置路由守护进程时不小心漏掉了一个 VIF,或者 VIF 被意外删除了,内核在这里就会直接把包丢弃。没有警告,没有报错,就是 out_free。这就是内核的冷酷之处——到了发送这一步才发现设备没了,除了扔掉它,还能怎么办呢?

特殊情况:PIM Register VIF

接下来是一个特定协议的处理逻辑。如果你开启了 CONFIG_IP_PIMSM(PIM 稀疏模式),你会遇到一种叫 VIFF_REGISTER 的特殊接口。

#ifdef CONFIG_IP_PIMSM
if (vif->flags & VIFF_REGISTER) {
vif->pkt_out++;
vif->bytes_out += skb->len;
vif->dev->stats.tx_bytes += skb->len;
vif->dev->stats.tx_packets++;
ipmr_cache_report(mrt, skb, vifi, IGMPMSG_WHOLEPKT);
goto out_free;
}
#endif

这不仅仅是转发。PIM Register VIF 的作用是把组播数据包封装在 PIM 注册消息里,发往 RP(Rendezvous Point)。所以这里调用了 ipmr_cache_report(),把整个数据包(IGMPMSG_WHOLEPKT)上交给用户空间的路由守护进程,由它来负责后续的封装和发送。内核本身不直接处理这种复杂的封装,它只是个搬运工。

路由查找:隧道 vs. 物理接口

接下来就是决定「这个包下一站去哪」的关键步骤。这里有一个巨大的分岔路口:你要发的 VIF 是一个隧道,还是一个普通的物理网卡?

如果是隧道(VIFF_TUNNEL

我们需要为这个隧道本身查找一条单播路由。注意这里的目的地不是组播组地址,而是隧道的远端地址

if (vif->flags & VIFF_TUNNEL) {
rt = ip_route_output_ports(net, &fl4, NULL,
vif->remote, vif->local,
0, 0,
IPPROTO_IPIP,
RT_TOS(iph->tos), vif->link);
if (IS_ERR(rt))
goto out_free;
encap = sizeof(struct iphdr);

看到 IPPROTO_IPIP 了吗?这说明隧道本质上就是 IP-in-IP 的封装。这里查出来的 rt,是指向隧道对端那台路由器的路由。因为要加一个新的 IP 头,所以 encap 被设为 sizeof(struct iphdr),预留了头部空间。

如果是普通物理接口

这就比较直观了。我们要找的是去往组播组地址的路由。

} else {
rt = ip_route_output_ports(net, &fl4, NULL, iph->daddr, 0,
0, 0,
IPPROTO_IPIP, // 注意这里参数虽是IPIP,但实际物理接口通常走常规路径
RT_TOS(iph->tos), vif->link);
if (IS_ERR(rt))
goto out_free;
}
dev = rt->dst.dev;

拿到 rt 之后,我们就知道了最终要发包的物理设备 dev

MTU 检查:残酷的黑洞

如果你做单播转发,当包太大会发生什么?内核会发 ICMP Fragmentation Needed(Type 3, Code 4)回去告诉主机「包太大了,切小点」。

但在组播里?什么都不做。

看这段代码:

if (skb->len+encap > dst_mtu(&rt->dst) && (ntohs(iph->frag_off) & IP_DF)) {
/* Do not fragment multicasts. Alas, IPv4 does not
* allow to send ICMP, so that packets will disappear
* to blackhole.
*/
IP_INC_STATS_BH(dev_net(dev), IPSTATS_MIB_FRAGFAILS);
ip_rt_put(rt);
goto out_free;
}

这是一个非常反直觉的设计,但它是深思熟虑的结果。 想象一下,一个组播组可能有成千上万个接收者。如果因为其中一条路径上的 MTU 变小了,路由器就开始向源头发 ICMP 错误报文,这会带来两个问题:

  1. ICMP 风暴:源头可能会被海量的 ICMP 消息淹没。
  2. 无法满足所有人:有的路径 MTU 是 1500,有的是 1400。源头切成 1400,浪费带宽;切成 1500,小 MTU 的路径依然通不过。

RFC 规定:遇到这种情况,直接丢弃。统计数加一,然后沉默。这就像是一个严厉的黑洞,它吞噬掉不合规矩的包,但不发出任何声响。

包头调整与封装

接下来是常规操作:为可能的头部扩张预留空间(skb_cow),然后把查到的路由 dst 绑定到 skb 上。

encap += LL_RESERVED_SPACE(dev) + rt->dst.header_len;

if (skb_cow(skb, encap)) {
ip_rt_put(rt);
goto out_free;
}

vif->pkt_out++;
vif->bytes_out += skb->len;

skb_dst_drop(skb);
skb_dst_set(skb, &rt->dst);

然后,TTL 减 1。这是 IP 转发的标准动作,无论是单播还是组播。

ip_decrease_ttl(ip_hdr(skb));

如果是隧道模式,我们还需要进行真正的封装(ip_encap)——在旧包外面套一层新的 IP 头。

if (vif->flags & VIFF_TUNNEL) {
ip_encap(skb, vif->local, vif->remote);
/* FIXME: extra output firewall step used to be here. --RR */
vif->dev->stats.tx_packets++;
vif->dev->stats.tx_bytes += skb->len;
}

代码里遗留的 FIXME 注释是历史遗留的痕迹。早期的内核版本里,这里还有防火墙钩子,但随着 Netfilter 框架的统一,这些散落在各处的钩子调用被移除了,现在统一走下面的 Netfilter Hook。

调用 Netfilter 与最终发送

最后,打上 FORWARDED 的标签,交给 Netfilter 的 NF_INET_FORWARD 钩子。

IPCB(skb)->flags |= IPSKB_FORWARDED;

这里有一段很长的注释,引用了 RFC 1584。它解释了一个微妙的设计:组播路由器通常需要把包既发给本地用户(如果有运行的组播程序),也转发出去。为了避免让用户态程序去 join 每一个接口,内核路由器本身应该作为一个接收者。

这段注释反映了早期组播实现的一些权衡,但在现代 Linux 内核里,这个逻辑主要通过 ip_mr_input 里的本地投递标记来处理。ipmr_queue_xmit 专注于它的本职工作——把包发出去。

NF_HOOK(NFPROTO_IPV4, NF_INET_FORWARD, skb, skb->dev, dev,
ipmr_forward_finish);
return;

out_free:
kfree_skb(skb);
}

如果 Netfilter 钩子放行(NF_ACCEPT),流程会走到 ipmr_forward_finish

ipmr_forward_finish() 与 dst_output

这个函数短得令人发指,它几乎是 ip_forward_finish 的克隆体:

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);
}

它做了三件事:

  1. 更新统计信息(发出去多少包,多少字节)。
  2. 处理老式的 IP Options(现在很少见了,但为了兼容性还得保留)。
  3. 调用 dst_output()

dst_output() 最终会调用邻居子系统的发送函数,把数据包塞到网卡驱动的发送队列(qdisc)里。如果 skb->dev 是一个物理网卡,包就以以太网帧的形式飞出去了;如果是隧道,包会被当成普通 IP 包发送出去,对端收到后解封装,还原成组播包。


The TTL in Multicast Traffic

至此,组播路由的内核发送路径就全部走通了。但在结束这一节之前,我们得回头聊聊一个一直在背景里扮演重要角色的字段——TTL

在组播世界里,TTL 有两层含义。

第一层含义是“跳数限制”,这和单播一样。每经过一个路由器,TTL 减 1,变成 0 就丢弃。这是为了防止路由环路形成的无限风暴。

第二层含义是“范围阈值”,这是组播独有的。 为了不让组播流量漫无目的地泛滥到整个互联网,早期的组播先驱 Steve Deering 制定了一套基于 TTL 的“行政边界”规则。每个路由器的接口都有一个阈值(Threshold),只有当数据包的 TTL 大于 这个阈值时,才允许转发。

这套规则把 TTL 值变成了地理或行政范围的代号:

  • 0:限制在本主机(根本出不去接口)。
  • 1:限制在同一个子网(穿不过路由器)。
  • 32:限制在同一个站点。
  • 64:限制在同一个地区。
  • 128:限制在同一个大洲。
  • 255:全球范围(Global)。

如果你在写组播应用程序,你可以通过设置 IP_MULTICAST_TTL socket 选项来控制你的包能飞多远。设置为 1,你的包就在局域网里晃悠;设置为 64,理论上能穿过你的 ISP,但这取决于 ISP 的路由器是不是真的按照 Deering 的建议配置了阈值。

这套机制虽然古老(源于 4.3BSD 时代),但在现代的 PIM(Protocol Independent Multicast)协议中,依然保留着类似的 TTL 检查逻辑作为第一道防线。


Implementation Files

Linux 内核的组播路由实现主要集中在以下三个文件中,如果你对源码感兴趣,可以去翻翻:

  • net/ipv4/ipmr.c:核心实现。
  • include/linux/mroute.h:内核内部头文件。
  • include/uapi/linux/mroute.h:用户空间 API(ioctl 接口定义)。

到这里,关于组播路由的内核机制就告一段落了。我们是怎么从“把包发给谁”这个朴素的问题出发,一路走到 mr_table、MFC 缓存、VIF 设备,最后在 ipmr_queue_xmit 里完成封装和发送的。

但我们的旅程还没结束。到目前为止,所有的路由决策本质上还是基于“目的地”的。下一章,我们要引入一个更强大、也更复杂的机制——策略路由(Policy Routing)。在那里,路由决策将不再只看 dest,还会看 sourcefwmark 甚至数据包的入接口。这才是高级路由的真正开始。