TCP/IP协议栈在Linux内核中的运行时序分析
调研要求
-
在深入理解Linux内核任务调度(中断处理、softirg、tasklet、wq、内核线程等)机制的基础上,分析梳理send和recv过程中TCP/IP协议栈相关的运行任务实体及相互协作的时序分析。
-
编译、部署、运行、测评、原理、源代码分析、跟踪调试等
-
应该包括时序图
1. TCP/IP协议栈总览
1.1 网络架构
Linux网络协议栈的架构如下图所示。该图展示了如何实现Internet模型,在最上面的是用户空
间中实现的应用层,而中间为内核空间中实现的网络子系统,底部为物理设备,提供了对网络
的连接能力。在网络协议栈内部流动的是套接口缓冲区(SKB),用于在协议栈的底层、上层以
及应用层之间传递报文数据。
网络协议栈顶部是系统调用接口,为用户空间中的应用程序提供一种访问内核网络子系统的接
口。下面是一个协议无关层,它提供了一种通用方法来使用传输层协议。然后是传输层的具体
协议,包括TCP、UDP。在传输层下面是网络层,之后是邻居子系统,再下面是网络设备接
口,提供了与各个设备驱动通信的通用接口。最底层是设备驱动程序。
1.2 系统调用接口:从两个角度进行描述:
(1)当用户进行网络调用时,通过系统调用接口多路复用到内核中。这最终作为 sys_socketcall
(./net/socket.c
)中的调用,然后进一步解复用到其预期目标的调用。
(2)使用正常的文件操作进行网络I/O。例如,典型的读写操作可以在网络socket(由文件描述符表示,就像普通文件)一样执行。因此,虽然存在一些特定于网络的操作(调用socket
创建socket,调用connect
将socket连接到目的地等等),但还是有一些适用于网络对象的标准文件操作,就像常规文件一样。最后系统调用接口提供了在用户空间应用程序和内核之间传输控制的手段。
协议无关接口:socket层是协议无关接口,其提供一组通用功能,以支持各种不同的协议。socket层不仅支持典型的TCP和UDP协议,还支持IP,原始以太网和其他传输协议,如流控制传输协议(SCTP)。
网络协议:网络协议部分定义了可用的特定网络协议(如TCP,UDP等)。
设备无关接口:协议层下面是另一个无关的接口层,将协议连接到具有不同功能的各种硬件设备的驱动程序。该层提供了一组通用的功能,由较低级别的网络设备驱动程序使用,以允许它们使用较高级协议栈进行操作。
设备驱动程序:网络栈的底部是管理物理网络设备的设备驱动程序。该层的设备示例包括串行接口上的SLIP驱动程序或以太网设备上的以太网驱动程序。
2. Socket简介
Linux中使用socket结构描述套接口,代表一条通信链路的一端,用来存储与该链路有关的所使用的协议的状态信息(包括源地址和目标地址),到达的连接队列
数据缓存和可选标志等等
使用socket结构描述套接口示意图如下所示:
其中最关键的成员是sk和ops,sk指向与该套接口相关的传输控制块,ops指向特定的传输协
议的操作集。
下图详细展示了socket结构体中的sk和ops字段,以TCP为例:
常见API
-
socket()
-
原型:int socket (int domain, int type, int protocol)
-
功能描述:初始化创建socket对象,通常是第一个调用的socket函数。 成功时,返回非负数的socket描述符;失败是返回-1。socket描述符是一个指向内部数据结构的指针,它指向描述符表入口。调用socket()函数时,socket执行体将建立一个socket,为一个socket数据结构分配存储空间。
-
-
bind()
-
原型:int bind(int sockfd, const struct sockaddr* myaddr, socklen_t addrlen)
-
功能描述:将创建的socket绑定到指定的IP地址和端口上,通常是第二个调用的socket函数。返回值:0代表成功,-1代表出错。当socket函数返回一个描述符时,只是存在于其协议族的空间中,并没有分配一个具体的协议地址(这里指IPv4/IPv6和端口号的组合),bind函数可以将一组固定的地址绑定到sockfd上。通常服务器在启动的时候都会绑定一个众所周知的协议地址,用于提供服务,客户就可以通过它来接连服务器;而客户端可以指定IP或端口也可以都不指定,未分配则系统自动分配。
-
-
listen()
-
原型:int listen(int sockfd, int backlog)
-
功能描述:listen()函数仅被TCP类型的服务器程序调用,实现监听服务。当socket()创建socket时,被假设为主动式套接字,也就是说它是一个将调用connect()发起连接请求的客户端套接字;函数listen()将套接口转换为被动式套接字,指示内核接受向此套接字的连接请求,调用此系统调用后tcp 状态机由close转换到listen。
-
-
accept()
-
原型: int accept (int sockfd, struct sockaddr *addr, socklen_t *addrlen)
-
功能描述:accept()函数仅被TCP类型的服务器程序调用,从已完成连接队列返回下一个建立成功的连接,如果已完成连接队列为空,线程进入阻塞态睡眠状态。成功时返回套接字描述符,错误时返回-1。如果accpet()执行成功,返回由内核自动生成的一个全新socket描述符,用它引用与客户端的TCP连接。通常我们把accept()第一个参数成为监听套接字,把accept()功能返回值成为已连接套接字。
-
-
connect()
-
原型: int connect(int sockfd, struct sockaddr *serv_addr, int addrlen)
-
功能描述:connect()通常由TCP类型客户端调用,用来与服务器建立一个TCP连接,实际是发起3次握手过程,连接成功返回0,连接失败返回1。
-
-
send()
-
原型:int send(int sockfd, const void *msg, int len, int flags)
-
功能描述:TCP类型的数据发送。每个TCP套接口都有一个发送缓冲区,它的大小可以用SO_SNDBUF这个选项来改变。调用send函数的过程,实际是内核将用户数据拷贝至TCP套接口的发送缓冲区的过程:若len大于发送缓冲区大小,则返回-1;否则,查看缓冲区剩余空间是否容纳得下要发送的len长度,若不够,则拷贝一部分,并返回拷贝长度;若缓冲区满,则等待发送,有剩余空间后拷贝至缓冲区;若在拷贝过程出现错误,则返回-1。
-
-
recv()
-
原型:int recv(int sockfd, void *buf, int len, unsigned int flags)
-
功能描述:TCP类型的数据接收。recv()从接收缓冲区拷贝数据。成功时,返回拷贝的字节数,失败返回-1。阻塞模式下,recv/recvfrom将会阻塞到缓冲区里至少有一个字节(TCP)/至少有一个完整的UDP数据报才返回,没有数据时处于休眠状态。若非阻塞,则立即返回,有数据则返回拷贝的数据大小,否则返回错误-1。
-
socket编程中,tcp的客户端和服务端的大致流程如下图所示:
3.linux内核启动
Linux内核协议栈模块在接收网卡数据包之前,需要做好如下准备:
-
创建ksofttirqd内核线程
-
注册协议相关处理函数
-
网络设备子系统初始化、网卡启动
3.1 创建ksoftirqd内核线程
相关代码如下:
//file: kernel/softirq.c 2 3 static struct smp_hotplug_thread softirq_threads = { 4 5 .store = &ksoftirqd, 6 .thread_should_run = ksoftirqd_should_run, 7 .thread_fn = run_ksoftirqd, 8 .thread_comm = "ksoftirqd/%u",}; 9 static __init int spawn_ksoftirqd(void){ 10 register_cpu_notifier(&cpu_nfb); 11 12 BUG_ON(smpboot_register_percpu_thread(&softirq_threads)); 13 return 0; 14 15 } 16 17 early_initcall(spawn_ksoftirqd);
其中spawn_ksoftirqd函数创建了softtirqd进程,值得一提的是, Linux的软中断都是在专门的内核线程(ksoftirqd)中进行的,当ksoftirqd被创建出来以后,它就会进入自己的线程循环函数ksoftirqd_should_run和run_ksoftirqd了。不停地判断有没有软中断需要被处理。
3.2网络子系统初始化
linux内核通过调用subsys_initcall来初始化各个子系统,在源代码目录里你可以grep出许多对这个函数的调用。这里我们要说的是网络子系统的初始化,会执行到net_dev_init函数。
//file: net/core/dev.c static int __init net_dev_init(void){ ...... for_each_possible_cpu(i) { struct softnet_data *sd = &per_cpu(softnet_data, i); memset(sd, 0, sizeof(*sd)); skb_queue_head_init(&sd->input_pkt_queue); skb_queue_head_init(&sd->process_queue); sd->completion_queue = NULL; INIT_LIST_HEAD(&sd->poll_list); ...... } ...... open_softirq(NET_TX_SOFTIRQ, net_tx_action); open_softirq(NET_RX_SOFTIRQ, net_rx_action); } subsys_initcall(net_dev_init);
在这个函数里,会为每个CPU都申请一个softnet_data数据结构,在这个数据结构里的poll_list是等待驱动程序将其poll函数注册进来,稍后网卡驱动初始化的时候我们可以看到这一过程。
3.3协议栈注册
网络层的ip协议和传输层的tcp协议和udp协议都在内核实现,实现函数有ip_rcv(),tcp_v4_rcv()和udp_rcv()。
内核是通过注册的方式来实现的。Linux内核中的fs_initcall和subsys_initcall类似,也是初始化模块的入口。fs_initcall调用inet_init后开始网络协议栈注册。通过inet_init,将这些函数注册到了inet_protos和ptype_base数据结构中。
3.4网卡驱动初始化与网卡启动
这一步主要是初始化对应网卡的驱动程序,启动网卡。驱动向内核注册了 structure net_device_ops 变量,它包含着网卡启用、发包、设置mac 地址等回调函数(函数指针)。当启用一个网卡时(例如,通过 ifconfig eth0 up),net_device_ops 中的 igb_open方法会被调用,之后分配队列内存,注册中断处理函数,然后打开硬中断等包进来。
3.5包的接收(socket接收队列)
内核使用 work_struct 结构体来管理一个工作队列中的任务,工作队列的原理是把work(需要推迟执行的函数)交由一个内核线程来执行,它总是在进程上下文中执行。
struct work_struct { atomic_long_t data; struct list_head entry; work_func_t func; // 下半部实现的处理函数指针 #ifdef CONFIG_LOCKDEP struct lockdep_map lockdep_map; #endif };
当中断发生后,调度 work_struct 指定的任务到工作队列 :
void queue_work(struct workqueue_struct *wq, struct work_struct *work)
此时任务的执行时机由内核调度器决定。当等待队列中的所有任务全部执行完时,等待队列 wq 为空,此时就删除等待队列:
void destroy_workqueue(struct workqueue_struct *wq)
工作队列由struct workqueue_struct数据结构描述
struct workqueue_struct { struct list_head pwqs; /* WR: all pwqs of this wq */ // 该workqueue所在的所有pool_workqueue链表 struct list_head list; /* PL: list of all workqueues */ // 系统所有workqueue_struct的全局链表 struct mutex mutex; /* protects this wq */ int work_color; /* WQ: current work color */ int flush_color; /* WQ: current flush color */ atomic_t nr_pwqs_to_flush; /* flush in progress */ struct wq_flusher *first_flusher; /* WQ: first flusher */ struct list_head flusher_queue; /* WQ: flush waiters */ struct list_head flusher_overflow; /* WQ: flush overflow list */ struct list_head maydays; /* MD: pwqs requesting rescue */ struct worker *rescuer; /* I: rescue worker */ int nr_drainers; /* WQ: drain in progress */ int saved_max_active; /* WQ: saved pwq max_active */ struct workqueue_attrs *unbound_attrs; /* WQ: only for unbound wqs */ struct pool_workqueue *dfl_pwq; /* WQ: only for unbound wqs */ #ifdef CONFIG_SYSFS struct wq_device *wq_dev; /* I: for sysfs interface */ #endif #ifdef CONFIG_LOCKDEP struct lockdep_map lockdep_map; #endif char name[WQ_NAME_LEN]; /* I: workqueue name */ // 该workqueue的名字 /* hot fields used during command issue, aligned to cacheline */ unsigned int flags ____cacheline_aligned; /* WQ: WQ_* flags */ // 经常被不同CUP访问,因此要和cache line对齐 struct pool_workqueue __percpu *cpu_pwqs; /* I: per-cpu pwqs */ // 指向per-cpu类型的pool_workqueue struct pool_workqueue __rcu *numa_pwq_tbl[]; /* FR: unbound pwqs indexed by node */ }
4.send和recv传输层分析
传输层向网际层方向:
大致经历了以下几个步骤:
调用Tcp_sendmsg函数检查链接状态,并同时获取链接的MSS。创建该数据包的 sk_buffer 数据结构实例 skb,从 userspace buffer 中拷贝 packet 的数据到 skb 的 buffer。构造数据包头部,接而计算 TCP 校验和(ack)和顺序号(seq)。最后调用ip_queue_xmit函数将数据包传输到网际层进行处理。
这里主要对Tcp_sendmsg函数的调用逻辑进行补充分析,该函数只要检查已经建立的 TCP connection 的状态,然后获取有效的 MSS,Tcp_sendmsg函数的内部调用顺序如下:
tcp发送数据
int tcp_sendmsg(struct sock *sk, struct msghdr *msg, size_t size) { int ret; lock_sock(sk); ret = tcp_sendmsg_locked(sk, msg, size); release_sock(sk); return ret; }
tcp_sendmsg实际上调用的是int tcp_sendmsg_locked(struct sock *sk, struct msghdr *msg, size_t size)
int tcp_sendmsg_locked(struct sock *sk, struct msghdr *msg, size_t size) { struct tcp_sock *tp = tcp_sk(sk); struct ubuf_info *uarg = NULL; struct sk_buff *skb; struct sockcm_cookie sockc; int flags, err, copied = 0; int mss_now = 0, size_goal, copied_syn = 0; int process_backlog = 0; bool zc = false; long timeo; flags = msg->msg_flags;
tcp接收数据
接收函数比发送函数要复杂得多,因为数据接收不仅仅只是接收,tcp的三次握手也是在接收函数实现的,所以收到数据后要判断当前的状态,是否正在建立连接等,根据发来的信息考虑状态是否要改变,在这里仅仅考虑在连接建立后数据的接收。
首先从上向下分析,即上一层中调用了tcp_recvmsg。
该函数完成从接收队列中读取数据复制到用户空间的任务;函数在执行过程中会锁定控制块,避免软中断在tcp层的影响;函数会涉及从接收队列receive_queue和后备队列backlog中读取数据;其中从backlog中读取的数据,还需要经过sk_backlog_rcv回调,该回调的实现为tcp_v4_do_rcv,实际上是先缓存到队列中,然后需要读取的时候,才进入协议栈处理,此时,是在进程上下文执行的,因为会设置tp->ucopy.task=current,在协议栈处理过程中,会直接将数据复制到用户空间。
int tcp_recvmsg(struct sock *sk, struct msghdr *msg, size_t len, int nonblock, int flags, int *addr_len) { struct tcp_sock *tp = tcp_sk(sk); int copied = 0; u32 peek_seq; u32 *seq; unsigned long used; int err, inq; int target; /* Read at least this many bytes */ long timeo; struct sk_buff *skb, *last; u32 urg_hole = 0; struct scm_timestamping_internal tss; int cmsg_flags; if (unlikely(flags & MSG_ERRQUEUE)) return inet_recv_error(sk, msg, len, addr_len); if (sk_can_busy_loop(sk) && skb_queue_empty_lockless(&sk->sk_receive_queue) && (sk->sk_state == TCP_ESTABLISHED)) sk_busy_loop(sk, nonblock); lock_sock(sk);
recvfrom系统调用
进程调用recvfrom后发生了什么呢?我们在代码里调用的recvfrom是一个glibc的库函数,该函数在执行后会将用户进行陷入到内核态,进入到Linux实现的系统调用sys_recvfrom。
以上一个包在linux内核下的接收读取便成功了。