FreeRTOS Queues & Inter-Task Communication Guide

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.

发表评论