On this page
- Starting Point
- Why Delays Are Not Just Sleeps
- vTaskDelay() vs vTaskDelayUntil()
- A Periodic Task Pattern
- What Queues Are For
- Creating a Queue
- Sending From One Task
- Receiving in Another Task
- Blocking Timeouts and Queue Capacity
- Queue Use From Interrupts
- Common Timing Mistakes
- Debugging Queue and Delay Problems
- Checklist Before Moving Peripheral Work Into Tasks
- Next Steps
The previous articles brought FreeRTOS to the point where tasks can be created, the scheduler can start, and basic task execution can be debugged.
That is the scheduler baseline.
The next step is using tasks without accidentally breaking timing. In FreeRTOS, timing problems often come from unclear blocking behavior: a task delays relative to the wrong moment, a queue send waits longer than expected, a consumer cannot keep up with a producer, or an interrupt calls the wrong API.
This article introduces delays and queues as controlled tools. The goal is not to build a full application yet. The goal is to make time and communication explicit before moving real peripheral work into RTOS tasks.
Starting Point
The project should already have a minimal scheduler bring-up working:
- FreeRTOS source files are included in the build.
FreeRTOSConfig.his configured for first bring-up.SysTick,PendSV, andSVCare wired correctly.- At least one task can run, delay, and run again.
- Basic scheduler failures have been debugged.
Now the project can add two important RTOS patterns:
- Time-based task execution with delays.
- Message passing between tasks with queues.
Those two patterns are enough to structure many small STM32 applications, but only if their timing behavior is understood.
Why Delays Are Not Just Sleeps
In a bare-metal loop, a delay often means the CPU spins or waits in a blocking HAL delay. In an RTOS task, a delay should usually mean the task blocks and lets other ready tasks run.
That is a major difference.
When a task calls vTaskDelay(), it is not asking the CPU to do nothing. It is telling the scheduler, "do not run this task again until this many ticks have passed." Other ready tasks can run during that time.
That makes task delays useful, but it also means they are part of scheduling behavior. A delay changes when a task becomes ready. If the tick rate is wrong, delays are wrong. If a higher-priority task never blocks, lower-priority delayed tasks may not get CPU time when expected.
Delays are not a substitute for understanding task priorities and blocking behavior.
vTaskDelay() vs vTaskDelayUntil()
vTaskDelay() delays relative to the moment it is called.
For example:
for (;;)
{
read_input();
update_state();
vTaskDelay(pdMS_TO_TICKS(100));
}
This task waits 100 ms after finishing read_input() and update_state(). If that work takes 5 ms, the loop period is roughly 105 ms. If the work later takes 20 ms, the loop period becomes roughly 120 ms.
That may be fine for simple background work, but it is not a stable periodic schedule.
vTaskDelayUntil() delays until the next absolute wake time:
static void heartbeat_task(void *argument)
{
(void)argument;
TickType_t last_wake = xTaskGetTickCount();
for (;;)
{
board_led_toggle();
vTaskDelayUntil(&last_wake, pdMS_TO_TICKS(500));
}
}
This is better for periodic work because the delay is based on a fixed cadence. The work inside the loop still consumes time, but the task tries to maintain a 500 ms period instead of adding 500 ms after each loop body.
Use vTaskDelay() for simple relative waits. Use vTaskDelayUntil() for periodic tasks.
A Periodic Task Pattern
A useful pattern for periodic task code is:
static void sample_task(void *argument)
{
(void)argument;
TickType_t last_wake = xTaskGetTickCount();
for (;;)
{
sample_inputs();
vTaskDelayUntil(&last_wake, pdMS_TO_TICKS(100));
}
}
This says the task should run every 100 ms.
That does not guarantee hard real-time behavior. If a higher-priority task or interrupt consumes too much time, this task can still run late. But the code expresses the intended cadence clearly, and late execution is easier to reason about.
Do not use HAL_Delay() inside FreeRTOS tasks. Once the scheduler is running, prefer FreeRTOS delay APIs so the task blocks cooperatively and the scheduler remains in control.
What Queues Are For
A queue lets one context send structured data to another context without sharing the same variable directly.
That is useful when one task produces data and another task consumes it.
Examples:
- A sampling task sends sensor readings to a processing task.
- A button task sends button events to a UI task.
- A UART receive path sends parsed messages to an application task.
- An interrupt sends a small event to a task that does the heavier work.
Queues help separate ownership. The producer does not need to know exactly when the consumer runs. The consumer does not need to poll a shared global flag continuously.
But queues are not magic. They have capacity. Sending can block. Receiving can block. If those choices are not explicit, queue bugs become timing bugs.
Creating a Queue
Define a message type first:
typedef struct
{
uint32_t value;
} sensor_message_t;
Then create a queue handle:
static QueueHandle_t sensor_queue;
Create the queue before starting the scheduler:
sensor_queue = xQueueCreate(4, sizeof(sensor_message_t));
configASSERT(sensor_queue != NULL);
The first argument is the queue length. This queue can hold four messages. The second argument is the size of each message.
FreeRTOS queues copy message data into the queue storage. The queue above stores copies of sensor_message_t, not pointers to local variables.
That copy behavior is useful for small messages. For large buffers, a queue of pointers may be more appropriate, but then buffer ownership must be designed carefully.
Sending From One Task
A producer task can send messages like this:
static void producer_task(void *argument)
{
(void)argument;
TickType_t last_wake = xTaskGetTickCount();
uint32_t sample = 0;
for (;;)
{
sensor_message_t message = { .value = sample++ };
(void)xQueueSend(sensor_queue, &message, pdMS_TO_TICKS(10));
vTaskDelayUntil(&last_wake, pdMS_TO_TICKS(100));
}
}
The timeout argument controls how long the task is willing to wait if the queue is full.
In this example, the producer waits up to 10 ms. If the queue is still full, the send fails and the return value would indicate that. The example casts the result to void only to keep the snippet short. In real bring-up code, check failed sends so queue capacity problems are visible.
For example:
BaseType_t sent = xQueueSend(sensor_queue, &message, pdMS_TO_TICKS(10));
configASSERT(sent == pdPASS);
During early testing, asserting on unexpected queue failure is reasonable. Later, a production application may choose to drop old data, count missed messages, or signal overload instead.
Receiving in Another Task
A consumer task can block until a message arrives:
static void consumer_task(void *argument)
{
(void)argument;
sensor_message_t message;
for (;;)
{
if (xQueueReceive(sensor_queue, &message, portMAX_DELAY) == pdPASS)
{
process_message(&message);
}
}
}
portMAX_DELAY means the task is willing to wait indefinitely for a message, assuming the FreeRTOS configuration supports that behavior as expected.
This is often the right pattern for a consumer task. It does not poll. It does not burn CPU time. It sleeps until there is work.
The consumer should still keep its processing bounded. If process_message() takes longer than messages arrive, the queue will eventually fill.
Blocking Timeouts and Queue Capacity
Every queue operation has a timing decision.
For sending:
- A timeout of
0means do not wait if the queue is full. - A finite timeout means wait for space up to that duration.
portMAX_DELAYmeans wait indefinitely, if enabled by configuration.
For receiving:
- A timeout of
0means poll and return immediately if empty. - A finite timeout means wait for a message up to that duration.
portMAX_DELAYmeans block until a message arrives.
Queue capacity is also a design decision. A larger queue can absorb bursts, but it also uses more RAM and can hide a slow consumer for longer. A smaller queue exposes overload sooner.
For early STM32 examples, small queue lengths such as 4 or 8 are easier to reason about.
Queue Use From Interrupts
Interrupt handlers must use the FromISR variants of FreeRTOS APIs.
For example:
void EXTI15_10_IRQHandler(void)
{
BaseType_t higher_priority_task_woken = pdFALSE;
sensor_message_t message = { .value = 1 };
(void)xQueueSendFromISR(sensor_queue, &message, &higher_priority_task_woken);
portYIELD_FROM_ISR(higher_priority_task_woken);
}
The higher_priority_task_woken flag tells the port whether the ISR unblocked a task that should run immediately after the interrupt exits.
This is an important boundary:
- Normal task code uses
xQueueSend()andxQueueReceive(). - Interrupt code uses
xQueueSendFromISR()and relatedFromISRAPIs. - Interrupts that call FreeRTOS APIs must obey
configMAX_SYSCALL_INTERRUPT_PRIORITYrules.
Do not call normal blocking FreeRTOS APIs from an ISR. An interrupt cannot block like a task.
Common Timing Mistakes
One common mistake is using vTaskDelay() for work that should run at a stable period. If the task body time varies, the period varies too. Use vTaskDelayUntil() for periodic work.
Another mistake is using HAL_Delay() inside a task. That hides timing behavior from the scheduler and may depend on the HAL time base in ways that conflict with the RTOS tick strategy.
A third mistake is creating queues that are too large because messages are being produced faster than they are consumed. A larger queue delays the symptom but does not fix the throughput mismatch.
A fourth mistake is ignoring queue send failures. If the queue fills and sends fail silently, the application loses data without any obvious sign.
A fifth mistake is using task APIs from interrupts instead of FromISR APIs. That can corrupt scheduler assumptions or trip assertions.
Debugging Queue and Delay Problems
For delay problems, first check the tick:
- Does
SysTick_Handlerrun? - Does the FreeRTOS tick count advance?
- Does
configTICK_RATE_HZmatch the expected timing? - Is
SystemCoreClockcorrect?
For queue problems, inspect:
- Queue handle is not
NULL. - Queue length matches expected burst behavior.
- Message size matches the type being sent.
- Send return values are checked during bring-up.
- Consumer task priority and blocking behavior make sense.
- ISR send paths use
FromISRAPIs and valid interrupt priorities.
Simple counters help. Count sent messages, failed sends, received messages, and processing overruns. A few volatile counters watched in the debugger can reveal whether the producer, queue, or consumer is the bottleneck.
Checklist Before Moving Peripheral Work Into Tasks
Before moving UART, I2C, SPI, display refresh, or sensor sampling into tasks, confirm:
- Periodic tasks use
vTaskDelayUntil()when stable cadence matters. - Queue handles are created and checked before scheduler start.
- Queue send failures are visible during bring-up.
- Queue lengths are small and intentional.
- Consumer tasks block instead of polling aggressively.
- ISR code uses
FromISRAPIs only. - Interrupt priorities are valid for FreeRTOS API calls.
- No task uses
HAL_Delay()after the scheduler starts.
With these rules in place, queues and delays become predictable building blocks instead of hidden timing traps.
Next Steps
The next article moves peripheral work into FreeRTOS tasks.
That is where the RTOS starts shaping real firmware architecture. The project can decide which work belongs in tasks, which work should stay in interrupt handlers, and which drivers should remain RTOS-independent so they can be reused in simpler bare-metal projects.