Linux 内核入口/出口处理机制深度解析:syscall、中断、NMI 与 KVM 的状态转换体系
【免费下载链接】linuxLinux kernel source tree项目地址: https://gitcode.com/GitHub_Trending/li/linux
本文围绕 Linux 内核文档 Documentation/core-api/entry.rst 展开,系统剖析内核在 syscall、中断(interrupt)、NMI 及 KVM 虚拟化边界上必须执行的状态更新(Lockdep、RCU/context tracking、抢占计数、tracing、时间统计)及其严格顺序约束。读者读完后,将能理解noinstr代码段的定位与instrumentation_begin()/end()的用法、四类转换路径的推荐实现模板(含可直接对照的 C 代码),以及irqentry_enter()/irqentry_nmi_enter()等核心接口在 kernel/entry/common.c 中的真实语义。
为什么入口/出口代码是内核最敏感的地带
内核代码会在多种"执行域"(execution domain)之间切换:用户态 ↔ 内核态、普通内核上下文 ↔ 中断上下文、宿主机 ↔ KVM 客户机。每一次切换都需要对如下状态进行严格有序的更新:
- Lockdep:锁依赖与 IRQ 标志状态跟踪;
- RCU / Context tracking:读侧临界区与上下文追踪(尤其在 NOHZ_FULL 下);
- 抢占计数器(Preemption counter):用于判定
in_hardirq()、in_nmi()等上下文; - Tracing:
trace_hardirqs_off/on等硬中断跟踪状态; - 时间统计(Time accounting):中断/软中断的时间归属。
这些更新的先后顺序因转换类型而异,本仓库文档将其划分为四类:Syscalls、KVM、Interrupts and regular exceptions、NMI and NMI-like exceptions。顺序一旦出错,轻则 tracing/lockdep 状态失真,重则导致 RCU 读侧临界区失效引发不可预期的崩溃。正因为顺序如此苛刻,现代内核把这段代码收敛为统一的基础设施:include/linux/entry-common.h、include/linux/irq-entry-common.h 与 kernel/entry/common.c。
noinstr:不可插桩的入口代码段
大多数插桩(instrumentation)设施依赖 RCU 处于活动状态,因此在 RCU 开始"观察"(watching)之前的入口代码、以及 RCU 停止观察之后的出口代码中,插桩是被禁止的。此外,很多架构在入口处需要保存/恢复寄存器状态——例如断点入口代码中的断点会覆写最初断点的调试寄存器。
满足这类要求的代码必须用noinstr属性标记,使其落入特殊的、插桩与调试设施不可达的 section。其定义位于 include/linux/compiler_types.h:
#define __noinstr_section(section) \ ... #define noinstr __noinstr_section(".noinstr.text")即所有noinstr函数被放置到.noinstr.textsection 中。文档给出了一部分可插桩、一部分不可插桩的典型函数结构:
noinstr void entry(void) { handle_entry(); // <-- must be 'noinstr' or '__always_inline' ... instrumentation_begin(); handle_context(); // <-- instrumentable code instrumentation_end(); ... handle_exit(); // <-- must be 'noinstr' or '__always_inline' }要点:
- 被
noinstr函数调用的函数同样必须是noinstr或__always_inline(内联后不产生独立的可插桩调用点); instrumentation_begin()/instrumentation_end()之间的区间允许插桩;- 反向的调用(从可插桩上下文调用不可插桩函数)没有限制,常用于保护"一旦被插桩就会出故障"的状态切换;
- 在支持 objtool 的架构上,这些约束可以被自动验证,防止开发者误把可插桩代码混入入口路径;
- 所有位于 RCU 状态转换之前/之后的不可插桩入口/出口代码段,必须在关中断(interrupts disabled)条件下运行。
Syscalls:推荐的两套入口模板与状态顺序
最稳健的模板:预置 -ENOSYS
syscall 入口代码起始于汇编,在建立底层架构状态与栈帧后调用低级 C 代码,该 C 代码必须不可插桩。文档推荐的 syscall 处理函数如下:
noinstr void syscall(struct pt_regs *regs, long nr) { arch_syscall_enter(regs); result_reg(regs) = -ENOSYS; if (syscall_enter_from_user_mode_randomize_stack(regs, &nr)) { instrumentation_begin(); if (valid(nr) result_reg(regs) = invoke_syscall(regs, nr); instrumentation_end(); } syscall_exit_to_user_mode(regs); }该变体始终保证返回码合法(先无条件写-ENOSYS,再仅在系统调用号有效时覆盖结果),因此最具韧性。
替代模板及其缺陷
noinstr void syscall(struct pt_regs *regs, long nr) { arch_syscall_enter(regs); if (syscall_enter_from_user_mode_randomize_stack(regs, &nr)) { instrumentation_begin(); if (valid(nr) result_reg(regs) = invoke_syscall(regs, nr); else result_reg(regs) = -ENOSYS; instrumentation_end(); } syscall_exit_to_user_mode(regs); }此变体在大多数场景可用,但存在一个已知陷阱:如果挂在 syscall tracepoint 上的 probe/BPF 程序把系统调用号改成非法值(如 -1)并同时修改了结果寄存器,那么else分支会用 -ENOSYS 覆写被修改过的结果。这正是第一套模板"始终预置返回码"的原因。
状态建立与拆除的精确顺序
syscall_enter_from_user_mode_randomize_stack()首先调用enter_from_user_mode_randomize_stack(),按以下顺序建立状态(对应 include/linux/entry-common.h 中的宏定义):
- Lockdep
- RCU / Context tracking
- Tracing
- 应用栈随机化(stack randomization)
随后才调用 ptrace、seccomp、audit、syscall tracing 等各类 entry work 函数(这些标志位集合见 include/linux/entry-common.h 的SYSCALL_WORK_ENTER)。上述工作完成后,才能调用可插桩的invoke_syscall;可插桩区间结束后调用syscall_exit_to_user_mode()。
syscall_exit_to_user_mode()负责返回用户态前的一切工作(tracing、audit、信号、task work 等,对应SYSCALL_WORK_EXIT标志集,见 include/linux/entry-common.h),最后调用exit_to_user_mode()以逆序拆除状态:
- Tracing
- RCU / Context tracking
- Lockdep
exit_to_user_mode()的完整实现位于 include/linux/irq-entry-common.h:先 trace hardirqs on、lockdep prepare,再user_enter_irqoff()通知 RCU,最后arch_exit_to_user_mode()(供架构做投机缓解等收尾)与lockdep_hardirqs_on()。
若架构需要在各步骤之间插入额外工作,可以使用syscall_enter_from_user_mode_randomize_stack()/syscall_exit_to_user_mode()的细粒度子函数,但必须保证入口先调enter_from_user_mode_randomize_stack(),出口最后调exit_to_user_mode()。
⚠️ 文档明确警告:不要嵌套 syscall。嵌套会触发 RCU 和/或 context tracking 的警告输出。
x86 上的落地实例
x86-64 的do_syscall_64()(arch/x86/entry/syscall_64.c)正是上述模板的忠实实现:
/* Returns true to return using SYSRET, or false to use IRET */ __visible noinstr bool do_syscall_64(struct pt_regs *regs, long nr) { if (likely(syscall_enter_from_user_mode_randomize_stack(regs, &nr))) { instrumentation_begin(); if (!do_syscall_x64(regs, nr)) do_syscall_x32(regs, nr); instrumentation_end(); } syscall_exit_to_user_mode(regs); ... }x86 32 位兼容入口do_int80_syscall_32()(arch/x86/entry/syscall_32.c)同样遵循此结构,可见该模板已是各架构的通用范式。
KVM:客户机进出等价于用户态/内核态转换
从宿主机内核视角看,进入 guest 等价于 CPU"跑到用户态",退出 guest 则等价于"回到内核"。因此 KVM 的边界转换与 syscall 高度相似:
guest_state_enter_irqoff()是 KVM 版的exit_to_user_mode();guest_state_exit_irqoff()是 KVM 版的enter_from_user_mode();- 状态操作采用相同的顺序。
其实现位于 include/linux/kvm_host.h。由于进入 guest 后宿主机 RCU、tracing、lockdep 等状态都必须被"挂起"并在退出时精确恢复,这两组函数与 syscall 路径共用同一套状态机,只是封装层面不同。
Task work 的处理被单独放在vcpu_run()循环边界,通过xfer_to_guest_mode_handle_work()完成,它是返回用户态时所做 work 的一个子集。相关接口声明在 include/linux/entry-virt.h,KVM 侧封装见 include/linux/kvm_host.h。
⚠️ 文档强调:不要嵌套 KVM 进出转换,因为这样做毫无意义且会破坏状态一致性。
中断与普通异常:比 syscall 更复杂的双路径
用户态被中断:与 syscall 完全一致
如果中断发生在用户态执行期间,其进出处理与 syscall 完全一致。
内核态被中断:条件性 RCU 更新
如果中断发生在内核态,处理略有不同:只有当中断落在 CPU 的 idle 任务上下文时才更新 RCU 状态(否则 RCU 必然已在观察);Lockdep 与 tracing 则无条件更新。这一逻辑由irqentry_enter()/irqentry_exit()实现,其内核态分支的细节见 include/linux/irq-entry-common.h 的irqentry_enter_from_kernel_mode():
- 若命中 idle 任务(或
arch_in_rcu_eqs()报告处于 RCU 扩展静止态),无条件调用ct_irq_enter()并置ret.exit_rcu = true——注释解释了为何不能用rcu_is_watching()判断:嵌套中断(如软中断重新使能中断后的再次中断)中的 tick 会误判为"第一个中断"而过早结束宽限期; - 否则只需
rcu_irq_enter_check_tick()检查是否需要重启 NOHZ tick。
架构相关的处理与 syscall 模板相似:
noinstr void interrupt(struct pt_regs *regs, int nr) { arch_interrupt_enter(regs); state = irqentry_enter(regs); instrumentation_begin(); irq_enter_rcu(); invoke_irq_handler(regs, nr); irq_exit_rcu(); instrumentation_end(); irqentry_exit(regs, state); }注意:实际中断处理函数被包裹在irq_enter_rcu()/irq_exit_rcu()对中。
irq_enter_rcu() 与 irq_exit_rcu() 的分工
irq_enter_rcu():更新抢占计数(使in_hardirq()返回 true)、处理 NOHZ tick 状态与中断时间统计。这意味着直到调用irq_enter_rcu()之前,in_hardirq()都返回 false;irq_exit_rcu():处理中断时间统计、撤销抢占计数更新,并最终处理软中断与 NOHZ tick 状态。
理论上抢占计数可以在irqentry_enter()中就更新,但实际推迟到irq_enter_rcu()有两个好处:其一,让抢占计数相关代码可以被 trace;其二,与irq_exit_rcu()、irqentry_exit()保持对称。代价是早期入口代码必须意识到抢占计数尚未叠加HARDIRQ_OFFSET。
另外两个关键约束:
irq_exit_rcu()必须在处理软中断之前从抢占计数中移除HARDIRQ_OFFSET,因为软中断处理函数运行在 BH 上下文而非关中断上下文;irqentry_exit()可能会发生调度(irqentry_exit_to_kernel_mode_preempt()在regs_irqs_disabled(regs)为 false 且开启抢占时会调用irqentry_exit_cond_resched(),见 include/linux/irq-entry-common.h),这同样要求HARDIRQ_OFFSET已被移除。
嵌套与重入
尽管中断处理函数期望在关中断下运行,但从进出视角看中断嵌套很常见:例如软中断处理发生在irqentry_{enter,exit}()块内且本地中断是使能的;此外虽然罕见,中断处理函数本身也可以重新使能中断。中断进出代码本身因运行在关中断环境而不必严格处理重入,但NMI 随时可能发生,且大量入口代码在两者之间共享——这正是下一节的主题。
NMI 与 NMI 类异常:可重入的状态机
NMI 及 NMI 类异常(machine check、double fault、debug 中断等)可能命中任意上下文,必须对状态格外小心。
一个关键区分:debug 异常与 machine-check 异常的状态变化取决于它们发生在用户态(断点/观察点)还是内核态(代码补丁):
- 来自用户态 → 按普通中断处理;
- 来自内核态 → 按NMI处理;
- 而 NMI 本身及其他 NMI 类异常不做用户态/内核态来源的区分。
irqentry_nmi_enter() 的进入顺序
irqentry_nmi_enter()(实现在 kernel/entry/common.c)按如下顺序更新状态:
- 抢占计数器(
__nmi_enter()) - Lockdep(
lockdep_hardirqs_off()+lockdep_hardirq_enter()) - RCU / Context tracking(
ct_nmi_enter()) - Tracing(
trace_hardirqs_off_finish()+ftrace_nmi_enter())
退出时irqentry_nmi_exit()(kernel/entry/common.c)做完全逆序的撤销:ftrace_nmi_exit()→ 恢复 lockdep 使能态 →ct_nmi_exit()→lockdep_hardirq_exit()→__nmi_exit()。
为什么抢占计数必须最先改、最后撤
抢占计数的更新必须是进入时的第一个操作、退出时的最后一个操作:因为 lockdep 和 RCU 都依赖in_nmi()在此场景下返回 true。因此 NMI 进出时的抢占计数修改绝不能被 trace(这也是整个 NMI 进出代码都用noinstr标记、并把可 trace 部分包在instrumentation_begin()/end()内的原因)。
架构代码模板:
noinstr void nmi(struct pt_regs *regs) { arch_nmi_enter(regs); state = irqentry_nmi_enter(regs); instrumentation_begin(); nmi_handler(regs); instrumentation_end(); irqentry_nmi_exit(regs); }debug 异常的模板则展示了"按来源分流"的完整写法:
noinstr void debug(struct pt_regs *regs) { arch_nmi_enter(regs); debug_regs = save_debug_regs(); if (user_mode(regs)) { state = irqentry_enter(regs); instrumentation_begin(); user_mode_debug_handler(regs, debug_regs); instrumentation_end(); irqentry_exit(regs, state); } else { state = irqentry_nmi_enter(regs); instrumentation_begin(); kernel_mode_debug_handler(regs, debug_regs); instrumentation_end(); irqentry_nmi_exit(regs, state); } }注意文档明确说明:不存在一个合并的irqentry_nmi_if_kernel()函数——上述分流无法以"异常无关"的方式通用处理,因此必须由各异常处理代码自行实现。
NMI 的重入性
NMI 可以发生在任何上下文——例如处理一个 NMI 的过程中又触发了一个 NMI 类异常。因此NMI 入口代码必须是可重入的(reentrant),状态更新需要处理嵌套。这也是 NMI 路径相比普通中断路径多出大量"保存/恢复现场"工作的根本原因。x86 侧可对照 arch/x86/entry/entry_fred.c 中fred_entry_from_user()、fred_entry_from_kernel()等 FRED 新入口的实现,观察它们如何对用户态/内核态来源分别调用irqentry_enter()/irqentry_nmi_enter()体系。
总结:一张顺序对照表
| 转换类型 | 入口状态顺序 | 出口状态顺序(逆序) | 核心接口 |
|---|---|---|---|
| Syscall | Lockdep → RCU/CT → Tracing → 栈随机化 | Tracing → RCU/CT → Lockdep | syscall_enter_from_user_mode_randomize_stack()/syscall_exit_to_user_mode() |
| KVM | 同 syscall(guest 退出视角) | 同 syscall(guest 进入视角) | guest_state_exit_irqoff()/guest_state_enter_irqoff(),task work 走xfer_to_guest_mode_handle_work() |
| 中断(用户态来源) | 同 syscall | 同 syscall | irqentry_enter()/irqentry_exit() |
| 中断(内核态来源) | Lockdep →(条件性)RCU/CT → Tracing;抢占计数由irq_enter_rcu()延迟更新 | 逆序;irq_exit_rcu()先移除HARDIRQ_OFFSET | irqentry_enter()/irqentry_exit()+irq_enter_rcu()/irq_exit_rcu() |
| NMI / NMI 类异常 | 抢占计数 → Lockdep → RCU/CT → Tracing | 逆序(抢占计数最后撤) | irqentry_nmi_enter()/irqentry_nmi_exit() |
这套机制的内核通用实现集中在三个文件,可作为进一步深入阅读的入口:
- include/linux/entry-common.h:syscall 进出、
SYSCALL_WORK_*标志与 work 分发; - include/linux/irq-entry-common.h:
enter_from_user_mode()、exit_to_user_mode()、irqentry_state_t及 irqentry 系列内联函数; - kernel/entry/common.c:
irqentry_enter()、irqentry_exit()、irqentry_nmi_enter()、irqentry_nmi_exit()与退出循环exit_to_user_mode_loop()的实际实现。
理解这些入口路径的顺序约束,是阅读任何架构(x86、arm64 等)入口汇编与低级 C 代码、排查 RCU/lockdep/tracing 异常告警、以及评估新异常类型接入方式的前提。
【免费下载链接】linuxLinux kernel source tree项目地址: https://gitcode.com/GitHub_Trending/li/linux
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考