2. 内核异常处理机制

2.1 x86 架构异常向量表

x86 架构定义了 256 个中断/异常向量(0~255),其中前 32 个(0~31)为 保留异常 和 NMI,由 Intel/AMD 硬编码。内核启动时通过 set_intr_gate / set_trap_gate 等宏将每个向量与对应的 入口函数 绑定。

向量号 异常名称 类型 错误码 内核入口符号
0 #DE — 除法错误 Fault 无 divide_error
1 #DB — 调试异常 Fault/Trap 无 debug
3 #BP — 断点 Trap 无 int3
6 #UD — 无效操作码 Fault 无 invalid_op
8 #DF — 双重故障 Abort 有(0) double_fault
13 #GP — 通用保护 Fault 有 general_protection
14 #PF — 页错误 Fault 有 page_fault
关键区分:Fault 异常返回后重新执行触发指令;Trap 异常返回后执行下一条指令;Abort 不返回,系统通常直接 panic。

2.2 异常处理流程全景

当 CPU 检测到异常时,硬件自动完成以下步骤:

  1. 压栈:SS、RSP、RFLAGS、CS、RIP,若异常有错误码则额外压入。
  2. 查 IDT:根据向量号从中断描述符表(IDT)中取出门描述符。
  3. 跳转:加载 CS:RIP 到对应的内核入口函数(如 page_fault)。
  4. 保存现场:入口函数调用 error_entry / paranoid_entry 保存所有通用寄存器。
  5. 调用 C 处理函数:如 do_page_fault、do_general_protection。
  6. 恢复与返回:若可修复则恢复现场并 iretq;否则进入 die() 或 panic()。
x86 异常处理流程 CPU 检测异常 硬件压栈 (SS/RSP/RFLAGS/CS/RIP) 查 IDT → 加载门描述符 跳转至入口函数 (entry_64.S) 保存现场 (error_entry) 调用 C 处理函数 (do_*) 可修复? 恢复现场 → iretq die() / panic()

2.3 do_trap 函数:异常分发中枢

do_trap 是大多数 Fault/Trap 类异常(#UD、#NM、#AC 等)的 C 语言入口。其核心逻辑:

// arch/x86/kernel/traps.c
static void do_trap(int trapnr, const char *str, struct pt_regs *regs,
                    long error_code, int sicode, void __user *addr)
{
    struct task_struct *tsk = current;

    if (!user_mode(regs)) {
        // 内核态异常 → 直接 die()
        die(str, regs, error_code);
        return;
    }

    // 用户态异常 → 发送信号
    if (show_unhandled_signals && unhandled_signal(tsk, SIGSEGV))
        pr_info_ratelimited("%s[%d] trap %s ip:%lx sp:%lx error:%lx",
                            tsk->comm, task_pid_nr(tsk), str,
                            regs->ip, regs->sp, error_code);

    force_sig_fault(sicode, addr ? : (void __user *)regs->ip, tsk);
}
内核态 vs 用户态分流:若异常发生在内核态(user_mode(regs)==false),说明内核自身触发了异常,属于严重故障,直接调用 die() 输出 Oops 信息;若发生在用户态,则向当前进程发送信号(如 SIGSEGV、SIGILL),由用户态处理。

2.4 die() 函数:Oops 的诞生地

die() 是内核异常时输出 Oops 信息 的核心函数。它负责:

  • 打印异常描述:异常名称、错误码、CPU 编号。
  • 打印寄存器现场:调用 __show_regs() 输出所有通用寄存器、段寄存器、RFLAGS、RSP、RIP。
  • 打印栈回溯:调用 show_stack() 输出内核调用栈。
  • 打印模块信息:若异常发生在内核模块中,输出模块名、偏移、符号。
  • 触发 kexec/kdump:若配置了 panic_on_oops,则调用 panic() 触发 kdump。
// arch/x86/kernel/dumpstack.c
void die(const char *str, struct pt_regs *regs, long err)
{
    unsigned long flags;
    raw_spin_lock_irqsave(&die_lock, flags);

    console_verbose();          // 提升控制台级别
    bust_spinlocks(1);          // 解锁自旋锁死锁

    pr_emerg("%s: %04lx [#%d] SMP\n", str, err & 0xffff, ++die_counter);
    __show_regs(regs, 0);       // 打印寄存器
    show_stack(NULL, NULL);     // 打印栈回溯

    bust_spinlocks(0);
    add_taint(TAINT_DIE, LOCKDEP_NOW_UNRELIABLE);
    raw_spin_unlock_irqrestore(&die_lock, flags);

    if (panic_on_oops)
        panic("Fatal exception");
    if (in_interrupt())
        panic("Fatal exception in interrupt");
    do_exit(SIGSEGV);           // 杀死当前进程
}
注意:die() 使用 die_lock 自旋锁确保多 CPU 同时异常时串行输出,避免 Oops 信息交错。但这也意味着如果持有 die_lock 的 CPU 死锁,其他 CPU 的异常将无法输出。

2.5 异常处理关键数据结构

数据结构 作用 关键字段
struct pt_regs 保存异常时的 CPU 寄存器现场 bx, cx, dx, si, di, bp, ax, ds, es, fs, gs, orig_ax, ip, cs, flags, sp, ss
struct desc_ptr IDT 基址与限长 address, size
struct gate_struct IDT 门描述符 offset_low, segment, ist, type, dpl, offset_middle, offset_high
struct exception_table_entry 内核异常修复表(fixup) insn, fixup

2.6 异常处理中的特殊路径

  • #DF 双重故障:当 CPU 在处理一个异常时又发生另一个异常,且两个异常无法嵌套处理时触发。内核为 #DF 分配独立的 IST(Interrupt Stack Table) 栈,防止栈溢出。若 #DF 再次失败,CPU 进入 Shutdown 状态。
  • #MC 机器检查:硬件错误(如内存 ECC 错误、总线错误),通过 do_machine_check() 处理,可能触发 panic() 或仅记录错误。
  • #NMI 不可屏蔽中断:硬件 watchdog、I/O 错误等触发,使用独立 IST 栈,处理函数 nmi_handler 必须极其谨慎,避免死锁。
异常处理特殊路径:IST 栈机制 普通异常 (共用 TSS 栈) #DF (IST=1) #NMI (IST=2) TSS 栈 (RSP0) IST1 栈 (独立 8KB) IST2 栈 (独立 8KB) IST 机制保证特殊异常在独立栈上执行,避免栈损坏导致二次故障

2.7 实战排查要点

  • 查看异常向量号:Oops 信息中 #PF 对应向量 14,#GP 对应向量 13,快速定位异常类型。
  • 检查错误码:#PF 错误码低 4 位标识(0=读/1=写/2=执行/4=用户态),#GP 错误码标识段选择子。
  • 关注 RIP 地址:若 RIP 指向 0x0 或 0xffffffff... 附近,通常是空指针解引用或函数指针被篡改。
  • 利用 show_stack 回溯:从栈回溯中找到最后一个内核函数调用,定位故障模块。
  • 检查 panic_on_oops:若系统配置了 panic_on_oops=1,则 Oops 会直接触发 panic 并 kdump,便于事后分析。
调试技巧:在 do_trap() 或 die() 中插入 dump_stack() 可以手动触发栈回溯,适合在开发阶段定位异常入口。