引言

在嵌入式实时系统(RTOS)中,任务优先级是调度的心脏。然而,当高优先级任务等待低优先级任务释放资源时,优先级反转(Priority Inversion)会悄然发生,导致高优先级任务被“拖累”,系统响应延迟甚至超时。在 ESP32 双核架构下,问题变得更加复杂:两个核心独立调度,共享外设和内存,优先级反转可能跨核发生,且难以复现和调试。本文基于实际项目经验,通过一个 I2C 总线共享案例,深入剖析优先级反转的机理,并提供经过验证的规避策略。

1. 优先级反转基础与双核差异

1.1 什么是优先级反转

假设有三个任务:H(高优先级)、M(中优先级)、L(低优先级)。L 持有共享资源(如 I2C 总线),H 等待该资源。此时 M 就绪(不依赖该资源),调度器会优先运行 M,因为 M 优先级高于 L。结果 H 被 M 间接阻塞,这就是优先级反转。经典解决方案是优先级继承:当 L 持有资源时,临时提升 L 的优先级到 H 的水平,从而阻止 M 抢占。

1.2 ESP32 双核的特殊性

  • 独立调度:ESP32 的 PRO_CPU 和 APP_CPU 各自运行 FreeRTOS 调度器,任务可绑定到特定核心(xTaskCreatePinnedToCore)。
  • 共享资源:外设(I2C、SPI、UART)和内存(堆、全局变量)是跨核共享的,需要同步机制。
  • 优先级继承失效:FreeRTOS 的互斥量(SemaphoreHandle_t)支持优先级继承,但仅在同一核心内有效。如果低优先级任务在 Core 0,高优先级任务在 Core 1,互斥量的继承机制无法跨核传递优先级,导致反转无法被自动修复。

2. 实测案例:I2C 总线共享

2.1 场景描述

  • 任务 A(优先级 3,Core 0):周期读取温度传感器(I2C 设备),耗时 10ms。
  • 任务 B(优先级 2,Core 1):周期读取气压传感器(I2C 设备),耗时 5ms。
  • 任务 C(优先级 1,Core 0):后台日志打印,使用 UART,不占用 I2C。
  • 任务 D(优先级 4,Core 1):紧急报警处理,需要立即访问 I2C 读取状态。

所有 I2C 访问通过一个互斥量保护。

2.2 问题现象

任务 D 优先级最高,但偶尔出现 50ms 以上的延迟,导致报警丢失。通过 vTaskGetRunTimeStats 统计,发现任务 D 的阻塞时间异常。

2.3 根因分析

  • 任务 A 持有互斥量,进行 I2C 读取(10ms)。
  • 任务 D 在 Core 1 等待互斥量,被阻塞。
  • 任务 B(优先级 2)在 Core 1 就绪,抢占运行(因为 D 被阻塞,B 是 Core 1 最高优先级)。
  • 任务 C(优先级 1)在 Core 0 就绪,但 Core 0 上 A 正在运行,C 无法抢占 A(A 优先级 3 > 1)。
  • 关键:A 在 Core 0 运行,但 B 在 Core 1 运行,互斥量的优先级继承只提升 A 在 Core 0 的优先级,无法影响 Core 1 的 B。因此 B 持续运行,直到完成,D 才能获得互斥量。

实际延迟 = B 的执行时间 + 其他中优先级任务的干扰,远超预期。

3. 规避策略

3.1 使用互斥量并启用优先级继承

FreeRTOS 互斥量(xSemaphoreCreateMutex)默认支持优先级继承,但仅限同核。确保所有共享资源的任务绑定到同一核心,或者使用带继承的互斥量变体(如 xSemaphoreCreateRecursiveMutex)。

// 创建互斥量
SemaphoreHandle_t i2c_mutex = xSemaphoreCreateMutex();

// 访问 I2C
if (xSemaphoreTake(i2c_mutex, portMAX_DELAY) == pdTRUE) {
    // 执行 I2C 操作
    xSemaphoreGive(i2c_mutex);
}

3.2 任务隔离与核心绑定

将高优先级任务和低优先级任务绑定到不同核心,并避免共享资源跨核。例如,将 I2C 操作封装为独立任务,所有访问通过队列发送请求,由该任务统一处理。

// 创建 I2C 管理任务,绑定到 Core 0
xTaskCreatePinnedToCore(i2c_task, "i2c", 4096, NULL, 5, &i2c_handle, 0);

// 其他任务通过队列发送请求
struct i2c_request req = {.addr = 0x40, .data = ...};
xQueueSend(i2c_queue, &req, portMAX_DELAY);

3.3 使用临界区或自旋锁(短临界区)

对于极短的共享操作(如寄存器位操作),使用 portENTER_CRITICAL 或自旋锁,避免任务切换。但注意,临界区会关闭中断,影响实时性,仅适用于微秒级操作。

portENTER_CRITICAL(&spinlock);
// 短操作
portEXIT_CRITICAL(&spinlock);

3.4 优先级天花板协议

FreeRTOS 不支持直接设置天花板,但可以手动提升低优先级任务的优先级到最高,在获取资源时。例如,在任务 A 获取互斥量时,临时将 A 的优先级提升到最高(如 configMAX_PRIORITIES-1),释放后恢复。

UBaseType_t original_prio = uxTaskPriorityGet(NULL);
vTaskPrioritySet(NULL, configMAX_PRIORITIES-1);
// 获取互斥量...
// 释放后恢复
vTaskPrioritySet(NULL, original_prio);

3.5 使用超时和错误处理

xSemaphoreTake 中设置超时,避免无限阻塞。如果超时,任务可以采取降级措施(如重试或报警)。

if (xSemaphoreTake(i2c_mutex, pdMS_TO_TICKS(10)) == pdTRUE) {
    // 成功
} else {
    // 超时处理
}

4. 完整代码示例

以下是一个改进后的 I2C 管理任务示例,采用队列隔离和互斥量,避免跨核反转。

#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/semphr.h"
#include "freertos/queue.h"

#define I2C_TASK_PRIO 5
#define I2C_QUEUE_LEN 10

SemaphoreHandle_t i2c_mutex;
QueueHandle_t i2c_queue;

// I2C 请求结构
typedef struct {
    uint8_t addr;
    uint8_t reg;
    uint8_t data;
} i2c_req_t;

// I2C 管理任务(绑定 Core 0)
void i2c_task(void *arg) {
    i2c_req_t req;
    while (1) {
        if (xQueueReceive(i2c_queue, &req, portMAX_DELAY) == pdTRUE) {
            // 使用互斥量保护实际 I2C 操作
            if (xSemaphoreTake(i2c_mutex, pdMS_TO_TICKS(20)) == pdTRUE) {
                // 模拟 I2C 读写
                vTaskDelay(pdMS_TO_TICKS(2));
                xSemaphoreGive(i2c_mutex);
            }
        }
    }
}

// 高优先级任务(Core 1)
void high_prio_task(void *arg) {
    i2c_req_t req = {.addr = 0x40, .reg = 0x01, .data = 0};
    while (1) {
        // 发送请求,不直接访问 I2C
        xQueueSend(i2c_queue, &req, portMAX_DELAY);
        vTaskDelay(pdMS_TO_TICKS(100));
    }
}

void app_main() {
    i2c_mutex = xSemaphoreCreateMutex();
    i2c_queue = xQueueCreate(I2C_QUEUE_LEN, sizeof(i2c_req_t));

    xTaskCreatePinnedToCore(i2c_task, "i2c", 4096, NULL, I2C_TASK_PRIO, NULL, 0);
    xTaskCreatePinnedToCore(high_prio_task, "high", 4096, NULL, 4, NULL, 1);
}

5. 注意事项

  • 优先级继承的局限:在双核下,互斥量的优先级继承只影响持有任务所在核心,无法跨核。因此,尽量将共享资源的访问集中到一个核心。
  • 队列替代互斥量:使用队列传递请求,将共享资源访问串行化,天然避免反转,但会增加通信开销。
  • 调试工具:使用 vTaskGetRunTimeStats 统计任务运行时间,通过 uxTaskGetStackHighWaterMark 检查栈溢出,利用 FreeRTOS 的 trace 功能(如 SystemView)观察阻塞点。
  • 优先级设计:避免过多不同优先级任务竞争同一资源,可合并中间优先级任务。
  • 测试覆盖:在双核下,反转可能只在特定时序出现,建议进行压力测试(如高频中断 + 多任务并发)。

结语

ESP32 双核 FreeRTOS 的优先级反转问题比单核更隐蔽,但通过合理的任务设计、资源访问隔离和同步机制,可以完全规避。本文的实测案例和策略已在多个项目中验证,能显著提升系统实时性。记住:在双核系统中,共享资源的访问路径设计比优先级设置更重要。