Table of Contents
KEY TAKEAWAYS
- RTOS queues provide thread-safe FIFO communication between tasks, handling all synchronization and blocking internally.
- Queue depth, item size, and timeout behavior are critical design decisions that affect both reliability and memory usage.
- For large data, queue pointers instead of values — but then you must manage the underlying memory with pools or careful ownership rules.
Part of the Complete Guide to RTOS for Embedded Systems series.
Queues are the primary mechanism for passing data between RTOS tasks safely. Unlike shared global variables (which require explicit mutex protection and are error-prone), queues handle synchronization internally and provide natural flow control through blocking. This guide covers queue design, implementation patterns, and common mistakes.
How RTOS Queues Work Internally
An RTOS queue is a fixed-size circular buffer managed by the kernel. When you create a queue, you specify the maximum number of items and the size of each item. The RTOS allocates the buffer and maintains read/write pointers, a count, and two wait lists (one for tasks waiting to send, one for tasks waiting to receive).
When a task calls xQueueSend():
- If the queue has space, the item is copied into the buffer and the call returns immediately
- If the queue is full, the task enters the Blocked state and is placed on the send wait list
- When another task reads from the queue, the blocked sender is unblocked and its item is copied in
- If the timeout expires before space becomes available, the call returns
errQUEUE_FULL
When a task calls xQueueReceive():
- If the queue has items, the oldest item is copied out and the call returns immediately
- If the queue is empty, the task blocks on the receive wait list
- When another task or ISR adds an item, the blocked receiver is unblocked
This blocking behavior is what makes queues powerful — tasks automatically sleep when there’s nothing to do and wake up when data arrives, using zero CPU while waiting.
Queue Design Patterns
The most common queue patterns in embedded firmware:
Single Producer, Single Consumer (SPSC) — one task writes, one task reads. This is the simplest and most common pattern. Example: sensor task sends readings to processing task.
#define QUEUE_DEPTH 16typedef struct { uint16_t channel; float value; uint32_t timestamp; } SensorReading_t;
QueueHandle_t xSensorQ = xQueueCreate(QUEUE_DEPTH, sizeof(SensorReading_t));
// Producer — sensor task SensorReading_t reading = {.channel = 0, .value = 23.5f, .timestamp = HAL_GetTick()}; xQueueSend(xSensorQ, &reading, pdMS_TO_TICKS(50));
// Consumer — processing task SensorReading_t rx; if (xQueueReceive(xSensorQ, &rx, portMAX_DELAY) == pdTRUE) { process(rx.channel, rx.value); } “`
Multiple Producers, Single Consumer (MPSC) — several tasks or ISRs send to one queue. Example: multiple sensor ISRs send data to a single processing task. The queue handles the serialization safely.
Command Queue Pattern — instead of queuing raw data, queue command structures that tell the consumer what to do. This turns the consumer into a server that handles requests.
typedef enum { CMD_READ_SENSOR, CMD_CALIBRATE, CMD_RESET } CmdType_t;typedef struct { CmdType_t type; uint8_t sensor_id; QueueHandle_t response_queue; // For sending results back } Command_t; “`
Sizing Queues Correctly
Queue depth (number of items) should absorb worst-case bursts. If a sensor produces data every 10 ms and the consumer sometimes takes 50 ms to process (due to occasional heavy computation), you need at least 5 slots. Add margin for safety — 8 to 16 slots in this case.
Item size affects total memory usage: depth × item_size + overhead. For small items (4-16 bytes), copy-by-value is fine. For large items (hundreds of bytes), queue pointers instead:
// Instead of copying large structs through the queue:
QueueHandle_t xQ = xQueueCreate(8, sizeof(LargePacket_t)); // 8 * 256 = 2 KB!// Queue pointers (only 8 * 4 = 32 bytes for the queue itself): QueueHandle_t xPtrQ = xQueueCreate(8, sizeof(LargePacket_t *)); LargePacket_t *pkt = pool_alloc(); fill_packet(pkt); xQueueSend(xPtrQ, &pkt, portMAX_DELAY); // Send pointer
// Consumer LargePacket_t *rx_pkt; xQueueReceive(xPtrQ, &rx_pkt, portMAX_DELAY); process_packet(rx_pkt); pool_free(rx_pkt); // Return to pool “`
When queuing pointers, you must manage ownership carefully. The producer allocates from a pool, sends the pointer, and must NOT touch the memory after sending. The consumer owns the memory after receiving and must free it back to the pool when done.
Queue Mistakes That Cause Bugs
The most common queue-related bugs:
Ignoring return values — xQueueSend() can fail if the queue is full and the timeout expires. Always check the return value and handle the failure (log, increment error counter, take corrective action).
Using queues from ISRs without the FromISR variant — calling xQueueSend() from an ISR causes undefined behavior. Always use xQueueSendFromISR() and check the pxHigherPriorityTaskWoken output.
Queuing stack-local data by pointer — if you queue a pointer to a local variable, the variable goes out of scope when the function returns. The consumer reads garbage memory. Always queue by value for small data, or allocate from a pool for pointer-based queues.
Blocking in ISRs — passing a non-zero timeout to any queue function from an ISR causes a system crash. ISR variants must always use a timeout of 0.
Queue overflow without detection — if the producer runs faster than the consumer and the queue fills up, data is silently dropped (if using zero timeout) or the producer blocks (if using a timeout). Add overflow counters to detect this during testing.

Vivek Bhageria — Lead Firmware R&D Engineer, 12+ years. Ex-Bosch (automotive powertrain), MusicTribe (real-time audio), medical devices. M.Tech BITS Pilani. I write at NerdyElectronics — practical, register-level embedded systems for engineers who want to understand what’s actually happening under the hood.





