本文做者张彦飞,原题“图解Linux网络包接收过程”,内容有少量改动。html
由于要对百万、千万、甚至是过亿的用户提供各类网络服务,因此在一线互联网企业里面试和晋升后端开发同窗的其中一个重点要求就是要能支撑高并发,要理解性能开销,会进行性能优化。而不少时候,若是你对网络底层的理解不深的话,遇到不少线上性能瓶颈你会以为狗拿刺猬,无从下手。linux
这篇文章将用图解的方式,从操做系统这一层来深度理解一下网络包的接收过程(由于能直接看到内核源码,本文以Linux为例)。面试
按照惯例来借用一段最简单的代码开始思考。编程
为了简单起见,咱们用udp来举例,以下:后端
int main(){api
intserverSocketFd = 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网卡举例)。
友情提示:本文略长,能够先Mark后看!
(本文同步发布于:http://www.52im.net/thread-3247-1-1.html)
本文是系列文章中的第10篇,本系列文章的大纲以下:
《鲜为人知的网络编程(一):浅析TCP协议中的疑难杂症(上篇)》
《鲜为人知的网络编程(二):浅析TCP协议中的疑难杂症(下篇)》
《鲜为人知的网络编程(三):关闭TCP链接时为何会TIME_WAIT、CLOSE_WAIT》
《鲜为人知的网络编程(七):如何让不可靠的UDP变的可靠?》
在TCP/IP网络分层模型里,整个协议栈被分红了:物理层、链路层、网络层,传输层和应用层。
物理层对应的是网卡和网线,应用层对应的是咱们常见的Nginx,FTP等等各类应用。对于Linux来讲,它实现的是链路层、网络层和传输层这三层。
在Linux内核实现中,链路层协议靠网卡驱动来实现,内核协议栈来实现网络层和传输层。内核对更上层的应用层提供socket接口来供用户进程访问。
咱们用Linux的视角来看到的TCP/IP网络分层模型应该是下面这个样子的:
在Linux的源代码中,网络设备驱动对应的逻辑位于driver/net/ethernet。
其中:
1)intel系列网卡的驱动在driver/net/ethernet/intel目录下;
2)协议栈模块代码位于kernel和net目录。
内核和网络设备驱动是经过中断的方式来处理的。
当设备上有数据到达的时候:会给CPU的相关引脚上触发一个电压变化,以通知CPU来处理数据。
对于网络模块来讲:因为处理过程比较复杂和耗时,若是在中断函数中完成全部的处理,将会致使中断处理函数(优先级太高)将过分占据CPU,将致使CPU没法响应其它设备,例如鼠标和键盘的消息。
所以Linux中断处理函数是分上半部和下半部的。上半部是只进行最简单的工做,快速处理而后释放CPU,接着CPU就能够容许其它中断进来。剩下将绝大部分的工做都放到下半部中,能够慢慢从容处理。Linux 2.4之后的内核版本采用的下半部实现方式是软中断,由ksoftirqd内核线程全权处理。和硬中断不一样的是,硬中断是经过给CPU物理引脚施加电压变化,而软中断是经过给内存中的一个变量的二进制值以通知软中断处理程序。
好了,大概了解了网卡驱动、硬中断、软中断和ksoftirqd线程以后,咱们在这几个概念的基础上给出一个内核收包的路径示意。
Linux内核网络收包总览:
如上图所示:当网卡上收到数据之后,Linux中第一个工做的模块是网络驱动。网络驱动会以DMA的方式把网卡上收到的帧写到内存里。再向CPU发起一个中断,以通知CPU有数据到达。第二,当CPU收到中断请求后,会去调用网络驱动注册的中断处理函数。网卡的中断处理函数并不作过多工做,发出软中断请求,而后尽快释放CPU。ksoftirqd检测到有软中断请求到达,调用poll开始轮询收包,收到后交由各级协议栈处理。对于UDP包来讲,会被放到用户socket的接收队列中。
咱们从上面这张图中已经从总体上把握到了操做系统对数据包的处理过程。可是要想了解更多网络模块工做的细节,咱们还得往下看。
Linux驱动、内核协议栈等等模块在具有接收网卡数据包以前,要作不少的准备工做才行。
好比:要提早建立好ksoftirqd内核线程,要注册好各个协议对应的处理函数,网络设备子系统要提早初始化好,网卡要启动好。只有这些都Ready以后,咱们才能真正开始接收数据包。
那么咱们如今来看看这些准备工做都是怎么作的。
Linux的软中断都是在专门的内核线程(ksoftirqd)中进行的,所以咱们很是有必要看一下这些进程是怎么初始化的,这样咱们才能在后面更准确地了解收包过程。该进程数量不是1个,而是N个,其中N等于你的机器的核数。
系统初始化的时候在kernel/smpboot.c中调用了smpboot_register_percpu_thread, 该函数进一步会执行到spawn_ksoftirqd(位于kernel/softirq.c)来建立出softirqd进程。
建立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 intspawn_ksoftirqd(void){
register_cpu_notifier(&cpu_nfb);
BUG_ON(smpboot_register_percpu_thread(&softirq_threads));
return0;
}
early_initcall(spawn_ksoftirqd);
当ksoftirqd被建立出来之后,它就会进入本身的线程循环函数ksoftirqd_should_run和run_ksoftirqd了。不停地判断有没有软中断须要被处理。
这里须要注意的一点是,软中断不只仅只有网络软中断,还有其它类型:
//file: include/linux/interrupt.h
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,
};
网络子系统初始化:
linux内核经过调用subsys_initcall来初始化各个子系统,在源代码目录里你能够grep出许多对这个函数的调用。
这里咱们要说的是网络子系统的初始化,会执行到net_dev_init函数:
//file: net/core/dev.c
static int __init net_dev_init(void){
......
for_each_possible_cpu(i) {
structsoftnet_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;
}
操做系统内核实现了网络层的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数据结构中了。
以下图:
相关代码以下:
//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,
};
static int __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 charprotocol){
if(!prot->netns_ok) {
pr_err("Protocol %u is not namespace aware, cannot register.\n",
protocol);
return-EINVAL;
}
return !cmpxchg((conststructnet_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这个函数的代码。
每个驱动程序(不只仅只是网卡驱动)会使用 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,
......
};
static int __init igb_init_module(void){
......
ret = pci_register_driver(&igb_driver);
return ret;
}
驱动的pci_register_driver调用完成后,Linux内核就知道了该驱动的相关信息,好比igb网卡驱动的igb_driver_name和igb_probe函数地址等等。当网卡设备被识别之后,内核会调用其驱动的probe方法(igb_driver的probe方法是igb_probe)。驱动probe方法执行的目的就是让设备ready,对于igb网卡,其igb_probe位于drivers/net/ethernet/intel/igb/igb_main.c下。
主要执行的操做以下:
第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
static const struct 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);
}
当上面的初始化都完成之后,就能够启动网卡了。
回忆前面网卡驱动初始化时,咱们提到了驱动向内核注册了 structure net_device_ops 变量,它包含着网卡启用、发包、设置mac 地址等回调函数(函数指针)。当启用一个网卡时(例如,经过 ifconfig eth0 up),net_device_ops 中的 igb_open方法会被调用。
它一般会作如下事情:
//file: drivers/net/ethernet/intel/igb/igb_main.c
static int __igb_open(struct net_device *netdev, bool resuming){
/* allocate transmit descriptors */
err = igb_setup_all_tx_resources(adapter);
/* allocate receive descriptors */
err = igb_setup_all_rx_resources(adapter);
/* 注册中断处理函数 */
err = igb_request_irq(adapter);
if(err)
goto err_req_irq;
/* 启用NAPI */
for(i = 0; i < adapter->num_q_vectors; i++)
napi_enable(&(adapter->q_vector[I ]->napi));
......
}
在上面__igb_open函数调用了igb_setup_all_tx_resources,和igb_setup_all_rx_resources。在igb_setup_all_rx_resources这一步操做中,分配了RingBuffer,并创建内存和Rx队列的映射关系。(Rx Tx 队列的数量和大小能够经过 ethtool 进行配置)。
咱们再接着看中断函数注册igb_request_irq:
static int igb_request_irq(struct igb_adapter *adapter){
if(adapter->msix_entries) {
err = igb_request_msix(adapter);
if(!err)
goto request_done;
......
}
}
static int igb_request_msix(struct igb_adapter *adapter){
......
for(i = 0; i < adapter->num_q_vectors; i++) {
...
err = request_irq(adapter->msix_entries[vector].vector,
igb_msix_ring, 0, q_vector->name,
}
在上面的代码中跟踪函数调用, __igb_open => igb_request_irq => igb_request_msix, 在igb_request_msix中咱们看到了,对于多队列的网卡,为每个队列都注册了中断,其对应的中断处理函数是igb_msix_ring(该函数也在drivers/net/ethernet/intel/igb/igb_main.c下)。
咱们也能够看到,msix方式下,每一个 RX 队列有独立的MSI-X 中断,从网卡硬件中断的层面就能够设置让收到的包被不一样的 CPU处理。(能够经过 irqbalance ,或者修改 /proc/irq/IRQ_NUMBER/smp_affinity可以修改和CPU的绑定行为)。
当作好以上准备工做之后,就能够开门迎客(数据包)了!
首先:当数据帧从网线到达网卡上的时候,第一站是网卡的接收队列。
网卡在分配给本身的RingBuffer中寻找可用的内存位置,找到后DMA引擎会把数据DMA到网卡以前关联的内存里,这个时候CPU都是无感的。当DMA操做完成之后,网卡会像CPU发起一个硬中断,通知CPU有数据到达。
网卡数据硬中断处理过程:
注意:当RingBuffer满的时候,新来的数据包将给丢弃。ifconfig查看网卡的时候,能够里面有个overruns,表示由于环形队列满被丢弃的包。若是发现有丢包,可能须要经过ethtool命令来加大环形队列的长度。
在启动网卡一节,咱们说到了网卡的硬中断注册的处理函数是igb_msix_ring:
//file: drivers/net/ethernet/intel/igb/igb_main.c
static irqreturn_t igb_msix_ring(intirq, void *data){
struct igb_q_vector *q_vector = data;
/* Write the ITR value calculated from the previous interrupt. */
igb_write_itr(q_vector);
napi_schedule(&q_vector->napi);
return IRQ_HANDLED;
}
igb_write_itr只是记录一下硬件中断频率(听说目的是在减小对CPU的中断频率时用到)。
顺着napi_schedule调用一路跟踪下去,__napi_schedule=>____napi_schedule:
/* Called with irq disabled */
static inline void____napi_schedule(struct softnet_data *sd,
struct napi_struct *napi){
list_add_tail(&napi->poll_list, &sd->poll_list);
__raise_softirq_irqoff(NET_RX_SOFTIRQ);
}
这里咱们看到:list_add_tail修改了CPU变量softnet_data里的poll_list,将驱动napi_struct传过来的poll_list添加了进来。
其中:softnet_data中的poll_list是一个双向列表,其中的设备都带有输入帧等着被处理。紧接着__raise_softirq_irqoff触发了一个软中断NET_RX_SOFTIRQ, 这个所谓的触发过程只是对一个变量进行了一次或运算而已。
void __raise_softirq_irqoff(unsigned int nr){
trace_softirq_raise(nr);
or_softirq_pending(1UL << nr);
}
//file: include/linux/irq_cpustat.h
#define or_softirq_pending(x) (local_softirq_pending() |= (x))
咱们说过:Linux在硬中断里只完成简单必要的工做,剩下的大部分的处理都是转交给软中断的。
经过上面代码能够看到:硬中断处理过程真的是很是短。只是记录了一个寄存器,修改了一下下CPU的poll_list,而后发出个软中断。就这么简单,硬中断工做就算是完成了。
ksoftirqd内核线程:
内核线程初始化的时候,咱们介绍了ksoftirqd中两个线程函数ksoftirqd_should_run和run_ksoftirqd。
其中ksoftirqd_should_run代码以下:
static int ksoftirqd_should_run(unsigned int cpu){
return local_softirq_pending();
}
#define local_softirq_pending() \ __IRQ_STAT(smp_processor_id(), __softirq_pending)
这里看到和硬中断中调用了同一个函数local_softirq_pending。使用方式不一样的是硬中断位置是为了写入标记,这里仅仅只是读取。若是硬中断中设置了NET_RX_SOFTIRQ,这里天然能读取的到。
接下来会真正进入线程函数中run_ksoftirqd处理:
static void run_ksoftirqd(unsigned int cpu){
local_irq_disable();
if(local_softirq_pending()) {
__do_softirq();
rcu_note_context_switch(cpu);
local_irq_enable();
cond_resched();
return;
}
local_irq_enable();
}
在__do_softirq中,判断根据当前CPU的软中断类型,调用其注册的action方法。
asmlinkage void__do_softirq(void){
do{
if(pending & 1) {
unsigned int vec_nr = h - softirq_vec;
int prev_count = preempt_count();
...
trace_softirq_entry(vec_nr);
h->action(h);
trace_softirq_exit(vec_nr);
...
}
h++;
pending >>= 1;
} while(pending);
}
在网络子系统初始化小节, 咱们看到咱们为NET_RX_SOFTIRQ注册了处理函数net_rx_action。因此net_rx_action函数就会被执行到了。
这里须要注意一个细节,硬中断中设置软中断标记,和ksoftirq的判断是否有软中断到达,都是基于smp_processor_id()的。这意味着只要硬中断在哪一个CPU上被响应,那么软中断也是在这个CPU上处理的。因此说,若是你发现你的Linux软中断CPU消耗都集中在一个核上的话,作法是要把调整硬中断的CPU亲和性,来将硬中断打散到不一样的CPU核上去。
咱们再来把精力集中到这个核心函数net_rx_action上来:
static void net_rx_action(struct softirq_action *h){
struct softnet_data *sd = &__get_cpu_var(softnet_data);
unsigned long time_limit = jiffies + 2;
int budget = netdev_budget;
void *have;
local_irq_disable();
while(!list_empty(&sd->poll_list)) {
......
n = list_first_entry(&sd->poll_list, struct napi_struct, poll_list);
work = 0;
if(test_bit(NAPI_STATE_SCHED, &n->state)) {
work = n->poll(n, weight);
trace_napi_poll(n);
}
budget -= work;
}
}
函数开头的time_limit和budget是用来控制net_rx_action函数主动退出的,目的是保证网络包的接收不霸占CPU不放。等下次网卡再有硬中断过来的时候再处理剩下的接收数据包。其中budget能够经过内核参数调整。这个函数中剩下的核心逻辑是获取到当前CPU变量softnet_data,对其poll_list进行遍历, 而后执行到网卡驱动注册到的poll函数。
对于igb网卡来讲,就是igb驱动力的igb_poll函数了:
static int igb_poll(struct napi_struct *napi, int budget){
...
if(q_vector->tx.ring)
clean_complete = igb_clean_tx_irq(q_vector);
if(q_vector->rx.ring)
clean_complete &= igb_clean_rx_irq(q_vector, budget);
...
}
在读取操做中,igb_poll的重点工做是对igb_clean_rx_irq的调用:
static bool igb_clean_rx_irq(struct igb_q_vector *q_vector, const int budget){
...
do{
/* retrieve a buffer from the ring */
skb = igb_fetch_rx_buffer(rx_ring, rx_desc, skb);
/* fetch next buffer in frame if non-eop */
if(igb_is_non_eop(rx_ring, rx_desc))
continue;
}
/* verify the packet layout is correct */
if(igb_cleanup_headers(rx_ring, rx_desc, skb)) {
skb = NULL;
continue;
}
/* populate checksum, timestamp, VLAN, and protocol */
igb_process_skb_fields(rx_ring, rx_desc, skb);
napi_gro_receive(&q_vector->napi, skb);
}
igb_fetch_rx_buffer和igb_is_non_eop的做用就是把数据帧从RingBuffer上取下来。
为何须要两个函数呢?由于有可能帧要占多多个RingBuffer,因此是在一个循环中获取的,直到帧尾部。获取下来的一个数据帧用一个sk_buff来表示。收取完数据之后,对其进行一些校验,而后开始设置sbk变量的timestamp, VLAN id, protocol等字段。
接下来进入到napi_gro_receive中:
//file: net/core/dev.c
gro_result_t napi_gro_receive(struct napi_struct *napi, struct sk_buff *skb){
skb_gro_reset_offset(skb);
return napi_skb_finish(dev_gro_receive(napi, skb), skb);
}
dev_gro_receive这个函数表明的是网卡GRO特性,能够简单理解成把相关的小包合并成一个大包就行,目的是减小传送给网络栈的包数,这有助于减小 CPU 的使用量。咱们暂且忽略,直接看napi_skb_finish。
这个函数主要就是调用了netif_receive_skb:
//file: net/core/dev.c
static gro_result_t napi_skb_finish(gro_result_t ret, struct sk_buff *skb){
switch(ret) {
case GRO_NORMAL:
if(netif_receive_skb(skb))
ret = GRO_DROP;
break;
......
}
在netif_receive_skb中,数据包将被送到协议栈中。声明,如下的5.三、5.四、5.5也都属于软中断的处理过程,只不过因为篇幅太长,单独拿出来成小节。
netif_receive_skb函数会根据包的协议,假如是udp包,会将包依次送到ip_rcv(),udp_rcv()协议处理函数中进行处理。
网络协议栈处理:
//file: net/core/dev.c
int netif_receive_skb(struct sk_buff *skb){
//RPS处理逻辑,先忽略 ......
return __netif_receive_skb(skb);
}
static int __netif_receive_skb(struct sk_buff *skb){
......
ret = __netif_receive_skb_core(skb, false);}static int __netif_receive_skb_core(struct sk_buff *skb, bool pfmemalloc){
......
//pcap逻辑,这里会将数据送入抓包点。tcpdump就是从这个入口获取包的 list_for_each_entry_rcu(ptype, &ptype_all, list) {
if(!ptype->dev || ptype->dev == skb->dev) {
if(pt_prev)
ret = deliver_skb(skb, pt_prev, orig_dev);
pt_prev = ptype;
}
}
......
list_for_each_entry_rcu(ptype,
&ptype_base[ntohs(type) & PTYPE_HASH_MASK], list) {
if(ptype->type == type &&
(ptype->dev == null_or_dev || ptype->dev == skb->dev ||
ptype->dev == orig_dev)) {
if(pt_prev)
ret = deliver_skb(skb, pt_prev, orig_dev);
pt_prev = ptype;
}
}
}
在__netif_receive_skb_core中,我看着原来常用的tcpdump的抓包点,非常激动,看来读一遍源代码时间真的没白浪费。
接着__netif_receive_skb_core取出protocol,它会从数据包中取出协议信息,而后遍历注册在这个协议上的回调函数列表。ptype_base 是一个 hash table,在协议注册小节咱们提到过。ip_rcv 函数地址就是存在这个 hash table中的。
//file: net/core/dev.c
static inline int deliver_skb(struct sk_buff *skb,
struct packet_type *pt_prev,
struct net_device *orig_dev){
......
return pt_prev->func(skb, skb->dev, pt_prev, orig_dev);
}
pt_prev->func这一行就调用到了协议层注册的处理函数了。对于ip包来说,就会进入到ip_rcv(若是是arp包的话,会进入到arp_rcv)。
咱们再来大体看一下linux在ip协议层都作了什么,包又是怎么样进一步被送到udp或tcp协议处理函数中的。
//file: net/ipv4/ip_input.c
int ip_rcv(struct sk_buff *skb, struct net_device *dev, struct packet_type *pt, struct net_device *orig_dev){
......
return NF_HOOK(NFPROTO_IPV4, NF_INET_PRE_ROUTING, skb, dev, NULL, ip_rcv_finish);
}
这里NF_HOOK是一个钩子函数,当执行完注册的钩子后就会执行到最后一个参数指向的函数ip_rcv_finish。
static int ip_rcv_finish(struct sk_buff *skb){
......
if(!skb_dst(skb)) {
int err = ip_route_input_noref(skb, iph->daddr, iph->saddr, iph->tos, skb->dev);
...
}
......
return dst_input(skb);
}
跟踪ip_route_input_noref 后看到它又调用了 ip_route_input_mc。
在ip_route_input_mc中,函数ip_local_deliver被赋值给了dst.input, 以下:
//file: net/ipv4/route.c
static int ip_route_input_mc(struct sk_buff *skb, __be32 daddr, __be32 saddr,u8 tos, struct net_device *dev, int our){
if(our) {
rth->dst.input= ip_local_deliver;
rth->rt_flags |= RTCF_LOCAL;
}
}
因此回到ip_rcv_finish中的return dst_input(skb):
/* Input packet from network to transport. */
static inline intdst_input(struct sk_buff *skb){
return skb_dst(skb)->input(skb);
}
skb_dst(skb)->input调用的input方法就是路由子系统赋的ip_local_deliver:
//file: net/ipv4/ip_input.c
int ip_local_deliver(struct sk_buff *skb){
/* * Reassemble IP fragments. */
if(ip_is_fragment(ip_hdr(skb))) {
if(ip_defrag(skb, IP_DEFRAG_LOCAL_DELIVER))
return 0;
}
return NF_HOOK(NFPROTO_IPV4, NF_INET_LOCAL_IN, skb, skb->dev, NULL, ip_local_deliver_finish);
}
static int ip_local_deliver_finish(struct sk_buff *skb){
......
int protocol = ip_hdr(skb)->protocol;
const struct net_protocol *ipprot;
ipprot = rcu_dereference(inet_protos[protocol]);
if(ipprot != NULL) {
ret = ipprot->handler(skb);
}
}
如协议注册小节看到inet_protos中保存着tcp_rcv()和udp_rcv()的函数地址。这里将会根据包中的协议类型选择进行分发,在这里skb包将会进一步被派送到更上层的协议中,udp和tcp。
在协议注册小节的时候咱们说过,udp协议的处理函数是udp_rcv。
//file: net/ipv4/udp.c
int udp_rcv(struct sk_buff *skb){
return __udp4_lib_rcv(skb, &udp_table, IPPROTO_UDP);
}
int __udp4_lib_rcv(struct sk_buff *skb, struct udp_table *udptable,
int proto){
sk = __udp4_lib_lookup_skb(skb, uh->source, uh->dest, udptable);
if(sk != NULL) {
intret = udp_queue_rcv_skb(sk, skb
}
icmp_send(skb, ICMP_DEST_UNREACH, ICMP_PORT_UNREACH, 0);
}
__udp4_lib_lookup_skb是根据skb来寻找对应的socket,当找到之后将数据包放到socket的缓存队列里。若是没有找到,则发送一个目标不可达的icmp包。
//file: net/ipv4/udp.c
int udp_queue_rcv_skb(struct sock *sk, struct sk_buff *skb){
......
if(sk_rcvqueues_full(sk, skb, sk->sk_rcvbuf))
goto drop;
rc = 0;
ipv4_pktinfo_prepare(skb);
bh_lock_sock(sk);
if(!sock_owned_by_user(sk))
rc = __udp_queue_rcv_skb(sk, skb);
else if(sk_add_backlog(sk, skb, sk->sk_rcvbuf)) {
bh_unlock_sock(sk);
goto drop;
}
bh_unlock_sock(sk);
return rc;
}
sock_owned_by_user判断的是用户是否是正在这个socker上进行系统调用(socket被占用),若是没有,那就能够直接放到socket的接收队列中。若是有,那就经过sk_add_backlog把数据包添加到backlog队列。
当用户释放的socket的时候,内核会检查backlog队列,若是有数据再移动到接收队列中。
sk_rcvqueues_full接收队列若是满了的话,将直接把包丢弃。接收队列大小受内核参数net.core.rmem_max和net.core.rmem_default影响。
花开两朵,各表一枝。上面咱们说完了整个Linux内核对数据包的接收和处理过程,最后把数据包放到socket的接收队列中了。那么咱们再回头看用户进程调用recvfrom后是发生了什么。
咱们在代码里调用的recvfrom是一个glibc的库函数,该函数在执行后会将用户进行陷入到内核态,进入到Linux实现的系统调用sys_recvfrom。
在理解Linux对sys_revvfrom以前,咱们先来简单看一下socket这个核心数据结构。这个数据结构太大了,咱们只把对和咱们今天主题相关的内容画出来。
以下(socket内核数据机构):
socket数据结构中的const struct proto_ops对应的是协议的方法集合。每一个协议都会实现不一样的方法集,对于IPv4 Internet协议族来讲,每种协议都有对应的处理方法,以下。对于udp来讲,是经过inet_dgram_ops来定义的,其中注册了inet_recvmsg方法。
//file: net/ipv4/af_inet.c
const struct proto_ops inet_stream_ops = {
......
.recvmsg = inet_recvmsg,
.mmap = sock_no_mmap,
......
}
const struct proto_ops inet_dgram_ops = {
......
.sendmsg = inet_sendmsg,
.recvmsg = inet_recvmsg,
......
}
socket数据结构中的另外一个数据结构struct sock *sk是一个很是大,很是重要的子结构体。其中的sk_prot又定义了二级处理函数。对于UDP协议来讲,会被设置成UDP协议实现的方法集udp_prot。
//file: net/ipv4/udp.c
struct proto udp_prot = {
.name = "UDP",
.owner = THIS_MODULE,
.close = udp_lib_close,
.connect = ip4_datagram_connect,
......
.sendmsg = udp_sendmsg,
.recvmsg = udp_recvmsg,
.sendpage = udp_sendpage,
......
}
看完了socket变量以后,咱们再来看sys_revvfrom的实现过程。
recvfrom函数内部实现过程:
在inet_recvmsg调用了sk->sk_prot->recvmsg:
//file: net/ipv4/af_inet.c
int inet_recvmsg(struct kiocb *iocb, struct socket *sock, struct msghdr *msg,size_tsize, int flags){
......
err = sk->sk_prot->recvmsg(iocb, sk, msg, size, flags & MSG_DONTWAIT,
flags & ~MSG_DONTWAIT, &addr_len);
if(err >= 0)
msg->msg_namelen = addr_len;
return err;
}
上面咱们说过这个对于udp协议的socket来讲,这个sk_prot就是net/ipv4/udp.c下的struct proto udp_prot。由此咱们找到了udp_recvmsg方法。
//file:net/core/datagram.c:EXPORT_SYMBOL(__skb_recv_datagram);
struct sk_buff *__skb_recv_datagram(struct sock *sk, unsigned int flags,int*peeked, int *off, int *err){
......
do{
struct sk_buff_head *queue = &sk->sk_receive_queue;
skb_queue_walk(queue, skb) {
......
}
/* User doesn't want to wait */
error = -EAGAIN;
if(!timeo)
goto no_packet;
} while(!wait_for_more_packets(sk, err, &timeo, last));
}
终于:咱们找到了咱们想要看的重点,在上面咱们看到了所谓的读取过程,就是访问sk->sk_receive_queue。若是没有数据,且用户也容许等待,则将调用wait_for_more_packets()执行等待操做,它加入会让用户进程进入睡眠状态。
网络模块是操做系统内核中最复杂的模块了,看起来一个简简单单的收包过程就涉及到许多内核组件之间的交互,如网卡驱动、协议栈、内核ksoftirqd线程等,看起来很复杂。本文想经过图示的方式,尽可能以容易理解的方式来将内核收包过程讲清楚。
如今让咱们再串一串整个收包过程:当用户执行完recvfrom调用后,用户进程就经过系统调用进行到内核态工做了。若是接收队列没有数据,进程就进入睡眠状态被操做系统挂起。这块相对比较简单,剩下大部分的戏份都是由Linux内核其它模块来表演了。
首先在开始收包以前,操做系统要作许多的准备工做(以Linux为例):
以上是内核准备收包以前的重要工做,当上面都ready以后,就能够打开硬中断,等待数据包的到来了。
当数据到来了之后,第一个迎接它的是网卡(我去,这不是废话么):
如今,咱们能够回到开篇的问题了:咱们在用户层看到的简单一行recvfrom,Linux内核要替咱们作如此之多的工做,才能让咱们顺利收到数据。
这仍是简简单单的UDP,若是是TCP,内核要作的工做更多,不禁得感叹内核的开发者们真的是用心良苦。
理解了整个收包过程之后,咱们就能明确知道Linux收一个包的CPU开销了:
后面咱们再专门发一篇文章实际观察一下这些开销。
另外:网络收发中有不少末支细节我们并无展开了说,好比说:no NAPI, GRO,RPS等。由于我以为说的太对了反而会影响你们对整个流程的把握,因此尽可能只保留主框架了,少便是多!
若是您以为本系列文章过于专业,可先阅读《网络编程懒人入门》系列,目录以下:
《网络编程懒人入门(五):快速理解为何说UDP有时比TCP更有优点》
《网络编程懒人入门(六):史上最通俗的集线器、交换机、路由器功能原理入门》
本文已同步发布于“即时通信技术圈”公众号。
▲ 本文在公众号上的连接是:点此进入,原文连接是:http://www.52im.net/thread-3247-1-1.html