调度器只负责选人,换人全靠汇编——这是 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-1370):
voidOSStartHighRdy(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,末尾调 OSIntExit(os_core.c:635-691):
voidOSIntEnter(void){if(OSRunning==OS_TRUE){if(OSIntNesting<255u){OSIntNesting++;/* Increment ISR nesting level */}}}voidOSIntExit(void){if(OSRunning==OS_TRUE){OS_ENTER_CRITICAL();if(OSIntNesting>0u){/* Prevent OSIntNesting from wrapping */OSIntNesting--;}if(OSIntNesting==0u){/* Reschedule only if all ISRs complete */if(OSLockNesting==0u){/* ... and not locked. */OS_SchedNew();OSTCBHighRdy=OSTCBPrioTbl[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,它会调用 OSTimeTick(os_core.c:889-958):
voidOSTimeTick(void){...OSTimeTickHook();/* 应用钩子 */OSTime++;/* 32 位节拍计数器 */if(OSRunning==OS_TRUE){ptcb=OSTCBList;/* 从链表头开始遍历所有任务 */while(ptcb->OSTCBPrio!=OS_TASK_IDLE_PRIO){if(ptcb->OSTCBDly!=0u){/* 有延时或等待超时? */ptcb->OSTCBDly--;if(ptcb->OSTCBDly==0u){/* 到期! */if((ptcb->OSTCBStat&OS_STAT_PEND_ANY)!=OS_STAT_RDY){ptcb->OSTCBStat&=~OS_STAT_PEND_ANY;/* 清等待状态 */ptcb->OSTCBStatPend=OS_STAT_PEND_TO;/* 标记超时 */}else{ptcb->OSTCBStatPend=OS_STAT_PEND_OK;}if((ptcb->OSTCBStat&OS_STAT_SUSPEND)==OS_STAT_RDY){OSRdyGrp|=ptcb->OSTCBBitY;/* 未挂起 → 置位就绪 */OSRdyTbl[ptcb->OSTCBY]|=ptcb->OSTCBBitX;}}}ptcb=ptcb->OSTCBNext;}}}这段代码做了四件事:
- 调用钩子函数
- 全局节拍计数器 OSTime 加一
- 遍历 TCB 链表,把每个任务的 OSTCBDly 递减
- 到期的任务:清等待状态、置位就绪表
注意第 4 步的操作——OSRdyGrp、OSRdyTbl、OSTCBBitY/X,这正是第 3 篇讲过的位图就绪表。节拍中断把任务「叫醒」的方式,就是往就绪表里置位。
还有一个容易混淆的点:OSTimeTick 本身不直接切换任务。它只负责叫醒任务和更新计数器,真正的切换发生在节拍 ISR 末尾调用 OSIntExit 时。
最后提醒一条 ISR 使用纪律:中断服务程序里不能调用会阻塞的 API(OSSemPend、OSTimeDly 这类"等待"函数),只能调用"通知类"(OSSemPost、OSFlagPost 等)——等待会让任务挂起,而 ISR 里没有任务上下文,挂起等于系统卡死。uC/OS-II 源码注释里反复强调这个约定。
节拍频率(OS_TICKS_PER_SEC)怎么定?100Hz 意味着时间分辨率 10ms,OSTimeDly(1) 就是最短 10ms 的延时。频率越高时间越准,但代价是每 tick 都要遍历一遍全部 TCB 链表——这是实时系统里经典的「精度 vs 开销」取舍,uC/OS-II 把选择权留给开发者。
至此,完整的切换链路已经清晰:
- 任务级:任务让出 → OS_Sched 选人 → OS_TASK_SW → OSCtxSw 保存/恢复
- 中断级:中断到来 → OSIntEnter → 服务处理 → OSIntExit 选人 → OSIntCtxSw 恢复
- 节拍级:SysTick 中断 → OSTimeTick 叫醒任务 →(回到中断级流程)→ 可能的切换
每一级切换发生时,内核还会调用任务切换钩子 OSTaskSwHook(OS_TASK_SW_HOOK_EN 控制,移植层提供)。典型用途:记录切换次数、统计任务 CPU 占用、保存 FPU 寄存器。应用想在切换点干点什么,也是在移植钩子里挂自己的应用钩子——第 1 篇讲过的两层钩子体系在这里收口。
写在最后
回顾整条链路,最核心的洞察只有一句话:C 负责决策,汇编负责执行。
OS_Sched 和 OSIntExit 再忙,也只是在「选人」;真正让任务无缝衔接的,是那几段压栈、弹栈的汇编代码。而栈,就是任务之间传递「现场」的唯一信物。
这也是为什么理解 RTOS 不能只看 C 代码——你至少要知道声明背后的汇编长什么样,哪怕不去写它。
理解这条链路还有个实用价值:以后排查「任务莫名其妙卡死」时,脑子里有这张图——先看切换器有没有被调用,再看 OSIntExit 是不是被漏掉(OSIntEnter/OSIntExit 必须成对,漏一次嵌套计数就错一次)。
下一篇,我们会顺着 OSTimeTick 的线索,深入时间与延时链表:OSTCBDly 是怎么被管理的?软件定时器为什么能工作?到时见。
你在看移植层的汇编时,有没有被哪个切换细节卡住过?欢迎留言聊聊。
觉得有用就点个关注,后面 6 篇继续更新。