4. ThreadX任务管理核心
抢占式调度,说白了就是高优先级任务能随时打断低优先级任务的执行。这在RTOS里是基本功,但ThreadX在这基础上加了几个很实在的“硬菜”——抢占阈值和优先级继承协议。这两个东西,我当年做工业控制器时帮了大忙。
4.1 抢占阈值(Preemption-Threshold)原理
先说说抢占阈值。标准抢占式调度里,只要优先级更高的任务就绪,当前任务就得让出CPU。但有些场景下,你并不希望所有高优先级任务都能抢进来——比如某个临界区代码,你只允许比当前任务高两个优先级的任务打断,其他更高的反而要等一等。
抢占阈值就是干这个的。每个任务除了有一个基本优先级,还可以设置一个抢占阈值优先级。只有优先级高于这个阈值的任务才能抢占当前任务。
核心规则:任务A的抢占阈值 = T,则只有优先级 > T 的任务才能抢占A。优先级在A和T之间的任务,即使就绪了也得排队。
举个例子,任务A优先级10,抢占阈值设为8。那么优先级9的任务就绪了,它抢不了A;但优先级7的任务可以。这就像给任务加了个“护城河”,只让真正紧急的任务进来。
我在一个多传感器数据采集项目里用过这个特性。当时有3个传感器任务,优先级分别是5、6、7,还有一个显示任务优先级3。正常情况下,传感器任务会频繁打断显示任务,导致屏幕闪烁。我把显示任务的抢占阈值设为4,这样只有优先级高于4的任务(即优先级3的显示任务自己?不对,这里要理清)——嗯,实际上我把显示任务的抢占阈值设得比它自己优先级高,让传感器任务无法打断它,只在显示任务主动挂起时才切换。效果立竿见影。
| 场景 | 标准抢占 | 抢占阈值 |
|---|---|---|
| 高优先级任务就绪 | 立即抢占 | 需高于阈值才抢占 |
| 临界区保护 | 关中断或锁调度器 | 阈值控制,粒度更细 |
| 优先级反转风险 | 高 | 低(配合协议使用) |
| 上下文切换次数 | 多 | 少,更高效 |
我的习惯:在配置抢占阈值时,我会先画出任务优先级图谱,标出哪些任务之间有共享资源,然后针对临界区所在的任务设置阈值。阈值一般比任务自身优先级高2-3级,留出余量。
4.2 优先级继承协议
优先级反转是个老生常谈的问题。低优先级任务拿着锁,中优先级任务抢占了CPU,高优先级任务等锁——结果高优先级任务反而被中优先级任务“踩在脚下”。
ThreadX的解决方案是优先级继承。当高优先级任务因为锁被低优先级任务持有而阻塞时,低优先级任务会临时“继承”高优先级任务的优先级,直到释放锁。
我曾经在一个机器人关节控制项目里踩过这个坑。三个任务:控制任务(优先级5)、通信任务(优先级3)、日志任务(优先级1)。控制任务和日志任务共享一个I2C总线锁。正常情况下没问题,但有一次日志任务拿着锁时,通信任务(优先级3)开始大量收发数据,占用了CPU。控制任务(优先级5)等锁等不到,关节控制指令延迟了200ms,机器人直接抖了一下。
后来我开了优先级继承,日志任务在持有锁期间临时提升到优先级5,通信任务抢不过它,锁很快释放,控制任务顺利拿到锁。问题解决。
注意:优先级继承不是万能的。它只能缓解优先级反转,不能完全消除。如果多个高优先级任务竞争同一个锁,继承链会变得复杂。我建议在锁的持有时间很短(微秒级)的场景下使用,长时间持锁的场景考虑用互斥信号量+优先级天花板协议。
4.3 调度器内部实现逻辑
ThreadX的调度器核心是一个优先级位图结构。每个优先级对应一个bit,就绪的任务在对应bit上置1。调度时,调度器用一条指令找到最高优先级的就绪任务——这比遍历链表快得多。
内部逻辑大致分三步:
- 就绪队列更新:任务状态变化时(创建、挂起、恢复、释放锁等),更新优先级位图和对应的就绪链表。
- 抢占检查:每次系统服务调用(如tx_thread_sleep、tx_semaphore_get)返回前,检查是否有更高优先级任务就绪。如果有,触发上下文切换。
- 上下文切换:保存当前任务的寄存器、栈指针到TCB,恢复新任务的寄存器、栈指针,然后执行新任务。
抢占阈值在第二步介入。调度器在检查抢占时,不光看优先级,还要看当前任务的抢占阈值。只有新任务的优先级高于阈值,才允许切换。
/* 伪代码:ThreadX调度器抢占检查逻辑 */
void _tx_thread_schedule(void) {
UINT priority_found = 0;
TX_THREAD *next_thread;
/* 1. 找到最高优先级的就绪任务 */
priority_found = _tx_thread_highest_priority();
next_thread = _tx_thread_priority_list[priority_found];
/* 2. 如果当前任务存在,检查抢占阈值 */
if (_tx_thread_current_ptr != TX_NULL) {
/* 只有新任务优先级高于当前任务的抢占阈值,才允许抢占 */
if (priority_found < _tx_thread_current_ptr->tx_thread_preempt_threshold) {
/* 允许抢占,执行上下文切换 */
_tx_thread_context_save();
_tx_thread_current_ptr = next_thread;
_tx_thread_context_restore();
} else {
/* 不允许抢占,继续执行当前任务 */
return;
}
} else {
/* 无当前任务,直接切换 */
_tx_thread_current_ptr = next_thread;
}
}
优先级继承的实现则是在获取互斥量时动态调整优先级。当高优先级任务因为互斥量阻塞时,持有互斥量的低优先级任务会被提升到高优先级任务的优先级。释放互斥量后,恢复原始优先级。
关键点:ThreadX的优先级继承是立即继承,不是延迟继承。也就是说,一旦检测到阻塞,立刻提升持有者的优先级,而不是等到调度点才处理。这减少了优先级反转的持续时间。
下面这张图展示了抢占阈值和优先级继承在调度器中的位置关系:
4.4 实战配置建议
说了这么多原理,最后给几条实际配置的建议:
- 抢占阈值用在临界区短的场景:比如操作一个全局变量、发一条短消息。如果临界区太长(毫秒级),阈值会导致其他中优先级任务饿死。
- 优先级继承配合互斥量使用:ThreadX的互斥量(tx_mutex)默认支持优先级继承,不需要额外配置。但信号量(tx_semaphore)不支持,注意区分。
- 不要滥用抢占阈值:我见过有人把所有任务的抢占阈值都设成最高优先级,结果系统退化成非抢占式调度。阈值是用来“精确控制”的,不是用来“一刀切”的。
- 调试时打开调度跟踪:ThreadX的tx_trace_event_trace可以记录调度事件。我曾经靠这个抓到过一次优先级继承没生效的bug——原因是互斥量被意外删除后重建,继承关系丢失了。
避坑指南:我曾经在一个项目里把抢占阈值设得比任务自身优先级还低,结果任务一运行就被更高优先级的任务抢占,连临界区都进不去。后来查文档才发现,抢占阈值必须 ≥ 任务自身优先级。这个坑,我替你们踩过了。