4. 基于优先级的抢占式调度算法:优先级位图算法(Bitmap)的实现,如何用 O(1) 时间复杂度找到最高优先级任务
各位同学,今天我们来啃一块硬骨头——优先级位图算法。说白了,就是怎么在几十上百个任务里,瞬间找到那个优先级最高的家伙。
我记得刚入行那会儿,用的还是最笨的办法:遍历整个就绪任务链表。每次调度都要从头扫到尾,任务一多,延迟就上去了。后来在一个工业控制项目里,因为调度延迟导致电机响应慢了半拍,差点把设备搞坏。从那以后,我就彻底倒向了位图算法。
4.1 为什么需要 O(1) 的调度算法?
先看一个现实问题。假设系统有 64 个优先级,每个优先级上可能挂着多个任务。调度器要做的,就是找到当前就绪的最高优先级,然后切换过去。
如果用遍历法:
- 最坏情况要检查 64 次
- 每次检查都要读内存、比较大小
- 任务越多,延迟越不可控
你想想看,在实时系统里,调度延迟每多 1 微秒,都可能让控制周期超时。我见过一个电机驱动项目,调度抖动超过 10 微秒,电机就开始嗡嗡叫了。
位图算法的好处就是:不管你有 8 个优先级还是 256 个优先级,找到最高优先级的时间都是固定的。这就是 O(1) 的含义——常数时间。
核心指标对比:
| 算法 | 时间复杂度 | 64 优先级最坏查找次数 | 适用场景 |
|---|---|---|---|
| 遍历链表 | O(n) | 64 次 | 任务少、非实时 |
| 位图算法 | O(1) | 1 次位运算 + 查表 | 硬实时、高优先级数 |
4.2 位图算法的核心思想
位图算法的思路其实很朴素:用二进制位来表示每个优先级上是否有就绪任务。
- 每个优先级对应一个 bit
- bit = 1 表示该优先级有就绪任务
- bit = 0 表示该优先级空闲
举个例子,假设我们有 8 个优先级(0~7),优先级 0 最高,优先级 7 最低。当前就绪情况是:优先级 2、优先级 5 上有任务就绪。那么位图就是:
bit7 bit6 bit5 bit4 bit3 bit2 bit1 bit0
0 0 1 0 0 1 0 0
转换成 8 位二进制数就是 0b00100100,也就是 0x24。
现在问题来了:怎么从这个数里找到最高优先级(也就是最低位的 1)?
我早期做这个的时候,用的是循环移位判断。后来发现,ARM Cortex-M 内核有一条指令叫 CLZ(Count Leading Zeros),一条指令就能搞定。但如果你用的 MCU 没有这条指令,就得靠查表法。
4.3 查表法实现 O(1) 查找
查表法的思路是:把 8 位位图的所有可能值(0~255)对应的最高优先级提前算好,存成一个数组。查找时直接用位图值做下标,一次内存访问就拿到结果。
下面是我在一个 STM32 项目里用过的实现:
/* 8位优先级位图查表 */
const uint8_t prio_table[256] = {
/* 0x00 */ 0, /* 没有就绪任务,返回0 */
/* 0x01 */ 0, /* bit0置位,最高优先级0 */
/* 0x02 */ 1, /* bit1置位,最高优先级1 */
/* 0x03 */ 0, /* bit0和bit1都置位,取最低位=0 */
/* 0x04 */ 2, /* bit2置位,最高优先级2 */
/* ... 中间省略 ... */
/* 0xFF */ 0 /* 所有位都置位,最低位=0 */
};
/* 查找最高优先级 */
uint8_t get_highest_prio(uint8_t bitmap)
{
return prio_table[bitmap];
}
个人经验:查表法虽然快,但 256 字节的表格在 8 位 MCU 上可能有点奢侈。我建议根据实际优先级数量裁剪表格。比如只用 6 个优先级,表格就只要 64 字节。
4.4 32 位位图的实现
实际 RTOS 里,优先级数量通常是 32 或 64。32 位位图的实现更常见,因为 32 位 MCU 处理 32 位数据是最快的。
来看一个完整的实现:
/* 32位优先级位图 */
typedef struct {
uint32_t bitmap; /* 位图,bit0对应最高优先级 */
} PrioBitmap;
/* 设置优先级就绪 */
void prio_set(PrioBitmap *pb, uint8_t prio)
{
pb->bitmap |= (1UL << prio);
}
/* 清除优先级就绪 */
void prio_clear(PrioBitmap *pb, uint8_t prio)
{
pb->bitmap &= ~(1UL << prio);
}
/* 查找最高优先级 - 使用CLZ指令 */
uint8_t prio_get_highest(PrioBitmap *pb)
{
if (pb->bitmap == 0) {
return 0xFF; /* 没有就绪任务 */
}
/* __CLZ是编译器内置函数,对应CLZ指令 */
/* 注意:CLZ计算前导0个数,需要转换 */
uint32_t leading_zeros = __CLZ(pb->bitmap);
return (uint8_t)(31 - leading_zeros);
}
这里有个细节要注意:__CLZ 计算的是前导 0 的个数。如果 bitmap 是 0b000...001(bit0 置位),前导 0 是 31 个,最高优先级就是 31 - 31 = 0。如果 bitmap 是 0b100...000(bit31 置位),前导 0 是 0 个,最高优先级就是 31 - 0 = 31。
避坑指南:我曾经在一个项目里直接用 __CLZ 的返回值当优先级,结果调度全乱套了。因为 CLZ 返回的是前导 0 个数,不是位的位置。一定要做 31 - leading_zeros 这个转换。
4.5 64 优先级位图的扩展
如果系统需要 64 个优先级,可以用两个 32 位变量组合:
typedef struct {
uint32_t high; /* 高32位,优先级0~31 */
uint32_t low; /* 低32位,优先级32~63 */
} PrioBitmap64;
uint8_t prio_get_highest_64(PrioBitmap64 *pb)
{
if (pb->high != 0) {
/* 高32位有就绪任务 */
return (uint8_t)(31 - __CLZ(pb->high));
} else if (pb->low != 0) {
/* 低32位有就绪任务 */
return (uint8_t)(63 - __CLZ(pb->low));
} else {
return 0xFF; /* 无就绪任务 */
}
}
这个实现仍然是 O(1) 的——最多两次 CLZ 指令,时间完全可预测。
4.6 位图算法的整体架构
下面这张图展示了位图算法在调度器中的位置和工作流程:
4.7 位图操作的临界区保护
位图操作虽然快,但也不是原子操作。在多任务环境下,设置和清除位图时可能被中断打断,导致数据不一致。
我建议的做法是:
- 在任务级代码中操作位图时,关中断保护
- 在中断服务程序中操作位图时,注意嵌套中断
- 使用
__LDREX/__STREX等原子指令(如果 MCU 支持)
/* 安全的位图设置 - 关中断保护 */
void prio_set_safe(PrioBitmap *pb, uint8_t prio)
{
uint32_t mask;
/* 保存当前中断状态并关中断 */
mask = __get_PRIMASK();
__disable_irq();
/* 设置位图 */
pb->bitmap |= (1UL << prio);
/* 恢复中断状态 */
__set_PRIMASK(mask);
}
个人建议:关中断的时间越短越好。位图操作就几条指令,关中断时间通常在几十纳秒级别,对实时性影响极小。但如果你在关中断期间还做了别的事,那就危险了。
4.8 实际项目中的性能数据
最后分享一组我在 Cortex-M4 上实测的数据:
| 操作 | 执行时间(CPU 周期) | 说明 |
|---|---|---|
| 设置位图 | 3~5 周期 | 一次 OR 操作 |
| 清除位图 | 3~5 周期 | 一次 AND 操作 |
| 查找最高优先级(CLZ) | 1~2 周期 | 单条指令 |
| 查找最高优先级(查表) | 3~4 周期 | 一次内存访问 |
| 完整调度决策 | 15~25 周期 | 包含位图操作和任务切换判断 |
看到没?从设置位图到完成调度决策,总共也就 25 个 CPU 周期。在 168MHz 的 STM32F4 上,这就是 0.15 微秒的事。这才是真正的 O(1) 调度。
嗯,位图算法的核心就这些。代码量不大,但效果立竿见影。你在自己的 RTOS 里实现一下,跑几个任务试试,就能体会到那种「瞬间找到最高优先级任务」的快感了。