uC/OS-II上下文切换:从OS_Sched到PendSV栈帧恢复
2026/9/18 10:31:32 网站建设 项目流程

1. 第 6 篇为什么专门啃任务切换这段代码

1.1 前五篇留下的那个“最后一公里”

uC/OS-II 的内核源码精读走到第六篇,前面已经把骨架摸得差不多了:目录树里哪些文件属于核心、OSInit()到底初始化了哪些全局变量、任务控制块OS_TCB里那几十个字段各自管什么、任务就绪表OSRdyGrp/OSRdyTbl[]是怎么用一张位图把 64 个优先级压缩进 8 个字节、OSTaskCreate()建栈挂表的完整流程。这些东西捋顺之后,你其实已经能回答一个问题了:此刻哪个任务该跑

但把这个问题回答出来,和 CPU 真的跳过去执行那个任务,中间还隔着最关键的一层——上下文切换。这个系列的定位是从 6736 行嵌入式内核源码里看懂一个 RTOS 是怎么运作的,而上下文切换恰恰是那 6736 行里最“不值一提”又最不能出错的一小段。它短到你翻源码的时候可能直接跳过去,也深到你一旦移植层写错一个寄存器,整个系统就进 HardFault。

这一篇的读者画像很明确:已经跟着前面几篇把任务管理和就绪表看完了、手上有一块 Cortex-M3 的开发板、准备自己把内核移植跑起来的人。如果你只是想了解 RTOS 和 Linux 调度模型的区别,看前面几篇也够了;但如果你想让两个任务真的交替跑起来,这一篇绕不过去。

1.2 6736 行里最容易看漏的两段代码

先把口径说清楚。uC/OS-II 内核源码的“6736 行”是个常见统计口径,指的是OS_CORE.COS_TASK.COS_TIME.COS_SEM.COS_Q.COS_MBOX.COS_MUTEX.COS_MEM.COS_FLAG.C这些核心 C 文件加起来的量级,不含移植层和上层应用。这个数字本身不用太较真,不同版本、不同宏开关裁剪之后会有出入,重要的是它的量级——一个完整可用的实时内核,核心逻辑就六千多行。

在这六千多行里,真正干“换栈”这件事的代码非常少。C 侧是OS_Sched()OS_SchedNew()OSIntExit()三个函数里寥寥几行,汇编侧是OSCtxSwOSIntCtxSwOSStartHighRdyPendSV_Handler这几个标签。加起来一百行出头。

我见过不少人读完就产生一个误解:以为OS_Sched()里那句OS_TASK_SW()是个函数调用,调完就换任务了。实际上它是个宏,展开之后是一条软中断指令或者一次异常触发。真正的寄存器保存和恢复,发生在另一个时间点、另一个栈上、由硬件协助完成。这个“时间差”和“栈切换”是理解 RTOS 上下文的钥匙,也是最近几年 RTOS 面试题里高频出现的一道——问“任务切换时哪些寄存器是硬件压的、哪些是软件压的”,能答清楚的人并不多。

2. 调度器 OSSched 的完整执行路径拆解

2.1 OSSched 里那五道必须过的门槛

先看源码。uC/OS-II 的OS_Sched()在不同版本里写法略有差异,V2.86 之后把查找最高优先级的动作抽成了OS_SchedNew(),主体大致是这样:

void OS_Sched (void) { #if OS_CRITICAL_METHOD == 3u OS_CPU_SR cpu_sr = 0u; #endif OS_ENTER_CRITICAL(); if (OSIntNesting == 0u) { /* 不在中断里 */ if (OSLockNesting == 0u) { /* 没有被上锁 */ OS_SchedNew(); OSTCBHighRdy = OSTCBPrioTbl[OSPrioHighRdy]; if (OSPrioHighRdy != OSPrioCur) { /* 确实有更该跑的任务 */ OS_TASK_SW(); /* 触发切换 */ } } } OS_EXIT_CRITICAL(); }

这四层嵌套判断,每一层都对应一个真实场景,漏掉任何一层都会出问题。

OSIntNesting == 0这一条是防止在中断服务程序里调用调度器。中断里也有任务切换,但走的是另一条路(OSIntExit()OSIntCtxSw()),两条路的栈状态完全不一样,绝对不能混用。OSLockNesting == 0是给临界区用的——调度器上锁期间,即使有更高优先级任务就绪,也不许切。这个机制在操作共享资源时是刚需的,比如你正在改一个链表,切走之后另一个任务也来改,链表就烂了。

OSPrioHighRdy != OSPrioCur这一条经常被忽略。它拦掉的是“找到的最高优先级就是当前任务”的情况。这个判断省掉了一次无意义的上下文切换,在中断频繁、任务优先级分布集中的系统里,能省下可观的 CPU 时间。至于OS_SchedNew()里的位图查表算法,前面第 4 篇已经拆过了,这里不重复。

注意:OS_Sched()必须在临界区里执行。有人为了“减少关中断时间”,把OS_ENTER_CRITICAL()挪到if (OSIntNesting == 0u)里面,这在单核上是危险的——判断和切换之间一旦被中断打断,OSPrioHighRdy可能已经过期。

2.2 OSPrioHighRdy 到 OSTCBCur 的指针替换

OS_SchedNew()算出来的OSPrioHighRdy只是一个INT8U的优先级号,真正的任务信息在OSTCBPrioTbl[]这张以优先级为下标的指针数组里。所以紧接着的一句OSTCBHighRdy = OSTCBPrioTbl[OSPrioHighRdy];是必须的——它把“该跑谁”从编号变成了 TCB 指针。

这里有个设计细节值得说:uC/OS-II 用优先级做下标来索引 TCB 表,而不是遍历任务链表找最高优先级。好处是查找时间恒定,OS_SchedNew()里只有两次内存查表加一次移位加法;坏处是优先级数量直接决定了这张表的大小,OS_LOWEST_PRIO开太大,静态 RAM 就吃得多。这是典型的实时系统取舍:确定性优先于空间效率

OSCtxSwCtr这个全局计数器在OS_Sched()里不加,在触发切换的那条路径上加。它的实际价值比看起来大——调试的时候单步看这个数,能立刻判断出“系统到底有没有在切任务”。如果你发现任务跑起来了但OSCtxSwCtr一直是 0,那说明要么只有一个任务就绪,要么调度被卡住了。

2.3 什么时候调度器不该被触发

有一类坑我是踩过的:在OSTaskCreate()之后立刻判断“新任务有没有抢占当前任务”,然后手动调OS_Sched()。这在任务创建于OSStart()之前是没问题的,因为那时OSIntNestingOSLockNesting都是 0;但如果在运行期创建任务,而当前正好在OSTimeDly()的临界区里,手动调用就可能踩到OSLockNesting != 0的分支,看起来“调度器没生效”。

真实情况是:OSTaskCreate()内部已经处理过这件事了,它会根据OSRunning标志决定要不要触发调度。你手动再调一次,通常是多余的。真正需要手动介入的场景很少,主要是任务删除、优先级动态改变之后,而 uC/OS-II 在这两个 API 里也都自己处理了。

所以我的建议是:不要主动调OS_Sched(),除非你明确知道自己在做什么,并且确认过调用点的OSIntNestingOSLockNesting状态。否则系统行为会变得难以预测。

3. 上下文切换的真实开销:从 OSCtxSw 到 OS_TASK_SW

3.1 为什么不用函数调用,而要用软中断

OS_TASK_SW()是个移植层定义的宏,在 Cortex-M 上通常长这样:

#define OS_TASK_SW() OSCtxSw() #define OSIntCtxSw() OSCtxSw()

或者更常见的做法是直接触发 PendSV:

#define OS_TASK_SW() NVIC_SetPendingIRQ(PendSV_IRQn) #define OSIntCtxSw() NVIC_SetPendingIRQ(PendSV_IRQn)

为什么绕这一圈,不直接在OS_Sched()里调用一个汇编函数把所有寄存器压栈再换栈?因为中断返回有硬件参与的状态恢复机制,而普通函数调用没有。

在 Cortex-M 上,异常进入时硬件会自动把xPSRPCLRR12R3R2R1R0这八个寄存器压到当前使用的栈上;异常返回时再由硬件自动弹出。这套机制是 Cortex-M 架构设计的一部分,效率比软件压栈高——8 个字压栈只需要 10 个周期左右(带写缓冲),软件写 8 条STM指令也未必更快,而且会破坏寄存器状态。

如果不用异常机制,你就得自己保存这 8 个寄存器,还要自己处理PC跳转和状态标志恢复,代码复杂度和出错概率都上一个数量级。用 PendSV 的另一个好处是它可以被延迟——PendSV 的优先级可以被设成最低(通常设成0xFF),这样在其他中断还在处理时,它会等到所有高优先级中断都退出了才执行。这正是“在中断里发现的调度需求,拖到中断退出后再统一处理”这个机制的物理基础。

3.2 任务栈帧的逐字段结构

Cortex-M3 上,一个被切换出去的任务,它的栈顶往下依次放着这些内容:

偏移(从栈顶起)内容由谁压入字节数
0 ~ 28R4 ~ R11软件(STM R0, {R4-R11}32
32R0硬件自动4
36R1硬件自动4
40R2硬件自动4
44R3硬件自动4
48R12硬件自动4
52LR硬件自动4
56PC(异常返回地址)硬件自动4
60xPSR硬件自动4

合计 64 字节,也就是 16 个OS_STKOS_STK在 32 位机上定义为INT32U)。这个 64 字节是固定的,不管你任务函数多简单,每个任务被切走时至少要有这么多栈空间留给上下文。

再往下才是任务自己的局部变量、函数调用的返回地址链、以及可能被中断压进来的内容。所以估算任务栈大小的公式大致是:

任务栈需求 = 64 字节(上下文)+ 最深调用链的局部变量总和 + 函数调用帧开销 + 安全余量

安全余量我一般留 30% 以上。栈溢出在 RTOS 里是最难查的一类问题——它不像在 PC 上会直接段错误,而是悄无声息地覆盖相邻内存,可能改掉另一个任务的 TCB,可能改掉你的全局变量,现象千奇百怪,可能运行几小时才崩一次。

3.3 临界区边界和关中断时间

OS_Sched()里那段临界区,关中断时间到底有多少?取决于OS_SchedNew()的实现。用OSUnMapTbl查表的话,两次查表加一次移位加法,十几个周期;如果OS_LOWEST_PRIO > 63,走的是另一套用INT16U的查表分支,周期数略多。

十几到二十几个周期的关中断时间,在 108MHz 的 GD32F103 上不到 0.3 微秒。这个量级对于大多数应用是可以接受的,但如果你的系统里有对中断延迟极度敏感的环节(比如高速采样、PWM 同步),就得掂量一下了。

可以优化的地方是:把OS_SchedNew()的结果缓存起来,避免频繁调用。但 uC/OS-II 的设计哲学是不做这种优化——它更看重代码的可读性和确定性,而不是把周期数压到极致。这也是它和商业内核的一个明显差异点。

提示:如果你在OSTaskSwHook()里塞了耗时操作,注意这个钩子是在中断上下文里执行的(Cortex-M 上的 PendSV 也是异常),它占用的是主栈 MSP 而不是任务栈 PSP。钩子里做长循环会直接拉长切换时间,甚至可能触发主栈溢出。

4. 中断里的任务切换:OSIntCtxSw 与 OSIntExit

4.1 为什么中断服务函数里不能直接调用 OSCtxSw

先讲清楚两条路径的区别。任务主动放弃 CPU(OSTimeDly()OSSemPend()阻塞等)走的是OS_Sched()OS_TASK_SW();中断服务程序里让一个任务就绪、需要抢占,走的是OSIntExit()OSIntCtxSw()

OSIntCtxSw()不能简单地等同于OSCtxSw()。原因在于栈的状态已经变了。中断进入时,被中断任务的上下文已经被硬件压到了它的任务栈上(Cortex-M 上,如果中断前在跑任务,用的是 PSP,硬件压到的就是 PSP 指向的任务栈)。中断服务程序本身是在主栈 MSP 上跑的。

所以当你在中断里决定“退出中断后不回到原任务,而是去跑另一个任务”时,需要做的是:从当前任务栈里把已经压好的上下文保留,然后把OSTCBCur换成OSTCBHighRdy,最后在异常返回时用新任务的 PSP 弹出上下文。这个动作比任务级切换要少一步——因为它不需要再单独触发一次异常,它就在异常处理流程里。

在 Micrium 官方的 Cortex-M3 移植里,OSCtxSw()OSIntCtxSw()都只是设置 PendSV 挂起位,真正的活在OS_CPU_PendSVHandler里干。这样做的另一个好处是:任务级切换和中断级切换共用同一段汇编,逻辑只有一份,不容易出岔子。

4.2 PendSV_Handler 的关键几句到底在干什么

看一眼官方的OS_CPU_PendSVHandler核心片段(语法做了整理,便于阅读):

OS_CPU_PendSVHandler CPSID I MRS R0, PSP CBZ R0, OS_CPU_PendSVHandler_nosave SUBS R0, R0, #0x20 STM R0, {R4-R11} LDR R1, =OSTCBCur LDR R1, [R1] STR R0, [R1] ; OSTCBCur->OSTCBStkPtr = SP OS_CPU_PendSVHandler_nosave PUSH {R14} LDR R0, =OSTaskSwHook BLX R0 POP {R14} LDR R0, =OSPrioCur LDR R1, =OSPrioHighRdy LDRB R2, [R1] STRB R2, [R0] LDR R0, =OSTCBCur LDR R1, =OSTCBHighRdy LDR R2, [R1] STR R2, [R0] LDR R0, [R2] LDM R0, {R4-R11} ADDS R0, R0, #0x20 MSR PSP, R0 ORR LR, LR, #0x04 CPSIE I BX LR

逐句拆一下。MRS R0, PSP取出当前任务的进程栈指针。CBZ R0, ..._nosave是处理第一次启动的情况——OSStartHighRdy()手动构造了一个栈帧,但那时 PendSV 还没执行过,PSP 可能为 0,直接跳到 nosave 分支。

SUBS R0, R0, #0x20加上STM R0, {R4-R11},是在硬件已经压好的 8 个寄存器(32 字节)下面,再腾出 32 字节空间存 R4~R11。这就是前面表格里那 64 字节的来源。

STR R0, [R1]把新的栈顶写回OSTCBCur->OSTCBStkPtr。这一步是整个切换的核心——任务的“现场”就靠这个指针串起来,下次切回来的时候,从这个指针一路往上弹,就能精确恢复到被切走的那一瞬间。

后半段LDR R0, [R2]取新任务的OSTCBStkPtrLDM R0, {R4-R11}恢复通用寄存器,ADDS R0, R0, #0x20把 PSP 调整到硬件栈帧的起点,MSR PSP, R0写回,最后ORR LR, LR, #0x04设置异常返回的 EXC_RETURN 位,告诉硬件“返回时用进程栈”。

注意:ORR LR, LR, #0x04这一句如果漏了,异常返回会使用主栈 MSP,结果就是从任务栈里弹出的内容被丢弃,PC 恢复到错误地址,表现为随机跑飞或者进 HardFault。这个错误在很多第三方移植版本里出现过,查起来极其费劲,因为现象不固定。

4.3 OSIntNesting 计数器的真实用途

OSIntNesting在 uC/OS-II 里不只是个统计量,它是调度器的“安全阀”。每次进中断OSIntEnter()加一,每次OSIntExit()减一。只有当它减到 0 的时候,OSIntExit()才会去查就绪表并决定要不要做中断级任务切换。

这套机制解决的是中断嵌套问题。假设三层中断嵌套,最内层让一个高优先级任务就绪了。如果每次退出中断都做一次调度判断,那前两层退出时的判断都是浪费的——因为外层中断退出后可能还有更高优先级的任务就绪。所以 uC/OS-II 选择只在最外层(OSIntNesting减到 0)做一次判断。

OSIntExit()里的判断条件也很有意思:

if ((--OSIntNesting | OSLockNesting) == 0u) {

这一句把“中断嵌套计数减到 0”和“调度器未上锁”两个条件用按位或拼在一起判断。只要任意一个不为 0,就跳过调度。我更倾向于写成两个独立的if,可读性更好,但 uC/OS-II 追求的是指令数——在中断退出的关键路径上,每一个周期都是成本。

5. 实测记录:移植到 GD32F103 后的验证

5.1 移植层要自己写的四个函数

GD32F103 是 Cortex-M3 内核,和 STM32F103 在架构上同源,做 uC/OS-II 移植时需要的文件是os_cpu.hos_cpu_c.cos_cpu_a.asm三个。真正绕不过去、必须自己动手的是这四个:

  • OSStartHighRdy():启动第一个任务。它要做的核心是取出OSTCBHighRdy->OSTCBStkPtr,把 R4~R11 弹出来,设置 PSP,然后触发异常返回到任务入口。
  • OSCtxSw():任务级切换,通常就是设 PendSV 挂起位。
  • OSIntCtxSw():中断级切换,同样设 PendSV 挂起位。
  • OS_CPU_PendSVHandler():真正的切换现场。

其中OSStartHighRdy()最容易写错。它需要处理的是一个“人造栈帧”——因为第一个任务从来没有被切走过,它的栈是自己用OSTaskStkInit()铺出来的。OSTaskStkInit()在 C 文件里,要按前面那张表格的顺序,把 R0~R15 和 xPSR 依次填到栈里,返回值是新的栈顶。常见错误是把 PC 填成了任务函数的地址而不是任务函数的地址(看起来是一回事,但有的移植版本要填OS_TaskReturn的地址再跳转),或者 xPSR 里没有设置 Thumb 位(bit 24),导致一执行就 HardFault。

我在 GD32F103 上踩过的具体坑是:OSTaskStkInit()里 xPSR 初始值写成了0x01000000之外的数,任务第一次被启动时立刻 HardFault。正确的做法是把这个值构造成0x01000000,确保进入任务时处于 Thumb 状态。

5.2 用双路手段验证切换是否真的发生

验证上下文切换我用了两个手段,互相印证。

第一个是软件手段:打开OS_TASK_STAT_EN,用OSStatInit()建立统计任务,然后周期性打印OSCtxSwCtr和 CPU 使用率。如果两个同优先级任务都用OSTimeDly()让出 CPU,OSCtxSwCtr应该稳定增长,且增量大致等于时间片的倒数。这个手段能证明“调度器在跑”,但证明不了“栈切换正确”。

第二个是硬件手段:在两个任务里各翻转一个 GPIO,用逻辑分析仪同时抓两路波形。正常情况应该看到两路方波交替出现,占空比由任务的运行时长决定。这一步能肉眼验证“两个任务真的在交替执行”,而且能看出来切换的边界是不是干净——如果某一路的高电平时间比预期长很多,说明有任务被卡住了。

还做了第三个补充测试:在两个任务里各自累加一个全局变量并定期校验,跑满 24 小时看有没有数值异常。这个测试专门针对栈溢出——如果某个任务的栈溢出踩到了另一个任务的变量,校验就会失败。跑了三天没有异常,才敢说这个移植是可信的。

5.3 切换耗时的实测数据

用 GPIO 在 PendSV 进入时拉高、在BX LR之前拉低,抓到的脉冲宽度就是切换本身的耗时。在 GD32F103 跑 108MHz 的情况下,实测这个宽度稳定在 0.8~1.2 微秒之间,换算成周期数是 85~130 个。

这个数字里包含了:异常进入的硬件压栈、STM保存 R4~R11、若干次LDR/STROSTaskSwHook()的空函数调用开销、LDM恢复、异常返回的硬件弹栈。其中OSTaskSwHook()占了不小一块,如果把它改成空宏而不是空函数,还能再省十几个周期。

对比一下:完整的一次任务切换(从OS_Sched()进入临界区算起到新任务开始执行)大概在 1.5~2 微秒。这个量级意味着每秒钟最多能做几十万次切换。对于绝大多数嵌入式应用,这个开销完全可以接受;但如果你的任务切换频率真到了每秒几万次,那就得重新审视任务划分了——多半是设计问题,而不是内核性能问题。

6. 常见问题与排查实录

6.1 问题速查表

现象最可能的原因排查手段
只有第一个任务在跑,不切换OSLockNesting没减回去,或OS_Sched()没被触发断点看OSLockNestingOSCtxSwCtr
切换后进 HardFault栈帧构造错误,或 PSP/MSP 用反看压栈内容,确认OSTaskStkInit()的 xPSR
中断里切换后卡死OSIntEnter()/OSIntExit()没配对检查OSIntNesting是否归零
偶发性跑飞,几小时一次栈溢出踩内存栈填0xA5模式法看水位
任务运行时间明显偏长中断占用过多,或钩子函数太重统计OSIntNesting峰值
第一个任务启动就崩OSStartHighRdy()汇编写错单步跟到异常返回那一条

6.2 几个只有踩过才懂的细节

第一,栈填充法要真的去做,不要凭感觉估。做法是在OSTaskCreate()之前把整个OSTaskStk[]数组全部填成0xA5,跑一段时间后从栈底往上扫,看还有多少连续的0xA5没被覆盖。剩下的就是剩余栈空间。我一般要求剩余量不低于总栈量的 25%。这个方法朴素但极其有效,能提前发现 90% 的栈溢出隐患。

第二,OSTaskSwHook()里别做任何耗时的事。这个钩子是在 PendSV 上下文里调用的,属于异常上下文。在里面做长循环、打印调试、甚至调用OSTimeDly(),都会直接破坏系统时序。我见过有人在钩子里做串口打印,结果每次切换花掉几百微秒,系统直接失去实时性。

第三,共享变量一定要加volatile编译器在-O2下会把循环里的变量读取优化到寄存器里,导致你在中断里改的值永远读不到。这个问题在调试版本里不出现(-O0),一发布就出问题,是典型的“Release Only Bug”。

第四,OSCtxSw()里关中断的时间必须短。Cortex-M 上 PendSV 的优先级通常设成最低,这意味着如果有一个高频中断一直在跑,PendSV 会被无限延迟,表现为任务切换“卡顿”。正确的做法是关中断时间够短,让 PendSV 有机会插进去。同时要确认你的高优先级中断服务程序里不要长时间关中断。

第五,主栈 MSP 的大小也要在启动文件里调大。中断嵌套和 PendSV 都跑在主栈上,默认的启动文件里Stack_Size往往只有 0x400。如果中断嵌套层数多,或者钩子函数里用了较大的局部数组,主栈溢出会直接踩到.data段,现象比任务栈溢出更诡异。我一般会把主栈调到 0x800 以上。

写到这里,关于 uC/OS-II 上下文切换这条线基本走完了。我自己在这个环节上花的时间,比前面五篇加起来都多——不是因为代码复杂,而是因为它涉及编译器、架构手册、链接脚本、汇编语法四件事的交叉。真正跑通那一刻,回头看那 6736 行,会突然觉得很多之前看不懂的注释都能读懂了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询