x86架构下的中断与异常处理:从IDT到FRED的演进之路

前言

在x86架构四十余年的发展历程中,中断和异常处理机制一直是操作系统与硬件交互的核心桥梁。从最初Intel 80286时代诞生的IDT(Interrupt Descriptor Table,中断描述符表)标准,到如今x86生态联盟力推的FRED(Flexible Return and Event Delivery,灵活返回与事件传递)技术,这条演进之路见证了计算机体系结构对性能与安全的不懈追求。

本文将从Linux内核的视角,深入剖析三种核心事件——软中断(int $0x80)、硬件中断和CPU异常——在传统IDT机制下的完整处理流程,包括硬件自动操作、内核栈切换、寄存器保存等关键环节。最后,我们还将对比syscall指令的快速路径,并展望FRED这一x86架构未来的统一事件分发框架。


一、IDT机制概述:事件分发的基石

中断描述符表(IDT)是x86架构处理事件的核心数据结构。它最多包含256个门描述符(gate descriptors),每个描述符定义了对应中断/异常的处理程序入口地址、段选择子、特权级等信息。

当CPU检测到事件发生时,它会根据事件向量号在IDT中查找对应的门描述符,经过特权级检查后,跳转到内核中预设的处理程序。

三种事件类型的本质区别:

事件类型 触发方式 典型例子 是否可屏蔽
软中断 软件指令主动触发 int $0x80
硬件中断 外设通过中断控制器发送 时钟中断、键盘中断 是(IF标志位)
CPU异常 指令执行过程中CPU检测 #PF缺页异常、#DE除零异常

尽管触发源不同,但CPU在处理这些事件时遵循相似的核心流程。下面我们以最经典的int $0x80为例,详细解剖这一过程。


二、软中断(int $0x80)的完整流程

int $0x80是x86架构传统的系统调用入口。在x86_64 Linux内核中,它作为32位兼容层继续存在,用于执行32位系统调用。

2.1 硬件自动执行的操作

当用户态程序执行int $0x80指令时,CPU在切换到内核态之前会自动完成以下操作:

flowchart LR
    A["执行 int 0x80"] --> B["检查特权级"]
    B --> C["从TSS加载内核栈"]
    C --> D["压入用户态上下文"]
    D --> E["跳转到 IDT 向量 0x80 入口"]

具体步骤如下:

第一步:特权级检查

第二步:内核栈切换

第三步:保存用户态上下文

2.2 TSS.ESP0的指向:trampoline机制

在x86_64 Linux中,TSS.ESP0并不直接指向最终的任务内核栈,而是指向 per-CPU entry trampoline stack(与 cpu_entry_stack / load_sp0 一致)。这样既能在用户↔内核切换时落到固定、可预测的栈地址,又与 KPTI / 内核入口安全模型相衔接(不单因某一侧信道攻击而设立)。

TSS.RSP0 / sp0 与 entry stack(每 CPU 在 cpu_init() 中设置):

cpu_init()(arch/x86/kernel/cpu/common.c:2466)
    └─ load_sp0(…)(common.c:2506)

cpu_init()sp0 设为该 CPU entry trampoline stack 顶端一侧:

/* arch/x86/kernel/cpu/common.c — cpu_init() 内 */
load_sp0((unsigned long)(cpu_entry_stack(cpu) + 1));

trampoline栈是一个每CPU独立的小型栈(大小约4KB),它的作用是:

  1. 提供一个安全的临时运行环境
  2. 允许内核安全地切换CR3(页表基址寄存器)
  3. 在切换到真正任务栈之前完成必要的安全检查

2.3 内核入口(IA32 兼容)

硬件完成初始切换后,CPU 进入 IDT 向量 0x80 对应的入口。x86_64 且启用 CONFIG_IA32_EMULATION 时,idtentry_bodyentry_64.S)里依次为:call error_entrymovq %rax,%rsp(用户态路径下 error_entry 末尾 jmp sync_regssync_regs 返回到 idtentry_body,不是在 sync_regs 内部再 call int80_emulation)→ movq %rsp,%rdicall int80_emulationjmp do_int80_emulation()

CPU: INT $0x80 → IDT[0x80],RSP ← TSS.RSP0(entry trampoline stack)
    └─ asm_int80_emulation(DECLARE_IDTENTRY_RAW,展开于 entry_64.S)
        └─ idtentry_body
              call error_entry →(用户态)jmp sync_regs()(traps.c:1044)→ 返回 ax
              movq %rax, %rsp
              movq %rsp, %rdi
              call int80_emulation(entry_64_compat.S)
                  jmp do_int80_emulation()(syscall_32.c)

源码入口:idtentry.hentry_64.Sidtentry_bodyerror_entry)、sync_regsentry_64_compat.Sdo_int80_emulation

纯 32 位内核:

entry_INT80_32(entry_32.S)
    └─ do_int80_syscall_32()(syscall_32.c)

entry_32.S 注释里的 entry_INT80_compat 是对 64 位内核上 IA32 兼容路径的称呼;实现侧符号为 asm_int80_emulation / int80_emulation 等,并无名为 entry_INT80_compat 的入口符号。

2.4 从trampoline栈到任务栈的切换

特权级切换后,硬件帧先压在 TSS.RSP0 所指的 per-CPU entry / trampoline stackcpu_entry_stack);从用户态进来的 INT $0x80、普通外设 IRQ、以及 IST=0 的典型异常,在进 C 函数前多数都经同一套 idtentry_bodyerror_entrysync_regs():由 sync_regs()traps.c:1044)在必要时把 struct pt_regs 拷贝到 current 线程内核栈顶(注释亦含 IST 栈情形;实际栈指针调整见 entry_64.Ssync_regs 返回值)。cpu_current_top_of_stack 提供线程内核栈顶,供 syscall 入口等与 sync_regs 对齐。

idtentry_body(共用)
    └─ error_entry → jmp sync_regs()(traps.c:1044)→ 返回 pt_regs*(常为线程栈上)

2.5 SAVE_ALL与system_call

在32位时代,int $0x80的入口是system_call,其中会调用SAVE_ALL宏来保存所有寄存器:

#define SAVE_ALL \
    cld; \
    pushl %es; \
    pushl %ds; \
    pushl %eax; \
    pushl %ebp; \
    pushl %edi; \
    pushl %esi; \
    pushl %edx; \
    pushl %ecx; \
    pushl %ebx; \
    movl $(__KERNEL_DS),%edx; \
    movl %edx,%ds; \
    movl %edx,%es;

SAVE_ALL不仅保存了寄存器状态,还巧妙地完成了系统调用参数的传递——在int $0x80之前,用户将系统调用号放入EAX,参数放入EBXECX等寄存器,SAVE_ALL将这些值压入内核栈后,C处理函数就可以通过栈指针访问这些参数。

2.6 完整流程图

下图针对 INT $0x80;从用户态进入的 外设 IRQ「CPU 硬件 → error_entry/sync_regs 一段与之相同(同一 TSS.RSP0 entry stack),区别见 §3。

flowchart TD
    subgraph User["用户态"]
        A["执行 int 0x80"] --> B["CPU 硬件自动操作"]
    end

    subgraph Hardware["CPU 硬件自动操作"]
        B --> C["从 TSS RSP0 加载 entry trampoline 栈"]
        C --> D["压入 SS、RSP、RFLAGS、CS、RIP"]
        D --> E["跳转 asm_int80_emulation"]
    end

    subgraph Kernel["内核态 IA32_EMU"]
        E --> F["error_entry 与 sync_regs"]
        F --> G["int80_emulation 到 do_int80_emulation"]
        G --> J["构建 pt_regs 并完成调用"]
        J --> K["系统调用返回路径"]
    end

    K --> L["IRET 返回用户态"]

三、硬件中断的处理流程

硬件中断与int $0x80在用户态触发时,第一站栈相同:均为 load_sp0(cpu_entry_stack(...)) 写入的 TSS.RSP0 → entry trampoline stack,再经 idtentry_bodyerror_entrysync_regs(与 §2 所述同源);差异在向量、桩符号与最终 C handler。

3.1 中断控制器与中断向量

外部设备通过中断控制器(如APIC)向CPU发送中断请求。每个中断源被分配一个中断向量号(32-255),CPU根据这个向量号在IDT中查找对应的门描述符。

3.2 中断处理的核心差异

对比项 int $0x80(软中断) 硬件中断
TSS.RSP0(用户态→ring 0) per-CPU entry / trampoline stack(cpu_entry_stack 同上,并非直接落到进程 thread stack
是否再迁到线程栈 error_entry/sync_regs 等到线程栈 同样经 error_entry/sync_regs 等到线程栈
中断屏蔽 不可屏蔽 可通过CLI/STI控制IF位
EOI处理 不需要 需要发送EOI给中断控制器
入口函数 asm_int80_emulationdo_int80_emulation asm_common_interruptcommon_interrupt()(内部 call_irq_handler() 等)

3.3 中断入口:common_interrupt

从用户态:外设向量 virq_entries_startpush 向量(idtentry.h)→ jmp asm_common_interrupt,随后仍是 idtentry_body(与 asm_int80_emulation 同源):call error_entrysync_regsmovq %rax,%rspmovq %rsp,%rdi,第二参数为向量(DEFINE_IDTENTRY_IRQ)→ call common_interrupt()

向量 v:RSP ← TSS.RSP0(entry trampoline stack)
    └─ irq_entries_start:push v → jmp asm_common_interrupt
        └─ idtentry_body(has_error_code=1)
              call error_entry … jmp sync_regs() → 返回
              movq %rax, %rsp;movq %rsp,%rdi;(has_error_code=1)取栈上向量作第二参 → common_interrupt(regs, vector)
              call common_interrupt()(irq.c)
                  └─ call_irq_handler() → handle_irq() / apic_eoi() 等

参见 idtentry.hirq.c

common_interrupt:
    /* 保存寄存器(idtentry_irq 展开的入口) */
    /* 调用 common_interrupt() → call_irq_handler() 进行中断分发 */
    /* 必要时 apic_eoi() */
    /* 恢复寄存器并返回 */

四、CPU异常的处理流程

CPU异常是处理器在执行指令过程中检测到异常情况时触发的事件,如缺页异常(#PF)、除零异常(#DE)等。

4.1 异常的分类与向量

x86定义了多种异常,每个异常有固定的向量号:

4.2 异常的栈切换:IST机制

部分 CPU 异常在 IDT 门中配置了 IST≠0 时(如 #DF、#NMI、#MC 等在 x86_64 Linux 中的典型 setup),硬件将 RSP 切到 IST 栈而非 RSP0 entry stack——这与「外设 IRQ / INT $0x80RSP0」并列,属于另一类交付栈。

IST允许为特定异常配置独立的应急栈,而不使用TSS.ESP0指向的栈。这是为了防止在栈已损坏的情况下发生异常时,系统完全无法响应。

IST的工作流程:

  1. CPU查阅IDT中对应异常门描述符的IST字段(3 位,取值 0~70 表示不使用 IST,改用 RSP0
  2. 从TSS中的IST表读取对应的应急栈指针
  3. 将RSP切换为该应急栈(而非TSS.RSP0)
  4. 压入上下文并执行异常处理程序
flowchart TD
    A["CPU 检测到异常"] --> B{"是否使用 IST"}
    B -->|是| C["从 TSS 的 IST 项取应急栈"]
    B -->|否| D["从 TSS RSP0 取栈"]
    C --> E["在 IST 栈上压入上下文"]
    D --> F["在普通内核栈上压入上下文"]
    E --> G["跳转异常处理入口"]
    F --> G

4.3 异常入口:exc_* 函数

每个异常对应专门的处理函数,如 exc_page_fault(实现于 arch/x86/mm/fault.c)处理缺页异常、exc_divide_error 处理除零异常。这些函数会:

  1. 读取错误码(如果有)
  2. CR2寄存器获取缺页地址(#PF)
  3. 调用核心处理逻辑(如handle_mm_fault
  4. 根据处理结果恢复执行或发送信号

五、快速路径:syscall指令

在x86_64架构中,syscall指令是更高效的系统调用方式,它专为快速特权级切换而设计。

5.1 syscall vs int 0x80

对比维度 int 0x80 syscall
性能 较慢(需内存访问IDT/TSS) 更快(专用指令)
栈切换 硬件自动切换(通过TSS.RSP0) 不自动切换,软件需手动设置
寄存器破坏 保存完整上下文 破坏RCX和R11
适用架构 32位/64位兼容 x86_64原生
内核入口 asm_int80_emulationdo_int80_emulation(IA32_EMU) entry_SYSCALL_64

5.2 syscall的独特之处

各 CPU 首次执行 cpu_init() 时会写入 syscall 所用 MSR(与 IDT 陷阱初始化分开:trap_init() 更早完成异常/中断 IDT 部分):

start_kernel()(init/main.c:1017)
    └─ trap_init()(arch/x86/kernel/traps.c:1682)【陷阱与 IDT】

cpu_init()(arch/x86/kernel/cpu/common.c:2466)
    └─ syscall_init()(common.c:2304)
        └─ idt_syscall_init()(common.c:2268)
            └─ MSR_STAR、MSR_LSTAR(entry_SYSCALL_64)、MSR_SYSCALL_MASK(约 common.c:2268–2300);IA32 兼容时尚有 CSTAR、SYSENTER 等

对应源码:start_kerneltrap_initcpu_initsyscall_init / idt_syscall_init

syscall指令的设计哲学是硬件做最少的事,软件做最多的事,以此换取极致性能:

运行时进入内核:

syscall 指令
    └─ entry_SYSCALL_64(entry_64.S)

入口汇编见 entry_SYSCALL_64entry_SYSCALL_64 把用户栈指针暂存在 per-CPU TSS 的 sp2

ENTRY(entry_SYSCALL_64)
    swapgs
    movq    %rsp, PER_CPU_VAR(cpu_tss_rw + TSS_sp2)
    SWITCH_TO_KERNEL_CR3 scratch_reg=%rsp
    movq    PER_CPU_VAR(cpu_current_top_of_stack), %rsp
    ...

由于RCX被syscall指令破坏,而x86-64 System V ABI规定第四个参数使用RCX传递,内核必须使用R10来接收第四个参数。这就是为什么64位系统调用包装器中会出现mov %rcx, %r10指令。


六、FRED:x86架构的未来

6.1 为什么需要FRED?

IDT机制诞生于20世纪80年代的Intel 80286时代,已有40余年历史。现代程序员普遍认为其设计“杂乱且别扭”:

6.2 FRED的核心改进

FRED(Flexible Return and Event Delivery,灵活返回与事件传递)由Intel提出,AMD已承诺在Zen 6架构中支持,标志着x86生态的重大统一。

主要改进包括:

  1. 原子性上下文保存/恢复
    • FRED事件传递时自动保存完整的管理程序/用户上下文
    • 避免%CR2/%DR6等瞬时状态问题
    • 不再需要处理“半生不熟”的入口状态
  2. 显式NMI控制
    • ERETS/ERETU指令替代IRET
    • 明确控制NMI的解锁时机,避免嵌套混乱
  3. 栈级别替代IST
    • 引入4个栈级别(0-3)代替不可重入的IST
    • 可为每个向量配置独立的栈级别
    • 支持栈级别的动态升降
  4. 消除SWAPGS
    • 引入LKGS指令管理GS段
    • FRED事件传递自动交换GS基址
    • SWAPGS在FRED下变为非法(#UD)
  5. 统一栈结构
    • 所有事件使用一致的栈帧格式
    • 开发者无需为边缘案例编写规避代码

6.3 两级事件分发

FRED要求软件根据事件类型和向量进行两级分发,而非IDT的直接向量索引:

事件类型 (fred_ss.type)
    ├── EVENT_TYPE_EXTINT(外部中断)→ 中断分发
    ├── EVENT_TYPE_NMI(NMI)→ NMI处理
    ├── EVENT_TYPE_HWEXC(硬件异常)→ 异常分发
    ├── EVENT_TYPE_SWINT(软件中断)→ 系统调用
    └── EVENT_TYPE_OTHER(其他)→ 特殊处理

这种设计让软件重新掌握了事件路由的控制权,同时保持了灵活性。

6.4 Linux内核支持状态

Linux内核从 6.9 起逐步加入 FRED 支持,主要文件:

entry_fred.c(arch/x86/entry/)
    └─ fred.c、fred.h(kernel / include/asm)

七、总结

int $0x80syscall,再到即将到来的FRED,x86架构的事件处理机制走过了漫长的演进之路:

时代 机制 特点 缺陷
1980s-2000s IDT + int 0x80 统一框架,硬件自动栈切换 性能较低,边缘情况复杂
2000s-2020s syscall/sysenter 专用指令,性能优化 与IDT并存,增加复杂度
2026+ FRED 原子性操作,统一栈结构,简化软件 尚未大规模部署

传统的IDT机制虽然历史悠久且功能完备,但其设计已难以满足现代操作系统对性能和安全性的双重追求。FRED通过硬件层面的重新设计,有望在保证兼容性的同时,为x86架构带来更高效、更健壮的事件处理框架。

正如Linus Torvalds所言,FRED是“更完整的解决方案”。随着Intel和AMD在下一代处理器中共同拥抱这一技术,我们有理由期待FRED将成为x86架构下一个四十年的基石。


参考文献

  1. Linux内核源码: arch/x86/entry/entry_64.S, arch/x86/entry/entry_64_compat.S, arch/x86/entry/entry_fred.c, arch/x86/kernel/cpu/common.c, arch/x86/kernel/irq.c, arch/x86/entry/syscall_32.c
  2. Linux内核文档: Documentation/arch/x86/x86_64/fred.rst
  3. “Linux系统调用中syscall与int 0x80的实现方式性能及适用场景对比”,阿里云开发者社区
  4. “终结 40 年 IDT 旧标准,AMD Zen 6 架构将使用英特尔 FRED 技术”,IT之家
  5. “软中断指令int $0x80的执行过程”,CSDN博客
  6. “Linux内核之中断INT 0x80的作用”,ChinaUnix博客
  7. Stack Overflow: “x86_64 Linux函数与syscalls之间的ABI差异”

My Github Page: https://github.com/liweinan

B站视频: https://space.bilibili.com/21947620

Powered by Jekyll and Theme by solid

If you have any question want to ask or find bugs regarding with my blog posts, please report it here:
https://github.com/liweinan/liweinan.github.io/issues