本文试图成为学习TCP/IP网络组播技术的入门材料。文中介绍了组播通信的概念及原理,以及用于组播应
用编程的 Linux API的详细资料。为了使读者更加完整的了解Linux 组播的整体概念,文中对实现该技术的核
心函数也做了介绍。在文章的最后给出了一个简单的C语言套接字编程例子,说明如何创建组播应用程序。
一、导言
在网络中,主机间可以用三种不同的地址进行通信:
单播地址(unicast):即在子网中主机的唯一地址(接口)。如IP地址:192.168.100.9或MAC地址:
80:C0:F6:A0:4A:B1。
广播地址:这种类型的地址用来向子网内的所有主机(接口)发送数据。如广播IP地址是192.168.100.255
,MAC广播地址:FF:FF:FF:FF:FF。
组播地址:通过该地址向子网内的多个主机即主机群(接口)发送数据。
如果只是向子网内的部分主机发送报文,组播地址就很有用处了;在需要向多个主机发送多媒体信息
(如实时音频、视频)的情况下,考虑到其所需的带宽,分别向每一客户端主机发送数据并不是个好办法,如
果发送主机与某些接收端的客户主机不在子网之内,采用广播方式也不是一个好的解决方案。
二、组播地址
大家知道,IP地址空间被划分为A、B、C三类。第四类即D类地址被保留用做组播地址。在第四版的IP
协议(IPv4)中,从224.0.0.0到239.255.255.255间的所有IP地址都属于D类地址。
组播地址中最重要的是第24位到27位间的这四位,对应到十进制是224到239,其它28位保留用做组
播的组标识,如下图所示:
图1 组播地址示意图
IPv4的组播地址在网络层要转换成网络物理地址。对一个单播的网络地址,通过ARP协议可以获取
与IP地址对应的物理地址。但在组播方式下ARP协议无法完成类似功能,必须得用其它的方法获取物理地址。
在下面列出的RFC文档中提出了完成这个转换过程的方法:
RFC1112:Multicast IPv4 to Ethernet physical address correspondence
RFC1390:Correspondence to FDDI
RFC1469:Correspondence to Token-Ring networks
在最大的以太网地址范围内,转换过程是这样的:将以太网地址的前24位最固定为01:00:5E,这几位
是重要的标志位。紧接着的一位固定为0,其它23位用IPv4组播地址中的低23位来填充。该转换过程如下图所示:
图2 地址转换示意图
例如,组播地址为224.0.0.5其以太网物理地址为01:00:5E:00:00:05。
还有一些特殊的IPv4组播地址:
224.0.0.1:标识子网中的所有主机。同一个子网中具有组播功能的主机都是这个组的成员。
224.0.0.2:该地址用来标识网络中每个具有组播功有的路由器。
224.0.0.0----224.0.0.255范围内的地址被分配给了低层次的协议。向这些范围内的地址发送数据包,
有组播功能的路由器将不会为其提供路由。
239.0.0.0----239.255.255.255间的地址分配用做管理用途。这些地址被分配给局部的每一个组织,
但不可以分配到组织外部,组织内的路由器不向在组织外的地址提供路由。
除了上面列出的部分组播地址外,还有许多的组播地址。在最新版本的RFC文档“Assinged Numbers”
中有完整的介绍。
下面的表中列出了全部的组播地址空间,同时还列出了相应的地址段的常用名称及其TTL(IP包的
活时间)。在IPv4组播方式下,TTL有双重意义:正如大家所知的,TTL原本用来控制数据包在网络中的存活时
间,防止由于路由器配置错误导致出现数据包传播的死循环;在组播方式下,它还代表了数据包的活动范
围,如:数据包在网络中能够传送多远?这样就可以基于数据包的分类来定义其传送范围。
范围 TTL 地址区间 描述
节点(Node) 0 只能向本机发送的数据包,不能向网络中的其它接口传送
链路(Link) 1 224.0.0.0-224.0.0.255 只能在发送主机所在的一个子网内的传送,不会通过路由器转发。
部门 32 239.255.0.0-239.255.255.255 只在整个组织下的一个部门内(Department) 传送
组织 64 239.192.0.0--239.195.255.255 在整个组织内传送(Organization)
全局(Global)255 224.0.1.0--238.255.255.255 没有限制,可全局范围内传送
三、组播的工作过程
在局域网内,主机的网络接口将到目的主机的数据包发送到高层,这些数据包中的目的地址是物理接口地
址或广播地址。
如果主机已经加入到一个组播组中,主机的网络接口就会识别出发送到该组成员的数据包。
因此,如果主机接口的物理地址为80:C0:F6:A0:4A:B1,其加入的组播组为224.0.1.10,则发送给主机的数
据包中的目的地址必是下面三种类型之一:
接口地址:80:C0:F6:A0:4A:B1
广播地址:FF:FF:FF:FF:FF:FF:FF:FF
组播地址:01:00:5E:00:01:0A
广域网中,路由器必须支持组播路由。当主机中运行的进程加入到某个组播组中时,主机向子网中的所
有组播路由器发送IGMP(Internet分组管理协议)报文,告诉路由器凡是发送到这个组播组的组播报文都必须发
送到本地的子网中,这样主机的进程就可以接收到报文了。子网中的路由器再通知其它的路由器,这些路由器就
知道该将组播报文转发到哪些子网中去。
子网中的路由器也向224.0.0.1发送一个IGMP报文(224.0.0.1代表组中的全部主机),要求组中的主
机提供组的相关信息。组中的主机收到这个报文后,都各将计数器的值设为随机值,当计数器递减为0 时再向路
由器发送应答。这样就防止了组中所有的主机同时向路由器发送应答,造成网络拥塞。主机向组播地址发送一
个报文做为对路由器的应答,组中的其它主机一旦看到这个应答报文,就不再发送应答报文了,因为组中的主
机向路由器提供的都是相同的信息,所以子网路由器只需得到组中一个主机提供的信息就可以了。
如果组中的主机都退出了,路由器就收不到应答,因此路由器认为该组目前没有主机加入,遂停止到
该子网报文的路由。IGMPv2的解决方案是:组中的主机在退出时向224.0.0.2 发送报文通知组播路由器。
四、应用编程接口(API)
如果你有套接字编程的经验,就会发现,对组播选项所进行的操作只需五个新的套接字操作。函数
setsockopt()及getsockopt()用来建立和读取这五个选项的值。下表中列出了组播的可选项,并列出其数据类型和描述:
IPv4 选项 数据类型 描 述
IP_ADD_MEMBERSHIP struct ip_mreq 加入到组播组中
IP_ROP_MEMBERSHIP struct ip_mreq 从组播组中退出
IP_MULTICAST_IF struct ip_mreq 指定提交组播报文的接口
IP_MULTICAST_TTL u_char 指定提交组播报文的TTL
IP_MULTICAST_LOOP u_char 使组播报文环路有效或无效
在头文件中定义了ip_mreq结构:
struct ip_mreq {
struct in_addr imr_multiaddr; /* IP multicast address of group */
struct in_addr imr_interface; /* local IP address of interface */
};
在头文件中组播选项的值为:
#define IP_MULTICAST_IF 32
#define IP_MULTICAST_TTL 33
#define IP_MULTICAST_LOOP 34
#define IP_ADD_MEMBERSHIP 35
#define IP_DROP_MEMBERSHIP 36
IP_ADD_MEMBERSHIP
若进程要加入到一个组播组中,用soket的setsockopt()函数发送该选项。该选项类型是ip_mreq
结构,它的第一个字段imr_multiaddr指定了组播组的地址,第二个字段imr_interface指定了接口的IPv4地址。
IP_DROP_MEMBERSHIP
该选项用来从某个组播组中退出。数据结构ip_mreq的使用方法与上面相同。
IP_MULTICAST_IF
该选项可以修改网络接口,在结构ip_mreq中定义新的接口。
IP_MULTICAST_TTL
设置组播报文的数据包的TTL(生存时间)。默认值是1,表示数据包只能在本地的子网中传送。
IP_MULTICAST_LOOP
组播组中的成员自己也会收到它向本组发送的报文。这个选项用于选择是否激活这种状态。
五、一个组播通信的例子
下面给出一个简单的例子实现文中阐述的思想:由一个进程向一个组播组发送报文,组播组中
的相关进程接收报文,并将报文显示到屏幕上。
下面的代码实现了一个服务进程,它将标准输入接口输入的信息全部发送到组播组224.0.1.1。
你会发现,将信息发送到组播组不需要特别的操作,只要设置好组播组的目的地址就足够了。若在开发过
程中,Loopback和TTL这两个选项的默认值不适合应用程序,可以加以调整。
服务程序
将标准输入端口的输入发送到组播组224.0.1.1。
#include
#include
#include
#include
#include
#include
#define MAXBUF 256
#define PUERTO 5000
#define GRUPO "224.0.1.1"
int main(void) {
int s;
struct sockaddr_in srv;
char buf;
bzero(&srv, sizeof(srv));
srv.sin_family = AF_INET;
srv.sin_port = htons(PUERTO);
if (inet_aton(GRUPO, &srv.sin_addr) < 0) {
perror("inet_aton");
return 1;
}
if ((s = socket(AF_INET, SOCK_DGRAM, 0)) < 0) {
perror("socket");
return 1;
}
while (fgets(buf, MAXBUF, stdin)) {
if (sendto(s, buf, strlen(buf), 0,
(struct sockaddr *)&srv, sizeof(srv)) < 0) {
perror("recvfrom");
} else {
fprintf(stdout, "Enviado a %s: %s
", GRUPO, buf);
}
}
}
客户端程序
下面的代码是客户端程序,它负责接收由服务程序发送到组播组中的信息,将收到的报文在标准
输出设备中显示。程序中唯一与接收UDP报文过程不同是它设置了IP_ADD_MEMBERSHIP选项。
#include
#include
#include
#include
#include
#define MAXBUF 256
#define PUERTO 5000
#define GRUPO "224.0.1.1"
int main(void) {
int s, n, r;
struct sockaddr_in srv, cli;
struct ip_mreq mreq;
char buf;
bzero(&srv, sizeof(srv));
srv.sin_family = AF_INET;
srv.sin_port = htons(PUERTO);
if (inet_aton(GRUPO, &srv.sin_addr) < 0) {
perror("inet_aton");
return 1;
}
if ((s = socket(AF_INET, SOCK_DGRAM, 0)) < 0) {
perror("socket");
return 1;
}
if (bind(s, (struct sockaddr *)&srv, sizeof(srv)) < 0) {
perror("bind");
return 1;
}
if (inet_aton(GRUPO, &mreq.imr_multiaddr) < 0) {
perror("inet_aton");
return 1;
}
mreq.imr_interface.s_addr = htonl(INADDR_ANY);
if (setsockopt(s,IPPROTO_IP,IP_ADD_MEMBERSHIP,&mreq,sizeof(mreq))
< 0) {
perror("setsockopt");
return 1;
}
n = sizeof(cli);
while (1) {
if ((r = recvfrom(s, buf, MAXBUF, 0, (struct sockaddr *)
&cli, &n)) < 0) {
perror("recvfrom");
} else {
buf = 0;
fprintf(stdout, "Mensaje desde %s: %s
",
inet_ntoa(cli.sin_addr), buf);
}
}
}
六、内核与组播
在上面的例子中我们看到:如果一个进程要加入到组播组中,就要使用setsockopt()函数在IP
层设置IP_ADD_MEMBERSHIP。
在/usr/src/linux/net/ipv4/ip_sockglue.c文件中可以找见该函数的源代码。 其中设置IP_ADD_MEMBERSHIP
和IP_DROP_MEMBERSHIP的部分代码如下:
struct ip_mreqn mreq;
if (optlen < sizeof(struct ip_mreq))
return -EINVAL;
if (optlen >= sizeof(struct ip_mreqn)) {
if(copy_from_user(&mreq,optval,sizeof(mreq)))
return -EFAULT;
} else {
memset(&mreq, 0, sizeof(mreq));
if (copy_from_user(&mreq,optval,sizeof(struct ip_mreq)))
return -EFAULT;
}
if (optname == IP_ADD_MEMBERSHIP)
return ip_mc_join_group(sk,&mreq);
else
return ip_mc_leave_group(sk,&mreq);
程序一开始先检查输入参数ip_mreq结构的长度是否正确,并将其从用户区复制到内核区。在得到参数
的值后,接着调用ip_mc_join_group()加入到组
播组或调用ip_mc_leave_group()退出组播组。
在/usr/src/linux/net/ipv4/igmp.c中可以找到这些函数的代码。加入组播组的源程序代码如下:
int ip_mc_join_group(struct sock *sk , struct ip_mreqn *imr)
{
int err;
u32 addr = imr->imr_multiaddr.s_addr;
struct ip_mc_socklist, *iml, *i;
struct in_device *in_dev;
int count = 0;
在开始部分用MULTICAST宏检查组的地址,确认其在保留的组播组地址范围内。只要检查IP地
址的第一部分是不是224就可以确认地址是否有效:
if (!MULTICAST(addr))
return -EINVAL;
rtnl_shlock();
检查完组播地址后,接着就要设置网络接口了。如果不能通过接口索引获得网络接口(如在IPv6下)
,在这种情况下可以调用函数 ip_mc_find_dev()获取网络接口。在本文中不存在这个问题,因为我们的工作
都是在IPv4下进行的。若地址的格式是INADDR_ANY,内核就依照路由表的定义,按照组地址在路由表中
查找网络接口。
if (!imr->imr_ifindex)
in_dev = ip_mc_find_dev(imr);
else
in_dev = inetdev_by_index(imr->imr_ifindex);
if (!in_dev) {
iml = NULL;
err = -ENODEV;
goto done;
}
接着给ip_mc_socklist结构分配内存,然后比较套接字的每个组地址和接口。只要发现了一个匹配项就
跳出该函数,因为有一个匹配项就可以了。若网络接口地址不是INADDR_ANY,相应的计数器值就要增加。
iml = (struct ip_mc_socklist *)sock_kmalloc(sk, sizeof(*iml),
GFP_KERNEL);
err = -EADDRINUSE;
for (i=sk->ip_mc_list; i; i=i->next) {
if (memcmp(&i->multi, imr, sizeof(*imr)) == 0) {
/* New style additions are reference counted */
if (imr->imr_address.s_addr == 0) {
i->count ;
err = 0;
}
goto done;
}
count ;
}
err = -ENOBUFS;
if (iml == NULL' 'count >= sysctl_igmp_max_memberships)
goto done;
到这里,就可以用新创建的套接字与组播组建立链接了,这时还必须创建一个新的记录,记录下属于
该套接字的组的列表。首先还是要预先分配内存,然后只要给相关结构中的几个字段赋值,就完成了这个操作:
memcpy(&iml->multi,imr, sizeof(*imr));
iml->next = sk->ip_mc_list;
iml->count = 1;
sk->ip_mc_list = iml;
ip_mc_inc_group(in_dev,addr);
iml = NULL;
err = 0;
done:
rtnl_shunlock();
if (iml)
sock_kfree_s(sk, iml, sizeof(*iml));
return err;
}
用函数ip_mc_leave_group()从一个组播组中退出,它的工作过程比前面的函数要来得简单。首先在
套接字记录中查找组播组及接口地址,找到后,将调用这个接口地址的进程数的值递减,若该值为0,就删除
该计数器,因为与组播组相关的进程至少要有一个。
int ip_mc_leave_group(struct sock *sk, struct ip_mreqn *imr)
{
struct ip_mc_socklist *iml, **imlp;
for (imlp=&sk->ip_mc_list;(iml=*imlp)!=NULL; imlp=&iml->next) {
if (iml->multi.imr_multiaddr.s_addr==imr->imr_multiaddr.s_addr
&& iml->multi.imr_address.s_addr==imr->imr_address.s_addr &&
(!imr->imr_ifindex' 'iml->multi.imr_ifindex==imr->imr_ifindex)) {
struct in_device *in_dev;
if (--iml->count)
return 0;
*imlp = iml->next;
synchronize_bh();
in_dev = inetdev_by_index(iml->multi.imr_ifindex);
if (in_dev)
ip_mc_dec_group(in_dev, imr->imr_multiaddr.s_addr);
sock_kfree_s(sk, iml, sizeof(*iml));
return 0;
}
}
return -EADDRNOTAVAIL;
}
其它的组播选项都很简单,只要给当前套接字内的字段赋值就可以了,赋值的过程由ip_setsockopt()函数完成。