FreeRTOS Queues & Inter-Task Communication Guide
Queue Fundamentals, Blocking, Locking, Binary/Mutex, Event Groups & Patterns
1. Introduction — Tasks Are Islands, Queues Are Bridges
FreeRTOS runs multiple tasks that appear to execute in parallel on a single core; but without a disciplined way to move data and control between them, the system quietly turns into a pile of race conditions. The queue is the primary FreeRTOS inter-task communication primitive: a thread-safe FIFO bounded buffer that supports blocking sends and receives, arbitrary-sized items, and timeout-based synchronization. Around the queue sit the binary semaphore, mutex, counting semaphore and event group — each a specialization that solves a specific coordination problem. This guide explains how queues work internally (why they are not just “a linked list with a lock”), how to size and use them with blocking and timeouts, the distinction between mutex and semaphore (and priority inversion), the ISR-safe variants, the standard producer/consumer and overtaking patterns, and the race conditions that survive naive queue use.
2. What a Queue Really Is
A FreeRTOS queue is a bounded array of items plus a linked list of blocked senders and a linked list of blocked receivers, guarded by the scheduler’s critical sections. Sending copies the item bytes into the array; receiving copies them out. Because the data is copied — not referenced — the sender is free to reuse its local variable immediately after a successful send; there is no shared pointer to corrupt. The queue depth and item size are fixed at creation:
QueueHandle_t q = xQueueCreate( 10, sizeof(struct sensor_reading) );
The capacity must be chosen deliberately: too small and a burst of events causes blocking or dropped data; too large and memory (dynamically from the heap, or statically with xQueueCreateStatic) is wasted. For high-rate sensor pushes, the queue acts as a lubrication gap that decouples the fast producer from the slower consumer.
3. Blocking, Timeouts and the Scheduler
The power of queues is that both send and receive can block with a timeout. The receiver call:
struct sensor_reading r;
if (xQueueReceive( q, &r, pdMS_TO_TICKS(100)) == pdPASS) { process(&r); }
blocks the calling task for at most 100 ms while the queue is empty; when an item arrives the scheduler wakes it. The fully-blocked send (portMAX_DELAY on a full queue) suspends the producer until a slot frees — this is how back-pressure is implemented naturally. A key subtlety: blocking tasks disappear from the ready list, so a “while loop with xQueueReceive” is not busy-waiting; it yields the CPU to lower-priority work. Timeouts also act as a watchdog: a receive that times out can flag “no data this period” and react, which is the backbone of periodic sensor/polling loops.
4. Mutex vs Binary Semaphore — Know the Difference
A binary semaphore is a signalling primitive (produce/consume); a mutex is a mutual-exclusion primitive (protect a resource). The difference shows immediately in the two classic bugs. Using a binary semaphore to protect a shared buffer allows a task to take and give from different tasks — no ownership check — which silently corrupts a protocol that needs owner-only release. Using a mutex for “data ready” signalling deadlocks when a receiver waits on a mutex it can never take. The mutex also supports priority inheritance, which mitigates priority inversion:
SemaphoreHandle_t m = xSemaphoreCreateMutex();
if (xSemaphoreTake(m, pdMS_TO_TICKS(50)) == pdPASS) { /* critical */ xSemaphoreGive(m); }
Priority inversion is the scenario where a low-priority task holds the mutex and a high-priority task waits: a medium-priority task can then preempt the low-priority holder indefinitely, starving the high-priority task. FreeRTOS mutexes raise the holder’s priority to the waiter’s level while waiting (priority inheritance), so the medium-priority task can no longer squeeze in; this is the single strongest reason to use mutexes rather than binary semaphores for resource protection.
5. ISR-Safe Communication
Interrupt handlers must never call the blocking queue APIs. FreeRTOS provides FromISR variants that only try once and signal the woken task via a second parameter:
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
xQueueSendToBackFromISR( q, &sample, &xHigherPriorityTaskWoken );
portYIELD_FROM_ISR( xHigherPriorityTaskWoken );
This classic defer-the-work pattern moves the heavy processing out of the ISR into a task that receives from the queue — cutting ISR latency to a copy plus a YIELD and letting the RTOS scheduler decide when to run the handler. The same discipline applies to binary semaphores as “task notification”-style wakeups; for one-shot notifications from ISR to a task, xTaskNotifyGiveFromISR is even cheaper than a semaphore.
6. Design Patterns
| Pattern | Primitive | Typical use |
|---|---|---|
| Producer → consumer | Queue | ADC samples → processing task |
| Event wakeup | Binary semaphore / task notify | Button ISR → handler task |
| Resource guard | Mutex | Shared UART/SPI buffer |
| N-resource allowance | Counting semaphore | Pool of buffer slots |
| Multi-condition wait | Event group | Start when flagA AND flagB set |
| Decoupling bursts | Deep queue | Sensor rate vs display rate |
The control/data split is a recurring theme: a control queue carrying compact command structs (opcode, length, pointer) and a data queue carrying the bulk bytes gives priority control over data without copying the payload every hop. The overtaking problem — a queued status must not be serviced after a newer one arrived — is solved by encoding a sequence number in the item and stamp-dropping stale entries on receive.
7. Worked Example — Sensor Pipeline
Specification: a 3 kHz ADC samples from a timer ISR; a mid-priority processing task computes features; a low-priority task forwards packets over UART-DMA.
- Create q_samples (depth 64 × 16 bytes), a binary semaphore for ‘samples ready’, and an event group for ‘feature computed AND link up’.
- Timer ISR reads ADC, samples to q_samples via xQueueSendToBackFromISR (with xHigherPriorityTaskWoken), gives the semaphore on batch completion.
- Processing task blocks on the semaphore (pdMS_TO_TICKS(10)), drains up to K samples with a timeout, computes features and pushes the result to q_packets.
- Forwarding task receives a packet from q_packets (50 ms timeout) and DMA-sends it; the DMA complete ISR notifies via task notify so the task can reuse the DMA ring.
- Sizing: q_samples depth absorbs ISR bursts (worst-case ISR stretch) without blocking the ISR; q_packets depth absorbs UART back-pressure, and the export task falls back to a “queue full → drop oldest” policy that keeps the link alive.
Power-aware systems can apply the same blocking discipline to sleep scheduling: the queue-timeout pattern (“wait up to N ms or until data”) is what lets a low-power task sleep with the low-power estimator quantifying the duty-cycle savings. For tasks whose cadence must be measured precisely, the STM32 timer/PWM calculator sets the timer period used to pace the producer or the tick-based timeout math.
8. Common Mistakes
- Using mutex as semaphore and vice versa: ownership semantics and priority inheritance only exist on the mutex.
- Blocking in an ISR: the FromISR variants exist for a reason; calling xQueueReceive in an ISR faults or deadlocks.
- Queue too shallow: burst producers overflow and either block (delaying the producer) or drop data silently if using the non-blocking try.
- Item size mismatch: creating a queue with sizeof(struct) and sending sizeof(struct) with padding differences corrupts the subsequent receive.
- Ignoring timeouts in the consumer: a task blocked forever on a producer that died hangs the whole app — always provide a timeout and a fallback.
- Writing to shared data without the mutex “just for speed”: one race condition later, all the speed is lost to debugging.
9. FAQ
Q: Should the queue hold values or pointers? A: For small (≤ a few words) or structured items, values are copied and safe. For large buffers, pass a pointer to a pool slot you manage with a counting semaphore; never let two tasks share the raw buffer without a protocol.
Q: What makes xQueueSendToFront different? A: It inserts at the front — used for priority traffic that must leapfrog queued items (e.g. an alarm pushed ahead of routine log frames).
Q: Is a queue thread-safe across tasks? A: Yes — the RTOS serializes queue operations with scheduler locks; the safety is exactly why all FreeRTOS IPC goes through queues/semaphores rather than plain globals.
Q: When should I use an event group instead of several queues? A: When a task must wait on a combination or OR of independent conditions (start when A and B ready), or when you only need flags, not data.
10. Conclusion
FreeRTOS task communication is a toolbox with exactly one correct tool per job: queues for data flow, binary semaphores/notifications for event wakeup, mutexes for resource ownership, and event groups for condition sets. Get the item sizing, depth, and ISR discipline right, and a multi-task design stays deterministic; get them wrong and the first race condition is a time bomb. The patterns above — with the queue timeout as the safety valve — are what make practical FreeRTOS designs work reliably.