每次调度都要从 64 个任务里立刻找出优先级最高的那个,uC/OS-II 不靠循环比较,而是靠两张位图和一张 256 字节的表。本文拆解 OSRdyGrp 与 OSRdyTbl 的数据布局、OSUnMapTbl 查表原理、OS_Sched 的完整流程,并用优先级 29 和 OSRdyGrp=0x34 两个实例,把 O(1) 调度背后的关键路径一次推演明白。
如果让你在 64 个任务里找出优先级最高的那个,第一反应是什么?挨个比一遍,最坏情况要比较 64 次。
uC/OS-II 的回答很干脆:固定两次查表,一次移位。不管系统里是 8 个任务还是 63 个任务,耗时一模一样。
这不反直觉吗?答案是:它把"找最高优先级"这件大事,提前拆解成了一张位图登记表。接上篇 TCB 里的 OSTCBY/X 预计算,本篇正式进入 uC/OS-II 内核源码精读系列的调度核心------看它怎么用空间换时间,把实时系统的确定性锁死在 O(1)。
就绪表:一张"任务能否运行"的登记簿
所谓就绪表,就是内核用来登记"哪些任务已经就绪、可以运行"的数据结构。uC/OS-II 用了二级位图:一张组表加一张行表。
c
OS_EXT OS_PRIO OSRdyGrp; /* Ready list group */
OS_EXT OS_PRIO OSRdyTbl[OS_RDY_TBL_SIZE]; /* Table of tasks which are ready to run */
- OSRdyGrp:1 个字节,bit0~bit7 分别对应 OSRdyTbl0~OSRdyTbl7 哪一行里有就绪任务。
- OSRdyTbl8:8 个字节,每字节的 8 个 bit 对应优先级 0~63。
一个任务的优先级 p,被拆成两段:行 y = p >> 3,列 x = p & 0x07。这就是位图的基本玩法------用 1 个 bit 表示一个任务的就绪状态。
拿优先级 29 来说:29 >> 3 = 3,29 & 0x07 = 5。所以它落在 OSRdyTbl3 的第 5 位,同时 OSRdyGrp 的第 3 位要置 1,表示"第 3 行非空"。

置位与清位:缓存在 TCB 里的四个字段
既然 p 的映射关系固定,为什么每次都要做一遍移位和与运算?uC/OS-II 的选择是:在创建任务时就预计算好,运行时直接取用。
c
OSTCBY = prio >> 3; /* 行号 y */
OSTCBX = prio & 0x07; /* 列号 x */
OSTCBBitY = 1 << OSTCBY; /* 行掩码,如 1<<3 = 0x08 */
OSTCBBitX = 1 << OSTCBX; /* 位掩码,如 1<<5 = 0x20 */
这四个字段在第 2 篇已经见过,当时可能觉得只是"缓存索引",现在它的价值才真正显现:运行期间,置位/清位只需要两次或运算/与运算。
任务进入就绪态,置位:
c
OSRdyGrp |= ptcb->OSTCBBitY; /* 组位:第 y 行非空 */
OSRdyTbl[ptcb->OSTCBY] |= ptcb->OSTCBBitX; /* 行位:优先级 x 就绪 */
任务退出就绪态,清位:
c
OSRdyTbl[y] &= ~ptcb->OSTCBBitX; /* 先清行内位 */
if (OSRdyTbl[y] == 0u) { /* 这一行全空了? */
OSRdyGrp &= ~ptcb->OSTCBBitY; /* 再清组位 */
}
注意清位的顺序:先清行内位,行空了才清组位。反之,置位时组位和行位可以同时写。这个细节是理解就绪表一致性的关键:组位是行位的"摘要",摘要必须滞后于明细更新。
置位/清位遍布内核各处:延时到期置位(OSTimeTick)、等待事件时清位(OS_EventTaskWait)、挂起时清位(OSTaskSuspend)......所有路径用的都是同一套位操作。
换优先级(OSTaskChangePrio)也是同一套规矩:先清旧位置的位,再置新位置的位------旧位清了行空才清组位,新位则组位行位一起置。先"摘下来"再"放上去",顺序一步都不能乱。
OSUnMapTbl:用 256 字节把找最小值变成查表
就绪表建好了,下一个问题:怎么从这张位图里快速找到"最小的置位位置"?所有就绪任务中,数值最小的优先级就是最高优先级。在二进制里,找最小置位位,就是找最低的置 1 位。
C 语言里做位扫描,要么写循环,要么依赖编译器内建函数(比如 clz)。循环最坏要扫 8 次,而且不是常数时间。uC/OS-II 的做法更直接:查表。
c
INT8U const OSUnMapTbl[256] = {
0u, 0u, 1u, 0u, 2u, 0u, 1u, 0u, 3u, 0u, 1u, 0u, 2u, 0u, 1u, 0u, /* 0x00 to 0x0F */
4u, 0u, 1u, 0u, 2u, 0u, 1u, 0u, 3u, 0u, 1u, 0u, 2u, 0u, 1u, 0u, /* 0x10 to 0x1F */
...
};
这张表的规律只有一句话:OSUnMapTbln = n 的最低置 1 位的位位置(0 开始)。
验证几个关键值:
| 输入 | 二进制 | 最低置位 | 表值 |
|---|---|---|---|
| 0x01 | 0001 | bit0 | 0 |
| 0x02 | 0010 | bit1 | 1 |
| 0x04 | 0100 | bit2 | 2 |
| 0x0C | 1100 | bit2 | 2 |
| 0x80 | 1000 0000 | bit7 | 7 |
| 0xFF | 1111 1111 | bit0 | 0 |
注意到没?0x02 的低位是 0,但最低置位是 bit1,所以表值是 1。这张表不看"最低位",只看"最低的置 1 位"。一次查表 O(1),与位宽无关------这是整个调度算法的地基。
顺便说一下这张表是怎么来的。看前 16 项:0,0,1,0,2,0,1,0,3,0,1,0,2,0,1,0------每 16 项一组,组首递进(0x10 组首是 4、0x20 组首是 5......)。其实它不需要背,规律就是位操作的自然结果:bit0 置位表值必为 0,bit0 为 0 而 bit1 置位则表值 1,依此类推。奇数输入表值全 0(bit0 总是置位),偶数输入的值取决于更高位。Micrium 把这 256 项直接展开写死在源码里,省去启动时生成,读起来也一目了然。
有人会问:为什么不用链表?把就绪任务串成链表,找最高优先级从链头拿就行了。问题是插入位置:新任务就绪时要按优先级找插入点,最坏 O(n);而且每个任务要多两个指针。位图的代价是 9 字节就绪表 + 256 字节查表,换来所有操作 O(1)。这是典型的"空间换时间",而且换得极其划算------对于实时内核,"操作耗时与任务数无关"本身就是卖点。
两步查表:0x34 的完整推演
有了 OSUnMapTbl,找最高优先级任务就变成两次查表。源码 os_core.c 里的 OS_SchedNew 是这么写的:
c
y = OSUnMapTbl[OSRdyGrp]; /* 第一步:找非空行,得到 y */
OSPrioHighRdy = (INT8U)((y << 3u) + OSUnMapTbl[OSRdyTbl[y]]); /* 第二步:行内找最低位 */
第一次查表,OSRdyGrp 的最低置位 bit 就是最小的非空行号 y------最小行号对应最高优先级所在的行。第二次查表,从那一行里再找最低置位 bit x。最终优先级 = y * 8 + x。
如果 OS_LOWEST_PRIO 配到 63 以上(支持 256 个任务),OS_SchedNew 走的是另一段 #else 分支:OSRdyGrp 按高 8 位/低 8 位分两步查,行内再分两步------多一层查表,思路完全一样。默认 64 任务配置下,就是上面这两行。
来推演一个具体场景。假设当前 OSRdyGrp = 0x34:
0x34 = 0b0011_0100,最低置 1 位是 bit2。第一步查表:y = OSUnMapTbl0x34 = 2。
再看第 2 行。假设 OSRdyTbl2 = 0x14 = 0b0001_0100,最低置 1 位是 bit2。第二步查表:x = OSUnMapTbl0x14 = 2。
所以最高优先级 = (2 << 3) + 2 = 18。
整个过程:两次数组下标访问,一次移位,一次加法。任务从 8 个涨到 64 个,这段代码一行都不用改,耗时一模一样。**调度的快慢,不由任务数量决定。**这就是 O(1) 的含义。

OS_Sched:调度器的三道门槛
OS_SchedNew 只负责"算出来谁是最高优先级"。真正决定"要不要切"的是 OS_Sched:
c
void OS_Sched (void)
{
OS_ENTER_CRITICAL();
if (OSIntNesting == 0u) { /* Schedule only if all ISRs done and ... */
if (OSLockNesting == 0u) { /* ... scheduler is not locked */
OS_SchedNew();
OSTCBHighRdy = OSTCBPrioTbl[OSPrioHighRdy];
if (OSPrioHighRdy != OSPrioCur) { /* No Ctx Sw if current task is highest */
OSCtxSwCtr++; /* Increment context switch counter */
OS_TASK_SW(); /* Perform a context switch */
}
}
}
OS_EXIT_CRITICAL();
}
三道门槛,一夫当关:
第一道:OSIntNesting == 0------不在中断嵌套里。为什么?中断服务程序里不会直接切换任务(先跑完 ISR,退出时由 OSIntExit 统一处理),在 ISR 里调用 OS_Sched 属于"越权",OSIntExit 会调 OS_Sched 但那是中断退出的专属流程。
第二道:OSLockNesting == 0------调度器没被锁。应用可以用 OSSchedLock/OSSchedUnlock 把调度暂时冻结(锁可以嵌套,计数为零才放行)。锁调度器 ≠ 关中断:中断照常响应,只是不切换任务。
什么场景会用锁?典型的是"想原子地操作共享数据,但又不能长时间关中断":比如更新一个几十字节的结构体,关中断会拉长中断响应时间,锁调度器则不影响中断,只是把任务切换推迟几微秒。锁了之后记得成对解锁,漏一次,系统就再也不会切换任务了。
第三道:OSPrioHighRdy != OSPrioCur ------最高优先级任务不是当前任务。如果算出来还是自己,就没必要切换。这一条保证了调度的最小开销:空转调度几乎免费。
通过三道门槛后:OSTCBHighRdy = OSTCBPrioTbl[OSPrioHighRdy] 从优先级表里取出目标 TCB,然后 OS_TASK_SW() 触发上下文切换。OS_TASK_SW 是宏,由 CPU 移植文件定义(通常是软中断指令或直接调用 OSCtxSw)------真正的寄存器保存与恢复是汇编代码,第 4 篇专门拆它。
一次抢占的完整推演
把前面所有零件拼起来,走一个完整的抢占场景。
系统里三个任务:任务 A 优先级 5(正在运行),任务 B 优先级 3(就绪),任务 C 优先级 20(在等信号量)。
当前就绪表:OSRdyTbl0 的 bit3(优先级 3)和 bit5(优先级 5)都是 1,即 OSRdyTbl0 = 0x28;OSRdyGrp 的 bit0 = 1(第 0 行非空)。
现在任务 C 等到了信号量。OSSemPost 唤醒它:OSRdyGrp |= OSTCBBitY(20>>3=2,置 bit2);OSRdyTbl[2] |= OSTCBBitX(20&7=4,置 bit4)。然后调用 OS_Sched()。
三道门槛全过(不在中断、调度器没锁)。OS_SchedNew 两步查表:
y = OSUnMapTbl[OSRdyGrp]。OSRdyGrp = 0x05 = 0b0101,最低置位 bit0 → y = 0。x = OSUnMapTbl[OSRdyTbl[0]] = OSUnMapTbl[0x28]。0x28 = 0b0010_1000,最低置位 bit3 → x = 3。- OSPrioHighRdy = (0 << 3) + 3 = 3。
注意:任务 C(优先级 20)刚就绪,最高优先级却是 3------因为任务 B 一直在就绪表里,20 > 3。C 就绪只是改动了 OSRdyTbl2,不影响查表结果。就绪表找的是"所有就绪任务里优先级最高的",不是"刚被唤醒的那个"。
最后:OSPrioHighRdy(3) != OSPrioCur(5),触发 OS_TASK_SW(),切到任务 B。
如果当前运行的就是任务 B 自己(OSPrioCur = 3),算出来还是 3,第三道门槛直接放行不了------什么都不做。空转调度几乎免费,就靠这一句。
调度器什么时候被触发
OS_Sched 从不"主动"跑,它只在别人调用它时工作。触发方分四类:
- 让出类:任务主动等------OSTimeDly、OSSemPend、OSMboxPend、OSQPend 等,阻塞自己前调 OS_Sched
- 释放类:事件就绪------OSSemPost、OSMboxPost、OSFlagPost 等,唤醒别人后调 OS_Sched
- 任务类:OSTaskCreate、OSTaskDel、OSTaskSuspend、OSTaskResume、OSTaskChangePrio
- 中断退出:OSIntExit(ISR 末尾,第 4 篇展开)
这里藏着一个设计规律:调度是"事件驱动的",不是"定时驱动的"。任何可能改变就绪表状态的 API,末尾都会看一眼调度器------这就是抢占式内核的"随时抢占"。
一切从清零开始
就绪表不是天生就整齐的。OSInit 里有一句 OS_InitRdyList()(os_core.c 第 1367 行),做的事朴素得很:OSRdyGrp 清零、OSRdyTbl 八个字节逐个清零,再给 OSPrioCur、OSTCBCur 置初值。系统启动早期还没有任务时,OSRdyGrp 保持 0------不过 OS_SchedNew 在 OSStart 之前根本不会被调用,所以 OSUnMapTbl0 的取值(0)永远轮不到上阵。
收尾:确定性来自 9 个字节
回顾一下这张图的全部家当:
OSRdyGrp+OSRdyTbl[8]:9 个字节,登记 64 个任务的就绪状态OSUnMapTbl[256]:256 字节只读表,把"找最低置位"变成查表OS_SchedNew:两次查表 + 一次移位 = 找到最高优先级任务OS_Sched:三道门槛 + 目标 TCB + 触发切换
整个调度决策路径,与系统里有几个任务完全无关。实时系统要的确定性,就是这么锁死的。
顺便一提:OSUnMapTbl 是 const 数组,编译后躺在 ROM/Flash 里,不占宝贵的 RAM;OSRdyGrp + OSRdyTbl 的 9 字节才在 RAM 里------整个就绪机制对内存的占用,就这么多。
下一篇我们跟着 OS_TASK_SW 走进汇编:OSCtxSw 是怎么把当前任务"挂起来"、把新任务"扶上台"的------那是 uC/OS-II 最后一块拼图。