zoukankan      html  css  js  c++  java
  • 网络协议栈(1)从tcp的connect开始

    一、基本结构

    内核中网络模块有眼花缭乱的数据结构,而且从名字和功能上看还没啥差别,所以对代码的理解还是有不小影响的。这里就是一个网络的开始阶段,然后尝试以这个为入口,看看系统中的网络的相关模块和功能实现。

    1、socket 

           根据 毛德操 《linux情景分析》下册863页有一个比较合理的解释:“socket和sock是同一个事物的两个方面。不妨说,socket结构是面向进程和系统调用的侧面,而sock结构则是面向底层驱动的一个侧面。……。另一方面,即于通讯关系比较密切的那一部分,则单独作为一个数据结构。”也就是说,socket是一个上层通用的界面,可以设想一下,所有的网络都是通过socket来通讯的,这个不经包含了我们最为常见的internet,还有很多其他的网络也是通过这一套接口来进行计算机之间的通讯,例如Appletalk、蓝牙、unixdomain等,这些明显是不同的网络协议(甚至unixdomain根本就是PC内部通讯),但是它们同样使用相同的socket API。而内核对于这些socket的管理也是通过sock文件系统来完成的。

    在linux-2.6.21 etsocket.c:sock_init()中注册了系统中的sockfs,其对应代码为

    register_filesystem(&sock_fs_type);

    这样,作为一个文件系统,它就有一个向更上层的同一个接口界面。然后按照linux内核实现的一贯原则,之后通过虚拟函数跳转表来进行具体协议的再次转发。

    2、sock

    这个就是一个和驱动联系比较紧密的结构。事实上,这个结构是各个不同的协议簇来自己创建的一个结构,而不像socket结构一样由内核的网络框架统一创建。这样,每个不同的协议簇就可以自主的初始化这个结构中的很多成员。另外,这个结构也是一个驱动层可以识别的数据结构,当网络数据到来的时候,这个socket的报文就是直接路由到本机的sock结构的队列中,从而完成报文的接受。所以说这个结构可以和驱动交互。

    3、sk_buff

    这个结构可以认为是一个真正的数据承载结构,底层的发送和接受的一个基本单位(和串口的单个字符接受不同,这里每次操作的都是一个报文,报文可以有结构,可以包含上K字节的数据,这也是网络明显快于串口的一个原因吧)。例如,底层的驱动要把每次接受的内容转换为这个结构,挂在一个用户socket的队列中。例如,在sock结构中,有下面的成员

     struct sk_buff_head sk_receive_queue;
     struct sk_buff_head sk_write_queue;
     struct sk_buff_head sk_async_wait_queue;

    4、proto_ops/proto

    在socket结构中,有一个const struct proto_ops *ops;成员,这个成员是由各个具体的协议簇实现的一个功能。可以认为是网络协议簇的第一次转发,不同的网络协议需要将这个结构初始化为不同的网络协议。按照这个结构最初的意图,它应该是为每个不同的协议簇有一个确定的proto_ops,其中的proto就是socket的第一个参数中对应的协议。例如,整个ax25使用的都是ax25_proto_ops结构。然后再根据socket的第二和第三个参数来确定sock结构中的struct proto  *skc_prot;结构。但是inet偏偏就不是,同样是inet协议,它的socket结构中的proto_ops结构都可以不同,这个区分是通过一个交换机结构来实现的,

    static struct inet_protosw inetsw_array[] =
    变量初始化了一个根据socket的后两个参数type和protocol确定了socket->proto_ops和 sock->proto两个结构的交换关系(其中的sw是switch的缩写)

    5、sockaddr

    这是一个地址相关数据结构,在内核中这个结构的定义为

    struct sockaddr {
     sa_family_t sa_family; /* address family, AF_xxx */
     char  sa_data[14]; /* 14 bytes of protocol address */
    };

    这里可以看到,sa_data最多是14个字符,但是对于类似于unix这样的协议簇,这个肯定是不够的,那么bind是如何处理统一处理不同协议的地址呢?在用户态,当unix套接字的创建时通过sockaddr_un结构来创建的,这个结构的定义为

    /* Structure describing the address of an AF_LOCAL (aka AF_UNIX) socket.  */
    struct sockaddr_un
      {
        __SOCKADDR_COMMON (sun_);
        char sun_path[108];  /* Path name.  */
      };
    所以虽然内核看到的是一个14字节成员,但是用户态是可以任意字节的,所以内核的bind系统调用不仅要传递这个sockaddr结构的指针,同样还需要传递一个长度指针,而内核拷贝的时候则是依据用户传入的地址进行拷贝,而不是根据静态的sockaddr的结构大小拷贝。进一步说,如果在bind系统调用的时候没有传递这个参数,那么内核将不会将这个地址拷贝到内核态,从而可能造成不确定问题。同样的问题在connect系统调用中也存在,所以大家不要奇怪为什么sockaddr的结构是确定的还需要在额外传递一个长度参数。

    二、协议簇的注册

    1、inet协议簇的注册

    每种协议簇都需要注册自己,其实注册的核心就是告诉内核自己的协议编号和对应的一个socket创建接口之间的一个映射关系。这个注册使用的结构位net_proto_family,由于内核和用户态都是通过Address Family(AF_XXX)来表示协议簇,但是事实上这个最正统的叫法还应该是Proto Family,只是因为大部分协议簇都有自己独特的物理地址,所以大家就给它起了个绰号叫Address Family。每个AF_XXX都有一个对应的PF_XX定义,所以我们可以认为这里的net_proto_family就是一个net_address_proto。

    这个net_proto_family根本的结构就是一个create接口,这个接口负责初始化socket成员,并且如果有必要的话,应该负责分配为一个sock结构并且将其初始化。我们看一下最为常见的inet的初始化地方:

    inet_init--->>>sock_register(&inet_family_ops)

    一般内核中的注册接口都比较简单,这次也不例外,就是将这个结构安装到全局变量

    static const struct net_proto_family *net_families[NPROTO] __read_mostly;
    中。这里有一个很和谐的内容,就是所有的协议簇编号都是比较连续的,而且都是从一个比较小的地址开始,所以这里可以使用最为高效的数组来进行操作的安装和注册。

    2、socket的创建

    sys_socketcall--->>>sys_socket--->>>isock_creat-->>>__sock_create--->>>enet_create

    在其中的sys_socket函数中,其中完成了socket结构的分配,这也就体现了这个结构和上层之间的一个更加接近的关系,也就是所有的net_proto_family不需要创建这个结构,它们只是根据自己的需要决定是否需要创建sock结构以及如何创建这个结构,并对socket结构进行必要的初始化。

    在inet_create接口中,其中根据socket的第二个和第三个参数来从inetsw表中查询到对应的proto_ops和proto成员,然后将他们分别安装到socket的对应域中,从而完成套接口的初始化。

    假设用户创建的是

      .type =       SOCK_STREAM,
      .protocol =   IPPROTO_TCP,
      .prot =       &tcp_prot,
      .ops =        &inet_stream_ops,

    类型的套接口,那么它们就可以路由到这个结构,也就是socket的proto_ops会被初始化为inet_stream_ops,而socket-->>sock-->>sock_common-->>proto则被初始化为tcp_prot。

    从名字上看,其中虽然对于同一个inet可以有不同的proto_ops,但是这些ops至少还是以inet开始的,只能从这里看到一点是AF_INET的血统。 

    三、connect的参数确定

    对于通常的客户端来说,当套接口创建之后,就可以直接通过connect来连接服务器。这里其实有一个细节的问题:对于IP层来说,当我们执行connect接口的时候,IP层是需要一个源IP和一个端口号的,但是明显地,我们没有处理这个东西。源地址的选择在单网卡的环境上就不需要选择了,就好像是亚当和夏娃一样,想选也没得选啊。但是如果说一个主机刚好比较奢侈,可以选择着发,那此时内核就要代劳了,当然发送端口的选择大部分情况下都是内核来选取的,可以认为是内核的分内工作。

    1、本地网口的选择

    这个还没有找到内核对应的代码,但是可以先看一下用户态的代码,然后猜测一下内核的实现流程。内核中的路由配置信息

    cat /proc/net/route

     Iface   Destination     Gateway         Flags   RefCnt  Use     Metric  Mask                MTU     Window  IRTT
      eth0    00CBA8C0        00000000        0001    0       0       1       00FFFFFF            0       0       0

      eth0    00000000        02CBA8C0        0003    0       0       0       00000000            0       0       0   

    这里列出了系统中的路由信息。对于connect这个模型来说,我们只是确定了目的地址的地址和端口号,由于路由的时候并不考虑端口号(端口号是传输层的概念,而IP属于网络层),所以此时只能根据目的IP来确定本地网口的地址。在这个路由表中,我们可以看到其中有两项配置,当然都是我电脑上的唯一一个网卡了。现在如果connect的是一个192.168.203.xx的IP,那么此时它将会选择第一个路由,然后是选择的网口,然后根据网口确定本地地址。而这个默认路由则应该是在一个网卡启动的时候添加的一个默认路由,也就是同一个局域网(localip & netmask == desk&netmask )中的主机可以不经过网关而直接发送。

    源代码中这个实现应该在

    tcp_v4_connect-->>ip_route_connect--->>__ip_route_output_key

    这条路径中实现。

    2、本地端口的选择

    inet_stream_connect-->>>tcp_v4_connect-->>>systcp_v4_connect-->>>inet_hash_connect

    在其中进行了本地端口的选择,这个地方还是一个比较有意思的地方。这里的本地端口选择并不是按照机械的连续选择,而是加入了随机hash值,这个值通过

    inet_sk_port_offset -->>>secure_ipv4_port_ephemeral

    加以扩大,也就是如果想通过系统中的现有端口号计算出下一个端口号的难度还是有一点的。其实对于hash来说,给定确定的输入,它的输出也是确定的,所以这个并不能说是不可预测的。但是在secure_ipv4_port_ephemeral--->>get_keyptr中使用了ip_keydata数组,这个数组却会和系统的随机数一样,会根据硬盘中断、键盘中断、网卡中断等一些不确定的事件来组成一个“熵”,从而使得系统中的这个数值的计算无论是静态还是动态预测不太现实。

    但是这个本地端口的选择也是有一个确定的区间的,并且这个值可以在用户态看到和修改,具体位置为:

    [tsecer@Harry sockport]$ cat /proc/sys/net/ipv4/ip_local_port_range 
    32768 61000
    [tsecer@Harry sockport]$ netstat -anp | more 
    (Not all processes could be identified, non-owned process info
     will not be shown, you would have to be root to see it all.)
    Active Internet connections (servers and established)
    Proto Recv-Q Send-Q Local Address               Foreign Address             State       PID
    /Program name   
    tcp        0      0 0.0.0.0:41417               0.0.0.0:*                   LISTEN      -  
                     
    tcp        0      0 0.0.0.0:111                 0.0.0.0:*                   LISTEN      -  
                     
    tcp        0      0 0.0.0.0:1234                0.0.0.0:*                   LISTEN      769
    4/qemu           
    tcp        0      0 0.0.0.0:22                  0.0.0.0:*                   LISTEN      -  
                     
    tcp        0      0 127.0.0.1:631               0.0.0.0:*                   LISTEN      -  
                     
    tcp        0      0 127.0.0.1:25                0.0.0.0:*                   LISTEN      -  
                     
    tcp        1      1 192.168.203.153:60490       69.22.138.138:80            LAST_ACK    -  

     3、发送第一个报文序列号的确定

    当使用TCP发送的时候,为了实现可靠传输,此时需要为发送的报文确定一个序列号,这个序列号应该是一个连接内是唯一、连续的。同样是在

    tcp_v4_connect函数中,其中通过

     if (!tp->write_seq)
      tp->write_seq = secure_tcp_sequence_number(inet->inet_saddr,
              inet->inet_daddr,
              inet->inet_sport,
              usin->sin_port);

    来为发送的同步消息确定了一个起始报文序列号,这样在报文发送的过程中就有一个确定的报文序列号了,这样可以认为发送的大部分参数都确定了。

    这里并没有确定这个序列号的范围,因为这个序列号只要在一个TCP连接中唯一就好了。

    四、状态同步

    1、connect等待

    这个是在传输层实现的,所以它比上面说的tcp_v4_connect的level要高一些,它是在inet_stream_connect函数中进行同步等待的。在调用tcp_v4_connect函数发送了SYN报文之后,此时该函数就返回了,它没有等待对方的回应,理想状态下,是否等待回应应该是有更高层的传输层来控制,所以inet_stream_ops-->>connect接口inet_stream_connect就责无旁贷了:

    timeo = sock_sndtimeo(sk, flags & O_NONBLOCK);

     if ((1 << sk->sk_state) & (TCPF_SYN_SENT | TCPF_SYN_RECV)) { 此时SYN请求已经发送,这里可以等待对方的回应了。这里等待了两个状态,一个是SYN_SENT,就是SYN已经发送,但是对方还没有回应,SYN_RECV则是对方回应了,但是只回应了一个SYN而没有回应ACK,这连个都不算是连接建立,只有当收到对应的ACK之后才能够进入TCPF_ESTABLISHED
      /* Error code is set above */
      if (!timeo || !inet_wait_for_connect(sk, timeo))
       goto out;

      err = sock_intr_errno(timeo);
      if (signal_pending(current))
       goto out;
     }

    从这里可以看到,这个connect系统调用是可以被信号唤醒的,用户态代码应该准备好对这个情况的处理。并且从

    /* Alas, with timeout socket operations are not restartable.
     * Compare this to poll().
     */
    static inline int sock_intr_errno(long timeo)
    {
     return timeo == MAX_SCHEDULE_TIMEOUT ? -ERESTARTSYS : -EINTR;
    }
    函数看,这个函数的一个好处就是如果被信号中断,并且中断该系统调用的信号处理函数设置了SA_RESTART属性,那么系统可以继续自动执行。

    另外,这个inet_stream_connect-->>inet_wait_for_connect的睡眠位置为

     prepare_to_wait(sk->sk_sleep, &wait, TASK_INTERRUPTIBLE);

    2、等待的唤醒

    当系统收到一个TCP报文的时候,最后将会中转到
    tcp_v4_rcv--->>> tcp_v4_do_rcv --->>>tcp_rcv_state_process--->>>tcp_rcv_synsent_state_process  
     if (th->ack) {
    ……
      tcp_set_state(sk, TCP_ESTABLISHED);
    ……
     if (!sock_flag(sk, SOCK_DEAD)) {
       sk->sk_state_change(sk);
       sk_wake_async(sk, 0, POLL_OUT);
      }   

    现在就是这个sk_state_change函数具体是指向哪里的问题了:

    在socket创建过程中:inet_create-->>sock_init_data--->>>

     sk->sk_state_change = sock_def_wakeup;
    所以在上面的收到ACK报文的时候,此时就会执行sock_def_wakeup函数,
    /*
     * Default Socket Callbacks
     */

    static void sock_def_wakeup(struct sock *sk)
    {
     read_lock(&sk->sk_callback_lock);
     if (sk->sk_sleep && waitqueue_active(sk->sk_sleep))
      wake_up_interruptible_all(sk->sk_sleep);
     read_unlock(&sk->sk_callback_lock);
    }
    这个地方就是将刚才那个在inet_wait_for_connect中等待的苦鳖函数唤醒,进而导致tcp_v4_connect可以开心的返回了,此时这个connect就算执行成功了。

    3、一个细节

    在socket中,有很多的状态,下面的状态是以一对一应的,其中开始的TCP_XXX是真正的sk->state中的值,而后面的TCPF_XXX则是上面状态的一个bit标志的flag(其中的F应该是Flag的缩写)。为什么要这样做呢?可能是为了便于上层方便的判断,对于某些状态,可以有多种状态都可以满足,如上面的connect等待。如果直接使用TCP_XXX,那么就要用 state == TCP_XX || state == TCP_YY 两种判断,但是如果用FLAGS,则TCP_XX||TCP_YY就可以在编译时变成一个常量,从而提高效率。

    enum {
     TCP_ESTABLISHED = 1,
     TCP_SYN_SENT,
     TCP_SYN_RECV,
     TCP_FIN_WAIT1,
     TCP_FIN_WAIT2,
     TCP_TIME_WAIT,
     TCP_CLOSE,
     TCP_CLOSE_WAIT,
     TCP_LAST_ACK,
     TCP_LISTEN,
     TCP_CLOSING, /* Now a valid state */

     TCP_MAX_STATES /* Leave at the end! */
    };

    #define TCP_STATE_MASK 0xF

    #define TCP_ACTION_FIN (1 << 7)

    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) 
    };

  • 相关阅读:
    electron调用c#动态库
    Mybatis使用自定义类型转换Postgresql
    Spring Boot Security And JSON Web Token
    从零开始把项目发布到NPM仓库中心
    从零开始把项目发布到Nuget仓库中心
    从零开始把项目发布到maven仓库中心
    vue项目中如何在外部js中例如utils.js直接调用vue实例及vue上挂在的方法
    vue单页应用在页面刷新时保留状态数据的方法
    Vue watch 监听复杂对象变化,oldvalue 和 newValue 一致的解决办法。
    vue项目的登录跳转代码
  • 原文地址:https://www.cnblogs.com/tsecer/p/10485931.html
Copyright © 2011-2022 走看看