In this guide
Every Arduino sketch starts the same way: setup() runs once, loop() runs forever, and before long loop() is a tangle of delay() calls where nothing can happen while something else is waiting. Add a second sensor, a display, and WiFi, and the whole thing falls over — the display freezes while the sensor warms up, MQTT disconnects while you debounce a button. FreeRTOS is the way out: it is the real-time operating system already running inside every ESP32 Arduino sketch, and learning to use it deliberately — tasks, queues, semaphores, core pinning — is the single biggest step from "Arduino sketch" to "embedded firmware."
This guide is practical, not theoretical: what each FreeRTOS primitive does, when to reach for it, and the exact mistakes students make with each one, with short ESP32 code you can run. If your firmware also needs over-the-air updates, the ESP32 OTA update workflow builds directly on the non-blocking task structure you learn here. And if your tasks will publish sensor data, the practical MQTT guide is the natural next read.
Why delay() breaks: the problem FreeRTOS solves
Consider the classic student loop():
void loop() {
float t = readTemperature(); // blocks 750 ms while DS18B20 converts
updateDisplay(t); // blocks while the OLED redraws
publishMQTT(t); // blocks if WiFi is reconnecting
delay(5000);
}
While readTemperature() waits 750 ms for the DS18B20 conversion, nothing else runs: the display is frozen, MQTT keep-alives are missed (the broker may disconnect you), and a button press goes unnoticed. The usual patches — millis()-based state machines, splitting work across loop iterations — work but turn the code into spaghetti that is hard to extend and harder to debug.
FreeRTOS replaces this with tasks: independent functions that the scheduler runs "simultaneously" (rapidly switching between them on the ESP32's two cores, or truly in parallel across cores). Each task has its own stack and its own timing. The temperature task waits 750 ms for its sensor; meanwhile the display task redraws, the MQTT task keeps the connection alive, and the button task responds instantly. Nobody blocks anybody.
Mental model:
loop()is one employee doing every job sequentially. FreeRTOS is hiring a small team and giving each person one job. The scheduler is the manager deciding who works right now.
Tasks: the unit of work
A task is a C function that never returns, created with xTaskCreate (or xTaskCreatePinnedToCore on the ESP32):
void temperatureTask(void* pvParameters) {
for (;;) { // tasks loop forever; never return
float t = readTemperature(); // 750 ms conversion
xQueueSend(tempQueue, &t, portMAX_DELAY);
vTaskDelay(pdMS_TO_TICKS(5000)); // sleep 5 s WITHOUT blocking others
}
}
void setup() {
tempQueue = xQueueCreate(10, sizeof(float));
xTaskCreatePinnedToCore(
temperatureTask, // function
"temp", // name (for debugging)
4096, // stack size in bytes
NULL, // parameters
2, // priority
NULL, // task handle (optional)
1 // core: 0 or 1
);
}
Three things to notice:
vTaskDelay()is notdelay().delay()spins and blocks everything on that core.vTaskDelay()tells the scheduler "I need nothing for 5 seconds" — the scheduler immediately runs other tasks. This one distinction is 80% of FreeRTOS's value.- A task must never return. If the function returns, the task is deleted and its stack freed — usually a crash soon after. The
for(;;)loop is the idiom; write it that way every time. - Stack size matters. Too small a stack → stack overflow → mysterious crashes (often in unrelated code). Start generous (4096 bytes for simple tasks), then measure the actual high-water mark (covered below) and trim.
Priorities: who wins
Each task has a priority (0 = lowest; higher number = more important). When multiple tasks are ready, the highest-priority ready task runs. Rules of thumb:
- Keep most application tasks at priority 1–3. The Arduino
loop()itself runs as a task at priority 1 — your tasks at priority 1 share time with it fairly. - Time-critical work (reading a fast sensor, feeding a watchdog) gets higher priority; background work (logging, display updates) gets lower.
- Never create priority inversions by accident: a low-priority task holding a mutex that a high-priority task needs will stall the high-priority task until a medium-priority task finishes — the classic unbounded-delay bug. Mutexes with priority inheritance (FreeRTOS mutexes have it; binary semaphores do not) exist to fix exactly this. When in doubt, use a mutex for resource protection, not a binary semaphore.
Core pinning: the ESP32 has two cores — use them deliberately
The ESP32 has two Xtensa cores (PRO_CPU = core 0, APP_CPU = core 1). In the Arduino core, WiFi/BT and loop() run on core 1 by default; core 0 handles RF housekeeping. xTaskCreatePinnedToCore lets you choose:
- Pin WiFi/MQTT/networking tasks to core 1 (with the Arduino networking stack).
- Pin sensor/actuator timing-sensitive tasks to core 0 — but keep them light; starving core 0's RF housekeeping causes WiFi dropouts.
- Leave unpinned (
xTaskCreate) only for tasks that genuinely do not care — the scheduler load-balances, which is fine for background work.
A practical split for a sensor node: sensor acquisition + control loop on core 0, WiFi/MQTT/dashboard on core 1, display on either. Measure before optimising — most student projects run fine with everything unpinned and sensible priorities.
Queues: tasks talking to tasks
Tasks cannot safely share plain global variables (one task may read a half-written value while another writes it). Queues are the FreeRTOS answer: thread-safe FIFO buffers that copy data between tasks.
QueueHandle_t tempQueue;
void temperatureTask(void* pv) {
for (;;) {
float t = readTemperature();
xQueueSend(tempQueue, &t, portMAX_DELAY); // blocks if queue full
vTaskDelay(pdMS_TO_TICKS(5000));
}
}
void mqttTask(void* pv) {
float t;
for (;;) {
if (xQueueReceive(tempQueue, &t, portMAX_DELAY) == pdTRUE) {
publishTemperature(t); // runs only when data arrives
}
}
}
The mqttTask here is elegant: it sleeps with zero CPU usage until data arrives. No polling, no flags, no race conditions. Queue patterns you will reuse constantly:
- Sensor → processor: raw readings in, filtered values out.
- Any producer → display/logger: one queue feeding an OLED task and an SD-card task simultaneously is a fan-out — create two queues, send to both.
- Command queue: a web-server or MQTT task pushes commands (
{valve: open}) into a queue; the actuator task consumes them. This decouples "receiving a command" from "acting on it" — the actuator never blocks the network.
Queue sizing: depth 5–10 is plenty for sensor data. If the queue is always full, the consumer is too slow — fix the consumer, not the queue depth. A queue that grows without bound is a memory leak wearing a disguise.
Semaphores and mutexes: protecting shared resources
When two tasks touch the same hardware (an I2C bus, the Serial port, a shared buffer), you need mutual exclusion: only one task in the critical section at a time.
SemaphoreHandle_t i2cMutex;
void sensorATask(void* pv) {
for (;;) {
if (xSemaphoreTake(i2cMutex, portMAX_DELAY) == pdTRUE) {
readSensorA(); // safe: nobody else on the I2C bus
xSemaphoreGive(i2cMutex);
}
vTaskDelay(pdMS_TO_TICKS(1000));
}
}
In setup(): i2cMutex = xSemaphoreCreateMutex(); — note mutex, not binary semaphore, for resource protection (priority inheritance, as discussed above).
The other semaphore flavour is the binary semaphore as a signal: an interrupt service routine "gives" the semaphore when hardware needs attention, and a task "takes" it (blocking) to do the slow work. This is the correct pattern for interrupt handling — ISRs must be fast, so the ISR just signals and a task does the heavy lifting:
void IRAM_ATTR buttonISR() {
BaseType_t woken = pdFALSE;
xSemaphoreGiveFromISR(buttonSem, &woken);
portYIELD_FROM_ISR(woken);
}
void buttonTask(void* pv) {
for (;;) {
if (xSemaphoreTake(buttonSem, portMAX_DELAY) == pdTRUE) {
vTaskDelay(pdMS_TO_TICKS(50)); // debounce OUTSIDE the ISR
handleButtonPress();
}
}
}
Debouncing inside an ISR is a classic bug (ISRs cannot block); debouncing in the task after the signal is the fix. This pattern — ISR signals, task works — appears in motor controllers, pulse counters, and alarm inputs across final-year builds.
Task notifications: the lightweight shortcut
For simple task-to-task or ISR-to-task signalling, task notifications are faster and lighter than semaphores (no separate object to create). Each task has a built-in 32-bit notification value:
// ISR or another task:
xTaskNotifyGive(workerHandle); // or xTaskNotifyGiveFromISR
// In the worker task:
for (;;) {
ulTaskNotifyTake(pdTRUE, portMAX_DELAY); // sleep until notified
doTheWork();
}
Use notifications for "wake up and do X" signals; use queues when data must travel with the signal. (A vibration-monitoring task woken by an accelerometer interrupt — as in an IoT motor-health monitor — is the textbook notification use case.)
Software timers and the watchdog
- Software timers (
xTimerCreate) run a callback in the timer service task — good for periodic housekeeping (blink a status LED, re-check WiFi every minute). Keep callbacks short; they share one task. - The task watchdog (TWDT) resets the chip if a watched task starves. In Arduino-ESP32 it watches
loop()by default. If you move all work into FreeRTOS tasks andloop()is empty, either feed the watchdog or understand why it fires. A watchdog reset withrst:0x8 (TG1WDT_SYS_RESET)in the serial log means some task hogged a core — usually awhile(1)withoutvTaskDelay()or a blocking driver call. The watchdog is your friend: it turns "mysteriously frozen device" into a reboot plus a log line pointing at the cause.
Stack sizing: measure, don't guess
Create tasks with generous stacks (4096–8192 bytes), run the system under load, then check the high-water mark — the minimum free stack ever observed:
UBaseType_t hw = uxTaskGetStackHighWaterMark(NULL); // calling task
Serial.printf("Stack high-water mark: %u bytes free\n", hw * 4); // in words -> bytes
If the high-water mark is 2000 bytes, a 4096-byte stack has comfortable margin. If it is 100 bytes, you are one function call from a stack overflow — and stack overflows corrupt whatever sits next to the stack, producing crashes that look like they come from other tasks. Size once, verify always.
The worked example: a proper multitasking sensor node
A realistic node — the kind behind an IoT energy meter with a live dashboard or a TinyML anomaly detector — structured as tasks:
| Task | Core | Priority | Job |
|---|---|---|---|
sensorTask |
0 | 2 | Read sensors every 5 s, push to dataQueue |
mlTask |
0 | 2 | Pull from dataQueue, run inference, push results to resultQueue |
mqttTask |
1 | 2 | Pull from resultQueue, publish; handle commands into cmdQueue |
actuatorTask |
0 | 3 | Pull from cmdQueue, drive outputs (higher priority: actuation beats telemetry) |
displayTask |
1 | 1 | Pull latest from a one-deep queue, redraw OLED at 2 Hz |
watchdogTask |
1 | 1 | Blink status LED, print stack high-water marks every 30 s |
Notice what this buys: the ML inference (slow, CPU-heavy) never blocks MQTT keep-alives; a stuck sensor read stalls only sensorTask; commands from the cloud reach the actuator through a queue even mid-inference. Each task is independently testable — comment out xTaskCreate lines to isolate subsystems during debugging.
Diagnosis table: the crashes you will meet
| Symptom | Likely cause | Check |
|---|---|---|
Crash in unrelated code, Stack canary watchpoint triggered |
Stack overflow in some task | Print uxTaskGetStackHighWaterMark for every task; enlarge the offender |
rst:0x8 (TG1WDT_SYS_RESET) reboots |
A task starved the watchdog: while loop without vTaskDelay, or a blocking call |
Add vTaskDelay(1) in tight loops; move blocking I/O out of high-priority tasks |
Guru Meditation LoadProhibited right after adding a task |
Task function returned (missing for(;;)), or null pointer in task params |
Verify the infinite loop; check the pvParameters cast |
| I2C reads corrupt when two tasks use the bus | Missing mutex around shared I2C | Wrap all bus access in xSemaphoreTake/Give on one mutex |
| Data arrives but values look half-updated | Queue item size wrong (sizeof mismatch) or shared global without protection |
Check xQueueCreate item size; move the data through the queue, not around it |
| Device runs for hours then crashes | Slow memory leak: queue growing unbounded, or malloc in a task without free |
Monitor xPortGetFreeHeapSize() in the watchdog task; graph it |
| WiFi drops when sensor task runs | Sensor task pinned to core 0 starving RF housekeeping, or ISR hogging | Lower the sensor task priority; keep core-0 tasks light; move heavy work to core 1 |
| Button ISR causes reboot | ISR did something illegal (blocked, used non-IRAM function, overflowed its tiny stack) | ISR only gives a semaphore/notification; all work in the task |
Checklist: FreeRTOS done right
- Every task has
for(;;)and never returns. - No
delay()in any task —vTaskDelay()everywhere; no blocking calls in high-priority tasks. - Shared hardware guarded by a mutex (not a binary semaphore).
- ISRs only signal (semaphore/notification); all work happens in tasks.
- Stack high-water marks measured under load, with margin.
- Priorities deliberate: actuation above telemetry above display; nothing above what it needs.
- Core pinning intentional: networking on core 1, timing-sensitive sensing on core 0, nothing starving RF.
- Watchdog understood: you know which tasks are watched and what a
TG1WDTreset means.
Putting it together
The FreeRTOS learning curve pays off the first time you add "one more sensor" to a working project and nothing breaks — because the new sensor is a new task with its own queue, not surgery on a fragile loop(). Start by converting one blocking delay() chain into a task with vTaskDelay(), then add a queue between two tasks, then a mutex around the I2C bus. Each step is small; the destination is firmware you can reason about.
Where this matters in real builds: the vibration-plus-temperature pipeline of an IoT motor-health monitor, the sense-infer-publish pipeline of a TinyML anomaly detector on ESP32, or the metering-plus-dashboard flow of an IoT energy meter. And when the hardware leaves the breadboard, the soldering and prototyping guide picks up where firmware leaves off.
What is already running under your Arduino sketch
A fact that surprises students: every ESP32 Arduino sketch already runs on FreeRTOS. setup() and loop() execute inside a FreeRTOS task (priority 1, core 1), the WiFi driver runs its own tasks, and the TCP/IP stack runs more. When you call xTaskCreate, you are not adding an RTOS to a bare-metal program — you are joining the one that is already there. Two consequences:
- Arduino
delay()yields. Unlike AVR Arduino wheredelay()busy-waits, the ESP32 core implementsdelay()asvTaskDelay()— it already yields to other tasks. Your olddelay()-based sketches were accidentally cooperative; FreeRTOS lets you be deliberate about it. - Library calls can block tasks, not the chip. A library that waits on WiFi blocks only the calling task; the scheduler keeps everything else alive. This is why structuring work as tasks is so forgiving on the ESP32 — one slow library stalls one task, and you see exactly which one in the logs.
Three project patterns, mapped to primitives
Most final-year IoT builds are compositions of three patterns. Recognise yours and the FreeRTOS design writes itself:
Pattern 1: Sense → decide → actuate (control loops). Example: a soil-moisture node that opens a valve. Tasks: sensorTask (reads, queues data), controlTask (pulls data, applies thresholds/hysteresis, queues commands), actuatorTask (pulls commands, drives relay/valve). Queues decouple the stages so a slow sensor never delays actuation, and the control logic is testable without hardware — feed the queue from a test task.
Pattern 2: Sense → publish → dashboard (telemetry). Example: an energy meter streaming to a live dashboard. Tasks: meterTask (samples at fixed rate into a queue), mqttTask (publishes; owns the network), displayTask (local OLED). The queue between meter and MQTT absorbs network stalls: if WiFi drops for a minute, readings accumulate in the queue instead of being lost — size the queue for the outage you want to ride through (a 60-deep queue at 1 Hz = 60 seconds of buffer).
Pattern 3: Event → capture → analyse (triggered sensing). Example: vibration-triggered anomaly detection. An accelerometer interrupt fires on threshold crossing → ISR gives a notification → captureTask wakes, grabs a burst of samples into a buffer → mlTask runs inference on the buffer → result queued to mqttTask. Nothing polls; the CPU sleeps (or does other tasks) until something actually happens. This is the lowest-power multitasking structure and pairs naturally with the low-power design guide.
When NOT to use FreeRTOS tasks
Deliberate multitasking is not always the answer. Skip tasks when:
- The whole program is genuinely sequential (read sensor → print to serial → sleep). One task — the Arduino
loop()— is the right design, and adding tasks adds failure modes for no benefit. - You have fewer than ~3 concurrent concerns. Two periodic jobs are often cleaner as one task with two
vTaskDelayphases or a software timer. - You are on an AVR Arduino (Uno/Nano): FreeRTOS exists for AVR but the 2 KB RAM makes tasks painful. This guide's advice is ESP32-shaped; on an Uno,
millis()state machines remain the pragmatic choice.
The heuristic: one task per independent timing domain. If two things need to happen on different schedules and neither may wait for the other, they are two tasks. If they share a schedule, they are one task.
Debugging FreeRTOS firmware: log like you mean it
Multitasking bugs are timing bugs — they vanish when you stare at them. Disciplined logging is the debugger:
- Name every task and print the name in logs.
Serial.printf("[mqttTask] published %.1f C\n", t)tells you who said it. Untagged logs in a 6-task system are noise. - Log state transitions, not every loop. A queue send/receive on every iteration floods the serial port (and the logging itself changes timing). Log when a task starts work, finishes work, or hits an error — plus a periodic heartbeat (every 30 s) so silence is informative.
- Keep a system-status snapshot. The watchdog task from the worked example should print free heap (
xPortGetFreeHeapSize()), each task's stack high-water mark, and uptime. When the device crashes after 6 hours, this history shows the trend — a shrinking heap points at a leak; a shrinking stack margin points at the overflow. - Reproduce with one task at a time. Comment out
xTaskCreatecalls to bisect: if the crash disappears with the display task disabled, the bug is in the display task or its interactions. Binary search beats staring. - Beware the observer effect. Heavy logging changes task timing and can hide race conditions. Once you have a hypothesis, confirm it with a minimal log (or a GPIO toggle watched on the logic analyser) rather than full tracing.
From here: the FreeRTOS features you will grow into
This guide covered the working set — tasks, delays, queues, mutexes, semaphores, notifications, timers, watchdog. When the project grows, the next layer is: event groups (a task waiting on any-of/several conditions without a queue), stream/message buffers (lighter than queues for byte streams like UART data), and direct-to-task notifications as counting semaphores (for counting events, not just signalling). You do not need them on day one; recognise the moment — "I need to wait for A or B" is the event-group moment — and learn that one primitive then.