引言

ESP32 作为双核 MCU,其 FreeRTOS 支持对称多处理(SMP),任务可运行在任意核心上。然而,双核环境下的任务调度与中断嵌套会引入单核系统中不存在的并发问题,尤其是当任务和中断共享资源时,优先级嵌套极易导致死锁。本文面向有一定嵌入式基础的开发者,深入剖析死锁的成因,并提供一套可落地的排查方法论。

1. 双核调度与中断嵌套模型

1.1 双核调度机制

ESP32 的 FreeRTOS 基于 SMP 扩展,每个核心独立运行调度器,但共享全局就绪队列。任务可被固定到某个核心(xTaskCreatePinnedToCore),也可由调度器动态分配。关键点在于:

  • 两个核心可能同时执行不同优先级的任务。
  • 自旋锁(spinlock)用于保护内核数据结构,但用户代码中的临界区依赖互斥量(Mutex)或临界区(portENTER_CRITICAL)。
  • 任务优先级是全局的,但调度决策是每核独立的。

1.2 中断嵌套与优先级

ESP32 的中断控制器支持可配置优先级(0-7,数值越大优先级越高)。FreeRTOS 中,中断服务函数(ISR)可以打断任务,且高优先级中断可以嵌套低优先级中断。当 ISR 与任务共享资源时,若 ISR 尝试获取一个被任务持有的互斥量(如 xSemaphoreTake),则可能导致死锁,因为 ISR 不能阻塞。

2. 死锁的典型场景

2.1 优先级反转 + 互斥量

假设任务 A(低优先级)持有互斥量 M,任务 B(高优先级)等待 M。若任务 C(中优先级)抢占 A,则 B 被 C 间接阻塞,形成优先级反转。若 C 又等待另一个互斥量 N,而 N 被 A 持有,则形成循环等待,死锁发生。

2.2 中断与任务竞争

ISR 中尝试获取任务持有的互斥量(非 FromISR 版本),会导致 ISR 阻塞,但 FreeRTOS 的 ISR 不允许阻塞,从而触发断言或死锁。更隐蔽的是,ISR 通过队列发送数据,而任务在持有锁的情况下等待队列空,若 ISR 等待该锁,则互相等待。

2.3 双核下的临界区嵌套

两个核心上的任务同时进入临界区(使用 portENTER_CRITICAL),若临界区实现基于全局中断屏蔽,则一个核心屏蔽中断会影响另一个核心,导致性能下降甚至死锁(如一个核心在临界区中等待另一个核心释放锁,但对方被中断屏蔽)。

3. 排查方法论

3.1 静态分析:代码审查与工具

  • 检查所有互斥量获取路径:列出每个互斥量的所有 takegive 位置,确保没有循环等待。
  • 使用 FreeRTOS+TraceTracealyzer:这些工具可以可视化任务状态和锁依赖。
  • 启用 configUSE_MUTEXESconfigUSE_RECURSIVE_MUTEXES:确保互斥量正确配置。

3.2 动态追踪:日志与断言

  • 在关键点添加日志:记录任务 ID、时间戳、锁操作。
  • 利用 FreeRTOS 的 vTaskListvTaskGetRunTimeStats:输出任务状态,观察是否有任务长期处于阻塞态。
  • 使用 xSemaphoreCreateMutex 后设置 uxSemaphoreucTrace 字段:但更简单的是在 take 前后打印。

3.3 中断排查技巧

  • 确认 ISR 中是否使用了 FromISR 结尾的 API:如 xSemaphoreGiveFromISR
  • 检查中断优先级是否高于 configMAX_SYSCALL_INTERRUPT_PRIORITY:若高于,则不能调用 FreeRTOS API。
  • 使用逻辑分析仪抓取中断时序:观察中断嵌套是否导致资源持有时间过长。

3.4 双核特定问题

  • 使用 portGET_CORE_ID() 记录任务所在核心:分析是否因核心间竞争导致。
  • 检查临界区实现:ESP32 的 portENTER_CRITICAL 会获取自旋锁,若在中断中调用,需使用 portENTER_CRITICAL_ISR

4. 完整代码示例

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

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

SemaphoreHandle_t mutexA, mutexB;

void taskA(void *arg) {
    while (1) {
        xSemaphoreTake(mutexA, portMAX_DELAY);
        printf("TaskA took A\n");
        vTaskDelay(pdMS_TO_TICKS(10)); // 模拟工作
        xSemaphoreTake(mutexB, portMAX_DELAY); // 等待 B
        printf("TaskA took B\n");
        xSemaphoreGive(mutexB);
        xSemaphoreGive(mutexA);
        vTaskDelay(pdMS_TO_TICKS(100));
    }
}

void taskB(void *arg) {
    while (1) {
        xSemaphoreTake(mutexB, portMAX_DELAY);
        printf("TaskB took B\n");
        vTaskDelay(pdMS_TO_TICKS(10));
        xSemaphoreTake(mutexA, portMAX_DELAY); // 等待 A,形成循环
        printf("TaskB took A\n");
        xSemaphoreGive(mutexA);
        xSemaphoreGive(mutexB);
        vTaskDelay(pdMS_TO_TICKS(100));
    }
}

void app_main() {
    mutexA = xSemaphoreCreateMutex();
    mutexB = xSemaphoreCreateMutex();
    xTaskCreatePinnedToCore(taskA, "A", 2048, NULL, 1, NULL, 0);
    xTaskCreatePinnedToCore(taskB, "B", 2048, NULL, 1, NULL, 1); // 固定不同核心
}

修复方法:统一锁顺序(如总是先 A 后 B),或使用 xSemaphoreTake 超时机制,并添加错误处理。

// 修复:统一顺序
void taskA(void *arg) {
    while (1) {
        xSemaphoreTake(mutexA, portMAX_DELAY);
        xSemaphoreTake(mutexB, portMAX_DELAY);
        // 工作
        xSemaphoreGive(mutexB);
        xSemaphoreGive(mutexA);
    }
}

void taskB(void *arg) {
    while (1) {
        xSemaphoreTake(mutexA, portMAX_DELAY); // 先 A
        xSemaphoreTake(mutexB, portMAX_DELAY);
        // 工作
        xSemaphoreGive(mutexB);
        xSemaphoreGive(mutexA);
    }
}

5. 注意事项与最佳实践

  • 绝对不要在 ISR 中调用阻塞型 API:使用 FromISR 版本,并确保中断优先级低于 configMAX_SYSCALL_INTERRUPT_PRIORITY
  • 使用互斥量时启用优先级继承:FreeRTOS 的互斥量默认支持,但需确保 configUSE_MUTEXES 为 1。
  • 避免在临界区中调用 FreeRTOS API:临界区应保持极短,且不可阻塞。
  • 双核下使用 portENTER_CRITICAL 时注意自旋锁:若在中断中,使用 portENTER_CRITICAL_ISR
  • 设计锁顺序:所有任务按相同顺序获取锁,避免循环等待。
  • 使用看门狗或超时机制:在 xSemaphoreTake 中设置超时,并检查返回值,防止无限阻塞。

结语

ESP32 双核环境下的死锁排查需要结合静态分析、动态追踪和对 FreeRTOS 内核机制的深入理解。通过本文的方法论,开发者可以系统化地定位问题,并通过合理的锁设计和中断处理来避免死锁。记住,预防胜于修复,良好的并发设计是嵌入式系统稳定性的基石。