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, ¶ms, 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
FromISRvariants in interrupt handlers; callportYIELD_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 = 2during development
What’s next
- ADC + DMA on STM32 — feeding the ADC task from a DMA pipeline
- Lock-free queues — when FreeRTOS queues are too heavy
- Active object pattern — encapsulating a task + queue as one C++ class