上下文切换机制: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,互不干扰。

切换的时机是这样的:

  1. 任务运行时,CONTROL寄存器bit1=1,CPU使用PSP。
  2. 发生中断或异常,硬件自动切换到MSP,并在PSP指向的栈上压入异常栈帧。
  3. 在PendSV中,我们手动保存R4-R11到PSP栈,然后切换PSP指向新任务的栈。
  4. 异常返回时,硬件从新任务的PSP栈上弹出异常栈帧,任务恢复运行。

下面这张图把整个流程串起来了:

PSP与MSP切换流程 任务A运行 使用PSP_A 触发PendSV 硬件切到MSP 保存R4-R11到PSP_A栈 更新TCB->StackPtr 切换任务 获取新TCB 恢复R4-R11 设置PSP_B 异常返回 硬件弹出异常栈帧 任务B运行 使用PSP_B PSP_A → 任务A的栈 PSP_B → 任务B的栈 MSP → 内核栈(不变)

嗯,这里要注意一个细节: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的调度机制你就掌握了最核心的一环。