【系列:uC/OS-II 内核源码精读:从 6736 行代码看懂一个 RTOS · 第 4 篇】 📅 2026/8/23 8:02:51 调度器只负责选人换人全靠汇编——这是 uC/OS-II 上下文切换的核心分工。本文从 OS_TASK_SW() 出发拆解 OSCtxSw / OSIntCtxSw / OSStartHighRdy 的原理讲透中断级调度与节拍处理帮你打通从「选择任务」到「切换任务」的完整链路。上篇结尾OS_Sched 的最后一行调用了 OS_TASK_SW()。C 代码的世界到这里戛然而止——下一步要交给谁答案是汇编。严格说OS_TASK_SW 宏并不在本仓库。它定义在宿主工程的 os_cpu.h 里惯例要么是#define OS_TASK_SW() OSCtxSw()要么直接用一条软中断指令。但无论哪种写法跳进去的都是同一个东西任务切换器。分工契约C 只选人汇编才换人先看三条声明的真面目ucos_ii.h:1368-1370voidOSStartHighRdy(void);/* 启动多任务跳到最高优先级任务 */voidOSIntCtxSw(void);/* 中断级切换ISR 末尾调用 */voidOSCtxSw(void);/* 任务级切换OS_Sched 触发 */注意一个关键事实本仓库只有这 3 条声明没有实现。实现全在宿主工程的 os_cpu_a.asm 等汇编文件里。这是 uC/OS-II 的经典架构——内核与 CPU 移植层分离。也就是说OS_Sched 用 C 做完了三件事检查锁、选最高优先级任务、给 OSTCBHighRdy 赋值。剩下的「换人」它管不着也没能力管。为什么 C 管不了因为**上下文切换Context Switch**的本质是操作 CPU 寄存器而寄存器的存取必须用汇编。这里的「现场」指任务被打断那一刻 CPU 寄存器的完整快照通用寄存器、状态寄存器、返回地址。谁保存了现场谁就能在下次恢复时让任务「无缝续跑」。现场任务的命脉是那根栈指针每个任务的核心私有资源只有一个栈。TCB 里的 OSTCBStkPtr 指向栈顶它是一切切换的命脉。切换的通用流程只有两步保存把当前 CPU 寄存器按序压入当前任务的栈 → 更新 OSTCBStkPtr 新栈顶恢复OSTCBStkPtr 指向新任务栈 → 按序弹出寄存器 → 最后弹出 PC → 新任务「接着上次继续跑」栈指针的更新时机很关键必须完整压栈之后才更新 OSTCBStkPtr。如果压栈中途被更高优先级中断打断恢复时栈指针就对不上现场了——所以切换器对 OSTCBStkPtr 的读写必须发生在关中断保护下OS_Sched 全程在临界区内正是这个原因。这里要回扣第 2 篇的初始栈帧那其实就是一次「提前摆好的恢复现场」。系统启动时每个任务的栈里已经按固定顺序填好了寄存器初始值OSStartHighRdy 只需要做一次恢复就能让任务从入口函数开始执行。换句话说启动第一个任务 假装它之前被切换走了一次。多任务启动的瞬间也靠汇编。OSStart第 1 篇见过选出最高优先级任务后调用 OSStartHighRdy——它做的是「纯恢复」没有当前任务需要保存还不存在直接把最高优先级任务的初始栈帧弹出来任务入口函数开始执行。从此就绪表、调度器、切换器全部上线系统进入多任务世界。OSCtxSw任务主动让出时的完整交接任务级切换的触发场景很典型任务 A 调用了延时或等待事件主动让出 CPU。此时任务 A 处于「活生生」的状态寄存器里全是有效数据必须完整保存现场。ARM Cortex-M 的汇编示意如下移植层实现非本仓库代码OSCtxSw: ; 1) 把当前任务的寄存器压栈R4-R11, LR 等 ; 2) 更新 OSTCBCur-OSTCBStkPtr 新栈顶 ; 3) OSTCBCur OSTCBHighRdy ; 4) SP OSTCBHighRdy-OSTCBStkPtr ; 5) 从新栈弹出寄存器 ; 6) 弹 PC → 新任务继续执行其中第 1 步是「保存现场」第 5、6 步是「恢复现场」中间 2、3、4 步完成 TCB 的交接。注意第 3 步OSTCBCur OSTCBHighRdy。这一步让「当前任务」的指针指向新任务从此内核视角里任务 B 就是当前任务了。C 代码里看不到这些因为 OS_Sched 只负责赋值 OSTCBHighRdy然后调 OS_TASK_SW()剩下的脏活累活全在汇编。关于「为什么压栈要按序」寄存器恢复必须严格逆序——后压的先弹。汇编里通常用 STMDB/LDMIA 这类多寄存器指令一次完成或者一条条 PUSH/POP。顺序本身无所谓只要保存和恢复对称就行。值得一提ARM 上 OS_TASK_SW 的常见实现是 SVC软中断指令——OS_Sched 里执行 SVC处理器陷入异常入口在异常处理里做完整压栈。好处是进入切换器时异常框架已经按硬件规则排好与真正的中断路径完全一致移植层不用为「任务级」和「中断级」各写一套压栈逻辑。OSIntCtxSw中断场景下为什么不用保存中断级切换是另一套逻辑。先想一个问题中断到来时任务 A 正在跑它的现场谁保存Cortex-M 的硬件会自动压栈——R0-R3、R12、LR、PC、xPSR 这 8 个寄存器被硬件压入当前栈xPSR 是程序状态寄存器保存条件标志和当前处理器模式。ISR 结束时硬件自动弹栈恢复。所以 OSIntCtxSw 不需要再次保存当前任务的现场。它要做的是从 OSTCBHighRdy 的栈顶恢复新任务的现场然后触发中断返回新任务直接从 PC 处继续执行。一句话对比OSCtxSw任务主动让出现场需要完整保存OSIntCtxSw硬件已保存被打断者的现场只需要恢复新任务在 Cortex-M 上这个差异会直接体现在代码量上OSCtxSw 要手动保存 R4-R11硬件只自动保存 R0-R3、R12、LR、PC、xPSR 这 8 个OSIntCtxSw 则完全不用保存——被打断者的全部现场已经在栈上了它只需要从最高优先级任务的栈顶开始弹栈然后执行异常返回指令。这个区别直接解释了为什么 ISR 末尾的调度要专门用一个独立的函数而不是复用 OSCtxSw。补充传统 ARM7 等架构没有自动压栈移植层需要手动补保存操作这也是 OSIntCtxSw 在移植层通常比 OSCtxSw 多几步「修正栈指针」的原因。OSIntEnter / OSIntExit中断的车轮战中断级调度依赖一对成对函数。ISR 开头调 OSIntEnter末尾调 OSIntExitos_core.c:635-691voidOSIntEnter(void){if(OSRunningOS_TRUE){if(OSIntNesting255u){OSIntNesting;/* Increment ISR nesting level */}}}voidOSIntExit(void){if(OSRunningOS_TRUE){OS_ENTER_CRITICAL();if(OSIntNesting0u){/* Prevent OSIntNesting from wrapping */OSIntNesting--;}if(OSIntNesting0u){/* Reschedule only if all ISRs complete */if(OSLockNesting0u){/* ... and not locked. */OS_SchedNew();OSTCBHighRdyOSTCBPrioTbl[OSPrioHighRdy];if(OSPrioHighRdy!OSPrioCur){OSCtxSwCtr;OSIntCtxSw();/* Perform interrupt level ctx switch */}}}OS_EXIT_CRITICAL();}}先解释术语ISR是 Interrupt Service Routine中断服务程序嵌套指中断处理过程中又来了更高优先级的中断形成中断套中断。OSIntNesting 就是嵌套计数器。它的逻辑很朴素嵌套值 0说明还有外层 ISR 没退出不调度——让外层的 OSIntExit 统一收尾嵌套值归 0说明所有 ISR 都退完了这时候才考虑切换OSIntEnter 开头先判断 OSRunning多任务启动之前来的中断不算数——系统还没开始调度嵌套计数没有意义。细心的读者会发现OSIntExit 里的调度逻辑和 OS_Sched 几乎一样唯一区别是最后调用了 OSIntCtxSw 而非 OS_TASK_SW。为什么不直接调 OS_Sched因为 OSIntExit 此时已经在临界区内三道门槛里的锁检查已经做完了它也已经有现成的 OSPrioHighRdy 结果。直接切不再走一遍流程。OSTimeTick心跳中断只负责叫醒最后一个关键角色是节拍处理。uC/OS-II 依赖一个周期性中断典型频率 100Hz即 OS_TICKS_PER_SEC 100来驱动时间相关功能这个中断的处理器就是节拍 ISR它会调用 OSTimeTickos_core.c:889-958voidOSTimeTick(void){...OSTimeTickHook();/* 应用钩子 */OSTime;/* 32 位节拍计数器 */if(OSRunningOS_TRUE){ptcbOSTCBList;/* 从链表头开始遍历所有任务 */while(ptcb-OSTCBPrio!OS_TASK_IDLE_PRIO){if(ptcb-OSTCBDly!0u){/* 有延时或等待超时 */ptcb-OSTCBDly--;if(ptcb-OSTCBDly0u){/* 到期 */if((ptcb-OSTCBStatOS_STAT_PEND_ANY)!OS_STAT_RDY){ptcb-OSTCBStat~OS_STAT_PEND_ANY;/* 清等待状态 */ptcb-OSTCBStatPendOS_STAT_PEND_TO;/* 标记超时 */}else{ptcb-OSTCBStatPendOS_STAT_PEND_OK;}if((ptcb-OSTCBStatOS_STAT_SUSPEND)OS_STAT_RDY){OSRdyGrp|ptcb-OSTCBBitY;/* 未挂起 → 置位就绪 */OSRdyTbl[ptcb-OSTCBY]|ptcb-OSTCBBitX;}}}ptcbptcb-OSTCBNext;}}}这段代码做了四件事调用钩子函数全局节拍计数器 OSTime 加一遍历 TCB 链表把每个任务的 OSTCBDly 递减到期的任务清等待状态、置位就绪表注意第 4 步的操作——OSRdyGrp、OSRdyTbl、OSTCBBitY/X这正是第 3 篇讲过的位图就绪表。节拍中断把任务「叫醒」的方式就是往就绪表里置位。还有一个容易混淆的点OSTimeTick 本身不直接切换任务。它只负责叫醒任务和更新计数器真正的切换发生在节拍 ISR 末尾调用 OSIntExit 时。最后提醒一条 ISR 使用纪律中断服务程序里不能调用会阻塞的 APIOSSemPend、OSTimeDly 这类等待函数只能调用通知类OSSemPost、OSFlagPost 等——等待会让任务挂起而 ISR 里没有任务上下文挂起等于系统卡死。uC/OS-II 源码注释里反复强调这个约定。节拍频率OS_TICKS_PER_SEC怎么定100Hz 意味着时间分辨率 10msOSTimeDly(1) 就是最短 10ms 的延时。频率越高时间越准但代价是每 tick 都要遍历一遍全部 TCB 链表——这是实时系统里经典的「精度 vs 开销」取舍uC/OS-II 把选择权留给开发者。至此完整的切换链路已经清晰任务级任务让出 → OS_Sched 选人 → OS_TASK_SW → OSCtxSw 保存/恢复中断级中断到来 → OSIntEnter → 服务处理 → OSIntExit 选人 → OSIntCtxSw 恢复节拍级SysTick 中断 → OSTimeTick 叫醒任务 →回到中断级流程→ 可能的切换每一级切换发生时内核还会调用任务切换钩子 OSTaskSwHookOS_TASK_SW_HOOK_EN 控制移植层提供。典型用途记录切换次数、统计任务 CPU 占用、保存 FPU 寄存器。应用想在切换点干点什么也是在移植钩子里挂自己的应用钩子——第 1 篇讲过的两层钩子体系在这里收口。写在最后回顾整条链路最核心的洞察只有一句话C 负责决策汇编负责执行。OS_Sched 和 OSIntExit 再忙也只是在「选人」真正让任务无缝衔接的是那几段压栈、弹栈的汇编代码。而栈就是任务之间传递「现场」的唯一信物。这也是为什么理解 RTOS 不能只看 C 代码——你至少要知道声明背后的汇编长什么样哪怕不去写它。理解这条链路还有个实用价值以后排查「任务莫名其妙卡死」时脑子里有这张图——先看切换器有没有被调用再看 OSIntExit 是不是被漏掉OSIntEnter/OSIntExit 必须成对漏一次嵌套计数就错一次。下一篇我们会顺着 OSTimeTick 的线索深入时间与延时链表OSTCBDly 是怎么被管理的软件定时器为什么能工作到时见。你在看移植层的汇编时有没有被哪个切换细节卡住过欢迎留言聊聊。觉得有用就点个关注后面 6 篇继续更新。