【系列:uC/OS-II 内核源码精读:从 6736 行代码看懂一个 RTOS · 第 3 篇】

每次调度都要从 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 两步查表:

  1. y = OSUnMapTbl[OSRdyGrp]。OSRdyGrp = 0x05 = 0b0101,最低置位 bit0 → y = 0。
  2. x = OSUnMapTbl[OSRdyTbl[0]] = OSUnMapTbl[0x28]。0x28 = 0b0010_1000,最低置位 bit3 → x = 3。
  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 最后一块拼图。

相关推荐
捷瑞电子工坊11 小时前
FreeRTOS任务管理:从入门到实践(标准库版)
stm32·嵌入式·freertos·实时操作系统·任务管理·标准外设库·任务挂起与恢复
深念Y20 小时前
JMS583 USB-NVMe 桥接芯片 LED 控制探索
嵌入式硬件·嵌入式·led·usb·电子·维修·硬盘盒
橘色的喵1 天前
USB MCU的固件烧写工具:从 vendor 命令到逐字节校验
单片机·嵌入式硬件·嵌入式
小僧景贤1 天前
嵌入式之队列解析(裸机/RTOS通用原理+实战场景+源码解析)
数据结构·嵌入式·队列·裸机开发·rtos开发·固件架构
国产化创客1 天前
AI时代全栈嵌入式开发学习路线
人工智能·嵌入式硬件·物联网·嵌入式·学习路线·知识体系·具身智能
BreezeJuvenile2 天前
嵌入式C语言常见考察点(2)
c语言·嵌入式
左手厨刀右手茼蒿3 天前
数据仓库的架构演进:从MySQL到ClickHouse到数据湖的工程化实践
linux·嵌入式·系统内核
左手厨刀右手茼蒿3 天前
创业团队技术选型:别用大厂架构解决小团队问题
linux·嵌入式·系统内核
DoLovya3 天前
从零实现 ESP8266 + MQTT 智能门禁:公网一键开门、舵机控制、在线监控完整方案
python·物联网·mqtt·嵌入式·esp8266·门禁系统