3、优先级机制详解:静态优先级与动态优先级、优先级位图算法、优先级上限协议
好,咱们接着聊优先级。这玩意儿是任务调度的灵魂,搞不懂它,你写出来的RTOS代码就跟没头苍蝇似的。
我个人习惯把优先级比作「插队权」。谁优先级高,谁就能在CPU面前插队。但这里头门道很多——优先级是固定的好,还是变来变去的好?怎么快速找到最高优先级的任务?高优先级任务跟低优先级任务抢共享资源时,会不会出乱子?
嗯,今天咱们就把这三个问题彻底讲透。
3.1 静态优先级 vs 动态优先级
先说说最基础的概念。
静态优先级,说白了就是任务一创建,优先级就定死了。你写代码时设成多少,它一辈子就是多少。NuttX里用pthread_attr_setschedparam或者task_create时传的优先级参数,就是静态的。
静态优先级的特点:
- 简单粗暴,系统开销小
- 适合周期性的、行为可预测的任务
- 缺点是不够灵活——万一某个任务突然变紧急了呢?
动态优先级就不一样了。任务的优先级可以在运行时调整。NuttX支持两种动态调整方式:一种是应用层主动调用sched_setparam改优先级;另一种是内核根据某种策略自动调整。
我记得在做一个工业控制项目时,有个数据采集任务平时优先级很低,但一旦检测到报警信号,就得立刻升到最高优先级去处理。静态优先级根本搞不定这种场景,必须上动态的。
我的建议:能用静态就别用动态。动态优先级带来的不确定性,有时候比它解决的问题还多。只有在明确需要「紧急升权」的场景下,才考虑动态方案。
3.2 优先级位图算法——找最高优先级任务的「杀手锏」
好,现在问题来了:系统里有几十个任务,优先级各不相同。每次调度时,内核得从这堆任务里找出「当前最高优先级且就绪」的那个。怎么找最快?
你可能会说:「遍历一遍不就完了?」
嗯,遍历确实能搞定。但你想过没有——如果系统里有64个优先级,每次调度都遍历64次,那调度延迟就不可控了。在实时系统里,这可是大忌。
NuttX用的是优先级位图算法。这玩意儿,说白了就是用一串二进制位来表示所有优先级的状态。每个bit对应一个优先级,1表示该优先级上有就绪任务,0表示没有。
举个例子,假设系统支持32个优先级(0~31),优先级0最高。当前就绪状态是这样的:
优先级: 31 30 29 ... 3 2 1 0
位图值: 0 0 0 ... 0 1 0 1
这个二进制数从高位往低位看,第一个出现1的位置,就是当前最高优先级。上面这个例子里,优先级1和优先级3都有就绪任务,但优先级1更高(数值更小),所以调度器会选优先级1上的任务来执行。
关键问题是:怎么快速找到第一个1的位置?
NuttX里用了一个叫ffs(find first set)的指令,或者用查表法。ARM Cortex-M系列有CLZ指令(Count Leading Zeros),一条指令就能算出最高优先级。你想想看,这比遍历快了多少倍?
NuttX中的位图实现(简化版):
/* 假设g_readytorun是就绪任务位图 */
uint32_t g_readytorun;
/* 找到最高优先级的任务 */
int highest_pri = ffs(g_readytorun) - 1;
/* 如果位图值为0b...00010100,ffs返回3,highest_pri就是2 */
我曾经在一个项目里手写过一个查表版本,因为那颗MCU没有CLZ指令。32个优先级,我建了一个256字节的查找表,查一次就能定位。虽然多占了一点ROM,但速度杠杠的。
注意:位图算法虽然快,但它要求优先级编号是连续的,而且不能太多(一般不超过256级)。NuttX默认支持128个优先级,用两个64位整型就能搞定位图。如果你把优先级扩展到上千级,位图就不太合适了。
3.3 优先级上限协议——解决优先级反转的「特效药」
说到优先级,就绕不开一个经典问题——优先级反转。
什么叫优先级反转?我给你讲个真实的故事。
有一次我在调试一个多任务系统,发现一个高优先级的任务老是超时。查了半天,发现它被一个中优先级的任务「间接」阻塞了。原因是这样的:
- 低优先级任务L拿到了一个互斥锁
- 高优先级任务H想拿同一个锁,被阻塞,于是L继续执行
- 这时中优先级任务M就绪了,它不需要那个锁,所以它抢占了L
- 结果呢?H在等L释放锁,L却被M抢占了——H的优先级「反转」成了比M还低!
你说气不气人?
NuttX解决这个问题,用的是优先级上限协议(Priority Ceiling Protocol)。
这个协议的核心思想是:每个互斥锁都有一个「上限优先级」。这个上限值,等于所有可能获取该锁的任务中的最高优先级。
当一个任务获取锁时,它的优先级会被临时提升到这个上限值。这样一来,任何优先级低于这个上限的任务都无法抢占它——也就不会出现上面那种反转情况了。
NuttX中启用优先级上限协议:
pthread_mutexattr_t attr;
pthread_mutexattr_init(&attr);
pthread_mutexattr_setprotocol(&attr, PTHREAD_PRIO_CEILING);
pthread_mutexattr_setprioceiling(&attr, 100); /* 上限优先级设为100 */
pthread_mutex_t mutex;
pthread_mutex_init(&mutex, &attr);
这里要注意,上限优先级设多少很有讲究。设得太高,会不必要地阻塞其他任务;设得太低,又起不到防止反转的作用。我个人习惯的做法是:先分析所有可能访问该锁的任务,取它们的最高优先级,再加1~2级作为安全余量。
避坑指南:我曾经在一个项目里把上限优先级设得过高,结果导致一个中等优先级的显示任务被频繁阻塞,界面卡顿。后来把上限调低了两级,问题就解决了。记住——上限不是越高越好,够用就行。
除了优先级上限协议,NuttX也支持优先级继承协议(Priority Inheritance)。两者的区别在于:
| 协议 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 优先级上限 | 锁有固定上限,获取锁时任务升到上限 | 实现简单,可预测 | 可能过度阻塞 |
| 优先级继承 | 只有发生阻塞时才临时提升优先级 | 更精确,阻塞更少 | 实现复杂,有死锁风险 |
我个人更倾向于优先级上限协议,尤其是在任务数量不多、锁的使用模式比较固定的场景下。它简单、可靠,不容易出幺蛾子。
好,优先级这块咱们就聊到这儿。下节课我会讲NuttX的调度器实现,包括那个经典的「就绪队列」是怎么维护的。到时候你会看到,位图算法和优先级协议是怎么在调度器里真正跑起来的。