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

一、问题背景

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

二、死锁根因分析

1. 双核调度特性

  • 每个核心独立运行一个 FreeRTOS 调度器,但共享同一任务就绪列表。
  • 任务可被迁移到另一个核心,但中断始终绑定在特定核心(ESP32 中中断可配置为任意核心)。
  • 自旋锁(spinlock)用于保护内核数据结构,但用户代码中的临界区需显式处理。

2. 优先级嵌套陷阱

FreeRTOS 中,中断优先级高于任务优先级。当 ISR 尝试获取一个被任务持有的互斥量(Mutex)时,若该任务被更高优先级任务抢占,则可能形成循环等待。

典型死锁场景:

  • 任务A(低优先级)持有互斥量M,正在访问共享外设。
  • 中断ISR(高优先级)触发,尝试获取M,但M被A持有,ISR阻塞(FreeRTOS 中 ISR 不能阻塞,但若使用 xQueueSendFromISR 等可能导致任务级死锁)。
  • 任务B(中优先级)就绪,抢占A,A无法释放M,ISR 等待,B 等待ISR结果,形成死锁。

3. 双核加剧问题

  • 任务A在核心0运行,ISR在核心1触发,核心1的ISR等待核心0的任务释放M,但核心0可能正在执行更高优先级任务,导致核心0无法调度A。
  • 若两个核心同时进入临界区,且使用 portENTER_CRITICAL 未处理多核,会引发系统崩溃。

三、排查方法

1. 启用 FreeRTOS 调试功能

sdkconfig 中启用:

  • CONFIG_FREERTOS_DEBUG_OCDAWARE:支持 JTAG 调试。
  • CONFIG_FREERTOS_QUEUE_REGISTRY_SIZE:注册队列/互斥量便于查看。
  • CONFIG_FREERTOS_USE_TRACE_FACILITY:启用跟踪。

2. 添加死锁检测钩子

FreeRTOSConfig.h 中定义:

#define configUSE_MUTEX_DEADLOCK_DETECTION 1

并实现钩子函数:

void vApplicationMutexDeadlockDetected( Mutex_t *pxMutex ) {
    // 打印互斥量名称和持有者任务
    ESP_LOGE("DEADLOCK", "Mutex %s held by %s", 
             pcMutexName(pxMutex), 
             pcTaskGetTaskName(pxMutex->u.xOwner));
    // 可触发断点或重启
}

注意:该功能需要 configUSE_TRACE_FACILITYconfigUSE_STATS_FORMATTING_FUNCTIONS

3. 使用静态分析工具

  • 使用 FreeRTOS+TraceSystemView 记录任务状态和中断事件,分析时序。
  • 在代码中定期打印任务状态:
void printTaskStats(void) {
    TaskStatus_t xTaskStatus;
    vTaskGetInfo(NULL, &xTaskStatus, pdTRUE, eInvalid);
    ESP_LOGI("TASK", "Task %s state=%d prio=%d", 
             xTaskStatus.pcTaskName, xTaskStatus.eCurrentState, xTaskStatus.uxCurrentPriority);
}

4. 日志追踪法

在关键临界区前后添加日志,使用时间戳定位卡死点:

#define LOG_CRITICAL_ENTER(mutex) ESP_LOGI("LOCK", "Enter %s at %lld", mutex->name, esp_timer_get_time())
#define LOG_CRITICAL_EXIT(mutex)  ESP_LOGI("LOCK", "Exit %s at %lld", mutex->name, esp_timer_get_time())

四、完整代码示例

以下示例演示一个典型死锁场景及修复方法。

1. 错误示例(死锁)

#include <stdio.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/semphr.h"
#include "esp_log.h"

static SemaphoreHandle_t mutex;
static const char *TAG = "DEADLOCK";

// 模拟共享资源
void access_shared() {
    vTaskDelay(pdMS_TO_TICKS(100)); // 模拟耗时操作
}

// 低优先级任务
void taskA(void *arg) {
    while (1) {
        xSemaphoreTake(mutex, portMAX_DELAY);
        ESP_LOGI(TAG, "TaskA acquired mutex");
        access_shared();
        xSemaphoreGive(mutex);
        vTaskDelay(pdMS_TO_TICKS(10));
    }
}

// 中优先级任务
void taskB(void *arg) {
    while (1) {
        vTaskDelay(pdMS_TO_TICKS(50));
        ESP_LOGI(TAG, "TaskB running");
        // 模拟CPU密集型操作,抢占A
        for (int i = 0; i < 100000; i++) {}
    }
}

// 中断模拟(使用定时器)
void timer_isr(void *arg) {
    // 尝试获取互斥量(错误:ISR中不能阻塞)
    if (xSemaphoreTakeFromISR(mutex, NULL) != pdTRUE) {
        ESP_DRAM_LOGE(TAG, "ISR failed to get mutex");
    } else {
        ESP_DRAM_LOGI(TAG, "ISR got mutex");
        xSemaphoreGiveFromISR(mutex, NULL);
    }
}

void app_main() {
    mutex = xSemaphoreCreateMutex();
    xTaskCreatePinnedToCore(taskA, "A", 2048, NULL, 1, NULL, 0);
    xTaskCreatePinnedToCore(taskB, "B", 2048, NULL, 2, NULL, 1);
    // 创建定时器中断
    esp_timer_create_args_t args = {.callback = timer_isr, .arg = NULL};
    esp_timer_handle_t timer;
    esp_timer_create(&args, &timer);
    esp_timer_start_periodic(timer, 10000); // 10ms周期
}

运行后,系统可能卡死,因为ISR中尝试获取互斥量,但互斥量被A持有,而A被B抢占,ISR无法完成。

2. 修复方案

  • 中断中不直接获取互斥量,而是通过队列通知任务。
  • 使用二值信号量代替互斥量,并确保在ISR中只做非阻塞操作。
// 修复后的ISR
void timer_isr(void *arg) {
    // 发送通知给任务,让任务处理
    BaseType_t xHigherPriorityTaskWoken = pdFALSE;
    xSemaphoreGiveFromISR(binarySem, &xHigherPriorityTaskWoken);
    portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}

// 任务C处理共享资源
void taskC(void *arg) {
    while (1) {
        xSemaphoreTake(binarySem, portMAX_DELAY);
        // 安全访问共享资源
        xSemaphoreTake(mutex, portMAX_DELAY);
        access_shared();
        xSemaphoreGive(mutex);
    }
}

五、注意事项

  • 中断中禁止调用阻塞API:如 xSemaphoreTake,应使用 FromISR 结尾的函数。
  • 多核临界区:使用 portENTER_CRITICAL 时,需确保在同一个核心上进入和退出,否则使用 spinlockportENTER_CRITICAL_ISR
  • 优先级设计:避免任务优先级与中断优先级形成嵌套等待,可采用优先级继承(互斥量默认支持)。
  • 使用看门狗:启用任务看门狗,检测任务饿死。
  • 测试覆盖:进行压力测试,模拟中断高频触发场景。

六、总结

ESP32 双核环境下的死锁排查需要结合 FreeRTOS 调度机制、中断行为和多核特性。通过启用调试钩子、日志追踪和静态分析,可以快速定位问题。核心原则是:中断中绝不阻塞,任务间共享资源使用互斥量并注意优先级翻转,多核访问使用自旋锁。希望本文的方法能帮助开发者高效解决此类问题。