4. 上下文切换核心原理:CPU 上下文保存与恢复

上下文切换,说白了就是「换人干活」。CPU 同一时间只能跑一个线程,但操作系统要让你感觉所有程序都在同时跑,怎么办?快速切来切去。每次切换,都得把当前线程的「工作状态」完整保存下来,再把下一个线程的「工作状态」恢复上去。这个保存和恢复的东西,就叫上下文。

在安全世界与普通世界的切换中,上下文切换比普通线程切换要复杂得多。我刚开始接触 TrustZone 时,以为就是多存几个寄存器的事,结果第一次调试就踩了坑——安全上下文没保存完整,切回普通世界后直接崩了。嗯,从那以后我再也不敢小看这一步了。

4.1 CPU 上下文到底保存了什么?

CPU 上下文,本质上就是 CPU 内部所有寄存器的快照。你想想看,一个程序跑着跑着,它的变量在寄存器里,栈指针在 SP 里,下一条指令地址在 PC 里,状态标志在 CPSR 里。如果不把这些东西原封不动存起来,切回来就没法继续跑。

具体来说,需要保存的内容包括:

  • 通用寄存器:R0-R12,共 13 个 32 位寄存器。这些是程序最常用的工作寄存器。
  • 栈指针 SP:每个世界有自己的栈,但切换时 SP 本身也要保存。
  • 链接寄存器 LR:保存返回地址。切换时 LR 会被覆盖,必须提前保存。
  • 程序计数器 PC:当前执行到的指令地址。
  • 程序状态寄存器 CPSR/SPSR:条件标志、中断屏蔽位、处理器模式等。
  • 安全相关寄存器:SCR(安全配置寄存器)、NS 位、监控模式下的专用寄存器。

核心要点:普通线程切换只保存「用户态上下文」,而安全世界切换还要额外保存「安全属性上下文」。NS 位、SCR 配置、以及安全内存的映射状态,一个都不能漏。

4.2 安全世界上下文结构

安全世界的上下文,比普通世界多了一层「安全隔离」的包袱。我在项目中维护过一个安全上下文结构体,大概长这样:

struct secure_context {
    // 通用寄存器组
    uint32_t regs[13];      // R0 - R12
    
    // 栈与返回地址
    uint32_t sp;            // 安全世界栈指针
    uint32_t lr;            // 安全世界返回地址
    uint32_t pc;            // 安全世界程序计数器
    
    // 状态寄存器
    uint32_t cpsr;          // 当前程序状态
    uint32_t spsr;          // 保存的程序状态
    
    // 安全特有
    uint32_t scr;           // 安全配置寄存器
    uint32_t ns_bit;        // 非安全位状态
    uint32_t monitor_sp;    // 监控模式栈指针
    
    // 内存保护
    uint32_t mpu_config[8]; // MPU 区域配置
    uint32_t tlb_context;   // TLB 上下文 ID
};

我个人习惯把安全上下文分成三块:通用部分(所有切换都要保存的)、安全特有部分(只有安全世界才有的寄存器)、内存保护部分(MPU/TLB 配置)。这样分层,调试时能快速定位问题。

经验之谈:我曾经遇到过一个诡异 bug——安全世界切回普通世界后,某些内存访问突然报权限错误。查了两天才发现,是 MPU 配置没保存完整,切回来时 MPU 还保留着安全世界的权限设置。普通世界访问自己的内存反而被拒绝了。所以,MPU 配置的保存和恢复,一定要成对出现。

4.3 普通世界上下文结构

普通世界的上下文相对简单,因为它不需要管安全属性。但要注意,普通世界上下文是「被保存」的一方,它不知道切换的发生。所以结构上更像一个标准的线程上下文:

struct normal_context {
    // 通用寄存器组
    uint32_t regs[13];      // R0 - R12
    
    // 栈与返回地址
    uint32_t sp;            // 普通世界栈指针
    uint32_t lr;            // 普通世界返回地址
    uint32_t pc;            // 普通世界程序计数器
    
    // 状态寄存器
    uint32_t cpsr;          // 当前程序状态
    
    // 浮点寄存器(如果支持)
    uint64_t fpu_regs[32];  // FPU 寄存器组
    uint32_t fpscr;         // 浮点状态控制寄存器
};

你可能会问:为什么普通世界不需要保存 SCR 和 NS 位?因为普通世界根本看不到这些寄存器。它在自己的世界里跑,NS 位固定为 1,SCR 由安全世界控制。普通世界连这些寄存器的存在都不知道,自然不需要保存。

对比项 安全世界上下文 普通世界上下文
寄存器数量 约 30+ 个 约 15+ 个
安全属性 包含 SCR、NS 位 不包含
MPU 配置 需要保存 8 个区域 不需要
FPU 状态 可选保存 可选保存
保存时机 进入监控模式前 被安全世界打断时
恢复时机 从监控模式返回后 安全世界释放 CPU 时

4.4 切换时的栈管理

栈管理是上下文切换中最容易出问题的地方。说白了,每个世界都有自己的栈,但切换发生时,CPU 会进入监控模式,这时候用的是监控模式栈。如果监控模式栈不够大,或者栈指针没对齐,一切换就崩。

我画了一张图,帮你理清切换时的栈使用关系:

上下文切换栈管理示意图 上下文切换栈管理示意图 普通世界 Normal World 普通栈 (SP_NS) 上下文保存区 监控模式 Monitor Mode 监控栈 (SP_MON) 切换代码执行区 安全世界 Secure World 安全栈 (SP_S) 上下文保存区 SMC 指令 切换完成 ERET 返回 关键规则 1. 切换时 SP 必须切换到监控模式栈,不能使用普通或安全栈 2. 上下文保存区在各自世界的栈中,监控模式只做中转

从图上你能看到,切换时栈的使用分三个阶段:

  1. 普通世界执行 SMC 指令:此时 SP 还是普通栈,CPU 自动保存部分上下文到普通栈。
  2. 进入监控模式:SP 切换到监控模式栈,保存剩余上下文,执行切换逻辑。
  3. 进入安全世界:SP 切换到安全栈,恢复安全上下文,开始执行安全代码。

避坑指南:我曾经犯过一个低级错误——监控模式栈只分配了 64 字节。结果切换时保存的上下文太多,栈溢出了,直接覆盖了监控模式的代码区。后果就是一切换就死机,连错误信息都打印不出来。后来我规定:监控模式栈至少 256 字节,并且加上栈溢出检测。

4.5 切换流程的完整步骤

把上面所有知识点串起来,一次完整的上下文切换流程是这样的:

// 伪代码:普通世界 -> 安全世界切换
void switch_to_secure_world() {
    // 1. 普通世界保存上下文
    save_normal_context_to_stack();  // 保存到 SP_NS 栈
    
    // 2. 执行 SMC 指令,触发异常
    __asm__ volatile("SMC #0");
    
    // 3. CPU 自动切换到监控模式
    //    - SP 切换到 SP_MON
    //    - LR 保存返回地址
    //    - SPSR 保存 CPSR
    
    // 4. 监控模式保存普通世界剩余上下文
    save_normal_monitor_context();   // 保存到 SP_MON 栈
    
    // 5. 保存安全世界当前上下文(如果之前有)
    if (secure_context_valid) {
        save_secure_context();       // 从安全栈读取并保存
    }
    
    // 6. 恢复安全世界上下文
    restore_secure_context();        // 从安全栈恢复
    
    // 7. 切换到安全世界
    //    - SP 切换到 SP_S
    //    - 设置 NS=0
    //    - 配置 MPU 为安全模式
    
    // 8. 执行 ERET,进入安全世界
    __asm__ volatile("ERET");
}

反过来,从安全世界切回普通世界的流程完全对称。只是方向相反,保存和恢复的对象互换。

个人习惯:我在每个上下文保存和恢复的入口、出口都加了调试标记。比如在保存前写一个魔数到栈顶,恢复时检查这个魔数是否被破坏。这个方法帮我抓到了好几次栈溢出和上下文损坏的问题。你可以在自己的项目里试试。

4.6 性能考量与优化

上下文切换是有代价的。每次切换,少则几十个寄存器,多则上百个(加上 FPU)。如果切换太频繁,性能会急剧下降。我见过一个项目,安全世界和普通世界之间每秒切换上万次,结果 30% 的 CPU 时间都花在切换上了。

优化方向主要有三个:

  • 减少切换次数:把多次小切换合并成一次大切换。比如批量处理安全请求。
  • 延迟保存:FPU 寄存器只在真正使用时才保存,用懒加载策略。
  • 上下文缓存:如果频繁切回同一个世界,可以缓存它的上下文,避免重复保存。

嗯,上下文切换的原理就这些。说白了就是「保存现场、切换栈、恢复现场」这三板斧。但每一斧头砍下去,都有无数细节要处理。希望你能从我的踩坑经历中吸取教训,少走一些弯路。