本节旨在提供一些信息,这些信息对于基本理解组播的工作原理或编写组播程序并非必需,但非常有趣,可以深入了解底层的组播协议和实现,并且可能有助于避免常见的错误和误解。
在讨论 IP_ADD_MEMBERSHIP
和 IP_DROP_MEMBERSHIP
时,我们说过这些“命令”提供的信息被内核用来选择接受或丢弃哪些组播数据报。这是事实,但并非全部真相。这种简化意味着,世界上所有组播组的组播数据报都将被我们的主机接收,然后它会检查其上运行的进程发出的成员关系,以决定是将流量传递给它们还是将其丢弃。您可以想象,这完全是带宽的浪费。
实际发生的情况是,主机指示其路由器,告诉它们自己对哪些组播组感兴趣;然后,这些路由器告诉其上游路由器它们想要接收该流量,依此类推。用于决定何时请求一个组的流量或声明不再需要该流量的算法各不相同。然而,有些东西永远不会改变:信息如何传输。IGMP 就是用于此目的的。它代表 Internet 组管理协议。它是一种新的协议,在许多方面与 ICMP 相似,协议号为 2,其消息在 IP 数据报中传输,并且所有符合 2 级标准的主机都必须实现它。
如前所述,主机使用它向路由器提供成员关系信息,路由器也使用它在彼此之间进行通信。在下文中,我将仅介绍主机与路由器之间的关系,主要是因为除了 mrouted 源代码之外,我找不到描述路由器到路由器通信的信息(描述距离向量组播路由协议的 rfc 1075 现已过时,而 mrouted
实现了一个尚未记录的修改后的 DVMRP)。
IGMP 版本 0 在 RFC-988 中指定,该 RFC 现已过时。现在几乎没有人使用版本 0。
IGMP 版本 1 在 RFC-1112 中描述,尽管它已被 RFC-2236(IGMP 版本 2)更新,但仍被广泛使用。Linux 内核实现了完整的 IGMP 版本 1 和部分版本 2 的要求,但并非全部。
现在我将尝试给出协议的非正式描述。您可以查看 RFC-2236 以获得正式的、经过验证的描述,其中包含大量的状态图和超时边界。
所有 IGMP 消息都具有以下结构
0 1 2 3 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | Type | Max Resp Time | Checksum | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | Group Address | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
IGMP 版本 1(以下简称 IGMPv1)将“最大响应时间”标记为“未使用”,发送时将其置零,接收时忽略它。此外,它将“类型”字段分解为两个 4 位宽的字段:“版本”和“类型”。由于 IGMPv1 将“成员关系查询”消息标识为 0x11(版本 1,类型 1),而 IGMPv2 也标识为 0x11,因此这 8 位具有相同的有效解释。
我认为首先给出 IGMPv1 的描述,然后指出 IGMPv2 的新增内容会更具启发性,因为它们主要是新增内容。
对于以下讨论,重要的是要记住组播路由器接收所有 IP 组播数据报。
路由器定期向所有主机组(224.0.0.1)发送 IGMP 主机成员关系查询,TTL 为 1(每隔一两分钟一次)。所有支持组播的主机都会听到它们,但不会立即响应,以避免 IGMP 主机成员关系报告风暴。相反,它们为自己所属的每个组启动一个随机延迟计时器,在接收到查询的接口上。
迟早,计时器会在其中一台主机上过期,并且它会向正在报告的组的组播地址发送 IGMP 主机成员关系报告(TTL 也为 1)。由于它是发送到组的,因此所有已加入该组且当前正在等待自己的计时器过期的主机也会收到它。然后,它们停止计时器,并且不再生成任何其他报告。只生成一个 - 由选择较小超时的主机生成 - 这对于路由器来说就足够了。它只需要知道子网中存在该组的成员,而不需要知道有多少成员或哪些成员。
当在一定数量的查询之后,对于给定组没有收到任何报告时,路由器会假定没有成员留下,因此不必在该子网上转发该组的流量。请注意,在 IGMPv1 中,没有“离开组消息”。
当主机加入新组时,内核会为该组发送报告,以便相应的进程无需等待一两分钟,直到收到新的成员关系查询。正如您所看到的,此 IGMP 数据包是由内核响应 IP_ADD_MEMBERSHIP
命令而生成的,如 IP_ADD_MEMBERSHIP 节中所述。请注意形容词“新”的强调:如果进程为某个组发出 IP_ADD_MEMBERSHIP
命令,而主机已经是该组的成员,则不会发送任何 IGMP 数据包,因为我们肯定已经在接收该组的流量;相反,该组的使用计数器会递增。IP_DROP_MEMBERSHIP
在 IGMPv1 中不生成任何数据报。
主机成员关系查询由类型 0x11 标识,主机成员关系报告由类型 0x12 标识。
不会为所有主机组发送报告。此组的成员关系是永久性的。
上述内容的一个重要补充是包含了一条 离开组 消息(类型 0x17)。原因是减少子网中最后一个主机放弃成员关系的时间与路由器查询超时并确定该组不再有成员存在的时间之间的带宽浪费(离开延迟)。离开组消息应寻址到所有路由器组(224.0.0.2),而不是寻址到正在离开的组,因为该信息对其他成员无用(内核版本高达 2.0.33 会将它们发送到组;虽然这对主机没有危害,但这是浪费时间,因为它们必须处理它们,但没有获得有用的信息)。关于何时以及何时不发送离开消息,存在某些细微的细节;如果感兴趣,请参阅 RFC。
当 IGMPv2 路由器收到某个组的离开消息时,它会向正在离开的组发送特定于组的查询。这是另一个新增功能。IGMPv1 没有特定于组的查询。所有查询都发送到所有主机组。IGMP 标头中的类型不会更改(与之前一样,为 0x11),但“组地址”填充了正在离开的组的地址。
“最大响应时间”字段在 IGMPv1 中设置为 0 进行传输,并在接收时忽略,它仅在“成员关系查询”消息中才有意义。它给出了在发送报告之前允许的最大时间,单位为 1/10 秒。它用作调整机制。
IGMPv2 添加了另一种消息类型:0x16。如果 IGMPv2 主机检测到存在 IGMPv2 路由器(当 IGMPv2 主机收到“最大响应”字段设置为 0 的查询时,它就知道存在 IGMPv1 路由器),则会发送“版本 2 成员关系报告”。
当多个路由器声称充当查询器时,IGMPv2 提供了一种机制来避免“争论”:IP 地址最低的路由器被指定为查询器。其他路由器保持超时。如果 IP 地址较低的路由器崩溃或关闭,则在计时器过期后,将再次决定谁将成为查询器。
本小节提供了一些起点,用于研究 Linux 内核的组播实现。它没有解释该实现。它只是说明在哪里可以找到内容。
该研究是在版本 2.0.32 上进行的,因此在您阅读本文时可能已过时(例如,网络代码在 2.1.x 版本中似乎发生了很大的变化)。
Linux 内核中的组播代码始终被 #ifdef CONFIG_IP_MULTICAST
/ #endif
对包围,以便您可以根据需要将其包含/排除在内核之外(这种包含/排除是在编译时完成的,如果您正在阅读本节,您可能知道... #ifdef
由预处理器处理。该决定是根据您在执行 make config
、make menuconfig
或 make xconfig
时选择的内容做出的)。
您可能想要组播功能,但如果您的 Linux 机器不打算充当组播路由器,您可能不希望在新内核中包含组播路由功能。为此,您的组播路由代码被 #ifdef CONFIG_IP_MROUTE
/ #endif
对包围。
内核源代码通常放在 /usr/src/linux 中。但是,位置可能会更改,因此,为了准确性和简洁性,我将内核源代码的根目录简称为 LINUX。那么,类似 LINUX/net/ipv4/udp.c
的内容应该与 /usr/src/linux/net/ipv4/udp.c
相同,如果您将内核源代码解压缩到 /usr/src/linux
目录中。
在专门介绍组播编程的部分中显示的所有与用户程序的组播接口都是通过 setsockopt()
/ getsockopt()
系统调用驱动的。它们都是通过函数实现的,这些函数进行一些测试以验证传递给它们的参数,然后又调用另一个函数,该函数进行一些额外的测试,根据 level
参数多路分解对任一系统调用的调用,然后调用另一个函数,该函数...(如果对所有这些跳转感兴趣,您可以在 LINUX/net/socket.c
中跟踪它们(函数 sys_socketcall()
和 sys_setsockopt()
,LINUX/net/ipv4/af_inet.c
(函数 inet_setsockopt()
)和 LINUX/net/ipv4/ip_sockglue.c
(函数 ip_setsockopt()
))。
我们感兴趣的是 LINUX/net/ipv4/ip_sockglue.c
。在这里,我们找到了 ip_setsockopt()
和 ip_getsockopt()
,它们主要是一个 switch
(在进行一些错误检查之后),用于验证 optname
的每个可能值。除了单播选项外,此处看到的所有组播选项都会被处理:IP_MULTICAST_TTL
、IP_MULTICAST_LOOP
、IP_MULTICAST_IF
、IP_ADD_MEMBERSHIP
和 IP_DROP_MEMBERSHIP
。在 switch
之前,会进行测试以确定选项是否是特定于组播路由器的,如果是,则将它们路由到 ip_mroute_setsockopt()
和 ip_mroute_getsockopt()
函数(文件 LINUX/net/ipv4/ipmr.c
)。
在 LINUX/net/ipv4/af_inet.c
中,我们可以看到我们在前面部分中谈到的默认值(启用环回,TTL=1),在创建套接字时提供(取自此文件中的函数 inet_create()
)
#ifdef CONFIG_IP_MULTICAST sk->ip_mc_loop=1; sk->ip_mc_ttl=1; *sk->ip_mc_name=0; sk->ip_mc_list=NULL; #endif
此外,“关闭套接字使内核放弃此套接字拥有的所有成员关系”的断言得到了
#ifdef CONFIG_IP_MULTICAST /* Applications forget to leave groups before exiting */ ip_mc_drop_socket(sk); #endif
inet_release()
,与之前的文件在同一文件中。链路层的设备无关操作保存在 LINUX/net/core/dev_mcast.c
中。
仍然缺少两个重要的函数:输入和输出组播数据报的处理。与任何其他数据报一样,传入的数据报从设备驱动程序传递到 ip_rcv()
函数 (LINUX/net/ipv4/ip_input.c
)。在此函数中,对已跨越设备层(回想一下,较低层仅执行尽力而为的过滤,而 IP 100% 知道我们是否对该组播组感兴趣)的组播数据包应用了完美的过滤。如果主机充当组播路由器,则此函数还会决定是否应转发数据报,并适当地调用 ipmr_forward()
。(ipmr_forward()
在 LINUX/net/ipv4/ipmr.c
中实现)。
负责输出数据包的代码保存在 LINUX/net/ipv4/ip_output.c
中。在这里,IP_MULTICAST_LOOP
选项生效,因为它被检查以查看是否环回数据包(函数 ip_queue_xmit()
)。此外,传出数据包的 TTL 是根据它是组播数据包还是单播数据包来选择的。在前一种情况下,使用传递给 IP_MULTICAST_TTL
选项的参数(函数 ip_build_xmit()
)。
在使用 mrouted
(一个程序,它向内核提供有关如何路由组播数据报的信息)时,我们检测到本地网络上的所有组播数据包都已正确路由...,除了来自充当组播路由器的 Linux 机器的数据包!!ip_input.c 工作正常,但 ip_output.c 似乎没有。阅读输出函数的源代码,我们发现传出的数据报没有传递给 ipmr_forward()
,该函数必须决定是否应路由它们。数据包已输出到本地网络,但由于网卡通常无法读取自己的传输,因此这些数据报永远不会被路由。我们在 ip_build_xmit()
函数中添加了必要的代码,一切又恢复正常。(拥有内核源代码不是奢侈或迂腐;而是一种需要!)
ipmr_forward()
已被提及了几次。它是一个重要的函数,因为它解决了一个似乎被广泛传播的重要误解。在路由组播流量时,不是 mrouted
制作副本并将它们发送给正确的接收者。mrouted
接收所有组播流量,并基于该信息,计算组播路由表并告诉内核如何路由:“来自该接口的此组的数据报应转发到那些接口”。此信息通过对 mrouted
守护程序打开的原始套接字调用 setsockopt()
传递给内核(创建原始套接字时指定的协议必须是 IPPROTO_IGMP
)。这些选项在 LINUX/net/ipv4/ipmr.c
中的 ip_mroute_setsockopt()
函数中处理。在该套接字上发出的第一个选项(最好称其为命令而不是选项)必须是 MRT_INIT
。如果未首先发出 MRT_INIT
,则所有其他命令都将被忽略(返回 -EACCES
)。同一主机中同一时间只能运行一个 mrouted
实例。为了跟踪这一点,当收到第一个 MRT_INIT
时,一个重要的变量 struct sock* mroute_socket
指向收到 MRT_INIT
的套接字。如果在处理 MRT_INIT
时 mroute_socket
不为空,则意味着另一个 mrouted 已经在运行,并且返回 -EADDRINUSE
。如果其余命令(MRT_DONE
、MRT_ADD_VIF
、MRT_DEL_VIF
、MRT_ADD_MFC
、MRT_DEL_MFC
和 MRT_ASSERT
)来自与 mroute_socket
不同的套接字,则返回 -EACCES
。
由于路由的组播数据报可以通过物理接口或隧道接收/发送,因此为两者设计了一个通用的抽象:VIF,虚拟接口。mrouted
将 vif 结构传递给内核,指示要添加到其路由表的物理或隧道接口,以及组播转发条目,说明将数据报转发到哪里。
VIF 使用 MRT_ADD_VIF
添加,使用 MRT_DEL_VIF
删除。两者都将 struct vifctl
传递给内核(在 /usr/include/linux/mroute.h
中定义),其中包含以下信息
struct vifctl { vifi_t vifc_vifi; /* Index of VIF */ unsigned char vifc_flags; /* VIFF_ flags */ unsigned char vifc_threshold; /* ttl limit */ unsigned int vifc_rate_limit; /* Rate limiter values (NI) */ struct in_addr vifc_lcl_addr; /* Our address */ struct in_addr vifc_rmt_addr; /* IPIP tunnel addr */ };
使用此信息构建 vif_device
结构
struct vif_device { struct device *dev; /* Device we are using */ struct route *rt_cache; /* Tunnel route cache */ unsigned long bytes_in,bytes_out; unsigned long pkt_in,pkt_out; /* Statistics */ unsigned long rate_limit; /* Traffic shaping (NI) */ unsigned char threshold; /* TTL threshold */ unsigned short flags; /* Control flags */ unsigned long local,remote; /* Addresses(remote for tunnels)*/ };
请注意结构中的 dev
条目。device
结构在 /usr/include/linux/netdevice.h
文件中定义。它是一个很大的结构,但我们感兴趣的字段是
struct ip_mc_list* ip_mc_list; /* IP multicast filter chain */
ip_mc_list
结构 - 在 /usr/include/linux/igmp.h
中定义 - 如下所示
struct ip_mc_list { struct device *interface; unsigned long multiaddr; struct ip_mc_list *next; struct timer_list timer; short tm_running; short reporter; int users; };
因此,dev
结构中的 ip_mc_list
成员是指向 ip_mc_list
结构链表的指针,每个结构都包含网络接口所属的每个组播组的条目。在这里,我们再次看到成员关系与接口相关联。LINUX/net/ipv4/ip_input.c
遍历此链表,以决定接收到的数据报是否是发送到接收到数据报的接口所属的任何组的
#ifdef CONFIG_IP_MULTICAST if(!(dev->flags&IFF_ALLMULTI) && brd==IS_MULTICAST && iph->daddr!=IGMP_ALL_HOSTS && !(dev->flags&IFF_LOOPBACK)) { /* * Check it is for one of our groups */ struct ip_mc_list *ip_mc=dev->ip_mc_list; do { if(ip_mc==NULL) { kfree_skb(skb, FREE_WRITE); return 0; } if(ip_mc->multiaddr==iph->daddr) break; ip_mc=ip_mc->next; } while(1); } #endif
ip_mc_list
结构中的 users
字段用于实现 IGMP 版本 1 节中所述的内容:如果进程加入一个组,并且接口已经是该组的成员(即,另一个进程之前在同一接口中加入了同一组),则只会递增成员计数 (users
)。不会发送任何 IGMP 消息,正如您在以下代码中看到的(取自 ip_mc_inc_group()
,由 ip_mc_join_group()
调用,都在 LINUX/net/ipv4/igmp.c
中)
for(i=dev->ip_mc_list;i!=NULL;i=i->next) { if(i->multiaddr==addr) { i->users++; return; } }
在删除成员关系时,计数器会递减,并且仅当计数达到 0 时才执行其他操作 (ip_mc_dec_group()
)。
MRT_ADD_MFC
和 MRT_DEL_MFC
在组播路由表中设置或删除转发条目。两者都将 struct mfcctl
传递给内核(也在 /usr/include/linux/mroute.h
中定义),其中包含以下信息
struct mfcctl { struct in_addr mfcc_origin; /* Origin of mcast */ struct in_addr mfcc_mcastgrp; /* Group in question */ vifi_t mfcc_parent; /* Where it arrived */ unsigned char mfcc_ttls[MAXVIFS]; /* Where it is going */ };
掌握了所有这些信息后,ipmr_forward()
会“遍历” VIF,如果找到匹配项,它会复制数据报并调用 ipmr_queue_xmit()
,后者又使用路由表指定的输出设备和正确的目的地地址(如果数据包要通过隧道发送,即隧道的另一端的单播目的地地址)。
函数 ip_rt_event()
(与输出没有直接关系,但也位于 ip_output.c 中)接收与网络设备相关的事件,例如设备启动。此函数确保设备随后加入 ALL-HOSTS 组播组。
IGMP 函数在 LINUX/net/ipv4/igmp.c
中实现。这些函数的重要信息出现在 /usr/include/linux/igmp.h
和 /usr/include/linux/mroute.h
中。/proc/net
目录中的 IGMP 条目是使用 LINUX/net/ipv4/ip_output.c
中的 ip_init()
创建的。