Queues — Inter-Task Data
Embedded → RTOS: Synchronization & IPC
Why not just a shared global?
The obvious first instinct for moving data between two tasks is a shared global variable — task A writes it, task B reads it. This breaks the moment either side is preempted mid-write, or the two sides run at different rates and B reads the same value twice while A only overwrites it once, silently dropping data. A queue fixes both at once: a thread-safe FIFO with its own internal locking, each item consumed exactly once, in order.
A queue is a fixed-depth, copy-in/copy-out ring buffer
An RTOS queue is created with a fixed length and item size — allocated once, up front, never resized at runtime. Sending an item copiesthe bytes into the queue's internal buffer (not a pointer to your local variable, which could go out of scope); receiving copies them back out. This means the sender can safely reuse or discard its own local variable the instant xQueueSend returns, with no lifetime coordination needed.
#include "FreeRTOS.h"
#include "queue.h"
// Queue of 10 slots, each holding one uint16_t sensor reading
QueueHandle_t sensor_queue;
void setup_ipc(void) {
sensor_queue = xQueueCreate(10, sizeof(uint16_t));
if (sensor_queue == NULL) {
// Out of heap — handle the failure, don't ignore it
}
}Sending and receiving — both can block
xQueueSend and xQueueReceive each take a timeout (in RTOS ticks —pdMS_TO_TICKS(ms) converts milliseconds to the right unit). A receiver on an empty queue blocks — genuinely yields the CPU, not a busy spin — until an item arrives or the timeout expires. A sender on a full queue blocks the same way, or you can pass 0 for non-blocking, or portMAX_DELAY to block forever.
// Producer task: read a sensor every 100ms, push it onto the queue
void vSensorTask(void *pvParameters) {
uint16_t reading;
for (;;) {
reading = read_adc();
if (xQueueSend(sensor_queue, &reading, pdMS_TO_TICKS(50)) != pdPASS) {
// Queue was full for 50ms — consumer is falling behind, log it
}
vTaskDelay(pdMS_TO_TICKS(100));
}
}
// Consumer task: block until data shows up, then process it
void vLoggerTask(void *pvParameters) {
uint16_t reading;
for (;;) {
if (xQueueReceive(sensor_queue, &reading, portMAX_DELAY) == pdPASS) {
log_to_flash(reading);
}
}
}Sending from an ISR: a different function, and no blocking
An interrupt handler can never block — there's no scheduler context to yield to inside an ISR. FreeRTOS gives every queue/semaphore operation a dedicated FromISR variant that never waits and instead reports, via an output parameter, whether the ISR should request a context switch on exit:
void UART_RX_IRQHandler(void) {
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
uint16_t byte = UART->DR;
xQueueSendFromISR(sensor_queue, &byte, &xHigherPriorityTaskWoken);
// If sending just woke a higher-priority task than the one we interrupted,
// ask the scheduler to switch to it immediately on ISR exit, not later.
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}Try it — watch a queue fill, block, and drain
A 4-slot queue. Push items in with Send; a slow consumer only Receives when you tell it to. Watch what happens when Send is called on a full queue:
Practice
uint16_t. Have one task generate an incrementing counter every 200ms and xQueueSend it with a 0 timeout (never block). Have a second task xQueueReceive every 1 second — slower than the producer. Log every failed send (queue full) and confirm the count of dropped items matches (producer_rate − consumer_rate) × time, roughly. Then switch the consumer to run every 100ms and confirm drops stop entirely.Completion saves automatically after you read through to the end.
Next lesson →