上下文切换机制:CPU寄存器现场保护与恢复、栈帧结构分析、PSP与MSP切换原理
上下文切换,说白了就是RTOS的"换人"操作。一个任务跑着跑着,突然被叫停,换另一个任务上。这个过程中,CPU当前的状态——寄存器里的值、程序跑到了哪、堆栈指针在哪——都得原封不动地保存下来,等下次这个任务恢复时再原样装回去。
我刚开始做RTOS移植时,总觉得上下文切换不就是压栈出栈嘛,没什么难的。直到有一次,一个任务恢复后莫名其妙地跑飞了,查了整整两天才发现是少保存了一个寄存器。从那以后,我对这个"换人"操作再也不敢马虎了。
2.1 寄存器现场保护与恢复
先看一个最核心的问题:到底要保存哪些寄存器?
以Cortex-M3/M4内核为例,CPU内部有两组寄存器需要关注:
- 自动保存的寄存器:当异常或中断发生时,硬件会自动将R0-R3、R12、LR、PC、xPSR这8个寄存器压入当前栈。这部分不需要我们操心。
- 需要手动保存的寄存器:R4-R11这8个通用寄存器,以及可选的FPU寄存器S0-S31。这部分是软件的责任。
关键点:硬件自动保存的寄存器构成"异常栈帧",手动保存的寄存器构成"任务栈帧"。两者合在一起,才是一个完整的任务上下文。
我个人的习惯是,在PendSV异常处理中完成手动寄存器的保存与恢复。为什么选PendSV?因为它优先级最低,可以等所有高优先级中断处理完后再执行,避免嵌套问题。
下面是一个典型的PendSV处理代码,我加上了详细注释:
__asm void PendSV_Handler(void)
{
; 保存上下文 - 进入时MSP已就绪
MRS R0, PSP ; 获取当前任务的PSP
STMFD R0!, {R4-R11} ; 手动保存R4-R11到任务栈
; 保存当前任务的栈指针到TCB
LDR R1, =CurrentTCB
LDR R2, [R1]
STR R0, [R2] ; TCB->StackPtr = PSP
; 切换任务 - 获取下一个任务的栈指针
BL GetNextTask
LDR R1, =CurrentTCB
STR R0, [R1] ; CurrentTCB = NextTCB
; 恢复上下文
LDR R1, [R0] ; 获取新任务的栈指针
LDMFD R1!, {R4-R11} ; 恢复R4-R11
MSR PSP, R1 ; 设置PSP为新任务的栈指针
; 异常返回时硬件自动恢复R0-R3,R12,LR,PC,xPSR
BX LR
}
避坑指南:我曾经在STM32F4上做FPU任务切换时,忘了保存FPU寄存器。结果任务A算浮点算到一半切走,任务B也用了浮点,回来时任务A的数据全乱了。如果你用了FPU,记得在SCB->CPACR里使能FPU,并在任务栈中分配S0-S31的空间。
2.2 栈帧结构分析
栈帧,就是任务在栈上存放上下文的那块区域。理解它的结构,对排查栈溢出、栈损坏问题至关重要。
一个完整的任务栈帧,从高地址到低地址依次是:
| 地址方向 | 内容 | 保存方式 | 大小 |
|---|---|---|---|
| 高地址 | xPSR | 硬件自动 | 4字节 |
| ↓ | PC(返回地址) | 硬件自动 | 4字节 |
| ↓ | LR(EXC_RETURN) | 硬件自动 | 4字节 |
| ↓ | R12 | 硬件自动 | 4字节 |
| ↓ | R3 | 硬件自动 | 4字节 |
| ↓ | R2 | 硬件自动 | 4字节 |
| ↓ | R1 | 硬件自动 | 4字节 |
| ↓ | R0 | 硬件自动 | 4字节 |
| ↓ | R11 | 手动保存 | 4字节 |
| ↓ | R10 | 手动保存 | 4字节 |
| ↓ | R9 | 手动保存 | 4字节 |
| ↓ | R8 | 手动保存 | 4字节 |
| ↓ | R7 | 手动保存 | 4字节 |
| ↓ | R6 | 手动保存 | 4字节 |
| ↓ | R5 | 手动保存 | 4字节 |
| 低地址 | R4 | 手动保存 | 4字节 |
你想想看,一个任务栈帧总共占了8×4 + 8×4 = 64字节。如果加上FPU寄存器,那就是64 + 32×4 = 192字节。这还不算任务自身的局部变量和函数调用栈。
注意:很多初学者只给任务分配128字节的栈空间,结果一跑多级函数调用就栈溢出。我建议至少给每个任务分配512字节,如果用了FPU或大量局部变量,建议1KB起步。
2.3 PSP与MSP切换原理
Cortex-M内核有两个堆栈指针:MSP(主栈指针)和PSP(进程栈指针)。这个设计很有意思——它把内核代码和任务代码的栈空间分开了。
- MSP:用于异常/中断处理、内核代码。系统启动时默认使用MSP。
- PSP:用于任务代码(线程模式)。每个任务有自己的栈,通过PSP访问。
为什么会这样设计?我举个例子你就明白了。假设任务A的栈指针是0x20001000,任务B的是0x20002000。如果都用MSP,切换任务时得手动改SP的值,一个不小心就把内核栈给踩了。有了PSP,任务只管用自己的栈,中断来了硬件自动切回MSP,互不干扰。
切换的时机是这样的:
- 任务运行时,CONTROL寄存器bit1=1,CPU使用PSP。
- 发生中断或异常,硬件自动切换到MSP,并在PSP指向的栈上压入异常栈帧。
- 在PendSV中,我们手动保存R4-R11到PSP栈,然后切换PSP指向新任务的栈。
- 异常返回时,硬件从新任务的PSP栈上弹出异常栈帧,任务恢复运行。
下面这张图把整个流程串起来了:
嗯,这里要注意一个细节:EXC_RETURN。异常返回时,LR里存放的不是普通返回地址,而是一个特殊值。比如0xFFFFFFFD表示返回后使用PSP,0xFFFFFFF9表示返回后使用MSP。这个值在PendSV中不能乱改,否则CPU就不知道切回哪个栈了。
调试技巧:如果你在调试时发现任务切换后栈指针不对,先检查EXC_RETURN的值。我曾经遇到一个情况,在PendSV里不小心用BL调了函数,把LR给覆盖了,结果返回时CPU用错了栈指针,整个系统直接HardFault。记住:PendSV里尽量用B跳转,别用BL。
2.4 任务创建时的初始栈帧
任务第一次运行,栈里什么都没有。我们需要手动构造一个初始栈帧,让任务看起来像是"刚从异常返回"一样。
构造规则很简单:
- xPSR:置位bit24(Thumb状态),值为0x01000000
- PC:任务函数的入口地址
- LR:任务退出时的处理函数(通常是任务删除函数)
- R0-R3、R12:初始化为0
- R4-R11:初始化为0
代码实现大概是这样的:
void TaskCreate(Task_t *task, void (*entry)(void *), void *arg, uint32_t *stack)
{
// 栈指针从高地址开始
uint32_t *sp = &stack[STACK_SIZE - 1];
// 构造异常栈帧(从高到低)
*sp-- = 0x01000000; // xPSR
*sp-- = (uint32_t)entry; // PC
*sp-- = (uint32_t)TaskExit; // LR
*sp-- = 0; // R12
*sp-- = 0; // R3
*sp-- = 0; // R2
*sp-- = 0; // R1
*sp-- = (uint32_t)arg; // R0(参数)
// 构造手动保存区
*sp-- = 0; // R11
*sp-- = 0; // R10
*sp-- = 0; // R9
*sp-- = 0; // R8
*sp-- = 0; // R7
*sp-- = 0; // R6
*sp-- = 0; // R5
*sp-- = 0; // R4
// 保存栈指针到TCB
task->stackPtr = sp;
}
常见错误:我见过有人把xPSR写成0x00000000,结果任务第一次启动就进HardFault。因为Cortex-M强制要求Thumb模式,xPSR的bit24必须为1。另外,PC地址必须是奇数(Thumb指令地址最低位为1),否则CPU会报错。
好了,上下文切换的核心机制就这些。说白了就是三件事:保存现场、切换栈指针、恢复现场。PSP和MSP的分工让这个过程变得优雅且安全。你只要记住:任务用PSP,内核用MSP,PendSV是切换的枢纽,栈帧结构是调试的基石。把这些搞清楚了,RTOS的调度机制你就掌握了最核心的一环。