
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.C、OS_TASK.C、OS_TIME.C、OS_SEM.C、OS_Q.C、OS_MBOX.C、OS_MUTEX.C、OS_MEM.C、OS_FLAG.C这些核心 C 文件加起来的量级不含移植层和上层应用。这个数字本身不用太较真不同版本、不同宏开关裁剪之后会有出入重要的是它的量级——一个完整可用的实时内核核心逻辑就六千多行。在这六千多行里真正干“换栈”这件事的代码非常少。C 侧是OS_Sched()、OS_SchedNew()、OSIntExit()三个函数里寥寥几行汇编侧是OSCtxSw、OSIntCtxSw、OSStartHighRdy、PendSV_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()之前是没问题的因为那时OSIntNesting和OSLockNesting都是 0但如果在运行期创建任务而当前正好在OSTimeDly()的临界区里手动调用就可能踩到OSLockNesting ! 0的分支看起来“调度器没生效”。真实情况是OSTaskCreate()内部已经处理过这件事了它会根据OSRunning标志决定要不要触发调度。你手动再调一次通常是多余的。真正需要手动介入的场景很少主要是任务删除、优先级动态改变之后而 uC/OS-II 在这两个 API 里也都自己处理了。所以我的建议是不要主动调OS_Sched()除非你明确知道自己在做什么并且确认过调用点的OSIntNesting和OSLockNesting状态。否则系统行为会变得难以预测。3. 上下文切换的真实开销从 OSCtxSw 到 OS_TASK_SW3.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 上异常进入时硬件会自动把xPSR、PC、LR、R12、R3、R2、R1、R0这八个寄存器压到当前使用的栈上异常返回时再由硬件自动弹出。这套机制是 Cortex-M 架构设计的一部分效率比软件压栈高——8 个字压栈只需要 10 个周期左右带写缓冲软件写 8 条STM指令也未必更快而且会破坏寄存器状态。如果不用异常机制你就得自己保存这 8 个寄存器还要自己处理PC跳转和状态标志恢复代码复杂度和出错概率都上一个数量级。用 PendSV 的另一个好处是它可以被延迟——PendSV 的优先级可以被设成最低通常设成0xFF这样在其他中断还在处理时它会等到所有高优先级中断都退出了才执行。这正是“在中断里发现的调度需求拖到中断退出后再统一处理”这个机制的物理基础。3.2 任务栈帧的逐字段结构Cortex-M3 上一个被切换出去的任务它的栈顶往下依次放着这些内容偏移从栈顶起内容由谁压入字节数0 ~ 28R4 ~ R11软件STM R0, {R4-R11}3232R0硬件自动436R1硬件自动440R2硬件自动444R3硬件自动448R12硬件自动452LR硬件自动456PC异常返回地址硬件自动460xPSR硬件自动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 与 OSIntExit4.1 为什么中断服务函数里不能直接调用 OSCtxSw先讲清楚两条路径的区别。任务主动放弃 CPUOSTimeDly()、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.h、os_cpu_c.c、os_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()让出 CPUOSCtxSwCtr应该稳定增长且增量大致等于时间片的倒数。这个手段能证明“调度器在跑”但证明不了“栈切换正确”。第二个是硬件手段在两个任务里各翻转一个 GPIO用逻辑分析仪同时抓两路波形。正常情况应该看到两路方波交替出现占空比由任务的运行时长决定。这一步能肉眼验证“两个任务真的在交替执行”而且能看出来切换的边界是不是干净——如果某一路的高电平时间比预期长很多说明有任务被卡住了。还做了第三个补充测试在两个任务里各自累加一个全局变量并定期校验跑满 24 小时看有没有数值异常。这个测试专门针对栈溢出——如果某个任务的栈溢出踩到了另一个任务的变量校验就会失败。跑了三天没有异常才敢说这个移植是可信的。5.3 切换耗时的实测数据用 GPIO 在 PendSV 进入时拉高、在BX LR之前拉低抓到的脉冲宽度就是切换本身的耗时。在 GD32F103 跑 108MHz 的情况下实测这个宽度稳定在 0.8~1.2 微秒之间换算成周期数是 85~130 个。这个数字里包含了异常进入的硬件压栈、STM保存 R4~R11、若干次LDR/STR、OSTaskSwHook()的空函数调用开销、LDM恢复、异常返回的硬件弹栈。其中OSTaskSwHook()占了不小一块如果把它改成空宏而不是空函数还能再省十几个周期。对比一下完整的一次任务切换从OS_Sched()进入临界区算起到新任务开始执行大概在 1.5~2 微秒。这个量级意味着每秒钟最多能做几十万次切换。对于绝大多数嵌入式应用这个开销完全可以接受但如果你的任务切换频率真到了每秒几万次那就得重新审视任务划分了——多半是设计问题而不是内核性能问题。6. 常见问题与排查实录6.1 问题速查表现象最可能的原因排查手段只有第一个任务在跑不切换OSLockNesting没减回去或OS_Sched()没被触发断点看OSLockNesting和OSCtxSwCtr切换后进 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 行会突然觉得很多之前看不懂的注释都能读懂了。