本文试图成为学习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()函数完成。
#include
#include
#include
#include
#include
#include
#define BUFLEN 255
/*********************************************************************
*filename: mcastclient.c
*purpose: 演示组播编程的基本步骤,其实这就是一个基本的UDP客户端程序
*tidied by: zhoulifa([email protected]) 周立发(http://zhoulifa.bokee.com)
Linux爱好者 Linux知识传播者 SOHO族 开发者 最擅长C语言
*date time:2007-01-25 13:10:00
*Note: 任何人可以任意复制代码并运用这些文档,当然包括你的商业用途
* 但请遵循GPL
*Thanks to: Google.com
*Hope:希望越来越多的人贡献自己的力量,为科学技术发展出力
* 科技站在巨人的肩膀上进步更快!感谢有开源前辈的贡献!
*********************************************************************/
int main(int argc, char **argv)
{
struct sockaddr_in peeraddr, myaddr;
int sockfd;
char recmsg[BUFLEN + 1];
unsigned int socklen;
/* 创建 socket 用于UDP通讯 */
sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0) {
printf("socket creating error/n");
exit(1);
}
socklen = sizeof(struct sockaddr_in);
/* 设置对方的端口和IP信息 */
memset(&peeraddr, 0, socklen);
peeraddr.sin_family = AF_INET;
if (argv[2])
peeraddr.sin_port = htons(atoi(argv[2]));
else
peeraddr.sin_port = htons(7838);
if (argv[1]) {
/* 注意这里设置的对方地址是指组播地址,而不是对方的实际IP地址 */
if (inet_pton(AF_INET, argv[1], &peeraddr.sin_addr) <= 0) {
printf("wrong group address!/n");
exit(0);
}
} else {
printf("no group address!/n");
exit(0);
}
/* 设置自己的端口和IP信息 */
memset(&myaddr, 0, socklen);
myaddr.sin_family = AF_INET;
if (argv[4])
myaddr.sin_port = htons(atoi(argv[4]));
else
myaddr.sin_port = htons(23456);
if (argv[3]) {
if (inet_pton(AF_INET, argv[3], &myaddr.sin_addr) <= 0) {
printf("self ip address error!/n");
exit(0);
}
} else
myaddr.sin_addr.s_addr = INADDR_ANY;
/* 绑定自己的端口和IP信息到socket上 */
if (bind
(sockfd, (struct sockaddr *) &myaddr,
sizeof(struct sockaddr_in)) == -1) {
printf("Bind error/n");
exit(0);
}
/* 循环接受用户输入的消息发送组播消息 */
for (;;) {
/* 接受用户输入 */
bzero(recmsg, BUFLEN + 1);
if (fgets(recmsg, BUFLEN, stdin) == (char *) EOF)
exit(0);
/* 发送消息 */
if (sendto
(sockfd, recmsg, strlen(recmsg), 0,
(struct sockaddr *) &peeraddr,
sizeof(struct sockaddr_in)) < 0) {
printf("sendto error!/n");
exit(3);
}
printf("'%s' send ok/n", recmsg);
}
}
组播服务器端程序源代码为:
#include
#include
#include
#include
#include
#include
#include
#include
#define BUFLEN 255
/*********************************************************************
*filename: mcastserver.c
*purpose: 演示组播编程的基本步骤,组播服务器端,关键在于加入组
*tidied by: zhoulifa([email protected]) 周立发(http://zhoulifa.bokee.com)
Linux爱好者 Linux知识传播者 SOHO族 开发者 最擅长C语言
*date time:2007-01-25 13:20:00
*Note: 任何人可以任意复制代码并运用这些文档,当然包括你的商业用途
* 但请遵循GPL
*Thanks to: Google.com
*Hope:希望越来越多的人贡献自己的力量,为科学技术发展出力
* 科技站在巨人的肩膀上进步更快!感谢有开源前辈的贡献!
*********************************************************************/
int main(int argc, char **argv)
{
struct sockaddr_in peeraddr;
struct in_addr ia;
int sockfd;
char recmsg[BUFLEN + 1];
unsigne int socklen, n;
struct hostent *group;
struct ip_mreq mreq;
/* 创建 socket 用于UDP通讯 */
sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0) {
printf("socket creating err in udptalk/n");
exit(1);
}
/* 设置要加入组播的地址 */
bzero(&mreq, sizeof(struct ip_mreq));
if (argv[1]) {
if ((group = gethostbyname(argv[1])) == (struct hostent *) 0) {
perror("gethostbyname");
exit(errno);
}
} else {
printf
("you should give me a group address, 224.0.0.0-239.255.255.255/n");
exit(errno);
}
bcopy((void *) group->h_addr, (void *) &ia, group->h_length);
/* 设置组地址 */
bcopy(&ia, &mreq.imr_multiaddr.s_addr, sizeof(struct in_addr));
/* 设置发送组播消息的源主机的地址信息 */
mreq.imr_interface.s_addr = htonl(INADDR_ANY);
/* 把本机加入组播地址,即本机网卡作为组播成员,只有加入组才能收到组播消息 */
if (setsockopt
(sockfd, IPPROTO_IP, IP_ADD_MEMBERSHIP, &mreq,
sizeof(struct ip_mreq)) == -1) {
perror("setsockopt");
exit(-1);
}
socklen = sizeof(struct sockaddr_in);
memset(&peeraddr, 0, socklen);
peeraddr.sin_family = AF_INET;
if (argv[2])
peeraddr.sin_port = htons(atoi(argv[2]));
else
peeraddr.sin_port = htons(7838);
if (argv[1]) {
if (inet_pton(AF_INET, argv[1], &peeraddr.sin_addr) <= 0) {
printf("Wrong dest IP address!/n");
exit(0);
}
} else {
printf("no group address given, 224.0.0.0-239.255.255.255/n");
exit(errno);
}
/* 绑定自己的端口和IP信息到socket上 */
if (bind
(sockfd, (struct sockaddr *) &peeraddr,
sizeof(struct sockaddr_in)) == -1) {
printf("Bind error/n");
exit(0);
}
/* 循环接收网络上来的组播消息 */
for (;;) {
bzero(recmsg, BUFLEN + 1);
n = recvfrom(sockfd, recmsg, BUFLEN, 0,
(struct sockaddr *) &peeraddr, &socklen);
if (n < 0) {
printf("recvfrom err in udptalk!/n");
exit(4);
} else {
/* 成功接收到数据报 */
recmsg[n] = 0;
printf("peer:%s", recmsg);
}
}
}
编译程序用下列命令:
gcc -Wall mcastclient.c -o mcastclient
gcc -Wall mcastserver.c -o mcastserver
运行程序用如下命令:
./mcastserver 230.1.1.1 7838
客户端程序运行命令为:
./mcastclient 230.1.1.1 7838 192.168.100.1 12345