TCP/IP 协议栈在 Linux 内核中的 运行时序分析
调研要求
1.在深入理解Linux内核任务调度(中断处理、softirg、tasklet、wq、内核线程等)机制的基础上,分析梳理send和recv过程中TCP/IP协议栈相关的运行任务实体及相互协作的时序分析。
2.编译、部署、运行、测评、原理、源代码分析、跟踪调试等。
3.应该包括时序图。
1.Linux概述
1.1Linux操作系统架构简介
Linux系统一般有4个主要部分:内核、shell、文件系统和应用程序。内核、shell和文件系统一起形成了基本的操作系统结构,它们使得用户可以运行程序、管理文件并使用系统。
内核是操作系统的核心,具有很多最基本功能,如虚拟内存、多任务、共享库、需求加载、可执行程序和TCP/IP网络功能。Linux内核的模块分为以下几个部分:存储管理、CPU和进程管理、文件系统、设备管理和驱动、网络通信、中断处理、系统的初始化和系统调用等。而本次调研内容就是Linux5.4.34内核中的下图所示的蓝色部分:TCP/IP协议栈。
1.2协议栈简介
一个主流的清晰的网络分层模型是OSI七层模型。如下图所示,它自上而下由应用层,表示层,会话层,传输层,网络层,数据链路层,物理层组成。但是这种分层只适用于学习,而不适用于实践。
生活中更多的是使用TCP/IP网络分层模型,这种模型分为四层,分别为应用层,传输层,网络接口层,接口层。
1.3 Linux内核协议栈的实现
每一类软件模型都不能独立存在,必定依托系统其他模块才可以工作,协议栈也是如此,Linux协议栈是在内核中实现的,具体支持方式如图所示。在用户空间最上层的APP就是应用程序,这些应用可以使用glibc库,这个库里封装了socket,bind,recv,send等函数,这些函数会使用相应的系统调用。然后是INET模块,它并不是TCP/IP体系必须的一部分,但是TCP/IP层的接口都要通过这个模块才可以访问操纵,这一操作也是在网络初始化的时候就已经注册到socket层的。再往下就是整个TCP/IP协议栈的部分,由TCP、IP、Routing System、ICMP、 ARP、Driver模块组成。
2.send和recv应用层流程
2.1本次调研采取的测试代码
服务器端代码:
客户端代码:
可以简化为下图所示:
客户端和服务器端交互具体流程如下:
2.2 socket
应用层的各种网络应用程序基本上都是通过 Linux Socket 编程接口来和内核空间的网络协议栈通信的。Linux Socket 是Linux 操作系统的重要组成部分之一,是网络应用程序的基础。从层次上来说,它位于应用层,位于传输层协议之上,屏蔽了不同网络协议之间的差异,同时也是网络编程的入口,它提供了大量的系统调用,构成了网络程序的主体。
在Linux系统中,socket 属于文件系统的一部分,网络通信可以被看作是对文件的读取,使得对网络的控制和对文件的控制一样方便。
2.2.1 socket的创建
在内核中与socket对应的系统调用是sys_soceket,所谓的创建套接口,就是在sockfs这个文件系统中创建一个节点,从Linux/Unix的角度来看,该节点是一个文件,不过这个文件具有非普通文件的属性,于是起了--个独特的名字socket。由于sockfs文件系统是系统初始化时就保存在全局指针sock_mnt中的,所以申请一个inode的过程便以sock_mnt为参数。
socket函数本身,经过glibc库对其封装,它将通过int 0x80产生-一个软件中断(注意不是软中断),由内核导向执行sys_socket,基本上参数会原封不动地传入内核,它们分别是(1) int family,(2) int type, (3) int protocol。
其调用树如下图:
在sock_create中会继续调用sock_alloc,该函数创建了struct socket{}结构。
通过调用树可知sock_alloc的作用就是分配和初始化属于网络类型的inode。
inode结构中的大部分字段只是对真正的文件系统重要,只有一部分由socket使用。Socket_alloc类型的Inode结构如下:
这里socket socket 部分就是特定文件的数据。Socket{}结构就表示这是一个和网络有关的文件描述符,是INET层和应用层打开的文件描述一一对应的实体,每一次调用socket都会在INET中保存这么一个实体。
2.2.2 socket的发送
在创建完socket之后,应用程序会使用send发送数据。在用户态调用系统接口send、sendto或者sendmsg都是在调用sock_sendmsg。其中send调用的是sendto,只是参数略有不同。
这里定义了一个struct msghdr msg,他是用来表示要发送的数据的一些属性。
还有一个struct iovec,他被称为io向量,故名思意,用来表示io数据的一些信息。
所以,__sys_sendto函数其实做了3件事:
1.通过fd获取了对应的struct socket
2.创建了用来描述要发送的数据的结构体struct msghdr。
3.调用了sock_sendmsg来执行实际的发送。
继续追踪这个函数,会看到最终调用的是inet_sendmsg
这里间接调用了tcp_sendmsg即传送到传输层。
调试验证:
2.2.3 socket的接收
对于recv函数,与send类似,自然也是recvfrom的特殊情况,调用的也就是__sys_recvfrom,整个函数的调用路径与send非常类似:
sock->ops->recvmsg即inet_recvmsg,最后在inet_recvmsg中调用的是tcp_recvmsg
调试验证:
3.send和recv传输层流程
3.1 tcp发送数据
tcp_sendmsg实际上调用的是int tcp_sendmsg_locked(struct sock *sk, struct msghdr *msg, size_t size)
在tcp_sendmsg_locked中,完成的是将所有的数据组织成发送队列,这个发送队列是struct sock结构中的一个域sk_write_queue,这个队列的每一个元素是一个skb,里面存放的就是待发送的数据。然后调用了tcp_push()函数。
在tcp协议的头部有几个标志字段:URG、ACK、RSH、RST、SYN、FIN,tcp_push中会判断这个skb的元素是否需要push,如果需要就将tcp头部字段的push置一,置一的过程如下:
然后,tcp_push调用了__tcp_push_pending_frames(sk, mss_now, nonagle);函数发送数据:
随后又调用了tcp_write_xmit来发送数据:
若发送队列未满,则准备发送报文
检查发送窗口的大小
tcp_write_xmit位于tcpoutput.c中,它实现了tcp的拥塞控制,然后调用了tcp_transmit_skb(sk, skb, 1, gfp)传输数据,实际上调用的是__tcp_transmit_skb
构建TCP头部和校验和
tcp_transmit_skb是tcp发送数据位于传输层的最后一步,这里首先对TCP数据段的头部进行了处理,然后调用了网络层提供的发送接口icsk->icsk_af_ops->queue_xmit(sk, skb, &inet->cork.fl);实现了数据的发送,自此,数据离开了传输层,传输层的任务也就结束了。
调试验证:
3.2 tcp接收数据
接收函数比发送函数要复杂得多,因为数据接收不仅仅只是接收,tcp的三次握手也是在接收函数实现的,所以收到数据后要判断当前的状态,是否正在建立连接等,根据发来的信息考虑状态是否要改变,在这里仅仅考虑在连接建立后数据的接收。
首先从上向下分析,即上一层中调用了tcp_recvmsg。
该函数完成从接收队列中读取数据复制到用户空间的任务;函数在执行过程中会锁定控制块,避免软中断在tcp层的影响;函数会涉及从接收队列receive_queue和后备队列backlog中读取数据;其中从backlog中读取的数据,还需要经过sk_backlog_rcv回调,该回调的实现为tcp_v4_do_rcv,实际上是先缓存到队列中,然后需要读取的时候,才进入协议栈处理,此时,是在进程上下文执行的,因为会设置tp->ucopy.task=current,在协议栈处理过程中,会直接将数据复制到用户空间。
这里的copied表示已经复制了多少字节,target表示目标是多少字节。
在连接建立后,若没有数据到来,接收队列为空,进程会在sk_busy_loop函数内循环等待。Lock_sock()传输层上锁,避免软中断影响 。
获得数据后,遍历接收队列,找到满足读取的skb
并调用函数skb_copy_datagram_msg将接收到的数据拷贝到用户态,实际调用的是__skb_datagram_iter,这里同样用了struct msghdr *msg来实现。
以上是对数据进行拷贝。
如果copied>0,即读取到数据则继续,否则的话,也就是没有读到想要的数据,[当设置了nonblock时,(表现在timeo=0)],就返回-EAGAIN,也就是非阻塞方式。
如果目标数据读取完,则处理后备队列。但是如果没有设置nonblock,同时也没有出现copied >= target的情况,也就是没有读到足够多的数据,则调用sk_wait_data将当前进程等待。也就是我们希望的阻塞方式。阻塞函数sk_wait_data所做的事情就是让出CPU,等数据来了或者设定超时之后再恢复运行。
然后从下向上分析,即tcp层是如何接收来自ip的数据并且插入相应队列的。
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,
};
在IP层处理本地数据包时,会获取到上述结构的实例,并且调用实例的handler回调,也就是调用了tcp_v4_rcv;
tcp_v4_rcv函数只要做以下几个工作:(1) 设置TCP_CB (2) 查找控制块 (3)根据控制块状态做不同处理,包括TCP_TIME_WAIT状态处理,TCP_NEW_SYN_RECV状态处理,TCP_LISTEN状态处理 (4) 接收TCP段;
tcp_v4_rcv判断状态为listen时会直接调用tcp_v4_do_rcv;如果是其他状态,将TCP包投递到目的套接字进行接收处理。如果套接字未被上锁则调用tcp_v4_do_rcv。当套接字正被用户锁定,TCP包将暂时排入该套接字的后备队列(sk_add_backlog)。
Tcp_v4_do_ecv检查状态如果是established,就调用tcp_rcv_established函数。
tcp_rcv_established用于处理已连接状态下的输入,处理过程根据首部预测字段分为快速路径和慢速路径。
在快路中,若无数据,则处理输入ack,释放该skb,检查是否有数据发送,有则发送;
若有数据,则使用tcp_queue_rcv()将数据加入到接收队列中。
加入方式包括合并到已有数据段,或者加入队列尾部。
回到快路中继续进行tcp_ack()处理ack , tcp_data_snd_check(sk)检查是否有数据要发送,需要则发送,__tcp_ack_snd_check(sk, 0)检查是否有ack要发送,需要则发送.
kfree_skb_partial(skb, fragstolen) skb已经复制到用户空间,则释放之。
唤醒用户进程通知有数据可读。
在慢路中,会进行更详细的校验,然后处理ack,处理紧急数据,接收数据段。
其中数据段可能包含乱序的情况,如果他是有序的就调用tcp_queue_rcv()将数据加入到接收队列中,无序的就放入无序队列中tcp_ofo_queue。最后tcp_data_ready唤醒用户进程通知有数据可读。
调试验证:
4.send和recv网络层流程
4.1 ip发送数据
ip_queue_xmit是ip层提供给tcp层发送回调,大多数tcp发送都会使用这个回调,tcp层使用tcp_transmit_skb封装了tcp头之后调用该函数。
Ip_queue_xmit实际上是调用__ip_queue_xmit。
Skb_rtable(skb)获取skb中的路由缓存,然后判断是否有缓存,如果有缓存就直接进行packet_routed。
如果没有路由缓存就ip_route_output_ports查找路由缓存,在之后封装ip头和ip选项的功能。
最后调用ip_local_out发送数据包
调用__ip_local_out。
经过netfilter的LOCAL_OUT钩子点进行检查过滤,如果通过,则调用dst_output函数,实际上调用的是ip数据包输出函数ip_output。
里面调用ip_finish_output。
实际上调用的是__ip_finish_output,如果需要分片就调用ip_fragment,否则直接调用ip_finish_output2。
在构造好ip头,检查完分片之后,会调用邻居子系统的输出函数neigh_output进行输出。
输出分为有二层头缓存和没有两种情况,有缓存时调用neigh_hh_output进行快速输出,没有缓存时,则调用邻居子系统的输出回调函数进行慢速输出。
最后调用dev_queue_xmit向链路层发送数据包。
调试验证:
4.2 ip接收数据
IP 层的入口函数在 ip_rcv 函数。
然后调用已经注册的 Pre-routing netfilter hook ,完成后最终到达 ip_rcv_finish 函数。
如果是发到本机就调用dst_input,里面由ip_local_deliver函数。
判断是否分片,如果有分片就ip_defrag()进行合并多个数据包的操作,没有分片就调用ip_local_deliver_finish()。
进一步调用ip_protocol_deliver_rcu,该函数根据 package 的下一个处理层的 protocal number,调用下一层接口,包括 tcp_v4_rcv (TCP), udp_rcv (UDP)。对于 TCP 来说,函数 tcp_v4_rcv 函数会被调用,从而处理流程进入 TCP 栈。
调试验证:
5.send和recv链路层和物理层流程
5.1 发送数据
上层调用dev_queue_xmit进入链路层的处理流程,实际上调用的是__dev_queue_xmit
调用dev_hard_start_xmit
然后调用 xmit_one
调用netdev_start_xmit,实际上是调用__netdev_start_xmit
调用各网络设备实现的ndo_start_xmit回调函数指针,其为数据结构struct net_device,从而把数据发送给网卡,物理层在收到发送请求之后,通过 DMA 将该主存中的数据拷贝至内部RAM(buffer)之中。在数据拷贝中,同时加入符合以太网协议的相关header,IFG、前导符和CRC。对于以太网网络,物理层发送采用CSMA/CD,即在发送过程中侦听链路冲突。
一旦网卡完成报文发送,将产生中断通知CPU,然后驱动层中的中断处理程序就可以删除保存的 skb 了。
调试验证:
5.2 接受数据
这层的数据接收要涉及到一些中断和硬件层面的东西。
1: 数据包从外面的网络进入物理网卡。如果目的地址不是该网卡,且该网卡没有开启混杂模式,该包会被网卡丢弃。
2: 网卡将数据包通过DMA的方式写入到指定的内存地址,该地址由网卡驱动分配并初始化。注: 老的网卡可能不支持DMA,不过新的网卡一般都支持。
3: 网卡通过硬件中断(IRQ)通知CPU,告诉它有数据来了
4: CPU根据中断表,调用已经注册的中断函数,这个中断函数会调到驱动程序(NIC Driver)中相应的函数
5: 驱动先禁用网卡的中断,表示驱动程序已经知道内存中有数据了,告诉网卡下次再收到数据包直接写内存就可以了,不要再通知CPU了,这样可以提高效率,避免CPU不停的被中断。
6: 启动软中断。这步结束后,硬件中断处理函数就结束返回了。由于硬中断处理程序执行的过程中不能被中断,所以如果它执行时间过长,会导致CPU没法响应其它硬件的中断,于是内核引入软中断,这样可以将硬中断处理函数中耗时的部分移到软中断处理函数里面来慢慢处理。
软中断会触发内核网络模块中的软中断处理函数,内核中的ksoftirqd进程专门负责软中断的处理,当它收到软中断后,就会调用相应软中断所对应的处理函数,对于上面第6步中是网卡驱动模块抛出的软中断,ksoftirqd会调用网络模块的net_rx_action函数。
net_rx_action调用网卡驱动里的naqi_poll函数来一个一个的处理数据包。在poll函数中,驱动会一个接一个的读取网卡写到内存中的数据包,内存中数据包的格式只有驱动知道。驱动程序将内存中的数据包转换成内核网络模块能识别的skb格式,然后调用napi_gro_receive函数。
napi_gro_receive会直接调用netif_receive_skb_core。
netif_receive_skb_core调用 __netif_receive_skb_one_core,将数据包交给上层ip_rcv进行处理。
待内存中的所有数据包被处理完成后(即poll函数执行完成),启用网卡的硬中断,这样下次网卡再收到数据的时候就会通知CPU。
调试验证:
6.时序图展示
参 考
[1] https://www.cnblogs.com/sammyliu/p/5225623.html
[2] https://www.cnblogs.com/myguaiguai/p/12069485.html
[3] https://blog.csdn.net/weixin_43414275/article/details/106425587
[4] https://www.cnblogs.com/wanpengcoder/p/11752173.html
[5] 电子书《linux内核协议栈源码解析》
[6] 源代码的编译和运行根据课上ppt进行操作
[7] 报告中所用部分图和时序图使用app.diagrams制作