Skip to content

FreeRTOS Tasks & Queues

Every Level 1 sketch had one loop(). It felt single-threaded, but it never was: the Arduino-ESP32 core boots FreeRTOS first and runs your setup() + loop() inside a task called loopTask, pinned to core 1 at priority 1, while the WiFi/Bluetooth stack quietly runs its own tasks on core 0. This module makes that machinery explicit — you'll create tasks of your own, pin them to a core, and pass data between them safely with queues and semaphores. Everything here still runs from a normal Arduino sketch; ESP-IDF's native project structure is next module.

Why one loop() stops being enough

A single loop forces every job — sampling a sensor, debouncing a button, running a display, watching the network — into one shared timeline, held together by millis() bookkeeping (module 1-07). That works, but it has limits: jobs with genuinely different timing needs (sample at exactly 1 kHz; block for up to 100 ms waiting on a bus) can't cleanly share one scheduling loop. Tasks give each job its own stack, its own priority, and its own call to vTaskDelay() — the FreeRTOS scheduler does the interleaving for you.

Creating a task

void sensorTask(void *pvParameters) {
  for (;;) {
    Serial.println("sampling...");
    vTaskDelay(pdMS_TO_TICKS(500));   // yields the CPU for ~500 ms
  }
  // a task must never fall off the end of its function —
  // if it can exit, call vTaskDelete(NULL) instead
}

void setup() {
  Serial.begin(115200);

  xTaskCreatePinnedToCore(
    sensorTask,      // function to run
    "sensorTask",    // name, for debugging (shows up in uxTaskGetStackHighWaterMark etc.)
    4096,            // stack size — IN BYTES on ESP-IDF, see the warning below
    NULL,            // parameter passed into the task function
    2,               // priority (higher number = higher priority)
    NULL,            // out: task handle, if you need to reference it later
    1                // core: 0, 1, or tskNO_AFFINITY to let the scheduler choose
  );
}

void loop() {
  vTaskDelay(pdMS_TO_TICKS(1000));   // loopTask itself is just another task
}

xTaskCreatePinnedToCore() returns pdPASS on success or an error code (commonly errCOULD_NOT_ALLOCATE_REQUIRED_MEMORY) if the heap can't supply the stack — worth checking whenever code creates tasks after boot rather than only at startup.

Stack size is in bytes, not words

Vanilla FreeRTOS measures usStackDepth in words (4 bytes each on a 32-bit core). ESP-IDF's port measures it in bytes. Copy a stack size from a non-ESP FreeRTOS tutorial and your task gets a quarter of the stack you meant to give it — a classic silent-corruption trap. 4096 below means 4 KB, not 16 KB.

Task priorities — and priority inversion

Priorities run from 0 (idle, lowest) up to configMAX_PRIORITIES - 1 (25 on the default ESP32 configuration). The scheduler always runs the highest-priority ready task; equal-priority tasks time-slice. loopTask sits at priority 1, which is why a task you create at priority 2 or higher can starve loop() entirely if it never blocks.

Priority inversion is the classic trap: a low-priority task holds a mutex a high-priority task needs. The high-priority task blocks waiting for it — but a medium-priority task, unrelated to either, keeps preempting the low-priority one, so the mutex never gets released and the high-priority task waits far longer than it should. FreeRTOS's xSemaphoreCreateMutex() solves this with priority inheritance: the low-priority holder is temporarily boosted to the waiter's priority for as long as it holds the lock. A plain binary semaphore (xSemaphoreCreateBinary()) does not do this — use a real mutex whenever a shared resource, not just a signal, is involved.

Queues: passing data between tasks

Never write to a shared variable from two tasks and hope for the best — pass copies of data through a queue instead. A queue is a fixed-length, fixed-item-size FIFO the kernel manages for you:

struct Reading {
  uint32_t timestampMs;
  float celsius;
};

QueueHandle_t readingQueue;

void producerTask(void *pv) {
  for (;;) {
    Reading r = { millis(), 21.5f + (esp_random() % 100) / 100.0f };
    if (xQueueSend(readingQueue, &r, pdMS_TO_TICKS(100)) != pdTRUE) {
      Serial.println("queue full — consumer too slow");
    }
    vTaskDelay(pdMS_TO_TICKS(200));
  }
}

void consumerTask(void *pv) {
  Reading r;
  for (;;) {
    if (xQueueReceive(readingQueue, &r, portMAX_DELAY) == pdTRUE) {
      Serial.printf("t=%lu  %.2f C\n", r.timestampMs, r.celsius);
    }
  }
}

void setup() {
  Serial.begin(115200);
  readingQueue = xQueueCreate(10, sizeof(Reading));   // 10 slots of struct Reading

  xTaskCreatePinnedToCore(producerTask, "producer", 2048, NULL, 2, NULL, 1);
  xTaskCreatePinnedToCore(consumerTask, "consumer", 2048, NULL, 2, NULL, 1);
}

void loop() { vTaskDelay(portMAX_DELAY); }

xQueueSend()'s third argument is how long to wait for space if the queue is full (0 = don't block, portMAX_DELAY = wait forever); xQueueReceive() works the same way waiting for data. portMAX_DELAY in the consumer means "block efficiently until something arrives" — no polling, no wasted CPU. Structs passed by value are copied into the queue's internal buffer, so the producer is free to reuse or destroy r immediately after xQueueSend() returns.

Protecting a shared peripheral with a mutex

Two tasks both calling Wire. (I2C, module 1-06) or Serial.print() at the same time is a race condition — bytes from both calls can interleave on the bus or the wire, producing garbage neither task sent. A mutex makes the critical section atomic:

SemaphoreHandle_t i2cMutex;

void readSensorSafely() {
  if (xSemaphoreTake(i2cMutex, pdMS_TO_TICKS(50)) == pdTRUE) {
    Wire.beginTransmission(0x3C);
    // ... talk to the bus ...
    Wire.endTransmission();
    xSemaphoreGive(i2cMutex);        // always release, even on early return
  } else {
    Serial.println("i2c busy, skipped this cycle");
  }
}

void setup() {
  i2cMutex = xSemaphoreCreateMutex();
  Wire.begin();
  // ... create tasks that call readSensorSafely() ...
}

The rule: any resource touched from more than one task — a bus, a shared struct, a file — needs a mutex, or it needs to live behind a single task that owns it exclusively (the producer/consumer pattern above follows the same idea: only the consumer task ever touches the serial port for readings).

Detecting stack overflow before it corrupts memory

An overrun task stack silently tramples whatever memory sits next to it — often another task's stack or heap data — producing symptoms nowhere near the real cause. Check headroom instead of guessing:

void sensorTask(void *pv) {
  for (;;) {
    // ... work ...
    UBaseType_t wordsFree = uxTaskGetStackHighWaterMark(NULL);  // NULL = this task
    if (wordsFree < 200) {
      Serial.printf("WARNING: %s low on stack: %u\n", pcTaskGetName(NULL), wordsFree);
    }
    vTaskDelay(pdMS_TO_TICKS(500));
  }
}

uxTaskGetStackHighWaterMark() reports the closest the task ever came to running out, not the current free space — a small, unchanging number across many cycles means the task found its steady-state stack use early; a number that keeps shrinking means something (often unbounded recursion or a large local buffer on one code path) is eating more stack over time. Module 2-08 covers the complementary safety net — watchdogs — for when a task stops checking in at all.

How It Actually Works

What a "task" physically is: each xTaskCreatePinnedToCore() call allocates a Task Control Block (TCB) — a struct holding the task's saved CPU register state, priority, and a pointer to its own private stack, which FreeRTOS carves out of the heap at the byte size you requested. A context switch (the scheduler moving from one task to another) is the same mechanism as an interrupt-driven register save/restore from module 1-07's ISR discussion, just triggered by the FreeRTOS tick interrupt (a hardware timer firing at the configured tick rate) or by a task voluntarily yielding via vTaskDelay: the current task's registers get pushed onto its own stack, the scheduler picks the next ready task by priority, and that task's previously-saved registers are popped back off its stack — which is exactly why each task needs an independent stack region in the first place, and why undersizing one (the 768-byte exercise) causes it to write past its allocated region into whatever memory sits next, corrupting another task's TCB or stack with no hardware memory protection to catch it (these chips have no MMU-enforced per-task stack guard by default).

Why vTaskDelay costs nothing while while(1){} costs everything: vTaskDelay removes the calling task from the scheduler's ready list and places it in a delayed list ordered by wake time, then immediately triggers a context switch to whatever task is next; the CPU is doing real work for some other task (or, if none is ready, the idle task, which can trigger light-sleep) rather than spinning. A blocking while loop, by contrast, keeps the task in the running state, consuming every cycle the scheduler gives it and starving any equal-or-lower priority task — which is the mechanism behind "a task at priority 2 can starve loop() (priority 1) entirely."

The queue's actual data-copy mechanism: xQueueCreate(len, itemSize) allocates one contiguous buffer of len × itemSize bytes plus internal head/tail indices. xQueueSend does a memcpy of your struct's bytes into the next free slot — not a pointer store — which is precisely why the producer can safely reuse or destroy its local Reading r immediately after the call returns: the queue owns an independent copy. xQueueReceive copies those same bytes back out into the caller's variable. Internally, both calls manipulate the queue's list of tasks blocked waiting on it (a task calling xQueueReceive with portMAX_DELAY on an empty queue is removed from the ready list and put on the queue's own wait list — again, zero CPU spent polling — and the scheduler wakes it the instant xQueueSend completes a matching item).

Priority inheritance, mechanically: xSemaphoreCreateMutex() returns a handle that, unlike a plain semaphore, carries an explicit "current owner" field. When a higher-priority task calls xSemaphoreTake and blocks because a lower-priority task holds it, FreeRTOS's mutex implementation directly raises that lower-priority holder's effective priority (a field on its TCB, temporarily) to match the highest-priority task waiting on it — so the scheduler now picks the holder to run ahead of whatever medium-priority task was previously preempting it, letting it finish and call xSemaphoreGive sooner. The instant it gives the mutex back, its effective priority reverts. A plain binary semaphore's TCB carries no such "owner" concept — there is nothing to boost — which is exactly why it can't solve priority inversion.

Cheat sheet

Concept Detail
xTaskCreatePinnedToCore(fn, name, stack, arg, prio, &handle, core) Create + pin a task; returns pdPASS
Stack size units Bytes on ESP-IDF (vanilla FreeRTOS uses words — a real gotcha)
Priority 0 (idle) .. configMAX_PRIORITIES-1 (25); higher preempts lower
loopTask Arduino's loop(), running as a task at priority 1, core 1
vTaskDelay(pdMS_TO_TICKS(ms)) Blocks this task only, yields CPU to others
Priority inversion Low-prio task holds a lock a high-prio task needs; a mid-prio task starves both
Fix: real mutex xSemaphoreCreateMutex() — has priority inheritance; binary semaphores don't
xQueueCreate(len, itemSize) Fixed-size FIFO; copies data, not pointers, between tasks
xQueueSend/xQueueReceive(q, &item, ticks) 0=don't block, portMAX_DELAY=block forever
xSemaphoreTake/Give(mutex, ticks) Guard any resource shared across tasks
uxTaskGetStackHighWaterMark(NULL) Worst-case-ever free stack (words) for the calling task

Exercise

Build a two-task system: a producerTask (priority 2, core 1) that reads a potentiometer every 200 ms and pushes {timestamp, raw} structs into a 10-slot queue, and a consumerTask (priority 1, core 0) that blocks on the queue with portMAX_DELAY and prints each reading. Add a shared Serial mutex even though only the consumer prints today — explain in a comment why you'd still want it the moment a second task needs to log. Then intentionally misconfigure the producer's stack to 768 bytes while giving it a local float buf[100], watch uxTaskGetStackHighWaterMark() head toward zero (or watch it crash outright), and fix it by moving the buffer out of the task's stack or increasing the stack size — note in a comment which fix you chose and why.