Un reinicio del task watchdog del ESP32 en un proyecto MQTT casi siempre significa que algún fragmento de código está bloqueando una tarea durante más tiempo del que permite el watchdog. Este artículo muestra cómo leer el mensaje task_wdt, identifica a los culpables habituales en los sketches MQTT y te da patrones sin bloqueos que mantienen el dispositivo conectado sin reinicios.
Qué hace realmente el task watchdog
El ESP32 ejecuta FreeRTOS. Incluso en un sketch de Arduino, setup() y loop() se ejecutan dentro de una tarea de FreeRTOS llamada loopTask, el Wi-Fi y la pila TCP/IP se ejecutan en sus propias tareas, y cada núcleo de la CPU tiene una tarea idle que se ejecuta cuando nadie más necesita la CPU.
El Task Watchdog Timer (TWDT) vigila un conjunto de tareas suscritas. Cada tarea suscrita debe "alimentar" el watchdog periódicamente. Si una de ellas no lo hace dentro del timeout, el TWDT imprime un error y, según la configuración, provoca un panic y reinicia el chip.
Qué tareas se vigilan depende de tu entorno:
| Entorno | Vigiladas por defecto | Timeout típico |
|---|---|---|
| ESP-IDF | Tareas idle de ambos núcleos (configurable en menuconfig) | 5 s |
| Arduino-ESP32 | Tarea idle del núcleo 0; loopTask solo si llamas a enableLoopWDT() |
5 s |
| AsyncTCP (usado por AsyncMqttClient) | Su tarea async_tcp, cuando se compila con su opción de watchdog activada |
Hereda el timeout del TWDT |
La tarea idle se vigila por un motivo indirecto: si nunca llega a ejecutarse, alguna otra tarea de ese núcleo está acaparando la CPU sin ceder el control. Así que un mensaje "IDLE0" no significa que la tarea idle esté rota, sino que otra cosa en el núcleo 0 está dando vueltas sin parar.
También existe un Interrupt Watchdog independiente, que salta cuando las interrupciones están desactivadas demasiado tiempo o una ISR dura demasiado. Su mensaje menciona "Interrupt wdt timeout" y tiene causas distintas, sobre todo en el código de las ISR. Este artículo trata del task watchdog.
Leer el mensaje de error
Un fallo típico tiene este aspecto:
E (25391) task_wdt: Task watchdog got triggered. The following tasks did not reset the watchdog in time:
E (25391) task_wdt: - IDLE0 (CPU 0)
E (25391) task_wdt: Tasks currently running:
E (25391) task_wdt: CPU 0: mqttTask
E (25391) task_wdt: CPU 1: loopTask
E (25391) task_wdt: Aborting.
Léelo en dos partes. La primera lista dice quién se quedó sin CPU: aquí, la tarea idle del núcleo 0. La lista "currently running" dice quién tenía la CPU en ese momento: una tarea llamada mqttTask en el núcleo 0. Esa es tu sospechosa. Si la tarea hambrienta es async_tcp, el problema es código que se ejecuta dentro de un callback de AsyncTCP o AsyncMqttClient. Si es loopTask, tu loop() se bloqueó con el watchdog del loop activado.
Activa el decodificador de excepciones en tu monitor serie (en PlatformIO, monitor_filters = esp32_exception_decoder) para convertir las direcciones del backtrace en nombres de archivo y números de línea.
Los culpables habituales de los fallos de ESP32 task watchdog MQTT
Bucles de espera activa sin ceder el control
El infractor clásico es un bucle de espera sin nada dentro:
while (WiFi.status() != WL_CONNECTED) { } // nunca cede el control
while (!mqtt.connect(clientId)) { } // reintenta a máxima velocidad
En loop(), en el núcleo 1, esto a menudo se libra de un reinicio, porque el core de Arduino no vigila por defecto la tarea idle 1. Mueve el mismo código a una tarea fijada en el núcleo 0, o activa el watchdog del loop, y se reiniciará a los cinco segundos. Un delay(10) o vTaskDelay() dentro del bucle resuelve la inanición pero no el diseño: el dispositivo sigue sin poder hacer nada más mientras espera.
Llamadas de conexión bloqueantes más largas que el timeout
El connect() de PubSubClient es bloqueante. Si el broker no es accesible, la conexión TCP subyacente y la espera del CONNACK de MQTT pueden sumar más de cinco segundos, sobre todo con TLS, donde el handshake por sí solo puede tardar segundos con una señal débil. Una función de reconexión que llama a connect() varias veces seguidas lo multiplica. Reduce el timeout del socket con mqtt.setSocketTimeout(4) e inténtalo solo una vez por llamada.
Trabajo pesado dentro de los callbacks MQTT
Con AsyncMqttClient, onMessage se ejecuta dentro de la tarea async_tcp. Analizar un documento JSON grande, escribir en flash, manejar una pantalla o llamar a delay() ahí bloquea la pila de red y puede disparar el watchdog de esa tarea. Con PubSubClient, el callback se ejecuta dentro de mqtt.loop(), así que el trabajo pesado ahí bloquea el loop. En ambos casos, copia el payload, activa un flag o envíalo a una cola, y haz el trabajo en otro sitio.
Lecturas de sensores y escrituras en flash largas y bloqueantes
Algunas librerías de sensores sondean con esperas activas, y una descarga OTA o una escritura grande en flash pueden durar segundos. No hay problema si van en su propia tarea que alimenta el watchdog, pero sí en la misma tarea que atiende MQTT.
Tormentas de reconexión tras perder la red
Cuando se cae el Wi-Fi, un sketch ingenuo llama a WiFi.begin() y mqtt.connect() una detrás de otra en un bucle cerrado. Cada llamada bloquea un poco, el bucle nunca hace una pausa y al final salta el watchdog. El backoff exponencial resuelve tanto el problema del watchdog como la carga sobre el broker.
Patrón MQTT sin bloqueos para loop()
Este sketch mantiene loop() corto en todo momento. Cada intento de conexión ocurre como mucho una vez por intervalo de backoff, el callback solo copia datos y el trabajo se ejecuta después en loop(). Se conecta al área pública de broker.connectmq.com.
#include <WiFi.h>
#include <PubSubClient.h>
const char* WIFI_SSID = "YOUR_WIFI";
const char* WIFI_PASS = "YOUR_WIFI_PASSWORD";
const char* CMD_TOPIC = "connectmq/demo/b52e07/cmd";
const char* TELEMETRY_TOPIC = "connectmq/demo/b52e07/telemetry";
WiFiClient net;
PubSubClient mqtt(net);
String clientId;
volatile bool commandPending = false;
char commandBuf[128];
unsigned long nextMqttAttempt = 0;
unsigned long mqttBackoff = 1000;
unsigned long lastPublish = 0;
void onMessage(char* topic, byte* payload, unsigned int length) {
size_t n = min((size_t)length, sizeof(commandBuf) - 1);
memcpy(commandBuf, payload, n);
commandBuf[n] = '\0';
commandPending = true; // nada de trabajo pesado aquí
}
void handleCommand(const char* cmd) {
Serial.printf("Handling command: %s\n", cmd);
// aquí el trabajo lento está bien, siempre que quede por debajo del timeout del watchdog
}
void maintainMqtt() {
if (WiFi.status() != WL_CONNECTED) return; // el Wi-Fi se reconecta solo
if (mqtt.connected()) { mqtt.loop(); return; }
if (millis() < nextMqttAttempt) return;
if (mqtt.connect(clientId.c_str())) {
mqtt.subscribe(CMD_TOPIC);
mqttBackoff = 1000;
} else {
nextMqttAttempt = millis() + mqttBackoff;
mqttBackoff = min(mqttBackoff * 2, 30000UL);
}
}
void setup() {
Serial.begin(115200);
enableLoopWDT(); // vigilar también loop(), para que los bloqueos aparezcan en las pruebas
WiFi.mode(WIFI_STA);
WiFi.setAutoReconnect(true);
WiFi.begin(WIFI_SSID, WIFI_PASS);
clientId = "esp32-wdt-" + String((uint32_t)ESP.getEfuseMac(), HEX);
mqtt.setServer("broker.connectmq.com", 1883);
mqtt.setSocketTimeout(4); // mantener cada connect bloqueante muy por debajo de 5 s
mqtt.setCallback(onMessage);
}
void loop() {
maintainMqtt();
if (commandPending) {
commandPending = false;
handleCommand(commandBuf);
}
if (mqtt.connected() && millis() - lastPublish > 10000) {
lastPublish = millis();
char payload[64];
snprintf(payload, sizeof(payload), "{\"uptime\":%lu,\"heap\":%u}",
millis() / 1000, ESP.getFreeHeap());
mqtt.publish(TELEMETRY_TOPIC, payload);
}
}
El core de Arduino alimenta automáticamente el watchdog del loop cada vez que loop() retorna, así que mientras una pasada por loop() se mantenga muy por debajo del timeout, activarlo no cuesta nada y detecta regresiones pronto. Encenderlo durante el desarrollo es una forma barata de encontrar la llamada bloqueante antes de que llegue al campo.
Ejecutar MQTT en su propia tarea de FreeRTOS
Si prefieres una tarea MQTT dedicada, suscríbela al watchdog y aliméntalo en cada iteración. La API esp_task_wdt de ESP-IDF 5 (usada por Arduino-ESP32 3.x) tiene este aspecto:
#include <esp_task_wdt.h>
void mqttTask(void* arg) {
esp_task_wdt_add(NULL); // vigilar esta tarea
for (;;) {
maintainMqtt(); // debe retornar rápido
esp_task_wdt_reset(); // alimentar el watchdog
vTaskDelay(pdMS_TO_TICKS(10)); // ceder siempre el control
}
}
void setup() {
// ... configuración de Wi-Fi y mqtt como arriba ...
xTaskCreatePinnedToCore(mqttTask, "mqttTask", 6144, NULL, 1, NULL, 1);
}
Mantén baja la prioridad (1 es la misma que loopTask) salvo que tengas un motivo para subirla; una tarea de alta prioridad que nunca se bloquea deja sin CPU a todo lo que está por debajo. Fíjala al núcleo 1 si quieres mantenerte fuera del núcleo donde se ejecuta el Wi-Fi. Si el cliente MQTT se usa desde más de una tarea, protégelo con un mutex, porque PubSubClient no es thread-safe. La guía de ESP32 FreeRTOS MQTT cubre con más detalle las colas y el diseño de tareas.
¿Deberías subir el timeout del watchdog?
A veces, pero trátalo como último recurso. Subir el timeout oculta el código bloqueante en lugar de arreglarlo, y el dispositivo tarda más en recuperarse de un cuelgue real. Los casos legítimos son operaciones largas e inevitables, como una escritura OTA en flash. En ESP-IDF 5 puedes reconfigurar el TWDT en tiempo de ejecución:
esp_task_wdt_config_t cfg = {
.timeout_ms = 15000,
.idle_core_mask = (1 << 0), // seguir vigilando IDLE0
.trigger_panic = true,
};
esp_task_wdt_reconfigure(&cfg);
Un enfoque más limpio para una única operación larga es alimentar el watchdog desde dentro de ella, por ejemplo desde un callback de progreso de la OTA, y dejar en paz el timeout global. Nunca desactives por completo el watchdog en producción. Es lo único que reinicia un dispositivo atascado en un interbloqueo en el campo.
Lista de comprobación para resolver problemas
- Lee qué tarea se quedó sin CPU y cuál se estaba ejecutando; ese par te dice dónde buscar.
- Decodifica el backtrace con el decodificador de excepciones para obtener la línea exacta.
- Busca en el código bucles
whilesindelay(),yield()nivTaskDelay(). - Ajusta
mqtt.setSocketTimeout()a unos pocos segundos y elimina los bucles de reintento alrededor deconnect(). - Saca de los callbacks MQTT el análisis de JSON, las escrituras en flash y las actualizaciones de pantalla.
- Añade backoff exponencial a las reconexiones de Wi-Fi y MQTT.
- Activa
enableLoopWDT()durante las pruebas para que los bloqueos enloop()aparezcan pronto. - Vigila el uptime del dispositivo por MQTT: un contador que se reinicia una y otra vez es un reinicio por watchdog o por brownout.
Publicar uptime y esp_reset_reason() tras cada arranque es un hábito sencillo que convierte reinicios invisibles en datos. Muchos problemas de conexión que parecen fallos del watchdog son en realidad problemas de red de fondo; si el dispositivo nunca llega a conectar, revisa primero por qué un ESP32 no puede conectarse a un broker MQTT, y para la estrategia de reconexión consulta la guía de reconexión MQTT en ESP32.
Monitoriza tus dispositivos en ConnectMQ
El sketch de prueba de arriba se ejecuta contra el servidor MQTT público gratuito de ConnectMQ, perfecto para reproducir un reinicio en tu mesa, aunque no apto para datos de producción. Para una flota, un broker MQTT gestionado privado de ConnectMQ te permite ver la telemetría de cada dispositivo en el explorador de topics en vivo y configurar reglas de alerta por umbral sobre valores como el heap libre, de modo que un dispositivo que empieza a reiniciarse destaca enseguida. Puedes crear uno en el plan gratuito Serverless en app.connectmq.com sin tarjeta de crédito. Si todavía estás decidiendo dónde alojar tu broker, la guía del broker MQTT en la nube compara las opciones, y el tutorial de IoT con ESP32 muestra de principio a fin un proyecto completo de dispositivo sin bloqueos.
Preguntas frecuentes
¿Qué significa "Task watchdog got triggered" con IDLE0?
Una tarea que se ejecutaba en el núcleo 0 mantuvo ocupada la CPU durante más tiempo que el timeout sin ceder el control, así que la tarea idle nunca se ejecutó. La línea "Tasks currently running" nombra al probable culpable.
¿Reinicia delay() el watchdog?
delay() cede el control a otras tareas, lo que permite que se ejecute la tarea idle, así que evita la inanición de IDLE. No alimenta una tarea que hayas suscrito con esp_task_wdt_add(); esa tarea debe llamar ella misma a esp_task_wdt_reset().
¿Por qué AsyncMqttClient dispara el watchdog de async_tcp?
Sus callbacks se ejecutan dentro de la tarea async_tcp. El trabajo largo ahí bloquea la tarea, así que mantén cortos los callbacks y pasa el payload a loop() o a una tarea aparte mediante un flag o una cola.
¿Es seguro desactivar el task watchdog?
No en producción. Es el mecanismo que recupera un dispositivo de un cuelgue. Arregla el código bloqueante y sube el timeout solo para operaciones realmente largas.



