本文将从初学者的视角介绍 Linux 内核是如何接收网络帧的:从网卡设备接收数据帧开始,到该帧被传递到网络栈的第三层结束。本文将聚焦于内核的工作机制,不会深入探讨驱动程序层面的过多细节。示例代码取自 Linux 2.6 版本。
网络设备接收到数据并将其存储在设备的接收帧缓冲区(该缓冲区可能位于设备内存中,或者位于通过直接内存访问(DMA)写入主机内存的接收环中)后,必须通知内核来处理接收到的数据。
轮询是指内核主动检查设备,例如,通过定期读取设备的内存寄存器来确定是否有新的传入帧需要处理。当设备负载较高时,这种方法效率低下;而当设备负载较低时,它会占用系统资源。因此,操作系统很少单独使用这种方法,通常会将其与其他机制结合使用以获得更好的效果。
当发生诸如接收到新数据帧之类的事件时,设备会生成一个硬件中断信号。该信号通常由网络设备发送给中断控制器,中断控制器再将其转发给 CPU。CPU 接收到信号后会从当前任务中被中断,转而执行设备驱动程序注册的中断处理程序来处理设备事件。中断处理程序将数据帧添加到内核的输入队列,并通知内核进行进一步处理。这种技术在低负载情况下表现良好,因为每个数据帧都能得到及时响应,但在高负载情况下,CPU 会频繁被中断,这会影响其他任务的执行。
接收到的帧的处理通常分为两部分:首先,驱动程序注册的中断处理程序将帧复制到内核可访问的输入队列(input queue),然后内核进行处理,通常是将其传递给相关协议(如 IPv4)的处理程序。中断处理程序的第一部分在中断上下文中执行,并且可以抢占第二部分的执行,这意味着将接收到的帧复制到输入队列的操作比消费这些数据帧的协议栈程序具有更高的优先级。
后果显而易见:输入队列最终会填满,但应该出队并处理这些帧的程序由于优先级较低而没有机会执行。结果,由于输入队列已满,新的接收帧无法添加到队列中,而旧的帧由于没有可用的 CPU 资源也不会被处理。这种情况被称为接收活锁。
硬件中断的优点是帧接收和处理之间的延迟非常低,但在高负载下会严重干扰其他内核或用户程序的执行。大多数网络驱动程序会使用某种优化版本的硬件中断。
一些设备驱动程序采用了一种改进的方法,即在执行中断处理程序时,它会在指定的窗口时间或帧数量限制内持续将数据帧入队。由于在执行中断处理程序时会禁用其他中断,因此必须设置合理的执行策略,以便与其他任务共享 CPU 资源。
这种方法可以进一步优化,让设备仅通过硬件中断通知内核有待接收的帧,而将接收帧的入队和处理工作留给其他内核处理程序。这也是 Linux 的新接口 NAPI 的工作方式。
除了根据事件立即生成中断外,当有接收到的帧时,设备还可以按固定间隔发送中断。中断处理程序会检查在这个间隔内是否有新的帧,并一次性处理所有帧。如果所有接收到的帧都已处理且没有新的帧,设备将停止发送中断。
这种方法要求设备在硬件层面实现定时功能,并且会根据定时间隔产生固定的处理延迟,但在高负载下能有效降低 CPU 使用率并避免接收活锁。
不同的中断通知机制都有各自适用的工作场景:纯中断模式在低负载时能保证极低的延迟,但在高负载时性能较差;定时中断在低负载时可能会引入过长的延迟并浪费 CPU 时间,但在高负载时有助于降低 CPU 使用率并解决接收死锁问题。在实践中,网络设备通常不会仅依赖单一模式,而是采用多种解决方案的组合。
以 Linux 2.6 系统中 Vortex 设备注册的中断处理函数 vortex_interrupt(位于 /drivers/net/3c59x.c)为例。
vortex_interrupt 函数的执行,并禁用该 CPU 上的中断。RxComplete 触发的,处理程序会调用其他代码来处理设备接收到的帧。vortex_interrupt 在执行过程中会持续读取设备寄存器,以检查是否有新的中断信号。如果有新中断且中断事件为 RxComplete,处理程序会继续处理接收到的帧,直到处理的帧数达到预设的 work_done 值。处理程序会忽略其他类型的中断。一次中断通常会触发以下事件。
当 CPU 接收到中断通知时,它会调用与该中断号对应的处理程序。在处理程序执行期间,内核代码处于中断上下文,且中断被禁用。这意味着当 CPU 正在处理一个中断时,它既不会处理其他中断,也不会被其他进程抢占;CPU 资源由该中断处理程序独占。这种设计决策减少了竞争条件的可能性,但也可能对性能产生潜在影响。
显然,中断处理程序应尽可能快速地完成工作。不同的中断事件所需的处理工作量并不相同。例如,当按下键盘上的某个按键时,触发的中断处理函数只需记录按键代码,而且这种事件并不经常发生;而当处理网络设备接收到的新数据帧时,需要为 skb 分配内存空间,复制接收到的数据,并完成一些初始化工作,如确定数据所属的网络协议等。
为此,操作系统引入了中断处理程序上半部和下半部的概念。
尽管中断触发的处理动作需要大量的 CPU 时间,但大多数动作通常可以等待。中断之所以能优先抢占 CPU 执行权,是因为如果操作系统让硬件等待太久,硬件可能会丢失数据。这对于实时数据和存储在固定大小缓冲区中的数据都适用。如果硬件丢失了数据,通常就无法再次恢复(不考虑发送方重传的情况)。另一方面,内核或用户空间的进程执行延迟或被抢占通常不会造成什么损失(除了那些对实时性要求极高的系统,这类系统需要以完全不同的方式处理进程和中断)。
考虑到这些因素,现代的中断处理程序被分为上半部和下半部。上半部执行那些在释放 CPU 资源之前必须完成的工作,比如保存接收到的数据;而下半部执行那些可以推迟到空闲时间进行的工作,比如完成对接收到的数据的进一步处理。
你可以将下半部看作一个可以异步执行的特定函数。当中断触发时,有些工作不需要立即完成,我们可以将这些工作打包成一个下半部处理程序,留待以后执行。上半部和下半部的工作模式可以有效减少 CPU 处于中断上下文(即中断禁用)的时间。
设备向 CPU 发送中断信号,通知其发生了特定事件。
CPU 执行与中断相关的处理程序函数的上半部,在处理程序完成工作之前禁用后续的中断通知:
a. 将一些数据存储在内存中,以便内核稍后进一步处理该中断事件。
b. 设置一个标志位,确保内核知晓有未处理的中断。
c. 在结束前重新启用本地 CPU 的中断通知。
在稍后的某个时间,当内核没有更紧急的任务要处理时,它会检查处理程序上半部设置的标志位,并调用关联的下半部处理程序。调用完成后,它会重置该标志位,然后进入下一轮处理。
Linux为下半部处理实现了几种不同的机制:软中断、微任务和工作队列,这些机制同样适用于操作系统中的延迟任务。下半部处理机制通常具有以下常见特征。
下一节将重点介绍用于处理网络数据帧的软中断机制。
有几种常见类型的软中断,如下所示。
enum{ HI_SOFTIRQ=0, TIMER_SOFTIRQ, NET_TX_SOFTIRQ, NET_RX_SOFTIRQ, BLOCK_SOFTIRQ, IRQ_POLL_SOFTIRQ, TASKLET_SOFTIRQ,};其中,NET_TX_SOFTIRQ和NET_RX_SOFTIRQ用于处理网络数据的接收与发送。
每次网络设备接收到一个数据帧时,它都会发送一个硬件中断来通知内核调用中断处理程序,该程序会触发本地CPU上软中断的调度,具备以下功能。
__raise_softirq_irqoff:在一个专用的位图结构中设置与软中断类型对应的位图,并在随后对位图进行检查结果为真时,调用与该软中断关联的处理程序。每个CPU都使用一个单独的位图。
raise_softirq_irqoff:内部封装了__raise_softirq_irqoff函数。如果该函数不是从中断上下文调用且未禁用抢占,那么会额外调度一个ksoftirqd线程。
raise_softirq:内部封装了raise_softirq_irqoff函数,但在禁用CPU中断的情况下执行。
在某一时刻,内核会检查每个CPU独有的位图,以确定是否存在已调度的软中断等待执行。如果存在,就会调用do_softirq来处理这些软中断。内核会在以下时机处理软中断。
do_IRQ
每当内核接收到硬件中断的IRQ通知时,它就会调用do_IRQ来执行该中断的处理程序。在中断处理程序中可能会调度新的软中断,因此在do_IRQ结束时处理软中断是一种自然的设计,并且可以有效减少延迟。此外,内核的时钟中断保证了两次软中断处理时机之间的最大时间间隔。
大多数架构会在退出中断上下文步骤irq_exit()中调用do_softirq。
unsigned int __irq_entry do_IRQ(struct pt_regs *regs){ ...... exit_idle(); irq_enter(); // handle irq with registered handler irq_exit(); set_irq_regs(old_regs);return 1;}在irq_exit()中,如果内核已退出中断上下文且存在待处理的软中断,则会调用invoke_softirq()。
void irq_exit(void){ account_system_vtime(current); trace_hardirq_exit(); sub_preempt_count(IRQ_EXIT_OFFSET);if (!in_interrupt() && local_softirq_pending()) invoke_softirq(); rcu_irq_exit(); preempt_enable_no_resched();}invoke_softirq是对do_softirq的简单封装。
static inline void invoke_softirq(void){if (!force_irqthreads) do_softirq();else wakeup_softirqd();}当中断和异常事件(包括系统调用)返回时,这部分处理逻辑直接写入汇编代码中。
当调用local_bh_enable来开启软中断时,会执行待处理的软中断。
每个处理器都有一个软中断线程ksoftirqd_CPUn,在其执行时也会处理软中断。
在执行软中断时,CPU中断是开启的,新的中断可能会使软中断挂起。然而,如果某一类型的软中断实例已经在一个CPU上运行或挂起,内核会禁止该类型的新软中断请求在该CPU上运行,这显著减少了软中断所需的并发锁。
do_softirq当执行软中断的时机到来时,内核会执行do_softirq函数。
do_softirq首先会保存一份待执行软中断的副本。在do_softirq运行时,同一类型的软中断可能会被调度多次:在运行软中断处理程序时,它可能会被硬件中断抢占,并且在中断处理期间,CPU的待处理软中断位图可能会被重置,也就是说,在一个待处理软中断的执行过程中,该软中断可能会被重新调度。因此,do_softirq首先会禁用中断,将pending 软中断位图的副本保存到局部变量pending 中,然后将本地CPU软中断位图中的对应位重置为0,接着重新开启中断。最后,基于pending 的副本,检查每一位是否为1,如果是,则根据软中断的类型调用相应的处理程序。
do {if (pending & 1) { unsigned int vec_nr = h - softirq_vec; int prev_count = preempt_count(); kstat_incr_softirqs_this_cpu(vec_nr); trace_softirq_entry(vec_nr); h->action(h); trace_softirq_exit(vec_nr);if (unlikely(prev_count != preempt_count())) { printk(KERN_ERR "huh, entered softirq %u %s %p""with preempt_count %08x,"" exited with %08x?\n", vec_nr, softirq_to_name[vec_nr], h->action, prev_count, preempt_count()); preempt_count() = prev_count; } rcu_bh_qs(cpu); } h++; pending >>= 1;} while (pending);待处理软中断的调用顺序取决于位图中标志位的位置和扫描方向(从低到高),并非按照先进先出的顺序执行。
当所有处理程序执行完毕后,do_softirq会再次禁用中断,并重新检查CPU的待处理中断位图。如果发现新的待处理软中断,会再次创建副本并再次执行上述过程。这个过程最多重复MAX_SOFTIRQ_RESTART 次(通常为10次),以避免无限占用CPU资源。
当处理轮数达到MAX_SOFTIRQ_RESTART 阈值时,do_softirq必须结束执行。如果仍然存在未执行的软中断,会唤醒ksoftirqd线程来处理它们。然而,在内核中do_softirq被频繁调用,因此后续对do_softirq的调用实际上可能会在ksoftirqd线程被调度之前完成这些软中断的处理。
每个 CPU 都有一个内核线程 ksoftirqd(通常根据 CPU 序列号命名为 ksoftirqd_CPUn)。当上述机制无法处理所有软中断时,该 CPU 后台的 ksoftirqd 线程会被唤醒,并在调度后承担处理尽可能多待处理软中断的责任。
与 ksoftirqd 关联的任务函数 run_ksoftirqd 如下。
static int run_ksoftirqd(void * __bind_cpu){ set_current_state(TASK_INTERRUPTIBLE);while (!kthread_should_stop()) { preempt_disable();if (!local_softirq_pending()) { preempt_enable_no_resched(); schedule(); preempt_disable(); } __set_current_state(TASK_RUNNING);while (local_softirq_pending()) { /* Preempt disable stops cpu going offline. If already offline, we'll be on wrong CPU: don't process */if (cpu_is_offline((long)__bind_cpu)) goto wait_to_die; local_irq_disable();if (local_softirq_pending()) __do_softirq(); local_irq_enable(); preempt_enable_no_resched(); cond_resched(); preempt_disable(); rcu_note_context_switch((long)__bind_cpu); } preempt_enable(); set_current_state(TASK_INTERRUPTIBLE); } __set_current_state(TASK_RUNNING);return 0;wait_to_die: preempt_enable(); /* Wait for kthread_stop */ set_current_state(TASK_INTERRUPTIBLE);while (!kthread_should_stop()) { schedule(); set_current_state(TASK_INTERRUPTIBLE); } __set_current_state(TASK_RUNNING);return 0;}ksoftirqd 所做的事情与 do_softirq 基本相同,其主要逻辑是通过 while 循环不断调用 __do_softirq(此函数也是 do_softirq 的核心逻辑),并且只有在满足以下两个条件时才会停止。
当没有待处理的软中断时,ksoftirqd 会调用 schedule() 触发调度,主动放弃 CPU 资源。
线程执行完分配的时间片,并被要求放弃 CPU 资源以进行下一次调度。
ksoftirqd 线程的调度优先级被设置得非常低,这也可以避免在软中断较多时占用过多的 CPU 资源。
Linux 的网络系统主要使用以下两种软中断。
NET_RX_SOFTIRQ 用于处理接收(入站)网络数据NET_TX_SOFTIRQ 用于处理发送(出站)网络数据本文主要关注数据的接收方式。
每个 CPU 都有一个用于接收传入网络帧的输入队列 input_pkt_queue,它位于 softnet_data 结构体中,但并非所有的网卡设备驱动程序都会使用这个输入队列。
struct softnet_data { struct Qdisc *output_queue; struct Qdisc **output_queue_tailp; struct list_head poll_list; struct sk_buff *completion_queue; struct sk_buff_head process_queue; /* stats */ unsigned int processed; unsigned int time_squeeze; unsigned int cpu_collision; unsigned int received_rps; unsigned dropped; struct sk_buff_head input_pkt_queue; struct napi_struct backlog;};网卡设备每次接收到二层网络帧时,会使用硬件中断向 CPU 发出信号,表示有新的帧需要处理。接收到中断的 CPU 会执行 do_IRQ 函数,该函数会调用与硬件中断号关联的处理程序。这个处理程序通常是设备驱动程序在初始化期间注册的一个函数。此中断处理程序会在禁用中断模式下执行,导致 CPU 暂时停止接收中断信号。中断处理程序会执行一些必要的即时任务,并将其他任务安排在后续阶段延迟执行。具体来说,中断处理程序会做以下事情。
sk_buff 数据结构中。sk_buff 参数,供上层网络栈使用。特别地,skb->protocol 用于标识上层的协议处理程序。NET_RX_SOFTIRQ 软中断,通知内核进一步处理接收到的帧。我们前面已经介绍了轮询和中断通知机制(包括几种改进版本),它们各有优缺点,适用于不同的工作场景。但 Linux 在 2.6 版本中引入了一种结合轮询和中断来通知和处理新传入帧的 NAPI 机制。本文将重点介绍 NAPI 机制。
当设备驱动程序支持 NAPI 时,设备在接收到网络帧时仍然会使用中断来通知内核,但内核在开始处理中断后会禁用该设备的中断,并继续轮询设备的输入缓冲区,以获取接收到的帧进行处理,直到缓冲区为空,然后结束处理程序并重新启用该设备的中断通知。NAPI 结合了轮询和中断的优点。
在空闲状态下,当设备接收到新的网络帧时,内核可以立即得到通知,而无需在轮询上浪费资源。
在内核得知设备缓冲区中有待处理的数据后,无需浪费资源来处理中断,只需通过轮询来处理数据即可。
对于内核而言,NAPI 有效地减少了高负载下需要处理的中断数量,从而降低了 CPU 使用率,并且通过轮询访问设备也减少了设备之间的竞争。内核使用以下数据结构来实现 NAPI。
poll:用于从设备的入站队列中对网络帧进行排队的虚拟函数,每个设备都会有一个单独的入站队列。
poll_list:维护轮询状态的设备链。多个设备可以共享同一个中断信号,因此内核需要对多个设备进行轮询。添加到该列表后,来自此设备的中断将被禁用。
quota 和 weight:内核使用这两个值来控制一次从一个设备排队的数据量。较小的配额意味着来自不同设备的数据帧有更公平的处理机会,但内核在设备之间切换会花费更多时间,反之亦然。
当设备发送中断信号并被接收时,内核会执行设备驱动程序注册的中断处理程序。中断处理程序会调用 napi_schedule 来调度轮询程序的执行。在 napi_schedule 中,如果发送中断的设备不在 CPU 的 poll_list 中,内核会将其添加到 poll_list 中,并通过 __raise_softirq_irqoff 触发 NET_RX_SOFTIRQ 软中断的调度。主要逻辑位于 ____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);}NET_RX_SOFTIRQ 的处理程序是 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)) { struct napi_struct *n; int work, weight; /* If softirq window is exhuasted then punt. * Allow this to run for 2 jiffies since which will allow * an average latency of 1.5/HZ. */if (unlikely(budget <= 0 || time_after(jiffies, time_limit))) goto softnet_break; local_irq_enable(); /* Even though interrupts have been re-enabled, this * access is safe because interrupts can only add new * entries to the tail of this list, and only ->poll() * calls can remove this head entry from the list. */ n = list_first_entry(&sd->poll_list, struct napi_struct, poll_list); have = netpoll_poll_lock(n); weight = n->weight; /* This NAPI_STATE_SCHED test is for avoiding a race * with netpoll's poll_napi(). Only the entity which * obtains the lock and sees NAPI_STATE_SCHED set will * actually make the ->poll() call. Therefore we avoid * accidentally calling ->poll() when NAPI is not scheduled. */ work = 0; if (test_bit(NAPI_STATE_SCHED, &n->state)) { work = n->poll(n, weight); trace_napi_poll(n); } WARN_ON_ONCE(work > weight); budget -= work; local_irq_disable(); /* Drivers must not modify the NAPI state if they * consume the entire weight. In such cases this code * still "owns" the NAPI instance and therefore can * move the instance around on the list at-will. */ if (unlikely(work == weight)) { if (unlikely(napi_disable_pending(n))) { local_irq_enable(); napi_complete(n); local_irq_disable(); } else list_move_tail(&n->poll_list, &sd->poll_list); } netpoll_poll_unlock(have); }out: net_rps_action_and_irq_enable(sd);#ifdef CONFIG_NET_DMA /* * There may not be any more sk_buffs coming right now, so push * any pending DMA copies to hardware */ dma_issue_pending_all();#endif return;softnet_break: sd->time_squeeze++; __raise_softirq_irqoff(NET_RX_SOFTIRQ); goto out;}当 net_rx_action 被调度执行时:
poll_list 链表的开头开始遍历其中的设备,并调用设备的轮询虚拟函数来处理入站队列中的数据帧。poll_list 的末尾,然后处理 poll_list 中的下一个设备。netif_rx_complete 将该设备从 poll_list 中移除,并开启该设备的中断通知。poll_list 被清空,或者 net_rx_action 已经执行了足够多的时间片(以免占用过多 CPU 资源),在这种情况下,net_rx_action 会在退出前重新调度自身以进行下一次执行。在设备驱动初始化期间,设备会将 dev->poll 指向驱动程序提供的自定义函数,因此不同的驱动程序会使用不同的轮询函数。我们将介绍 Linux 提供的默认轮询函数 process_backlog,它的工作方式与大多数驱动轮询函数类似,主要区别在于 process_backlog 在不禁止中断的情况下工作。由于非 NAPI 设备使用共享输入队列,从输入队列中取出数据帧时需要临时禁止中断以实现加锁,而 NAPI 设备使用单独的入站队列,并且加入 poll_list 的设备的中断已单独禁用,因此在轮询期间无需考虑加锁问题。
当 process_backlog 执行时,它首先计算设备的配额,然后进入以下循环:
poll_list 中移除并结束执行。netif_receive_skb(skb) 处理取出的数据帧,我们将在下一节进行描述。netif_receive_skb 是轮询虚拟函数用于处理接收帧的工具函数,简而言之,它按顺序对数据帧执行以下操作:
skb->dev 会从接收设备更改为绑定中的主设备。skb->protocol 对应的已注册三层协议处理程序。然后数据帧进入内核网络栈的上层。如果未找到相应的协议处理程序,或者数据帧未被桥接等功能处理,则内核会丢弃该数据帧。
通常,三层协议处理程序对数据帧的处理方式如下:
至此,关于 Linux 如何接收网络帧的讨论结束。
https://www.sobyte.net/post/2022-04/linux-process-input-frames/