看门狗与异常恢复:产品不死机的底线
- 搞懂产品为什么必须有看门狗:无人值守现场卡死了得自己复位重启
- 用 esp_task_wdt_init/esp_task_wdt_add/esp_task_wdt_reset 把一个任务挂上 TWDT 并按时喂狗
- 亲手制造一次"卡住不喂狗",看串口打出 Task watchdog got triggered 并自动复位
- 看懂 Guru Meditation Error + backtrace,知道 esp_restart 主动重启和 coredump 各管什么
设想一个场景:你的设备发货了,装在客户家天花板上、机柜深处、或者某个野外的杆子上。某天它跑着跑着,因为一个偶发的网络异常、一次内存踩踏、或者某段代码进了死循环——卡死了。屏幕黑了、灯不亮了、网断了。
在你的开发桌上,这种时候你顺手就按一下板子上的 EN/RESET 键,它就活了。可现在它在客户家里,没有人会去按那个键。客户只会觉得"这破玩意又坏了",然后给你发差评、打售后电话。
这就是看门狗(Watchdog)存在的全部理由:让设备在卡死时自己爬起来,不依赖任何人去按复位键。 它是产品级固件和"桌面玩具 demo"之间一条很硬的分界线——demo 卡了你重启就行,产品卡了必须自愈。这一节就把这条底线焊死。
读这篇前,你需要已经跑通过FreeRTOS 任务——会用 xTaskCreate 建任务、知道 vTaskDelay 是在"让出 CPU"、见过 Task watchdog got triggered 这行报错(上一篇我们故意把 vTaskDelay 删掉触发过它)。本篇把那次"意外触发"反过来,主动、可控地把它用成产品的保命机制。
[!warning] 看门狗是可靠性的最后一道闸,不是"装上就万事大吉"的免死金牌。它能在卡死后救你一次复位,但它救不了"反复在同一个地方卡死→复位→再卡死"的死循环重启(boot loop)——那只会让设备一秒重启一次更难用。看门狗是兜底,写不卡死的代码才是正道;下面会讲怎么把这两件事配合好。
第一步:先把任务看门狗跑通
ESP-IDF 里最常用、也最该先掌握的是任务看门狗 TWDT(Task Watchdog Timer)。它的逻辑很朴素:你把某个任务"登记"到看门狗名下,约定这个任务必须在规定时间内(默认几秒)"喂"一次狗;只要它按时喂,看门狗就安静待着;一旦超时没喂,看门狗判定"这任务八成卡死了",直接触发系统复位。
下面这段是完整可烧录的程序——它建一个工作任务 worker_task,把自己挂到 TWDT 上,然后在循环里按时喂狗。直接放进工程的 main/main.c:
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_task_wdt.h" // 任务看门狗的头文件
#include "esp_log.h"
static const char *TAG = "wdt";
void worker_task(void *arg)
{
// ① 把"当前任务"登记到 TWDT 名下,从此它必须按时喂狗
ESP_ERROR_CHECK(esp_task_wdt_add(NULL)); // NULL = 登记调用者自己
int count = 0;
for (;;) {
// ... 这里是任务真正要干的活(采集、上报、控制……)...
ESP_LOGI(TAG, "干活中 #%d,马上喂狗", count++);
// ② 喂狗:告诉看门狗"我还活着、没卡死"
esp_task_wdt_reset();
vTaskDelay(pdMS_TO_TICKS(1000)); // 睡 1 秒,让出 CPU
}
}
void app_main(void)
{
// ③ 初始化 TWDT:超时 3 秒,超时即触发系统复位
esp_task_wdt_config_t twdt_cfg = {
.timeout_ms = 3000, // 喂狗间隔上限 3 秒
.idle_core_mask = (1 << 0) | (1 << 1), // 双核的 idle 任务也挂上(默认行为)
.trigger_panic = true, // 超时触发 panic→复位(而非只警告)
};
// 注意:ESP-IDF 可能已在启动时初始化过 TWDT,重复 init 会返回 ESP_ERR_INVALID_STATE,
// 这里用 esp_task_wdt_reconfigure 更稳;首次自己管就用 init。按你的 sdkconfig 来。
esp_err_t err = esp_task_wdt_init(&twdt_cfg);
if (err == ESP_ERR_INVALID_STATE) {
esp_task_wdt_reconfigure(&twdt_cfg); // 已初始化过,改配置即可
}
xTaskCreate(worker_task, "worker", 4096, NULL, 5, NULL);
}
在工程目录下一条命令编译、烧录、看日志:
idf.py build flash monitor
(第一次用要先 idf.py set-target esp32s3 选好芯片。按 Ctrl + ] 退出监视。menuconfig 里 Component config → ESP System Settings → Task Watchdog 那几项能改默认超时、是否开机自启。)
你应该看到什么
串口里每秒滚出一行"干活中",计数往上走,永远不复位——因为任务老老实实在 esp_task_wdt_reset() 喂狗:
I (1010) wdt: 干活中 #0,马上喂狗
I (2010) wdt: 干活中 #1,马上喂狗
I (3010) wdt: 干活中 #2,马上喂狗
I (4010) wdt: 干活中 #3,马上喂狗
这就是正常状态:看门狗在背后默默盯着,任务每秒喂一次、远没到 3 秒超时线,所以它一声不吭。没消息就是好消息——看门狗安静的时候,你甚至感觉不到它存在。
这里有个你上一篇可能已经撞过的关联点:ESP-IDF 默认就把两个核的 idle(空闲)任务挂在 TWDT 上。这就是为什么——只要你某个任务忙等死循环、从不 vTaskDelay 让出 CPU,idle 任务永远轮不上、喂不了狗,几秒后系统就报 Task watchdog got triggered 复位。上一篇那次"意外"触发,根子就在这。本篇是把这个机制主动用起来给你自己的任务保命。
第二步:故意卡住,亲眼看它复位
光看它正常跑没有说服力。看门狗的价值在"卡死时它真的会救你",所以我们主动制造一次卡死,验证它确实会复位。把 worker_task 改成这样——跑几轮正常喂狗后,故意进一个不喂狗的死循环:
void worker_task(void *arg)
{
ESP_ERROR_CHECK(esp_task_wdt_add(NULL));
int count = 0;
for (;;) {
ESP_LOGI(TAG, "干活中 #%d", count++);
esp_task_wdt_reset();
vTaskDelay(pdMS_TO_TICKS(1000));
if (count == 3) {
// ★ 模拟"卡死":进一个忙等死循环,既不喂狗、也不让出 CPU
ESP_LOGW(TAG, "故意卡住,从现在起不再喂狗……");
while (1) { /* 空转,烧 CPU,绝不 vTaskDelay、绝不喂狗 */ }
}
}
}
你应该看到什么
前三行正常滚出,然后任务卡进 while(1)、不再喂狗。大约 3 秒后(你设的 timeout_ms),看门狗超时,串口炸出一段告警,紧接着系统自动复位、程序从头重启:
I (1010) wdt: 干活中 #0
I (2010) wdt: 干活中 #1
I (3010) wdt: 干活中 #2
W (3010) wdt: 故意卡住,从现在起不再喂狗……
E (6010) task_wdt: Task watchdog got triggered. The following tasks/users did not reset the watchdog in time:
E (6010) task_wdt: - worker (CPU 0)
E (6010) task_wdt: Tasks currently running:
E (6010) task_wdt: CPU 0: worker
...
abort() was called at PC 0x... on core 0
Backtrace: 0x... 0x... 0x...
...
I (0) cpu_start: Multicore app ← 看到这行说明它真的重启了
I (xxx) wdt: 干活中 #0 ← 计数清零,从头开始跑
盯住这段日志想一下,这正是产品现场会发生的事的缩影:
Task watchdog got triggered+ 哪个任务没喂狗(worker (CPU 0))——看门狗精确点名了卡死的任务。abort()+Backtrace——系统主动崩溃并打出调用栈,告诉你卡在哪。- 重启日志
cpu_start+ 计数从#0重来——设备自己爬起来了,全程没人按任何键。
这就是看门狗的全部价值:在你的现场设备卡死的那一刻,它替你按下了那个没人去按的复位键。
[!warning] 演示里我们用
while(1)模拟卡死。真实产品里你绝不该写这种忙等——它纯烧 CPU、不让出。这个 demo 只是为了把"卡死→看门狗复位"这条链路演给你看一遍、让你以后一眼认得Task watchdog got triggered。真实卡死往往是别的形态:等一个永远不来的信号量、死锁、外设读卡住,看门狗对这些一视同仁地兜底。
第三步:把每个 API 和参数讲透
上面用到的几个调用,逐个拆开看,省得你抄完不知其所以然。
esp_task_wdt_init —— 配置看门狗
esp_task_wdt_config_t cfg = {
.timeout_ms = 3000, // 喂狗超时(毫秒)
.idle_core_mask = (1 << 0) | (1 << 1), // 哪些核的 idle 任务自动挂 TWDT
.trigger_panic = true, // 超时是 panic 复位 还是 只打警告
};
esp_task_wdt_init(&cfg);
timeout_ms:喂狗间隔的上限。设太短,任务里一个稍微长点的正常操作(比如一次慢 I/O)就误触发复位;设太长,真卡死了要等很久才救。常见从5000(5 秒)起步,按你任务最长那段合法耗时往上留余量。trigger_panic = true:超时直接 panic→复位(产品要的就是这个)。设false则只在串口打警告、不复位——调试期想"看到卡死但别打断我"时才这么用。idle_core_mask:决定哪几个核的 idle 任务被自动监视。保持默认即可,它是"任务忙等会触发看门狗"那条规则的来源。
esp_task_wdt_add(handle) —— 把任务登记进来
esp_task_wdt_add(NULL); // 登记"当前任务"自己
esp_task_wdt_add(some_task_handle); // 登记另一个任务(传它的 TaskHandle_t)
add 之后,这个任务就有了喂狗义务——从此它必须按时 reset,否则看门狗找它算账。传 NULL 是"登记我自己",最常用。要监视别的任务,就把 xTaskCreate 第六个参数回填的那个句柄传进来(句柄怎么来的,回看上一篇)。
esp_task_wdt_reset() —— 喂狗
esp_task_wdt_reset(); // "我还活着,把我的计时器清零"
这是核心动作:在登记过的任务循环里,定时调用它,等于跟看门狗报平安"我没卡死"。每调一次,这个任务的超时计时器清零重新计。喂狗要放在任务的"主干路径"上——确保任务只要在正常推进,就一定会路过这行;别把它藏在某个偶尔才走的 if 分支里,那样正常跑也可能漏喂、误触发。
喂狗位置是最容易栽的地方。别把 esp_task_wdt_reset() 放在一段"可能很长"的操作之前就完事——如果那段操作本身就会超时(比如阻塞读一个挂了的传感器),你喂完狗就进去卡住,照样超时复位。正确做法是:要么把长操作拆成小段、中间穿插喂狗;要么给那段操作本身设超时、别让它无限阻塞。喂狗不是"开头喂一次",是"持续报平安"。
第四步:为什么深一层——看门狗家族与 panic 全景
往下挖一层,你会对 ESP32-S3 整套"自愈"机制通透很多。
看门狗其实有好几条狗,分工不同。 你刚用的 TWDT 是"任务级"的,盯的是"某个任务是否按时喂狗"。除它之外还有一条更底层的——
中断看门狗 IWDT(Interrupt Watchdog Timer):它盯的不是任务,而是中断。一句话说清它的职责:如果某个中断服务程序(ISR)或临界区跑太久、长时间不让调度器运行,IWDT 就触发 panic。 为什么需要它单管一条线?因为 TWDT 靠 idle 任务喂狗,可万一是中断或临界区把整个系统卡住了、连任务调度都停了,TWDT 自己都喂不了狗——这种"更深的卡死"得靠 IWDT 来兜。你平时基本不用碰 IWDT,但要知道:ISR 里不能干重活、不能长时间关中断,否则撞的就是它。
panic 和 Guru Meditation Error 是什么? 当芯片遇到无法继续的致命错误(看门狗超时、访问非法地址、除零、栈溢出……),它进入 panic handler,在串口打出那段标志性的:
Guru Meditation Error: Core 0 panic'ed (LoadProhibited). Exception was unhandled.
...
Backtrace: 0x4008... 0x4008... 0x4200...
Guru Meditation Error 是 ESP 系列致敬早年 Amiga 的彩蛋名,看见它就一个意思:程序撞墙了。括号里的 LoadProhibited/StoreProhibited/IllegalInstruction 是死因类型(多半是野指针/空指针解引用)。下面的 Backtrace 是崩溃那一刻的调用栈——一串地址,配合 idf.py monitor(它会自动把地址翻译成函数名+行号)或 addr2line,就能定位到崩在你代码的哪一行。这是固件调试最重要的线索,别一看到报错就慌着重烧,先把这段 backtrace 截下来读。
主动重启:esp_restart()。 看门狗是"被动"自愈(卡死了被强制复位)。有时你想主动重启——比如检测到 WiFi 连续重连失败 N 次、或者收到云端的"远程重启"指令、或 OTA 升级完要重启加载新固件。这时调用:
#include "esp_system.h"
esp_restart(); // 立刻软复位,干净重启,相当于按了一下 EN 键
它不像看门狗那样"崩着重启",而是有序、可控地重启。产品里常见的兜底逻辑是:自己监控关键指标(连不上网、内存快耗尽、状态机进了死角),到阈值就 esp_restart() 主动重来一次,而不是干等看门狗那一下硬复位。
崩溃现场怎么留证据:coredump。 设备在客户现场崩了,你拿不到当时的串口怎么办?ESP-IDF 的 coredump 能在 panic 那一刻把崩溃现场(寄存器、栈、关键内存)转储到 flash 一块专门分区,事后导出来用工具还原成"案发现场"。这块我们在用 AI 调试 ESP-IDF 崩溃那篇专门讲怎么开启、怎么导出、怎么把它喂给 AI 帮你定位——这里你先知道有这么个"黑匣子"即可。
故障排查:看门狗/崩溃相关的现象,按这个查
看门狗和 panic 的日志现象很有特征,照这张表认:
| 现象 / 日志 | 最可能的原因 | 怎么办 |
|---|---|---|
Task watchdog got triggered + 点名某任务 (CPU x) |
那个任务忙等/长操作没喂狗,超过 timeout_ms |
在任务主干加 esp_task_wdt_reset();把长操作拆段穿插喂狗;忙等改 vTaskDelay |
Task watchdog got triggered 但点名的是 IDLE/IDLE0 |
有更高优先级任务一直霸着 CPU 不让出,idle 喂不了狗 | 给那个霸占任务加 vTaskDelay/降优先级;别用空转死等 |
Guru Meditation Error ... LoadProhibited/StoreProhibited + backtrace |
野指针/空指针解引用(读/写非法地址) | 读 backtrace 定位到行;查那行用到的指针是不是 NULL 或已释放 |
Guru Meditation Error ... IllegalInstruction |
跳到了非法地址执行(函数指针乱了/栈被踩坏) | 多半是栈溢出连带:把相关任务栈调大;查有没有越界写数组 |
| 设备一秒一重启、串口刷 boot 日志循环(boot loop) | 启动早期就崩 / 反复在同一处卡死被看门狗反复复位 | 看每次重启前最后几行定位崩点;临时把 timeout_ms 调大或关 trigger_panic 先把日志看全 |
| 正常跑着突然复位、但没有任何 panic/wdt 日志 | 多半是硬件问题:供电不稳/电流不够导致掉电复位(brownout) | 看有没有 Brownout detector was triggered;换稳的电源/线、加电容;别用劣质 USB 线供电 |
改了 timeout_ms 后正常跑也偶尔复位 |
超时设得太短,某次合法的长操作超了 | 把 timeout_ms 往上调,留够任务最长合法耗时的余量 |
最坑的是boot loop(反复重启):看门狗复位后又在同一处崩,复位→崩→复位……一秒一轮,设备彻底没法用,连日志都刷得太快看不清。这时别死磕看门狗——它在"正确地"做它的事,问题在你的代码每次都崩。应对:临时把 trigger_panic 设 false 或 timeout_ms 调大,让设备别急着重启,把完整的崩溃日志/backtrace 看全,定位真正的崩点。看门狗治标,改 bug 才治本。
变体:两个产品里真会用到的玩法
变体一:多个关键任务一起监视
产品里往往有好几个关键任务(采集、上报、控制),你想确保它们全都没卡死——任何一个卡了都该复位。做法是每个关键任务各自 add + 在自己循环里 reset:
void sensor_task(void *arg)
{
esp_task_wdt_add(NULL); // 采集任务登记
for (;;) {
read_sensor(); // 干自己的活
esp_task_wdt_reset(); // 各喂各的狗
vTaskDelay(pdMS_TO_TICKS(500));
}
}
void report_task(void *arg)
{
esp_task_wdt_add(NULL); // 上报任务也登记
for (;;) {
upload_data();
esp_task_wdt_reset();
vTaskDelay(pdMS_TO_TICKS(2000));
}
}
只要任何一个登记过的任务超时没喂,看门狗就复位整机,并在日志里点名是哪个。注意:登记的任务越多,越要保证它们各自的循环周期都明显短于 timeout_ms,否则正常跑也会误触发。
变体二:用主动重启做"软自愈"
有些故障不到"卡死"程度,但已经没法正常工作了——比如 WiFi 怎么都连不上。与其干等看门狗,不如主动重启:
static int wifi_fail_count = 0;
void on_wifi_disconnected(void)
{
wifi_fail_count++;
ESP_LOGW(TAG, "WiFi 断开第 %d 次", wifi_fail_count);
if (wifi_fail_count >= 10) { // 连续失败 10 次,主动重来
ESP_LOGE(TAG, "WiFi 反复连不上,主动重启自愈");
vTaskDelay(pdMS_TO_TICKS(1000)); // 给日志一点时间打出去
esp_restart(); // 有序软复位
}
}
这种"软自愈"比硬复位更可控——你能在重启前打日志、存状态、做清理。产品级固件常是"主动监控 + 主动重启"打头阵,看门狗在最后兜底,两层保险。
本篇代码为参考实现,需结合你所用的最新 ESP-IDF 文档与实物自校。尤其
esp_task_wdt_init的结构体字段、是否需要先reconfigure、idle_core_mask的取值、TWDT 是否开机自启,随 ESP-IDF 版本和sdkconfig配置会有差异,以官方 Watchdogs 文档 为准。
动手挑战
别只看,动手改一个:
- 把第一步的
timeout_ms改成5000,然后在worker_task的循环里加一段vTaskDelay(pdMS_TO_TICKS(4000))(模拟一次 4 秒的合法长操作),但喂狗放在这段延时之前。观察它是否安然无事;再把timeout_ms改回3000,看是不是因为 4 秒 > 3 秒超时而被复位——亲手体会"喂狗间隔必须短于超时"这条铁律。 - 实现变体二:建一个任务,每秒打一行日志、计数到 8 时调用
esp_restart()。观察串口:计数 0→7 后打出重启日志、cpu_start重新出现、计数从 0 重来。再对比一下——esp_restart()的重启日志里没有Guru Meditation Error,它是干净重启,不是崩溃;体会主动重启和看门狗强制复位在日志上的区别。
卡住了?把你的代码、idf.py monitor 的完整日志(尤其 Task watchdog got triggered 那段点名、或 Guru Meditation Error 的 backtrace)一起发给 AI,说清楚"我看到了什么、期望看到什么",它定位会准得多。读 backtrace 这件事 AI 尤其在行。
小结 · 你现在掌握了什么
- 你懂了产品为什么必须有看门狗:设备在没人值守的现场卡死时,看门狗替你按下那个没人去按的复位键,让它自己爬起来。
- 你会用
esp_task_wdt_init(配超时/panic)+esp_task_wdt_add(NULL)(登记任务)+esp_task_wdt_reset()(按时喂狗)把一个任务挂上 TWDT,并亲手制造了一次"卡住不喂狗→Task watchdog got triggered→自动复位"。 - 你知道了 IWDT 单管"中断/临界区跑太久"这条更深的卡死线,所以 ISR 里不能干重活。
- 你能读
Guru Meditation Error+ backtrace 定位崩点,会用esp_restart()做有序主动重启,也知道 coredump 是崩溃黑匣子(详见 9.8 AI 调试那篇)。 - 你认得看门狗/崩溃/boot loop/掉电复位这几类故障的日志特征,明白看门狗只兜底、写不卡死的代码才治本。
现在你的固件能在卡死时自愈了,可还有一类"慢性病"看门狗管不了:内存。任务建多了、缓冲区分配了不释放、堆一点点被吃光——它不会当场卡死,而是跑几小时、几天后才在某次 malloc 失败时崩,最难查。下一步学FreeRTOS 内存:堆、栈与内存泄漏排查,把这块"慢性病"防住,固件才算真正能在现场长期跑。