Linux 内核入口/出口处理机制深度解析:syscall、中断、NMI 与 KVM 的状态转换体系
2026/9/15 3:29:41 网站建设 项目流程

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()等上下文;
  • Tracingtrace_hardirqs_off/on等硬中断跟踪状态;
  • 时间统计(Time accounting):中断/软中断的时间归属。

这些更新的先后顺序因转换类型而异,本仓库文档将其划分为四类:SyscallsKVMInterrupts and regular exceptionsNMI 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 中的宏定义):

  1. Lockdep
  2. RCU / Context tracking
  3. Tracing
  4. 应用栈随机化(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()逆序拆除状态:

  1. Tracing
  2. RCU / Context tracking
  3. 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)按如下顺序更新状态:

  1. 抢占计数器__nmi_enter()
  2. Lockdeplockdep_hardirqs_off()+lockdep_hardirq_enter()
  3. RCU / Context trackingct_nmi_enter()
  4. Tracingtrace_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()体系。

总结:一张顺序对照表

转换类型入口状态顺序出口状态顺序(逆序)核心接口
SyscallLockdep → RCU/CT → Tracing → 栈随机化Tracing → RCU/CT → Lockdepsyscall_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同 syscallirqentry_enter()/irqentry_exit()
中断(内核态来源)Lockdep →(条件性)RCU/CT → Tracing;抢占计数由irq_enter_rcu()延迟更新逆序;irq_exit_rcu()先移除HARDIRQ_OFFSETirqentry_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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询