If you have ever tried to read a DHT22 sensor, update an OLED display, and serve a web page all inside a single loop() function, you know how quickly things fall apart. Timing becomes unpredictable, your web server feels sluggish, and sensor readings get delayed. The ESP32 has a powerful solution built right in: FreeRTOS.
This guide walks you through everything you need to start writing real multitasking firmware on ESP32 — from creating your first task to building a full weather station with four independent tasks running across both cores.
Why Multitasking Matters
Consider a typical IoT project. Your ESP32 needs to:
- Read sensors every 2 seconds (DHT22, BMP280, etc.)
- Update an OLED display every 500ms
- Serve a web dashboard responding to HTTP requests instantly
- Blink a status LED at a steady 1Hz rate
- Transmit data over LoRa or MQTT every 30 seconds
In a single-threaded loop(), each of these operations blocks the others. A slow sensor read delays your web response. A long display update causes your LED to stutter. You end up writing complex state machines with millis() checks scattered everywhere.
Multitasking lets each of these jobs run as an independent task with its own timing, priority, and even its own CPU core. FreeRTOS makes this possible without writing a custom scheduler.
What is FreeRTOS?
FreeRTOS (Free Real-Time Operating System) is a lightweight, open-source RTOS kernel designed for microcontrollers. It provides:
- Task scheduling — run multiple functions concurrently
- Inter-task communication — queues, semaphores, event groups
- Memory management — per-task stack allocation
- Timing services — precise delays and software timers
The ESP32 runs FreeRTOS by default. Whether you use ESP-IDF or Arduino framework, FreeRTOS is already there. When you write setup() and loop() in Arduino, those functions actually run inside a FreeRTOS task called loopTask pinned to Core 1.
The ESP32's dual-core Xtensa LX6 processor makes FreeRTOS especially powerful. You have two cores that can genuinely run two tasks in parallel — not just time-sliced concurrency, but true parallelism.
| Core | Default Role | Notes |
|---|---|---|
| Core 0 | WiFi, Bluetooth, system tasks | Be careful not to starve it |
| Core 1 | Arduino loop(), your application |
Default for user code |
Tasks: The Building Blocks
A task in FreeRTOS is a function that runs independently with its own stack and priority. Think of each task as a mini-program running in its own loop().
Creating a Basic Task
void sensorTask(void *parameter) {
// Setup code for this task
pinMode(34, INPUT);
for (;;) { // Tasks must never return
int value = analogRead(34);
Serial.printf("Sensor: %d\n", value);
vTaskDelay(pdMS_TO_TICKS(2000)); // Wait 2 seconds
}
}
void setup() {
Serial.begin(115200);
xTaskCreate(
sensorTask, // Task function
"SensorTask", // Name (for debugging)
2048, // Stack size in bytes
NULL, // Parameter to pass
1, // Priority (0 = lowest)
NULL // Task handle (optional)
);
}
void loop() {
// This is also a task — still runs on Core 1
digitalWrite(LED_BUILTIN, !digitalRead(LED_BUILTIN));
delay(500);
}
Pinning Tasks to Specific Cores
The ESP32's dual cores let you distribute work. Use xTaskCreatePinnedToCore to control which core a task runs on:
void blinkTask(void *parameter) {
pinMode(2, OUTPUT);
for (;;) {
digitalWrite(2, HIGH);
vTaskDelay(pdMS_TO_TICKS(500));
digitalWrite(2, LOW);
vTaskDelay(pdMS_TO_TICKS(500));
}
}
void sensorTask(void *parameter) {
for (;;) {
float temperature = readDHT22();
Serial.printf("Temp: %.1f C\n", temperature);
vTaskDelay(pdMS_TO_TICKS(2000));
}
}
void setup() {
Serial.begin(115200);
// Pin sensor reading to Core 0
xTaskCreatePinnedToCore(
sensorTask,
"SensorTask",
4096, // Larger stack for sensor libraries
NULL,
1, // Priority
NULL,
0 // Core 0
);
// Pin LED blinking to Core 1
xTaskCreatePinnedToCore(
blinkTask,
"BlinkTask",
1024, // Small stack — simple task
NULL,
1,
NULL,
1 // Core 1
);
}
void loop() {
vTaskDelay(pdMS_TO_TICKS(1000)); // Keep loop alive but idle
}
Rule of thumb: Pin WiFi/Bluetooth-related tasks to Core 0 alongside the system tasks. Pin computation-heavy or timing-critical tasks to Core 1. If you do not specify a core, FreeRTOS will schedule the task on whichever core is available.
Task Priorities
Every task has a priority level (0 to configMAX_PRIORITIES - 1, typically 0-24). Higher numbers mean higher priority. The scheduler always runs the highest-priority task that is in the Ready state.
| Priority | Typical Use |
|---|---|
| 0 | Idle task (system) |
| 1 | Background logging, LED blinking |
| 2 | Sensor reading, display updates |
| 3 | Communication (MQTT, HTTP) |
| 4-5 | Safety-critical, real-time control |
// High-priority motor control task
xTaskCreate(motorControl, "Motor", 2048, NULL, 5, NULL);
// Low-priority data logging task
xTaskCreate(dataLogger, "Logger", 4096, NULL, 1, NULL);
Important: A high-priority task that never blocks will starve lower-priority tasks. Always include a vTaskDelay() or a blocking call (queue receive, semaphore take) so lower-priority tasks get CPU time.
Task States
Every FreeRTOS task is in one of four states:
+-----------+
Create ---> | Ready | <--- Unblocked / Resumed
+-----+-----+
|
Scheduler picks
|
+-----v-----+
| Running |
+-----+-----+
|
+------------+------------+
| | |
vTaskDelay vTaskSuspend Waiting on
Queue/Sem (explicit) resource
| | |
+-----v-----+ +---v-------+ +-v--------+
| Blocked | | Suspended | | Blocked |
+-----------+ +-----------+ +----------+
- Running — currently executing on a CPU core
- Ready — able to run, waiting for the scheduler to pick it
- Blocked — waiting for a time delay, queue, or semaphore
- Suspended — explicitly paused with
vTaskSuspend(); must be resumed withvTaskResume()
Delays: vTaskDelay vs vTaskDelayUntil
vTaskDelay
Delays relative to the current moment. If your task takes 50ms to execute and you call vTaskDelay(pdMS_TO_TICKS(1000)), the total cycle time is 1050ms.
void task(void *param) {
for (;;) {
doWork(); // Takes variable time
vTaskDelay(pdMS_TO_TICKS(1000)); // Then wait 1 second
}
}
vTaskDelayUntil
Delays until an absolute tick count. This gives you a precise, fixed-frequency loop regardless of how long your work takes.
void preciseTask(void *param) {
TickType_t lastWakeTime = xTaskGetTickCount();
for (;;) {
doWork(); // Takes variable time
vTaskDelayUntil(&lastWakeTime, pdMS_TO_TICKS(1000));
// Total cycle is exactly 1000ms
}
}
Use vTaskDelayUntil when you need consistent sample rates (sensor polling, PID control loops, display refresh).
Why delay() still works in Arduino: The Arduino delay() function internally calls vTaskDelay(), so it yields to other tasks correctly. But vTaskDelayUntil() gives you more precise timing control.
Queues: Passing Data Between Tasks
Tasks should not share global variables directly — race conditions cause subtle bugs. Queues are the FreeRTOS-approved way to pass data between tasks safely.
Creating and Using Queues
QueueHandle_t sensorQueue;
struct SensorData {
float temperature;
float humidity;
uint32_t timestamp;
};
void sensorTask(void *param) {
for (;;) {
SensorData data;
data.temperature = dht.readTemperature();
data.humidity = dht.readHumidity();
data.timestamp = millis();
// Send to queue, wait up to 100ms if queue is full
if (xQueueSend(sensorQueue, &data, pdMS_TO_TICKS(100)) != pdPASS) {
Serial.println("Queue full — dropping reading");
}
vTaskDelay(pdMS_TO_TICKS(2000));
}
}
void displayTask(void *param) {
SensorData received;
for (;;) {
// Block until data arrives (up to 5 seconds)
if (xQueueReceive(sensorQueue, &received, pdMS_TO_TICKS(5000)) == pdPASS) {
display.clearDisplay();
display.printf("Temp: %.1f C\n", received.temperature);
display.printf("Hum: %.1f %%\n", received.humidity);
display.display();
} else {
display.clearDisplay();
display.println("No sensor data!");
display.display();
}
}
}
void setup() {
Serial.begin(115200);
// Create queue that holds 10 SensorData items
sensorQueue = xQueueCreate(10, sizeof(SensorData));
if (sensorQueue == NULL) {
Serial.println("Failed to create queue!");
return;
}
xTaskCreatePinnedToCore(sensorTask, "Sensor", 4096, NULL, 2, NULL, 1);
xTaskCreatePinnedToCore(displayTask, "Display", 4096, NULL, 1, NULL, 1);
}
void loop() {
vTaskDelay(pdMS_TO_TICKS(10000));
}
This is the producer-consumer pattern. The sensor task produces data, the display task consumes it. The queue handles all synchronization automatically. If the sensor is faster than the display, readings buffer in the queue. If the display is faster, it blocks waiting for new data.
Queue Tips
- Size the queue appropriately. Too small and you drop data. Too large and you waste RAM.
- Use
xQueuePeekto read without removing an item — useful when multiple tasks need the same data. - Use
xQueueOverwritefor single-item queues where you always want the latest value.
Semaphores and Mutexes: Protecting Shared Resources
When two tasks need to use the same hardware peripheral (I2C bus, SPI bus, Serial), you need a mutex (mutual exclusion) to prevent simultaneous access.
Using a Mutex for I2C Bus Sharing
SemaphoreHandle_t i2cMutex;
void oledTask(void *param) {
for (;;) {
if (xSemaphoreTake(i2cMutex, pdMS_TO_TICKS(100)) == pdTRUE) {
// Safe to use I2C — no other task can access it
display.clearDisplay();
display.println("Hello from OLED task");
display.display();
xSemaphoreGive(i2cMutex); // Release the mutex
} else {
Serial.println("Could not get I2C mutex");
}
vTaskDelay(pdMS_TO_TICKS(500));
}
}
void bmp280Task(void *param) {
for (;;) {
if (xSemaphoreTake(i2cMutex, pdMS_TO_TICKS(100)) == pdTRUE) {
float pressure = bmp.readPressure();
float altitude = bmp.readAltitude(1013.25);
xSemaphoreGive(i2cMutex); // Release immediately after I2C access
// Process data outside the mutex — don't hold it longer than needed
Serial.printf("Pressure: %.0f Pa, Alt: %.1f m\n", pressure, altitude);
}
vTaskDelay(pdMS_TO_TICKS(1000));
}
}
void setup() {
Serial.begin(115200);
Wire.begin(21, 22); // I2C pins
i2cMutex = xSemaphoreCreateMutex();
xTaskCreate(oledTask, "OLED", 4096, NULL, 1, NULL);
xTaskCreate(bmp280Task, "BMP280", 4096, NULL, 2, NULL);
}
Key rule: Hold the mutex for the shortest time possible. Take it, do your I2C operation, give it back. Never call vTaskDelay() while holding a mutex.
Binary Semaphores
While mutexes protect shared resources, binary semaphores signal events between tasks or from an ISR to a task:
SemaphoreHandle_t buttonSemaphore;
void IRAM_ATTR buttonISR() {
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
xSemaphoreGiveFromISR(buttonSemaphore, &xHigherPriorityTaskWoken);
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}
void buttonHandlerTask(void *param) {
for (;;) {
if (xSemaphoreTake(buttonSemaphore, portMAX_DELAY) == pdTRUE) {
Serial.println("Button pressed — handling event");
// Do the actual work here, not in the ISR
}
}
}
Task Notifications: A Lightweight Alternative
Task notifications are faster and use less RAM than semaphores. Each task has a built-in 32-bit notification value.
TaskHandle_t processingTask;
void IRAM_ATTR dataReadyISR() {
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
vTaskNotifyGiveFromISR(processingTask, &xHigherPriorityTaskWoken);
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}
void processingTaskFunc(void *param) {
for (;;) {
// Block until notified
ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
// Process the new data
processIncomingData();
}
}
void setup() {
xTaskCreate(processingTaskFunc, "Process", 4096, NULL, 3, &processingTask);
attachInterrupt(digitalPinToInterrupt(GPIO_NUM_4), dataReadyISR, RISING);
}
When to use what:
| Mechanism | Use Case | RAM Cost |
|---|---|---|
| Queue | Passing data between tasks | 76+ bytes + item storage |
| Mutex | Protecting shared hardware/resources | 76 bytes |
| Binary semaphore | Signaling events from ISR to task | 76 bytes |
| Task notification | Lightweight signaling (1:1 only) | 0 bytes (built-in) |
Task notifications have one limitation: they are point-to-point. Only one task can wait on a specific notification. For broadcasting to multiple tasks, use event groups or multiple queues.
Watchdog Timer: Preventing Stuck Tasks
The ESP32 has a Task Watchdog Timer (TWDT) that resets the system if a task stops responding. By default, it monitors the idle tasks on both cores.
#include "esp_task_wdt.h"
#define WDT_TIMEOUT_S 10 // 10 second timeout
void criticalTask(void *param) {
// Subscribe this task to the watchdog
esp_task_wdt_add(NULL);
for (;;) {
doImportantWork();
// Reset the watchdog — "I'm still alive"
esp_task_wdt_reset();
vTaskDelay(pdMS_TO_TICKS(1000));
}
}
void setup() {
// Initialize watchdog with 10-second timeout
esp_task_wdt_init(WDT_TIMEOUT_S, true); // true = trigger panic on timeout
}
If criticalTask gets stuck in a loop or deadlock for more than 10 seconds without calling esp_task_wdt_reset(), the ESP32 will reboot automatically. This is essential for deployed IoT devices that must recover from hangs without manual intervention.
Common Pitfalls
1. Stack Overflow
Each task gets a fixed-size stack. If your task uses too many local variables, deep function calls, or large buffers, the stack overflows and the system crashes silently.
How to detect: Enable stack overflow checking in menuconfig or check the high water mark:
void myTask(void *param) {
for (;;) {
doWork();
vTaskDelay(pdMS_TO_TICKS(1000));
// Check remaining stack space
UBaseType_t stackRemaining = uxTaskGetStackHighWaterMark(NULL);
Serial.printf("Stack free: %u bytes\n", stackRemaining * 4);
}
}
If the high water mark drops below 200 bytes, increase the task's stack size.
2. Priority Inversion
A high-priority task waits for a mutex held by a low-priority task, but a medium-priority task preempts the low-priority one. The high-priority task is now stuck waiting for the medium-priority task to finish — priorities are effectively inverted.
Solution: Use xSemaphoreCreateMutex() (not binary semaphores for resource protection). FreeRTOS mutexes include priority inheritance that temporarily boosts the low-priority task's priority.
3. Blocking on Core 0
Core 0 runs WiFi and Bluetooth protocol stacks. If you pin a task to Core 0 that blocks for too long without yielding, WiFi will disconnect and Bluetooth will drop.
// BAD — blocks Core 0
xTaskCreatePinnedToCore(heavyComputation, "Heavy", 8192, NULL, 5, NULL, 0);
// GOOD — run heavy work on Core 1
xTaskCreatePinnedToCore(heavyComputation, "Heavy", 8192, NULL, 5, NULL, 1);
4. Forgetting to Yield
A task with priority 2 that never calls vTaskDelay() or any blocking function will permanently starve all tasks with priority 0 and 1 on the same core.
5. Using Global Variables Without Protection
// BAD — race condition
float globalTemp = 0;
void sensorTask(void *param) {
for (;;) {
globalTemp = readSensor(); // Write
vTaskDelay(pdMS_TO_TICKS(1000));
}
}
void displayTask(void *param) {
for (;;) {
Serial.println(globalTemp); // Read — might get partial write
vTaskDelay(pdMS_TO_TICKS(500));
}
}
// GOOD — use a queue or mutex
Debugging FreeRTOS Tasks
Listing All Running Tasks
void debugTask(void *param) {
for (;;) {
char buffer[512];
vTaskList(buffer);
Serial.println("Task State Prio Stack Num");
Serial.println(buffer);
vTaskDelay(pdMS_TO_TICKS(10000));
}
}
Output looks like:
Task State Prio Stack Num
SensorTask B 2 1024 3
DisplayTask B 1 892 4
BlinkTask B 1 456 5
loopTask B 1 3200 2
IDLE R 0 512 1
State codes: R = Running, B = Blocked, S = Suspended, D = Deleted, X = Ready
Runtime Stats
char buffer[512];
vTaskGetRunTimeStats(buffer);
Serial.println("Task Abs Time % Time");
Serial.println(buffer);
This shows you what percentage of CPU time each task consumes — invaluable for identifying tasks that hog the processor.
Practical Example: ESP32 Weather Station
Let us bring it all together. This weather station has four independent tasks communicating through queues and sharing an I2C bus with a mutex.
Hardware: ESP32, DHT22 (GPIO 4), BMP280 (I2C), SSD1306 OLED (I2C), optional LoRa module (SPI)
#include <WiFi.h>
#include <WebServer.h>
#include <Wire.h>
#include <Adafruit_BMP280.h>
#include <DHT.h>
#include <Adafruit_SSD1306.h>
#include "esp_task_wdt.h"
// Peripherals
DHT dht(4, DHT22);
Adafruit_BMP280 bmp;
Adafruit_SSD1306 display(128, 64, &Wire, -1);
WebServer server(80);
// Synchronization primitives
SemaphoreHandle_t i2cMutex;
QueueHandle_t weatherQueue;
QueueHandle_t displayQueue;
// Data structure shared via queues
struct WeatherData {
float temperature;
float humidity;
float pressure;
float altitude;
uint32_t timestamp;
};
// Latest data for the web server (protected by mutex)
SemaphoreHandle_t dataMutex;
WeatherData latestData;
// ===================== TASK 1: Sensor Reading =====================
void sensorTask(void *param) {
esp_task_wdt_add(NULL);
for (;;) {
WeatherData data;
data.timestamp = millis();
// Read DHT22 (GPIO-based, no I2C needed)
data.temperature = dht.readTemperature();
data.humidity = dht.readHumidity();
// Read BMP280 (I2C — need mutex)
if (xSemaphoreTake(i2cMutex, pdMS_TO_TICKS(200)) == pdTRUE) {
data.pressure = bmp.readPressure() / 100.0F; // hPa
data.altitude = bmp.readAltitude(1013.25);
xSemaphoreGive(i2cMutex);
}
// Send to both queues
xQueueOverwrite(weatherQueue, &data); // For web server (latest only)
xQueueSend(displayQueue, &data, 0); // For display (buffered)
// Update shared data for web server
if (xSemaphoreTake(dataMutex, pdMS_TO_TICKS(50)) == pdTRUE) {
latestData = data;
xSemaphoreGive(dataMutex);
}
esp_task_wdt_reset();
vTaskDelay(pdMS_TO_TICKS(2000));
}
}
// ===================== TASK 2: OLED Display =====================
void displayTask(void *param) {
WeatherData data;
for (;;) {
if (xQueueReceive(displayQueue, &data, pdMS_TO_TICKS(5000)) == pdPASS) {
if (xSemaphoreTake(i2cMutex, pdMS_TO_TICKS(200)) == pdTRUE) {
display.clearDisplay();
display.setTextSize(1);
display.setTextColor(SSD1306_WHITE);
display.setCursor(0, 0);
display.printf("Temp: %.1f C", data.temperature);
display.setCursor(0, 16);
display.printf("Hum: %.1f %%", data.humidity);
display.setCursor(0, 32);
display.printf("Pres: %.0f hPa", data.pressure);
display.setCursor(0, 48);
display.printf("Alt: %.0f m", data.altitude);
display.display();
xSemaphoreGive(i2cMutex);
}
}
}
}
// ===================== TASK 3: Web Server =====================
void webServerTask(void *param) {
WiFi.begin("your-ssid", "your-password");
while (WiFi.status() != WL_CONNECTED) {
vTaskDelay(pdMS_TO_TICKS(500));
}
Serial.printf("IP: %s\n", WiFi.localIP().toString().c_str());
server.on("/", []() {
WeatherData data;
if (xSemaphoreTake(dataMutex, pdMS_TO_TICKS(100)) == pdTRUE) {
data = latestData;
xSemaphoreGive(dataMutex);
}
String html = "<html><body>";
html += "<h1>ESP32 Weather Station</h1>";
html += "<p>Temperature: " + String(data.temperature, 1) + " C</p>";
html += "<p>Humidity: " + String(data.humidity, 1) + " %</p>";
html += "<p>Pressure: " + String(data.pressure, 0) + " hPa</p>";
html += "<p>Altitude: " + String(data.altitude, 0) + " m</p>";
html += "</body></html>";
server.send(200, "text/html", html);
});
server.on("/api/weather", []() {
WeatherData data;
if (xSemaphoreTake(dataMutex, pdMS_TO_TICKS(100)) == pdTRUE) {
data = latestData;
xSemaphoreGive(dataMutex);
}
String json = "{";
json += "\"temperature\":" + String(data.temperature, 1) + ",";
json += "\"humidity\":" + String(data.humidity, 1) + ",";
json += "\"pressure\":" + String(data.pressure, 1) + ",";
json += "\"altitude\":" + String(data.altitude, 1);
json += "}";
server.send(200, "application/json", json);
});
server.begin();
for (;;) {
server.handleClient();
vTaskDelay(pdMS_TO_TICKS(10)); // Small delay to yield
}
}
// ===================== TASK 4: Data Logger =====================
void loggerTask(void *param) {
for (;;) {
WeatherData data;
if (xSemaphoreTake(dataMutex, pdMS_TO_TICKS(100)) == pdTRUE) {
data = latestData;
xSemaphoreGive(dataMutex);
}
Serial.printf("[%lu] T=%.1fC H=%.1f%% P=%.0fhPa A=%.0fm\n",
data.timestamp,
data.temperature,
data.humidity,
data.pressure,
data.altitude
);
vTaskDelay(pdMS_TO_TICKS(30000)); // Log every 30 seconds
}
}
// ===================== SETUP =====================
void setup() {
Serial.begin(115200);
Wire.begin(21, 22);
dht.begin();
// Initialize watchdog
esp_task_wdt_init(15, true);
// Create synchronization primitives
i2cMutex = xSemaphoreCreateMutex();
dataMutex = xSemaphoreCreateMutex();
weatherQueue = xQueueCreate(1, sizeof(WeatherData)); // Latest only
displayQueue = xQueueCreate(5, sizeof(WeatherData)); // Buffer 5 readings
// Initialize I2C devices
if (xSemaphoreTake(i2cMutex, pdMS_TO_TICKS(1000)) == pdTRUE) {
bmp.begin(0x76);
display.begin(SSD1306_SWITCHCAPVCC, 0x3C);
display.clearDisplay();
display.display();
xSemaphoreGive(i2cMutex);
}
// Create tasks pinned to appropriate cores
xTaskCreatePinnedToCore(sensorTask, "Sensor", 4096, NULL, 3, NULL, 1);
xTaskCreatePinnedToCore(displayTask, "Display", 4096, NULL, 1, NULL, 1);
xTaskCreatePinnedToCore(webServerTask, "Web", 8192, NULL, 2, NULL, 0);
xTaskCreatePinnedToCore(loggerTask, "Logger", 4096, NULL, 1, NULL, 1);
}
void loop() {
vTaskDelay(portMAX_DELAY); // Suspend loop — not needed
}
Architecture Breakdown
| Task | Core | Priority | Stack | Communication |
|---|---|---|---|---|
| Sensor | 1 | 3 (highest) | 4096 | Produces to weatherQueue, displayQueue |
| Web Server | 0 | 2 | 8192 | Reads latestData via dataMutex |
| Display | 1 | 1 | 4096 | Consumes from displayQueue, uses i2cMutex |
| Logger | 1 | 1 (lowest) | 4096 | Reads latestData via dataMutex |
Notice how the web server is on Core 0 alongside WiFi — this minimises context switching for network operations. The sensor task has the highest priority because timely readings are critical. The logger has the lowest priority because missing a log entry is acceptable.
Recommended Stack Sizes
| Task Type | Recommended Stack | Notes |
|---|---|---|
| Simple GPIO (blink) | 1024-2048 | Minimal local variables |
| Sensor reading | 4096 | Library overhead |
| Display (OLED, TFT) | 4096 | Display buffers |
| WiFi/Web server | 8192 | HTTP parsing needs RAM |
| JSON/String processing | 8192+ | String concatenation is expensive |
Always check uxTaskGetStackHighWaterMark() during development and reduce or increase accordingly.
Quick Reference
| FreeRTOS Function | Purpose |
|---|---|
xTaskCreate() |
Create a task (any core) |
xTaskCreatePinnedToCore() |
Create a task on a specific core |
vTaskDelay() |
Relative delay (yields to scheduler) |
vTaskDelayUntil() |
Absolute delay (fixed frequency) |
xQueueCreate() |
Create a queue |
xQueueSend() |
Send item to queue |
xQueueReceive() |
Receive item from queue (blocking) |
xSemaphoreCreateMutex() |
Create a mutex |
xSemaphoreTake() |
Lock a mutex/semaphore |
xSemaphoreGive() |
Unlock a mutex/semaphore |
ulTaskNotifyTake() |
Wait for task notification |
xTaskNotifyGive() |
Send task notification |
vTaskList() |
Print all task info (debug) |
uxTaskGetStackHighWaterMark() |
Check remaining stack |
Next Steps
Once you are comfortable with the basics covered here, explore these advanced topics:
- Event Groups — synchronize multiple tasks waiting for multiple conditions
- Software Timers — periodic callbacks without dedicated tasks
- Stream and Message Buffers — efficient byte-stream passing between tasks
- Static Task Allocation — pre-allocate memory to avoid heap fragmentation
- FreeRTOS + ESP-IDF — deeper integration with ESP32-specific features like deep sleep and ULP co-processor
The ESP32's dual-core processor combined with FreeRTOS gives you a genuinely powerful platform for complex IoT projects. Start with two tasks and a queue, and build up from there. Every project that outgrows a single loop() is a project that benefits from FreeRTOS.
Building a multitasking ESP32 project? Browse our collection of ESP32 development boards, sensors, and OLED displays at Wavtron.



