On this page
- Starting Point
- What Moving Peripheral Work Means
- Keep Drivers RTOS-Independent When Possible
- Task-Owned Peripheral Work
- Interrupt-Owned Peripheral Work
- Queue-Owned Boundaries
- Example: UART Logging Task
- Example: Periodic Sensor Polling Task
- Example: Display Refresh Task
- Shared Bus Access
- What Not To Put in a Task
- Common Architecture Mistakes
- Checklist Before Calling It an RTOS Design
- Next Steps
The previous article used FreeRTOS queues and delays as controlled building blocks. Tasks could wait without spinning, periodic work could use vTaskDelayUntil(), and queues could pass messages between contexts.
This article applies those tools to STM32 peripheral work.
The goal is not to put every driver into its own task. A task is a scheduling boundary, not automatically a driver boundary. Good RTOS structure keeps timing, ownership, and interrupt rules visible while preserving simple driver interfaces where possible.
If a driver can stay useful in both bare-metal and RTOS projects, keep it that way. Put RTOS behavior around the driver, not inside it, unless the driver truly owns RTOS-specific behavior.
Starting Point
At this point, the project should have:
- A working FreeRTOS scheduler baseline.
- At least one task that runs and delays correctly.
- Queues available for task-to-task or ISR-to-task communication.
- Basic STM32 peripheral setup from the earlier peripheral series.
- A small firmware structure with application, board support, peripheral setup, and driver boundaries.
The question now is architectural: which code should become task code, which code should stay as a reusable driver, and which code should stay in interrupt handlers?
What Moving Peripheral Work Means
Moving peripheral work into FreeRTOS tasks means moving long-running or blocking behavior into scheduled task context.
Examples include:
- A UART logging task that serializes debug output.
- A sensor polling task that samples every 100 ms.
- A display task that refreshes a screen at a fixed cadence.
- A protocol task that waits for messages from another task or interrupt.
It does not mean every low-level peripheral function becomes RTOS-aware.
For example, this can remain a plain driver function:
void uart_write(const char *text);
The RTOS-specific part can live in the task that decides when to call it.
That separation matters. A task owns scheduling behavior. A driver owns device behavior.
Keep Drivers RTOS-Independent When Possible
Low-level drivers are often more reusable if they do not directly call FreeRTOS APIs.
A small SPI display driver can expose functions such as:
void display_init(void);
void display_write_text(const char *text);
void display_refresh(void);
Those functions can be used from a bare-metal loop or from a FreeRTOS task.
If the driver internally calls vTaskDelay(), takes a mutex, or sends to a queue, it becomes tied to FreeRTOS. That may be justified for some drivers, but it should be a deliberate choice.
Prefer this layering first:
FreeRTOS task
calls application service
calls reusable driver
calls board/peripheral I/O
This keeps RTOS decisions near the application behavior instead of spreading them through every hardware module.
Task-Owned Peripheral Work
A task is a good home for peripheral work when the behavior:
- Runs periodically.
- Waits for messages.
- Blocks on a queue.
- Sequences multiple operations.
- Owns a shared peripheral for a longer operation.
- Should run independently from other application behavior.
For example, periodic sensor polling belongs naturally in a task:
static void sensor_task(void *argument)
{
(void)argument;
TickType_t last_wake = xTaskGetTickCount();
for (;;)
{
sensor_sample_t sample = sensor_read();
(void)xQueueSend(sensor_queue, &sample, pdMS_TO_TICKS(10));
vTaskDelayUntil(&last_wake, pdMS_TO_TICKS(100));
}
}
The task owns the polling cadence. The sensor_read() function can remain a plain driver function.
Interrupt-Owned Peripheral Work
Interrupt handlers should stay short.
Good interrupt behavior includes:
- Clear an interrupt flag.
- Capture a timestamp.
- Read or write a small data register when required.
- Set a small event flag.
- Send a compact message to a queue using a
FromISRAPI. - Wake a task to do heavier work.
Risky interrupt behavior includes:
- Long I2C or SPI transactions.
- Blocking UART logging.
- Display rendering.
- Dynamic allocation.
- Complex application decisions.
- Calling normal task APIs instead of
FromISRAPIs.
An interrupt can hand work to a task:
void EXTI15_10_IRQHandler(void)
{
BaseType_t higher_priority_task_woken = pdFALSE;
button_event_t event = { .pressed = true };
(void)xQueueSendFromISR(button_queue, &event, &higher_priority_task_woken);
portYIELD_FROM_ISR(higher_priority_task_woken);
}
The task that receives button_queue owns the application response.
Queue-Owned Boundaries
Queues are useful boundaries between producers and consumers.
A queue can separate:
- An interrupt from a task.
- A sampling task from a processing task.
- Application code from a UART logging task.
- UI input from display update logic.
The queue message should be small and clear. Avoid sending vague global state through queues. Prefer named event or message types:
typedef struct
{
uint32_t value;
} sensor_sample_t;
The queue also defines timing behavior. If the queue fills, the producer must either wait, drop data, overwrite old data, or report an overload condition.
Do not ignore that decision.
Example: UART Logging Task
UART logging is a good example of task-owned peripheral work.
Instead of letting every task write directly to the UART, create a logging queue and one UART logging task:
typedef struct
{
const char *text;
} log_message_t;
static QueueHandle_t log_queue;
Create the queue before scheduler start:
log_queue = xQueueCreate(8, sizeof(log_message_t));
configASSERT(log_queue != NULL);
The logging task owns UART writes:
static void uart_log_task(void *argument)
{
(void)argument;
log_message_t message;
for (;;)
{
if (xQueueReceive(log_queue, &message, portMAX_DELAY) == pdPASS)
{
uart_write(message.text);
}
}
}
Other tasks send log messages instead of touching UART directly.
This keeps UART output serialized. It also makes blocking behavior visible: if the logging queue fills, senders need a policy.
For a first project, send static strings or carefully owned buffers. Do not queue pointers to stack-allocated temporary strings that disappear before the logging task reads them.
Example: Periodic Sensor Polling Task
A sensor polling task owns timing:
static void sensor_task(void *argument)
{
(void)argument;
TickType_t last_wake = xTaskGetTickCount();
for (;;)
{
sensor_sample_t sample = sensor_read();
BaseType_t sent = xQueueSend(sensor_queue, &sample, pdMS_TO_TICKS(10));
configASSERT(sent == pdPASS);
vTaskDelayUntil(&last_wake, pdMS_TO_TICKS(100));
}
}
The driver reads the device. The task owns the 100 ms cadence. The queue decouples sampling from whichever task processes or displays the result.
This structure is better than burying FreeRTOS calls inside sensor_read(). The same sensor driver can still be used in a non-RTOS project.
Example: Display Refresh Task
Display updates often benefit from a task because rendering can take time.
A simple display task might wait for state changes and refresh at a controlled rate:
static void display_task(void *argument)
{
(void)argument;
TickType_t last_wake = xTaskGetTickCount();
for (;;)
{
display_render_current_state();
display_refresh();
vTaskDelayUntil(&last_wake, pdMS_TO_TICKS(50));
}
}
The display driver can still expose plain functions. The task decides when those functions run.
For displays on I2C or SPI, be careful if other tasks also use the same bus. Shared bus access needs an ownership model.
Shared Bus Access
I2C and SPI buses are shared resources.
Do not let multiple tasks call into the same bus at the same time without coordination.
Common approaches include:
- One owner task for the bus.
- A mutex around bus transactions.
- A higher-level manager that sequences device operations.
- A design rule that only one task ever touches that bus.
For early projects, the simplest reliable model is often single ownership. For example, one display task owns the display SPI bus, or one sensor task owns the I2C sensor bus.
Mutexes are useful, but they introduce priority and blocking behavior that must be understood. Do not use a mutex as a way to avoid deciding who owns a resource.
What Not To Put in a Task
Do not create one task per driver by default.
This usually creates too many stacks, too many priorities, and too much scheduling complexity. A driver that only provides a simple transaction function does not need a task just because FreeRTOS is present.
Do not put tiny interrupt-only work into a task if the interrupt can safely handle it immediately.
Do not put long blocking operations inside high-priority tasks unless the scheduling impact is understood.
Do not call HAL_Delay() inside tasks. Use FreeRTOS delay APIs so the scheduler controls blocking.
Do not let multiple tasks use the same peripheral with no ownership model.
Common Architecture Mistakes
One common mistake is making every driver include FreeRTOS.h. That spreads RTOS dependency through code that could have stayed reusable.
Another mistake is doing too much inside HAL callbacks or interrupt handlers. A callback that starts bus transfers, logs text, updates state machines, and wakes tasks is too complex for bring-up.
A third mistake is letting several tasks write directly to UART or SPI. That creates interleaved output, bus collisions, or hidden blocking.
A fourth mistake is adding many task priorities before the application has a real scheduling model. More priorities do not automatically make the design more real-time.
A fifth mistake is using queues without an overload policy. If a queue can fill, the project needs to decide what happens next.
Checklist Before Calling It an RTOS Design
Before moving on, confirm:
- Drivers only depend on FreeRTOS when there is a clear reason.
- Long-running peripheral behavior lives in task context.
- Interrupt handlers stay short and use
FromISRAPIs when needed. - Shared buses have an ownership model.
- Queues have intentional lengths and failure behavior.
- Periodic tasks use
vTaskDelayUntil()when cadence matters. - Task priorities are few and deliberate.
- No task uses
HAL_Delay()after scheduler start.
If those rules are true, the RTOS is helping structure the firmware instead of just adding more moving parts.
Next Steps
The next article closes the series with a decision guide: when to use bare metal, HAL, or FreeRTOS on STM32.
FreeRTOS is useful, but it is not the right answer for every firmware project. The final article will compare the tradeoffs and show how to choose the simplest structure that still fits the problem.