FreeRTOS on STM32: Tasks, Queues and Mutexes with CMSIS-RTOS v2
As projects grow, a single main loop becomes hard to manage. You need to read a sensor every 100 ms, update a display every second, respond to a button immediately and handle UART commands whenever they arrive. Writing all of this as one loop with HAL_GetTick checks quickly becomes a tangle of flags. A real-time operating system (RTOS) lets you write each job as an independent task with its own loop, and a scheduler decides which task runs at each moment.
FreeRTOS is the most widely used RTOS on microcontrollers and is built into STM32CubeIDE. In this tutorial you will enable it on a Nucleo-F401RE and build a program with three tasks: one samples the ADC at a fixed rate, one prints results, and one blinks an LED. The tasks communicate through a message queue, and a mutex protects the shared UART.
Key RTOS Concepts
| Concept | CMSIS-RTOS v2 function | What it does |
|---|---|---|
| Task (thread) | osThreadNew | An independent function with its own stack and priority |
| Delay | osDelay / osDelayUntil | Blocks the task and lets others run; does not waste CPU |
| Queue | osMessageQueueNew / Put / Get | Copies fixed-size messages safely between tasks or from interrupts |
| Mutex | osMutexNew / Acquire / Release | Lets only one task at a time use a shared resource |
| Scheduler start | osKernelStart | Hands control to FreeRTOS; never returns |
FreeRTOS uses priority-based preemptive scheduling. The highest-priority task that is ready always runs. When it calls osDelay or waits on an empty queue, it becomes blocked, and the next ready task runs. Tasks of equal priority share the CPU in time slices of one tick, which is 1 ms by default. The key habit is that every task must block somewhere in its loop; a task that never blocks starves all tasks of lower priority.
CubeMX wraps FreeRTOS in the CMSIS-RTOS v2 API, a standard interface defined by Arm. The functions above call the native FreeRTOS functions such as xTaskCreate and xQueueSend underneath. You can mix both styles, but sticking to one makes code easier to read.
Enabling FreeRTOS in CubeMX
- System Core, SYS: change Timebase Source from SysTick to a basic timer such as TIM11. FreeRTOS takes over SysTick for its own tick, and the HAL needs a separate time base for
HAL_Delayand timeouts. - Middleware, FREERTOS: set Interface to CMSIS_V2.
- In Config parameters, keep TICK_RATE_HZ at 1000 and check that TOTAL_HEAP_SIZE is at least 15360 bytes. Set CHECK_FOR_STACK_OVERFLOW to Option2 while developing.
- In Advanced settings, set USE_NEWLIB_REENTRANT to Enabled, because more than one task will call
snprintf. - Enable PA0 as ADC1_IN0 (single conversion) with a potentiometer, as in the ADC tutorial, and keep USART2.
- Generate the code. CubeMX adds
osKernelInitialize(), a default task andosKernelStart()tomain().
Three Tasks, One Queue, One Mutex
Place the declarations, the creation calls and the task functions in the matching USER CODE sections of main.c. The generated default task can stay; it simply runs osDelay(1) forever.
/* USER CODE BEGIN Includes */
#include <stdio.h>
#include <string.h>
/* USER CODE END Includes */
/* USER CODE BEGIN PV */
osMessageQueueId_t adcQueue; /* carries uint16_t ADC readings */
osMutexId_t uartMutex; /* protects huart2 */
const osThreadAttr_t sensorAttr = { .name = "sensor", .stack_size = 256 * 4,
.priority = (osPriority_t)osPriorityAboveNormal };
const osThreadAttr_t printAttr = { .name = "print", .stack_size = 512 * 4,
.priority = (osPriority_t)osPriorityNormal };
const osThreadAttr_t ledAttr = { .name = "led", .stack_size = 128 * 4,
.priority = (osPriority_t)osPriorityLow };
/* USER CODE END PV */
/* USER CODE BEGIN PFP */
void SensorTask(void *argument);
void PrintTask(void *argument);
void LedTask(void *argument);
/* USER CODE END PFP */
/* USER CODE BEGIN 0 */
/* Thread-safe UART output: only one task may transmit at a time */
static void uart_log(const char *text)
{
if (osMutexAcquire(uartMutex, osWaitForever) == osOK)
{
HAL_UART_Transmit(&huart2, (uint8_t *)text, strlen(text), 100);
osMutexRelease(uartMutex);
}
}
/* USER CODE END 0 */
/* Inside main(), after osKernelInitialize(): */
/* USER CODE BEGIN RTOS_MUTEX */
uartMutex = osMutexNew(NULL);
/* USER CODE END RTOS_MUTEX */
/* USER CODE BEGIN RTOS_QUEUES */
adcQueue = osMessageQueueNew(16, sizeof(uint16_t), NULL); /* 16 slots */
/* USER CODE END RTOS_QUEUES */
/* USER CODE BEGIN RTOS_THREADS */
osThreadNew(SensorTask, NULL, &sensorAttr);
osThreadNew(PrintTask, NULL, &printAttr);
osThreadNew(LedTask, NULL, &ledAttr);
/* USER CODE END RTOS_THREADS */
/* USER CODE BEGIN 4 */
/* Samples the potentiometer exactly every 100 ms */
void SensorTask(void *argument)
{
(void)argument;
uint32_t next = osKernelGetTickCount();
for (;;)
{
uint16_t raw = 0;
HAL_ADC_Start(&hadc1);
if (HAL_ADC_PollForConversion(&hadc1, 5) == HAL_OK)
{
raw = (uint16_t)HAL_ADC_GetValue(&hadc1);
}
HAL_ADC_Stop(&hadc1);
/* Timeout 0: if the queue is full, drop the sample instead of blocking */
osMessageQueuePut(adcQueue, &raw, 0, 0);
next += 100; /* absolute time of the next sample */
osDelayUntil(next); /* no drift, unlike osDelay(100) */
}
}
/* Waits for readings, averages 10 of them, prints once per second */
void PrintTask(void *argument)
{
(void)argument;
char line[64];
uint32_t sum = 0, count = 0;
for (;;)
{
uint16_t raw;
/* Blocks here, using no CPU, until the sensor task sends a value */
if (osMessageQueueGet(adcQueue, &raw, NULL, osWaitForever) == osOK)
{
sum += raw;
if (++count == 10)
{
uint32_t mv = (sum / 10U) * 3300U / 4095U;
snprintf(line, sizeof(line), "avg %4lu mV, free stack %lu words\r\n",
mv, (uint32_t)uxTaskGetStackHighWaterMark(NULL));
uart_log(line);
sum = 0;
count = 0;
}
}
}
}
/* Lowest priority: runs only when the other tasks are blocked */
void LedTask(void *argument)
{
(void)argument;
for (;;)
{
HAL_GPIO_TogglePin(LD2_GPIO_Port, LD2_Pin);
osDelay(250);
}
}
/* Called by FreeRTOS if a task overflows its stack (CHECK_FOR_STACK_OVERFLOW) */
void vApplicationStackOverflowHook(TaskHandle_t xTask, char *pcTaskName)
{
(void)xTask;
(void)pcTaskName;
__disable_irq();
while (1) { } /* put a breakpoint here and inspect pcTaskName */
}
/* USER CODE END 4 */
How the Program Behaves
- The sensor task has the highest priority, so its 100 ms sampling instant is never delayed by printing.
osDelayUntiluses an absolute time, so small variations in the loop body do not accumulate into drift. - The queue copies each 2-byte reading. The print task sleeps inside
osMessageQueueGetuntil data arrives, which is far cleaner than polling a shared variable. - The mutex prevents two tasks from interleaving characters on the UART. Any task that prints must go through
uart_log. uxTaskGetStackHighWaterMark(NULL)reports the smallest amount of free stack the task has ever had, in words. If it approaches zero, increasestack_size. This needs INCLUDE_uxTaskGetStackHighWaterMark enabled in CubeMX Include parameters.- Stack sizes in
osThreadAttr_tare in bytes, which is why the code writes256 * 4for 256 words.
Sending Data from an Interrupt
osMessageQueuePut may be called from an interrupt callback, such as HAL_GPIO_EXTI_Callback, as long as the timeout is 0. Mutexes, on the other hand, must never be used from interrupts. Also, any interrupt that calls RTOS functions must have a priority number equal to or higher than configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY, which CubeMX sets to 5 by default; CubeMX warns you in the NVIC table if this rule is broken.
Troubleshooting
- Program hangs at start: the heap is too small to create all tasks and the queue. Check return values of
osThreadNewfor NULL and increase TOTAL_HEAP_SIZE. - HardFault when printing floats or long strings: the task stack is too small for
snprintf. Give printing tasks at least 512 words. - LED task never runs: a higher-priority task loops without blocking.
HAL_Delaynever returns: the timebase source is still SysTick, or its interrupt priority is wrong.- Garbled UART output: some code prints without taking the mutex.
Summary
You enabled FreeRTOS with the CMSIS-RTOS v2 interface, moved the HAL time base to TIM11, and built three tasks with different priorities. A queue carried ADC readings from producer to consumer, a mutex protected the UART, and osDelayUntil gave drift-free sampling. In the final tutorial of this series you will go below the HAL and program STM32 peripherals directly through their registers.