Un diseño ESP32 FreeRTOS MQTT coloca el muestreo del sensor y la publicación en red en tareas separadas, de modo que una reconexión Wi-Fi lenta nunca retrasa una lectura. En este tutorial crearás un sketch con una tarea de sensor, una tarea MQTT y una cola entre ambas, lo probarás contra el área pública gratuita de broker.connectmq.com y después lo pasarás a TLS con tus propias credenciales.
Por qué usar tareas de FreeRTOS para MQTT en el ESP32
La mayoría de los sketches de iniciación lo hacen todo dentro de loop(): leer el sensor, comprobar el Wi-Fi, llamar a client.loop(), publicar y luego delay(). Funciona hasta que la red se porta mal. Una llamada a connect() contra un broker inaccesible puede bloquear varios segundos, y durante ese tiempo no se toma ninguna muestra. Si estás contando pulsos, promediando una señal analógica o vigilando un umbral, esos huecos importan.
El core de Arduino para ESP32 ya se ejecuta sobre FreeRTOS. loop() no es más que una tarea fijada al núcleo 1. Nada te impide crear más tareas y, en cuanto lo haces, la temporización de un trabajo deja de depender del otro:
- La tarea de sensor se despierta con un periodo fijo mediante
vTaskDelayUntil(), lee el hardware y deja una pequeña estructura en una cola. Nunca toca la red. - La tarea MQTT es dueña del Wi-Fi y del cliente MQTT. Vacía la cola, publica, atiende los keep-alives y se reconecta cuando hace falta.
- La cola es el único objeto compartido. Las colas de FreeRTOS copian los datos y son seguras entre tareas, así que no necesitas un mutex para las lecturas.
Hay una regla que hace robusto este diseño: solo una tarea llama al cliente MQTT. PubSubClient no es thread-safe, y llamar a publish() desde dos tareas a la vez corrompe su búfer interno. Canaliza todo a través de la cola.
Qué necesitas
- Cualquier placa ESP32 con dos núcleos (ESP32 DevKit, ESP32-S3). Los chips de un solo núcleo como el ESP32-C3 o el ESP32-C6 también funcionan si fijas ambas tareas al núcleo 0.
- Arduino IDE 2.x o PlatformIO con el core esp32 3.x.
- La librería PubSubClient de Nick O'Leary.
- Opcional: cualquier sensor. El sketch simula temperatura y humedad para que funcione con la placa sola.
mosquitto_suben tu equipo para las pruebas.
Distribución de tareas y prioridades
| Tarea | Núcleo | Prioridad | Stack | Función |
|---|---|---|---|---|
sensorTask |
1 | 3 | 3072 bytes | Muestrear cada 1 s y enviar a la cola |
mqttTask |
0 | 2 | 6144 bytes | Wi-Fi, conexión MQTT, publicación, keep-alive |
loop() de Arduino |
1 | 1 | por defecto | Eliminada, no se necesita |
La tarea de sensor recibe la prioridad más alta porque es corta y crítica en tiempo; se ejecuta, encola un elemento y se duerme. La tarea MQTT se ejecuta en el núcleo 0 junto a la pila Wi-Fi, que también reside allí, y se encarga del trabajo lento. En el core de Arduino para ESP32 los tamaños de stack se indican en bytes, no en palabras como en FreeRTOS estándar.
El sketch completo de ESP32 FreeRTOS MQTT
Sustituye las credenciales Wi-Fi y la parte aleatoria del prefijo de topic, y súbelo.
#include <Arduino.h>
#include <WiFi.h>
#include <PubSubClient.h>
// ---------- configuración ----------
const char* WIFI_SSID = "YOUR_WIFI_SSID";
const char* WIFI_PASSWORD = "YOUR_WIFI_PASSWORD";
const char* MQTT_HOST = "broker.connectmq.com";
const uint16_t MQTT_PORT = 1883;
const char* TOPIC_PREFIX = "connectmq/demo/k3v9q1"; // hazlo único
// ---------- objetos compartidos ----------
struct Reading {
uint32_t seq;
uint32_t uptimeMs;
float temperature;
float humidity;
};
QueueHandle_t readingQueue;
WiFiClient net;
PubSubClient mqtt(net);
String clientId;
// ---------- tarea de sensor ----------
void sensorTask(void* param) {
const TickType_t period = pdMS_TO_TICKS(1000);
TickType_t lastWake = xTaskGetTickCount();
uint32_t seq = 0;
for (;;) {
Reading r;
r.seq = seq++;
r.uptimeMs = millis();
// Sustitúyelo por la lectura de un sensor real (DHT22, BME280, ADC...)
r.temperature = 21.0f + (esp_random() % 400) / 100.0f;
r.humidity = 45.0f + (esp_random() % 1000) / 100.0f;
// No bloquear: si la cola está llena, descarta el elemento más antiguo y reintenta
if (xQueueSend(readingQueue, &r, 0) != pdTRUE) {
Reading discarded;
xQueueReceive(readingQueue, &discarded, 0);
xQueueSend(readingQueue, &r, 0);
}
vTaskDelayUntil(&lastWake, period);
}
}
// ---------- utilidades MQTT (solo se llaman desde mqttTask) ----------
bool ensureWifi() {
if (WiFi.status() == WL_CONNECTED) return true;
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
for (int i = 0; i < 40 && WiFi.status() != WL_CONNECTED; i++) {
vTaskDelay(pdMS_TO_TICKS(250));
}
return WiFi.status() == WL_CONNECTED;
}
bool ensureMqtt() {
if (mqtt.connected()) return true;
String willTopic = String(TOPIC_PREFIX) + "/status";
bool ok = mqtt.connect(clientId.c_str(), willTopic.c_str(), 1, true, "offline");
if (ok) {
mqtt.publish(willTopic.c_str(), "online", true);
}
return ok;
}
// ---------- tarea MQTT ----------
void mqttTask(void* param) {
mqtt.setServer(MQTT_HOST, MQTT_PORT);
mqtt.setBufferSize(512);
String topic = String(TOPIC_PREFIX) + "/telemetry";
uint32_t backoffMs = 1000;
for (;;) {
if (!ensureWifi() || !ensureMqtt()) {
vTaskDelay(pdMS_TO_TICKS(backoffMs));
backoffMs = min<uint32_t>(backoffMs * 2, 30000);
continue;
}
backoffMs = 1000;
mqtt.loop();
Reading r;
// Espera brevemente datos para que la tarea ceda la CPU cuando está inactiva
if (xQueueReceive(readingQueue, &r, pdMS_TO_TICKS(50)) == pdTRUE) {
char payload[160];
snprintf(payload, sizeof(payload),
"{\"seq\":%lu,\"uptimeMs\":%lu,\"temperature\":%.2f,\"humidity\":%.2f,\"queued\":%u}",
(unsigned long)r.seq, (unsigned long)r.uptimeMs,
r.temperature, r.humidity,
(unsigned)uxQueueMessagesWaiting(readingQueue));
if (!mqtt.publish(topic.c_str(), payload)) {
// Devuélvelo al principio de la cola y reintenta tras reconectar
xQueueSendToFront(readingQueue, &r, 0);
}
}
}
}
void setup() {
Serial.begin(115200);
clientId = "esp32-rtos-" + String((uint32_t)ESP.getEfuseMac(), HEX);
readingQueue = xQueueCreate(60, sizeof(Reading)); // un minuto de búfer
if (readingQueue == nullptr) {
Serial.println("Queue allocation failed");
while (true) delay(1000);
}
xTaskCreatePinnedToCore(sensorTask, "sensor", 3072, nullptr, 3, nullptr, 1);
xTaskCreatePinnedToCore(mqttTask, "mqtt", 6144, nullptr, 2, nullptr, 0);
}
void loop() {
vTaskDelete(nullptr); // la tarea loop de Arduino no se necesita
}
Observa la salida desde tu equipo:
mosquitto_sub -h broker.connectmq.com -p 1883 -i sub-k3v9q1 -t "connectmq/demo/k3v9q1/#" -v
Deberías ver un mensaje status con online y un mensaje de telemetría por segundo. Ahora desconecta el router durante veinte segundos. El campo queued crece mientras la placa está sin conexión y, cuando la conexión vuelve, la tarea MQTT vacía el atasco en orden. Esa es la clave del diseño: las lecturas se tomaron a su hora aunque la publicación se detuviera.
El área pública del broker puede leerla cualquiera, así que mantén genéricos los datos de demostración. También limita cada cliente a 10 mensajes por segundo, un límite que el vaciado de un atasco puede alcanzar. Si almacenas muchas lecturas, añade un breve vTaskDelay(pdMS_TO_TICKS(120)) tras cada publicación mientras uxQueueMessagesWaiting() sea alto. La guía del servidor MQTT público gratuito enumera los demás límites.
Cómo funcionan las piezas
La cola como búfer
xQueueCreate(60, sizeof(Reading)) reserva 60 huecos de tamaño fijo en el heap. Cada xQueueSend copia la estructura de 16 bytes, así que la tarea de sensor puede reutilizar su variable local de inmediato. Dimensionar la cola es un compromiso entre la RAM y la duración del corte que quieres soportar. Sesenta huecos de 16 bytes ocupan menos de 1 KB; incluso unos miles de huecos caben sin problema en un ESP32 clásico, pero recuerda que los datos se pierden al reiniciar.
Cuando la cola está llena, el sketch descarta la lectura más antigua en lugar de la más reciente. Para telemetría, el último valor suele ser el más útil. Si cada muestra importa, bloquea unos pocos ticks o agrega las lecturas (mínimo, máximo, media) antes de encolarlas.
vTaskDelayUntil frente a delay
vTaskDelayUntil() despierta la tarea a un ritmo fijo medido desde el despertar anterior, de modo que el periodo no deriva por lo que tarde la lectura del sensor. Un simple delay(1000) tras una lectura de 30 ms da un periodo de 1,03 s que se va desplazando poco a poco. Ambas funciones ceden la CPU, a diferencia de un bucle activo con millis().
Reconexión con backoff
La tarea MQTT reintenta con un backoff exponencial limitado a 30 segundos. Saturar el broker con reintentos inmediatos desperdicia energía y, en un broker MQTT gestionado, puede activar la protección contra inundación o el bloqueo de IP por fallos repetidos. La guía de Last Will MQTT en ESP32 explica por qué el mensaje will retenido offline se registra al conectar, para que los dashboards detecten un dispositivo caído.
Problemas habituales y solución
- Guru Meditation "Stack canary watchpoint triggered". El stack de la tarea es demasiado pequeño. Tanto
snprintfcon floats como TLS necesitan margen. ImprimeuxTaskGetStackHighWaterMark(nullptr)dentro de cada tarea durante el desarrollo; devuelve el mínimo de bytes libres registrado. Deja al menos 512 bytes de reserva. - Reinicios del task watchdog. Una tarea que itera sin bloquear nunca deja sin CPU a la tarea idle de su núcleo. Cada iteración debe llamar a algo que ceda:
vTaskDelay,xQueueReceivecon timeout o una llamada de socket bloqueante. - Salida serie entremezclada. Dos tareas imprimiendo a la vez intercalan caracteres. Imprime desde una sola tarea o protege
Serialcon un mutex creado conxSemaphoreCreateMutex(). - Publicar desde la tarea de sensor. Parecerá que funciona y luego fallará aleatoriamente bajo carga. Mantén el cliente MQTT en una sola tarea.
- Los callbacks se ejecutan en la tarea MQTT. Si te suscribes a comandos, el callback de PubSubClient se ejecuta dentro de
mqtt.loop(). No hagas trabajo lento ahí; envía el comando a una segunda cola para que lo gestione otra tarea. - JSON grande truncado. El búfer por defecto de PubSubClient es de 256 bytes, incluido el topic. El sketch lo amplía con
setBufferSize(512).
Pasar a TLS y a tu propio broker
El área pública es ideal para pruebas, pero los dispositivos en producción deben autenticarse y cifrar. Crea una cuenta gratuita en app.connectmq.com, crea un broker y copia el usuario, la contraseña y el client ID generados. Después cambia solo la parte de red de la tarea MQTT:
#include <WiFiClientSecure.h>
// ISRG Root X1, la raíz de Let's Encrypt. Pega el PEM publicado por Let's Encrypt.
static const char ROOT_CA[] = R"PEM(
-----BEGIN CERTIFICATE-----
...paste ISRG Root X1 here...
-----END CERTIFICATE-----
)PEM";
WiFiClientSecure net;
PubSubClient mqtt(net);
// en mqttTask, antes del bucle:
net.setCACert(ROOT_CA);
mqtt.setServer("broker.connectmq.com", 8883);
// en ensureMqtt():
mqtt.connect("YOUR_CLIENT_ID", "YOUR_USERNAME", "YOUR_PASSWORD",
willTopic.c_str(), 1, true, "offline");
Aumenta el stack de la tarea MQTT a unos 8192 bytes, porque el handshake TLS usa bastante más stack y heap. En ConnectMQ el client ID está vinculado a las credenciales emitidas, así que usa exactamente el que te da el portal. Cuando los datos fluyan, abre el explorador de topics en vivo del portal para confirmar que llegan los mensajes y configura una regla de alerta por umbral en el campo de temperatura.
FreeRTOS en el ESP32 está documentado en detalle en la referencia de FreeRTOS de ESP-IDF, incluidas las diferencias SMP respecto al FreeRTOS original. Para una visión más amplia de los proyectos con dispositivos, el tutorial de IoT con ESP32 reúne el resto de esta serie, y añadir marcas de tiempo NTP a la estructura Reading es el siguiente paso natural para que las muestras almacenadas lleven su hora real de captura.
Empieza con ConnectMQ
Un sketch basado en tareas es solo la mitad de un sistema fiable; la otra mitad es un broker que sigue en pie mientras tus dispositivos se reconectan. ConnectMQ es un broker MQTT gestionado con MQTT 3.1.1 y 5.0, TLS en el 8883 y un portal que muestra tus dispositivos y topics en directo. Empieza con el plan gratuito Serverless sin tarjeta de crédito y pasa a un plan de pago o dedicado cuando tu flota supere las 25 conexiones. Cuando el firmware sea sólido, el mismo patrón funciona con sensores reales como en el proyecto ESP32 con DHT22.
Preguntas frecuentes
¿Puedo llamar a PubSubClient desde más de una tarea de FreeRTOS?
No de forma segura. PubSubClient tiene un único búfer compartido y ningún bloqueo, así que las llamadas concurrentes corrompen los paquetes. Mantenlo en una sola tarea y envíale los datos mediante una cola.
¿En qué núcleo debe ejecutarse la tarea MQTT?
El núcleo 0 es una buena opción por defecto porque las tareas de Wi-Fi y lwIP ya se ejecutan allí. En chips de un solo núcleo como el ESP32-C3 o el ESP32-C6, fija todas las tareas al núcleo 0 o usa xTaskCreate sin fijarlas.
¿Qué tamaño debe tener la cola de lecturas?
Dimensiónala para el corte más largo que quieras cubrir. Multiplica la frecuencia de muestreo por la duración del corte y por el tamaño de la estructura, y mantén el total muy por debajo del heap libre que indica ESP.getFreeHeap().
¿Funciona esto con AsyncMqttClient?
Sí, pero AsyncMqttClient ya ejecuta su propia tarea de red y sus callbacks, así que solo necesitas la tarea de sensor y una cola. Publica desde la tarea que vacía la cola, no desde dentro de los callbacks de la librería.



