第3章 调度器启动与上下文切换:启动流程剖析、PendSV异常优化、寄存器压栈出栈技巧
各位做嵌入式的老铁,今天我们来聊聊FreeRTOS最核心的机制——调度器启动和上下文切换。说实话,这部分代码我翻来覆去看了不下五十遍。每次在ARM Cortex-M上移植新平台,都得跟这几个函数打交道。今天我把这些年踩过的坑、总结的技巧,一次性倒给你们。
3.1 调度器启动流程剖析
调度器启动,说白了就是让FreeRTOS从「单任务裸奔模式」切换到「多任务调度模式」。这个转折点就在 vTaskStartScheduler() 这个函数里。
我个人习惯把启动流程拆成三步走:
- 创建空闲任务——系统必须有个「兜底」的任务
- 初始化系统时钟——SysTick定时器开始滴答
- 启动第一个任务——通过SVC异常跳进去
嗯,这里要注意,vTaskStartScheduler() 最后会调用 portSchedulerStart()。这个函数在ARM Cortex-M上,其实就是触发一个SVC异常。为什么会这样?因为只有通过异常,才能让CPU从特权级切换到用户级,同时把第一个任务的寄存器环境恢复好。
核心要点:调度器启动的本质,就是「伪造」一个任务切换的现场,然后通过异常返回,让CPU以为自己刚从某个任务中被中断了。
我在项目中遇到过一个问题:某次在STM32F4上移植,调度器启动后直接跑飞。查了两天才发现,是SVC中断优先级没配好。你想想看,SVC的优先级如果比PendSV低,那系统一启动就乱了套。
3.2 PendSV异常优化
PendSV是ARM Cortex-M上专门为RTOS设计的异常。它的优先级可以设为最低,这样就不会打断其他中断。说白了,任务切换这件事,得等所有中断都处理完了再做。
FreeRTOS触发PendSV的方式很简单:
/* 触发PendSV异常 */
portNVIC_INT_CTRL_REG = portNVIC_PENDSVSET_BIT;
但这里有个优化点——PendSV的触发时机。我建议只在以下三种情况触发:
- 任务主动让出CPU(调用taskYIELD)
- 时间片用完(SysTick中断中检查)
- 更高优先级任务就绪(比如中断中释放了信号量)
我曾经犯过一个错误:在每次SysTick中断里都无条件触发PendSV。结果系统性能直接掉了30%。你想想看,如果当前任务还没用完时间片,你强行切换,那不是白折腾吗?
优化技巧:在SysTick中断里,先检查当前任务是否真的需要被切换。如果当前任务仍然是最高优先级的就绪任务,那就别触发PendSV。这个判断在 xTaskIncrementTick() 里已经做了,但很多人没注意到返回值。
3.3 寄存器压栈出栈技巧
这部分是上下文切换的「硬核」内容。ARM Cortex-M在进入异常时,硬件会自动压栈一部分寄存器:R0-R3、R12、LR、PC、xPSR。这就是所谓的「自动压栈」。
但FreeRTOS需要保存的寄存器不止这些。R4-R11这些通用寄存器,还有FPU寄存器(如果用了浮点),都得手动压栈。
看这段核心代码:
/* 保存当前任务的上下文 */
__asm void xPortPendSVHandler(void)
{
extern uxCriticalNesting;
extern pxCurrentTCB;
extern vTaskSwitchContext;
PRESERVE8
mrs r0, psp /* 获取进程栈指针 */
isb
ldr r3, =pxCurrentTCB
ldr r2, [r3]
stmdb r0!, {r4-r11} /* 手动压栈R4-R11 */
str r0, [r2] /* 更新TCB中的栈指针 */
stmdb sp!, {r3, r14}
mov r0, #configMAX_SYSCALL_INTERRUPT_PRIORITY
msr basepri, r0
bl vTaskSwitchContext
mov r0, #0
msr basepri, r0
ldmia sp!, {r3, r14}
ldr r1, [r3]
ldr r0, [r1] /* 获取新任务的栈指针 */
ldmia r0!, {r4-r11} /* 手动出栈R4-R11 */
msr psp, r0 /* 更新进程栈指针 */
isb
bx r14 /* 异常返回,硬件自动出栈 */
}
这段代码我手写过不下十次。有几个细节你得注意:
| 操作 | 说明 | 常见坑 |
|---|---|---|
| mrs r0, psp | 获取当前任务的栈指针 | 忘记加isb指令,导致读到的值不对 |
| stmdb r0!, {r4-r11} | 手动压栈8个寄存器 | 压栈顺序和出栈顺序必须一致 |
| str r0, [r2] | 更新TCB中的栈顶指针 | TCB结构体偏移量要算对 |
| bx r14 | 异常返回 | LR的EXC_RETURN值必须正确 |
警告:如果你用了FPU,事情就复杂了。FPU寄存器有32个(S0-S31),每个4字节,总共128字节。硬件自动压栈只压S16-S31,S0-S15需要手动处理。我曾经因为FPU寄存器没压全,导致浮点运算结果时对时错,排查了整整两天。
还有个技巧——临界区保护。在PendSV处理函数里,切换任务上下文时,必须关中断。你看代码里用 basepri 寄存器屏蔽了所有可屏蔽中断。为什么?因为如果切换过程中来了个中断,中断里又触发了PendSV,那栈指针就乱套了。
我个人习惯在 vTaskSwitchContext 调用前后都加上临界区保护。虽然FreeRTOS源码里已经做了,但多一层保护总没错。毕竟嵌入式系统,稳定第一。
最后说个经验之谈:调试上下文切换时,别光看代码。用调试器单步跟踪,盯着 pxCurrentTCB 的变化,看栈指针的移动。我每次移植新平台,都会在PendSV入口和出口各放一个GPIO翻转,用示波器看切换时间。这样心里才有底。
好了,调度器启动和上下文切换就聊到这儿。下一章我们讲任务创建和删除,到时候再聊聊动态内存分配的那些坑。