版权信息
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 位用来标记被中断的任务刚刚有没有使用浮点运算。
-
如果有用(第4位=0),我们就必须手动把剩下的高位浮点寄存器
s16-s31也压入当前任务的栈(R0)里。(s0-s15由硬件压)。" stmdb r0!, {r4-r11, r14} \n" /* 将普通寄存器 r4-r11 和 r14(EXC_RETURN) 压栈 */ " str r0, [r2] \n" /* 【关键!】把最终的栈指针 R0 保存到 TCB 的第一个成员 pxTopOfStack 里 */
解读:接着把普通寄存器 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,完全可以删掉所有带 vstmdbeq 和 vldmiaeq 的浮点指令,以及测试 R14 的逻辑,那么你的代码只剩下最核心的:
MRS r0, PSP- 推入
r4-r11 - 存入旧 TCB
- 调用
vTaskSwitchContext - 读取新 TCB
- 弹出
r4-r11 MSR PSP, r0BX LR
2. vTaskSwitchContext 的实现细节
如果说 PendSV 是负责干苦力的“搬运工”(把寄存器压栈出栈),那么 vTaskSwitchContext 就是坐在调度室里的“总调度长”。
在 PendSV 的纯汇编代码中,有一句极其关键的调用:bl vTaskSwitchContext。搬运工把旧任务打包好之后,跑来问总调度长:“下一个该让谁上?”总调度长查阅一番后,在小黑板上写下新任务的名字(更新 pxCurrentTCB 指针),然后搬运工再去把新任务解包运行。
1. 核心使命:更新 pxCurrentTCB
这个函数存在的终极目的只有一个:找到当前就绪链表中优先级最高的任务,并将全局指针 pxCurrentTCB 指向它的任务控制块(TCB)。
2. 附加工作(推荐开启)
在去挑下一个任务之前,调度长会顺手做两件附加工作:
-
运行时间统计 (Run Time Stats): 如果你的系统开启了
configGENERATE_RUN_TIME_STATS,调度长会记录一下上一个任务总共跑了多长时间。你平时在终端里敲top命令看到的 CPU 占用率,底层数据就是在这里收集的。 -
栈溢出检查 (Stack Overflow Checking): 如果你开启了
configCHECK_FOR_STACK_OVERFLOW。调度长会去检查刚刚被暂停的那个任务的栈。它会去看栈底的最后几个字节(也就是我们之前提到过的魔术字,通常是0xA5A5A5A5)有没有被改写。如果被改写了,说明任务的局部变量开得太大,把栈底击穿了!调度长会立刻触发vApplicationStackOverflowHook()函数,通常是直接死机报错,以确保系统不带病运行。
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的初始化不同,是条件编译实现的。
- 它的每一位(Bit)代表一个优先级。
- 如果优先级 3 的就绪链表里有任务,就把 Bit 3 置为 1。如果没有,就置 0。 当调度长需要找最高优先级时,只需执行一条汇编指令:
__asm ("clz r0, r1")
这条指令会在 1 个时钟周期内,直接告诉这个 32 位变量里,从左往右数有几个 0,从而反推出最高位是 1 的是在第几位。 这就等于瞬间知道了当前系统中有就绪任务的最高优先级是多少(比如优先级 5)。
4. 同级轮转再复习
找到了最高优先级(假设是 5),接下来就要从 pxReadyTasksLists[5] 这个链表里挑任务了。
如果这个链表里有多个任务(比如任务 A 和任务 B 都是优先级 5,并且都在就绪状态),怎么做到公平的时间片轮转呢?
在 taskSELECT_HIGHEST_PRIORITY_TASK 宏的内部,有这样一句代码:
listGET_OWNER_OF_NEXT_ENTRY( pxCurrentTCB, &( pxReadyTasksLists[ uxTopPriority ] ) );
- 就绪链表不仅记录了头部,还记录了一个索引指针(Index)。
- 每次调用这个函数,索引指针就会往后移动一格。
- 第一次挑出任务 A 给
pxCurrentTCB,索引指向 B。下一个 SysTick 到来触发切换时,再调用这个函数,就会挑出任务 B,索引又绕回 A。 - 这就完美实现了同级优先级的公平时间片轮转(Round-Robin)