在linux下编程
网络中的一台主机如果希望能够接收到来自网络中其它主机发往某一个组播组的数据报,那么这么主机必须先加入该组播组,然后就可以从组地址接收数据包。在广域网中,还涉及到路由器支持组播路由等,但本文希望以一个最为简单的例子解释清楚协议栈关于组播的一个最为简单明了的工作过程,甚至,我们不希望涉及到IGMP包。
我们先从一个组播客户端的应用程序入手来解析组播的工作过程:
#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <string.h>
#include "my_inet.h"
#include <arpa/inet.h>
#define MAXBUF 256
#define PUERTO 5000
#define GRUPO "224.0.1.1"
int main(void)
{
int fd, n, r;
struct sockaddr_in srv, cli;
struct ip_mreq mreq;
char buf[MAXBUF];
memset( &srv, 0, sizeof(struct sockaddr_in) );
memset( &cli, 0, sizeof(struct sockaddr_in) );
memset( &mreq, 0, sizeof(struct ip_mreq) );
srv.sin_family = MY_AF_INET;
srv.sin_port = htons(PUERTO);
if( inet_aton(GRUPO, &srv.sin_addr ) < 0 ) {
perror("inet_aton");
return -1;
}
if( (fd = socket( MY_AF_INET, SOCK_DGRAM, MY_IPPROTO_UDP) ) < 0 ){
perror("socket");
return -1;
}
if( bind(fd, (struct sockaddr *)&srv, sizeof(srv)) < 0 ){
perror("bind");
return -1;
}
if (inet_aton(GRUPO, &mreq.imr_multiaddr) < 0) {
perror("inet_aton");
return -1;
}
inet_aton( "172.16.48.2", &(mreq.imr_interface) );
if( setsockopt(fd, SOL_IP, IP_ADD_MEMBERSHIP, &mreq,sizeof(mreq)) < 0 ){
perror("setsockopt");
return -1;
}
n = sizeof(cli);
while(1){
if( (r = recvfrom(fd, buf, MAXBUF, 0, (struct sockaddr *)&cli, (socklen_t*)&n)) < 0 ){
perror("recvfrom");
}else{
buf[r] = 0;
fprintf(stdout, "Mensaje desde %s: %s", inet_ntoa(cli.sin_addr), buf);
}
}
}
这是一个非常简单的组播客户端,它指定从组播组224.0.1.1的5000端口读数据,并显示在终端上,下面我们通过分析该程序来了解内核的工作过程。
前面我们讲过,bind操作首先检查用户指定的端口是否可用,然后为socket的一些成员设置正确的值,并添加到哈希表myudp_hash中。然后,协议栈每次收到UDP数据,就会检查该数据报的源和目的地址,还有源和目的端口,在myudp_hash中找到匹配的socket,把该数据报放入该socket的接收队列,以备用户读取。在这个程序中,bind操作把socket绑定到地址224.0.0.1:5000上, 该操作产生的直接结果就是,对于socket本身,下列值受影响:
struct inet_sock{
.rcv_saddr = 224.0.0.1;
.saddr = 0.0.0.0;
.sport = 5000;
.daddr = 0.0.0.0;
.dport = 0;
}
这五个数据表示,该套接字在发送数据包时,本地使用端口5000,本地可以使用任意一个网络设备接口,发往的目的地址不指定。在接收数据时,只接收发往IP地址224.0.0.1的端口为5000的数据。
程序中,紧接着bind有一个setsockopt操作,它的作用是将socket加入一个组播组,因为socket要接收组播地址224.0.0.1的数据,它就必须加入该组播组。结构体struct ip_mreq mreq是该操作的参数,下面是其定义:
struct ip_mreq
{
struct in_addr imr_multiaddr; // 组播组的IP地址。
struct in_addr imr_interface; // 本地某一网络设备接口的IP地址。
};
一台主机上可能有多块网卡,接入多个不同的子网,imr_interface参数就是指定一个特定的设备接口,告诉协议栈只想在这个设备所在的子网中加入某个组播组。有了这两个参数,协议栈就能知道:在哪个网络设备接口上加入哪个组播组。为了简单起见,我们的程序中直接写明了IP地址:在172.16.48.2所在的设备接口上加入组播组224.0.1.1。
这个操作是在网络层上的一个选项,所以级别是SOL_IP,IP_ADD_MEMBERSHIP选项把用户传入的参数拷贝成了struct ip_mreqn结构体:
struct ip_mreqn
{
struct in_addr imr_multiaddr;
struct in_addr imr_address;
int imr_ifindex;
};
多了一个输入接口的索引,暂时被拷贝成零。
该操作最终引发内核函数myip_mc_join_group执行加入组播组的操作。首先检查imr_multiaddr是否为合法的组播地址,然后根据imr_interface的值找到对应的struct in_device结构。接下来就要为socket加入到组播组了,在inet_sock的结构体中有一个成员mc_list,它是一个结构体struct ip_mc_socklist的链表,每一个节点代表socket当前正加入的一个组播组,该链表是有上限限制的,缺省值为IP_MAX_MEMBERSHIPS(20),也就是说一个socket最多允许同时加入20个组播组。下面是struct ip_mc_socklist的定义:
struct ip_mc_socklist
{
struct ip_mc_socklist *next;
struct ip_mreqn multi;
unsigned int sfmode; /* MCAST_{INCLUDE,EXCLUDE} */
struct ip_sf_socklist *sflist;
};
struct ip_sf_socklist
{
unsigned int sl_max;
unsigned int sl_count;
__u32 sl_addr[0];
};
除了multi成员,它还有一个源过滤机制。如果我们新添加的struct ip_mreqn已经存在于这个链表中(表示socket早就加入这个组播组了),那么不做任何事情,否则,创建一个新的struct ip_mc_socklist:
struct ip_mc_socklist
{
.next = inet->mc_list; //新节点放到链表头。
.multi = 传入的参数; //这是关键的组信息。
.sfmode = MCAST_EXCLUDE; //过滤掉sflist中的所有源。
.sflist = NULL; //没有源需要过滤。
};
最后,调用myip_mc_inc_group函数在struct in_device和struct net_device的mc_list链表中都添上相应的组播组节点,关于这部分的细节可以在前一篇文章《初识组播2》中找到。不再重复。
到此为止,我们完成了最为简单的加入组播组的操作,对于同一子网内的情况,socket已经可以接收组播数据了,关于组播数据如何接收,下回分解。
前面我们讲到如何加入到一个组播组中,当一个客户端完成了加入一个组播组的操作后,就可以从该组接收数据了。下面我们看看组播数据报接收的详细流程。
通过加入组播组的操作后,网络设备接口已经知道要接收该组的数据报,所以组播数据会从网卡接收进来,一直到达myip_rcv函数,我们就从myip_rcv函数开始,跟踪整个组播数据报的接收流程。
同样,myip_rcv还是先检查数据报的类型(是否为本机需要接收的包),ip首部是否正确,然后调用myip_rcv_finish。myip_rcv_finish对任何数据报都要先查找输入路由,输入路由查找函数是myip_route_input,当该函数在路由缓存myrt_hash_table中找不到相应的路由项时,判断数据报的输入地址,如果发现是组播地址,就不能简单地查找FIB,而是要作特殊处理。
首先,调用myip_check_mc对这个组播数据报作检查,从网络设备接口的struct in_device中去匹配组播地址,如果匹配不到,表示这个不是我们希望接收的组播包,丢弃。匹配到了,则作下一步检查,如果这本身就是一个IGMP包,则接收,否则,查看这个组播组在我们的struct in_device中设置的过滤机制,如果该数据报的源地址在我们的过滤名单中,则丢弃,否则接收。
如果检查通过,准备接收这个组播包,则调用myip_route_input_mc查找组播输入路由,这是一个专门为组播设置的函数,它第一步要检查数据报源地址的有效性,即源地址不能是组播地址,不能是广播地址,也不能是回环地址,同时,该数据报必须是一个因特网协议包(ETH_P_IP)。如果源地址为0,那么只有当目的地址是224.0.0.0-224.0.0.255之间的值(只能在发送主机所在的一个子网内的传送,不会通过路由器转发。)时,系统可以自己选定一个scope为RT_SCOPE_LINK的源地址,否则出错。
当验证了源地址的有效性之后,我们建立路由项,即结构体struct rtable。该路由项的rt_type值是RTN_MULTICAST,表示这一条组播路由。对于本地接收的组播包,我们设置接收函数为myip_local_deliver。
有了这个路由项,我们可以通过调用myip_local_deliver,继续接收流程,这部分流程前面已有多次介绍,所以讲得简单一点,只注意组播特有的。同样,到myip_local_deliver_finish后,首先要检查是否有raw socket要接收这个组播包。然后根据IP首部里协议字段,调用相应协议的接收函数,我们这儿是一个UDP组播包,所以调用myudp_rcv。
myudp_rcv首先会对路由项的成员rt_flags作一个检查,如果发现它有RTCF_BROADCAST或者RTCF_MULTICAST,就不会走常规的从myudp_hash中匹配源和目的地址,找到socket,把数据报放入接收队列这么一个流程。而是调用函数myudp_v4_mcast_deliver,这是一个专用于接收UDP组播数据报的函数,它首先根据目的端口确定在哈希表mydup_hash中的位置,然后遍历找到的这个链表。与普通的UDP数据报接收相比,它多一个过滤检查,即在套接字结构体的成员mc_list中找到与该数据报所属组对应的ip_mc_socklist项,查看它的过滤配置,确认该数据报的源地址是否在过滤列表中。如果不在,则把数据放到该socket的接收队列中,完成组播数据报的接收。