您的位置:首页 > 博客中心 > 网络系统 >

TCP/IP协议栈在Linux内核中的运行时序分析

时间:2022-04-03 16:29

调研要求

  • 在深入理解Linux内核任务调度(中断处理、softirg、tasklet、wq、内核线程等)机制的基础上,分析梳理send和recv过程中TCP/IP协议栈相关的运行任务实体及相互协作的时序分析。
  • 编译、部署、运行、测评、原理、源代码分析、跟踪调试等
  • 应该包括时序图

一、TCP/IP协议介绍

1.1 TCP/IP协议栈

TCP/IP模型是一个抽象的分层模型,这个模型中,所有的TCP/IP系列网络协议都被归类到4个抽象的"层"中。每一抽象层创建在低一层提供的服务上,并且为高一层提供服务。 完成一些特定的任务需要众多的协议协同工作,这些协议分布在参考模型的不同层中的,因此有时称它们为一个协议栈。

技术图片

 

 

TCP/IP协议是Internet最基本的协议,其中应用层的主要协议有FTP、SMTP等,是用来接收来自传输层的数据或者按不同应用要求与方式将数据传输至传输层;传输层的主要协议有UDP、TCP,是使用者使用平台和计算机信息网内部数据结合的通道,可以实现数据传输与数据共享;网络层的主要协议有ICMP、IP、IGMP,主要负责网络中数据包的传送等;而网络访问层,也叫网路接口层或数据链路层,主要协议有ARP、RARP,主要功能是提供链路管理错误检测、对不同通信媒介有关信息细节问题进行有效处理等。

技术图片

 

1.2 Linux网络协议栈

Linux的整个网络协议栈都构建与Linux Kernel中,整个栈也是严格按照分层的思想来设计的,整个栈共分为五层,分别是 :

    1、系统调用接口层,实质是一个面向用户空间应用程序的接口调用库,向用户空间应用程序提供使用网络服务的接口。

    2、协议无关的接口层,就是SOCKET层,这一层的目的是屏蔽底层的不同协议(更准确的来说主要是TCP与UDP,当然还包括RAW IP, SCTP等),以便与系统调用层之间的接口可以简单,统一。简单的说,不管我们应用层使用什么协议,都要通过系统调用接口来建立一个SOCKET,这个SOCKET其实是一个巨大的sock结构,它和下面一层的网络协议层联系起来,屏蔽了不同的网络协议的不同,只吧数据部分呈献给应用层(通过系统调用接口来呈献)。

    3、网络协议实现层,毫无疑问,这是整个协议栈的核心。这一层主要实现各种网络协议,最主要的当然是IP,ICMP,ARP,RARP,TCP,UDP等。这一层包含了很多设计的技巧与算法,相当的不错。

    4、与具体设备无关的驱动接口层,这一层的目的主要是为了统一不同的接口卡的驱动程序与网络协议层的接口,它将各种不同的驱动程序的功能统一抽象为几个特殊的动作,如open,close,init等,这一层可以屏蔽底层不同的驱动程序。

    5、驱动程序层,建立与硬件的接口层。

 

1.2 socket

  Socket是应用层与TCP/IP协议族通信的中间软件抽象层,它是一组接口。在设计模式中,Socket其实就是一个门面模式,它把复杂的TCP/IP协议族隐藏在Socket接口后面,对用户来说,一组简单的接口就是全部,让Socket去组织数据,以符合指定的协议。

  Unix/Linux基本哲学之一就是“一切皆文件”,socket即是一种特殊的文件,一些socket函数就是对其进行的操作(读/写IO、打开、关闭)。

技术图片

 

 

  基本socket()函数

  Linux系统是通过提供套接字(socket)来进行网络编程的。网络的socket数据传输是一种特殊的I/O,socket也是一种文件描述符。socket也有一个类似于打开文件的函数:socket(),调用socket(),该函数返回一个整型的socket的描述符,随后的连接建立、数据传输等操作也都是通过该socket实现。

  1、socket函数    int socket(int domain, int type, int protocol);

        功能:调用成功,返回socket文件描述符;失败,返回-1,并设置errno

  2、bind函数    int bind(int sock_fd,struct sockaddr_in *my_addr, int addrlen);
  功能:将套接字和指定的端口相连。成功返回0,否则,返回-1,并置errno.

  3、connect函数     int connect(int sock_fd, struct sockaddr *serv_addr,int addrlen);

    功能:客户端发送服务请求。成功返回0,否则返回-1,并置errno。

  4、listen函数    int listen(int sock_fd, int backlog);
    功能:等待指定的端口的出现客户端连接。调用成功返回0,否则,返回-1,并置errno.

  5、accecpt函数    int accept(int sock_fd, struct sockadd_in* addr, int addrlen);

    功能:用于接受客户端的服务请求,成功返回新的套接字描述符,失败返回-1,并置errno。

  6、write函数    ssize_t write(int fd,const void *buf,size_t nbytes)

    功能:write函数将buf中的nbytes字节内容写入文件描述符fd.成功时返回写的字节数.失败时返回-1. 并设置errno变量。

  7、read函数    ssize_t read(int fd,void *buf,size_t nbyte)

    功能: read函数是负责从fd中读取内容.当读成功时,read返回实际所读的字节数,如果返回的值是0 表示已经读到文件的结束了,小于0表示出现了错误.。如果错误为EINTR说明读是由中断引起的,

如果错误是ECONNREST表示网络连接出了问题.。

  8、close函数    int close(sock_fd)

    功能:当所有的数据操作结束以后,你可以调用close()函数来释放该socket,从而停止在该socket上的任何数据操作。

  运行成功返回0,否则返回-1。

 二、调试环境及测试代码

调试版本:linux-5.4.34

虚拟机环境:ubuntu-18.04.5 64位      ;      QEMU

服务端代码:server.c

#include <stdio.h>

#include <sys/types.h>

#include <sys/socket.h>

#include <unistd.h>

#include <string.h>

#include <netinet/in.h>

#include <pthread.h>

#include <stdlib.h>

void* fun(void* arg)

{

    int fd;

    char sendbuf[64];

    fd = *(int*)arg;

    printf("Please input:\n");

    while (1)

    {

        memset(sendbuf, 0, sizeof(sendbuf));

        scanf("%s", sendbuf);

        write(fd, sendbuf, strlen(sendbuf));

        if (!strncmp(sendbuf, "quit", strlen("quit")))

        {

            close(fd);

            exit(0);

        }

    }

    return NULL;

}



int main()

{

    int socket_fd, client_fd;

    int ret, len;

    char recvbuf[64];

    struct sockaddr_in server_addr, client_addr;

    pthread_t tid;

    socket_fd = socket(PF_INET, SOCK_STREAM, 0);



    if (socket_fd < 0)

    {

        perror("Error");

        return -1;

    }



    server_addr.sin_family = AF_INET;

    server_addr.sin_addr.s_addr = INADDR_ANY;

    server_addr.sin_port = htons(33333);



    ret = bind(socket_fd, (struct sockaddr*)&server_addr, sizeof(server_addr));

    if (ret < 0)

    {

        perror("Error");

        return -1;

    }



    listen(socket_fd, 5);



    len = sizeof(client_addr);

    client_fd = accept(socket_fd, (struct sockaddr*)&client_addr, &len);

    if (client_fd < 0)

    {

        perror("Error");

        return -1;

    }

    pthread_create(&tid, NULL, fun, (void*)&client_fd);

    while (1)

    {

        memset(recvbuf, 0, sizeof(recvbuf));

        read(client_fd, recvbuf, sizeof(recvbuf));

        if (!strncmp(recvbuf, "quit", strlen("quit")))

        {

            pthread_cancel(tid);

            break;

        }

        printf("recv: %s\n", recvbuf);

    }



    close(client_fd);

    close(socket_fd);

    return 0;

}

客户端代码:client.c

#include <stdio.h>

#include <sys/types.h>

#include <sys/socket.h>

#include <netinet/in.h>

#include <unistd.h>

#include <string.h>

#include <pthread.h>

#include <stdlib.h>

void* fun(void* arg)

{

    int fd;

    char sendbuf[64];

    fd = *(int*)arg;

    printf("Please input:\n");

    while (1)

    {

        memset(sendbuf, 0, sizeof(sendbuf));

        scanf("%s", sendbuf);

        write(fd, sendbuf, strlen(sendbuf));

        if (!strncmp(sendbuf, "quit", strlen("quit")))

        {

            close(fd);

            exit(0);

        }

    }

    return NULL;

}



int main()

{

    int socket_fd;

    struct sockaddr_in server_addr;

    char recvbuf[64];

    int ret;

    pthread_t tid;



    socket_fd = socket(PF_INET, SOCK_STREAM, 0);

    if (socket_fd < 0)

    {

        perror("Error");

        return -1;

    }



    server_addr.sin_family = AF_INET;

    server_addr.sin_port = htons(33333);

    server_addr.sin_addr.s_addr = inet_addr("127.0.0.1");



    ret = connect(socket_fd, (struct sockaddr*)&server_addr, sizeof(server_addr));

    if (ret < 0)

    {

        perror("Error");

        return -1;

    }



    ret = pthread_create(&tid, NULL, fun, (void*)&socket_fd);

    if (ret != 0)

    {

        perror("Error");

        return -1;

    }

    while (1)

    {

        memset(recvbuf, 0, sizeof(recvbuf));

        read(socket_fd, recvbuf, sizeof(recvbuf));

        if (!strncmp(recvbuf, "quit", strlen("quit")))

        {

            pthread_cancel(tid);

            break;

        }

        printf("recv: %s\n", recvbuf);

    }

    close(socket_fd);

    return 0;

}

 

技术图片

 

技术图片

 

 

 三、具体流程

3.1 socket

Socket结构如下:

// socket_state: socket 状态
// type: socket type 
// flags: socket flags 
// ops: 专用协议的socket的操作
// file: 与socket 有关的指针列表
// sk: 负责协议相关结构体,这样就让这个这个结构体和协议分开。
// wq: 等待队列
struct socket {  
    socket_state        state;                                                  
?
    kmemcheck_bitfield_begin(type);                                             
    short           type;                                                       
    kmemcheck_bitfield_end(type);                                               
?
    unsigned long       flags;                                                  
?
    struct socket_wq __rcu  *wq;                                                
?
    struct file     *file;                                                      
    struct sock     *sk;                                                        
    const struct proto_ops  *ops;                                               
};

网络应用调用Socket API socket (int family, int type, int protocol) 创建一个 socket,该调用最终会调用 Linux system call socket() ,并最终调用 Linux Kernel 的 sock_create() 方法。该方法返回被创建好了的那个 socket 的 file descriptor。对于每一个 userspace 网络应用创建的 socket,在内核中都有一个对应的 struct socket和 struct sock。其中,struct sock 有三个队列(queue),分别是 rx , tx 和 err,在 sock 结构被初始化的时候,这些缓冲队列也被初始化完成;在收据收发过程中,每个 queue 中保存要发送或者接受的每个 packet 对应的 Linux 网络栈 sk_buffer 数据结构的实例 skb。

函数调用图如下:

技术图片

 

 3.2 send和recv函数介绍

ssize_t recv(int sockfd, void *buff, size_t nbytes, int flags);
ssize_t send(int sockfd, const void *buff, size_t nbytes, int flags);
  1. send函数

    sockfd:指定发送端套接字描述符。

    buff: 存放要发送数据的缓冲区

    nbytes: 实际要改善的数据的字节数

    flags: 一般设置为0

  1. send先比较发送数据的长度nbytes和套接字sockfd的发送缓冲区的长度,如果nbytes > 套接字sockfd的发送缓冲区的长度, 该函数返回SOCKET_ERROR;

  2. 如果nbtyes <= 套接字sockfd的发送缓冲区的长度,那么send先检查协议是否正在发送sockfd的发送缓冲区中的数据,如果是就等待协议把数据发送完,如果协议还没有开始发送sockfd的发送缓冲区中的数据或者sockfd的发送缓冲区中没有数据,那么send就比较sockfd的发送缓冲区的剩余空间和nbytes

  3. 如果 nbytes > 套接字sockfd的发送缓冲区剩余空间的长度,send就一起等待协议把套接字sockfd的发送缓冲区中的数据发送完

  4. 如果 nbytes < 套接字sockfd的发送缓冲区剩余空间大小,send就仅仅把buf中的数据copy到剩余空间里(注意并不是send把套接字sockfd的发送缓冲区中的数据传到连接的另一端的,而是协议传送的,send仅仅是把buf中的数据copy到套接字sockfd的发送缓冲区的剩余空间里)。

  5. 如果send函数copy成功,就返回实际copy的字节数,如果send在copy数据时出现错误,那么send就返回SOCKET_ERROR; 如果在等待协议传送数据时网络断开,send函数也返回SOCKET_ERROR。

  6. send函数把buff中的数据成功copy到sockfd的改善缓冲区的剩余空间后它就返回了,但是此时这些数据并不一定马上被传到连接的另一端。如果协议在后续的传送过程中出现网络错误的话,那么下一个socket函数就会返回SOCKET_ERROR。(每一个除send的socket函数在执行的最开始总要先等待套接字的发送缓冲区中的数据被协议传递完毕才能继续,如果在等待时出现网络错误那么该socket函数就返回SOCKET_ERROR)

  7. 在unix系统下,如果send在等待协议传送数据时网络断开,调用send的进程会接收到一个SIGPIPE信号,进程对该信号的处理是进程终止。

  1. recv函数

    sockfd: 接收端套接字描述符

    buff: 用来存放recv函数接收到的数据的缓冲区

    nbytes: 指明buff的长度

    flags: 一般置为0

  1. recv先等待s的发送缓冲区的数据被协议传送完毕,如果协议在传送sock的发送缓冲区中的数据时出现网络错误,那么recv函数返回SOCKET_ERROR

  2. 如果套接字sockfd的发送缓冲区中没有数据或者数据被协议成功发送完毕后,recv先检查套接字sockfd的接收缓冲区,如果sockfd的接收缓冲区中没有数据或者协议正在接收数据,那么recv就一起等待,直到把数据接收完毕。当协议把数据接收完毕,recv函数就把s的接收缓冲区中的数据copy到buff中(注意协议接收到的数据可能大于buff的长度,所以在这种情况下要调用几次recv函数才能把sockfd的接收缓冲区中的数据copy完。recv函数仅仅是copy数据,真正的接收数据是协议来完成的)

  3. recv函数返回其实际copy的字节数,如果recv在copy时出错,那么它返回SOCKET_ERROR。如果recv函数在等待协议接收数据时网络中断了,那么它返回0。

  4. 在unix系统下,如果recv函数在等待协议接收数据时网络断开了,那么调用 recv的进程会接收到一个SIGPIPE信号,进程对该信号的默认处理是进程终止。

3.3 send过程的内核实现

  client/server 程序运行后,执行socket通信过程,使用send系统调用发送数据,依次经过应用层、传输层、网络层、数据链路层封装。

3.3.1  应用层

1)网络应用调用Socket API socket (int family, int type, int protocol) 创建一个 socket,该调用最终会调用 Linux system call socket() ,并最终调用 Linux Kernel 的 sock_create() 方法。该方法返回被创建好了的那个 socket 的 file descriptor。对于每一个 userspace 网络应用创建的 socket,在内核中都有一个对应的 struct socket和 struct sock。其中,struct sock 有三个队列(queue),分别是 rx , tx 和 err,在 sock 结构被初始化的时候,这些缓冲队列也被初始化完成;在收据收发过程中,每个 queue 中保存要发送或者接受的每个 packet 对应的 Linux 网络栈 sk_buffer 数据结构的实例 skb。

2)对于 TCP socket 来说,应用调用 connect()API ,使得客户端和服务器端通过该 socket 建立一个虚拟连接。在此过程中,TCP 协议栈通过三次握手会建立 TCP 连接。默认地,该 API 会等到 TCP 握手完成连接建立后才返回。在建立连接的过程中的一个重要步骤是,确定双方使用的 Maxium Segemet Size (MSS)。因为 UDP 是面向无连接的协议,因此它是不需要该步骤的。

3)应用调用 Linux Socket 的 send 或者 write API 来发出一个 message 给接收端

4) sock_sendmsg 被调用,它使用 socket descriptor 获取 sock struct,创建 message header 和 socket control message

5) _sock_sendmsg 被调用,根据 socket 的协议类型,调用相应协议的发送函数。TCP 调用 tcp_sendmsg 函数, UDP调用send()/sendto()/sendmsg() 三个 system call 中的任意一个来发送 UDP message,它们最终都会调用内核中的 udp_sendmsg() 函数。

技术图片

 

 3.3.2 传输层

tcp_sendmsg实际上调用的是tcp_sendmsg_locked函数。

技术图片

 

   在tcp_sendmsg_locked中,完成的是将所有的数据组织成发送队列,这个发送队列是struct sock结构中的一个域sk_write_queue,这个队列的每一个元素是一个skb,里面存放的就是待发送的数据。然后调用了tcp_push()函数。

  __tcp_transmit_skb是tcp发送数据位于传输层的最后一步,这里首先对TCP数据段的头部进行了处理,然后调用了网络层提供的发送接口icsk->icsk_af_ops->queue_xmit(sk, skb, &inet->cork.fl);实现了数据的发送,自此,数据离开了传输层,传输层的任务也就结束了。

 技术图片

 

 3.3.3网络层

  入口函数是 ip_queue_xmit ,ip_queue_xmit是 ip 层提供给 tcp 层发送回调函数。ip_queue_xmit() 完成面向连接套接字的包输出,当套接字处于连接状态时,所有从套接字发出的包都具有确定的路由, 无 需为每一个输出包查询它的目的入口,可将套接字直接绑定到路由入口上, 这由套接字的目的缓冲指针 (dst_cache)来完成。ip_queue_xmit()首先为输入包建立IP包头, 经过本地包过滤器后,再将IP包分片输出 (ip_fragment)。

 

 

技术图片

 

   路由查询从fib_lookup函数开始,之后调用fib_table_lookup函数,函数中加锁进行同步控制,互斥访问fib_table路由表数据结构,得到的路由查询结果以fib_result数据结构返回。

技术图片

 

 3.3.4 数据链路层

  发送端调用__dev_queue_xmit:

 

技术图片

 

   __dev_queue_xmit会调用dev_hard_start_xmit函数获取skb。

  在xmit_one中调用__net_dev_start_xmit函数。一旦网卡完成报文发送,将产生中断通知 CPU,然后驱动层中的中断处理程序就可以删 除保存的 skb 。

 

3.4 recv过程的内核实现

3.4.1 应用层

1)每当用户应用调用 read 或者 recvfrom 时,该调用会被映射为/net/socket.c 中的 sys_recv 系统调用,并被转化为 sys_recvfrom 调用,然后调用 sock_recgmsg 函数。

2)对于 INET 类型的 socket,/net/ipv4/af inet.c 中的 inet_recvmsg 方法会被调用,它会调用相关协议的数据接收方法。

3)对 TCP 来说,调用 tcp_recvmsg。该函数从 socket buffer 中拷贝数据到 user buffer。

技术图片

 

 3.4.2 传输层

  为了将输入数据包传送给传输层正确的协议处理函数,输入数据包使用的传输层协议在IP层处理时已设定。在传输层,各协议输入处理函数在协议初始化时注册到内核TCP/IP协议栈接口,IP层通过调用ip_local_deliver函数在IP数据包协议头 iphdr->protocol数据域中设定的值,在传输层与IP层之间管理协议处理接口的哈希链表inet_protocol中查询,找到正确的传输层协议处理函数块,并上传数据包。

?   对于TCP协议,它在inet _protocol结构中初始化的输入数据包处理函数是tcp_v4_rcv。tcp_v4_rcv函数为TCP的总入口,数据包从IP层传递上来,进入该函数;其协议操作函数结构如下所示,其中handler即为IP层向TCP传递数据包的回调函数,设置为tcp_v4_rcv;

static struct net_protocol tcp_protocol = {
    .early_demux    =    tcp_v4_early_demux,
    .early_demux_handler =  tcp_v4_early_demux,
    .handler    =    tcp_v4_rcv,
    .err_handler    =    tcp_v4_err,
    .no_policy    =    1,
    .netns_ok    =    1,
    .icmp_strict_tag_validation = 1,
};

  tcp_v4_rcv函数只要做以下几个工作:

    (1) 设置TCP_CB

    (2) 查找控制块

    (3)根据控制块状态做不同处理,包括TCP_TIME_WAIT状态处理,TCP_NEW_SYN_RECV状态处理,TCP_LISTEN状态处理

    (4) 接收TCP段;

技术图片

 

 

  之后,调用的也就是__sys_recvfrom,整个函数的调用路径与send非常类似。整个函数实际调用的是sock->ops->recvmsg(sock, msg, msg_data_left(msg), flags),同样,根据tcp_prot结构的初始化,调用的其实是tcp_rcvmsg .接受函数比发送函数要复杂得多,因为数据接收不仅仅只是接收,tcp的三次握手也是在接收函数实现的,所以收到数据后要判断当前的状态,是否正在建立连接等,根据发来的信息考虑状态是否要改变,在这里,我们仅仅考虑在连接建立后数据的接收。

技术图片

 

   这里共维护了三个队列:prequeuebacklogreceive_queue,分别为预处理队列,后备队列和接收队列,在连接建立后,若没有数据到来,接收队列为空,进程会在sk_busy_loop函数内循环等待,知道接收队列不为空,并调用函数数skb_copy_datagram_msg将接收到的数据拷贝到用户态,实际调用的是__skb_datagram_iter,这里同样用了struct msghdr *msg来实现。

技术图片

 

 3.4.3 网络层

  IP 层的入口函数在 ip_rcv 函数。该函数首先会做包括 package checksum 在内的各种检查,如果需 要的话会做 IP defragment(将多个分片合并),然后 packet 调用已经注册的 Pre-routing netfilter hook ,完成后最终到达 ip_rcv_finish 函数。ip_rcv_finish 函数会调用 ip_router_input 函数,进入 路由处理环节。它首先会调用 ip_route_input 来更新路由,然后查找 route,决定该 package 将会 被发到本机还是会被转发还是丢弃。

 

   技术图片

 

 3.4.4 数据链路层

  1. 包到达机器的物理网卡时候触发一个中断,并将通过DMA传送到位于 linux kernel 内存中的rx_ring。中断处理程序分配 skb_buff 数据结构,并将接收到的数据帧从网络适配器I/O端口拷贝到skb_buff 缓冲区中,并设置 skb_buff 相应的参数,这些参数将被上层的网络协议使用,例如skb->protocol;

  2. 然后发出一个软中断(NET_RX_SOFTIRQ,该变量定义在include/linux/interrupt.h 文件中),通知内核接收到新的数据帧。进入软中断处理流程,调用 net_rx_action 函数。包从 rx_ring 中被删除,进入 netif _receive_skb 处理流程。

  3. netif_receive_skb根据注册在全局数组 ptype_all 和 ptype_base 里的网络层数据报类型,把数据报递交给不同的网络层协议的接收函数(INET域中主要是ip_rcv和arp_rcv)。

  接受数据的入口函数是net_rx_action:

技术图片

 

 四、时序图

技术图片

 

本类排行

今日推荐

热门手游