Electronics Design AU
RTOS

How Do FreeRTOS Queues, Semaphores, and Mutexes Work?

Last updated 30 June 2026 · 13 min read

Direct Answer

FreeRTOS provides six inter-task communication primitives: queues copy typed data between tasks or from ISRs to tasks; binary semaphores signal single events; counting semaphores accumulate event counts; mutexes protect shared resources with priority inheritance; task notifications send a 32-bit signal directly to one named task without heap allocation; and event groups let tasks wait on combinations of bitfield events. Use a mutex — never a binary semaphore — when protecting a shared resource, because only mutexes implement priority inheritance to prevent priority inversion.

Detailed Explanation

FreeRTOS tasks run concurrently and regularly need to exchange data, signal events, and coordinate access to shared hardware. Sharing global variables directly without synchronisation causes race conditions — one task reads a half-written value, or two tasks drive the same SPI bus simultaneously. FreeRTOS solves this with a set of kernel-managed communication primitives that are inherently thread-safe.

For task creation, priorities, scheduling, and stack sizing, see How Do You Create and Schedule Tasks in FreeRTOS?. This page covers what happens after tasks are running: how they communicate and coordinate.

Choosing the Right Primitive

PrimitiveTransfers data?ISR-safe?Priority inheritance?Typical use
QueueYes (typed copy)Yes (FromISR)NoPass structs or values between tasks or from an ISR to a task
Binary semaphoreNoYes (FromISR)NoSignal a single event from an ISR or task to one waiting task
Counting semaphoreNo (count only)Yes (FromISR)NoCount available resources or accumulate multiple events
MutexNoNoYesProtect a shared resource; prevents priority inversion
Task notification32-bit valueYes (FromISR)NoFast single-receiver signal; lightweight replacement for a binary semaphore
Event groupBitfieldYes (via daemon)NoWait on any or all of several independent events simultaneously

Each primitive has a static allocation variant (xQueueCreateStatic, xSemaphoreCreateMutexStatic, etc.) for systems that must avoid runtime heap allocation.

Queues

A queue is a thread-safe FIFO that copies items of a fixed size from sender to receiver. Items are copied by value into the queue on send and copied out on receive — no pointer ownership or lifetime management required. This makes queues safe even if the sending task immediately overwrites the local variable after the send.

/* Create a queue at startup, before vTaskStartScheduler() */
QueueHandle_t xSensorQueue;

xSensorQueue = xQueueCreate(
    10,              /* Maximum number of items the queue can hold */
    sizeof(int16_t)  /* Size of each item in bytes */
);
configASSERT(xSensorQueue != NULL);  /* NULL = heap allocation failed */

Sending to a queue:

int16_t rawAdc = ADC_Read();

/* Block up to 10 ms if the queue is full */
if (xQueueSend(xSensorQueue, &rawAdc, pdMS_TO_TICKS(10)) != pdPASS) {
    /* Queue was full — decide: drop, log, or assert */
}

/* portMAX_DELAY blocks indefinitely (requires INCLUDE_vTaskSuspend = 1) */
xQueueSend(xSensorQueue, &rawAdc, portMAX_DELAY);

Receiving from a queue:

void vProcessTask(void *pvParameters) {
    int16_t sample;
    for (;;) {
        if (xQueueReceive(xSensorQueue, &sample, portMAX_DELAY) == pdPASS) {
            /* sample contains the value copied from the sender */
        }
    }
}

ISR-safe variant — the only correct way to send from an interrupt handler:

void ADC_ConvCpltCallback(void) {
    int16_t raw = ADC_GetResult();
    BaseType_t xHigherPriorityTaskWoken = pdFALSE;

    xQueueSendFromISR(xSensorQueue, &raw, &xHigherPriorityTaskWoken);
    portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}

Always call portYIELD_FROM_ISR() at the end of an ISR that uses FromISR functions. If xHigherPriorityTaskWoken is set to pdTRUE by any FromISR call during the ISR, portYIELD_FROM_ISR() requests an immediate context switch — so the unblocked task runs as soon as the ISR exits rather than waiting up to one full tick period.

Sending large structs: For payloads larger than a few bytes, send a pointer rather than the struct itself to avoid copying large data through the queue. The pointed-to buffer must remain valid until the receiver has processed it — use a statically allocated pool or a FreeRTOS heap block.

Binary Semaphores

A binary semaphore is a one-bit flag: given or taken. It is the simplest way to signal that an event has occurred, typically from an ISR to a task.

SemaphoreHandle_t xDataReadySem;

xDataReadySem = xSemaphoreCreateBinary();
configASSERT(xDataReadySem != NULL);

A binary semaphore created with xSemaphoreCreateBinary() starts in the taken (empty) state. The pattern is always ISR or sender gives, task takes:

/* ISR signals the event */
void UART_RxCpltCallback(void) {
    BaseType_t xHigherPriorityTaskWoken = pdFALSE;
    xSemaphoreGiveFromISR(xDataReadySem, &xHigherPriorityTaskWoken);
    portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}

/* Task blocks until the event occurs */
void vProcessTask(void *pvParameters) {
    for (;;) {
        xSemaphoreTake(xDataReadySem, portMAX_DELAY);
        /* Process the event */
    }
}

If the ISR fires before the task calls Take, the Give latches the semaphore — the task picks it up immediately on the next Take. If the ISR fires twice before the task runs, the second Give is silently lost: a binary semaphore cannot count beyond one. Use a counting semaphore or a queue if missed events matter.

Do not use binary semaphores to protect shared resources. They have no ownership and no priority inheritance. Use a mutex instead (see below).

Counting Semaphores

A counting semaphore extends the binary semaphore with an integer counter. Each Give increments the count; each Take decrements it. Take blocks when the count reaches zero. Requires configUSE_COUNTING_SEMAPHORES = 1 in FreeRTOSConfig.h.

/* Resource pool: 3 available DMA channels */
SemaphoreHandle_t xDmaSlotSem = xSemaphoreCreateCounting(3, 3);
/* xMaxCount = 3, xInitialCount = 3 (all slots available at start) */

Two common uses:

  • Resource pool — initialise count to the number of available resources. Tasks call Take to claim one and Give to release it.
  • Event accumulation — initialise to 0. Each ISR fires Give; the processing task drains the count with successive Take calls.

Mutexes and Priority Inheritance

A mutex (mutual exclusion lock) protects a shared resource against concurrent access. The critical difference from a binary semaphore is priority inheritance: when a high-priority task blocks waiting for a mutex held by a low-priority task, the kernel temporarily raises the holding task's priority to match the waiting task's — preventing medium-priority tasks from running ahead of the holder and indirectly starving the high-priority waiter.

Requires configUSE_MUTEXES = 1 in FreeRTOSConfig.h.

SemaphoreHandle_t xSpiMutex;

xSpiMutex = xSemaphoreCreateMutex();
configASSERT(xSpiMutex != NULL);

void vSpiWrite(uint8_t *data, size_t len) {
    if (xSemaphoreTake(xSpiMutex, portMAX_DELAY) == pdPASS) {
        /* --- Exclusive SPI access --- */
        HAL_SPI_Transmit(&hspi1, data, len, HAL_MAX_DELAY);
        /* ----------------------------- */
        xSemaphoreGive(xSpiMutex);
    }
}

Mutex rules — all are strict:

  • A mutex must be given back by the same task that took it. Giving from a different task is undefined behaviour.
  • Never call mutex functions from an ISR. xSemaphoreGiveFromISR() is not valid for a mutex handle — it silently corrupts the priority-inheritance state. Use a binary semaphore, task notification, or queue for ISR-to-task signalling.
  • Never hold a mutex while calling a blocking FreeRTOS API — this causes deadlock or unintended priority propagation.
  • Keep the critical section as short as possible. Long critical sections increase priority inversion risk even with inheritance.

For code that legitimately needs to acquire the same mutex multiple times within one call stack (recursive acquisition), use xSemaphoreCreateRecursiveMutex() with xSemaphoreTakeRecursive() and xSemaphoreGiveRecursive(). The recursive mutex tracks the nesting depth and only truly releases when every Take has been matched with a Give.

Task Notifications

Every FreeRTOS task has a 32-bit notification value and a pending flag built into its Task Control Block — no additional heap allocation required. Task notifications are the fastest inter-task signalling mechanism in FreeRTOS and should be the default choice for ISR-to-task signals when there is a single, known receiver.

Enabled by default (configUSE_TASK_NOTIFICATIONS = 1); enabled in nearly all FreeRTOS configurations.

Binary notification — replaces a binary semaphore:

/* Sender (task or ISR) gives a notification to a specific task */
xTaskNotifyGive(xTargetTaskHandle);              /* From a task */
vTaskNotifyGiveFromISR(xTargetTaskHandle,        /* From an ISR */
                       &xHigherPriorityTaskWoken);

/* Receiver — waits for the notification */
void vProcessTask(void *pvParameters) {
    for (;;) {
        ulTaskNotifyTake(pdTRUE,          /* pdTRUE = clear to 0 on exit (binary) */
                         portMAX_DELAY);  /* Block until notified */
        /* Handle event */
    }
}

ulTaskNotifyTake(pdFALSE, ...) decrements the count by 1 instead of clearing it, giving counting-semaphore behaviour.

Sending a value — lightweight replacement for a small queue:

/* Send a 32-bit value directly to a task */
xTaskNotify(xTargetTaskHandle,
            ulSensorReading,
            eSetValueWithOverwrite);  /* Overwrite any unread notification */

/* Receive in the target task */
uint32_t ulValue;
xTaskNotifyWait(0x00,        /* Bits to clear on entry */
                0xFFFFFFFF,  /* Bits to clear on exit */
                &ulValue,
                portMAX_DELAY);

eNotifyAction options for xTaskNotify():

ActionEffect
eSetValueWithOverwriteOverwrite the notification value unconditionally
eSetValueWithoutOverwriteWrite only if no notification is pending; returns pdFAIL if already pending
eSetBitsOR the value into the notification value (bitfield accumulation)
eIncrementIncrement the notification value (equivalent to xTaskNotifyGive)
eNoActionSet the pending flag only; leave the notification value unchanged

Limitation: Each task has exactly one notification slot. If two independent events need to be tracked simultaneously, use two separate semaphores or an event group. Task notifications are many-senders-to-one-receiver — the receiver is always a specific named task.

Event Groups

An event group is a bitfield where each bit represents an independent event. Multiple tasks can set bits and multiple tasks can wait for different combinations of bits. Event groups are the right choice when a task must wait for several conditions to be true simultaneously — or when any one of several conditions should trigger action.

On 32-bit FreeRTOS ports, bits 0–23 are available for application use (bits 24–31 are reserved by the kernel).

#define EVT_SENSOR_READY   (1UL << 0)
#define EVT_CONFIG_LOADED  (1UL << 1)
#define EVT_COMMS_UP       (1UL << 2)

EventGroupHandle_t xStartupEvents;

xStartupEvents = xEventGroupCreate();
configASSERT(xStartupEvents != NULL);

/* Each subsystem task sets its bit when initialisation completes */
void vSensorInitTask(void *pvParameters) {
    SensorHardwareInit();
    xEventGroupSetBits(xStartupEvents, EVT_SENSOR_READY);
    vTaskDelete(NULL);  /* One-shot init task — delete itself */
}

/* Main application task waits for all three subsystems */
void vApplicationTask(void *pvParameters) {
    xEventGroupWaitBits(
        xStartupEvents,
        EVT_SENSOR_READY | EVT_CONFIG_LOADED | EVT_COMMS_UP,
        pdTRUE,    /* Clear the bits on exit */
        pdTRUE,    /* pdTRUE = wait for ALL bits; pdFALSE = wait for ANY bit */
        portMAX_DELAY
    );
    /* All subsystems ready — begin the main loop */
    for (;;) { /* ... */ }
}

Setting bits from an ISR — requires configUSE_TIMERS = 1 and INCLUDE_xTimerPendFunctionCall = 1, because xEventGroupSetBitsFromISR() posts the operation to the FreeRTOS timer daemon task rather than setting bits directly:

void EXTI_IRQHandler(void) {
    BaseType_t xHigherPriorityTaskWoken = pdFALSE;
    xEventGroupSetBitsFromISR(xStartupEvents, EVT_SENSOR_READY,
                               &xHigherPriorityTaskWoken);
    portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}

Because the daemon task processes the bit-set operation, there is a delay of up to one tick period between the ISR firing and the bits appearing in the event group. For task-to-task bit setting (not ISR), use xEventGroupSetBits() directly — it sets bits atomically with no daemon delay.

Design Considerations

Queue vs task notification for ISR-to-task data. A queue buffers multiple items if the consumer falls behind — safe when the producer can outrun the receiver in bursts. A task notification holds only one 32-bit value: a second notification before the task runs overwrites (or increments) the first. Use a queue when no sample can be dropped; use task notifications when only the latest value matters or when you know the task services the signal faster than it arrives.

Mutex granularity. A coarse mutex protecting an entire driver (e.g. a full SPI bus) is simple but forces high-priority tasks to wait for the entire duration of any lower-priority transfer. Consider shorter critical sections, double buffering, or per-operation locking to reduce blocking time. The goal is: take the mutex, do the minimum work, give the mutex.

Heap budget. Queues, semaphores, mutexes, and event groups all allocate from the FreeRTOS heap. Create all primitives before vTaskStartScheduler() to keep allocations predictable. On heavily constrained parts, use static allocation variants (xQueueCreateStatic, xSemaphoreCreateMutexStatic, xEventGroupCreateStatic) with caller-supplied buffers to eliminate heap use entirely and make memory requirements visible at link time.

FreeRTOSConfig.h requirements. Some primitives require opt-in configuration:

PrimitiveFreeRTOSConfig.h requirement
Counting semaphoreconfigUSE_COUNTING_SEMAPHORES = 1
MutexconfigUSE_MUTEXES = 1
Recursive mutexconfigUSE_RECURSIVE_MUTEXES = 1
Task notificationsconfigUSE_TASK_NOTIFICATIONS = 1 (default)
Event group ISR setterconfigUSE_TIMERS = 1, INCLUDE_xTimerPendFunctionCall = 1
portMAX_DELAY blockingINCLUDE_vTaskSuspend = 1

If you need a production-grade FreeRTOS inter-task communication architecture across multiple peripherals and priorities — particularly on STM32, ESP32, or nRF platforms — Zeus Design's firmware team designs and validates RTOS task communication patterns for firmware that ships in deployed products.

See How Does the Memory Map Work in an Embedded Microcontroller? for how the FreeRTOS heap, task stacks, and application data coexist in the MCU address space.

When a task is blocked indefinitely on a mutex or semaphore, a task-aware debugger showing FreeRTOS task states — or the vTaskList() diagnostic — can identify which task holds the lock and why the waiter never unblocks. See How Do You Debug Embedded Firmware? for the complete firmware debugging toolkit, including task-aware debugger setup and vTaskList() use during RTOS bring-up.

Common Mistakes

1. Using a binary semaphore instead of a mutex for shared resource access Binary semaphores do not implement priority inheritance. If a low-priority task holds a binary semaphore and a high-priority task waits for it, any medium-priority task can run indefinitely — the holder never gets CPU time to release the semaphore, and the high-priority task is permanently blocked. Always use xSemaphoreCreateMutex() when guarding a shared resource.

2. Calling mutex functions from an ISR xSemaphoreTakeFromISR() and xSemaphoreGiveFromISR() are not valid for mutex handles. Calling them silently corrupts the priority-inheritance bookkeeping in the kernel. Use a binary semaphore, task notification, or queue for ISR-to-task signalling — never a mutex.

3. Not checking the return value of xQueueSend() If the queue is full and the timeout expires, xQueueSend() returns errQUEUE_FULL. Ignoring this means silently dropping data. Size the queue for worst-case burst traffic and decide explicitly how to handle overflow: drop the oldest item with xQueueOverwrite(), discard the new item, or block with a longer timeout.

4. Forgetting portYIELD_FROM_ISR() after a FromISR call Without portYIELD_FROM_ISR(xHigherPriorityTaskWoken), a task unblocked by an ISR must wait until the next scheduler tick (typically up to 1 ms at a 1 kHz tick rate) before it runs. Always call portYIELD_FROM_ISR() at the end of any ISR that uses FromISR variants, even if only one call is made.

5. Calling the non-FromISR variant inside an ISR xQueueSend(), xSemaphoreGive(), and xTaskNotifyGive() assume task context when they disable interrupts. Calling any of them from an ISR can corrupt the scheduler's ready list or cause a hard fault. Always use the FromISR suffix inside interrupt handlers — no exceptions.

6. Task notification overwrite with multiple pending events xTaskNotify(handle, value, eSetValueWithOverwrite) silently replaces any unread notification. If two events fire in rapid succession, the first is lost. Use eSetBits with distinct per-event bits to accumulate multiple events, or switch to a queue when lossless delivery is required. Use eSetValueWithoutOverwrite when detecting missed notifications matters — it returns pdFAIL if a notification is already pending.

7. Holding a mutex while calling a blocking API Taking a mutex and then calling vTaskDelay(), xQueueReceive(..., portMAX_DELAY), or any blocking operation leaves the mutex locked for the blocked duration. Any other task needing the same mutex will also block — potentially cascading through the entire system. Keep mutex-protected sections non-blocking and as short as possible.

Frequently Asked Questions

What is the difference between a mutex and a binary semaphore in FreeRTOS?
Both use the same xSemaphoreTake()/xSemaphoreGive() API, but they serve different purposes. A binary semaphore is a signalling mechanism — one task or ISR signals another that an event occurred. It has no ownership: any task or ISR can Give or Take it. A mutex is a resource-guarding mechanism — it has strict ownership (the task that Took it must Give it back) and implements priority inheritance, temporarily raising the holder's priority to prevent a lower-priority task from indirectly blocking a higher-priority waiter. Rule: use a binary semaphore to signal events; use a mutex to protect shared resources. Replacing a mutex with a binary semaphore when guarding a resource is a common bug that causes priority inversion.
Can I use xQueueSend() or xSemaphoreGive() from an interrupt service routine?
No. Standard (non-FromISR) FreeRTOS functions must not be called from an ISR — they disable interrupts at a granularity that assumes task context, and calling them from an ISR corrupts the scheduler's internal state. Always use the ISR-safe variants: xQueueSendFromISR(), xSemaphoreGiveFromISR(), vTaskNotifyGiveFromISR(), and xEventGroupSetBitsFromISR(). After any FromISR call, check xHigherPriorityTaskWoken and call portYIELD_FROM_ISR() at the end of the ISR so the newly unblocked task runs immediately rather than waiting for the next tick.
When should I use task notifications instead of a binary semaphore?
Task notifications are faster and use no additional heap memory, making them the preferred choice when: (1) there is exactly one receiver task (notifications target a specific task handle), (2) you need only a simple event signal or a 32-bit value, and (3) you can tolerate the limitation that only one notification can be pending per task. Use a binary semaphore instead when multiple tasks need to wait on the same event (each gets its own semaphore), or when the sender does not have access to the receiver's task handle. As a rule of thumb: default to task notifications for ISR-to-task signals in new code — they are faster and cheaper than binary semaphores for the single-receiver case.

References

Related Questions

Related Forum Discussions