尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

uC/OS-II内核调度器精读:从就绪表位图到O(1)任务切换

uC/OS-II内核调度器精读:从就绪表位图到O(1)任务切换 这是 uC/OS-II 内核源码精读系列的第 3 篇。前两篇我们聊了任务控制块TCB的来龙去脉和系统启动的整体流程今天这一篇直接进入整个内核最核心、也最值得反复看的模块调度器。任何 RTOS说白了都是在回答一个问题“CPU 这一时刻到底该去执行哪个任务”uC/OS-II 用一张组织得极其精妙的就绪表Ready Table加上几条查表逻辑就把这个问题变成了确定性的 O(1) 查找整个调度相关代码加起来不过几百行。说实话我第一次读完这部分代码时最大的感受不是“它能跑”而是“它怎么能用这么少的代码把调度做得这么规矩”。后来在实际项目中移植、裁剪、定位问题越用越觉得这 6736 行里藏着的不是某个高深算法而是一整套工程取舍。这篇我把调度器拆开讲从数据结构到汇编切换现场尽量把我这几年的理解一次说透。无论你是正在学内核源码的学生、准备 RTOS 面试的开发者还是想自己写一个微型调度器这篇都能给你一条比较完整的参考路径。1. 调度的本质多任务竞争下的资源分配1.1 一个厨师与一堆订单把单核 CPU 想成一个厨师任务就是排队等做菜的订单。厨师一次只能炒一个菜但他不可能按“先来后到”傻做因为有的订单是 VIP 急单有的可以慢悠悠等着。RTOS 调度器干的事情就是决定下一道菜到底炒哪一单而且这个决定必须在极短时间内完成不能耽误炒菜本身。uC/OS-II 的选择很明确完全基于优先级的抢占式调度。每个任务有一个优先级数字越小优先级越高。调度器永远从所有“就绪”的任务中挑出优先级最高的那个来执行。这里的“就绪”是内核里的一个状态位表示这个任务没有被挂起、没有在延时、也没有在等某个内核对象只要 CPU 给它它就能立刻跑。关键点在于“挑出优先级最高的任务”这一步。如果每次切换都要扫一遍所有任务任务多了性能就崩。uC/OS-II 的做法是引入一张位图形式的就绪表用位运算把查找时间压到常数级。这个设计决策放在 90 年代的单片机上非常合理没有动态内存分配没有复杂的链表结构一张 8×8 的位图加上一张 256 字节的查找表就把调度核心问题解决了。1.2 抢占式优先级调度的设计约束抢占式调度跟协作式最大的区别在于任务不需要主动让出 CPU。只要更高优先级的任务进入就绪态当前任务就要被立刻打断现场保存好等高优先级任务跑完再恢复。这个机制带来的好处是实时性有保障坏处是任务间的共享数据很容易出现竞争所以 uC/OS-II 里到处都是 OS_ENTER_CRITICAL 和 OS_EXIT_CRITICAL也就是关中断/开中断保护临界区。uC/OS-II 的任务优先级是静态的创建任务时定死运行中一般不修改。这样的设计让内核非常精简因为不需要处理动态优先级带来的复杂调度语义。很多从 Linux 转过来的人会觉得不习惯但嵌入式实时系统的哲学本来就不是“公平”而是“确定性”。我要知道我的关键任务最坏情况下多久能被调度到这比让所有任务都分到一点 CPU 更重要。另外还要注意uC/OS-II 经典版本是不做时间片轮转的也就是说两个相同优先级的任务如果不主动挂起或延时其中一个可能会一直占着 CPU。这一点在 2.91 版本之后才加入时间片轮转支持。后面我会专门展开讲这个问题这也是很多工程师从裸机转 RTOS 时最容易掉进去的坑。1.3 为什么位图是当时的最优解你可能想问为什么不用链表每个就绪任务加到一个链表节点里要查最高优先级就遍历一遍不也简单吗简单是简单但复杂度是 O(n)任务一多每次调度都要做可变时长的遍历实时系统的“确定性”就没了。我做过一个项目最多 20 多个任务如果用链表扫描调度时间最坏和最好能差出好几倍这在控制周期敏感的场合很难接受。位图的思路是把“哪个优先级就绪”这个信息压缩到几个字节里然后用一条查表指令就能找到最高优先级。uC/OS-II 最多支持 64 个优先级底层用两个维度表示OSRdyGrp 一个字节OSRdyTbl[8] 共 8 个字节。换句话说用一个 8×8 的矩阵每个格子对应一个优先级。这个方案的本质是用少量内存空间换确定性的执行时间在当年 RAM 以 KB 计的单片机上这 9 个字节的开销几乎可以忽略。2. 就绪表的几何学8×8 位图这么排布2.1 OSRdyGrp 与 OSRdyTbl 的楼层-房间模型理解就绪表我建议你脑子里建立一个“楼层-房间”的模型。把优先级从 0 到 63 看成一栋 8 层的楼每层 8 个房间。楼层编号 y 从 0 到 7房间编号 x 从 0 到 7那么优先级 prio 就等于y * 8 x。OSRdyTbl[y] 这个字节表示第 y 层的入住情况bit x 为 1说明第 y 层第 x 号房间有人住也就是优先级y * 8 x的任务处于就绪态。OSRdyGrp 则是一个“楼层指示灯”bit y 为 1说明第 y 层至少有一个房间有人住。变量作用类比OSRdyGrp8 个 bit标记哪些楼层有就绪任务一楼大厅的楼层指示灯OSRdyTbl[8]每层 8 个 bit标记具体哪个房间就绪每层的房间入住牌OSMapTbl把 0~7 的索引值转成对应的 bit 位门牌号编码映射表OSUnMapTbl从一字节中找出第一个为 1 的 bit 位置楼层引导员的查询手册这个模型最妙的地方在于不需要遍历 64 个任务只需要先看 OSRdyGrp 哪一位为 1确定最高优先级在哪个楼层再查那层的 OSRdyTbl就能精确定位到具体房间。2.2 优先级数字的拆分规则从优先级到楼层和房间就是简单的位运算。高 3 位是楼层号低 3 位是房间号y prio 3; /* 楼层0~7 */ x prio 0x07; /* 房间0~7 */注意 uC/OS-II 里还有两个对应的位掩码变量创建任务时会算好存到 TCB 里ptcb-OSTCBY prio 3; ptcb-OSTCBX prio 0x07; ptcb-OSTCBBitY OSMapTbl[ptcb-OSTCBY]; ptcb-OSTCBBitX OSMapTbl[ptcb-OSTCBX];OSMapTbl 是内核自己的一张常量表内容很简单OSMapTbl[8] {0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80};也就是把楼层号 0 映射到 bit0楼层号 1 映射到 bit1以此类推。为什么不直接用1 y主要原因是 8 位单片机上移位指令并不一定比查表快而且查表在 C 语言层面语义更清晰照顾了老式编译器的优化能力。你现在看这段代码可能觉得多余但在当年这是很务实的写法。2.3 OSUnMapTbl256 字节换 O(1) 查找有了就绪表下一步要回答的问题就是OSRdyGrp 这个字节里可能有多个 bit 为 1到底哪个楼层优先级最高uC/OS-II 没有用循环扫描而是直接查一张预先生成的 256 字节表 OSUnMapTbl。这张表做的事情很简单给定一个 0~255 的值返回该值中最低位的 1 所在的位置。比如输入二进制返回0x010000000100x040000010020x801000000070x82100000101因为优先级数字越小优先级越高所以找“最低位的 1”就是找“最高优先级”。OSUnMapTbl 的空间开销是 256 字节换来的是每次查找固定几条指令的确定性时间。对于 RAM 通常只有 2KB~64KB 的 MCU 来说这笔交易很划算。新版本的代码里如果编译器支持 CLZCount Leading Zeros指令有人会改成硬件指令来做效果一样但经典查表法最大的优势是纯 C 实现没有任何架构依赖移植到任意 MCU 都能跑。查找最高优先级就绪任务的代码可以说是整个内核的精华y OSUnMapTbl[OSRdyGrp]; OSPrioHighRdy (y 3) OSUnMapTbl[OSRdyTbl[y]];两步查到结果没有任何循环。第一次查表确定楼层第二次查表确定房间组合起来就是完整优先级。很多 RTOS 面试题就是问这段代码的意图你要能讲清楚 OSRdyGrp 和 OSRdyTbl 各自承担什么角色基本就过关了。3. 代码级走读就绪、切换、启动3.1 OS_TCBInit 里的“登记”操作任务创建时内核在 OS_TCBInit 里除了初始化栈指针、状态字、延时计数器之外最重要的一步就是把任务“登记”到就绪表里。一个任务创建之后默认就是就绪的代码大致是这样OSRdyGrp | ptcb-OSTCBBitY; OSRdyTbl[ptcb-OSTCBY] | ptcb-OSTCBBitX;这两行做的事就是点亮“楼层指示灯”和“房间入住牌”。比如创建一个优先级为 23 的任务23 拆成 y2、x7于是 OSRdyGrp 的 bit2 被置 1OSRdyTbl[2] 的 bit7 被置 1。从代码里可以学到一个细节内核不是每次都现场计算prio 3和1 x而是在 TCB 初始化时就已经把 OSTCBBitY、OSTCBBitX 等算好存下来了。这样调度路径上每次都少做几次移位操作。别小看这几条指令在上下文切换频率很高的时候省下来的周期都是实打实的。3.2 任务删除/挂起时的“销户”操作有登记就要有销户。任务被删除或者挂起时要把对应的位从就绪表里清掉。清除逻辑比置位多一步要注意处理“楼层指示灯”if ((OSRdyTbl[ptcb-OSTCBY] ~ptcb-OSTCBBitX) 0) { OSRdyGrp ~ptcb-OSTCBBitY; }只有当前楼层所有房间都空了才把对应的楼层指示灯关掉。这个判断非常关键漏掉它的话OSRdyGrp 会出现“假亮”调度器会以为某个楼层有任务就绪结果查过去发现 OSRdyTbl[y] 是 0。我在一次项目里见过有人仿照内核自己写任务管理模块时漏了这行判断导致系统随机出现一次“空调度”那个 bug 排查了两天才定位到。所以读源码别只看热闹这种细节才是真正决定系统稳不稳定的地方。3.3 OS_Sched 与 OS_TASK_SW调度的真正入口uC/OS-II 的调度入口是 OS_Sched很多地方也叫任务级调度器因为它只能在任务上下文里调用不能在中断服务程序里调用。核心代码不长void OS_Sched(void) { OS_ENTER_CRITICAL(); if (OSIntNesting 0) { if (OSLockNesting 0) { OS_SchedNew(); if (OSTCBHighRdy ! OSTCBCur) { OSTCBHighRdy-OSTCBStat OS_STAT_RDY; OSCtxSwCtr; OS_TASK_SW(); } } } OS_EXIT_CRITICAL(); }OSIntNesting 是中断嵌套计数器如果当前是在中断里就跳过任务调度因为中断退出后 OSIntExit 会再调度一次。OSLockNesting 是调度锁计数器大于 0 时说明应用程序调用了 OSSchedLock暂时不允许任务切换。OS_SchedNew 做的就是前面说的查表操作把当前最高优先级就绪任务算出来放到 OSPrioHighRdy。然后是 OS_TASK_SW 宏。这个宏在常规移植里会被定义成触发一次软件中断或者直接调用汇编函数 OSCtxSw。下面这段是典型的上下文切换动作以伪代码表示OSCtxSw: 保存当前任务的所有寄存器到当前任务栈 更新 OSTCBCur-OSTCBStkPtr 设置 OSTCBCur OSTCBHighRdy 从新的 OSTCBHighRdy-OSTCBStkPtr 恢复寄存器 返回并继续执行新任务OS_TASK_SW 之后当前函数不会返回到原来的地方而是直接跳到了新任务的执行流里。所以 OS_Sched 末尾的 OS_EXIT_CRITICAL 实际上是新任务在恢复现场后执行的。理解这一点很关键很多人看完 OS_Sched 的 C 代码都会困惑为什么切走了还能回来答案就是每个任务都有自己的栈和现场调度器只是把现场的“快照”做了交换。这里分享一个调试经验如果你想观察任务切换的完整链路在 Keil 里给 OS_TASK_SW 下断点然后看调用栈和 OSTCBCur 的变化比在好几个任务里来回打断点要直观得多。配合汇编单步走一遍 OSCtxSw你对“栈指针切换”这个抽象概念会突然变得非常具体。3.4 OSStart 如何启动第一个任务OSStart 是系统的点火开关它没有去“切换”任务而是直接“接管”到第一个任务void OSStart(void) { if (OSRunning OS_FALSE) { OS_SchedNew(); OSPrioCur OSPrioHighRdy; OSTCBHighRdy OSTCBPrioTbl[OSPrioHighRdy]; OSTCBCur OSTCBHighRdy; OSStartHighRdy(); } }OSStartHighRdy 是汇编实现的它不会返回而是把 OSTCBHighRdy 指向的任务栈现场恢复出来然后跳过去执行。之所以不需要“保存当前任务现场”是因为此刻还没有当前任务系统还处于裸机世界里第一个任务的现场是假的、是我们提前构造好的。构造现场的动作发生在 OSTaskCreate 里创建任务时内核会往任务栈里压入一组初始寄存器值模拟出“这个任务刚被中断打断”的样子。任务第一次运行时弹出来的“返回地址”就是任务入口函数。所以任务函数永远不需要返回如果真返回了就会跑到一个异常处理里通常就是死循环或者触发硬件错误这个行为在 uC/OS-II 里有专门处理。3.5 上下文切换现场处理上下文切换是所有 RTOS 里最贴近硬件的地方也是移植工作的核心。不同 CPU 的寄存器不一样栈增长方向不一样所以这段代码基本都是汇编写在 os_cpu_a.asm 里。我建议你不要被具体指令吓到抓住三个核心动作把当前 CPU 寄存器全部压入当前任务栈把当前任务栈指针保存到 OSTCBCur-OSTCBStkPtr从新任务 TCB 取出栈指针恢复寄存器返回。在 ARM Cortex-M 上常见做法是借助 PendSV 异常来做切换。原因是 PendSV 可以设置为最低优先级不会被其他中断打断切换动作有很好的确定性。任务切换的核心流程不复杂但能真正手写一遍并跑起来的才算把 RTOS 吃透了一半。这里有个很容易忽略的小细节任务栈里的寄存器顺序必须跟 CPU 的压栈规则一致。你如果在移植时把某个寄存器的顺序写错任务跑起来可能第一轮就崩溃。我的习惯是先在任务入口函数第一行放个断点能停进去说明现场恢复基本正常再单步走几步确认栈帧没有错位。4. 中断、时钟和调度的第二现场4.1 OSIntExit 里发生什么中断退出调用的调度是 OSIntExit它跟 OS_Sched 长得很像但有本质区别。区别在于进入中断时 CPU 硬件已经自动保存了一部分现场所以中断退出时不需要再完整执行一次任务切换的“保存现场”动作而是可以从“恢复现场”这一步开始。uC/OS-II 专门提供了一个 OSIntCtxSw 来处理这种情况避免重复保存现场导致栈帧错乱。void OSIntExit(void) { OS_ENTER_CRITICAL(); if (OSRunning OS_TRUE) { if (OSIntNesting 0) { OSIntNesting--; } if (OSIntNesting 0) { if (OSLockNesting 0) { OS_SchedNew(); if (OSPrioHighRdy ! OSPrioCur) { OSTCBHighRdy OSTCBPrioTbl[OSPrioHighRdy]; OSCtxSwCtr; OSIntCtxSw(); } } } } OS_EXIT_CRITICAL(); }注意 OSIntExit 里的比较对象是 OSPrioHighRdy 和 OSPrioCur而不是 OSTCBHighRdy 和 OSTCBCur因为中断环境里当前任务指针已经保存过了直接比较优先级就够了。这个细节也能解释为什么在中断里调 OS_Sched 会出问题OSIntNesting 不为 0OS_Sched 里直接跳过调度判断等于白调。4.2 时钟节拍与延时驱动的轮转提到调度就离不开时钟节拍。uC/OS-II 的 OS_TICKS_PER_SEC 决定了系统节拍频率OSTimeTick 在每个节拍中断里减掉所有任务的延时计数延时到期的任务会被重新置成就绪态就有可能触发一次调度。整个系统的时间观念全部建立在这个“心跳”上。经典 uC/OS-II 若不做特殊处理两个同优先级任务之间不会自动轮转必须靠任务自己调用延时或者挂起。很多人写应用时习惯把所有任务都设置成同一优先级结果发现只有一个任务在跑其他任务完全饿死。我建议使用 2.91 以上版本或者自己实现一个简单的调度 hook在 OSTimeTick 里累计每个任务运行的时间片到点后把当前任务放到同优先级就绪队列尾部再触发调度。这里的关键是不能破坏就绪表的位图结构需要额外维护同优先级任务的轮转链表。4.3 临界区与调度锁的正确用法OS_ENTER_CRITICAL 的本质是关中断它能保护的是几条指令的原子性。OSLockNesting 则是更柔和的手段不关中断但禁止调度中断照常响应。两者使用场景完全不同。临界区里不能做耗时操作因为整个系统都被暂停调度锁可以适当长一点但也要控制时间否则高优先级任务的中断能响应但任务始终得不到执行等效于实时性失效。我在实际项目中见过最典型的错误是把 OS_ENTER_CRITICAL 用在包含延时、打印、甚至等待信号量的代码外面导致系统偶发性卡死。正确的做法是临界区只保护共享变量的赋值和读取复杂逻辑尽量通过消息队列或信号量交出去处理。5. 实战踩坑与问题排查实录5.1 高优先级任务不执行有一次我在 STM32 上调一块采集板某个高优先级任务迟迟不运行低优先级任务倒是正常。断点打在目标任务入口根本不命中。我首先怀疑优先级配错查了 OSTaskCreate 的参数没问题又怀疑延时太长看了 OSTCBDly也没问题最后把内存窗口打开直接观察 OSRdyGrp 和 OSRdyTbl才发现 OSRdyGrp 的值跟预期不符某个驱动模块在业务代码里直接对 OSRdyGrp 做了赋值把其他任务的就绪位全冲掉了。这是应用工程师最容易犯的错误内核的全局数据结构不是普通全局变量绝对不能绕过 API 去乱改。排查这类问题最快的办法就是实时查看 OSRdyGrp 是否与当前任务状态一致。只要发现异常位顺着写这个位的地方往回找基本都能快速定位。5.2 在 ISR 里直接调 OSSched 导致崩溃很多人刚开始写中断里的延时等待时会想当然地“在中断里主动让出 CPU”于是写了 OSSched()。结果一进中断就死机。为什么因为 OSSched 只能在任务上下文里用中断上下文里调度逻辑会跳过OSIntCtxSw 没有被正确触发。正确的方式是在中断里只做置标志、发信号量、发消息这些轻量操作具体切换由 OSIntExit 在中断返回时自动完成。uC/OS-II 的这套机制已经替你安排好了不需要画蛇添足。5.3 低优先级任务长期得不到执行如果你发现某个低优先级任务偶尔跑一下、经常被饿死不要急着怪调度器。先检查是不是有同优先级任务互相卡住再看是不是有关中断过长的临界区。第一个问题用“高优先级任务让出 CPU”的思路解决第二个问题用临界区裁短解决如果是资源竞争导致的优先级反转建议把二值信号量换成互斥信号量因为 uC/OS-II 的互斥量内部做了优先级继承它会在等待期间临时提升持有者优先级本质上是动态修改了调度器的比较依据。我在实际项目里遇到过一种隐蔽情况两个任务共享一个串口低优先级任务持有了互斥量后突然被抢占高优先级任务等待互斥量结果反而把低优先级任务的事拖长了。后来靠互斥量的优先级继承才解决问题。所以要记住RTOS 的调度器本身没有好坏关键是你会不会用好配套的同步机制。5.4 常见问题速查表现象可能原因排查手段高优先级任务不运行OSRdyGrp 被业务代码误改内存窗口实时观察就绪表任务进入死循环任务栈溢出破坏了 TCB检查 OSTCBCur 附近内存填充值进中断后系统崩溃在 ISR 里调了 OSSched改为置标志位或发信号量低优先级任务饿死同优先级无时间片轮转升级版本或自行实现时间片偶发调度异常临界区内耗时过长裁剪临界区范围移植后第一次切换失败栈帧寄存器顺序不对在任务入口第一行下断点6. 从 6736 行学到什么给读者的延伸建议6.1 和 FreeRTOS、RT-Thread 调度思想对比读 uC/OS-II 调度器最大的收获是可以掌握一种“用最简单数据结构解决核心问题”的思路。FreeRTOS 的任务调度用了类似思路但为了支持更多特性软件定时器、事件组、流缓冲它的内核结构要复杂不少。RT-Thread 在调度核心上也保留了位图查表的思路同时引入了对象模型和动态内存管理。维度uC/OS-IIFreeRTOSRT-Thread调度查找位图 查表 O(1)位图 循环/CLZ位图 查表优先级数量最多 64 级可配置典型 32 级256 级时间片轮转经典版不支持原生支持原生支持动态内存不支持静态支持支持代码量极小中等中等偏大如果你只是想快速跑一个 demoFreeRTOS 可能更省事但如果你想理解一个 RTOS 的骨架如何搭建uC/OS-II 的源码干净而且短非常适合精读。读完它再看别的 RTOS你会发现很多概念都是相通的。6.2 动手实验自己写一个 20 行的核心调度器看代码和写代码是两回事。我自己学内核源码时会动手做一个最小可运行版本把调度核心单独抽出来不用下载到板子在 PC 上也能跑。把找最高优先级的部分简化成这样#define TASK_NUM 64 static uint8_t rdy_grp; static uint8_t rdy_tbl[8]; static const uint8_t unmap[256] { /* 这里根据“返回最低位 1 的位置”规则生成 */ }; void task_ready(uint8_t prio) { uint8_t y prio 3; uint8_t x prio 0x07; rdy_grp | (uint8_t)(1u y); rdy_tbl[y] | (uint8_t)(1u x); } void task_unready(uint8_t prio) { uint8_t y prio 3; uint8_t x prio 0x07; rdy_tbl[y] (uint8_t)~(1u x); if (rdy_tbl[y] 0) { rdy_grp (uint8_t)~(1u y); } } uint8_t get_highest_prio(void) { uint8_t y unmap[rdy_grp]; uint8_t x unmap[rdy_tbl[y]]; return (uint8_t)((y 3) | x); }把这个逻辑跑通之后再看 uC/OS-II 的源码就完全不一样了因为你已经知道每一步在干什么。接着可以往里面加 OSIntNesting、OSLockNesting、空闲任务这些机制一步步把整个 RTOS 的骨架复现出来。这种“从零搭建”的感觉比单纯翻源码要扎实太多。6.3 后续怎么继续深入读调度器只是第一步后面还有信号量、消息邮箱、互斥信号量、内存管理这些内核对象。你会发现它们全都围绕着一个核心概念让任务在等待某个条件时把自己从就绪表摘掉条件满足时再把自己加回来。理解了这些之后你就能回答一个经典问题RTOS 里的延时是怎么做到“不占 CPU”的答案就是任务主动从就绪表摘掉自己直到节拍中断把它重新放回来。这个机制的内核就是在就绪表上做“登记”和“销户”的位操作。最后再分享一个我自己读源码的习惯拿到一份内核源码先用脚本统计一下每个文件的函数数量和关键数据结构画一张简单的调用关系图然后挑一条主线读比如“任务创建 - 启动 - 调度 - 延时 - 切换”这一条线走完整个系统的主干就清楚了。uC/OS-II 的 6736 行代码并不多真正核心的调度逻辑撑死三百行把这些真正吃透你会发现自己看其他 RTOS 的内核源码时思路会清晰很多。
返回列表