The wrong way to start with FreeRTOS

The most common mistake when adding FreeRTOS to a project is recreating the bare-metal structure with tasks:

 1// Task 1 — reads sensor
 2void sensorTask(void*) {
 3    while (true) {
 4        g_sensorValue = readAdc();    // writes global
 5        vTaskDelay(pdMS_TO_TICKS(10));
 6    }
 7}
 8
 9// Task 2 — processes data
10void processTask(void*) {
11    while (true) {
12        float v = g_sensorValue;      // reads global — data race
13        process(v);
14        vTaskDelay(pdMS_TO_TICKS(10));
15    }
16}

This has a data race on g_sensorValue. It works on Cortex-M because 32-bit aligned reads and writes are atomic on ARMv7-M — but it’s undefined behaviour in C++, it won’t work for multi-byte types, and it’s not portable across MCU families.

The correct model: tasks communicate through queues, not shared variables.


The queue as a communication channel

A FreeRTOS queue is a thread-safe, blocking FIFO. One task sends; another receives. No shared variables, no mutexes needed for the data transfer itself.

 1// Define message type
 2struct SensorReading {
 3    uint32_t timestamp_ms;
 4    float    value;
 5    uint8_t  channel;
 6};
 7
 8// Create queue (holds up to 8 readings)
 9static QueueHandle_t sensorQueue = xQueueCreate(8, sizeof(SensorReading));
10
11// Sender task
12void sensorTask(void*) {
13    while (true) {
14        SensorReading reading {
15            .timestamp_ms = HAL_GetTick(),
16            .value        = readAdc(),
17            .channel      = 0
18        };
19        // Send — doesn't block if queue has space; drops if full (timeout = 0)
20        xQueueSend(sensorQueue, &reading, 0);
21        vTaskDelay(pdMS_TO_TICKS(10));
22    }
23}
24
25// Receiver task
26void processTask(void*) {
27    SensorReading reading;
28    while (true) {
29        // Block indefinitely until a reading arrives
30        if (xQueueReceive(sensorQueue, &reading, portMAX_DELAY) == pdTRUE) {
31            process(reading.value);
32        }
33    }
34}

The receiver blocks on xQueueReceive — it consumes zero CPU while waiting. The scheduler switches to another task. When the sender posts a message, the receiver unblocks immediately if its priority is higher; otherwise at the next scheduler tick.


Sending from an ISR

Queues are accessible from ISRs using the FromISR variants. These don’t block and use a higher-priority wakeup mechanism:

 1void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef* hadc) {
 2    SensorReading reading {
 3        .timestamp_ms = HAL_GetTick(),
 4        .value        = HAL_ADC_GetValue(hadc) * (3.3f / 4096.0f),
 5        .channel      = 0
 6    };
 7
 8    BaseType_t higherPriorityTaskWoken = pdFALSE;
 9    xQueueSendFromISR(sensorQueue, &reading, &higherPriorityTaskWoken);
10
11    // If a higher-priority task was unblocked, request a context switch
12    portYIELD_FROM_ISR(higherPriorityTaskWoken);
13}

portYIELD_FROM_ISR ensures the newly unblocked task runs immediately after the ISR returns, rather than waiting for the next tick. This minimises latency for real-time processing.

Never call non-FromISR FreeRTOS functions from an ISR. xQueueSend from an ISR will corrupt the kernel state.


Task structure

Each task follows the same pattern: initialise, then loop forever processing messages.

 1void processingTask(void* pvParameters) {
 2    // 1. Initialisation (runs once, in task context)
 3    Filter filter(8);
 4    Display* display = static_cast<Display*>(pvParameters);
 5
 6    SensorReading reading;
 7
 8    // 2. Infinite loop
 9    for (;;) {
10        if (xQueueReceive(inputQueue, &reading, portMAX_DELAY) == pdTRUE) {
11            float filtered = filter.process(reading.value);
12            display->show(filtered);
13        }
14    }
15    // Never reaches here — tasks don't return in FreeRTOS
16    // vTaskDelete(nullptr);  // delete self if task can exit
17}

Pass dependencies through pvParameters — cast to the correct type inside the task. For multiple parameters, wrap them in a struct:

1struct ProcessingTaskParams {
2    Display*    display;
3    IAlarmSink* alarm;
4    float       threshold;
5};
6
7// In main/init:
8static ProcessingTaskParams params { &tftDisplay, &buzzer, 25.0f };
9xTaskCreate(processingTask, "Process", 512, &params, 3, nullptr);

Stack sizing

FreeRTOS stack sizes are specified in words (4 bytes on Cortex-M), not bytes.

1// 512 words = 2048 bytes
2xTaskCreate(sensorTask, "Sensor", 512, nullptr, 2, nullptr);

A task’s stack must hold:

  • All local variables across the call tree
  • The task context saved by the scheduler (typically 17 registers = 68 bytes on Cortex-M)
  • Interrupt frames if the task can be preempted (it always can)

Common mistake: underestimating the stack because printf/sprintf adds ~400–800 bytes of local variables for formatting buffers.

Check stack usage at runtime with FreeRTOS water-marking:

1// In a diagnostic task:
2UBaseType_t hwm = uxTaskGetStackHighWaterMark(sensorTaskHandle);
3// hwm = minimum free words ever observed; if 0, the stack overflowed

Run this during stress testing. Set stack sizes so hwm stays above ~50–100 words.


Priority assignment

FreeRTOS uses priorities 0 (idle) to configMAX_PRIORITIES - 1 (highest). Higher number = higher priority.

Rate monotonic scheduling is a proven approach for periodic tasks: assign higher priority to tasks with shorter periods.

Period 1 ms  → Priority 5  (e.g. ADC processing)
Period 10 ms → Priority 4  (e.g. sensor fusion)
Period 50 ms → Priority 3  (e.g. display update)
Period 100ms → Priority 2  (e.g. UART logging)
Idle logic   → Priority 1

Avoid giving multiple tasks the same priority unless they genuinely need round-robin scheduling — FreeRTOS will time-slice them, which can cause unpredictable latency.

Priority inversion: if a high-priority task waits on a mutex held by a low-priority task, and a medium-priority task preempts the low-priority task, the high-priority task is effectively blocked by the medium one. Use xSemaphoreCreateMutex (which implements priority inheritance) rather than binary semaphores for shared resources.


Notification instead of semaphore

For simple “signal this task” patterns, task notifications are faster and use less RAM than binary semaphores:

 1static TaskHandle_t processingTaskHandle;
 2
 3// In ISR: wake the processing task
 4void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef*) {
 5    BaseType_t woken = pdFALSE;
 6    vTaskNotifyGiveFromISR(processingTaskHandle, &woken);
 7    portYIELD_FROM_ISR(woken);
 8}
 9
10// In processing task:
11void processingTask(void*) {
12    for (;;) {
13        ulTaskNotifyTake(pdTRUE, portMAX_DELAY);  // block until notified
14        processLatestAdcBuffer();
15    }
16}

Use notifications when:

  • One producer signals one consumer
  • No data is passed (data is in a shared buffer managed by the caller)
  • Minimum overhead is required

Use queues when:

  • Data must be passed with the signal
  • Multiple items can be pending simultaneously
  • Multiple producers or consumers exist

Typical task topology

[ADC ISR] ──notify──▶ [ADC Task] ──queue──▶ [Filter Task] ──queue──▶ [Display Task]
                                                  │
                                                  └──queue──▶ [Logger Task] ──UART──▶ serial
  • Each arrow is a queue or notification — no shared variables
  • Each task has a single input and one or more outputs
  • Tasks can be independently tested by mocking their queues

Common pitfalls

Blocking in an ISR: calling xQueueSend (not FromISR) from an ISR will assert in debug builds or silently corrupt the kernel in release.

Zero-timeout sends dropping data: xQueueSend(queue, &msg, 0) returns errQUEUE_FULL silently if the queue is full. Either use a timeout, increase the queue depth, or handle the return value.

Stack overflow: configCHECK_FOR_STACK_OVERFLOW in FreeRTOSConfig.h should be set to 2 during development. This enables a watermark pattern check on every context switch — small runtime cost, catches overflows before they corrupt memory.

Forgetting to call vTaskStartScheduler: FreeRTOS tasks are created before the scheduler starts. None of them run until vTaskStartScheduler() is called in main(). After that call, main() never returns.


Summary

  • Communicate between tasks via queues, not globals
  • Use FromISR variants in interrupt handlers; call portYIELD_FROM_ISR
  • Size stacks with water-marking; check during stress testing
  • Assign priorities by period (rate-monotonic) — shorter period = higher priority
  • Prefer task notifications over semaphores for single-producer/single-consumer wakeups
  • Enable configCHECK_FOR_STACK_OVERFLOW = 2 during development

What’s next