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 会进入监控模式,这时候用的是监控模式栈。如果监控模式栈不够大,或者栈指针没对齐,一切换就崩。
我画了一张图,帮你理清切换时的栈使用关系:
从图上你能看到,切换时栈的使用分三个阶段:
- 普通世界执行 SMC 指令:此时 SP 还是普通栈,CPU 自动保存部分上下文到普通栈。
- 进入监控模式:SP 切换到监控模式栈,保存剩余上下文,执行切换逻辑。
- 进入安全世界: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 寄存器只在真正使用时才保存,用懒加载策略。
- 上下文缓存:如果频繁切回同一个世界,可以缓存它的上下文,避免重复保存。
嗯,上下文切换的原理就这些。说白了就是「保存现场、切换栈、恢复现场」这三板斧。但每一斧头砍下去,都有无数细节要处理。希望你能从我的踩坑经历中吸取教训,少走一些弯路。