为什么网卡收到一个数据包,系统要分成两步处理,而不是一步到位?
因为第一步(硬件中断)必须在中断上下文里跑,有严格限制;第二步(协议栈处理)可以慢慢来,但已经出了中断上下文。这两个"场景"就是 Linux 里说的上下文(Context)。
搞懂上下文这个概念,才明白为什么有些代码能睡,有些不能。
01 上下文是什么
上下文就是 CPU 执行代码时的"状态快照":寄存器值、程序计数器、内核栈指针、当前进程信息。
Linux 里最重要的两条线:
- • 进程上下文(Process Context):CPU 在执行某个进程的内核态代码——比如进程 A 在跑
read() 系统调用。进程上下文属于某个进程,可以睡,可以阻塞。 - • 中断上下文(Interrupt Context):CPU 在响应硬件中断,不属于任何进程。没有进程可以睡,因为没有人可以被调度进来接替。
02 陷入:用户态怎么进内核
用户态代码(Ring 3)想进内核态(Ring 0),靠陷入指令(Trap)。x86_64 上这条指令叫 syscall,不是软中断(那是 int 0x80,32 位时代的东西)。
// arch/x86/entry/entry_64.S (Linux v6.6)syscall: swapgs // 切换到内核GS基址 movq %rsp, PER_CPU_VAR(rsp_scratch) movq PER_CPU_VAR(kernel_stacks), %rsp ... call do_syscall_64
CPU 执行 syscall,自动跳到内核,跳转路径由 MSR 寄存器指定,不经过中断向量表,比 int 0x80 快得多。Linux 64 位统一走这条路径。
do_syscall_64 里查 sys_call_table[nr],分发到具体函数,返回后 syscall_exit_to_user_mode() 把 CPU 切回 Ring 3:
$ cat /proc/kallsyms | grep sys_call_tableffffffffabc00000 T sys_call_table
还有一条进内核的路:硬件中断。外设(网卡、磁盘)通过 CPU 的 INT 引脚发起中断,异步打断当前执行,CPU 自动切换到内核的中断处理入口。跟 syscall 的区别是:中断是外设主动打的,跟用户代码没关系。
03 中断上下文里不能干什么
中断上下文的核心约束:不能睡、不能阻塞、不能拿可能导致阻塞的锁。
原因很简单:中断打断的是一段正在执行的代码,中断处理完还要回到被打断的地方继续跑。如果在中断处理函数里调用了 schedule()(会触发调度),CPU 去跑另一个进程了,那被打断的进程谁来恢复?
所以中断处理程序必须:
- • 不能调用
copy_to_user()(涉及用户态内存,可能触发缺页中断→阻塞) - • 不能拿
down(&semaphore)(信号量可能直接让当前进程等住)
Linux 解决这个矛盾的思路是:拆分——把必须在中断上下文跑的工作(最少、最急)跑完,剩下的扔给"可以睡"的环境去处理。
04 Bottom Half:延后处理的三条路
拆出来的后半段,就是 Bottom Half。Linux 历史上用过多种实现,现在稳定的三条路:
Softirq(软中断)
最高优先级的延后处理,在中断退出路径上执行。多 CPU 可以并行跑,同一个类型的 softirq 在每个 CPU 上独立实例运行。Linux 内核为特定场景静态注册了软中断向量:
// kernel/softirq.c (Linux v6.6)enum { HI_SOFTIRQ=0, TIMER_SOFTIRQ, NET_TX_SOFTIRQ, NET_RX_SOFTIRQ, BLOCK_SOFTIRQ, IRQ_POLL_SOFTIRQ, TASKLET_SOFTIRQ, SCHED_SOFTIRQ, HRTIMER_SOFTIRQ, ...};
网络收包走 NET_RX_SOFTIRQ,块设备请求走 BLOCK_SOFTIRQ,定时器到期走 TIMER_SOFTIRQ。
Tasklet(任务队列)
基于 softirq 实现,比 softirq 更晚引入,API 更友好。同一 CPU 上同一类型的 tasklet 串行执行,不同类型之间不保证顺序。驱动开发者常用 tasklet 而不是直接注册 softirq。
Workqueue(工作队列)
最灵活的一种。Workqueue 把延后处理扔到进程上下文里——在内核线程中执行。内核线程是可以被 schedule() 调度走的,所以 workqueue 的回调函数可以睡、可以阻塞、可以拿信号量、可以调用 copy_to_user()。
$ ps aux | grep kworkerroot kworker/0:1H // 高优先级工作队列线程root kworker/0:2 // 普通工作队列线程root ksoftirqd/0 // 处理软中断的工作线程
05 进程上下文:可以睡在内核里
进程上下文属于某个进程,核心特点是可以睡、可以阻塞。代表场景有两个:
系统调用
用户态 read() → syscall 指令 → do_syscall_64(),内核在进程上下文中执行 sys_read()。如果磁盘数据未就绪,sys_read() 里会调用 io_schedule() 让当前进程睡等。
内核线程
内核线程是没有对应用户态进程的"纯内核进程",有自己的 task_struct,参与调度,可以被 schedule() 换出。kworker/* 系列是最常见的,每秒处理大量工作队列任务。
上下文选择的核心逻辑
用这张表收尾:
回到开头的问题:网卡数据包为什么要分两步处理?因为中断处理函数必须在原子上下文里跑——它要快,不能等。而协议栈处理(TCP 重传、socket 缓冲、应用层拷贝)逻辑重、耗时长,必须扔到工作队列里,让内核线程在进程上下文里慢慢处理。