Queues, Semaphores
& Mutexes — The Full Picture
Overview
In this part of the FreeRTOS Deep Dive series we tackle the three most misunderstood IPC primitives: queues, semaphores, and mutexes. By the end of the video you'll know exactly when to reach for each one — and more importantly, when not to.
We build a live producer–consumer pipeline on a real STM32F4 Discovery board, deliberately trigger a priority inversion bug, watch the system deadlock on the oscilloscope, then fix it with a priority-inheritance mutex. All source code is linked below.
- Understand the difference between queues, binary semaphores, counting semaphores, and mutexes
- Build a queue-based producer–consumer pipeline between three tasks
- Reproduce priority inversion live on hardware and see its impact on timing
- Fix priority inversion using
xSemaphoreCreateMutex()with priority inheritance - Know the runtime cost of each primitive and choose appropriately
Core Concepts at a Glance
xSemaphoreGiveFromISR() instead.Queues
Queues are FreeRTOS's primary inter-task communication mechanism. They copy data by value — not by pointer — which keeps tasks fully decoupled. A sending task blocks if the queue is full; a receiving task blocks if it's empty.
// Define the data type to pass through the queue typedef struct { uint32_t timestamp; float temperature; uint8_t sensor_id; } SensorReading_t; // Create a queue that holds 10 SensorReading_t items QueueHandle_t xSensorQueue; xSensorQueue = xQueueCreate(10, sizeof(SensorReading_t)); // Producer task — sends data into the queue void vProducerTask(void *pvParameters) { SensorReading_t reading; while (1) { reading.timestamp = xTaskGetTickCount(); reading.temperature = BME680_ReadTemp(); reading.sensor_id = 1; // Block up to 100ms if queue is full if (xQueueSend(xSensorQueue, &reading, pdMS_TO_TICKS(100)) != pdPASS) { // Queue overflow — handle gracefully } vTaskDelay(pdMS_TO_TICKS(200)); } } // Consumer task — receives data from the queue void vConsumerTask(void *pvParameters) { SensorReading_t reading; while (1) { // Block indefinitely until data arrives if (xQueueReceive(xSensorQueue, &reading, portMAX_DELAY) == pdPASS) { ProcessReading(&reading); } } }
sizeof(SensorReading_t) = 10 × 8 bytes = 80 bytes of heap. Always check xQueueCreate() returns non-NULL.Semaphores
Semaphores are pure signalling mechanisms — they carry no data. The most common use case is ISR-to-task deferred processing: the ISR fires, gives a semaphore, and a waiting task wakes up to do the heavy work.
SemaphoreHandle_t xDataReadySem; void setup(void) { // Create binary semaphore — starts empty (taken) xDataReadySem = xSemaphoreCreateBinary(); xTaskCreate(vProcessingTask, "Process", 512, NULL, 3, NULL); } // ISR — runs in interrupt context, keep it SHORT void EXTI0_IRQHandler(void) { BaseType_t xHigherPriorityTaskWoken = pdFALSE; // Signal the processing task xSemaphoreGiveFromISR(xDataReadySem, &xHigherPriorityTaskWoken); // Yield if a higher-priority task was woken portYIELD_FROM_ISR(xHigherPriorityTaskWoken); HAL_GPIO_EXTI_IRQHandler(GPIO_PIN_0); } // Task — blocks until semaphore is given by ISR void vProcessingTask(void *pvParameters) { while (1) { xSemaphoreTake(xDataReadySem, portMAX_DELAY); // Safe to do heavy processing here ProcessNewData(); } }
// Limit concurrent access to 3 DMA channels #define MAX_DMA_CHANNELS 3 SemaphoreHandle_t xDMAPool; // Max count = 3, initial count = 3 (all available) xDMAPool = xSemaphoreCreateCounting(MAX_DMA_CHANNELS, MAX_DMA_CHANNELS); void vTransferTask(void *pvParameters) { while (1) { // Acquire a DMA channel slot (blocks if all busy) xSemaphoreTake(xDMAPool, portMAX_DELAY); DMA_StartTransfer(...); DMA_WaitComplete(...); // Release channel back to pool xSemaphoreGive(xDMAPool); } }
Mutexes
A mutex is a binary semaphore with two extra properties: ownership (only the task that took it can give it) and priority inheritance (FreeRTOS temporarily boosts the holder's priority to prevent inversion). Use mutexes exclusively to protect shared resources like peripherals or buffers.
SemaphoreHandle_t xUARTMutex; void UART_Init(void) { xUARTMutex = xSemaphoreCreateMutex(); } // Thread-safe UART print — any task can call this void UART_SafePrint(const char *msg) { // Take mutex — will block if another task holds it if (xSemaphoreTake(xUARTMutex, pdMS_TO_TICKS(100)) == pdPASS) { HAL_UART_Transmit(&huart2, (uint8_t*)msg, strlen(msg), HAL_MAX_DELAY); // MUST give back — only the owner can release xSemaphoreGive(xUARTMutex); } else { // Timeout — log error, don't deadlock } }
Priority Inversion & How Mutexes Fix It
Priority inversion is one of the most dangerous bugs in RTOS systems — and one of the hardest to catch in testing. It happens when a high-priority task is blocked waiting for a resource held by a low-priority task, which itself gets preempted by a medium-priority task. The result: the medium-priority task effectively runs ahead of the high-priority one.
Full Producer–Consumer Demo
This is the complete demo from the video. Three tasks — a sensor reader, a processor, and a logger — pass data through queues and use a mutex to protect the shared SD card handle.
// ── Shared handles ────────────────────────────────── QueueHandle_t xRawQueue; // Sensor → Processor QueueHandle_t xLogQueue; // Processor → Logger SemaphoreHandle_t xSDMutex; // Protects SD card // ── Task 1: Sensor Reader (HIGH priority) ─────────── void vSensorTask(void *pv) { RawData_t raw; TickType_t xLast = xTaskGetTickCount(); while(1) { raw.ts = xTaskGetTickCount(); raw.temp = BME680_ReadTemp(); raw.hum = BME680_ReadHumidity(); xQueueSend(xRawQueue, &raw, pdMS_TO_TICKS(10)); vTaskDelayUntil(&xLast, pdMS_TO_TICKS(100)); } } // ── Task 2: Processor (MEDIUM priority) ───────────── void vProcessorTask(void *pv) { RawData_t raw; ProcessedData_t out; while(1) { if(xQueueReceive(xRawQueue, &raw, portMAX_DELAY)) { out.ts = raw.ts; out.temp_avg = EMA(raw.temp); out.hum_avg = EMA(raw.hum); out.crc = CRC16(&out, sizeof(out) - 2); xQueueSend(xLogQueue, &out, pdMS_TO_TICKS(10)); } } } // ── Task 3: Logger (LOW priority) ─────────────────── void vLoggerTask(void *pv) { ProcessedData_t out; while(1) { if(xQueueReceive(xLogQueue, &out, portMAX_DELAY)) { // Mutex protects the shared SD file handle xSemaphoreTake(xSDMutex, portMAX_DELAY); SD_WriteLine(&out); xSemaphoreGive(xSDMutex); } } } // ── Initialisation ─────────────────────────────────── void vInitIPC(void) { xRawQueue = xQueueCreate(16, sizeof(RawData_t)); xLogQueue = xQueueCreate(32, sizeof(ProcessedData_t)); xSDMutex = xSemaphoreCreateMutex(); xTaskCreate(vSensorTask, "Sensor", 512, NULL, 4, NULL); xTaskCreate(vProcessorTask, "Processor", 768, NULL, 3, NULL); xTaskCreate(vLoggerTask, "Logger", 1024, NULL, 2, NULL); }
When to Use What
| Primitive | Carries Data? | ISR Safe? | Ownership? | Best For |
|---|---|---|---|---|
| Queue | ✓ Yes | ✓ FromISR | ✗ | Passing structs between tasks |
| Binary Semaphore | ✗ No | ✓ Yes | ✗ | ISR → task deferred processing |
| Counting Semaphore | ✗ No | ✓ Yes | ✗ | Resource pool management |
| Mutex | ✗ No | ✗ Never | ✓ Yes | Protecting shared resources |
| Recursive Mutex | ✗ No | ✗ Never | ✓ Reentrant | Nested critical sections |
Exercises
- Modify the producer–consumer demo to add a third queue stage between Processor and Logger
- Replace the binary semaphore ISR demo with a counting semaphore and handle 3 simultaneous GPIO events
- Deliberately introduce priority inversion by replacing
xSemaphoreCreateMutex()withxSemaphoreCreateBinary()and observe timing on an oscilloscope - Use
uxQueueMessagesWaiting()in the Monitor task to detect queue build-up and trigger an alert LED - Profile each primitive's overhead using the FreeRTOS runtime stats feature and compare context switch times
Continue the Series
Part 5 covers FreeRTOS heap models — heap_1 through heap_5 — and how to tune memory allocation for deeply constrained devices.