Most FreeRTOS synchronisation tutorials treat semaphores and mutexes as two flavours of the same thing — a binary flag you acquire and release. That framing compiles. It passes a smoke test. It also plants the seed of a priority-inversion bug that only surfaces when your system runs at full task load with realistic interrupt rates.
This lesson draws the line precisely: a mutex has an owner and performs priority inheritance; a binary semaphore has neither. A semaphore is for signalling — one task or ISR tells another that something happened. A mutex is for mutual exclusion — one task guards a shared resource. Ownership plus inheritance is the entire distinction, and blurring it produces a class of real-time failures that are invisible in development.
Where This Sits in the Series
This lesson builds directly on FreeRTOS Queues on STM32 — copy semantics, the ISR boundary, and queue sizing are assumed knowledge. The next lesson covers ISR Integration in depth. If you have previously read Intertask Communication Part 2 on this site, be aware that it overlaps this material but also covers Task Notifications; a redirect will be added once the Task Notifications and Event Groups lessons exist — for now, both pages are live.
The One Crux: Ownership and Priority Inheritance
A FreeRTOS mutex is a queue of length 1 whose control structure carries an owner field. When a task calls xSemaphoreTake on a mutex, the kernel records that task as the current owner. When the owner calls xSemaphoreGive, ownership is cleared. If a higher-priority task blocks waiting for a mutex already held by a lower-priority task, the kernel immediately raises the lower-priority task’s effective priority to match the waiter — this is priority inheritance. The mechanism is automatic and built into every FreeRTOS mutex. It is absent from every FreeRTOS semaphore.
A binary semaphore is also a queue of length 1, but there is no owner field, no inheritance logic, and no restriction on which task calls Give. Any task — or ISR — can post to a semaphore regardless of which entity took it. This makes semaphores correct for signalling and incorrect for mutual exclusion. Using a semaphore to protect a resource throws away the only defence against unbounded priority inversion. Using a mutex to signal is a category error: there is no owner to hand it back, and the API itself does not support ISR-side Give in the mutex variants.
Priority Inversion in One Sentence
Priority inversion occurs when a high-priority task is forced to wait not for a resource, but for an unrelated medium-priority task to finish executing — because the medium-priority task preempted the low-priority task that holds the resource. Without priority inheritance, the wait is unbounded. The Mars Pathfinder livelock of 1997 is the canonical example. FreeRTOS mutex inheritance bounds the inversion to the time the holding task needs to finish its critical section, which is deterministic and configurable.
Internals: Mutex vs Binary Semaphore Side by Side
| Property | Mutex (xSemaphoreCreateMutex) | Binary Semaphore (xSemaphoreCreateBinary) |
|---|---|---|
| Owner tracking | Yes — kernel records holding task | No |
| Priority inheritance | Yes — automatic, built-in | No |
| Give from ISR | Not permitted (configASSERT fires) | Yes — xSemaphoreGiveFromISR |
| Give from non-owner | Allowed but semantically wrong; no fault | Allowed and correct |
| Recursive take | No (use xSemaphoreCreateRecursiveMutex) | N/A |
| Correct use case | Protect shared resource; critical section | Signal from ISR or task to task |
| Wrong use case | ISR-to-task signal | Protecting a shared resource |
| Internal storage | Queue of length 1 + owner + priority field | Queue of length 1, no owner |
Semaphore: Signalling Pattern
The canonical semaphore use case is an ISR that fires when a hardware event occurs and a task that processes the event. The ISR cannot block and must not manipulate the scheduler directly; a binary semaphore is the minimal-overhead primitive for this handoff.
/* ----------------------------------------------------------------
* uart_signal.c — STM32H743, FreeRTOS, static allocation
* UART1 RXNE ISR signals a processing task via binary semaphore.
* ---------------------------------------------------------------- */
#include "FreeRTOS.h"
#include "semphr.h"
#include "task.h"
#include "stm32h7xx_hal.h"
/* ---------- Semaphore ------------------------------------------- */
static StaticSemaphore_t s_semStruct;
static SemaphoreHandle_t s_rxSem;
/* ---------- Task ------------------------------------------------- */
#define RX_TASK_STACK 512U
static StaticTask_t s_rxTaskTCB;
static StackType_t s_rxTaskStack[RX_TASK_STACK];
/* ---------- ISR: UART1 RXNE ------------------------------------- */
void USART1_IRQHandler(void)
{
BaseType_t higherPriorityTaskWoken = pdFALSE;
/* Acknowledge the interrupt (peripheral-specific, elided) */
__HAL_UART_CLEAR_FLAG(&huart1, UART_FLAG_RXNE);
/* Signal the task — no data passed, just the event occurrence */
xSemaphoreGiveFromISR(s_rxSem, &higherPriorityTaskWoken);
portYIELD_FROM_ISR(higherPriorityTaskWoken);
}
/* ---------- Processing task ------------------------------------- */
static void RxTask(void *pvParameters)
{
(void)pvParameters;
for (;;) {
/* Block until ISR signals */
if (xSemaphoreTake(s_rxSem, portMAX_DELAY) == pdTRUE) {
/* Read from UART data register — ISR has cleared the flag */
ProcessIncomingByte(USART1->RDR & 0xFFU);
}
}
}
/* ---------- Init ------------------------------------------------ */
void UartSignal_Init(void)
{
s_rxSem = xSemaphoreCreateBinaryStatic(&s_semStruct);
configASSERT(s_rxSem != NULL);
xTaskCreateStatic(RxTask, "UartRx", RX_TASK_STACK, NULL,
configMAX_PRIORITIES - 2U,
s_rxTaskStack, &s_rxTaskTCB);
}
Why a semaphore and not a queue here? The ISR is passing no data — only the occurrence of an event. The data lives in the UART peripheral register and is read by the task. If you need to pass data, use a queue (see the previous lesson). If you need to pass only the fact that an event happened, a binary semaphore is the correct primitive. It carries one bit of information: taken (event pending) or given (event occurred).
Counting Semaphore: When Multiple Events Must Not Be Lost
A binary semaphore saturates at 1. If the ISR fires twice before the task runs, the second give is lost — the semaphore is already at its maximum count of 1. For burst sources where every occurrence matters, use a counting semaphore (xSemaphoreCreateCounting). Each give increments the count (up to the configured maximum); each take decrements it. The task will loop and process each event individually.
/* Counting semaphore: handles bursts without losing events */
#define MAX_PENDING_EVENTS 16U
static StaticSemaphore_t s_countSemStruct;
static SemaphoreHandle_t s_eventSem;
void EventSource_Init(void)
{
s_eventSem = xSemaphoreCreateCountingStatic(
MAX_PENDING_EVENTS, /* uxMaxCount */
0U, /* uxInitialCount — starts empty */
&s_countSemStruct
);
configASSERT(s_eventSem != NULL);
}
/* ISR — called on each hardware event */
void TIM3_IRQHandler(void)
{
BaseType_t woken = pdFALSE;
__HAL_TIM_CLEAR_FLAG(&htim3, TIM_FLAG_UPDATE);
xSemaphoreGiveFromISR(s_eventSem, &woken);
portYIELD_FROM_ISR(woken);
}
/* Task — drains all pending events before blocking again */
static void EventTask(void *pv)
{
for (;;) {
if (xSemaphoreTake(s_eventSem, portMAX_DELAY) == pdTRUE) {
HandleTimerEvent();
}
}
}
Mutex: Mutual Exclusion Pattern
The canonical mutex use case is two or more tasks that share a resource — a peripheral register set, a data structure, a communication buffer — where only one task may access the resource at a time. The mutex enforces this and, critically, performs priority inheritance to prevent priority inversion.
/* ----------------------------------------------------------------
* spi_bus.c — STM32H743, FreeRTOS, static allocation
* SPI1 bus shared by a sensor task and a display task.
* Mutex protects the bus; priority inheritance prevents inversion.
* ---------------------------------------------------------------- */
#include "FreeRTOS.h"
#include "semphr.h"
#include "task.h"
/* ---------- Mutex ----------------------------------------------- */
static StaticSemaphore_t s_spiBusMutexStruct;
static SemaphoreHandle_t s_spiBusMutex;
/* ---------- Shared SPI transaction ------------------------------ */
static void SpiBus_Transact(const uint8_t *txBuf, uint8_t *rxBuf, size_t len)
{
/* Caller already holds the mutex — peripheral access is safe */
HAL_SPI_TransmitReceive(&hspi1, (uint8_t *)txBuf, rxBuf, len, HAL_MAX_DELAY);
}
/* Public API: acquire bus, transact, release */
bool SpiBus_Exchange(const uint8_t *txBuf, uint8_t *rxBuf, size_t len,
TickType_t timeout)
{
if (xSemaphoreTake(s_spiBusMutex, timeout) != pdTRUE) {
return false; /* timed out — bus busy */
}
SpiBus_Transact(txBuf, rxBuf, len);
xSemaphoreGive(s_spiBusMutex);
return true;
}
/* ---------- Init ------------------------------------------------ */
void SpiBus_Init(void)
{
s_spiBusMutex = xSemaphoreCreateMutexStatic(&s_spiBusMutexStruct);
configASSERT(s_spiBusMutex != NULL);
}
/* ----------------------------------------------------------------
* Usage in sensor task (priority 4):
* SpiBus_Exchange(txSensor, rxSensor, sizeof(rxSensor), pdMS_TO_TICKS(10));
*
* Usage in display task (priority 2):
* SpiBus_Exchange(txDisplay, rxDisplay, sizeof(rxDisplay), portMAX_DELAY);
*
* If a priority-5 task blocks on the mutex while the priority-2
* display task holds it, FreeRTOS raises the display task to
* priority 5 for the duration — bounded inversion.
* ---------------------------------------------------------------- */
Notice the asymmetry with the semaphore example: there is no FromISR variant here. An ISR must never call xSemaphoreTake on a mutex — it cannot block, and the mutex ownership model requires a task context. If you need an ISR to participate in resource protection, restructure: have the ISR post to a queue or semaphore and let a dedicated task perform the protected access.
Recursive Mutex
If a task may call the same function that takes a mutex from multiple call-stack depths — for example, a logging function that acquires a UART mutex, called both directly and from within another function that has already acquired it — use a recursive mutex (xSemaphoreCreateRecursiveMutex). The same task can take the recursive mutex multiple times; each take must be matched by a give. The mutex is released to other tasks only when the take count returns to zero. Recursive mutexes have slightly higher overhead than non-recursive ones; use the non-recursive variant everywhere else.
Priority Inversion: The Full Scenario
Consider three tasks on an STM32H7 running FreeRTOS at configTICK_RATE_HZ = 1000:
- TaskH — priority 5, periodic, acquires the SPI mutex to read a sensor
- TaskM — priority 3, CPU-intensive, never touches the SPI bus
- TaskL — priority 1, acquires the SPI mutex to update a display
Without priority inheritance (binary semaphore misused as a mutex):
- TaskL takes the semaphore, begins a slow SPI display update (say, 5 ms).
- TaskH becomes ready, preempts TaskL, tries to take the semaphore, and blocks — semaphore is held.
- TaskM becomes ready. It has higher priority than TaskL, so it preempts TaskL.
- TaskM runs for as long as it has work — potentially 10 ms, 50 ms, unbounded.
- TaskL cannot run while TaskM is running. The semaphore is not released.
- TaskH, priority 5, is blocked indefinitely waiting for a priority-1 task to get CPU time that a priority-3 task is consuming. Unbounded priority inversion.
With priority inheritance (mutex):
- TaskL takes the mutex, begins the display update.
- TaskH becomes ready, tries to take the mutex, blocks.
- The FreeRTOS kernel immediately raises TaskL’s effective priority to 5 (matching TaskH).
- TaskM becomes ready at priority 3 — but TaskL is now effectively priority 5. TaskM cannot preempt TaskL.
- TaskL completes the display update, gives the mutex. Its priority drops back to 1.
- TaskH unblocks and runs immediately — bounded inversion: only as long as TaskL needed to finish.
The difference is not theoretical. In production firmware with USB, CAN, or Ethernet tasks at medium priority, unbounded priority inversion produces watchdog resets and missed deadlines that correlate with communication traffic and are nearly impossible to reproduce in a test environment.
Anti-Patterns
1. Using a Binary Semaphore to Protect a Shared Resource
/* WRONG — no priority inheritance, unbounded inversion possible */
static SemaphoreHandle_t s_spiBusSem; /* binary semaphore */
bool SpiBus_Exchange(...)
{
xSemaphoreTake(s_spiBusSem, portMAX_DELAY);
SpiBus_Transact(...);
xSemaphoreGive(s_spiBusSem);
return true;
}
/* The semaphore protects the bus in the functional sense
but does NOT inherit priority — TaskM scenario above applies. */
This compiles, passes functional tests, and fails under load. The failure mode is non-deterministic latency for high-priority tasks, not a crash. It will not surface in a test environment unless you deliberately create the three-task inversion scenario at full CPU load.
2. Giving a Mutex from an ISR
/* WRONG — mutex Give is not ISR-safe */
void TIM3_IRQHandler(void)
{
BaseType_t woken = pdFALSE;
xSemaphoreGiveFromISR(s_mutex, &woken); /* configASSERT fires in debug */
portYIELD_FROM_ISR(woken);
/* In release builds without configASSERT, this silently
corrupts the ownership state of the mutex. */
}
FreeRTOS mutexes are not ISR-safe. The xSemaphoreGiveFromISR function includes a configASSERT that fires when called on a mutex in debug builds. In release builds with assertions disabled, calling it corrupts the mutex owner field. If you need to signal from an ISR, use a binary semaphore or a queue.
3. Taking a Mutex in an ISR
/* WRONG — ISR cannot block; will fault or corrupt scheduler state */
void EXTI0_IRQHandler(void)
{
xSemaphoreTake(s_mutex, portMAX_DELAY); /* ISR cannot block */
AccessSharedResource();
xSemaphoreGive(s_mutex);
}
An ISR operates outside the scheduler’s task-switching mechanism. Blocking inside an ISR either faults immediately (if the RTOS detects it) or corrupts the scheduler state by attempting a context switch from interrupt context. Never acquire a mutex from an ISR. Restructure: post the data to a queue from the ISR, and let a task acquire the mutex and perform the protected access.
4. Using a Mutex for Task-to-Task Signalling
/* WRONG — category error; mutex taken by TaskA, given by TaskB */
void TaskA(void *pv)
{
for (;;) {
xSemaphoreTake(s_mutex, portMAX_DELAY); /* TaskA "waits" */
ProcessEvent();
}
}
void TaskB(void *pv)
{
for (;;) {
WaitForHardwareEvent();
xSemaphoreGive(s_mutex); /* TaskB signals TaskA */
/* TaskB is not the owner — ownership model is violated */
}
}
This uses a mutex as a semaphore. TaskB gives a mutex it does not own. FreeRTOS allows this at the API level (no assertion fires on Give from a non-owner in most configurations) but the ownership semantics are broken — priority inheritance will not function correctly because the owner field is inconsistent. Use a binary semaphore for this pattern.
5. Holding a Mutex Across a Blocking Call
/* WRONG — holds mutex while blocking, starving other users */
bool SpiBus_Exchange(...)
{
xSemaphoreTake(s_spiBusMutex, portMAX_DELAY);
SpiBus_Transact(...); /* fast — OK */
vTaskDelay(pdMS_TO_TICKS(50)); /* WRONG: holds mutex for 50 ms */
xSemaphoreGive(s_spiBusMutex);
return true;
}
Holding a mutex for any longer than the minimum time needed to complete the protected access defeats the purpose of the mutex. Any delay, queue receive, or other blocking operation taken while holding the mutex extends the window during which other tasks block on it. Keep critical sections short: take the mutex, do the minimum necessary work on the shared resource, give the mutex immediately.
6. Nested Mutex Acquisition (Deadlock)
/* WRONG — deadlock if TaskA holds MutexA and waits for MutexB
while TaskB holds MutexB and waits for MutexA */
void TaskA(void *pv)
{
xSemaphoreTake(s_mutexA, portMAX_DELAY);
xSemaphoreTake(s_mutexB, portMAX_DELAY); /* blocks if TaskB holds B */
/* ... */
xSemaphoreGive(s_mutexB);
xSemaphoreGive(s_mutexA);
}
void TaskB(void *pv)
{
xSemaphoreTake(s_mutexB, portMAX_DELAY);
xSemaphoreTake(s_mutexA, portMAX_DELAY); /* blocks if TaskA holds A */
/* ... */
xSemaphoreGive(s_mutexA);
xSemaphoreGive(s_mutexB);
}
Classic deadlock. FreeRTOS has no deadlock detection. Both tasks block forever; the watchdog eventually fires. The fix is a strict lock-ordering discipline: all tasks must acquire multiple mutexes in the same global order (e.g., always MutexA before MutexB). Alternatively, redesign so that any code path requiring two mutexes simultaneously is eliminated.
Complete Decision Guide
| Scenario | Correct Primitive | Reason |
|---|---|---|
| ISR fires → task must process | Binary semaphore | Signalling; ISR can Give; no resource to protect |
| ISR fires repeatedly; no event must be lost | Counting semaphore | Counts pending events; task drains one per Take |
| ISR fires with data payload | Queue | Carries data + event in one operation |
| Two tasks share a peripheral | Mutex | Mutual exclusion with priority inheritance |
| One task guards a shared data structure | Mutex | Mutual exclusion with priority inheritance |
| Task needs to signal another task (no ISR) | Binary semaphore or Task Notification | Signalling; no resource ownership needed |
| Same task re-enters a locked section | Recursive mutex | Re-entrant take without self-deadlock |
Best Practices
Always use static allocation. xSemaphoreCreateMutexStatic and xSemaphoreCreateBinaryStatic give you a fixed RAM footprint with no heap touchpoint. In certified or hard-real-time firmware, dynamic allocation during normal operation is typically prohibited.
configASSERT the handle immediately after creation. A null handle from a static-allocation call means a coding error that must die at boot, not silently corrupt synchronisation state at runtime.
Never take a mutex from an ISR, never give a mutex from an ISR. If the ISR needs to signal a task, use a binary semaphore or queue. If the ISR needs to modify a shared resource, post the data to a queue and let a task do the modification under mutex protection.
Keep mutex-protected sections as short as possible. Measure the worst-case hold time and verify it is acceptable for all tasks that compete for the mutex. A 50 ms hold time on a mutex shared with a high-priority periodic task is a real-time violation waiting to happen.
Establish and document a global lock order if multiple mutexes must be acquired simultaneously. Enforce it via code review and static analysis. A single inversion of lock order is sufficient to produce a deadlock that manifests only under specific timing conditions.
Enable configUSE_MUTEXES and configUSE_RECURSIVE_MUTEXES in FreeRTOSConfig.h. Without configUSE_MUTEXES = 1, xSemaphoreCreateMutex reduces to a binary semaphore — ownership and inheritance are silently disabled. Check your config file.
Interview Questions
Q1: What is the structural difference between a FreeRTOS mutex and a binary semaphore?
Both are implemented as a queue of length 1. A mutex additionally maintains an owner field (the handle of the task that called Take) and a priority-inheritance field. A binary semaphore has neither. The owner field is what enables priority inheritance: when a high-priority task blocks on the mutex, the kernel can raise the owner’s effective priority because it knows who the owner is.
Q2: Can an ISR give a mutex? What happens if it tries?
No. In debug builds with configASSERT enabled, xSemaphoreGiveFromISR includes an assertion that fails when called on a mutex. In release builds with assertions disabled, it corrupts the owner state of the mutex. Use a binary semaphore or queue for ISR-to-task signalling.
Q3: Describe a three-task priority inversion scenario and explain how a mutex resolves it.
TaskH (priority 5) and TaskL (priority 1) share a resource; TaskM (priority 3) does not touch the resource. TaskL takes the resource, TaskH tries to take it and blocks. Without inheritance, TaskM preempts TaskL and holds the CPU indefinitely — TaskH waits for a priority-1 task while a priority-3 task runs. With a mutex, the kernel raises TaskL to priority 5 the moment TaskH blocks. TaskM (priority 3) can no longer preempt TaskL (now effectively priority 5). TaskL finishes, releases the mutex, its priority drops back to 1, and TaskH runs immediately.
Q4: A colleague replaces every mutex in your firmware with a binary semaphore to “simplify the code.” The unit tests all pass. What is the risk?
Priority inheritance is lost. Under low load with only one or two tasks competing, the inversion window is short and tests pass. Under production load — multiple tasks at varied priorities, interrupt-driven activity, USB or CAN traffic — the inversion window grows and becomes unbounded. High-priority tasks miss deadlines; the watchdog fires. The failure correlates with unrelated system activity and is extremely difficult to reproduce in a test environment.
Q5: When should you use a counting semaphore instead of a binary semaphore?
When the event source can fire multiple times before the consumer runs, and each occurrence must be processed individually. A binary semaphore saturates at 1; the second give before the first take is lost. A counting semaphore increments on each give up to its configured maximum; the consumer calls take in a loop, processing one event per iteration, until the count reaches zero.
Q6: What does configUSE_MUTEXES = 0 in FreeRTOSConfig.h actually do to xSemaphoreCreateMutex?
With configUSE_MUTEXES = 0, the mutex API macros are not defined, and attempting to use them produces a compilation error. In some port configurations, xSemaphoreCreateMutex may silently create a binary semaphore instead. Either way, priority inheritance is unavailable. Always verify your FreeRTOSConfig.h has configUSE_MUTEXES 1 before relying on mutex behaviour.
Summary
A FreeRTOS mutex and a binary semaphore are not interchangeable. The mutex carries an owner field and performs priority inheritance; the semaphore has neither. Use a semaphore to signal — one task or ISR tells another that an event occurred. Use a mutex to protect — one task guards a shared resource with bounded inversion as the guarantee. Using a semaphore to protect a resource throws away priority inheritance and admits unbounded priority inversion; using a mutex to signal is a category error because there is no owner to hand it back and the ISR-side API is unavailable. Ownership plus inheritance is the entire distinction, and getting it right is the difference between firmware that works in the lab and firmware that works in production.
What’s Next
The next lesson covers ISR Integration in depth — the full picture of deferred interrupt processing, the ISR-to-task handoff patterns, and the interrupt priority configuration on Cortex-M that determines which FreeRTOS APIs are callable from which interrupt priority levels. The semaphore and mutex ownership model you’ve just studied is the foundation for understanding why the configMAX_SYSCALL_INTERRUPT_PRIORITY boundary exists and what happens when you cross it.