收比发要复杂点,主要包含2方面
- APP调用recvfrom系统调用,然后阻塞在内核,等待包的到来
- 包到达网卡后,触发硬中断,中断例程又把执行流转到软中断,然后执行流收网卡数据,并根据路由信息确定本地包,最后把包放入队列中,唤醒阻塞的进程。
3、网络收包总览
在TCP/IP网络分层模型里,整个协议栈被分成了:物理层、链路层、网络层,传输层和应用层。
物理层对应的是网卡和网线,应用层对应的是常见的Nginx,FTP等各种应用。Linux实现的是链路层、网络层和传输层这3层。
在Linux内核实现中,链路层协议靠网卡驱动来实现,内核协议栈来实现网络层和传输层。内核对更上层的应用层提供socket接口来供用户进程访问。
用Linux的视角来看到的TCP/IP网络分层模型应是下面这样:

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

如上图:当网卡上收到数据后,Linux中第1个工作的模块是网络驱动。网络驱动会以DMA的方式把网卡上收到的帧写到内存里。再向CPU发起1个中断,通知CPU有数据到达。第2,当CPU收到中断请求后,调用网络驱动注册的中断处理函数。网卡的中断处理函数并不做过多工作,发出软中断请求,然后尽快释放CPU。ksoftirqd检测到有软中断请求到达,调用poll开始轮询收包,收到后交由各级协议栈处理。UDP包会被放到用户socket的收队列中。
从上面这张图中已从整体上把握到了OS对包的处理过程。但要想了解更多网络模块工作的细节,还得往下看。
4、网络数据到来前OS的准备
Linux驱动、内核协议栈等模块在具备收网卡包前,要做很多的准备工作才行。
如:要提前创建好ksoftirqd内核线程,要注册好各协议对应的处理函数,网络设备子系统要提前初始化好,网卡要启动好。只有这些都Ready后,才能真正开始收包。
现在来看看这些准备工作都是怎么做的。
4.1创建ksoftirqd内核线程
Linux的软中断在专门的内核线程ksoftirqd中进行,因此很有必要看下这些进程怎么初始化,这样才能在后面更准确地了解收包过程。该进程数量不是1个,而是N个,N等于机器的核数。
系统初始化时在kernel/smpboot.c中调用了smpboot_register_percpu_thread, 该函数进1步会执行到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 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了。不停地判断有没软中断需被处理。
注意,软中断不仅有网络软中断,还有其它类型:
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,
};
4.2网络子系统初始化
网络子系统初始化:

linux内核通过调用subsys_initcall来初始化各子系统,在源码目录里可grep出许多对此函数的调用。
这里要说的是网络子系统的初始化,会执行到net_dev_init函数:
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都申请1个softnet_data数据结构,此数据结构里的poll_list是等待驱动程序将其poll函数注册进来,稍后网卡驱动初始化时可看到这1过程。
另外open_softirq注册了每种软中断都注册1个处理函数。NET_TX_SOFTIRQ的处理函数为net_tx_action,NET_RX_SOFTIRQ的为net_rx_action。继续跟踪open_softirq后发现此注册的方式是记录在softirq_vec变量里。后面ksoftirqd线程收到软中断时,也会使用此变量来找到每种软中断对应的处理函数。
void open_softirq(int nr, void (*action)(struct softirq_action *)){
softirq_vec[nr].action = action;
}
4.3协议栈注册
OS内核实现了网络层的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 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过滤,若有很多或很复杂的 netfilter规则,这些规则都在软中断的上下文中执行,会加大网络延迟。
再如:udp_rcv中会判断socket收队列是否满了。对应的相关内核参数是net.core.rmem_max和net.core.rmem_default。若有兴趣,建议大家好好读下inet_init此函数的代码。
4.4网卡驱动初始化
每个驱动程序(不仅是网卡驱动)会使用 module_init 向内核注册1个初始化函数,当驱动被加载时,内核会调用此函数。
如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 发起1个系统调用后,内核会找到对应操作的回调函数。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。他注册了1个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);
}
4.5启动网卡
当上面的初始化都完成后,就可启动网卡了。
回忆前面网卡驱动初始化时,提到了驱动向内核注册了 struct net_device_ops 变量,它包含着网卡启用、发包、设置mac等回调函数(函数指针)。当启用1个网卡时(如,通过 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的绑定行为)。
当做好以上准备工作以后,就可开门迎客(包)了!
5、开始迎接数据的到来
5.1硬中断处理
首先:当数据帧从网线到达网卡上时,第1站是网卡的收队列。
网卡在分配给自己的RingBuffer中寻找可用内存,找到后DMA引擎会把数据DMA到网卡之前关联的内存里,此时CPU无感。当DMA操作完后,网卡会像CPU发起1个硬中断,通知CPU有数据到达。
网卡数据硬中断处理过程:

注意:当RingBuffer满时,新来的包将丢弃。ifconfig查看网卡时,可里面有个overruns,表示因环形队列满被丢弃的包。若发现有丢包,可能需通过ethtool命令来加大环形队列的长度。
在启动网卡1节,说到了网卡的硬中断注册的处理函数是igb_msix_ring:
//file: drivers/net/ethernet/intel/igb/igb_main.c
static irqreturn_t igb_msix_ring(int irq, 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调用1路跟踪下去,__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触发了1个软中断NET_RX_SOFTIRQ, 触发过程只是对1个变量进行1次或运算。
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在硬中断里只完成简单必要的工作,剩下的大部分的处理都转交给软中断。
通过上面代码可看到:硬中断处理过程真的很短。只是记录了1个寄存器,修改了1下CPU的poll_list,然后发出个软中断。
5.2ksoftirqd内核线程处理软中断
ksoftirqd内核线程:

内核线程初始化时,介绍了ksoftirqd中2个线程函数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消耗都集中在1个核上,要调整硬中断的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上取下来。
为何需2个函数?因有可能帧要占多个RingBuffer,所以在1个循环中获取,直到帧尾部。获取下来的1个数据帧用1个sk_buff表示。收取完数据后,对其进行1些校验,然后开始设置sbk变量的timestamp, VLAN id, protocol等字段。
接下来进入到napi_gro_receive中:
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特性,可简单理解成把相关的小包合并成1个大包,目的是减少传送给网络栈的包数,有助于减少 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.3、5.4、5.5也都属于软中断的处理过程,只不过由于篇幅太长,单独拿出来成小节。
5.3网络协议栈处理
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取出protocol,它会从包中取出协议信息,然后遍历注册在此协议上的回调函数列表。ptype_base 是1个 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)。
5.4ip层处理
//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是1个钩子函数,当执行完注册的钩子后就会执行到最后1个参数指向的函数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。
Linux网络路由选择很复杂,但总是设置dst_entry的input接口函数来实现具体功能:转发设置ip_forward;本地收设置ip_local_deliver。之后执行流程函数很多,调用路径也很长,从ip_local_deliver到udp_rcv,再到udp_queue_rcv_skb,最后在__udp_enqueue_schedule_skb中把skb加入队列,然后调用sk_data_ready接口唤醒阻塞线程。
在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 int dst_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包将会进1步被派送到更上层的协议中,udp和tcp。
5.5udp层处理
在协议注册小节时说过,udp的处理函数是udp_rcv。
ip_local_deliver_finish
ip_protocol_deliver_rcu
udp_rcv
__udp4_lib_rcv
udp_unicast_rcv_skb
udp_queue_rcv_skb
//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) {
int ret = udp_queue_rcv_skb(sk, skb
}
。。。。。。
icmp_send(skb, ICMP_DEST_UNREACH, ICMP_PORT_UNREACH, 0);
}
__udp4_lib_lookup_skb是根据skb来寻找对应的socket,找到后将包放到socket的缓存队列里。若没找到,则发1个目标不可达的icmp包。
static int udp_queue_rcv_skb(struct sock *sk, struct sk_buff *skb)
{
struct sk_buff *next, *segs;
int ret;
if (likely(!udp_unexpected_gso(sk, skb)))
return udp_queue_rcv_one_skb(sk, skb);
BUILD_BUG_ON(sizeof(struct udp_skb_cb) > SKB_GSO_CB_OFFSET);
__skb_push(skb, -skb_mac_offset(skb));
segs = udp_rcv_segment(sk, skb, true);
skb_list_walk_safe(segs, skb, next) {
__skb_pull(skb, skb_transport_offset(skb));
udp_post_segment_fix_csum(skb);
ret = udp_queue_rcv_one_skb(sk, skb);
if (ret > 0)
ip_protocol_deliver_rcu(dev_net(skb->dev), skb, ret);
}
return 0;
}
首先判断包是否需进行 GSO(Generic Segmentation Offload)处理,若否,则直接调用 udp_queue_rcv_one_skb将包加入收队列。若是,调用 udp_rcv_segment 对包分段,并依次将每个分段加入收队列。最后,若收队列中有包,则调用 ip_protocol_deliver_rcu将包交给上层协议处理。
GSO是种网络协议栈的优化技术,可将大块的包分割成更小的包,以便更好地利用网络带宽、减轻主机CPU的负担。在传输大量数据时,GSO可提高网络传输效率和性能,同时减少主机CPU负载,提高系统整体性能。GSO通常用于高速网络接口,如千兆以太网和万兆以太网等。
若网络设备支持GSO,内核会自动对符合条件的UDP包GSO处理,并将多个小的UDP包合并成1个大的包传输,提高网络传输效率。
在Linux内核中,收到的UDP包是否需进行GSO处理,取决于以下几个因素:
1. 收网卡是否支持GSO:只有支持GSO的网卡才能进行GSO处理。
2. 收缓冲区大小:若收缓冲区大小小于UDP包的大小,则需进行GSO处理。
3. MTU大小:若收网卡的MTU小于UDP包的大小,则需进行GSO处理。
4. 是否开启GSO:若开启了GSO,则需进行GSO处理。
若以上条件都满足,则需进行GSO处理,否则不需。在Linux内核中,可通过设置ethtool工具来开启或关闭GSO功能。
在收到UDP包后,可通过1些条件来判断是否需进行GSO处理:
- 包的总长度大于MTU
- 包的数据部分大于MSS(最大分段大小),即超过了TCP中的1个分段大小。当使用GSO时,每个分段的大小不能超过MSS。
- 当前网卡支持GSO特性。可通过检查网卡驱动是否支持GSO功能,若支持则可开启GSO。
- 内核中的特定参数已开启GSO处理。可通过sysctl工具对“net.core.xfrm_acq_expires”和“net.ipv4.tcp_syncookies”这2个参数进行设置,以启用GSO特性。
若上述条件全满足,则可开启GSO。对UDP包的GSO处理通常是在Linux内核中通过skb_shinfo->gso_segs计算出分段数量,再通过skb_shinfo->gso_size来设置每个分段的大小,从而进行具体的分段操作。
static inline bool udp_unexpected_gso(struct sock *sk, struct sk_buff *skb)
{
if (!skb_is_gso(skb))
return false;
if (skb_shinfo(skb)->gso_type & SKB_GSO_UDP_L4 && !udp_sk(sk)->accept_udp_l4)
return true;
if (skb_shinfo(skb)->gso_type & SKB_GSO_FRAGLIST && !udp_sk(sk)->accept_udp_fraglist)
return true;
return false;
}
该函数会根据输入参数sk和skb来判断是否需开启UDP数据报的GSO操作。
首先使用skb_is_gso判断当前SKB包是否可进行GSO处理。若否,直接返回false。若是,则检查包的gso_type类型,及是否开启了接受UDP分片或L4协议的标志。
static inline bool skb_is_gso(const struct sk_buff *skb)
{
return skb_shinfo(skb)->gso_size;
}
//gso_size表示生产GSO大包时的包长度,1般时mss的整数倍
当gso_type为SKB_GSO_UDP_L4,且accept_udp_l4标志没开启时,返回true。
if (skb_shinfo(skb)->gso_type & SKB_GSO_UDP_L4 && !udp_sk(sk)->accept_udp_l4)
return true;
同理,当gso_type为SKB_GSO_FRAGLIST,且accept_udp_fraglist标志没开启时,也返回true。
最后,若上述2个判断条件都不满足,则返回false。
static inline struct sk_buff *udp_rcv_segment(struct sock *sk,
struct sk_buff *skb, bool ipv4)
{
netdev_features_t features = NETIF_F_SG;
struct sk_buff *segs;
/* Avoid csum recalculation by skb_segment unless userspace explicitly
* asks for the final checksum values
*/
if (!inet_get_convert_csum(sk))
features |= NETIF_F_IP_CSUM | NETIF_F_IPV6_CSUM;
/* UDP segmentation expects packets of type CHECKSUM_PARTIAL or
* CHECKSUM_NONE in __udp_gso_segment. UDP GRO indeed builds partial
* packets in udp_gro_complete_segment. As does UDP GSO, verified by
* udp_send_skb. But when those packets are looped in dev_loopback_xmit
* their ip_summed CHECKSUM_NONE is changed to CHECKSUM_UNNECESSARY.
* Reset in this specific case, where PARTIAL is both correct and
* required.
*/
if (skb->pkt_type == PACKET_LOOPBACK)
skb->ip_summed = CHECKSUM_PARTIAL;
/* the GSO CB lays after the UDP one, no need to save and restore any
* CB fragment
*/
segs = __skb_gso_segment(skb, features, false);
if (IS_ERR_OR_NULL(segs)) {
int segs_nr = skb_shinfo(skb)->gso_segs;
atomic_add(segs_nr, &sk->sk_drops);
SNMP_ADD_STATS(__UDPX_MIB(sk, ipv4), UDP_MIB_INERRORS, segs_nr);
kfree_skb(skb);
return NULL;
}
consume_skb(skb);
return segs;
}
sock_owned_by_user判断用户是否正在此socker上进行系统调用(socket被占用),若否,直接放到socket的收队列中。若是,通过sk_add_backlog把包添加到backlog队列。
当用户释放的socket时,内核会检查backlog队列,若有数据再移动到收队列中。
sk_rcvqueues_full收队列若满了,将直接把包丢弃。收队列大小受内核参数net.core.rmem_max和net.core.rmem_default影响。
6、recvfrom系统调用
上面说完了整个Linux内核对包的收和处理过程,最后把包放到socket的收队列中了。再回头看用户进程调用recvfrom后发生了什么。
recvfrom是个glibc的库函数,该函数在执行后会将用户进行陷入到内核态,进入到Linux实现的系统调用sys_recvfrom。
SYSCALL_DEFINE6(recvfrom, int, fd, void __user *, ubuf, size_t, size,
unsigned int, flags, struct sockaddr __user *, addr,
int __user *, addr_len)
{
return __sys_recvfrom(fd, ubuf, size, flags, addr, addr_len);
}
在理解Linux对sys_revvfrom前,先来简单看下socket核心数据结构。该数据结构太大了,只把对和今天主题相关的内容画出来。
如下(socket内核数据机构):

socket数据结构中的const struct proto_ops对应的是协议的方法集合。每个协议都会实现不同的方法集,IPv4 Internet协议族每种协议都有对应的处理方法,如下。udp是通过inet_dgram_ops来定义的,其中注册了inet_recvmsg方法。
01
02
03
04
05
06
07
08
09
10
11
12
13
//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数据结构中的另1个数据结构struct sock *sk是个很大,很重要的子结构体。其中的sk_prot又定义了2级处理函数。对于udp,会被设置成udp实现的方法集udp_prot。
01
02
03
04
05
06
07
08
09
10
11
12
//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:
1
2
3
4
5
6
7
8
9
//file: net/ipv4/af_inet.c
int inet_recvmsg(struct kiocb *iocb, struct socket *sock, struct msghdr *msg,size_t size, 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方法。
UDP层收包的实现是udp_recvmsg。首先调用__skb_recv_udp获取队列中的sk_buff包,若有,就把相关的数据拷贝到用户空间,函数返回。每个socket都有自己的收队列,保存在udp_sock的reader_queue中,若队列中没数据,那就没什么事了,直接把线程挂起,等待调度。
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
//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()执行等待操作,它加入会让用户进程进入睡眠状态。
7、本文小结
网络模块是OS内核中最复杂的模块,看起来1个简单的收包过程就涉及到许多内核组件间的交互,如网卡驱动、协议栈、内核ksoftirqd线程等,看起来很复杂。本文想通过图示的方式,尽量以容易理解的方式来将内核收包过程讲清楚。
现在让再串1串整个收包过程:当用户执行完recvfrom调用后,用户进程就通过系统调用进行到内核态工作了。若收队列没数据,进程就进入睡眠状态被OS挂起。这块相对较简单,剩下大部分的戏份都是由Linux内核其它模块来表演了。
首先在开始收包前,OS要做许多的准备工作(以Linux为例):
- 1)创建ksoftirqd线程,为它设置好它自己的线程函数,后面指望着它来处理软中断;
- 2)协议栈注册,linux要实现许多协议,如arp,icmp,ip,udp,tcp,每个协议都会将自己的处理函数注册下,方便包来了迅速找到对应的处理函数;
- 3)网卡驱动初始化,每个驱动都有个初始化函数,内核会让驱动也初始化下。在此初始化过程中,把自己的DMA准备好,把NAPI的poll函数地址告诉内核;
- 4)启动网卡,分配RX,TX队列,注册中断对应的处理函数。
以上是内核准备收包前的重要工作,当上面都ready后,就可打开硬中断,等待包的到来。
当数据到来了后,第1个迎接它的是网卡:
- 1)网卡将数据帧DMA到内存的RingBuffer中,然后向CPU发起中断通知;
- 2)CPU响应中断请求,调用网卡启动时注册的中断处理函数;
- 3)中断处理函数几乎没干啥,就发起了软中断请求;
- 4)内核线程ksoftirqd线程发现有软中断请求到来,先关闭硬中断;
- 5)ksoftirqd线程开始调用驱动的poll函数收包;
- 6)poll函数将收到的包送到协议栈注册的ip_rcv函数中;
- 7)ip_rcv函数再讲包送到udp_rcv函数中(对于tcp包就送到tcp_rcv)。
现在,可回到开篇的问题了:在用户层看到的简单1行recvfrom,Linux内核要替做如此之多的工作,才能让顺利收到数据。
这还是简单的UDP,若是TCP,内核要做的工作更多。
理解了整个收包过程后,就能明确知道Linux收1个包的CPU开销了:
- 1)用户进程调用系统调用陷入内核态的开销;
- 2)CPU响应包的硬中断的CPU开销;
- 3)ksoftirqd内核线程的软中断上下文花费的。
后面再专门发篇文章实际观察下这些开销。
另外:网络收发中有很多末支细节并没展开说,如说:no NAPI, GRO,RPS等。
发包基本流程

创建socket
socket是内核统1对外的网络描述符,使用前先创建,内核中每个协议族都有自己的实现方式,inet_create函数就是IPv4创建socket的方法
发包
inet_sendmsg是IPv4协议族中发包入口,不管是stream包,还是packet包都由此函数处理,但它并没做啥实事,只是根据socket具体绑定的协议调用tcp_sendmsg或udp_sendmsg函数。
UDP组包
udp_sendmsg首先调用ip_route_output_flow确定路由对象;然后调用ip_make_skb分配sk_buff保存数据,关于包的所有信息都保存在sk_buff中,这是协议层和设备层通讯的关键数据结构;最后调用udp_send_skb把包发出去。
路由选择
发数据涉及到的路由项非常复杂,是由绑定在dst_entry中的output接口负责,1个dst_entry对象代表1个路由表项。单纯的UDP发就是把它绑定为ip_output函数发往对端的路由项。ip_output中有1个HOOK,此是网络的1个钩子,这里没设置,所以直接调用ip_finish_output把包发出去。
设备接口
IP包准备好后,调用网络设备框架发出去。框架代码是较完善的,但最终的功能还是要由设备驱动来实现,net_device的net_device_ops封装了网络设备的各种接口,驱动代码必须实现其中的某些接口,比如ndo_start_xmit就是发数据的关键接口,几乎每个网卡驱动都要实现它。Intel的e1000网卡的驱动代码在drivers/net/ethernet/intel/e1000中可找到。