TCP/IP协议栈在Linux内核中的运行时序分析
调研要求
-
在深入理解Linux内核任务调度(中断处理、softirg、tasklet、wq、内核线程等)机制的基础上,分析梳理send和recv过程中TCP/IP协议栈相关的运行任务实体及相互协作的时序分析。
-
-
应该包括时序图
基础概念
Linux操作系统架构
Linux系统一般有4个主要部分:内核、shell、文件系统和应用程序。内核、shell和文件系统一起形成了基本的操作系统结构,它们使得用户可以运行程序、管理文件并使用系统。
内核是操作系统的核心,具有很多最基本功能,如虚拟内存、多任务、共享库、需求加载、可执行程序和TCP/IP网络功能。Linux内核的模块分为以下几个部分:存储管理、CPU和进程管理、文件系统、设备管理和驱动、网络通信、中断处理、系统的初始化和系统调用等。
本次调研报告的主题是Linux5.4.34内核中TCP/IP协议栈。
网络分层模型
国际标准化组织(ISO)提出的网络体系结构模型,称为开发系统互连参考模型(OSI/RM),通常简称为OSI参考模型。OSI有7层,自下而上依次为物理层、数据链路层、网络层、传输层、会话层、表示层、应用层。第三层称为通信子网,它是为了联网而附加的通信设备,完成数据的传输功能;高三层统称为资源子网,它相当于计算机系统,完成数据的处理等功能。传输层承上启下。OSI的层次结构如下图所示。
ARPA在研究ARPAnet时提出了TCP/IP模型,模型从低到高依次为网络接口层(对应OSI参考模型中的物理层和数据链路层)、往基层、传输层和应用层(对应OSI参考模型中的会话层、表示层和应用层)。TCP/IP由于得到广泛应用而成为事实上的国际标准。TCP/IP的层次结构及各层的主要协议如下图所示。
Linux网络协议栈结构
Linux网络协议栈结构Linux的整个网络协议栈都构建与Linux Kernel中,整个栈也是严格按照分层的思想来设计的,整个栈共分为五层,分别是 :
-
系统调用接口层,面向用户空间应用程序的接口调用库,向用户空间应用程序提供使用网络服务的接口。
-
协议无关的接口层,就是SOCKET层,这一层的目的是屏蔽底层的不同协议(更准确的来说主要是TCP与UDP,当然还包括RAW IP, SCTP等),以便与系统调用层之间的接口可以简单,统一。简单的说,不管我们应用层使用什么协议,都要通过系统调用接口来建立一个SOCKET,这个SOCKET其实是一个巨大的sock结构,它和下面一层的网络协议层联系起来,屏蔽了不同的网络协议的不同,只吧数据部分呈献给应用层(通过系统调用接口来呈献)。
-
网络协议实现层,这是整个协议栈的核心。这一层主要实现各种网络协议,最主要的当然是IP,ICMP,ARP,RARP,TCP,UDP等。
-
与具体设备无关的驱动接口层,这一层的目的主要是为了统一不同的接口卡的驱动程序与网络协议层的接口,它将各种不同的驱动程序的功能统一抽象为几个特殊的动作,如open,close,init等,这一层可以屏蔽底层不同的驱动程序。
-
驱动程序层,建立与硬件的接口层。
Linux内核任务调度机制
中断处理
中断处理会有一些特点,其中最主要的两个是:
-
中断处理必须快速执行完毕
-
有时中断处理必须做很多冗长的事情
因此中断被切分为两部分:
-
前半部
-
后半部
中断处理代码运行于中断处理上下文中,此时禁止响应后续的中断,所以要避免中断处理代码长时间执行。但有些中断却又需要执行很多工作,所以中断处理有时会被分为两部分。第一部分中,中断处理先只做尽量少的重要工作,接下来提交第二部分给内核调度,然后就结束运行。当系统比较空闲并且处理器上下文允许处理中断时,第二部分被延后的剩余任务就会开始执行。
目前实现延后中断有如下三种途径:
-
软中断(softirq)
-
tasklets
-
工作队列(wq)
软中断(softirq)
伴随着内核对并行处理的支持,出于性能考虑,所有新的下半部实现方案都基于被称之为 ksoftirqd
。每个处理器都有自己的内核线程,名字叫做 ksoftirqd/n
,n是处理器的编号,由 spawn_ksoftirqd
函数启动这些线程。
软中断在 Linux 内核编译时就静态地确定了。open_softirq
函数负责 softirq
初始化,它的定义如下所示:
void open_softirq(int nr, void (*action)(struct softirq_action *))
{
softirq_vec[nr].action = action;
}
这个函数有两个参数:
-
softirq_vec
数组的索引序号 -
一个指向软中断处理函数的指针
softirq_vec
数组包含了 NR_SOFTIRQS
(其值为10)个不同 softirq
类型的 softirq_action
。当前版本的 Linux 内核定义了十种软中断向量。其中两个 tasklet 相关,两个网络相关,两个块处理相关,两个定时器相关,另外调度器和 RCU 也各占一个。所有这些都在一个枚举中定义:
enum
{
HI_SOFTIRQ=0,
TIMER_SOFTIRQ,
NET_TX_SOFTIRQ,
NET_RX_SOFTIRQ,
BLOCK_SOFTIRQ,
BLOCK_IOPOLL_SOFTIRQ,
TASKLET_SOFTIRQ,
SCHED_SOFTIRQ,
HRTIMER_SOFTIRQ,
RCU_SOFTIRQ,
NR_SOFTIRQS
};
可以看到 softirq_vec
数组的类型为 softirq_action
。这是软中断机制里一个重要的数据结构,它只有一个指向中断处理函数的成员:
struct softirq_action
{
void (*action)(struct softirq_action *);
};
open_softirq
函数实际上用 softirq_action
参数填充了 softirq_vec
数组。由 open_softirq
注册的延后中断处理函数会由 raise_softirq
调用。这个函数只有一个参数 -- 软中断序号 nr
。下面是该函数的代码:
void raise_softirq(unsigned int nr)
{
unsigned long flags;
local_irq_save(flags);
raise_softirq_irqoff(nr);
local_irq_restore(flags);
}
raise_softirq_irqoff
函数设置当前处理器上和nr参数对应的软中断标志位(__softirq_pending
)。这是通过以下代码做到的:
__raise_softirq_irqoff(nr);
然后,通过 in_interrupt
函数获得 irq_count
值,该函数可以用来检测一个cpu是否处于中断环境,如果处于中断上下文中,就退出 raise_softirq_irqoff
函数,装回 IF
标志位并允许当前处理器的中断。如果不在中断上下文中,就会调用 wakeup_softirqd
函数,wakeup_softirqd
函数会激活当前处理器上的 ksoftirqd
内核线程,其代码如下所示:
static void wakeup_softirqd(void)
{
struct task_struct *tsk = __this_cpu_read(ksoftirqd);
if (tsk && tsk->state != TASK_RUNNING)
wake_up_process(tsk);
}
ksoftirqd
内核线程都运行 run_ksoftirqd
函数来检测是否有延后中断需要处理,如果有的话就会调用 __do_softirq
函数。__do_softirq
读取当前处理器对应的 __softirq_pending
软中断标记,并调用所有已被标记中断对应的处理函数。
每个 softirq
都有如下的阶段:通过 open_softirq
函数注册一个软中断,通过 raise_softirq
函数标记一个软中断来激活它,然后所有被标记的软中断将会在 Linux 内核下一次执行周期性软中断检测时得以调度,对应此类型软中断的处理函数也就得以执行。
从上述可看出,软中断是静态分配的,这对于后期加载的内核模块将是一个问题。基于软中断实现的 tasklets
解决了这个问题。
tasklet
tasklets
构建于 softirq
中断之上,他是基于下面两个软中断实现的:
-
TASKLET_SOFTIRQ
; -
HI_SOFTIRQ
.
简而言之,tasklets
是运行时分配和初始化的软中断。和软中断不同的是,同一类型的 tasklets
可以在同一时间运行于不同的处理器上。
工作队列(wq)
工作队列是另外一个处理延后函数的概念,它大体上和 tasklets
类似。工作队列运行于内核进程上下文,而 tasklets
运行于软中断上下文。这意味着工作队列函数不必像 tasklets
一样必须是原子性的。Tasklets 总是运行于它提交自的那个处理器,工作队列在默认情况下也是这样。
工作队列最基础的用法,是作为创建内核线程的接口来处理提交到队列里的工作任务。所有这些内核线程称之为 worker thread,
这些线程会被用来调度执行工作队列的延后函数。
内核线程
内核线程是直接由内核本身启动的进程。内核线程实际上是将内核函数委托给独立的进程,它与内核中的其他进程”并行”执行。内核线程经常被称之为内核守护进程。
他们执行下列任务
-
周期性地将修改的内存页与页来源块设备同步
-
如果内存页很少使用,则写入交换区
-
管理延时动作, 如2号进程接手内核进程的创建
-
实现文件系统的事务日志
内核线程主要有两种类型
-
线程启动后一直等待,直至内核请求线程执行某一特定操作。
-
线程启动后按周期性间隔运行,检测特定资源的使用,在用量超出或低于预置的限制时采取行动。
内核线程由内核自身生成,其特点在于
-
它们在CPU的管态执行,而不是用户态。
-
它们只可以访问虚拟地址空间的内核部分(高于TASK_SIZE的所有地址),但不能访问用户空间
内核线程是一种只运行在内核地址空间的线程。所有的内核线程共享内核地址空间,所以也共享同一份内核页表,内核线程只运行在内核地址空间中,只会访问 3-4GB (32位系统)的内核地址空间,不存在虚拟地址空间,因此每个内核线程的 task_struct 对象中的 mm 为 NULL。普通线程虽然也是同主线程共享地址空间,但是它的 task_struct 对象中的 mm 不为空,指向的是主线程的 mm_struct 对象,普通进程既可以运行在内核态,也可以运行在用户态而内核线程只运行在内核态。
socket套接字
套接字是通信的基石,是支持TCP/IP协议的路通信的基本操作单元。可以将套接字看作不同主机间的进程进行双间通信的端点,它构成了单个主机内及整个网络间的编程界面。套接字存在于通信域中,通信域是为了处理一般的线程通过套接字通信而引进的一种抽象概念。套接字通常和同一个域中的套接字交换数据(数据交换也可能穿越域的界限,但这时一定要执行某种解释程序),各种进程使用这个相同的域互相之间用Internet协议簇来进行通信。 Socket(套接字)可以看成是两个网络应用程序进行通信时,各自通信连接中的端点,这是一个逻辑上的概念。它是网络环境中进程间通信的API(应用程序编程接口),也是可以被命名和寻址的通信端点,使用中的每一个套接字都有其类型和一个与之相连进程。通信时其中一个网络应用程序将要传输的一段信息写入它所在主机的 Socket中,该 Socket通过与网络接口卡(NIC)相连的传输介质将这段信息送到另外一台主机的 Socket中,使对方能够接收到这段信息。 Socket是由IP地址和端口结合的,提供向应用层进程传送数据包的机制。
send过程分析
应用层
进程想要发送消息,必须进行系统调用进入内核空间插口层,发送方进入的是BSD sendmsg interface,即
__sys_sendmsg
是Linux sendmmsg interface,在这个函数里面调用了sock_sendmsg
这里调用了sock-ops->sendmsg
.看一下socket结构:
struct socket {
socket_state state;
short type;
unsigned long flags;
struct socket_wq *wq;
struct file *file;
struct sock *sk;
const struct proto_ops *ops;
};
struct proto_ops
里面有许多成员,其中就包括:
int (*sendmsg) (struct socket *sock, struct msghdr *m, size_t total_len);
int (*recvmsg) (struct socket *sock, struct msghdr *m, size_t total_len, int flags);
sock-ops->sendmsg
的意思就是根据不同的协议调用不同的sendmsg函数,对于 TCP ,调用tcp_sendmsg
函数。
传输层
要发送的数据到达传输层时,调用tcp_sendmsg
函数,这个函数首先对传输控制块上锁(在发送和接收TCP数据前都要对传输控制块上锁,以免应用程序主动发送接收和传输控制块被动接收而导致 控制块中的发送或接收队列混乱)。然后调用tcp_sendsmg_locked
函数,再释放锁。 tcp_sendmsg_locked
函数中代码很多,主要步骤是:
获取阻塞标识,并且做出相应动作,然后获取阻塞超时时间。
接下来就是检查已经建立的 TCP connection 的状态,开始 segement 发送流程。TCP只在ESTABLISHED或CLOSE_WAIT这两种状态下,接收窗口是打开的,才能接收数据。因此如果不处于这两种状态,则调用sk_stream_wait_connect()
等待建立起连接,一旦超时则跳转到out_err
处做出错处理。 接下来构造 TCP 段的 playload:首先获取连接的MSS,并且清零copied(copied是已从用户数据块复制到SKB的字节数。)
在开始分段前,先初始化错误码为-EPIPE,然后判断此时套接字是否存在错误,以及该套接字是否允许发送数据,如果有错或不允许发送数据,则跳转到do_error
处作处理。
随后就是将所有的用户数据拷贝到skb,并组织好sk发送队列。(代码太多,到检查push前全都是,不上了)
最后就是检查是否必须立即发送,即检查自上次发送后产生的数据是否已超过对方曾经通告过的最大通告窗口值的一半.如果必须立即发送,则设置PUSH标志后调用__tcp_push_pending_frames()
将在发送队列上的SKB从sk_send_head开始发送出去。如果没有必要立即发送,且发送队列上只存在这个段,则调用tcp_push_one()
只发送当前段.
__tcp_push_pending_frames()
只是在判断是否有段需要发送时简单地调用tcp_write_xmit()
发送段,如果发送失败,再调用tcp_check_probe_timer()
复位持续探测定时器.tcp_push_one
也是调用的tcp_write_xmit()
函数。
tcp_write_xmit
代码也很多(在/net/ipv4/tcp_output.c
里面),mss_now:当前有效的MSS,nonagle: 标识是否启用nonagle算法。push_one表示是发送队列上的一个SKB还是把全部SKB一起发送出去。这个函数返回值为0表示发送成功。
这个函数的大致流程:
1)检测当前状态是否是TCP_CLOSE
2)检测拥塞窗口的大小
3)检测当前段是否完全处在发送窗口内
4)检测段是否使用nagle算法进行发送
5)通过以上检测后将该SKB发送出去,最终调用的是tcp_transmit_skb
tcp_transmit_skb
函数主要就是为待发送的段构造TCP首部,然后调用网络层接口到IP层,最终抵达网络设备。由于在成功发送到网络设备后会释放该 SKB,而TCP必须要接到对应的ACK后才能真正释放数据,因此在发送前会根据参数确定是克隆还是复制一份SKB用于发送。最终的tcp发送都会调用这个 clone_it表示发送发送队列的第一个SKB的时候,采用克隆skb还是直接使用skb,如果是发送应用层数据则使用克隆的,等待对方应答ack回来才把数据删除。如果是回送ack信息,则无需克隆。
调用网络层接口代码如下:调用发送接口queue_xmit
发送报文,如果失败则返回错误码。在TCP中该接口实现函数为ip_queue_xmit()
。
网络层
主要任务:路由选择,构造ip头部,获取下一跳的 MAC 地址,设置链路层报文头,然后转入链路层处理。
发送端调用接口ip_queue_xmit()
进入到网络层,ip_queue_xmit
实际上调用的是__ip_queue_xmit
函数
__ip_queue_xmit
函数首先是查找路由缓存,如果找到则直接跳转到packet_routed
处作处理。
如果输出该数据包的传输控制块中缓存了输出路由缓存项,则需检测该路由缓存项是否过期。如果过期,重新通过输出网络设备、目的地址、源地址等信息查找输出路由缓存项。如果查找到对应的路由缓存项,则将其缓存到传输控制块中,否则丢弃该数据包。如果未过期,则直接使用缓存在传输控制块中的路由缓存项。 也就是说通过ip_route_output_ports
查找路由的,这个函数会调用ip_route_output_flow
函数,而后调用__ip_route_output_key
函数。这个函数调用ip_route_output_key_hash
函数进行查找,然后调用 ip_route_output_key_hash_rcu
函数。再调用fib_lookup
函数查找表最后执行fib_table_lookup
函数然后通过fib_get_table
找到路由项。
packet_routed
代码段首先先进行严格源路由选项的处理。如果存在严格源路由选项,并且数据包的下一跳地址和网关地址不 一致,则丢弃该数据包(goto no_route
)。如果没问题,就进行ip头部设置,设置完成后调用ip_local_out
函数。
ip_local_out
函数调用的是__ip_local_out
函数,而__ip_local_out
函数最终调用的是nf_hook
函数并在里面调用了dst_output
函数。 dst_output
函数实际上调用ip_output
函数,而ip_output
函数调用了ip_finish_output
函数。这个函数实际上调用的是__ip_finish_output
函数。 ip_finish_output
函数会检查数据包长度,如果数据包长度大于MTU,则调用ip_fragment()
对IP数据包进行分片。否则走ip_finish_output2
。
ip_finish_output2
函数会检测skb的前部空间是否还能存储链路层首部。如果不够,则重新分配更大存储区的skb,并释放原skb。最终会调用邻居子系统的输出函数neigh_output
进行输出。
如果缓存了链路层的首部,则调用neigh_hh_output()
输出数据包。否则通过邻居项的输出方法输出数据包。不管执行哪个函数,最终都会调用dev_queue_xmit
将数据包传入数据链路层。
链路层和物理层
发送端通过dev_queue_xmit
进入数据链路层。这个函数调用的是__dev_queue_xmit
函数。 __dev_queue_xmit
函数对不同类型的数据包进行不同的处理。HARD_TX_LOCK/HARD_TX_UNLOCK是一对操作, 在这两个操作之间不能再次调用dev_queue_xmi
t接口。因此如果正在用该网络设备发送数据包的CPU又调用dev_queue_xmit()
输出数据包,则 说明代码有bug,需输出警告信息。否则,首先需加锁,以防止其他CPU的并发操作,然后在网络设备处于开启状态时,调用dev_hard_start_xmit()
输出数据包到设备。
dev_hard_start_xmit
函数会循环调用xmit_one
函数,直到将待输出的数据包提交给网络设备的输出接口,完成数据包的输出。 xmit_one
函数会调用netdev_start_xmti
函数,而netdev_start_xmit
会调用_netdev_start_xmit
函数。
物理层在收到发送请求之后,通过 DMA 将该主存中的数据拷贝至内部RAM(buffer)之中。在数据拷贝中,同时加入符合以太网协议的相关header,IFG、前导符和CRC。对于以太网网络,物理层发送采用CSMA/CD,即在发送过程中侦听链路冲突。
一旦网卡完成报文发送,将产生中断通知CPU,然后驱动层中的中断处理程序就可以删除保存的 skb 了。
recv过程分析
链路层和物理层
接收端物理网络适配器接收到数据帧时,会触发一个中断,并将通过 DMA 传送到位于 linux kernel 内存中的 rx_ring。当底层设备驱动程序接收一个报文时,就会通过调用netif_rx
将报文的SKB上传至网络层。 在netif_rx
函数中会调用netif_rx_schedule
, 然后该函数又会去调用__netif_rx_schedule
,在函数__netif_rx_schedule
中会去触发软中断NET_RX_SOFTIRQ, 也即是去调用net_rx_action
.然后在net_rx_action
函数中会去调用设备的poll函数, 它是设备自己注册的. 在设备的poll函数中, 会去调用netif_receive_skb
函数。 neti_receive_skb
调用netif_reveive_skb_internal
函数,这个函数又调用了__netif_receive_skb
函数。又是一连串的调用,最终执行到_netif_recieve_skb_one_core
,在里面有代码 pt_prev->func
, 此处的func为一个函数指针, 在之前的注册中设置为ip_rcv
. 因此, 就完成了从链路层上传到网络层的这一个过程了.
网络层
接收端IP 层的入口函数是 ip_rcv
函数。首先会做包括 package checksum 在内的各种检查,如果需要的话会做 IP defragment(将多个分片合并),然后 packet 调用已经注册的 Pre-routing netfilter hook ,完成后最终到达 ip_rcv_finish
函数。
ip_rcv_finish
最终会调用dst_input
函数,这个函数调用的是ip_input
函数,当缓存查找没有匹配路由时将调用ip_route_input_slow()
,决定该 package 将会被发到本机还是会被转发还是丢弃。如果是发到本机的将会执行ip_local_deliver
函数。
如果分片,就调用ip_defrag
函数,没有则调用ip_local_deliver_finish
函数 接着调用ip_protocol_deliver_rcu
函数 ip_protocol_deliver_rcu
将输入数据包从网络层传递到传输层。过程如下:
1)首先,在数据包传递给传输层之前,去掉IP首部
2)接着,如果是RAW套接字接收数据包,则需要复制一份副本,输入到接收该数据包的套接字。
3)最后,通过传输层的接收例程,将数据包传递到传输层,由传输层进行处理(ret = ipprot->handler(skb)
这句代码将数据包传送到上层)。
传输层
传输层 TCP 处理入口在tcp_v4_rcv
函数,该函数首先对tcp头部以及校验和进行一番检测以及查找该 package 的 open socket,稍有不对就丢弃或者转到出错处理。
然后就是和发送方一样,检测连接状态,最后调用tcp_v4_do_rcv
。
若连接建立,tcp_v4_do_rcv
会调用tcp_rcv_established
函数。
tcp_rcv_established
函数对头部进行一系列检测和相应操作,一切正常后会调用tcp_queue_rcv
函数。
tcp_queue_rcv
函数将收到的数据挂到sk接收队列末尾。而后然socket 会被唤醒,调用 system call,并最终调用 tcp_recvmsg
函数去从 socket recieve queue 中获取 segment。
应用层
对于接收端来说也是类似的操作,先系统调用进入内核插口层,调用BSD recvmsg interface,在里面调用Linux recvmmsg interface:___sys_recvmsg
函数
___sys_recvmsg
函数里面调用了sock_recvmsg_nosec
函数,再根据不同的协议类型调用不同的recvmsg函数,tcp调用的是 tcp_recvmsg。
时序图