万字硬核!深入理解Linux网络包接收过程

因为要对百万、千万、甚至是过亿的用户提供各种网络服务,所以在一线互联网企业里面试和晋升后端开发同学的其中一个重点要求就是要能支撑高并发,要理解性能开销,会进行性能优化 。而很多时候,如果你对Linux底层的理解不深的话,遇到很多线上性能瓶颈你会觉得狗拿刺猬,无从下手 。

我们今天用图解的方式,来深度理解一下在Linux下网络包的接收过程 。还是按照惯例来借用一段最简单的代码开始思考 。为了简单起见,我们用udp来举例,如下:
int main(){
int serverSocketFd = socket(AF_INET, SOCK_DGRAM, 0);
bind(serverSocketFd, ...);

char buff[BUFFSIZE];
int readCount = recvfrom(serverSocketFd, buff, BUFFSIZE, 0, ...);
buff[readCount] = '\0';
printf("Receive from client:%s\n", buff);
}
上面代码是一段udp server接收收据的逻辑 。当在开发视角看的时候,只要客户端有对应的数据发送过来,服务器端执行recv_from后就能收到它,并把它打印出来 。我们现在想知道的是,当网络包达到网卡,直到我们的recvfrom收到数据,这中间,究竟都发生过什么?
通过本文,你将深入理解Linux网络系统内部是如何实现的,以及各个部分之间如何交互 。相信这对你的工作将会有非常大的帮助 。本文基于Linux 3.10,源代码参见https://mirrors.edge.kernel.org/pub/linux/kernel/v3.x/,网卡驱动采用Intel的igb网卡举例 。
一Linux网络收包总览在TCP/IP网络分层模型里,整个协议栈被分成了物理层、链路层、网络层,传输层和应用层 。物理层对应的是网卡和网线,应用层对应的是我们常见的Nginx,FTP等等各种应用 。Linux实现的是链路层、网络层和传输层这三层 。

在Linux内核实现中,链路层协议靠网卡驱动来实现,内核协议栈来实现网络层和传输层 。内核对更上层的应用层提供socket接口来供用户进程访问 。我们用Linux的视角来看到的TCP/IP网络分层模型应该是下面这个样子的 。

图1 Linux视角的网络协议栈
在Linux的源代码中,网络设备驱动对应的逻辑位于driver/net/ethernet, 其中intel系列网卡的驱动在driver/net/ethernet/intel目录下 。协议栈模块代码位于kernelnet目录 。
内核和网络设备驱动是通过中断的方式来处理的 。当设备上有数据到达的时候,会给CPU的相关引脚上触发一个电压变化,以通知CPU来处理数据 。
对于网络模块来说,由于处理过程比较复杂和耗时,如果在中断函数中完成所有的处理,将会导致中断处理函数(优先级过高)将过度占据CPU,将导致CPU无法响应其它设备,例如鼠标和键盘的消息,因此Linux中断处理函数是分上半部和下半部的 。
上半部是只进行最简单的工作,快速处理然后释放CPU,接着CPU就可以允许其它中断进来 。剩下将绝大部分的工作都放到下半部中,可以慢慢从容处理 。2.4以后的内核版本采用的下半部实现方式是软中断,由ksoftirqd内核线程全权处理 。和硬中断不同的是,硬中断是通过给CPU物理引脚施加电压变化,而软中断是通过给内存中的一个变量的二进制值以通知软中断处理程序 。

好了,大概了解了网卡驱动、硬中断、软中断和ksoftirqd线程之后,我们在这几个概念的基础上给出一个内核收包的路径示意:
图2 Linux内核网络收包总览
当网卡上收到数据以后,Linux中第一个工作的模块是网络驱动 。网络驱动会以DMA的方式把网卡上收到的帧写到内存里 。再向CPU发起一个中断,以通知CPU有数据到达 。
第二,当CPU收到中断请求后,会去调用网络驱动注册的中断处理函数 。网卡的中断处理函数并不做过多工作,发出软中断请求,然后尽快释放CPU 。ksoftirqd检测到有软中断请求到达,调用poll开始轮询收包,收到后交由各级协议栈处理 。对于UDP包来说,会被放到用户socket的接收队列中 。
我们从上面这张图中已经从整体上把握到了Linux对数据包的处理过程 。但是要想了解更多网络模块工作的细节,我们还得往下看 。
二Linux启动Linux驱动,内核协议栈等等模块在具备接收网卡数据包之前,要做很多的准备工作才行 。比如要提前创建好ksoftirqd内核线程,要注册好各个协议对应的处理函数,网络设备子系统要提前初始化好,网卡要启动好 。只有这些都Ready之后,我们才能真正开始接收数据包 。那么我们现在来看看这些准备工作都是怎么做的 。

2.1 创建ksoftirqd内核线程Linux的软中断都是在专门的内核线程(ksoftirqd)中进行的,因此我们非常有必要看一下这些进程是怎么初始化的,这样我们才能在后面更准确地了解收包过程 。该进程数量不是1个,而是N个,其中N等于你的机器的核数 。
系统初始化的时候在kernel/smpboot.c中调用了smpboot_register_percpu_thread, 该函数进一步会执行到spawn_ksoftirqd(位于kernel/softirq.c)来创建出softirqd进程 。
图3 创建ksoftirqd内核线程
相关代码如下:
//file: kernel/softirq.c
static struct smp_hotplug_thread softirq_threads = {
.store= &ksoftirqd,
.thread_should_run= ksoftirqd_should_run,
.thread_fn= run_ksoftirqd,
.thread_comm= "ksoftirqd/%u",};
static __init int spawn_ksoftirqd(void){
register_cpu_notifier(&cpu_nfb);

BUG_ON(smpboot_register_percpu_thread(&softirq_threads));
return 0;
}
early_initcall(spawn_ksoftirqd);
当ksoftirqd被创建出来以后,它就会进入自己的线程循环函数ksoftirqd_should_run和run_ksoftirqd了 。不停地判断有没有软中断需要被处理 。这里需要注意的一点是,软中断不仅仅只有网络软中断,还有其它类型 。
//file: include/linux/interrupt.henum{
HI_SOFTIRQ=0,
TIMER_SOFTIRQ,
NET_TX_SOFTIRQ,
NET_RX_SOFTIRQ,
BLOCK_SOFTIRQ,
BLOCK_IOPOLL_SOFTIRQ,
TASKLET_SOFTIRQ,
SCHED_SOFTIRQ,
HRTIMER_SOFTIRQ,
RCU_SOFTIRQ,
};
2.2 网络子系统初始化
图4 网络子系统初始化
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函数注册进来,稍后网卡驱动初始化的时候我们可以看到这一过程 。
另外open_softirq注册了每一种软中断都注册一个处理函数 。NET_TX_SOFTIRQ的处理函数为net_tx_action,NET_RX_SOFTIRQ的为net_rx_action 。继续跟踪open_softirq后发现这个注册的方式是记录在softirq_vec变量里的 。后面ksoftirqd线程收到软中断的时候,也会使用这个变量来找到每一种软中断对应的处理函数 。
//file: kernel/softirq.c
void open_softirq(int nr, void (*action)(struct softirq_action *)){
softirq_vec[nr].action = action;
}
2.3 协议栈注册内核实现了网络层的ip协议,也实现了传输层的tcp协议和udp协议 。这些协议对应的实现函数分别是ip_rcv(),tcp_v4_rcv()和udp_rcv() 。和我们平时写代码的方式不一样的是,内核是通过注册的方式来实现的 。
Linux内核中的fs_initcallsubsys_initcall类似,也是初始化模块的入口 。fs_initcall调用inet_init后开始网络协议栈注册 。通过inet_init,将这些函数注册到了inet_protos和ptype_base数据结构中了 。如下图:
图5 AF_INET协议栈注册
相关代码如下:
//file: net/ipv4/af_inet.c
static struct packet_type ip_packet_type __read_mostly = {
.type = cpu_to_be16(ETH_P_IP),
.func = ip_rcv,};static const struct net_protocol udp_protocol = {
.handler =udp_rcv,
.err_handler =udp_err,
.no_policy =1,
.netns_ok = 1,};static const struct net_protocol tcp_protocol = {
.early_demux=tcp_v4_early_demux,
.handler=tcp_v4_rcv,
.err_handler=tcp_v4_err,
.no_policy=1,
.netns_ok=1,
};
staticint __init inet_init(void){
......
if (inet_add_protocol(&icmp_protocol, IPPROTO_ICMP) < 0)
pr_crit("%s: Cannot add ICMP protocol\n", __func__);
if (inet_add_protocol(&udp_protocol, IPPROTO_UDP) < 0)
pr_crit("%s: Cannot add UDP protocol\n", __func__);
if (inet_add_protocol(&tcp_protocol, IPPROTO_TCP) < 0)
pr_crit("%s: Cannot add TCP protocol\n", __func__);
......
dev_add_pack(&ip_packet_type);
}
上面的代码中我们可以看到,udp_protocol结构体中的handler是udp_rcv,tcp_protocol结构体中的handler是tcp_v4_rcv,通过inet_add_protocol被初始化了进来 。
int inet_add_protocol(const struct net_protocol *prot, unsigned char protocol){
if (!prot->netns_ok) {
pr_err("Protocol %u is not namespace aware, cannot register.\n",
protocol);
return -EINVAL;
}

return !cmpxchg((const struct net_protocol **)&inet_protos[protocol],
NULL, prot) ? 0 : -1;
}
inet_add_protocol函数将tcp和udp对应的处理函数都注册到了inet_protos数组中了 。再看dev_add_pack(&ip_packet_type);这一行,ip_packet_type结构体中的type是协议名,func是ip_rcv函数,在dev_add_pack中会被注册到ptype_base哈希表中 。
//file: net/core/dev.c
void dev_add_pack(struct packet_type *pt){
struct list_head *head = ptype_head(pt);......
}
static inline struct list_head *ptype_head(const struct packet_type *pt){
if (pt->type == htons(ETH_P_ALL))
return &ptype_all;
else
return &ptype_base[ntohs(pt->type) & PTYPE_HASH_MASK];
}
这里我们需要记住inet_protos记录着udp,tcp的处理函数地址,ptype_base存储着ip_rcv()函数的处理地址 。后面我们会看到软中断中会通过ptype_base找到ip_rcv函数地址,进而将ip包正确地送到ip_rcv()中执行 。在ip_rcv中将会通过inet_protos找到tcp或者udp的处理函数,再而把包转发给udp_rcv()或tcp_v4_rcv()函数 。
扩展一下,如果看一下ip_rcv和udp_rcv等函数的代码能看到很多协议的处理过程 。例如,ip_rcv中会处理netfilter和iptable过滤,如果你有很多或者很复杂的 netfilter 或 iptables 规则,这些规则都是在软中断的上下文中执行的,会加大网络延迟 。再例如,udp_rcv中会判断socket接收队列是否满了 。对应的相关内核参数是net.core.rmem_max和net.core.rmem_default 。如果有兴趣,建议大家好好读一下inet_init这个函数的代码 。
2.4 网卡驱动初始化每一个驱动程序(不仅仅只是网卡驱动)会使用 module_init 向内核注册一个初始化函数,当驱动被加载时,内核会调用这个函数 。比如igb网卡驱动的代码位于drivers/net/ethernet/intel/igb/igb_main.c
//file: drivers/net/ethernet/intel/igb/igb_main.c
static struct pci_driver igb_driver = {
.name= igb_driver_name,
.id_table = igb_pci_tbl,
.probe= igb_probe,
.remove= igb_remove,
......
};
staticint __init igb_init_module(void){
......
ret = pci_register_driver(&igb_driver);
return ret;
}
驱动的pci_register_driver调用完成后,Linux内核就知道了该驱动的相关信息,比如igb网卡驱动的igb_driver_nameigb_probe函数地址等等 。当网卡设备被识别以后,内核会调用其驱动的probe方法(igb_driver的probe方法是igb_probe) 。
驱动probe方法执行的目的就是让设备ready,对于igb网卡,其igb_probe位于drivers/net/ethernet/intel/igb/igb_main.c下 。主要执行的操作如下:
图6 网卡驱动初始化
第5步中我们看到,网卡驱动实现了ethtool所需要的接口,也在这里注册完成函数地址的注册 。当 ethtool 发起一个系统调用之后,内核会找到对应操作的回调函数 。对于igb网卡来说,其实现函数都在drivers/net/ethernet/intel/igb/igb_ethtool.c下 。
相信你这次能彻底理解ethtool的工作原理了吧?这个命令之所以能查看网卡收发包统计、能修改网卡自适应模式、能调整RX 队列的数量和大小,是因为ethtool命令最终调用到了网卡驱动的相应方法,而不是ethtool本身有这个超能力 。
第6步注册的igb_netdev_ops中包含的是igb_open等函数,该函数在网卡被启动的时候会被调用 。
//file: drivers/net/ethernet/intel/igb/igb_main.c
staticconststruct net_device_ops igb_netdev_ops = {
.ndo_open= igb_open,
.ndo_stop= igb_close,
.ndo_start_xmit= igb_xmit_frame,
.ndo_get_stats64= igb_get_stats64,
.ndo_set_rx_mode= igb_set_rx_mode,
.ndo_set_mac_address= igb_set_mac,
.ndo_change_mtu= igb_change_mtu,
.ndo_do_ioctl= igb_ioctl,
......
第7步中,在igb_probe初始化过程中,还调用到了igb_alloc_q_vector 。他注册了一个NAPI机制所必须的poll函数,对于igb网卡驱动来说,这个函数就是igb_poll,如下代码所示 。
static int igb_alloc_q_vector(struct igb_adapter *adapter,
int v_count, int v_idx,
int txr_count, int txr_idx,
int rxr_count, int rxr_idx){
......
/* initialize NAPI */
netif_napi_add(adapter->netdev, &q_vector->napi,
igb_poll, 64);
}
2.5 启动网卡当上面的初始化都完成以后,就可以启动网卡了 。回忆前面网卡驱动初始化时,我们提到了驱动向内核注册了 structure net_device_ops 变量,它包含着网卡启用、发包、设置mac 地址等回调函数(函数指针) 。当启用一个网卡时(例如,通过 ifconfig eth0 up),net_device_ops 中的 igb_open方法会被调用 。它通常会做以下事情:
图7 启动网卡

    推荐阅读