3. 异常入口与栈切换:从 EL0 到 EL1 的栈切换机制

当用户空间(EL0)发生异常(系统调用、中断或数据异常)陷入内核(EL1)时,处理器必须完成执行栈的切换。这是异常处理的第一步,也是寄存器现场保存的基础。如果栈指针错误,后续的现场保存与恢复将毫无意义。

3.1 为什么需要栈切换?

ARMv8-A 架构为每个异常等级提供了独立的栈指针寄存器:

  • SP_EL0:用于 EL0(用户态)的栈操作。
  • SP_EL1、SP_EL2、SP_EL3:分别用于更高异常等级。

当异常从 EL0 进入 EL1 时,如果继续使用 SP_EL0,会带来严重的安全与稳定性问题:

风险 说明
栈溢出攻击 用户态可控制 SP_EL0 指向恶意地址,内核若使用该栈,攻击者可劫持内核控制流。
栈空间不足 用户栈通常较小(几 MB),内核异常处理需要更大的栈空间(如 16KB 内核栈)。
数据污染 用户态数据可能被其他线程修改,内核保存的现场寄存器可能被破坏。
嵌套异常 若在异常处理中再次发生异常,用户栈无法支持嵌套上下文。
关键原则: 异常入口必须立即将 SP 从 SP_EL0 切换到 SP_EL1,确保后续所有压栈操作都在安全的内核栈上进行。

3.2 SP_EL0 与 SP_EL1 的切换时机

ARMv8-A 在异常发生时,硬件自动完成以下操作:

  1. 保存返回地址:将 ELR_EL1 设置为发生异常的指令地址。
  2. 保存处理器状态:将 SPSR_EL1 设置为异常发生时的 PSTATE。
  3. 切换异常等级:将 CurrentEL 设置为 EL1。
  4. 切换栈指针:根据异常类型,硬件自动选择 SP_EL1 作为当前 SP。

切换的精确时机由 SPSel 栈指针选择位控制:

SPSel 值 当前异常等级 使用的 SP 说明
0 EL0 SP_EL0 用户态始终使用 SP_EL0
1 EL1 SP_EL1 内核态默认使用 SP_EL1

在 Linux 内核中,__entry_tramp_is_sp_el1 等汇编宏确保在异常向量入口处,SP 已经指向内核栈。如果异常来自 EL0,硬件自动将 SP 切换为 SP_EL1;如果异常来自 EL1(嵌套异常),SP 保持不变。

调试要点: 在异常入口处检查 SP 的值,可以快速判断异常来源。若 SP 值落在用户栈区域(如 0x7fxxxxxxxx),说明栈切换未正确执行,这是内核的致命错误。

3.3 内核栈的建立

Linux 内核为每个进程分配一个独立的内核栈,用于异常处理和系统调用执行。内核栈的建立分为编译时和运行时两个阶段。

3.3.1 内核栈的分配与布局

每个进程的 task_struct 中,stack 字段指向内核栈的基地址。内核栈的大小通常为 16KB(可配置为 8KB 或 4KB)。

// include/linux/sched.h
struct task_struct {
    void *stack;        // 指向内核栈的指针
    // ... 其他字段
};

// kernel/fork.c
static struct task_struct *dup_task_struct(struct task_struct *orig)
{
    struct task_struct *tsk;
    unsigned long *stack;

    stack = alloc_thread_info_node(tsk, node);  // 分配内核栈
    tsk->stack = stack;                         // 关联栈指针
    // ...
}

内核栈的典型布局如下:

内核栈布局(16KB) 栈底(高地址) 内核栈空间(16KB) ← SP_EL1(当前栈指针) 栈顶(低地址) 说明:栈从高地址向低地址增长,SP_EL1 指向当前栈顶位置 异常入口压栈后,SP_EL1 向低地址移动

3.3.2 异常入口处的栈切换流程

当异常从 EL0 进入 EL1 时,Linux 内核的异常向量入口汇编代码执行以下步骤:

  1. 硬件自动切换 SP:处理器将 SP 从 SP_EL0 切换为 SP_EL1。
  2. 检查栈指针有效性:部分内核配置会验证 SP_EL1 是否指向合法的内核栈区域。
  3. 压栈保存现场:使用 stp 指令将 x0-x29、LR、SP_EL0 等寄存器压入内核栈。
  4. 建立栈帧:设置 FP(x29)指向当前栈帧基址,便于回溯。
// arch/arm64/kernel/entry.S(简化)
el0_sync:
    // 此时 SP 已自动切换为 SP_EL1
    // 保存 EL0 的 SP 到内核栈
    mrs x25, sp_el0
    stp x24, x25, [sp, #-16]!

    // 保存通用寄存器 x0-x29
    stp x0, x1, [sp, #-16]!
    stp x2, x3, [sp, #-16]!
    // ... 继续保存其他寄存器

    // 建立栈帧
    stp x29, x30, [sp, #-16]!
    mov x29, sp

    // 进入 C 语言异常处理函数
    bl el0_sync_handler
性能优化: ARMv8.2 引入了 Shadow Call Stack(SCS)机制,使用独立的影子栈保存返回地址,防止栈溢出攻击。在开启 SCS 的内核中,异常入口还会额外切换影子栈指针。

3.4 栈切换的完整流程 SVG 图

从 EL0 到 EL1 的栈切换完整流程 用户态执行 SP = SP_EL0 异常发生 硬件自动切换 SP → SP_EL1 保存 EL0 上下文 SP_EL0 入栈 压栈通用寄存器(x0-x29, LR, SP_EL0) 使用 stp 指令批量保存,SP_EL1 向低地址移动 建立栈帧(FP = SP, LR 入栈) 设置 x29 = SP,保存 x30(返回地址) 进入 C 语言异常处理函数 el0_sync_handler() / el0_irq_handler() 关键点:整个切换过程在异常向量入口的汇编代码中完成,不依赖 C 语言运行时 SP_EL1 必须指向有效的内核栈,否则系统立即崩溃

3.5 常见调试陷阱与避坑指南

陷阱 现象 根因 解决方案
栈指针未切换 异常入口处 SP 指向用户栈 SPSel 位配置错误或硬件异常 检查异常向量入口的 SP 值,确认是否在 0xffff 内核地址空间
内核栈溢出 系统随机崩溃,栈回溯异常 内核栈太小或递归调用 增大 CONFIG_THREAD_SIZE,使用 KASAN 检测栈溢出
SP_EL0 未保存 返回用户态后栈指针错误 异常入口未保存 SP_EL0 确保在压栈序列中包含 mrs x25, sp_el0
嵌套异常栈污染 异常处理中再次异常,现场被覆盖 未预留嵌套异常栈空间 内核栈预留至少 512 字节的紧急栈空间
调试命令: 在 QEMU 或真实硬件上,使用以下方法检查栈切换状态:
(gdb) info registers sp_el0 sp_el1 — 查看两个栈指针的值
(gdb) x/16gx $sp_el1 — 查看内核栈顶的保存内容
cat /proc/<pid>/stack — 查看进程的内核栈使用情况

3.6 总结

从 EL0 到 EL1 的栈切换是异常处理的第一道防线。硬件自动完成 SP 切换后,内核通过汇编代码快速保存现场、建立栈帧,为后续的 C 语言处理函数提供安全的执行环境。理解 SP_EL0 与 SP_EL1 的切换时机、内核栈的布局与建立过程,是分析异常现场恢复的基础。任何栈切换的失误都会导致寄存器保存失败,进而使系统状态不可恢复。