FreeRTOS on ESP32: Task Scheduling, Queues, and Resource Management for IoT
When IoT firmware grows beyond reading a sensor and blinking an LED, bare-metal super-loops start causing subtle, hard-to-reproduce bugs. WiFi reconnection blocks sensor sampling. MQTT publish blocks display updates. A slow JSON serialization pass causes you to miss a real-time control deadline. An RTOS eliminates these problems by giving each concern its own preemptible thread of execution.
The ESP-IDF ships FreeRTOS as its default task scheduler — and for good reason. After years of shipping production IoT firmware, FreeRTOS on ESP32 is our standard starting point for any project with more than two concurrent concerns.
Why RTOS Over Bare-Metal for IoT
A bare-metal super-loop executes tasks sequentially. If WiFi takes 2 seconds to reconnect, your sensor reading is 2 seconds late. With FreeRTOS, the WiFi reconnection runs in its own task at low priority; the high-priority sensor task preempts it every 100 ms regardless.
ESP32's dual-core architecture makes this even more powerful. FreeRTOS on ESP-IDF is a symmetric multiprocessing variant — tasks can be pinned to Core 0 (protocol CPU) or Core 1 (application CPU), giving you true parallelism.
Task Creation and Priority Design
Priorities in FreeRTOS are integers where higher numbers mean higher priority. ESP-IDF reserves priority 24 for the timer daemon; keep your tasks below that.
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/queue.h"
#include "freertos/semphr.h"// Forward declarations
void sensor_task(void *pvParameters);
void mqtt_publish_task(void *pvParameters);
void display_task(void *pvParameters);
// Shared queue handle — sensor → MQTT
QueueHandle_t sensor_data_queue;
void app_main(void) {
// Create queue: 10 items, each a sensor_reading_t struct
sensor_data_queue = xQueueCreate(10, sizeof(sensor_reading_t));
configASSERT(sensor_data_queue != NULL);
// Sensor task: high priority, pinned to Core 1 (app CPU)
xTaskCreatePinnedToCore(
sensor_task, // Task function
"sensor", // Task name (for debugging)
4096, // Stack size in bytes
NULL, // Parameters
5, // Priority (5 = higher than MQTT)
NULL, // Task handle (optional)
1 // Core 1
);
// MQTT publish: medium priority, Core 0 (protocol CPU)
xTaskCreatePinnedToCore(
mqtt_publish_task,
"mqtt_pub",
8192, // MQTT needs more stack for TLS
NULL,
3,
NULL,
0 // Core 0 — alongside WiFi stack
);
// Display: low priority, any core
xTaskCreate(display_task, "display", 4096, NULL, 1, NULL);
}
Key rule: assign priorities based on deadline urgency, not importance. A sensor reading with a 100 ms deadline outranks a display refresh with a 500 ms deadline.
Queues for Safe Inter-Task Communication
Queues are the primary mechanism for passing data between tasks without shared memory races. The sending task copies data into the queue; the receiving task copies it out. The queue is thread-safe by design.
typedef struct {
float temperature;
float humidity;
uint32_t timestamp_ms;
} sensor_reading_t;void sensor_task(void *pvParameters) {
sensor_reading_t reading;
for (;;) {
// Read sensor (blocking I2C call)
if (sht31_read(&reading.temperature, &reading.humidity) == ESP_OK) {
reading.timestamp_ms = xTaskGetTickCount() * portTICK_PERIOD_MS;
// Send to queue — block up to 10 ms if queue is full
// Never use portMAX_DELAY in high-priority tasks (blocks scheduler)
if (xQueueSend(sensor_data_queue, &reading, pdMS_TO_TICKS(10)) != pdTRUE) {
ESP_LOGW("SENSOR", "Queue full — dropped reading");
}
}
vTaskDelay(pdMS_TO_TICKS(100)); // Sample at 10 Hz
}
}
void mqtt_publish_task(void *pvParameters) {
sensor_reading_t reading;
char payload[128];
for (;;) {
// Block indefinitely waiting for a new reading
if (xQueueReceive(sensor_data_queue, &reading, portMAX_DELAY) == pdTRUE) {
snprintf(payload, sizeof(payload),
"{"temp":%.2f,"hum":%.2f,"ts":%lu}",
reading.temperature, reading.humidity, reading.timestamp_ms);
mqtt_publish("devices/sensor01/data", payload);
}
}
}
Mutexes for Shared Resources
When two tasks share a resource (SPI bus, UART, a global configuration structure), use a mutex. Never access shared hardware directly from multiple tasks without one.
SemaphoreHandle_t spi_mutex;void init_shared_spi(void) {
spi_mutex = xSemaphoreCreateMutex();
configASSERT(spi_mutex != NULL);
}
esp_err_t spi_write_protected(const uint8_t *data, size_t len) {
// Acquire mutex — wait up to 100 ms
if (xSemaphoreTake(spi_mutex, pdMS_TO_TICKS(100)) != pdTRUE) {
ESP_LOGE("SPI", "Failed to acquire SPI mutex");
return ESP_ERR_TIMEOUT;
}
esp_err_t ret = spi_device_transmit(spi_handle, &transaction);
xSemaphoreGive(spi_mutex); // Always release — even on error
return ret;
}
Critical pitfall: Never call xSemaphoreTake from an ISR. Use xSemaphoreTakeFromISR and the corresponding BaseType_t pxHigherPriorityTaskWoken pattern.
Stack Overflow Detection
Stack overflows are the number-one silent killer in FreeRTOS firmware. Enable stack overflow checking in sdkconfig:
CONFIG_FREERTOS_CHECK_STACKOVERFLOW_PTRVAL=y
Then implement the hook:
void vApplicationStackOverflowHook(TaskHandle_t xTask, char *pcTaskName) {
ESP_LOGE("FREERTOS", "Stack overflow in task: %s", pcTaskName);
// Log to NVS before restarting so you can diagnose later
nvs_log_crash("stack_overflow", pcTaskName);
esp_restart();
}
Use uxTaskGetStackHighWaterMark(NULL) periodically in each task during development to measure actual peak stack usage, then size your stacks at 1.5× that value for production.
Common Pitfalls
Calling blocking functions from ISRs: Never call xQueueSend, vTaskDelay, or any FreeRTOS blocking API from an interrupt. Use the FromISR variants and yield if pxHigherPriorityTaskWoken is set.
Starvation of low-priority tasks: If your high-priority task never blocks, lower-priority tasks never run. Always call vTaskDelay or a blocking queue/semaphore operation in every task loop.
Heap fragmentation: Every xTaskCreate allocates stack from the heap. In long-running firmware, create all tasks at startup and never delete them. See our guide on [embedded firmware memory management](/embedded-firmware-memory-management) for deeper coverage.
portMAX_DELAY in network tasks: WiFi and MQTT calls can block indefinitely on connection loss. Always use a timeout and a reconnection state machine rather than blocking forever.
FreeRTOS on ESP32 is production-ready when used correctly. The patterns above have served us across dozens of deployments with multi-year uptime requirements.
[Contact Code Caracal](/contact) — we build production firmware for clients across 15+ countries.