引言

ESP32 作为一款双核 MCU,其 FreeRTOS 调度器支持对称多处理(SMP),任务可分配到 Core 0 或 Core 1。然而,多核环境下的资源竞争、优先级反转或任务长时间占用 CPU 都可能导致系统级死锁。任务看门狗(TWDT)正是用于检测这类问题的硬件辅助机制,但若配置不当,反而会引发误复位或掩盖真正故障。本文将从原理到实践,教你正确使用 TWDT。

一、双核调度与死锁的本质

1.1 双核调度模型

  • ESP32 的 FreeRTOS 使用 xTaskCreatePinnedToCore() 将任务绑定到指定核心,未绑定任务由调度器动态分配。
  • 每个核心独立运行调度器,但共享全局就绪队列和互斥量。
  • 死锁常见场景:
    • 任务 A(Core 0)持有锁,等待任务 B(Core 1)释放另一把锁,而 B 又在等 A 释放锁,形成循环等待。
    • 一个任务在临界区中执行耗时操作(如 vTaskDelay 或长循环),阻塞其他核心任务。

1.2 为什么需要 TWDT?

  • 软件看门狗(如 vTaskDelay 超时)无法检测 CPU 占用型死锁,因为任务可能仍在运行但未让出 CPU。
  • TWDT 由硬件定时器驱动,周期性检查每个已订阅任务是否被调度执行。若任务在超时时间内未获得 CPU 时间,则触发中断或复位。

二、ESP32 任务看门狗的工作原理

2.1 硬件基础

  • ESP32 使用 TIMG0 定时器作为 TWDT 时钟源,默认超时时间为 5 秒(可配置)。
  • 每个核心有一个独立的 TWDT 实例,但共享同一中断源。

2.2 订阅机制

  • 任务必须显式调用 esp_task_wdt_add() 订阅看门狗,否则不被监控。
  • 订阅后,任务需要在每次执行循环中调用 esp_task_wdt_reset() 喂狗,否则超时触发。
  • 空闲任务(IDLE)默认被订阅,用于检测调度器是否停滞。

2.3 触发与处理

  • 超时后,TWDT 会先触发中断,默认处理函数打印任务栈回溯并复位系统。
  • 可通过 esp_task_wdt_init() 配置超时时间和是否立即复位。

三、配置步骤与代码示例

3.1 初始化 TWDT

#include "esp_task_wdt.h"

void app_main() {
    // 初始化 TWDT,超时 3 秒,不立即复位(便于调试)
    esp_task_wdt_config_t twdt_config = {
        .timeout_ms = 3000,
        .idle_core_mask = (1 << 0) | (1 << 1), // 监控两个核心的空闲任务
        .trigger_panic = false,  // 触发 panic 而非复位
    };
    esp_task_wdt_init(&twdt_config);
    
    // 创建任务并订阅
    xTaskCreatePinnedToCore(task_worker, "worker", 4096, NULL, 5, NULL, 1);
}

3.2 任务内订阅与喂狗

void task_worker(void *arg) {
    // 订阅当前任务
    esp_task_wdt_add(NULL);  // NULL 表示当前任务
    
    while (1) {
        // 执行工作
        do_some_work();
        
        // 喂狗,告知 TWDT 本任务仍活跃
        esp_task_wdt_reset();
        
        // 适当让出 CPU,避免独占核心
        vTaskDelay(pdMS_TO_TICKS(10));
    }
}

3.3 处理超时事件(自定义回调)

void twdt_timeout_handler(void *arg) {
    // 打印所有任务状态,便于定位死锁
    vTaskList((char *)pcTaskListBuffer);
    ESP_LOGE("TWDT", "Timeout! Tasks:\n%s", pcTaskListBuffer);
    // 可在此处尝试恢复,如强制删除死锁任务
}

// 在初始化时注册回调
esp_task_wdt_isr_handler_t handler = twdt_timeout_handler;
esp_task_wdt_isr_register(handler, NULL);

四、避免死锁的实战策略

4.1 合理分配任务优先级和核心

  • 将高实时性任务(如传感器读取)绑定到 Core 0,将通信任务(如 WiFi)绑定到 Core 1,避免竞争。
  • 避免两个核心上的任务互相等待对方释放锁,使用 xSemaphoreGive 前检查返回值。

4.2 使用互斥量而非二值信号量

  • 互斥量支持优先级继承,可减少优先级反转导致的死锁。
  • 示例:
SemaphoreHandle_t mutex = xSemaphoreCreateMutex();

void task_a(void *arg) {
    while (1) {
        if (xSemaphoreTake(mutex, pdMS_TO_TICKS(100)) == pdTRUE) {
            // 临界区操作
            xSemaphoreGive(mutex);
        } else {
            ESP_LOGW("TASK", "Failed to take mutex");
        }
        vTaskDelay(pdMS_TO_TICKS(10));
    }
}

4.3 避免在临界区中阻塞

  • 临界区内不要调用 vTaskDelayvTaskSuspend,否则会阻塞其他任务。
  • 如果必须等待,使用 xSemaphoreTake 带超时参数。

4.4 监控任务状态

  • 定期调用 vTaskListvTaskGetRunTimeStats 查看任务运行时间,发现异常占用。

五、注意事项与常见误区

  • 误区 1:所有任务都订阅 TWDT。实际上,只有关键任务需要订阅,过多订阅会增加开销。
  • 误区 2:喂狗频率越高越好。过于频繁的喂狗会掩盖任务长时间阻塞问题,建议在任务主循环末尾喂狗。
  • 误区 3:忽略空闲任务监控。空闲任务若超时,说明调度器被阻塞,这是最严重的死锁。
  • 注意trigger_panic 设为 false 时,超时只触发中断,不会自动复位,便于调试;生产环境建议设为 true 以自动恢复。
  • 注意:在中断服务函数中不要调用 esp_task_wdt_reset(),因为中断上下文不关联任务。

六、总结

正确使用 TWDT 是 ESP32 多任务开发的关键技能。通过理解双核调度原理、合理配置看门狗、遵循避免死锁的编程规范,你可以显著提高系统的稳定性。当死锁发生时,TWDT 提供的任务栈回溯能快速定位问题,减少调试时间。记住:看门狗是最后防线,良好的设计才是根本。