RTOS

RTOS Concepts: Tasks, Queues, Semaphores, Mutexes

The hardest part of learning an RTOS is the vocabulary. Once you know what a task is, what a queue is, and what a semaphore is, the actual API calls are unsurprising. This article skips the academic version and goes straight to the working understanding you need to write firmware on top of FreeRTOS, Zephyr, or any similar kernel.

Code samples use FreeRTOS because it is the most widely deployed and the API generalises directly to other kernels.

The mental shift

Bare-metal firmware is one main loop that does everything. RTOS firmware is multiple independent loops (tasks) that the kernel switches between. The kernel decides which task runs at any given moment based on priorities and what each task is waiting for.

flowchart TD subgraph Bare_Metal [Bare metal] Loop[main loop
handles everything in turn] end subgraph RTOS [With RTOS] Task1[Task: Sensor reader
priority 2] Task2[Task: WiFi handler
priority 3] Task3[Task: UI updater
priority 1] Sched[Kernel scheduler
preempts based on priority] Sched --> Task1 Sched --> Task2 Sched --> Task3 end

Bare metal: one loop juggles everything. RTOS: independent tasks; the kernel decides who runs.

Tasks

A task is a function that runs forever (typically while (1) { ... }) with its own private stack. The kernel can pause it at any moment, save its state, and resume another task. From the task's point of view, time mostly stands still while another task is running — it picks up exactly where it left off.

void sensor_task(void *params) {
    while (1) {
        int reading = read_sensor();
        send_to_queue(reading);
        vTaskDelay(pdMS_TO_TICKS(100));   // sleep 100 ms
    }
}

void app_main() {
    xTaskCreate(sensor_task,        // function
                "sensor",           // name (debug only)
                2048,               // stack size in words
                NULL,               // parameter
                2,                  // priority
                NULL);              // handle (optional)
}

The crucial primitive: vTaskDelay. While the task is delaying, the kernel runs other tasks. This is fundamentally different from delay() on bare metal — an RTOS delay yields the CPU to others rather than blocking everything.

Priorities

Each task has an integer priority. Higher numbers preempt lower numbers. If a high-priority task wakes up while a low-priority task is running, the kernel immediately suspends the low-priority task and runs the high-priority one.

Common priority assignments:

  • Idle: priority 0. Runs only when nothing else needs the CPU. Often used for power-saving (enters WFI/sleep).
  • Background: priority 1–2. Periodic logging, UI refresh, statistics.
  • Application: priority 3–5. Sensor reading, business logic.
  • Communication: priority 6–8. WiFi, BLE, network stacks.
  • Critical: priority 9–15. Hardware interrupts, real-time control loops.

Priority inversion is the classic gotcha: a high-priority task waiting on a resource held by a low-priority task gets blocked indefinitely if a medium-priority task preempts the low-priority one. We covered this in the bare-metal vs RTOS article. Mitigation: priority inheritance on mutexes (FreeRTOS supports this if you use mutexes, not raw semaphores).

Queues

A queue is a fixed-size FIFO buffer that one task writes to and another reads from. The kernel handles all the synchronisation. If the queue is empty, the reader sleeps until data arrives. If the queue is full, the writer either blocks or returns failure.

QueueHandle_t sensor_queue;

void producer_task(void *params) {
    while (1) {
        int value = read_sensor();
        xQueueSend(sensor_queue, &value, portMAX_DELAY);
        vTaskDelay(pdMS_TO_TICKS(100));
    }
}

void consumer_task(void *params) {
    int value;
    while (1) {
        if (xQueueReceive(sensor_queue, &value, portMAX_DELAY) == pdTRUE) {
            process(value);
        }
    }
}

void app_main() {
    sensor_queue = xQueueCreate(10, sizeof(int));   // 10 ints
    xTaskCreate(producer_task, "producer", 2048, NULL, 2, NULL);
    xTaskCreate(consumer_task, "consumer", 2048, NULL, 2, NULL);
}

Queues are the most useful RTOS primitive. They handle synchronisation, buffering, and decoupling between tasks all at once. Most inter-task communication should be a queue, not shared global variables with manual locking.

Semaphores

A semaphore is a counter that tasks can increment (give) or decrement (take). If a task tries to take a semaphore at zero, it blocks until someone gives it.

Two flavours:

  • Binary semaphore: 0 or 1. Used for signaling — "something happened". An ISR gives the semaphore on an interrupt; a task takes it and processes the event.
  • Counting semaphore: 0 to N. Used for resource pools — "up to N parallel users". Take to use one slot; give to release it.
SemaphoreHandle_t button_sem;

void IRAM_ATTR button_isr(void *arg) {
    BaseType_t higher_priority_woken = pdFALSE;
    xSemaphoreGiveFromISR(button_sem, &higher_priority_woken);
    portYIELD_FROM_ISR(higher_priority_woken);
}

void button_handler_task(void *params) {
    while (1) {
        if (xSemaphoreTake(button_sem, portMAX_DELAY) == pdTRUE) {
            handle_button_press();
        }
    }
}

The pattern: ISR signals, task processes. Keeps the ISR microscopic; lets the task do the real work in normal context.

Mutexes

A mutex is a special binary semaphore designed for protecting shared resources. The key difference: it tracks which task holds it, and supports priority inheritance.

SemaphoreHandle_t i2c_mutex;

void sensor_a_task(void *params) {
    while (1) {
        xSemaphoreTake(i2c_mutex, portMAX_DELAY);
        int a = read_sensor_a_via_i2c();
        xSemaphoreGive(i2c_mutex);
        // process a
        vTaskDelay(pdMS_TO_TICKS(100));
    }
}

void sensor_b_task(void *params) {
    while (1) {
        xSemaphoreTake(i2c_mutex, portMAX_DELAY);
        int b = read_sensor_b_via_i2c();
        xSemaphoreGive(i2c_mutex);
        vTaskDelay(pdMS_TO_TICKS(100));
    }
}

Without the mutex, the two tasks would corrupt each other's I2C transactions when the kernel switches between them mid-transfer. With the mutex, only one task uses the bus at a time.

If a high-priority task waits on a mutex held by a low-priority one, FreeRTOS temporarily boosts the low-priority task's priority to the waiter's level, ensuring it finishes its critical section quickly. This is priority inheritance.

Event groups

An event group is a set of bit flags. Tasks can wait for any combination of bits to be set. Useful for "wait for either WiFi to connect or the timeout to fire" patterns.

EventGroupHandle_t events;
#define WIFI_CONNECTED_BIT  (1 << 0)
#define DATA_READY_BIT      (1 << 1)

void wait_for_data() {
    EventBits_t bits = xEventGroupWaitBits(
        events,
        WIFI_CONNECTED_BIT | DATA_READY_BIT,
        pdFALSE,    // do not clear on exit
        pdTRUE,     // wait for ALL bits
        pdMS_TO_TICKS(5000));
}

Common pitfalls

  • Stack too small. Each task has its own stack. Default of 1 KB or 2 KB words is enough for simple tasks; complex ones (with printf, JSON parsing, etc.) often need 4–8 KB. Stack overflow corrupts adjacent tasks silently.
  • Sharing globals without protection. Two tasks updating the same global variable without a mutex produces races. Even a 32-bit assignment is not always atomic on 32-bit chips if it crosses a word boundary.
  • ISR using non-FromISR functions. RTOS functions called from interrupt context must use the FromISR variant. Calling regular versions can corrupt the kernel state.
  • Forgetting to give back a mutex. A task that takes a mutex and returns without giving holds it forever. Make sure every code path releases.
  • Priority inversion via raw semaphores. Use mutexes for resource protection (priority inheritance). Use semaphores for signaling.

Frequently Asked Questions

How much memory does FreeRTOS use?

The kernel itself is around 5–10 KB of flash. Each task adds its stack (typically 1–4 KB of RAM). Each queue/semaphore/mutex is a small constant. A minimal three-task system fits comfortably in 16 KB of RAM.

Should I use the Arduino framework or ESP-IDF for FreeRTOS on ESP32?

Both expose FreeRTOS. The Arduino core hides some details and uses a default 8 KB stack for the main loop task. ESP-IDF exposes the full API and is what professional ESP32 firmware uses. Start with Arduino; switch to ESP-IDF when you outgrow it.

What is the difference between FreeRTOS and Zephyr?

FreeRTOS is a small RTOS with a focused API. Zephyr is a full embedded operating system with networking, file systems, USB, BLE, and a configuration system. Zephyr's footprint is bigger; its capabilities are also bigger. For a sensor node, FreeRTOS suffices. For a connected industrial device, Zephyr saves you from gluing many libraries together.

Can I use C++ with FreeRTOS?

Yes, with caveats. Static class members work fine. Avoid exceptions, RTTI, and operator new in tasks (heap allocations are bounded by the FreeRTOS heap). The Arduino ESP32 core is C++ and uses FreeRTOS underneath; mixed-language firmware is normal.

How do I debug task crashes?

Stack overflow is the most common cause. FreeRTOS has a stack-overflow hook (vApplicationStackOverflowHook) you can implement to log which task overflowed. For deeper debugging, use SWD with a real-time aware debugger; SEGGER Ozone in particular shows you per-task stack usage and event traces.

Share your thoughts

Worked with this in production and have a story to share, or disagree with a tradeoff? Email us at support@mybytenest.com — we read everything.