一、问题现象与背景

在ESP32双核(Core 0 与 Core 1)上运行FreeRTOS时,许多开发者遇到过这样的场景:系统明明没有死循环,但任务看门狗(Task Watchdog Timer, TWDT)却频繁复位。日志中常出现:

E (12345) task_wdt: Task watchdog got triggered. The following tasks did not reset the watchdog in time:
E (12345) task_wdt:  - IDLE1 (CPU 1)
E (12345) task_wdt: Tasks currently running:
E (12345) task_wdt: CPU 0: main
E (12345) task_wdt: CPU 1: IDLE1

注意关键信息:IDLE1 任务未喂狗。这往往不是代码逻辑错误,而是对ESP32双核调度与TWDT机制理解不足导致的误触发。

二、TWDT与idle任务的关系

2.1 TWDT工作原理

ESP32的TWDT默认监控两个任务:IDLE0(Core 0 空闲任务)和 IDLE1(Core 1 空闲任务)。当某个被监控任务在超时时间内未“喂狗”(即未调用esp_task_wdt_reset()),TWDT会触发报警或复位。

  • 默认超时时间:5秒(可配置)
  • 默认监控对象:IDLE0、IDLE1
  • 喂狗方式:idle任务在空闲循环中自动调用esp_task_wdt_reset()

2.2 为什么idle任务会“饥饿”

FreeRTOS是抢占式调度器,idle任务优先级最低(0)。当高优先级任务持续占用CPU时,idle任务无法获得运行机会,自然无法喂狗。在双核系统中:

  • Core 0 通常运行Wi-Fi/蓝牙协议栈及系统任务
  • Core 1 运行用户应用任务

若用户在Core 1上创建了一个高优先级且不阻塞的任务(例如死循环轮询),IDLE1将永远得不到调度,TWDT便会误判为系统挂死。

关键点:TWDT误触发的本质是idle任务饥饿,而非系统真死。

三、复现与诊断

3.1 最小复现代码

#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_task_wdt.h"

void busy_task(void *pvParameters) {
    while (1) {
        // 纯忙等待,不阻塞、不让出CPU
        volatile int i = 0;
        i++;
    }
}

void app_main() {
    // 默认TWDT已启用,监控IDLE0/IDLE1
    xTaskCreatePinnedToCore(busy_task, "busy", 4096, NULL, 5, NULL, 1);
}

烧录后约5秒,串口输出TWDT报警,提示IDLE1未喂狗。

3.2 诊断方法

  • 查看TWDT日志中“did not reset”的任务名
  • 使用vTaskGetRunTimeStats()分析CPU占用
  • 检查高优先级任务是否包含阻塞点(vTaskDelay、队列等待等)

四、喂狗策略修正方案

4.1 方案一:为忙任务添加阻塞点(推荐)

最根本的解决方式是让高优先级任务主动让出CPU,给idle任务运行机会:

void busy_task(void *pvParameters) {
    while (1) {
        // 业务处理
        do_something();
        // 主动阻塞1 tick,让idle任务运行
        vTaskDelay(1);
    }
}

若业务不允许阻塞,可改用taskYIELD(),但需注意taskYIELD()仅触发同优先级调度,对idle任务无效。因此vTaskDelay(1)是更可靠的选择。

4.2 方案二:调整TWDT监控对象

若确实需要长时间占用CPU,可将TWDT配置为不监控idle任务,改为监控用户任务:

#include "esp_task_wdt.h"

void app_main() {
    // 取消idle任务监控
    esp_task_wdt_delete(xTaskGetIdleTaskHandleForCPU(0));
    esp_task_wdt_delete(xTaskGetIdleTaskHandleForCPU(1));

    // 将用户任务加入TWDT监控
    TaskHandle_t my_task_handle;
    xTaskCreatePinnedToCore(my_task, "my_task", 4096, NULL, 5, &my_task_handle, 1);
    esp_task_wdt_add(my_task_handle);
}

void my_task(void *pvParameters) {
    while (1) {
        // 业务处理
        do_something();
        // 在合适位置喂狗
        esp_task_wdt_reset();
        vTaskDelay(pdMS_TO_TICKS(100));
    }
}

注意:此方案要求用户任务必须周期性喂狗,否则仍会触发复位。

4.3 方案三:使用空闲钩子喂狗(不推荐)

部分开发者尝试在vApplicationIdleHook中喂狗,但该钩子仅在idle任务运行时执行,无法解决饥饿问题,反而掩盖真实故障。

五、完整修正示例

以下代码展示一个典型应用:Core 1上运行高优先级数据处理任务,同时保证TWDT不误触发。

#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_task_wdt.h"
#include "esp_log.h"

static const char *TAG = "TWDT_DEMO";

// 数据处理任务:高优先级,但周期性阻塞
void data_task(void *pvParameters) {
    while (1) {
        // 模拟数据处理(耗时约10ms)
        for (volatile int i = 0; i < 100000; i++);
        ESP_LOGI(TAG, "Data processed");
        // 关键:主动阻塞,让idle任务运行并喂狗
        vTaskDelay(pdMS_TO_TICKS(50));
    }
}

void app_main() {
    // 保持默认TWDT配置(监控IDLE0/IDLE1,超时5秒)
    // 创建任务并绑定到Core 1
    xTaskCreatePinnedToCore(data_task, "data_task", 4096, NULL, 5, NULL, 1);
    ESP_LOGI(TAG, "System started");
}

若业务要求任务不能阻塞,则采用方案二,将TWDT监控对象改为用户任务,并在任务内显式喂狗。

六、注意事项

  • 不要滥用esp_task_wdt_reset():在非监控任务中调用无效,且可能掩盖真实死锁。
  • 双核独立监控:Core 0与Core 1的idle任务需分别喂狗,任一饥饿都会触发报警。
  • 超时时间合理设置:默认5秒适合多数场景,若任务周期较长,可通过esp_task_wdt_init()调整。
  • 调试阶段可临时关闭TWDT:调用esp_task_wdt_deinit(),但量产固件务必启用。
  • 优先使用阻塞式设计:让高优先级任务主动让出CPU,是避免idle饥饿的最佳实践。

七、总结

ESP32双核下TWDT误触发的根源在于idle任务饥饿。通过分析调度机制,我们明确了两种修正路径:一是为忙任务添加阻塞点,二是调整TWDT监控对象并显式喂狗。推荐优先采用阻塞式设计,既符合FreeRTOS编程规范,又能保证系统看门狗的有效性。掌握这些策略后,开发者可彻底告别TWDT误复位,构建更稳定的嵌入式系统。