ESP32 多核环境下 FreeRTOS 任务与中断优先级嵌套导致死锁的排查方法

一、问题背景

ESP32 采用双核 Xtensa LX6 处理器,FreeRTOS 默认支持对称多处理(SMP),任务可运行在任意核心上。当任务与中断服务程序(ISR)共享资源时,若优先级嵌套处理不当,极易引发死锁。典型症状:系统运行一段时间后卡死,看门狗超时复位,或低优先级任务永远得不到执行。

二、死锁原理分析

1. 多核环境下的竞争条件

在单核 MCU 中,任务切换发生在中断或系统节拍中,临界区只需屏蔽中断即可保证原子性。但在 ESP32 双核上,两个核心并行执行,若任务 A 在 Core 0 持有锁,任务 B 在 Core 1 同时请求同一锁,就会产生竞争。FreeRTOS 使用自旋锁(spinlock)保护内部数据结构,但用户代码中的互斥量(Mutex)并不自动屏蔽中断,因此需要额外处理。

2. 优先级嵌套死锁场景

  • 场景:高优先级任务 H 等待互斥量 M,而 M 被低优先级任务 L 持有。L 在持有 M 期间被中断 ISR 打断,ISR 中又尝试获取另一个互斥量 N,而 N 被 H 持有(H 此时在等待 M)。
  • 结果:H 等待 M,L 等待 ISR 完成,ISR 等待 N,N 被 H 持有,形成循环等待。
  • 多核加剧:若 H 和 L 运行在不同核心,ISR 可能绑定在某个核心,导致调度器无法通过优先级抢占打破僵局。

3. FreeRTOS 优先级机制

  • 任务优先级:0(最低)到 configMAX_PRIORITIES-1(最高),数字越大优先级越高。
  • 中断优先级:ESP32 使用 1-7 级,数字越大优先级越高。中断可以抢占任务,但 ISR 中不能调用阻塞 API(如 xSemaphoreTake 带超时),否则会触发断言或死锁。

三、排查流程

1. 复现与日志

  • 在死锁发生前打印关键状态:每个任务的状态(运行、阻塞、就绪)、持有的互斥量、等待的互斥量。
  • 使用 vTaskListvTaskGetRunTimeStats 输出任务信息。
  • 开启 FreeRTOS 的跟踪宏:configUSE_TRACE_FACILITYconfigUSE_STATS_FORMATTING_FUNCTIONS

2. 检查优先级配置

  • 确认所有互斥量是否设置了优先级继承(xSemaphoreCreateMutex 默认支持)。
  • 检查中断优先级是否高于可延迟中断(如 portMAX_DELAY 在 ISR 中禁用)。
  • 使用 ESP_INTR_FLAG_LEVEL 明确中断级别,避免嵌套。

3. 审查临界区

  • 在任务中,使用 taskENTER_CRITICALtaskEXIT_CRITICAL 保护共享资源,但注意这些宏在 SMP 下会关闭当前核心的中断并获取自旋锁,可能导致其他核心等待。
  • 避免在临界区中调用任何可能阻塞的函数。

4. 使用死锁检测工具

  • 开启 configUSE_MUTEX_DEADLOCK_DETECTION(需自定义实现),或使用 xSemaphoreGetMutexHolder 查询持有者。
  • 利用 ESP-IDF 的 esp_cpu_dump 或 JTAG 调试器查看当前任务栈和 PC。

四、代码示例与修复

1. 问题代码(死锁触发)

// 共享互斥量
SemaphoreHandle_t mutexA, mutexB;

// 高优先级任务 H
void taskH(void *arg) {
    while (1) {
        xSemaphoreTake(mutexA, portMAX_DELAY); // 等待 A
        // 处理...
        xSemaphoreGive(mutexA);
        vTaskDelay(10);
    }
}

// 低优先级任务 L
void taskL(void *arg) {
    while (1) {
        xSemaphoreTake(mutexB, portMAX_DELAY); // 持有 B
        // 模拟中断触发
        vTaskDelay(1);
        xSemaphoreGive(mutexB);
        vTaskDelay(100);
    }
}

// 中断服务程序(假设绑定 Core 1)
void IRAM_ATTR isrHandler(void *arg) {
    BaseType_t xHigherPriorityTaskWoken = pdFALSE;
    xSemaphoreTakeFromISR(mutexB, &xHigherPriorityTaskWoken); // 尝试获取 B,但被 L 持有
    // 若 B 被 L 持有,这里会死等(实际上 ISR 中不能阻塞,但错误代码可能使用轮询)
    portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}

2. 修复方案

  • 方案一:统一资源访问顺序。所有任务和 ISR 按相同顺序获取互斥量,避免循环等待。
  • 方案二:使用队列代替互斥量。ISR 中通过 xQueueSendFromISR 发送事件,任务中处理,避免 ISR 直接获取锁。
  • 方案三:设置互斥量超时。在任务中使用 xSemaphoreTake(mutex, pdMS_TO_TICKS(100)),超时后释放已持有的锁并重试。
  • 方案四:调整优先级。将持有互斥量的任务优先级临时提升(优先级继承),或使用 xSemaphoreCreateRecursiveMutex 支持递归获取。

修复后的代码示例:

// 任务 L 中,先获取 A 再获取 B,保持一致顺序
void taskL(void *arg) {
    while (1) {
        if (xSemaphoreTake(mutexA, pdMS_TO_TICKS(50)) == pdTRUE) {
            if (xSemaphoreTake(mutexB, pdMS_TO_TICKS(50)) == pdTRUE) {
                // 临界区
                xSemaphoreGive(mutexB);
            }
            xSemaphoreGive(mutexA);
        }
        vTaskDelay(100);
    }
}

// ISR 中只发送通知,不获取锁
void IRAM_ATTR isrHandler(void *arg) {
    BaseType_t xHigherPriorityTaskWoken = pdFALSE;
    xSemaphoreGiveFromISR(semaphoreNotify, &xHigherPriorityTaskWoken);
    portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}

五、注意事项

  • ISR 中严禁阻塞:任何 portMAX_DELAY 或轮询等待都会导致系统崩溃。
  • 多核亲和性:使用 xTaskCreatePinnedToCore 将关键任务固定到特定核心,减少竞争。
  • 优先级反转:FreeRTOS 互斥量默认支持优先级继承,但仅限任务间,不涉及中断。
  • 调试技巧:在死锁发生时,通过 esp_backtrace 打印调用栈,或使用 gdb 连接 JTAG 查看任务状态。
  • 测试覆盖:使用 stress test 长时间运行,并随机触发中断,以暴露潜在死锁。

六、总结

ESP32 多核环境下的死锁排查需要结合 FreeRTOS 调度原理和硬件特性。通过系统化检查优先级配置、临界区使用和资源获取顺序,配合日志和工具,可以快速定位问题。建议在设计阶段就遵循“统一顺序、避免中断持锁、使用队列通信”的原则,从根源上减少死锁风险。