GoDm@'s Blog

FreeRTOS的内部机制4

版权信息

warning

本文章为博主原创文章。遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。


这章我们来分析一下PendSV和SwitchContext的实现细节,OS内核中比较难啃的地方。
那我们先来看看 /portable/GCC/ARM_CM4F/port.c 对 PendSV_Handler 的实现。

1. PendSV_Handler 的实现(带浮点运算单元的版本)

void xPortPendSVHandler( void )
{
    /* This is a naked function. */

    __asm volatile
    (
        "   mrs r0, psp                         \n"
        "   isb                                 \n"
        "                                       \n"
        "   ldr r3, =pxCurrentTCB               \n" /* Get the location of the current TCB. */
        "   ldr r2, [r3]                        \n"
        "                                       \n"
        "   tst r14, #0x10                      \n" /* Is the task using the FPU context?  If so, push high vfp registers. */
        "   it eq                               \n"
        "   vstmdbeq r0!, {s16-s31}             \n"
        "                                       \n"
        "   stmdb r0!, {r4-r11, r14}            \n" /* Save the core registers. */
        "   str r0, [r2]                        \n" /* Save the new top of stack into the first member of the TCB. */
        "                                       \n"
        "   stmdb sp!, {r0, r3}                 \n"
        "   mov r0, %0                          \n"
        "   msr basepri, r0                     \n"
        "   dsb                                 \n"
        "   isb                                 \n"
        "   bl vTaskSwitchContext               \n"
        "   mov r0, #0                          \n"
        "   msr basepri, r0                     \n"
        "   ldmia sp!, {r0, r3}                 \n"
        "                                       \n"
        "   ldr r1, [r3]                        \n" /* The first item in pxCurrentTCB is the task top of stack. */
        "   ldr r0, [r1]                        \n"
        "                                       \n"
        "   ldmia r0!, {r4-r11, r14}            \n" /* Pop the core registers. */
        "                                       \n"
        "   tst r14, #0x10                      \n" /* Is the task using the FPU context?  If so, pop the high vfp registers too. */
        "   it eq                               \n"
        "   vldmiaeq r0!, {s16-s31}             \n"
        "                                       \n"
        "   msr psp, r0                         \n"
        "   isb                                 \n"
        "                                       \n"
        #ifdef WORKAROUND_PMU_CM001 /* XMC4000 specific errata workaround. */
            #if WORKAROUND_PMU_CM001 == 1
                "           push { r14 }                \n"
                "           pop { pc }                  \n"
            #endif
        #endif
        "                                       \n"
        "   bx r14                              \n"
        "                                       \n"
        "   .ltorg                              \n"
        ::"i" ( configMAX_SYSCALL_INTERRUPT_PRIORITY )
    );
}

整个过程可以划分为三个阶段:上半场(保存现场)、中场休息(挑选任务)、下半场(恢复现场)

第一阶段:上半场 —— 保存当前任务的现场

当系统触发 PendSV 进入这个中断时,硬件已经自动帮你把当前任务的 R0-R3, R12, LR, PC, xPSR(如果用了浮点运算,还有 S0-S15)压入了当前任务的栈里。剩下的活需要这段汇编手动干。

"   mrs r0, psp                         \n" /* 读取当前任务的栈指针 PSP 到 R0 */
"   isb                                 \n" /* 指令同步屏障,确保流水线执行完毕 */

"   ldr r3, =pxCurrentTCB               \n" /* R3 获取 pxCurrentTCB 指针的地址 */
"   ldr r2, [r3]                        \n" /* R2 获取当前 TCB 的真实首地址 */

解读:先把当前任务的栈顶指针 PSP 拿出来放在 R0 里备用。接着,顺藤摸瓜找到当前正在运行的任务控制块 TCB

"   tst r14, #0x10                      \n" /* 测试 R14(LR) 的第4位 */
"   it eq                               \n" /* 如果第4位是0 (Equal) */
"   vstmdbeq r0!, {s16-s31}             \n" /* 把浮点寄存器 s16-s31 压栈 */

解读这是带 FPU 芯片的特有。 在中断里,R14(也就是 LR 寄存器)里面装的不再是返回地址,而是一个叫做 EXC_RETURN 的特殊值。它的第 4 位用来标记被中断的任务刚刚有没有使用浮点运算。

解读:接着把普通寄存器 R4-R11 压栈。注意这里连 R14 (EXC_RETURN) 也一并压栈了,因为下一个任务醒来时,也要根据自己的 EXC_RETURN 来决定怎么出栈。
压栈结束后,R0 的位置发生移动,成为了新的“栈顶”。 str 则把这个新栈顶记录到任务的“身份证” TCB 里。当前任务,正式进入休眠!

第二阶段:中场休息 —— 呼叫 C 函数挑选新任务

这部分的核心是调用 vTaskSwitchContext,但调用 C 函数之前需要做周密的保护。

"   stmdb sp!, {r0, r3}                 \n" /* 把 r0 和 r3 压入主堆栈 (MSP) 保护起来 */

解读:由于接下来要调用 C 语言函数,C 函数在运行中可能会破坏寄存器的值。由于等会“下半场”我们还要靠 R3 去找下一个任务的 TCB,所以先把它们压入主堆栈(中断专用栈)藏起来。

"   mov r0, %0                          \n" /* %0 是宏传入的参数 configMAX_SYSCALL_INTERRUPT_PRIORITY */
"   msr basepri, r0                     \n" /* 写入 basepri 寄存器,屏蔽部分中断 */
"   dsb                                 \n" 
"   isb                                 \n"
"   bl vTaskSwitchContext               \n" /* 调用 C 函数,挑选最高优先级的就绪任务! */
"   mov r0, #0                          \n" 
"   msr basepri, r0                     \n" /* 清零 basepri,重新开启中断 */

解读:这就是 FreeRTOS 的临界区保护机制。通过配置 BASEPRI 寄存器,屏蔽掉所有优先级低于某个设定的中断,防止在切换任务指针的关键时刻又被打断。安全锁死后,调用调度算法 vTaskSwitchContext函数执行完毕后,全局变量 pxCurrentTCB 则被偷天换日,指向下一个要运行的高优先级任务

第三阶段:下半场 —— 恢复新任务的现场

现在,我们要把新任务唤醒,把刚才存入栈里的数据全部弹回 CPU。

"   ldmia sp!, {r0, r3}                 \n" /* 把之前藏在主堆栈里的 R0, R3 拿回来 */

"   ldr r1, [r3]                        \n" /* 此时 R3 指向的 pxCurrentTCB 已经变了!R1 拿到新任务的 TCB 首地址 */
"   ldr r0, [r1]                        \n" /* R0 拿到新任务的 pxTopOfStack (栈顶指针) */

解读:拿到新任务上次休眠时保存的栈顶指针 R0

"   ldmia r0!, {r4-r11, r14}            \n" /* 从新任务的栈中弹出 r4-r11 和 r14(EXC_RETURN) */

"   tst r14, #0x10                      \n" /* 再次检查弹出来的 R14 的第 4 位 */
"   it eq                               \n"
"   vldmiaeq r0!, {s16-s31}             \n" /* 如果新任务使用了浮点运算,把浮点寄存器也弹出来 */

解读怎么吃的怎么吐出来。 刚才压栈的顺序反过来执行一次。注意,弹出的 R14 决定了接下来是否要恢复浮点寄存器。

"   msr psp, r0                         \n" /* 把弹完数据后的 R0 重新写回 CPU 的栈指针寄存器 PSP */
"   isb                                 \n"
"   bx r14                              \n" /* 异常返回!硬件接管剩余出栈工作 */

解读:大功告成!把最新的栈位置还给 PSP 。最后一句 bx r14 让 CPU 根据 R14 的值退出中断模式。硬件会自动从 PSP 指向的栈里弹出 R0-R3, R12, LR, PC, xPSR。 伴随着 PC 寄存器的更新,CPU 已经置身于全新的任务现场中了!

如若不需浮点运算

如果不搞浮点运算,只用最普通的 Cortex-M3,完全可以删掉所有带 vstmdbeqvldmiaeq 的浮点指令,以及测试 R14 的逻辑,那么你的代码只剩下最核心的:

  1. MRS r0, PSP
  2. 推入 r4-r11
  3. 存入旧 TCB
  4. 调用 vTaskSwitchContext
  5. 读取新 TCB
  6. 弹出 r4-r11
  7. MSR PSP, r0
  8. BX LR

2. vTaskSwitchContext 的实现细节

如果说 PendSV 是负责干苦力的“搬运工”(把寄存器压栈出栈),那么 vTaskSwitchContext 就是坐在调度室里的“总调度长”。

PendSV 的纯汇编代码中,有一句极其关键的调用:bl vTaskSwitchContext。搬运工把旧任务打包好之后,跑来问总调度长:“下一个该让谁上?”总调度长查阅一番后,在小黑板上写下新任务的名字(更新 pxCurrentTCB 指针),然后搬运工再去把新任务解包运行。

1. 核心使命:更新 pxCurrentTCB

这个函数存在的终极目的只有一个:找到当前就绪链表中优先级最高的任务,并将全局指针 pxCurrentTCB 指向它的任务控制块(TCB)。

2. 附加工作(推荐开启)

在去挑下一个任务之前,调度长会顺手做两件附加工作:

3. 挑选最高优先级任务

附加工作做完,进入核心环节。在源码中,有一个宏: taskSELECT_HIGHEST_PRIORITY_TASK()

这是 FreeRTOS 神奇的地方:O(1) 时间复杂度的调度算法。 无论你创建了 5 个任务还是 50 个任务,它找出最高优先级任务消耗的时间是绝对恒定的。这是怎么做到的呢?这取决于你的架构配置。

方法 A:通用 C 语言法

—— 硬件不支持时的备选

如果是比较低端的 8 位单片机(没有专门的硬件指令),FreeRTOS 只能用一个 while 循环,从最高优先级的就绪链表往下遍历,直到找到第一个里面有任务的链表。这种方法虽然简单,但每次耗时不一样(O(n)),不够实时。

FreeRTOS会维护一个uxTopReadyPriority的变量记录就绪链表数,也是优先级数。

方法 B:硬件指令加速法

对于ARM架构芯片,FreeRTOS 采用了硬件优化方案:前导零计数指令(CLZ - Count Leading Zeros)

FreeRTOS 维护了一个 32 位的无符号整数变量,也叫做 uxTopReadyPriority。不同的方法uxTopReadyPriority的初始化不同,是条件编译实现的。

这条指令会在 1 个时钟周期内,直接告诉这个 32 位变量里,从左往右数有几个 0,从而反推出最高位是 1 的是在第几位。 这就等于瞬间知道了当前系统中有就绪任务的最高优先级是多少(比如优先级 5)。

4. 同级轮转再复习

找到了最高优先级(假设是 5),接下来就要从 pxReadyTasksLists[5] 这个链表里挑任务了。

如果这个链表里有多个任务(比如任务 A 和任务 B 都是优先级 5,并且都在就绪状态),怎么做到公平的时间片轮转呢?

taskSELECT_HIGHEST_PRIORITY_TASK 宏的内部,有这样一句代码:

listGET_OWNER_OF_NEXT_ENTRY( pxCurrentTCB, &( pxReadyTasksLists[ uxTopPriority ] ) );

共计约2.7k字。于2026/03/28首次发布,最后更新于2026/03/28。

本文章为博主原创文章。遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。

  1. 1. PendSV_Handler 的实现(带浮点运算单元的版本)
    1. 第一阶段:上半场 —— 保存当前任务的现场
    2. 第二阶段:中场休息 —— 呼叫 C 函数挑选新任务
    3. 第三阶段:下半场 —— 恢复新任务的现场
    4. 如若不需浮点运算
  2. 2. vTaskSwitchContext 的实现细节
    1. 1. 核心使命:更新 pxCurrentTCB
    2. 2. 附加工作(推荐开启)
    3. 3. 挑选最高优先级任务
    4. 4. 同级轮转再复习