ESP32 y Arduino

Cómo solucionar los reinicios del task watchdog del ESP32 en proyectos MQTT

Por qué los sketches MQTT del ESP32 provocan reinicios del task watchdog, cómo leer el mensaje task_wdt y cómo reestructurar bucles de reconexión, callbacks y código bloqueante para que el watchdog no salte.

Monitor serie del ESP32 mostrando un error task_wdt en un proyecto ESP32 task watchdog MQTT
Pruébalo en tu propio broker MQTTConsigue un broker MQTT privado y gestionado con el plan Free y empieza con ejemplos de código listos para usar.
Pruébalo gratis Ejemplos de código Sin tarjeta

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

  1. Lee qué tarea se quedó sin CPU y cuál se estaba ejecutando; ese par te dice dónde buscar.
  2. Decodifica el backtrace con el decodificador de excepciones para obtener la línea exacta.
  3. Busca en el código bucles while sin delay(), yield() ni vTaskDelay().
  4. Ajusta mqtt.setSocketTimeout() a unos pocos segundos y elimina los bucles de reintento alrededor de connect().
  5. Saca de los callbacks MQTT el análisis de JSON, las escrituras en flash y las actualizaciones de pantalla.
  6. Añade backoff exponencial a las reconexiones de Wi-Fi y MQTT.
  7. Activa enableLoopWDT() durante las pruebas para que los bloqueos en loop() aparezcan pronto.
  8. 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.

Artículos relacionados

Ejemplo de ESP-IDF MQTT: crea un cliente esp-mqtt desde cero

Construye un proyecto ESP-IDF desde una carpeta vacía con el componente esp-mqtt: configuración de Wi-Fi en modo estación, el manejador de eventos MQTT, publicación y suscripción, propiedades de MQTT 5 y TLS con el bundle de certificados.

9 min de lectura

Todos los artículos →

Tu broker MQTT privado, a unos pocos clics

Crea una cuenta, obtén las credenciales de tu broker y conecta tu primer dispositivo, sin servidores que instalar, parchear ni escalar.