引言

ESP32 作为双核 MCU,其 FreeRTOS 支持对称多处理(SMP),任务可分配到不同核心运行。然而,多核并发访问共享资源时,优先级翻转问题变得更为复杂和隐蔽。传统单核下的优先级翻转(低优先级任务持有锁,高优先级任务等待)在双核环境下可能演变为跨核死锁、优先级继承失效等新形态。本文面向有 FreeRTOS 基础的开发者,揭示这些隐蔽场景并提供可落地的解决方案。

一、优先级翻转的本质与双核新挑战

优先级翻转是指高优先级任务因等待低优先级任务释放资源而被阻塞,导致实时性受损。经典场景:任务 H(高优先级)和任务 L(低优先级)共享互斥量,L 持有锁时被任务 M(中优先级)抢占,H 等待 L,而 M 持续运行,H 被无限期阻塞。

在 ESP32 双核环境下,问题升级:

  • 跨核锁竞争:任务 H 在 Core 0 等待锁,而持有锁的任务 L 在 Core 1 上被另一个中优先级任务 M 抢占,且 M 运行在 Core 1 上,导致 H 无法获得 CPU 时间。
  • 优先级继承失效:FreeRTOS 的互斥量(Mutex)默认支持优先级继承,但在 SMP 下,继承机制仅作用于持有锁的任务所在核心,无法影响另一核心上的任务调度。
  • 中断与任务交互:ESP32 的定时器中断或外设中断可能在任意核心触发,若中断服务程序(ISR)中尝试获取互斥量(非安全操作),会引发不可预测的优先级翻转。

二、隐蔽触发场景剖析

场景 1:跨核互斥锁竞争

假设任务 A(优先级 10)运行在 Core 0,任务 B(优先级 5)运行在 Core 1,两者共享一个互斥量。A 等待 B 释放锁,但 B 在 Core 1 上被任务 C(优先级 8)抢占。由于 C 优先级高于 B,B 无法运行,A 在 Core 0 上空转等待。此时,即使 A 优先级最高,也无法获得锁,形成跨核优先级翻转。

// 代码示例:跨核锁竞争
SemaphoreHandle_t xMutex = xSemaphoreCreateMutex();

void taskA(void *arg) { // 优先级 10,Core 0
    while(1) {
        xSemaphoreTake(xMutex, portMAX_DELAY);
        // 临界区
        xSemaphoreGive(xMutex);
        vTaskDelay(10);
    }
}

void taskB(void *arg) { // 优先级 5,Core 1
    while(1) {
        xSemaphoreTake(xMutex, portMAX_DELAY);
        // 长时间处理
        vTaskDelay(100); // 模拟被抢占
        xSemaphoreGive(xMutex);
    }
}

void taskC(void *arg) { // 优先级 8,Core 1
    while(1) {
        // 持续占用 CPU,无阻塞
        for(volatile int i=0; i<100000; i++);
        vTaskDelay(1);
    }
}

场景 2:中断与任务交互

ESP32 的定时器中断可能触发在任意核心。若 ISR 中调用 xSemaphoreGiveFromISR 释放一个互斥量,而该互斥量被高优先级任务等待,但低优先级任务持有锁,ISR 无法解决优先级继承,导致高优先级任务延迟。

// ISR 中释放互斥量(不安全示例)
void IRAM_ATTR timer_isr(void *arg) {
    BaseType_t xHigherPriorityTaskWoken = pdFALSE;
    xSemaphoreGiveFromISR(xMutex, &xHigherPriorityTaskWoken);
    portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}

场景 3:任务优先级动态变化

使用 vTaskPrioritySet 动态调整优先级时,若在持有锁的任务上降低其优先级,可能破坏优先级继承链,导致高优先级任务等待时间不可控。

三、对策与实战代码

对策 1:使用互斥量并启用优先级继承

FreeRTOS 的互斥量(xSemaphoreCreateMutex)自带优先级继承,但需确保所有任务使用同一互斥量,且避免在 ISR 中使用。

// 正确使用互斥量
SemaphoreHandle_t xMutex = xSemaphoreCreateMutex();

void taskA(void *arg) {
    while(1) {
        xSemaphoreTake(xMutex, portMAX_DELAY);
        // 临界区
        xSemaphoreGive(xMutex);
        vTaskDelay(10);
    }
}

对策 2:使用队列或信号量替代互斥量

对于数据共享,优先使用队列(xQueueSend/xQueueReceive)或二进制信号量,它们不涉及优先级继承,但可通过设计避免长时间持有。例如,生产者-消费者模式。

QueueHandle_t xQueue = xQueueCreate(10, sizeof(uint32_t));

void producer(void *arg) {
    uint32_t data = 0;
    while(1) {
        xQueueSend(xQueue, &data, 0);
        data++;
        vTaskDelay(10);
    }
}

void consumer(void *arg) {
    uint32_t data;
    while(1) {
        xQueueReceive(xQueue, &data, portMAX_DELAY);
        // 处理数据
    }
}

对策 3:核间通信与任务绑定

将相关任务绑定到同一核心,减少跨核锁竞争。使用 xTaskCreatePinnedToCore 指定核心,并利用 vTaskPrioritySet 保持优先级稳定。

// 将任务绑定到 Core 0
xTaskCreatePinnedToCore(taskA, "A", 2048, NULL, 10, &handleA, 0);
// 将任务绑定到 Core 1
xTaskCreatePinnedToCore(taskB, "B", 2048, NULL, 5, &handleB, 1);

对策 4:使用临界区(taskENTER_CRITICAL

对于极短临界区,使用临界区禁用中断(在 SMP 下会禁用当前核心中断),避免互斥量开销。但注意临界区不能跨核心。

portMUX_TYPE mux = portMUX_INITIALIZER_UNLOCKED;

void critical_section_example() {
    taskENTER_CRITICAL(&mux);
    // 短操作
    taskEXIT_CRITICAL(&mux);
}

对策 5:避免在 ISR 中获取互斥量

ISR 中应仅使用 FromISR 结尾的 API,如 xSemaphoreGiveFromISR,且释放的信号量不能是互斥量,应使用二进制信号量或直接通知任务。

// 使用任务通知替代互斥量
TaskHandle_t xTaskToNotify;

void ISR() {
    BaseType_t xHigherPriorityTaskWoken = pdFALSE;
    vTaskNotifyGiveFromISR(xTaskToNotify, &xHigherPriorityTaskWoken);
    portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}

void task() {
    while(1) {
        ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
        // 处理事件
    }
}

四、注意事项

  • 优先级继承的局限:在 SMP 下,优先级继承仅影响持有锁任务所在核心的调度器,另一核心可能继续运行中优先级任务。因此,尽量将相关任务绑定到同一核心。
  • 互斥量 vs 信号量:互斥量用于互斥访问,信号量用于同步。不要混淆。
  • 临界区长度:临界区应尽量短,否则会阻塞中断,影响实时性。
  • 测试与验证:使用 vTaskListuxTaskGetStackHighWaterMark 监控任务状态,并利用逻辑分析仪观察任务切换时序。

五、总结

ESP32 双核环境下的优先级翻转问题比单核更隐蔽,涉及跨核调度、中断交互等。通过合理使用互斥量、队列、任务绑定和临界区,并避免在 ISR 中操作互斥量,可以有效降低风险。开发者需深入理解 FreeRTOS SMP 调度机制,结合具体场景设计,才能确保系统实时性。