第4章:优先级反转问题——一个让系统“假死”的隐形杀手

优先级反转,这词听起来挺学术的。说白了就是:一个高优先级任务,被一个低优先级任务堵着跑不了,中间还夹着一个中优先级任务在那捣乱。

我第一次遇到这问题是在一个I2C传感器读取的项目里。当时系统跑着跑着就卡死了,高优先级的控制任务死活拿不到数据。我查了三天,最后发现是优先级反转在作祟。嗯,从那以后我再也不敢小看这个问题了。

4.1 什么是优先级反转

先看一个最简单的场景。系统里有三个任务:

  • 任务A:高优先级,负责紧急控制
  • 任务B:中优先级,负责普通计算
  • 任务C:低优先级,负责I2C通信

正常情况下,任务A应该优先执行。但问题出在共享资源上——任务A和任务C都要用同一个I2C外设。

流程是这样的:

  1. 任务C先拿到I2C总线的锁,开始传输数据
  2. 任务A来了,想用I2C,发现锁被占着,只能挂起等待
  3. 这时候任务B来了,它不需要I2C,直接抢占了CPU
  4. 任务B一直跑,任务C拿不到CPU,锁就释放不了
  5. 任务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 知识结构图

下面这张图把优先级反转的完整脉络画出来了:

优先级反转问题知识结构 优先级反转 定义 高优先级被低优先级阻塞 经典案例 I2C/SPI共享资源 解决方案 三种主流方案 优先级继承(Mutex) 优先级天花板 关中断/关调度 💡 核心建议 优先使用Mutex(优先级继承),避免使用二进制信号量保护共享资源。

4.5 避坑指南

最后总结几个我踩过的坑,你遇到了可以直接抄答案:

  • 别用二进制信号量保护共享资源——它没有优先级继承,反转问题必现
  • Mutex的优先级继承只在等待发生时生效——如果高优先级任务不等待,低优先级任务不会提升
  • 关中断的时间要严格控制——超过10微秒就可能丢中断,超过100微秒系统基本废了
  • 优先级天花板不要设得太高——否则中优先级任务会被长时间饿死

我的调试小技巧:遇到系统卡死,先看所有任务的优先级和状态。如果高优先级任务在等待一个低优先级任务持有的资源,而中优先级任务在跑,十有八九就是优先级反转。用RTOS的调试工具打印任务状态,一眼就能看出来。