第1章:上下文切换与堆栈

大家好,我是你们的老朋友。今天咱们聊聊RTOS里最核心、也最容易出问题的一个环节——上下文切换与堆栈管理。

说实话,我刚开始做RTOS开发那会儿,觉得上下文切换不就是保存几个寄存器嘛,有什么难的?结果第一次跑多任务,系统就莫名其妙地死机了。查了三天,最后发现是堆栈溢出把任务控制块给踩了。嗯,从那以后,我对堆栈管理就再也不敢马虎了。

1.1 PendSV异常:上下文切换的“调度员”

在ARM Cortex-M系列处理器里,PendSV异常是专门为上下文切换设计的。你想想看,为什么不用SVC或者SysTick直接做切换?

原因其实很简单:PendSV的优先级可以设到最低。这意味着,当高优先级的中断正在执行时,即使SysTick触发了任务调度,PendSV也会乖乖等着,直到所有中断处理完毕才执行切换。这避免了中断响应延迟被拉长。

我个人习惯把PendSV优先级设为0xFF(最低),SysTick优先级设得稍高一点。这样调度请求可以随时发生,但真正的切换动作会等到“安全时刻”才执行。

核心要点: PendSV是“延迟执行”的上下文切换机制。它保证了中断响应时间不受任务切换影响。

1.2 保存寄存器到堆栈:一场“打包”行动

当PendSV异常触发时,处理器会自动把一部分寄存器压入当前任务的堆栈。这个过程叫“自动入栈”,包括:R0-R3、R12、LR、PC、xPSR。一共8个寄存器,32字节。

但光靠硬件自动保存还不够。我们还需要手动保存R4-R11这8个通用寄存器。为什么?因为这些寄存器在C语言里被规定为“被调用者保存”的,说白了就是任务切换时不能丢。

我在项目中遇到过一个问题:有个同事只保存了R4-R7,觉得R8-R11用不上。结果某个任务里用了浮点运算,R8被编译器拿来当临时变量,切换回来时数据全乱了。嗯,从那以后我规定:R4-R11必须全部保存,一个都不能少

; PendSV异常处理入口
PendSV_Handler:
    ; 保存R4-R11到当前任务堆栈
    MRS     R0, PSP             ; 获取当前任务堆栈指针
    STMFD   R0!, {R4-R11}       ; 手动保存R4-R11
    
    ; 保存当前堆栈指针到任务控制块
    LDR     R1, =CurrentTaskTCB
    LDR     R2, [R1]
    STR     R0, [R2]            ; TCB->StackPtr = PSP
    
    ; ... 选择下一个任务 ...
    
    ; 恢复下一个任务的堆栈指针
    LDR     R1, =NextTaskTCB
    LDR     R2, [R1]
    LDR     R0, [R2]            ; PSP = TCB->StackPtr
    
    ; 恢复R4-R11
    LDMFD   R0!, {R4-R11}
    MSR     PSP, R0
    
    ; 异常返回,硬件自动恢复R0-R3,R12,LR,PC,xPSR
    BX      LR
小技巧: 我习惯在堆栈底部填充一个特殊标记(比如0xDEADBEEF),每次任务切换时检查这个标记是否被覆盖。这样能第一时间发现堆栈溢出。

1.3 恢复寄存器从堆栈:一场“拆包”行动

恢复过程其实就是保存的逆操作。但这里有个坑:恢复顺序必须和保存顺序完全相反。你想想看,堆栈是后进先出的,如果顺序搞反了,R4的值会跑到R11里去,那画面太美我不敢看。

我曾经见过一个案例:某工程师在恢复时用了LDMIA而不是LDMFD,结果寄存器全乱了。系统跑起来就像喝醉了酒,任务A打印的东西跑到任务B的缓冲区里去了。查了两天才找到原因。

正确的恢复流程是这样的:

  1. 从任务控制块中取出目标任务的堆栈指针
  2. 用LDMFD指令恢复R4-R11(注意是递增还是递减)
  3. 更新PSP寄存器
  4. 执行异常返回指令,硬件自动恢复R0-R3、R12、LR、PC、xPSR
警告: 千万不要在恢复过程中使能中断!我曾经犯过这个错,结果恢复到一半,中断来了,堆栈又被压了一层,返回时PC指向了错误地址。系统直接HardFault。

1.4 切换开销分析:别小看那几十个周期

很多初学者觉得上下文切换不就是保存恢复几个寄存器嘛,能有多慢?我给你们算笔账:

操作 周期数(Cortex-M4) 说明
硬件自动入栈 12 8个寄存器,流水线优化
手动保存R4-R11 8 每条LDMFD约4周期
切换堆栈指针 4 读写TCB
手动恢复R4-R11 8 每条LDMFD约4周期
硬件自动出栈 12 8个寄存器
总计 ~44周期 约1-2微秒(@100MHz)

你看,一次完整的上下文切换大概需要44个时钟周期。在100MHz的主频下,也就是不到0.5微秒。听起来很快对吧?但如果你系统里有10个任务,每个任务每毫秒切换一次,那每秒就是10000次切换,光切换就占用了5%的CPU时间。

我在做工业控制器时,有个任务需要每100微秒采集一次数据。结果发现切换开销占到了CPU时间的15%!后来我把这个任务的优先级提到最高,让它独占CPU,切换次数从每秒10000次降到了100次,问题才解决。

经验之谈: 上下文切换不是免费的。设计任务时,尽量让长时间运行的任务优先级高一些,减少不必要的切换。我一般建议:切换频率不要超过每秒1000次,否则CPU时间都浪费在“搬家”上了。

1.5 堆栈深度估算:别让任务“踩脚”

每个任务都需要独立的堆栈空间。堆栈深度怎么算?我有个简单粗暴的方法:

  1. 先估算函数调用最深的那条路径,数一数嵌套了多少层
  2. 每层按40字节算(局部变量+返回地址+寄存器保存)
  3. 加上中断嵌套的深度(一般按2层算,每层80字节)
  4. 最后乘以2,留出安全余量

举个例子:一个任务最深调用5层函数,中断嵌套2层。那堆栈大小就是:5×40 + 2×80 + 安全余量 = 360字节。我一般取整到512字节。

避坑指南: 我曾经给一个任务分配了256字节堆栈,觉得够用了。结果某次升级加了几个打印语句,堆栈直接爆了。从那以后,我每个任务至少给512字节,关键任务给1KB。堆栈这东西,宁多勿少。

好了,这一章的内容就到这里。下一章咱们聊聊堆栈溢出检测的几种实用方法,包括硬件检测和软件检测。到时候我会分享一个我用了好几年的堆栈使用率统计工具,保证实用。

记住:堆栈是RTOS的命脉。堆栈不乱,系统不崩。