Skip to main content
NotesFirmwareNew

Real-Time Operating System (RTOS): FreeRTOS on ESP32

A beginner-friendly guide to using FreeRTOS on ESP32 for multitasking firmware with tasks, queues, notifications, mutexes, and practical debugging habits.

Share

LinkedInFacebookX
An ESP32 development board connected to a small sensor on a realistic electronics workbench

When an ESP32 project only reads one sensor and blinks one LED, a single loop() feels perfectly fine. The trouble starts when Wi-Fi, a display, buttons, logging, and sensor sampling all need attention at different times.

This is where FreeRTOS helps. It lets us split firmware into small tasks that can wait, wake up, and communicate without turning one giant loop into a traffic jam. In ESP-IDF, FreeRTOS is already part of the system, so you do not install a separate operating system on the board.

An ESP32 board connected to a push button and LED on a breadboard for a multitasking demo

A small setup is enough to observe several tasks working together.

What does “real-time” mean here?

Real-time does not mean “everything runs instantly”. It means we can define how work should be scheduled and what delay is acceptable. A button task may react within a few milliseconds, while a temperature task only needs to run once every second.

FreeRTOS gives us the building blocks to express that intent:

  • Task: an independent unit of work with its own stack and priority.
  • Queue: a small mailbox for passing data between tasks.
  • Task notification: a lightweight signal sent directly to one task.
  • Mutex: a lock for protecting a shared resource, such as one display.
  • Event group: a set of bits for system states such as Wi-Fi ready or sensor ready.
  • Software timer: a callback scheduled from the FreeRTOS timer service task.

The scheduler normally runs the highest-priority task that is ready. A task that calls vTaskDelay() or waits on a queue becomes blocked, allowing another ready task to run.

A simple architecture that scales

For a small sensor demo, I would separate the work like this:

  1. sensor_task samples the input on a fixed interval.
  2. It sends a compact reading to a queue.
  3. worker_task receives the reading and decides whether the LED should change.
  4. logger_task prints status without making the sensor task wait on serial output.

Diagram of sensor, queue, worker and logger tasks working together in ESP32 FreeRTOS firmware

Give each task a clear role, and use a queue as the data boundary.

The important detail is the boundary between tasks. Instead of letting every task touch every global variable, pass a small message. That makes ownership easier to reason about and reduces accidental races.

Creating two tasks with ESP-IDF

Create a project with ESP-IDF, then include the FreeRTOS headers you need:

#include "freertos/FreeRTOS.h"
#include "freertos/task.h"

static void heartbeat_task(void *arg)
{
    for (;;) {
        // Toggle an LED or perform a small periodic job here.
        vTaskDelay(pdMS_TO_TICKS(500));
    }
}

void app_main(void)
{
    xTaskCreate(
        heartbeat_task,
        "heartbeat",
        2048,               // ESP-IDF stack size is in bytes
        NULL,
        5,                  // priority
        NULL
    );
}

A task function normally runs forever. If it really needs to finish, call vTaskDelete(NULL) instead of simply returning. On dual-core ESP32 targets, xTaskCreatePinnedToCore() can make core affinity explicit; xTaskCreate() leaves the task unpinned in IDF FreeRTOS.

One ESP32-specific detail is easy to miss: ESP-IDF specifies task stack sizes in bytes, while classic Vanilla FreeRTOS documentation often describes stack depth in words. Always check the API for the port you are using.

Passing sensor data through a queue

Here is the core of a small producer-consumer example. The queue copies the message, so the producer does not need to keep a pointer to a temporary local variable alive.

#include "freertos/FreeRTOS.h"
#include "freertos/queue.h"
#include "freertos/task.h"

typedef struct {
    int value;
    TickType_t tick;
} sensor_reading_t;

static QueueHandle_t reading_queue;

static void sensor_task(void *arg)
{
    for (;;) {
        sensor_reading_t reading = {
            .value = read_sensor_value(),
            .tick = xTaskGetTickCount(),
        };

        // Block briefly if the consumer is temporarily behind.
        xQueueSend(reading_queue, &reading, pdMS_TO_TICKS(20));
        vTaskDelay(pdMS_TO_TICKS(200));
    }
}

static void worker_task(void *arg)
{
    sensor_reading_t reading;

    for (;;) {
        if (xQueueReceive(reading_queue, &reading, portMAX_DELAY)) {
            set_led(reading.value > 700);
        }
    }
}

void app_main(void)
{
    reading_queue = xQueueCreate(8, sizeof(sensor_reading_t));
    configASSERT(reading_queue != NULL);

    xTaskCreate(sensor_task, "sensor", 3072, NULL, 5, NULL);
    xTaskCreate(worker_task, "worker", 3072, NULL, 5, NULL);
}

Queues are a good default when a task needs to send actual data. Keep messages small: a reading, an enum, or a pointer to a buffer with clear ownership. Do not put a large image frame directly into a tiny queue; use a buffer pool or a ring buffer for that kind of workload.

Queue, notification, semaphore, or mutex?

The choice becomes much easier if you start from the question “what am I trying to communicate?”

NeedGood fitExample
Send a value or structQueueSensor reading to a processing task
Wake exactly one taskTask notificationUART received, work is ready
Protect one shared deviceMutexTwo tasks share an OLED display
Signal an event from an ISRBinary semaphore or notificationGPIO interrupt wakes a task
Wait for several system statesEvent groupWi-Fi connected and sensor initialized

A mutex is not just a binary semaphore with a different name: it provides priority inheritance and is intended for mutual exclusion. Use it around a short critical section, not around a network request or a long delay.

For example:

static SemaphoreHandle_t display_mutex;

void display_status(const char *text)
{
    if (xSemaphoreTake(display_mutex, pdMS_TO_TICKS(50)) == pdTRUE) {
        oled_print(text);
        xSemaphoreGive(display_mutex);
    }
}

If the display update can be owned by one task, an even cleaner design is to send display messages to that task instead of locking the display from multiple places.

Timing: delay is not synchronization

This pattern is fragile:

vTaskDelay(pdMS_TO_TICKS(1000));
assume_wifi_is_ready();

The delay only means “do not run this task for at least this long”. It does not prove that Wi-Fi, a peripheral, or another task has finished. Use a queue, notification, semaphore, or event group when one task must wait for a real event.

For periodic work, vTaskDelayUntil() keeps a steadier schedule than repeatedly adding a relative delay:

TickType_t last_wake = xTaskGetTickCount();
const TickType_t period = pdMS_TO_TICKS(100);

for (;;) {
    sample_sensor();
    vTaskDelayUntil(&last_wake, period);
}

The task can still miss a deadline if its work takes too long or a higher-priority task consumes the CPU. That is why measuring the real workload matters more than choosing a pretty number for the delay.

Stack size, priority, and the bugs they hide

Give a task enough stack for its deepest call path, but do not solve every crash by allocating a huge stack. On ESP32, task stacks consume RAM that the rest of the application also needs.

During testing, inspect the minimum unused stack:

ESP_LOGI("worker", "stack spare: %u bytes",
         uxTaskGetStackHighWaterMark(NULL));

Exercise the real worst case: long JSON, a busy Wi-Fi transaction, a display refresh, and the error path. Also keep large arrays out of the task's local scope when a dedicated buffer is clearer.

Priorities should express urgency, not importance in the product roadmap. A high-priority task that never blocks can starve lower-priority work. Most application tasks can start at a modest priority, block on the object they need, and be raised only after measurement shows a scheduling problem.

A logic analyzer and Dupont leads on a workbench while preparing to inspect firmware timing

Set up the measurement clearly first; when timing is unstable, the real signal is often faster than guessing.

A practical debugging routine

When the firmware behaves “randomly”, I check these things in order:

  1. Give every task a useful name and log its core ID when debugging a dual-core target.
  2. Check the return value of xQueueSend, xQueueReceive, mutex takes, and task creation.
  3. Log queue depth and uxTaskGetStackHighWaterMark() under the heaviest workflow.
  4. Use a mutex for shared state; do not rely on disabling the scheduler as a cross-core lock.
  5. Keep logs out of tight timing loops and avoid blocking network calls while holding a mutex.
  6. If timing still looks wrong, put a GPIO toggle around the work and inspect it with a logic analyzer.

Do not use printf as the only timing instrument. Serial logging itself takes time and can change the behavior you are trying to measure.

My beginner rule of thumb

Start with two or three tasks, not ten. Give each task one job, define how it receives data, and make the ownership of every buffer explicit. Use queues for messages, notifications for a direct wake-up, mutexes for short shared-resource access, and event groups for broad system state.

FreeRTOS feels complicated when every task can touch everything. It becomes much more comfortable when the firmware is treated as a set of small workers connected by narrow, visible paths. That is the habit worth carrying into a larger ESP32 product.

References

Share

LinkedInFacebookX

Keep exploring

Read next

Related articles

View more in Notes

Nastrotek uses cookies for analytics and ad personalization to help us understand how the site is used. You can accept or decline non-essential cookies. Privacy Policy