本文将介绍在Linux系统中,数据包是如何一步一步从网卡传到进程手中的。
本文只讨论以太网的物理网卡,不涉及虚拟设备,并且以一个UDP包的接收过程作为示例。
本文基于其他博文总结、翻译、梳理
网卡到内存
网卡需要有驱动才能工作,驱动是加载到内核中的模块。Linux设计了网络设备子系统(网络模块)负责衔接网卡驱动和内核,驱动在加载的时候将自己注册进网络模块,当相应的网卡收到数据包时,网络模块会调用相应的驱动函数处理数据。
下图展示了数据包(packet)如何进入内存,并被内核的网络模块开始处理:
-
数据包从外面的网络进入物理网卡。
-
网卡将数据包通过DMA的方式写入到指定的内存地址,该地址由网卡驱动初始化时分配,在内存中的数据结构便是经常提到的ring buffer。
-
网卡通过硬件中断(Interrupt Request IRQ)通知CPU,告诉它有数据来了。
-
CPU调用已经注册的中断函数,这个中断函数在启动网卡(ifconfig eth0 up)的时候由网卡驱动注册。
-
中断函数首先更新网卡中特定的寄存器,跟踪中断到达的频率,启动"中断节流",关闭对cpu的中断。在关闭中断的同时会启动对NAPI子系统的调度工作。NAPI子系统是一种机制来收集数据帧(从ring buffer拿数据),在网卡驱动初始化时启用但是处于非工作状态,在数据到来后调度至工作状态,没有更多数据处理时再次到非工作状态,进入非工作状态的同时重新打开网卡中断。
-
启动软中断,这步结束后,硬件中断处理函数就结束返回了。由于硬中断处理程序执行的过程中不能被中断,所以如果它执行时间过长,会导致CPU没法响应其它硬件的中断,于是内核引入软中断,这样可以将硬中断处理函数中耗时的部分移到软中断处理函数里面来慢慢处理。
原译者没有讨论NAPI相关的内容,所以图中没有体现NAPI。
内核的网络模块
-
内核中的ksoftirqd进程专门负责软中断的处理,当它收到软中断后,就会调用相应软中断所对应的处理函数,对于上面第6步中是网卡驱动模块抛出的软中断,ksoftirqd会调用网络模块的net_rx_action函数。
-
net_rx_action调用网卡驱动注册的poll函数来一个一个的处理数据包,在pool函数中驱动会一个接一个的读取网卡写到内存中的数据包,然后合并成内核网络栈要的skb格式,然后调用napi_gro_receive函数。值得注意的是,从硬中断到到poll函数,都会是在同一个cpu核中处理,多队列网卡一个队列对应一个cpu核。
-
napi_gro_receive会处理GRO(Generic Receive Offloading)相关的内容,GRO是一种优化技术,优化将可以合并的包进行合并,这样在交付给上层协议栈的时候,协议栈只需处理一个相对大的数据包的一个包头,而不是多个小数据包多个包头,降低cpu使用。
-
napi_gro_receive最后调用netif_receive_skb,netif_receive_skb负责将数据包交付上层协议栈。netif_receive_skb中有两个路径,一个启用了RPS,一个没有启用RPS。RPS(Receive Packet Steering)是一种向上层协议栈交付包的优化。在前面net_rx_action中无法把处理包的过程分散到多个cpu,但是交付协议栈的过程可以使用RPS进行分散,使用更多核进行包协议栈交付。
-
如果没有开启RPS,netif_receive_skb直接调用__netif_receive_skb_core,进入协议栈流程。
-
如果开启了RPS,netif_receive_skb会调用enqueue_to_backlog,最后skb放入对应cpu核的input_pkt_queue。input_pkt_queue满了的话,该数据包将会被丢弃,queue的大小可以通过net.core.netdev_max_backlog来配置。这里还可以配置对skb的流控,流控达到了的话,数据包也会被丢弃,默认流控是不开启的。每个核对queue会有一个poller过程,最后调用__netif_receive_skb_core,进入协议栈流程。
-
在进协议栈之前会检查下,是否有捕获包的packet taps,如果有则拷贝一份给对应的tap(比如是不是有AF_PACKET类型的socket也就是我们常说的原始套接字,tcpdump抓包就是抓的这里的包),再送入协议栈。
原译者没有讨论netif_receive_skb过程,所以图中没有体现netif_receive_skb流程。
IP层
由于是UDP包,所以第一步会进入IP层,然后一级一级的函数往下调:
- ip_rcv: ip_rcv函数是IP模块的入口函数,在该函数里调用netfilter注册在NF_INET_PRE_ROUTING的函数修改或者丢弃数据包,如果数据包没被丢弃,将继续往下走。
- routing: 进行路由,如果是目的IP不是本地IP,且没有开启ip forward功能,那么数据包将被丢弃,如果开启了ip forward功能,那将进入ip_forward函数。
- ip_forward: ip_forward会先调用netfilter注册在NF_INET_FORWARD的相关函数,如果数据包没有被丢弃,那么将继续往后调用dst_output_sk函数。
- dst_output_sk: 该函数会调用IP层的相应函数将该数据包发送出去。
- ip_local_deliver:如果上面routing的时候发现目的IP是本地IP,那么将会调用该函数,在该函数中会先调用netfilter注册在NF_INET_LOCAL_IN的相关函数,如果数据包没有被丢弃,数据包将会向下发送到UDP层。
netfilter是在内核协议栈上的防火墙框架,常用的iptables工具便是基于netfilter实现。
UDP层
- udp_rcv:udp_rcv函数是UDP模块的入口函数,它里面会调用其它的函数,主要是做一些必要的检查,其中一个重要的调用是__udp4_lib_lookup_skb,该函数会根据包信息找对应的socket,如果没有找到相应的socket,那么该数据包将会被丢弃,否则继续。
- sock_queue_rcv_skb: 主要干了两件事,一是检查这个socket的receive buffer是不是满了,如果满了的话,丢弃该数据包。然后调用在该socket上的sk_filter,sk_filter会执行BPF(Berkeley Packet Filter)过滤逻辑。
- __skb_queue_tail:将数据包放入socket接收队列。
- sk_data_ready:通知等待在该socket上的进程。
调用完sk_data_ready之后,一个数据包处理完成,等待应用层程序来读取。IP层到UDP层所有函数的执行过程都在软中断的上下文中。
Socket
应用层一般有两种方式接收数据,一种是recvfrom函数阻塞在那里等着数据来,这种情况下当socket收到通知后,recvfrom就会被唤醒,然后读取接收队列的数据。
另一种是通过epoll或者select监听相应的socket,当收到通知后,再调用recvfrom函数去读取接收队列的数据。
两种情况都能正常的接收到相应的数据包。