Up to this point, every project has used a single main loop with interrupt handlers. That works fine for simple firmware, but as complexity grows, the main loop turns into a tangled mess of timing workarounds and state machines. FreeRTOS solves this by letting you split your firmware into independent tasks, each with its own stack and priority. In this lesson you will restructure the sensor dashboard from previous lessons into clean, separate tasks that communicate through queues and share resources safely with mutexes. #STM32 #FreeRTOS #RTOS
What We Are Building
Multitasking Sensor Dashboard
Three independent tasks running on FreeRTOS: a sensor task that reads the BME280 every 500 ms and pushes data into a queue, a display task that pulls from the queue and updates the SSD1306 OLED, and a serial logging task that formats readings and sends them over UART with DMA. A mutex protects the I2C bus (shared between the BME280 and the INA219), and a binary semaphore synchronizes the display update with fresh data arrival.
Project specifications:
Parameter
Value
Board
Blue Pill (STM32F103C8T6)
RTOS
FreeRTOS v10.5+ (kernel only, no CMSIS-RTOS wrapper)
Task: Sensor
Priority 3 (high), 256-word stack, reads BME280 every 500 ms
Task: Display
Priority 2 (medium), 256-word stack, updates OLED on queue receive
Task: Logger
Priority 1 (low), 256-word stack, formats and sends serial output
Task: Idle
Priority 0 (built-in), runs __WFI for low power
Queue
4 slots, each holds a sensor_data_t struct (12 bytes)
Mutex
I2C bus access (BME280 and INA219 share I2C1)
Parts
Reuse existing (OLED, BME280, serial adapter)
Adding FreeRTOS to Your Project
Getting the FreeRTOS Source
Download FreeRTOS from freertos.org or clone from GitHub:
The FreeRTOS scheduler switches between tasks based on priority. Each task has its own stack and runs independently. The SysTick interrupt drives the tick timer that triggers context switches.
FreeRTOS task scheduling (preemptive):
Priority 3 [Sensor ] [Sensor ]
Priority 2 [Display] [Display]
Priority 1 [Logger ]
Priority 0 [Idle] [Idle]
+----+--+------+-+------+--+-----> time
^ ^ ^ ^ ^ ^
| | | | | |
Sensor Display Logger Sensor Display
wakes gets gets wakes gets
(500ms) data CPU again data
from
queue
Higher priority always preempts lower.
Blocked tasks consume zero CPU time.
A FreeRTOS task is a C function that runs in its own context with its own stack. The scheduler switches between tasks based on priority: the highest-priority ready task always runs. When a task blocks (waiting on a queue, semaphore, or delay), the scheduler immediately switches to the next highest-priority ready task. Tasks never return; they contain an infinite loop and use vTaskDelay() or blocking API calls to yield CPU time.
/* Task function signature */
voidsensor_task(void*params) {
/* One-time setup */
bme280_init();
/* Infinite loop */
for (;;) {
/* Do work */
bme280_read(&data);
/* Block for 500 ms (yields CPU to other tasks) */
vTaskDelay(pdMS_TO_TICKS(500));
}
}
/* Create the task */
xTaskCreate(sensor_task, /* Function */
"Sensor", /* Name (for debugging) */
256, /* Stack size in words */
NULL, /* Parameters */
3, /* Priority (higher = more important) */
&sensor_handle); /* Handle (optional) */
Queues
Queues are the primary way to pass data between tasks. Data flows from producers to consumers through a thread-safe FIFO.
Queue-based data flow:
+--------+ xQueueSend +---------+
| Sensor |===============>| Queue |
| Task | sensor_data_t | (4 slots|
| (P3) | | x 12B) |
+--------+ +----+----+
|
xQueueReceive|
+-------+------+
| |
+----+---+ +-----+---+
| Display| | Logger |
| Task | | Task |
| (P2) | | (P1) |
+--------+ +---------+
Sensor sends every 500 ms.
Display and Logger consume data.
Queue blocks consumers when empty.
They are thread-safe FIFO buffers with a fixed number of fixed-size slots. A producing task sends data with xQueueSend(), and a consuming task receives with xQueueReceive(). If the queue is full, the sender can block until space becomes available. If the queue is empty, the receiver blocks until data arrives. This blocking behavior is what makes FreeRTOS efficient: blocked tasks consume zero CPU time.
/* Binary semaphore: signal that new data is available */
SemaphoreHandle_t data_ready_sem;
data_ready_sem =xSemaphoreCreateBinary();
/* In sensor task: signal new data */
xSemaphoreGive(data_ready_sem);
/* In display task: wait for signal */
xSemaphoreTake(data_ready_sem, portMAX_DELAY);
Mutexes
A mutex (mutual exclusion) protects a shared resource so only one task accesses it at a time. Unlike a binary semaphore, a mutex has priority inheritance: if a low-priority task holds the mutex and a high-priority task is waiting for it, the low-priority task temporarily inherits the high priority to prevent priority inversion. Always use mutexes (not binary semaphores) for resource protection.
/* Mutex for I2C bus */
SemaphoreHandle_t i2c_mutex;
i2c_mutex =xSemaphoreCreateMutex();
/* In any task that uses I2C */
if (xSemaphoreTake(i2c_mutex, pdMS_TO_TICKS(100)) == pdTRUE) {
The following diagram shows how priority inversion happens and how mutex priority inheritance solves it.
Without priority inheritance:
P3 Sensor: [run]....blocked on mutex....
P2 Display: [runs, preempts Logger]
P1 Logger: [holds mutex]..preempted....
Sensor starved!
With mutex priority inheritance:
P3 Sensor: [run]..blocked.. [run]
P2 Display: ..wait..
P1->P3 Log: [holds mutex, boosted!] [P1]
^ ^
Logger inherits P3 mutex released,
to finish quickly Logger returns
to priority 1
Priority inversion occurs when a high-priority task is blocked waiting for a resource held by a low-priority task, while a medium-priority task runs instead. The classic example: the sensor task (priority 3) tries to take the I2C mutex, but the logger task (priority 1) currently holds it. If the display task (priority 2) becomes ready, it preempts the logger, further delaying the mutex release and starving the sensor task.
FreeRTOS mutexes solve this with priority inheritance: when the sensor task (priority 3) blocks on the mutex, the logger task (priority 1) temporarily inherits priority 3, preempting the display task (priority 2) and releasing the mutex quickly.
This is why you should always use xSemaphoreCreateMutex() instead of xSemaphoreCreateBinary() when protecting shared resources.
Debugging FreeRTOS
Task List in GDB
# FreeRTOS-aware GDB commands (via OpenOCD)
monitor freertos task-list
# Examine a specific task's stack
print *((TCB_t *)sensor_handle)
# Check free stack space (stack high water mark)
print uxTaskGetStackHighWaterMark(sensor_handle)
Common FreeRTOS Problems
Problem
Symptom
Fix
Stack overflow
Random crashes, corrupted data
Increase stack size, check with watermark
Heap exhaustion
Malloc failed hook fires
Increase configTOTAL_HEAP_SIZE
Priority inversion
High-priority task starved
Use mutexes (not binary semaphores)
Deadlock
Two tasks blocked forever
Always acquire mutexes in the same order
Queue overflow
Data lost
Increase queue depth or reduce send rate
What You Have Learned
Lesson 8 Complete
RTOS fundamentals:
Adding FreeRTOS to a bare-metal STM32 project
FreeRTOSConfig.h configuration for Cortex-M3
Task creation with priorities, stack sizes, and function pointers
The FreeRTOS scheduler and preemptive multitasking
Inter-task communication:
Queues for passing structured data between tasks
Binary semaphores for event signaling
Mutexes for protecting shared resources (I2C bus, SPI bus)
Priority inheritance to prevent priority inversion
Practical multitasking:
Splitting a monolithic main loop into independent tasks
Designing task priorities based on real-time requirements
Idle hook for low-power sleep between task activations
Stack overflow detection and malloc failure hooks
Debugging RTOS applications:
FreeRTOS task list inspection in GDB
Stack high water mark monitoring
Recognizing deadlocks, priority inversion, and queue overflow
Comments