第3章 调度器启动与上下文切换:启动流程剖析、PendSV异常优化、寄存器压栈出栈技巧

各位做嵌入式的老铁,今天我们来聊聊FreeRTOS最核心的机制——调度器启动和上下文切换。说实话,这部分代码我翻来覆去看了不下五十遍。每次在ARM Cortex-M上移植新平台,都得跟这几个函数打交道。今天我把这些年踩过的坑、总结的技巧,一次性倒给你们。

3.1 调度器启动流程剖析

调度器启动,说白了就是让FreeRTOS从「单任务裸奔模式」切换到「多任务调度模式」。这个转折点就在 vTaskStartScheduler() 这个函数里。

我个人习惯把启动流程拆成三步走:

  1. 创建空闲任务——系统必须有个「兜底」的任务
  2. 初始化系统时钟——SysTick定时器开始滴答
  3. 启动第一个任务——通过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翻转,用示波器看切换时间。这样心里才有底。

好了,调度器启动和上下文切换就聊到这儿。下一章我们讲任务创建和删除,到时候再聊聊动态内存分配的那些坑。