第4章:优先级反转问题——一个让系统“假死”的隐形杀手
优先级反转,这词听起来挺学术的。说白了就是:一个高优先级任务,被一个低优先级任务堵着跑不了,中间还夹着一个中优先级任务在那捣乱。
我第一次遇到这问题是在一个I2C传感器读取的项目里。当时系统跑着跑着就卡死了,高优先级的控制任务死活拿不到数据。我查了三天,最后发现是优先级反转在作祟。嗯,从那以后我再也不敢小看这个问题了。
4.1 什么是优先级反转
先看一个最简单的场景。系统里有三个任务:
- 任务A:高优先级,负责紧急控制
- 任务B:中优先级,负责普通计算
- 任务C:低优先级,负责I2C通信
正常情况下,任务A应该优先执行。但问题出在共享资源上——任务A和任务C都要用同一个I2C外设。
流程是这样的:
- 任务C先拿到I2C总线的锁,开始传输数据
- 任务A来了,想用I2C,发现锁被占着,只能挂起等待
- 这时候任务B来了,它不需要I2C,直接抢占了CPU
- 任务B一直跑,任务C拿不到CPU,锁就释放不了
- 任务A就这么被任务B和任务C联手堵死了
你想想看,高优先级的任务A,反而被中优先级的任务B给拖住了。这就是优先级反转。
核心定义:优先级反转是指高优先级任务因等待低优先级任务持有的共享资源,而被中优先级任务间接阻塞的现象。
4.2 经典案例:I2C/SPI共享资源
我在项目中遇到最典型的案例就是I2C总线。STM32的I2C外设通常只有一个,多个任务都要用它读写传感器。
看这个代码片段:
// 低优先级任务 - 温度传感器读取
void Task_Temperature(void *arg) {
while(1) {
HAL_I2C_Master_Transmit(&hi2c1, addr, data, len, timeout);
// 这里持有I2C锁
HAL_Delay(100); // 模拟处理时间
HAL_I2C_Master_Receive(&hi2c1, addr, buf, len, timeout);
vTaskDelay(500);
}
}
// 中优先级任务 - 数据日志处理
void Task_Logging(void *arg) {
while(1) {
// 纯计算任务,不需要I2C
process_log_data();
vTaskDelay(10);
}
}
// 高优先级任务 - 电机控制
void Task_MotorControl(void *arg) {
while(1) {
// 需要读取编码器数据,也要用I2C
HAL_I2C_Master_Receive(&hi2c1, encoder_addr, &speed, 2, timeout);
update_motor(speed);
vTaskDelay(1);
}
}
这个场景里,Task_MotorControl优先级最高,但它要等Task_Temperature释放I2C。而Task_Logging一直在抢CPU,Task_Temperature根本跑不起来。结果就是电机控制任务超时,系统抖动。
我曾经踩过的坑:在SPI总线上也遇到过同样的问题。当时用SPI驱动一个LCD显示,低优先级的显示任务占着SPI总线,高优先级的触摸检测任务死活读不到数据。触摸响应延迟从1ms飙到了200ms,用户体验极差。
4.3 解决方案
解决优先级反转,主流方案有三个。我按推荐程度排个序:
| 方案 | 原理 | 适用场景 | 复杂度 |
|---|---|---|---|
| 优先级继承 | 低优先级任务临时提升到高优先级 | 通用场景,RTOS原生支持 | 低 |
| 优先级天花板 | 共享资源设定最高优先级 | 资源固定,任务数量少 | 中 |
| 关中断/关调度 | 访问共享资源时禁止任务切换 | 临界区极短 | 低 |
4.3.1 优先级继承(最推荐)
优先级继承的思路很直接:当低优先级任务持有高优先级任务需要的资源时,把低优先级任务的优先级临时提升到和高优先级一样高。这样中优先级任务就抢不过它了,锁能尽快释放。
在FreeRTOS里,用互斥量(Mutex)就能自动实现优先级继承:
// 创建互斥量,自带优先级继承
SemaphoreHandle_t i2c_mutex = xSemaphoreCreateMutex();
// 低优先级任务
void Task_Temperature(void *arg) {
while(1) {
xSemaphoreTake(i2c_mutex, portMAX_DELAY); // 拿锁
// 此时如果高优先级任务也在等这把锁
// 本任务的优先级会被临时提升
HAL_I2C_Master_Transmit(&hi2c1, addr, data, len, timeout);
HAL_I2C_Master_Receive(&hi2c1, addr, buf, len, timeout);
xSemaphoreGive(i2c_mutex); // 释放锁,优先级恢复
vTaskDelay(500);
}
}
// 高优先级任务
void Task_MotorControl(void *arg) {
while(1) {
xSemaphoreTake(i2c_mutex, portMAX_DELAY);
HAL_I2C_Master_Receive(&hi2c1, encoder_addr, &speed, 2, timeout);
xSemaphoreGive(i2c_mutex);
update_motor(speed);
vTaskDelay(1);
}
}
我个人的习惯:只要涉及共享资源,一律用Mutex而不是二进制信号量。二进制信号量没有优先级继承机制,遇到反转问题只能干瞪眼。
4.3.2 优先级天花板
这个方案更粗暴一点。给每个共享资源设定一个“天花板优先级”,谁拿了这把锁,谁的优先级就升到天花板。不管有没有高优先级任务在等,先升上去再说。
实现方式:
// 定义I2C资源的天花板优先级
#define I2C_CEILING_PRIORITY 3 // 最高优先级
// 拿锁时手动提升优先级
void take_i2c_lock(void) {
UBaseType_t original_prio = uxTaskPriorityGet(NULL);
vTaskPrioritySet(NULL, I2C_CEILING_PRIORITY);
xSemaphoreTake(i2c_mutex, portMAX_DELAY);
// 注意:释放锁时要恢复优先级
}
void give_i2c_lock(void) {
xSemaphoreGive(i2c_mutex);
vTaskPrioritySet(NULL, original_prio);
}
这个方案的缺点很明显:如果天花板设得太高,中优先级任务会被长时间饿死。我一般只在任务数量少、资源固定的场景用。
4.3.3 关中断/关调度(最后的选择)
如果临界区非常短,比如就几条指令,可以直接关中断或关调度:
// 关调度,防止任务切换
taskENTER_CRITICAL();
// 访问共享资源,比如读一个全局变量
shared_data = some_register;
taskEXIT_CRITICAL();
// 或者关中断
__disable_irq();
// 极短的临界区操作
__enable_irq();
注意:关中断的时间绝对不能超过10微秒,否则会影响系统实时性。我见过有人关中断做I2C传输,结果其他中断全部丢失,系统直接崩了。
4.4 知识结构图
下面这张图把优先级反转的完整脉络画出来了:
4.5 避坑指南
最后总结几个我踩过的坑,你遇到了可以直接抄答案:
- 别用二进制信号量保护共享资源——它没有优先级继承,反转问题必现
- Mutex的优先级继承只在等待发生时生效——如果高优先级任务不等待,低优先级任务不会提升
- 关中断的时间要严格控制——超过10微秒就可能丢中断,超过100微秒系统基本废了
- 优先级天花板不要设得太高——否则中优先级任务会被长时间饿死
我的调试小技巧:遇到系统卡死,先看所有任务的优先级和状态。如果高优先级任务在等待一个低优先级任务持有的资源,而中优先级任务在跑,十有八九就是优先级反转。用RTOS的调试工具打印任务状态,一眼就能看出来。