windows网络编程

 
打开
TCP首部(1)
    TCP数据被封装在一个IP数据报中,TCP首部在数据报中的位置跟UDP首部一样,紧跟在IP首部后面。为简单起见,我们先看不计任何选项的TCP首部,它总共20字节,下面是其定义:
    struct tcphdr {
        __u16   source;
        __u16   dest;
        __u32   seq;
        __u32   ack_seq;
#if defined(__LITTLE_ENDIAN_BITFIELD)
        __u16   res1:4,
                doff:4,
                fin:1,
                syn:1,
                rst:1,
                psh:1,
                ack:1,
                urg:1,
                ece:1,
                cwr:1;
#elif defined(__BIG_ENDIAN_BITFIELD)
        __u16   doff:4,
                res1:4,
                cwr:1,
                ece:1,
                urg:1,
                ack:1,
                psh:1,
                rst:1,
                syn:1,
                fin:1;
#else #error  "Adjust your <asm/byteorder.h> defines"
#endif
        __u16   window;
        __u16   check;
        __u16   urg_ptr;
    };
    下面我们依次介绍每个字段的含义。
    source和dst分别表示源和目的端口号,每个TCP段都包含源端口号和目的端口号,用于寻找发送端和接收端的应用进程,这两个值加上IP首部中的源端IP地址和目的端IP地址就可以唯一确定一个TCP连接。
    seq是发送端序列号,用于标识从TCP发送端向接收端发送的数据字节流,也就是说,每发送一个字节,该字段值加1,为了安全起见,它的初始值是一个随机生成的数,它到达32位最大值后,又从零开始。ack_seq是确认序列号,发送给对端,用于确认希望对端发送的下一个数据报的起始序号,显然,一个 TCP客户端发起连接请求时,ack_seq的值为0。
    doff占4位,表示首部长度,它的单位并非字节,而是32bit,所以,一个不含任何选项的TCP首部,其首部长度是5(5*4=20字节),含有选项时,还要加上选项的长度。res1为保留位,cwr, ece用途暂时不明。
    window是16位滑动窗口的大小,单位为字节,起始于确认序列号字段指明的值,这个值是接收端正期望接收的字节数,其最大值是63353字节。
    check是检验和,覆盖了整个的TCP报文段,这是一个强制性的字段,一定是由发送端计算和存储,并由接收端进行验证。
    urg_ptr是一个紧急指针,是一个正的偏移量,和序列号字段中的值相加表示紧急数据最后一个字节的序列号。
    urg, ack, psh, rst, syn, fin为6个标志位,它们的含义如下:
    urg: 紧急指针有效。
    ack: 确认序号有效。
    psh: 接收方应该尽快将这个报文段交给应用层。
    rst: 重建连接。
    syn: 同步序号,用来发起一个连接。
    fin: 发送端完成发送任务。
    内核源代码在函数mytcp_transmit_skb中建立tcp首部。
2006/7/31
TCP socket上的connect(2)
    mytcp_v4_connect是IPv4相关的一个tcp协议连接函数,在myinet_stream_connect中,检查到struct socket->state状态为SS_UNCONNECTED后,调用该函数。该函数首先调用myip_route_connect确定到连接对端的路由,根据路由查询的结果,填充inet_sock->saddr, inet_sock->rcv_saddr, inet_sock->daddr的值。
    接下来是一些tcp socket相关的参数的设置,我们暂时先略过。在mytcp_v4_connect中把struct sock->sk_state的状态改为TCP_SYN_SENT,这是建立TCP连接的三次握手协议的第一步的状态。
    接下来调用函数myinet_hash_connect,为tcp socket绑定一个本地端口。mytcp_hashinfo是一个全局变量,它管理很多tcp协议中需要用到的哈希表,其类型是一个结构体struct inet_hashinfo,定义如下:
    struct inet_hashinfo {
        struct inet_ehash_bucket    *ehash;
        struct inet_bind_hashbucket *bhash;

        int             bhash_size;
        unsigned int    ehash_size;

        struct hlist_head       listening_hash[INET_LHTABLE_SIZE];

        rwlock_t            lhash_lock ____cacheline_aligned;
        atomic_t            lhash_users;
        wait_queue_head_t   lhash_wait;
        kmem_cache_t        *bind_bucket_cachep;
    };
    bind_bucket_cachep是一块后备高速缓存,其单位内存大小为一个结构体struct inet_bind_bucket的大小,插入哈希表bhash中的结构体struct inet_bind_bucket都是从这块后备高速缓存中进行分配。该结构体是一个关于本地端口信息的结构体,其完整定义如下:
    struct inet_bind_bucket {
        unsigned short      port;       //端口
        signed short        fastreuse;  //重用。
        struct hlist_node   node;       //作为bhash链表的节点。
        struct hlist_head   owners;     //绑定在该端口上的socket的链表。
    };
    bhash_size是关于哈希表bhash的大小。
    myinet_hash_connect首先在范围32768-61000之间为该tcp socket选择一个可以使用的本地端口,然后为该端口建立一个结构体struct inet_bind_bucket,插入到mytcp_hashinfo.bhash中。
    接下来,inet_sock->snum = port。套接口struct sock有一个成员sk_bind_node,用于把struct sock放入到一个链表中,在这里,我们把该tcp socket放入到struct inet_bind_bucket->owners的链表中,表示该socket绑定在了该端口上。同时,struct inet_connection_sock是结构体struct inet_sock的一个扩展,它有一个成员icsk_bind_hash,用于指向刚刚创建的struct inet_bind_bucket。这三步操作把一个tcp本地套接口完全绑定到了一个端口上。
    接下来,我们还要把struct sock本身放到一个哈希队列中,这跟udp, raw的哈希表功能相似,是为了接收数据时,能够找到这个socket。根据tcp socket本身的工作状态,如果它处于TCP_LISTEN,则把这个socket放入到哈希队列listening_hash中,否则,放入到哈希队列ehash中,ehash实际上是被一分为二的,前半部分放非TIME_WAIT状态的socket,后半部分放TIME_WAIT状态的 socket。struct sock的成员sk_node用于把socket放入到队列中。
    struct inet_hashinfo的成员lhash_wait中存放的应该是等待连接接收的来自网络中其它主机的连接请求,如果当前有一个tcp socket进入listening_hash哈希队列,则唤醒该等待队列。
    如果在myinet_hash_connect函数中,我们发现要绑定的本地端口已经在bhash哈希队列中存在,我们可以选择跳过,取下一个端口继续尝试,也可以重用该端口,这里有一些规则需要遵守。首先,如果结构体的成员fastreuse>=0,则不可重用,直接取下一个。否则,调用 __myinet_check_established进行验证,验证的基本原理是绑定在不同网络设备接口上的socket可以共用端口;如果每个 socket的sk_reuse被置位,并且都不处于TCP_LISTEN状态,则可以共用端口;如果所有的socket都被绑定到同一个 rcv_saddr本地地址,但它们都是不相同的,则可以共用端口。
2006/7/30
TCP socket上的connect(1)
    前面讲到过,UDP socket和RAW socket上的connect调用的原理是相当简单的,它所做的操作只是通过路由规则和路由表等一些信息,在struct socket结构中填入一些有关对端服务器的信息,这样,以后向对端发送数据报时,就不需要每次进行路由查询等操作以确定对端地址信息和本地发送接口,应用程序也就不需要每次传入对端地址信息(可以使用send而不使用sendto)。这也就是为什么UDP被称为无连接的协议。
    但TCP socket上的connect系统调用就相对比较复杂了,下面我们逐步探索tcp socket上的connect系统调用的流程,及涉及到的一些相关的数据结构。
    tcp socket的connect系统调用首先到达的函数是myinet_stream_connect,它首先判断套接字的当前状态,并根据当前的状态决定下一步要执行的动作。在代表套接口的结构体struct socket中有一个成员state,它表示当前套接口所处的连接状态,该成员只对tcp socket有意义,因为只有tcp是面向连接的协议。其可能的取值如下:
    typedef enum {
        SS_FREE = 0,            //未创建
        SS_UNCONNECTED,         //未连接到任何socket
        SS_CONNECTING,          //正在接接过程中
        SS_CONNECTED,           //已连接
        SS_DISCONNECTING        //正在断开连接之中
    } socket_state;
    在一个socket被创建的时候,其状态应该是SS_UNCONNECTED,myinet_stream_connect调用tcp协议的连接函数 mytcp_v4_connect完成socket的一些本地设置后,将这个状态改为SS_CONNECTING。然后调用 myinet_wait_for_connect建立到对端的连接,连接成功,则将状态改为SS_CONNECTED,connect成功。
    代表网络层套接口的结构体struct sock也有一个成员sk_state,它表示的是TCP套接口的工作状态,其可能的取值如下:
    enum {
        TCPF_ESTABLISHED = (1 << 1),
        TCPF_SYN_SENT    = (1 << 2),
        TCPF_SYN_RECV    = (1 << 3),
        TCPF_FIN_WAIT1   = (1 << 4),
        TCPF_FIN_WAIT2   = (1 << 5),
        TCPF_TIME_WAIT   = (1 << 6),
        TCPF_CLOSE   = (1 << 7),
        TCPF_CLOSE_WAIT  = (1 << 8),
        TCPF_LAST_ACK    = (1 << 9),
        TCPF_LISTEN  = (1 << 10),
        TCPF_CLOSING     = (1 << 11)
    };
    在tcp socket建立之初,其状态为TCPF_CLOSE,准备建立到对端的连接之前,被改为TCP_SYN_SENT。
2006/7/25
发送组播数据报(2)
    前面一篇文章中提到的两个示例程序,它们虽然对外发送了组播数据报,但它们实际上调用的是协议栈中的单播发送的代码。一般情况下,它们不会有什么问题,但是它们不是标准的组播程序,下面我们看看协议栈究竟是如何发送组播数据报的。
    我们还是以发送UDP的组播数据报为例。前面已经讲过,IP选项IP_MULTICAST_IF确定组播发送的接口,在通过系统调用设置该选项时,参数只需要一个本地网络接口的IP地址即可,myudp_sendmsg函数在发送组播数据报时,会以该选项设定的IP地址作为输出路由查询的源地址。
    对于一个输出组播数据报,协议栈也要做检查,检查该组播发送的接口是否也加入了同一个组播组(即检查net_device->in_device- >mc_list链表,查看是否存在跟输出组播数据报目的地址相同的组),如果检查结果确实加入了同一个组(本机可能有其它进程在同一网络设备口上,在该组中接收数据报),则把组播输出函数指定为myip_mc_output,该函数与普通的IP数据报输出函数相比,多了一个判断,如果启用了组播环路,则先向loopback接口发送一个组播数据报,确保本机需要接收该组中的数据的进程能收到数据。组播环路缺省是打开的,可以通过IP选项 IP_MULTICAST_LOOP进行设置。
    组播数据报的TTL的缺省值是1,这在很多情况下,显然是不适用的,我们必须能够修改它,IP选项IP_MULTICAST_TTL可用来修改这个TTL 值,该选项把它的参数值赋给套接字结构体的成员mc_ttl。协议栈在为待发送数据报构建IP首部时,发现该数据的目的地址是一个组播地址时,就会把 mc_ttl的值填入IP首部的ttl域。
    下面是一个组播客户端和一个组播服务端程序,让它们运行在同一台主机上,试着修改一些参数,你就能得到各种不同的行为。我在调试中发现一个问题,就是现有的Linux TCP/IP协议栈代码在往组播环路发数据报时,本机接收进程会收到两个数据包,也就是本示例中,服务端开启了环路以后,发一个数据报,客户端会收到两个,很有趣的问题,暂时没想通,欢迎大家探讨。
   服务端:
    #include <stdio.h>
    #include <sys/types.h>
    #include <sys/socket.h>
    #include <string.h>
    #include <arpa/inet.h>
    #include "my_inet.h"

    #define MAXBUF 256
    #define PUERTO 5000
    #define GROUP "224.0.1.1"
    int main(void)
    {
        int fd, mc_loop = 1;
        struct sockaddr_in srv,local;
        struct in_addr if_req;
        char buf[MAXBUF];

        srv.sin_family = MY_AF_INET;
        srv.sin_port = htons(PUERTO);
        inet_aton(GROUP, &srv.sin_addr);

        if( (fd = socket( MY_AF_INET, SOCK_DGRAM, MY_IPPROTO_UDP) ) < 0 ){
            perror("socket");
            return -1;
        }
        inet_aton("172.16.48.2", &(if_req) );
        if( setsockopt( fd, SOL_IP, IP_MULTICAST_IF, &if_req, sizeof(struct in_addr) ) < 0 ){
            perror("setsockopt:");
            return -1;
        }
        if( setsockopt( fd, SOL_IP, IP_MULTICAST_LOOP, &mc_loop, sizeof(int) ) < 0 ){
            perror("setsockopt:");
            return -1;
        }
        while( fgets(buf, MAXBUF, stdin) ){
            if( sendto(fd, buf, strlen(buf), 0, (struct sockaddr *)&srv, sizeof(srv)) < 0 ){
                perror("sendto");
            }else{
                fprintf(stdout, "Enviado a %s: %s", GROUP, buf);
            }
        }
    }
    客户端程序:
    #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 GROUP "224.0.1.1"

    int main(void)
    {
        int fd, n, r;
        struct sockaddr_in srv, cli;
        struct ip_mreq mreq;
        char buf[MAXBUF];

        srv.sin_family = MY_AF_INET;
        srv.sin_port = htons(PUERTO);
        inet_aton(GROUP, &srv.sin_addr );

        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;
        }
/*
   inet_aton( GROUP, &mreq.imr_multiaddr );
   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;
   }

   if( setsockopt(fd, SOL_IP, IP_DROP_MEMBERSHIP, &mreq, sizeof(mreq)) < 0 ){
   perror("setsockopt");
   return -1;
   }
*/
        n = sizeof(cli);
        while(1){
            if( (r = recv(fd, buf, MAXBUF, 0)) < 0 ){
                perror("recv:");
            }else{
                buf[r] = 0;
                fprintf(stdout, "Mensaje desde: %s", buf);
            }
        }
    }
2006/7/24
发送组播数据报(1)
    我们还是以发送UDP的组播数据为例。其实发送一个UDP的组播数据报跟发送一个单播UDP数据报的差别并不大。
    首先是在myudp_sendmsg函数中,如果发送接口的源地址没有确定,并且目的地址是组播地址的话,则源地址使用inet_sock-> mc_addr。而发送接口的源地址首先是通过inet_sock->saddr来确定的,如果发现inet_sock->saddr为零,才会采用inet_sock->mc_addr的值。
    通过前面的文章,我们可以了解到bind系统调用的作用就是为一个本地套接口指定发送源地址和接收地址(即把一个本地套接口绑定在一个本地网络设备接口上)。而组播选项IP_MULTICAST_IF用于指定组播数据报的发送接口,两者的功能似乎有些重复。bind影响的是inet_sock的成员 rcv_saddr, saddr, sport,分别表示接收地址(输入数据报首部中指定该地址为目的地址的,将被接收),发送源地址(本地某个网络设备接口的地址),发送和接收的端口。对于单播的情况,显然rcv_saddr==saddr,因为一般来讲,一个应用程序总是使用一个网络设备接口进行数据的收发的。但如果应用程序非要把一个组播地址和端口绑定到一个本地套接口上,则bind系统调用会让 rcv_addr=组播地址,sport=端口,而saddr等于0,但协议栈发送组播数据报必须要有一个本地网络设备接口,没有saddr,协议栈就不知道通过那个设备发送数据报,这个任务就留给了IP_MULTICAST_IF选项,它为inet_sock的成员mc_addr和mc_index赋值,指定本地接口用于发送组播数据报。
    由上可得,如果我们的应用希望通过本地一个网络设备接口向网络发送组播数据报,而不关心接收该组的数据报(可能来自其它主机,在启动环路的情况下,也可能是来自自己),我们可以简单地通过bind把这个发送套接字绑定到一个本地接口,然后再向组播地址发送数据报即可,但这样的话,感觉就像是自己站在组外(不属于这个组)向组内发送数据报,源代码如下:
    #include <stdio.h>
    #include <string.h>
    #include <sys/types.h>
    #include <sys/socket.h>
    #include "my_inet.h"
    #include <linux/in.h>

    #define MAXBUF 256
    #define PUERTO 5000
    #define GRUPO "224.0.1.1"
    int main(void)
    {
        int fd;
        struct sockaddr_in srv,local;
        char buf[MAXBUF];
        memset( &srv, 0, sizeof(srv) );

        srv.sin_family = MY_AF_INET;
        srv.sin_port = htons(PUERTO);

        local.sin_family = AF_INET;
        local.sin_port = htons(16000);
        inet_aton("172.16.48.2", &(local.sin_addr) );

        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 *)&local, sizeof(local) ) < 0 ){
            perror("bind:");
            return -1;
        }

        while( fgets(buf, MAXBUF, stdin) ){
            if( sendto(fd, buf, strlen(buf), 0, (struct sockaddr *)&srv, sizeof(srv)) < 0 ){
                perror("recvfrom");
            }else{
                fprintf(stdout, "Enviado a %s: %s", GRUPO, buf);
            }
        }
    }
    因为bind系统调用把inet_sock的成员rcv_addr也置成了本地网络设备接口的地址172.16.48.2,所以这个程序只能发送数据报,不能够接收到来自组224.0.1.1的数据报。如果想要发送者也能接收,应该这样改程序:
    #include <stdio.h>
    #include <sys/types.h>
    #include <sys/socket.h>
    #include <string.h>
    #include <arpa/inet.h>
    #include "my_inet.h"

    #define MAXBUF 256
    #define PUERTO 5000
    #define GROUP "224.0.1.1"
    int main(void)
    {
        int fd;
        struct sockaddr_in srv,local;
        struct in_addr if_req;
        char buf[MAXBUF];

        srv.sin_family = MY_AF_INET;
        srv.sin_port = htons(PUERTO);
        inet_aton(GROUP, &srv.sin_addr);

        local.sin_family =MY_ AF_INET;
        local.sin_port = htons(16000);
        inet_aton(GROUP, &(local.sin_addr) );

        if( (fd = socket( AF_INET, SOCK_DGRAM, IPPROTO_UDP) ) < 0 ){
            perror("socket");
            return -1;
        }
        if( bind( fd, (struct sockaddr *)&local, sizeof(local) ) < 0 ){
            perror("bind:");
            return -1;
        }
        inet_aton("172.16.48.2", &(if_req) );
        if( setsockopt( fd, SOL_IP, IP_MULTICAST_IF, &if_req, sizeof(struct in_addr) ) < 0 ){
            perror("setsockopt:");
            return -1;
        }
        while( fgets(buf, MAXBUF, stdin) ){
            if( sendto(fd, buf, strlen(buf), 0, (struct sockaddr *)&srv, sizeof(srv)) < 0 ){
                perror("sendto");
            }else{
                fprintf(stdout, "Enviado a %s: %s", GROUP, buf);
            }
        }
    }
    这回,本地套接口被绑定在了一个组播地址上了,这样,这个应用程序不仅能够发送组播数据,也能够接受同组中发往16000端口的数据报了(最好再加个 IP_ADD_MEMBERSHIP选项的操作),但bind组播地址时,只会设定接收地址为该组播地址,不会设定发送源地址,所以,必须使用 IP_MULTICAST_IF接口指定一个发送接口(程序中指定了172.16.48.2,即eth0接口)。
    这两个程序在现在的my_inet模块中均能够正常工作,但是它们调用的实际发送代码是UDP单播的代码,这基本能正常工作,但是单播的代码少了很多对组播的特殊处理,比如组播路由验证,环路发送等。
    在下一篇,我们将为模块添加组播数据报发送的代码,并给出分析。
    注:严格来讲,上述两个程序都是有问题的,程序1的套接口会收到发往本机172.16.48.2接口的16000端口的数据,并阻塞在套接口的接收队列中,程序2会收到发往组224.0.1.1的16000端口的数据,并阻塞在套接口的接收队列中。
2006/7/23
关于组播的其它几个选项
   前面我们已经讲到了加入一个组播组的IP选项IP_ADD_MEMBERSHIP,关于组播的IP选项,除了这个,还有总共四个,它们分别是 IP_DROP_MEMBERSHIP,IP_MULTICAST_IF,IP_MULTICAST_TTL,IP_MULTICAST_LOOP,下面分别一一介绍。
    IP_DROP_MEMBERSHIP表示退出一个组播组,该选项最终会调用内核函数myip_mc_leave_group。该函数首先拿到结构体 struct in_device,取走要离开的组的源过滤机制,即从in_device->mc_list中找到对应的组struct ip_mc_list,将其成员sfcount[sfmode]减一,然后从其成员sources中取走相应的过滤源。然后将in_device- >mc_list中该组所在的节点的引用计数减一,如果引用计数已经减为零了,则清struct net_device和struct in_device中该组的记录。最后,套接字结构体struct inet_sock的成员mc_list中有关该组的节点也被删除。至此,完成离开一个组播组的操作,该选项的参数是结构体struct ip_mreq,同IP_ADD_MEMBERSHIP。
    IP_MULTICAST_IF是一个用于确定提交组播报文的接口,它的参数也是struct ip_mreq,通过该参数指定发送组播报文所使用的本地IP地址和本地网络设备接口的索引号,用于发送组播数据报,这两个值确定后放在套接字的结构体 struct inet_sock的成员mc_addr和mc_index中,以备发送组播数据报时查询。
    IP_MULTICAST_TTL指定提交的组播报文的TTL,有效的TTL在0到255之间,该选项提供的参数会被赋给套接字结构体struct inet_sock的成员mc_ttl。以备发送组播数据报时查询。
    IP_MULTICAST_LOOP使组播报文环路有效或无效,如果环路有效,则在发送组播报文的时候,会给环回接口也发一份。该值存放在套接字的结构体struct inet_sock的成员mc_loop中。
    以上IP_MULTICAST_IF,IP_MULTICAST_TTL和IP_MULTICAST_LOOP三项都是跟组播报文发送相关的选项,在接下来的发送组播数据报的分析中会再次提到。
接收组播数据报
    前面我们讲到如何加入到一个组播组中,当一个客户端完成了加入一个组播组的操作后,就可以从该组接收数据了。下面我们看看组播数据报接收的详细流程。
    通过加入组播组的操作后,网络设备接口已经知道要接收该组的数据报,所以组播数据会从网卡接收进来,一直到达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的接收队列中,完成组播数据报的接收。
2006/7/22
加入一个组播组
    网络中的一台主机如果希望能够接收到来自网络中其它主机发往某一个组播组的数据报,那么这么主机必须先加入该组播组,然后就可以从组地址接收数据包。在广域网中,还涉及到路由器支持组播路由等,但本文希望以一个最为简单的例子解释清楚协议栈关于组播的一个最为简单明了的工作过程,甚至,我们不希望涉及到 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已经可以接收组播数据了,关于组播数据如何接收,下回分解。
2006/7/21
初识组播(2)
    上一篇我们讲到,因为my_inet是系统中第二个加载的IPv4模块,所以网络设备接口早已完成了加入IGMP_ALL_HOST组的操作, my_inet只是简单增加引用计数和源过滤计数,下面我们来看看,第一个加载的IPv4模块(即内核原有的TCP/IP协议栈模块)是如何把网络设备接口加入IGMP_ALL_HOST组的。
    在myip_mc_inc_group函数中,首先检查in_device->mc_list列表中已加入的组播组,看本接口是否已经加入了 IGMP_ALL_HOST组,结果当然是没有。则,首先创建一个新的结构体struct ip_mc_list *im,初始化其成员值,设成员multiaddr为组播地址224.0.0.1,sf_mode为MCAST_EXCLUDE,sfcount [MCAST_EXCLUDE]为1,sources为NULL,表示使用一个源过滤机制,该机制不过滤任何组播源。成员loaded为0,表示该组播组尚未被载入(稍后将看到载入的操作)。初始化完成后,将这个新的组播组加入到mc_list链表的表头。
    前面讲到过,mc_tomb也是in_device的一个成员,也表示一个组播组列表,这个列表中的组应该是不活跃的(当前不在使用的,具体留待以后分析),新的组加入到mc_list成功后,还要到这个列表中查找,看是否也存在于这个列表中,如果存在,要删除,因为该组当前是活跃的。
    最后,调用myigmp_group_added完成真正的加入组播组的操作,对于IGMP_ALL_HOST这个组来讲,该函数做的事情相对比较少,它检查loaded成员,发现为0,则调用myip_mc_filter_add,加入一个网络设备级的组播地址。也就是说,代表网络设备接口的结构体 struct net_device有一个成员mc_list,它是一个链表,每个结点代表一个组播组的mac地址。与in_device的mc_list中的组播IP 地址对应。loaded为0时,我们要做的事情就是把IP地址224.0.0.1映射成一个mac地址加到net_device的mc_list链表中去,然后把loaded置1,该成员的结点定义如下:
    struct dev_mc_list
    {
        struct dev_mc_list  *next;
        __u8            dmi_addr[MAX_ADDR_LEN];
        unsigned char   dmi_addrlen;
        int             dmi_users;
        int             dmi_gusers;
    };
    dmi_addr是mac地址,dmi_addrlen是地址长度,dmi_users是引用计数。添加完成后,net_device的成员mc_count相应的加1。
    下面我们来看看组播IP地址是如何被映射成组播mac地址的。一个mac地址总共有6字节,48位,被分成两段:前3字节和后3字节,前3字节用于标识网卡的制造厂商,其中第40位(第一字节的最低位)用于标识组播,所以在网卡的mac地址中必须置0,后3字节是厂商内部使用的序列号。一个组播IP地址映射成mac地址的规则是:前三字节强制置01:00:5E,后3字节中,第23位置0,0-22位放入IP地址的0-23位。
2006/7/20
初识组播(1)
    我们知道在内核中用结构体struct net_device标识一个网络设备接口,该结构体有一个成员指针ip_ptr,它是留给IPv4协议用于填充协议相关的一些数据的。IPv4协议的模块将其指向一个结构体struct in_device,该结构体含有很多协议相关的数据,比如配置在这个网络设备接口上的所有的IPv4的地址,该网络设备接口接受的组播地址等,下面是其完整的定义:
    struct in_device
    {
        struct net_device   *dev;
        atomic_t refcnt;
        int dead;
        struct in_ifaddr    *ifa_list;  //IP地址列表
        rwlock_t        mc_list_lock;
        struct ip_mc_list   *mc_list;   //IP组播过滤列表。
        spinlock_t      mc_tomb_lock;   //下面都是组播相关数据。
        struct ip_mc_list   *mc_tomb;
        unsigned long       mr_v1_seen;
        unsigned long       mr_v2_seen;
        unsigned long       mr_maxdelay;
        unsigned char       mr_qrv;
        unsigned char       mr_gq_running;
        unsigned char       mr_ifc_count;
        struct timer_list   mr_gq_timer;    /* general query timer */
        struct timer_list   mr_ifc_timer;   /* interface change timer */

        struct neigh_parms  *arp_parms;
        struct ipv4_devconf cnf;
        struct rcu_head     rcu_head;
    };
    我们暂时还无法完全理解这个结构体,目前只需关注的是mc_list成员,这是关于组播的一个最为关键的数据结构。mc_list是一个链表,链表的一个结点代表一个组播地址(也就是一个多播组的组号),代表这个网络设备接口已经加入了这个组播组,需要接收来自这个组的数据报。下面是该节点的结构体定义:
    struct ip_mc_list
    {
        struct in_device    *interface;
        unsigned long       multiaddr;
        struct ip_sf_list   *sources;
        struct ip_sf_list   *tomb;
        unsigned int        sfmode;
        unsigned long       sfcount[2];
        struct ip_mc_list   *next;
        struct timer_list   timer;
        int             users;
        atomic_t        refcnt;
        spinlock_t      lock;
        char            tm_running;
        char            reporter;
        char            unsolicit_count;
        char            loaded;
        unsigned char   gsquery;
        unsigned char   crcount;
    };
    同样,我们把其中的大部分成员留待以后理解。multiaddr就是组播地址,sources和tomb是关于组播源地址的一个列表,sfmode和 sfcount是过滤参数,也就是说,该网络设备接口虽然加入了某个组播组,但对某些主机向该组发的数据报不接收,或者只接收某个主机发向该组的数据报,这就要对组播源进行过滤。
    关于D类地址的常识,这里不再介绍,可参考相关书籍。
    下面介绍一个特殊的组播地址224.0.0.1,它标识子网中的所有主机,同一个子网内具有组播功能的主机都属于这个组。我们的my_inet模块在初始化时,myinetdev_event函数收到网络设备接口启动(NETDEV_UP)的消息后,调用myip_mc_up启动组播功能。
    启动组播功能的第一件事便是把本机加入到这个特殊的组播组IGMP_ALL_HOSTS(即224.0.0.1),调用 myip_mc_inc_group函数完成加入动作。因为我们的my_inet模块是作为系统中的第二个IPv4模块,在系统正常运行后被加载的,所以,网络设备接口早已完成了加入该组播组的操作。my_inet模块的加入动作是只是简单地将成员users加1,然后调用 myip_mc_add_src函数加入组播源过滤。
    IGMP_ALL_HOSTS组的sources为NULL,sfmode为MCAST_EXCLUDE(过滤掉sources中列出的所有源),所以结果是不过滤任何组播源。myip_mc_add_src函数中,将sfcount[MCAST_EXCLUDE]的值加1,表示新增一个过滤机制。
2006/7/19
UDP通讯的两端
    说起套接字的通讯,我们一般会想到C/S结构(客户端/服务端结构),从应用的角度来讲,确实如此,比如IM(即时通讯)服务,一台IM服务器同时与几万的IM客户端进行着UDP数据报的通讯。但我们从套接字的编程实现,以及TCP/IP协议栈的实现原理这一层上来看,并不存在着服务端与客户端的明显区别。
    网络上的两台主机(为简化起见,我们假设它们处于同一子网内,并能互相连通),为了建立一个UDP的通讯,一端A必须事先知道另一端B的端口(B有一个进程可以接受UDP数据报)。即B必须先建立一个套接字,并自己为其选择一个固定的端口号,而不是让系统自动选择,并把这个端口号告知A(通过人,或者是熟知端口)。然后A就可以通过connect系统调用,在其socket上设置好相应参数,以后每个发出的数据包的UDP首部中总标明目的地址B和其相应的端口号;或者每发一个数据报,通过sendto传入B的地址和端口号,以确保每个发出的数据报在B端被正确的进程接收。
    而A端可以自己选择一个固定的端口号,也可以由系统自动选择,这并不重要。因为B端收到来自A端的第一个数据报后,可以从UDP首部中判断A端的端口号,并在回应包中进行正确设置。在这样的情况下,我们一般就认为B是服务端,A是客户端。它们的唯一区别在于服务端的端口号必须是事先被客户端知道的。而客户端的端口号的选择则相对比较随便。
    我们前面文章提到过协议栈中的函数myudp_v4_get_port可以为一个UDP套接口选择一个可以使用的端口号,也可以由我们自己选择一个端口号,然后由该函数判断是否可用。我们通过bind系统调用选择一个固定的端口号:
        int bind(int sockfd, struct sockaddr *my_addr, socklen_t addrlen);
    sockfd是套接字描述符,会被转化为相应的套接字结构体,my_addr和addrlen是要绑定的地址和地址长度。地址包括IP地址(标明主机)和端口号(标明进程)。
    UDP协议没有自己的bind函数,直接使用了MY_INET域的通用bind函数。我们通常绑定的地址是INADDR_ANY,这是一个全零地址,表示对本机接收地址不作限制,即对端可以通过本机的任何一个网络设备接口向本进程发送UDP数据报,这个IP地址被赋给套接字结构体的成员rcv_saddr 和saddr,这两个成员分别表示绑定的本机IP地址和发送数据报时的本机源IP地址。为INADDR_ANY,表示发送时根据对端IP地址,通过查询路由表获得本机源IP地址。但端口必须由应用程序指定,这样才使bind系统变得有意义,当然,也可以设定端口为0,让协议栈自动选择,但这样,bind调用不调用变得没有任何区别了(connect或myinet_sendmsg函数在发现端口没有绑定时,会执行自动绑定的)。对于端口绑定还有一个限制,即要绑定的端口小于1024(PROT_SOCK)时,用户必须有相应的权限才能执行绑定,因为小于1024的端口一般为系统服务保留。
    最后,设置目的IP地址和目的端口为0,表示对对端不作任何限制。
2006/7/17
深度探索套接字缓冲区(3)
    结构体struct sk_buff中共有三个联合体,分别是h, nh和mac,它们都是一些指针,指向协议栈各层协议的首部。从含有的首部类型来看,nh是h的子集,而mac是nh的子集。《Linux设备驱动程序》第三版第522页这样介绍这三个联合体:h中包含有传输层的报文头,nh中包含有网络层的报文头,而mac中包含的是链路层的报文头。
    光靠这样的一个解释可能过于抽象,让我们来看一个UDP数据报是怎么样穿过数千公里长的网线来到我们的网卡,通过网卡的驱动程序层层向上来到协议栈的上层的。
    当网卡驱动程序收到一个UDP数据报后,它创建一个结构体struct sk_buff,确保data成员指向的空间足够存放收到的数据(对于数据报分片的情况,因为比较复杂,我们暂时忽略,我们假设一次收到的是一个完整的 UDP数据报)。把收到的数据全部拷贝到data指向的空间,然后,把skb->mac.raw指向data,此时,数据报的开始位置是一个以太网头,所以skb->mac.raw指向链路层的以太网头。然后通过调用skb_pull剥掉以太网头,所谓剥掉以太网头,只是把data加上 sizeof(struct ethhdr),同时len减去这个值,这样,在逻辑上,skb已经不包含以太网头了,但通过skb->mac.raw还能找到它。这就是我们通常所说的,IP数据报被收到后,在链路层被剥去以太网头。
    在继续往上层的过程中,一直到我们的my_inet域的函数myip_local_deliver_finish中,我们通过 __skb_pull剥去IP首部,同样,我们可以通过skb->nh.raw找到它。最后,skb->h.raw指向data,即udp首部,udp首部其实到最后都没有被剥去,应用程序在调用recv接收数据时,直接从skb->data+sizeof(struc udphdr)的位置开始拷贝。   
    我们可以看到,从网卡驱动开始,通过协议栈层层往上传送数据报时,通过增加skb->data的值,来逐步剥离协议首部,但通过h,nh,mac这三个联合指针,我们可以访问到这些协议首部,从而利用其提供的有效信息。
    但必须指出的是,《Linux设备驱动程序》中的解释并不完全准确,mac中包含链路层报文头,这是毫无疑问的,nh中包含义网络层的报文头,也没有问题,因为ARP协议也属于网络层协议,nh中包含IP首部或者ARP首部。当我们接收到一个icmp数据报时,在 myip_local_deliver_finish中剥去IP首部后,skb->h.raw指向的是icmp首部,但icmp显然不是传输层协议,它是网络层的一个附属协议。igmp也是相同的情况,我想这也是为什么sk_buff的三个联合体不命名为th, nh, mac的原因,因为th(transprot header)不能准确反映它的内容。
    正确的理解应该是三个联合体是按TCP/IP数据报的协议首部的排列顺序来制定的。排在最前面的是以太网头,包含在mac中,第二是网络层协议首部,包括 IP和ARP,包含在nh中,第三包括传输层协议头(TCP, UDP)、ICMP, IGMP。
    另外,再选择两个重要的数据成员作个简短介绍。
    pkt_type,数据报的类型。这个值在网卡驱动程序中由函数eth_type_trans通过判断目的以太网地址来确定。如果目的地址是FF:FF: FF:FF:FF:FF,则为广播地址,pkt_type=PACKET_BROADCAST,如果最高位为1,则为组播地址,pkt_type= PACKET_MULTICAST,如果目的mac地址跟本机mac地址不相等,则不是发给本机的数据报,pkt_type= PACKET_OTHERHOST,否则就是缺省值PACKET_HOST。
    protocol, 它的值是以太网首部的第三个成员,即帧类型,对于IP数据来讲,就是ETH_P_IP(0x8000),对ARP数据报来讲,就是ETH_P_ARP(0x8086)。
    sk_buff还有一组操作函数,在理解sk_buff本身的基础上,理解这些函数并不困难,这里不再作分析。关于套接字缓冲区的分析就到这里结束。
深度探索套接字缓冲区(2)
    前面一篇文章分析了套接字缓冲区sk_buff的创建过程,但一般来讲,一个套接字缓冲区总是属于一个套接字,所以,除了调用sk_buff本身的 alloc_skb函数创建一个套接字缓冲区,套接字本身还要对sk_buff进行一些操作,以及设置自身的一些成员值。下面我们来分析这个过程。
    如果检查到待发送数据报没有传输层协议头(不是传输层的tcp或udp数据报),套接字创建缓冲区的函数是sock_alloc_send_skb,它的函数原型是:
    struct sk_buff *sock_alloc_send_skb(struct sock *sk, unsigned long size,
            int noblock, int *errcode)
    它直接调用函数:
    static struct sk_buff *sock_alloc_send_pskb(struct sock *sk,
            unsigned long header_len,
            unsigned long data_len,
            int noblock, int *errcode)
    参数sk是要创建缓冲区的那个套接字,header_len是sk_buff中,成员data指向的那块数据区的长度,而data_len则是指除那块数据区以外的被分片的数据的总长。noblock指示是否阻塞模式。对于非传输层协议包,不使用分散/聚集IO,所以,置data_len为0。
    网络层代表一个套接字的结构体struct sock有两个成员sk_wmem_alloc和sk_sndbuf,sk_wmem_alloc表示在这个套接字上已经分配的写缓冲区(发送缓冲区)的总长,每次分配完一个属于它的写sk_buff,这个值总是加上sk_buff->truesize。而sk_sndbuf则是这个socket所允许的最大发送缓冲区。它的值在系统初始化的时候设为变量sysctl_wmem_max的值,可以通过系统调用进行修改。其缺省值 sysctl_wmem_max为107520字节,因为它的计算长度还包括了struct sk_buff,所以,一般认为其缺省值是64K数据。
    而对于传输层协议包,我们使用sock_wmalloc创建套接字缓冲区,这是一个更为简单的创建函数,没有超时、出错判断机制,直接通过调用 alloc_skb创建一个sk_buff并返回。但对于传输层协议有一个不同点就是sk_wmem_alloc最大可以达到两倍sk_sndbuf,即缺省的发送缓冲区可以达到128K。
    到这里,我们就不难理解struct sk_buff中另外两个成员的含义了:
    len是指数据包全部数据的长度,包括data指向的数据和end后面的分片的数据的总长,而data_len只包括分片的数据的长度。而truesize的最终值是len+sizeof(struct sk_buff)。
2006/7/16
深度探索套接字缓冲区(1)
    套接字缓冲区用结构体struct sk_buff表示,它用于在网络子系统中的各层之间传递数据,处于一个核心地位,非常之重要。它包含了一组成员数据用于承载网络数据,同时,也定义了在这些数据上操作的一组函数。下面是其完整的定义:
    struct sk_buff {
        struct sk_buff      *next;
        struct sk_buff      *prev;

        struct sock     *sk;
        struct skb_timeval  tstamp;
        struct net_device   *dev;
        struct net_device   *input_dev;

        union{
            struct tcphdr   *th;
            struct udphdr   *uh;
            struct icmphdr  *icmph;
            struct igmphdr  *igmph;
            struct iphdr    *ipiph;
            struct ipv6hdr  *ipv6h;
            unsigned char   *raw;
        }h;

        union{
            struct iphdr    *iph;
            struct ipv6hdr  *ipv6h;
            struct arphdr   *arph;
            unsigned char   *raw;
        }nh;
        union{
            unsigned char   *raw;
        }mac;

        struct  dst_entry   *dst;
        struct  sec_path    *sp;

        char            cb[48];

        unsigned int        len,
                            data_len,
                            mac_len,
                            csum;
        __u32           priority;
        __u8            local_df:1,
                        cloned:1,
                        ip_summed:2,
                        nohdr:1,
                        nfctinfo:3;
        __u8            pkt_type:3,
                        fclone:2,
                        ipvs_property:1;
        __be16          protocol;

        void            (*destructor)(struct sk_buff *skb);
#ifdef CONFIG_NETFILTER
        __u32           nfmark;
        struct nf_conntrack *nfct;
#if defined(CONFIG_NF_CONNTRACK) || defined(CONFIG_NF_CONNTRACK_MODULE)
        struct sk_buff      *nfct_reasm;
#endif
#ifdef CONFIG_BRIDGE_NETFILTER
        struct nf_bridge_info   *nf_bridge;
#endif
#endif /* CONFIG_NETFILTER */
#ifdef CONFIG_NET_SCHED
        __u16           tc_index;
#ifdef CONFIG_NET_CLS_ACT
        __u16           tc_verd;
#endif
#endif


        unsigned int    truesize;
        atomic_t        users;
        unsigned char   *head,
                        *data,
                        *tail,
                        *end;
    };
    这是一个比较宠大的结构体,为了便于理解,我们分成多块进行分析。
    为了使用套接字缓冲区,内核创建了两个后备高速缓存(looaside cache),它们分别是skbuff_head_cache和skbuff_fclone_cache,协议栈中所使用到的所有的sk_buff结构都是从这两个后备高速缓存中分配出来的。两者的区别在于skbuff_head_cache在创建时指定的单位内存区域的大小是sizeof(struct sk_buff),可以容纳任意数目的struct sk_buff,而skbuff_fclone_cache在创建时指定的单位内存区域大小是2*sizeof(struct sk_buff)+sizeof(atomic_t),它的最小区域单位是一对strcut sk_buff和一个引用计数,这一对sk_buff是克隆的,即它们指向同一个数据缓冲区,引用计数值是0,1或2,表示这一对中有几个sk_buff 已被使用。
    创建一个套接字缓冲区,最常用的操作是alloc_skb,它在skbuff_head_cache中创建一个struct sk_buff,如果要在skbuff_fclone_cache中创建,可以调用__alloc_skb,通过特定参数进行。
    struct sk_buff的成员head指向一个已分配的空间的头部,该空间用于承载网络数据,end指向该空间的尾部,这两个成员指针从空间创建之后,就不能被修改。data指向分配空间中数据的头部,tail指向数据的尾部,这两个值随着网络数据在各层之间的传递、修改,会被不断改动。所以,这四个指针指向共同的一块内存区域的不同位置,该内存区域由__alloc_skb在创建缓冲区时创建,四个指针间存在如下关系:
        head <= data <= tail < end
    那指向的这块内存区域有多大呢?一般由外部根据需要传入。外部设定这个大小时,会根据实际数据量加上各层协议的首部,再加15(为了处理对齐)传入,在 __alloc_skb中根据各平台不同进行长度向上对齐。但是,我们另外还要加上一个存放结构体struct skb_shared_info的空间,也就是说end并不真正指向内存区域的尾部,在end后面还有一个结构体struct skb_shared_info,下面是其定义:
        struct skb_shared_info{
            atomic_t        dataref;    //引用计数。
            unsigned short  nr_frags;   //数据片段的数量。
            unsigned short  tso_size;
            unsigned short  tso_segs;
            unsigned short  ufo_size;
            unsigned int    ip6_frag_id;
            struct sk_buff  *frag_list;     //数据片段的链表。
            skb_frag_t  frags[MAX_SKB_FRAGS];   //每一个数据片段的长度。
        };
    这个结构体存放分隔存储的数据片段,将数据分解为多个数据片段是为了使用分散/聚集I/O。
    如果是在skbuff_fclone_cache中创建,则创建一个struct sk_buff后,还要把紧邻它的一个struct sk_buff的fclone成员置标志SKB_FCLONE_UNAVAILABLE,表示该缓冲区还没有被创建出来,同时置自己的fclone为 SKB_FCLONE_ORIG,表示自己可以被克隆。最后置引用计数为1。
    最后,truesize表示缓存区的整体长度,置为sizeof(struct sk_buff)+传入的长度,不包括结构struct skb_shared_info的长度。
接收一个UDP数据报
    现在我们换一个角度,站在服务器的一方,看看如何被动地接收一个UDP数据,并作出相应的处理。我们把前面文章提及的UDP示例客户端放到 172.16.48.1上,在主机172.16.48.2的eth0网络设备接口上再配一个从属IP地址172.16.48.13。让客户端向13发送 UDP数据报。
    函数myudp_rcv处理接收到的UDP数据报,该函数首先从数据报头中取出源和目的地址、端口。向哈希表myudp_hash查询,看是否存在相应的 sokcet等待处理该数据报。如果没有,则发生错误,即对端主机向本机的某个特定端口发送了一个UDP数据,但本机并没有该端口标识的进程需要处理该数据报,所以,这是一个目的端口不可达错误。调用myicmp_send发送一个目的端口不可达出错报文。
    目的端口不可达ICMP报文需要将整个源出错UDP报文作为数据负载(payload)放进报文中,所以,首先要对源UDP报文进行基本的正确性检查。还需要生成一份详细的ICMP参数信息,用于生成ICMP报文,ICMP参数信息用结构体struct icmp_bxm表示,下面是该结构体的定义:
    struct icmp_bxm {
        struct sk_buff *skb;    //指向所在的sk_buff
        int offset;             //出错的源UDP报文在ICMP报文中的偏移量。
        int data_len;

        struct {
            struct icmphdr icmph;   //icmp头,需要填充完整。
            __u32          times[3];
        } data;
        int head_len;
        struct ip_options replyopts;    //ip选项。
        unsigned char  optbuf[40];
    };
    发送一个数据报,相应的,肯定需要一个套接字,为发送这个ICMP服文,协议栈为每个CPU创建了一个套接字,命名为myicmp_socket。
从套接字上得到扩展的更为可靠的出错信息(续)
    接着前一篇,我们来看这个应用程序背后,内核真正做了一些什么事情。
    代表MY_INET域套接字的结构体struct inet_sock有一个成员recverr,它占1bit长度,可能的取值是1或0,当为0时表示socket上出错时,只通过系统调用向应用程序返回错误号,不提供进一步的详细信息。当取值为1时,则表示socket上出错时,则向struct inet_sock的成员sk_error_queue(一个sk_buff的队列)存入一个特殊的struct sk_buff,在sk_buff的成员cb中放入详细的错误信息,应用程序通过特定的系统调用可以取得详细的出错信息。
    recverr的值可以通过套接字选项操作进行设置,它是一个IP层的选项,对应的选项名是IP_RECVERR。下面的代码就是将它的值设为1(打开选项):
        int val = 1;
        if( setsockopt( fd, SOL_IP, IP_RECVERR, &val, sizeof(val) ) == -1 )
            ;//deal with error
    当打开了这个选项后,我们在该socket上发送UDP数据报,按照前面文章提及的测试环境运行,172.16.48.2继续会收到ICMP目的不可达报文,在差错数据报处理时,会达到函数myudp_err,该函数会设置socket的成员sk_err,同时,它也会检查recverr成员,如果为1,则要在sk_error_queue队列中放入一个特殊的出错信息sk_buff。该sk_buff保留了出错的那个源UDP数据报,同时在它的cb成员中保存了一个结构体struct sock_exterr_skb,该结构体记录了详细的出错信息,下面是其定义:
        struct sock_exterr_skb
        {
            union {
            struct inet_skb_parm    h4;
#if defined(CONFIG_IPV6) || defined (CONFIG_IPV6_MODULE)
            struct inet6_skb_parm   h6;
#endif
            } header;
            struct sock_extended_err    ee;
            u16             addr_offset;
            u16             port;
        };
    addr_offset和port是出错UDP数据报的地址和端口号,ee的定义如下:
    struct sock_extended_err
    {
        __u32   ee_errno;       //错误号。
        __u8    ee_origin;      //产生错误的源,我们的环境下,产生错误的源为一个ICMP包。
        __u8    ee_type;        //ICMP类型。
        __u8    ee_code;        //ICMP代码。
        __u8    ee_pad;
        __u32   ee_info;        //用于EMSGSIZE时找到的MTU。
        __u32   ee_data;
    };
    我们保存了出错信息,应用程序要取得这个出错信息,必须使用特定的系统调用,recvmsg可以获得详细的出错信息,同时,调用接口上必须使用标志MSG_ERRQUEUE表示取错误队列,下面是recvmsg的定义:
        ssize_t recvmsg(int s, struct msghdr *msg, int flags);
    flags置MSG_ERRQUEUE,msg结构控制信息成员msg_control和msg_controllen需要分配一个缓存,用于辅助信息的传递。关于接收,可以查看前面一篇的源代码和man recvmsg,这里不再重复。
2006/7/15
从套接字上得到扩展的更为可靠的出错信息
    在前一篇中,我们提到在对端主机上没有创建指定的UDP套接字时,我们向其发送一个UDP包,会得到一个目的端口不可达的ICMP出错报文。但内核在处理完该报文后,给应用程序仅仅返回一个ECONNREFUSED错误号,所以应用程序能知道的全部信息就是连接被拒绝,至于为什么被拒绝,没有办法知道。我们可以通过套接字选项的设置,让内核返回更为详细的出错信息,以利于调试程序,发现问题。下面是通过套接字选项传递扩展出错信息的一个示例程序。关于内核原理的分析,在下一篇给出。
#include <sys/socket.h>
#include <linux/types.h>
#include <linux/errqueue.h>
#include <sys/ioctl.h>
#include "my_inet.h"
#include <stdio.h>
#include <errno.h>
#include <string.h>

#include <arpa/inet.h>
#include <unistd.h>

int ip_control_msg( struct cmsghdr *msg )
{
    int ret = 0;
    switch( msg->cmsg_type ){
    case IP_RECVERR:
        {
            struct sock_extended_err *exterr;
            exterr = (struct sock_extended_err *)(CMSG_DATA(msg));
            printf("ee_errno: %u/n", exterr->ee_errno );
            printf("ee_origin: %u/n", exterr->ee_origin );
            printf("ee_type: %u/n", exterr->ee_type );
            printf("ee_code: %u/n", exterr->ee_code );
            printf("ee_pad: %u/n", exterr->ee_pad );
            printf("ee_info: %u/n", exterr->ee_info );
            printf("ee_data: %u/n", exterr->ee_data );
        }
        ret = -1;
        break;
    default:
        break;
    }
    return ret;
}

int control_msg( struct msghdr *msg )
{
    int ret = 0;
    struct cmsghdr *control_msg = CMSG_FIRSTHDR( msg );
    while( control_msg != NULL ){
        switch( control_msg->cmsg_level ){
        case SOL_IP:
            ret = ip_control_msg( control_msg );
            break;
        default:
            break;
        }
        control_msg = CMSG_NXTHDR( msg, control_msg );
    }
    return ret;
}

int main()
{
    int i;
    struct sockaddr_in dest;
    dest.sin_family = MY_PF_INET;
    dest.sin_port = htons(16000);
    dest.sin_addr.s_addr = 0x013010AC;

    int fd = socket( MY_PF_INET, SOCK_DGRAM, MY_IPPROTO_UDP );
    if( fd < 0 ){
        perror("socket: ");
        return -1;
    }
    if( connect( fd, (struct sockaddr*)&dest, sizeof(dest) ) < 0 ){
        perror("connect: ");
        return -1;
    }

    int val = 1;
    if( setsockopt( fd, SOL_IP, IP_RECVERR, &val, sizeof(val) ) == -1 ){
        perror("setsockopt: ");
        return -1;
    }

    int bwrite = send( fd, "abcdefg", 7, 0 );
    if( bwrite == -1 ){
        perror("send: ");
        return -1;
    }
    char buf[1024];
    char control_buf[1024];
    struct msghdr msg;
    struct iovec iov = { buf, 1024 };
    memset( &msg, 0, sizeof(msg) );
    msg.msg_iov = &iov;
    msg.msg_iovlen = 1;
    msg.msg_control = &control_buf;
    msg.msg_controllen = 1024;

    int bread = recvmsg( fd, &msg, MSG_ERRQUEUE );
    if( bread == -1 ){
        perror("recv: ");
        return -1;
    }
    if( control_msg( &msg ) >= 0 )
        printf("successed!/n");
    else
        printf("failed!/n");

    close( fd );
    return 0;
}

执行结果:
    ee_errno: 111           //ECONNREFUSED
    ee_origin: 2            //SO_EE_ORIGIN_ICMP
    ee_type: 3              //目的不可达
    ee_code: 3              //端口不可达
    ee_pad: 0
    ee_info: 0
    ee_data: 0
    failed!
发送UDP数据报之后,接收一个回应
    标题看起来有点别扭,但的确如此,在我们前面的例子中,我们并没有在对端主机172.16.48.1的16000端口上创建一个UDP套接字(简单地讲就是没有开启UDP服务器),所以,我们不能期望向这个目的地址发送一个UDP数据报后会收到一个相应的UDP回应。但是,这并不表示我们发出去的数据报石沉大海,对端主机的TCP/IP协议栈在检查了16000的端口并没有相应的进程需要接收UDP数据后,会回应一个ICMP包,告诉发送端,目的地址不可达,具体原因是目的端口不可达。下面是在网络中截获的这个ICMP包:
    16进制表示的数据包内容          内容解析
    00 0c 29 80 d8 70               以太网首部,目的MAC地址,即172.16.48.2
    00 50 56 c0 00 08               以太网首部,源MAC地址,即172.16.48.1
    08 00                           以太网首部,帧类型
    45                              IP首部,4位版本(4), 4位长度(5)
    c0                              IP首部,服务类型(一般服务)
    00 3f                           IP首部,总长度(63字节,不包括以太网首部)
    66 54                           IP首部,标识符
    00 00                           IP首部,3位标识加13位偏移(未分片)
    40                              IP首部,TTL(64)
    01                              IP首部,协议
    5b 86                           IP首部,校验和
    ac 10 30 01                     IP首部,源IP地址,172.16.48.1
    ac 10 30 02                     IP首部,目的IP地址,172.16.48.2
    03 03                           ICMP首部,8位类型(3),8位代码(3),端口不可达。
    b5 41                           ICMP首部,校验和
    00 00 00 00                     ICMP首部,必须置0
    以下为产生错差的那个数据报(除以太网首部外的全部内容)。
    45 00 00 23 40 f6 40 00 40 11 41 b0 ac 10 30 02    
    ac 10 30 01 80 00 3e 80 00 0f f7 fd 61 62 63 64
    65 66 67
    以太网首部总共14字节,分别为目的MAC地址,源MAC地址和帧类型,帧类型在my_inet域中,一般有两种取值,ETH_P_IP(0x8000) 和ETH_P_ARP(0x8086),分别表示因特网协议包和地址解析协议包。
    在IP首部中有一个8位的协议字段,由于TCP,UDP,ICMP,IGMP都要向IP传送数据,因此IP必须在生成的首部中加入某种标识,以表明数据属于哪一层。为此,加入了这个协议域,1表示为ICMP,2表示为IGMP,6表示为TCP,17表示UDP。这里是一个ICMP报文不可达数据报,所以为 1。
    当172.16.48.2收到这个ICMP数据报后,会作出怎么样的处理?
    我们还是从myip_rcv开始跟踪整个执行流。myip_rcv对数据报的IP头进行一些正确性检查,然后交给了myip_rcv_finish, myip_recv_finish进行输入路由的查询,并调用myip_local_deliver进行本地接收,myip_local_deliver 调用myip_local_deliver_finish,在该函数中,首先检查myraw_v4_htable哈希表,看有没有要接收这个数据报的 raw套接字,因为这是一个ICMP包,有可能是对一个raw套接字发出的数据包的回应,显然没有。再根据协议类型(ICMP)查询 myinet_protos数组,该数组中注册有当前所有支持的协议,然后调用icmp的处理函数myicmp_rcv。
    myicmp_rcv检查icmp头中的类型字段,发现是ICMP_DEST_UNREACH(目的不可达),将数据报交给myicmp_unreach 处理。myicmp_unreach检查代码字段,发现是ICMP_PORT_UNREACH(端口不可达),取出差错数据报中的ip首部,其协议字段值是17(UDP)。同样先到myraw_v4_htable中查询是否有raw套接字要处理该差错报文,显然没有。然后再到myinet_protos数组中查询协议相符的成员处理该差错报文,调用到myudp_err。
    myudp_err函数会首先从myudp_hash表中找出发送时hash在里面的相应的socket(差错报文中的源端口的主机字节序跟socket 结构的成员hnum相等即可,简单地,可以理解为一个进程标识符,该差错报文曾经是那个进程发送的,现在要交还它处理)。然后查看这个ICMP报文的类型,是ICMP_DEST_UNREACH,所以接下来,通过数组icmp_err_convert(把目的不可达ICMP报文的代码号转化为相应错误号的一个数组),把ICMP_PORT_UNREACH代码号转化为错误号ECONNREFUSED。
    为了把错误信息传达给进程(进程在调用recv来接收数据时,可以通过struct socket的一些成员值看到)。我们还需要在套接字上存放一些信息。成员sk_error_queue是一个错误队列,存放的是一些特殊的 sk_buff结构,用于报告错误信息。我们创建这样的一个sk_buff,放入,同时,置成员sk_err=ECONNREFUSED。到此为止,我们整个接收ICMP报文的流程全部结束。
    当然,应用程序这个时候并不知道对端主机端口不可达,将调用recv系统调用进行数据接收。但内核做的第一件事情就是检查socket的sk_err成员,如果不为零,直接将错误号返回。应用程序马上得到错误返回:连接被拒绝。
2006/7/14
在一个已连接的socket上发送UDP数据报
    很多介绍网络编程的书籍中会这样介绍connect系统调用:将本机的一个指定的套接字连接到一个指定地址的服务器套接字上去。下面是connect系统调用的定义:
        int connect(int sockfd, const struct sockaddr *serv_addr, socklen_t addrlen);
    参数sockfd是本地机器上的一个套接字描述符,在内核的系统调用函数中该描述符会被转换成与之绑定的一个struct socket结构,这是真正的一个socket,代表了网络通讯中连接的一端。serv_addr和addrlen则是要连接的服务器的地址和地址长度。
    于是乎,有了这样的理解:connect将在本机和指定服务器间建立一个连接。但实际上,connect操作并不引发网络设备传送任何的数据到对端。它所做的操作只是通过路由规则和路由表等一些信息,在struct socket结构中填入一些有关对端服务器的信息。这样,以后向对端发送数据报时,就不需要每次进行路由查询等操作以确定对端地址信息和本地发送接口,应用程序也就不需要每次传入对端地址信息(可以使用send而不使用sendto)。基于这样的理解,我们就不难弄明白,为什么不只是tcp socket可以connect,udp, raw socket也可以通过connect进行连接。它们的本质其实没有多大差别:把通过路由查询得到的对端主机的地址信息缓存到套接字结构struct socket中。
    udp和raw的connect操作其实是完全一致的,都使用了myip4_datagram_connect函数。
    为方便起见,我们再以一个实际的例子来描述该函数所做的事情,我们在主机172.16.48.2上向主机172.16.48.1的端口16000发送一个 udp数据报,172.16.48.2上的udp端口由系统自动选择(为32768)。下面是一个简单的应用程序示例:
        #include <sys/types.h>
        #include <sys/socket.h>
        #include <sys/ioctl.h>
        #include "my_inet.h"
        #include <stdio.h>
        #include <errno.h>

        #include <arpa/inet.h>
        #include <unistd.h>

        int main()
        {
            int i;
            //代表服务器地址的结构。
            struct sockaddr_in dest;
            dest.sin_family = MY_PF_INET;
            dest.sin_port = htons(16000);
            dest.sin_addr.s_addr = 0x013010AC;//172.16.48.1的网络字节序。

            int fd = socket( MY_PF_INET, SOCK_DGRAM, MY_IPPROTO_UDP );
            if( fd < 0 ){
                perror("socket: ");
                return -1;
            }
            //连接操作。
            if( connect( fd, (struct sockaddr*)&dest, sizeof(dest) ) < 0 )
                perror("connect: ");
            //不必通过sendto每次传入对端地址信息了。
            int bwrite = send( fd, "abcdefg", 7, 0 );
            if( bwrite == -1 ){
                perror("send: ");
                return -1;
            }

            printf("sendto: %d/n", bwrite);
            close( fd );
            return 0;
        }
    connect系统调用的执行流在到达myip4_datagram_connect函数之前,已经对本地端口号进行自动选择,并把socket绑定到了 myudp_hash表中。到达myip4_datagram_connect函数之后,第一件事情是建立一个struct flowi:
    struct flowi fl = { .oif = 0,   //输出设备接口未定。
            .nl_u = { .ip4_u = { .daddr = 172.16.48.1       //目的地址。
                                 .saddr = 0.0.0.0           //源地址未定。
                                 .tos   = 0 } },            //一般服务
            .proto = MY_IPPROTO_UDP,        //UDP协议
            .uli_u = { .ports =
                            { .sport = 32768,               //自动选择的第一个源端口
                              .dport = 16000 } } };         //目的端口
    以该结构体为信息查询路由表,结果肯定查到main表,确定saddr为172.16.48.2。并得到一个struct rtable结构作为路由查询结果。
    对于my_inet域的套接字,结构体struct socket有一个成员struct inet_sock sock代表网络层的一个套接字,其成员rcv_saddr(含义尚不明确)和saddr被赋172.16.48.2,daddr, dport被赋于服务器的地址和端口号。而表示连接状态的sk_state成员被赋于TCP_ESTABLISHED,这里需要注意的是 TCP_ESTABLISHED并不专指TCP连接建立状态,所有执行connect成功的套接字,其状态都是TCP_ESTABLISHED。id被赋于当前时间。成员sk_dst_cache指向路由查询结果rtable的成员u.dst。从而套接字完全缓存路由查询的结果。
    执行了connect后的socket,需要发送数据报时,关于对端的信息全部可以从socket本身得到。但需要重申的一点是:由于路由缓存的存在,在连接的socket上发送数据报并不会比在未连接的socket上发送数据报效率高多少。
 
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值