
Every embedded developer working with an RTOS eventually faces the same question: should I use a mutex or a semaphore? The two primitives look similar on the surface — both block tasks, both coordinate access — but they solve fundamentally different problems. Choosing the wrong one leads to priority inversion, resource leaks, or subtle race conditions that are nearly impossible to reproduce in testing.
This article breaks down the differences, explains the ownership model that separates the two, and provides clear guidance on which primitive to reach for in common embedded scenarios.
A mutex (mutual exclusion object) is a locking primitive with ownership. When a task takes a mutex, it becomes the owner. Only the owning task can release (unlock) the mutex. If any other task attempts to unlock it, the RTOS rejects the operation (in FreeRTOS, xSemaphoreGive() returns pdFALSE, and configASSERT() fires if defined).
This ownership property has a critical consequence: the RTOS kernel can implement priority inheritance on a mutex. If a high-priority task blocks waiting for a mutex held by a low-priority task, the kernel temporarily elevates the low task’s priority to match the waiting high-priority task. This bounds priority inversion to the duration of the low-priority task’s critical section, preventing medium-priority tasks from causing unbounded delay.
Important: Mutexes must not be used from Interrupt Service Routines (ISRs). ISRs do not run in a task context, so the kernel cannot assign ownership or apply priority inheritance. Use binary semaphores (
xSemaphoreGiveFromISR) for ISR-to-task signaling.
SemaphoreHandle_t xMutex = xSemaphoreCreateMutex();void high_priority_task(void *param) {while (1) {if (xSemaphoreTake(xMutex, pdMS_TO_TICKS(100)) == pdTRUE) {access_shared_resource();xSemaphoreGive(xMutex); // Only the owner can give}}}
In FreeRTOS, xSemaphoreCreateMutex() returns a SemaphoreHandle_t, but the underlying type is a mutex — it supports priority inheritance and must be released by the same task that took it. Unlike binary semaphores (created empty), a newly created mutex starts in the available state so it can be taken immediately.
A semaphore is a signaling and counting primitive without ownership. Any task (or ISR) can post (give) a semaphore, and any task can take (wait on) it. There is no concept of “the task that owns this semaphore.”
Semaphores come in two flavors:
// Binary semaphore for ISR-to-task signalingSemaphoreHandle_t xBinSem = xSemaphoreCreateBinary();void UART_ISR(void) {BaseType_t xHigherPriorityTaskWoken = pdFALSE;xSemaphoreGiveFromISR(xBinSem, &xHigherPriorityTaskWoken);portYIELD_FROM_ISR(xHigherPriorityTaskWoken);}void comm_task(void *param) {while (1) {xSemaphoreTake(xBinSem, portMAX_DELAY);process_uart_data();}}
The single most important difference is ownership. Here is a visual comparison:
+=====================================================================+| MUTEX || || Task A (HIGH) Kernel Task B (LOW) || +-----------+ +--------+ +-----------+ || | blocks | ------> |raises | -------> | running | || | waiting | wait |priority| inherit | at HIGH | || | for lock | |of B | | priority | || +-----------+ +--------+ +-----------+ || || KEY: Only the task that took the mutex can give it back. || KEY: Priority inheritance prevents unbounded inversion. |+=====================================================================+
+=====================================================================+| SEMAPHORE || || Task A (HIGH) Task B (LOW) || +-----------+ +-----------+ || | blocks | NO priority | running | || | waiting | inheritance! | at LOW | || | for sem | (inversion can | priority | || +-----------+ be unbounded) +-----------+ || || KEY: Any task/ISR can give. Any task can take. || KEY: No ownership -- no priority inheritance. |+=====================================================================+
With a mutex, the kernel knows who holds the lock and can boost that task’s priority. With a semaphore, the kernel has no such knowledge — any task can post, so there is no single owner to boost.
Priority inversion occurs when a high-priority task is blocked waiting for a resource held by a low-priority task, while medium-priority tasks preempt the low-priority task. The high-priority task is effectively delayed behind the medium-priority tasks — its execution order is inverted from its intended level. If multiple medium-priority tasks continue to preempt, the delay on the high-priority task grows without bound.
Without priority inversion protection:
+=====================================================================+| TIMELINE: Unbounded Priority Inversion (Binary Semaphore) || || Time ----------------------------------------------------------> || || HIGH ..............[blocks on sem..................takes sem|work]|| MED ............ [runs][runs][runs][runs][runs].. || LOW [takes sem].. preempted repeatedly ..[gives sem] || || HIGH is blocked for LOW's critical section + ALL MED execution || = UNBOUNDED INVERSION (MED tasks delay HIGH indefinitely) |+=====================================================================+
With a mutex (priority inheritance):
+=====================================================================+| TIMELINE: Bounded Priority Inversion (Mutex) || || Time ----------------------------------------------------------> || || HIGH ...[blocks on mutex].........[takes mutex|======work======] || MED .. blocked . || LOW [takes mutex|boosted to HIGH|gives mutex] || ^-- kernel raises LOW's priority || || HIGH waits ONLY for LOW's critical section = BOUNDED || MED cannot preempt LOW while LOW runs at HIGH's priority |+=====================================================================+
The mutex bounds the inversion to the duration of the low-priority task’s critical section. Because the kernel knows the owner (LOW), it can boost LOW above MED, preventing medium-priority tasks from extending the delay. A semaphore has no ownership, so the kernel cannot perform this boost.
| Scenario | Correct Primitive | Why |
|---|---|---|
| Protecting a shared resource (peripheral, data structure) | Mutex | Ownership + priority inheritance |
| ISR signaling a task (event occurred) | Binary Semaphore | No ownership needed; ISR can give |
| Counting available resources (buffer pool) | Counting Semaphore | Multiple tokens, no ownership |
| Task-to-task handshake (ping-pong sync) | Binary Semaphore or Task Notification | Simple signaling |
| Recursive locking (same task re-enters) | Recursive Mutex | Only mutex supports re-entrant take |
This is the most common error in RTOS code:
// WRONG: Using binary semaphore to protect a shared resourceSemaphoreHandle_t xLock = xSemaphoreCreateBinary();xSemaphoreGive(xLock); // Must give first -- binary semaphores start emptyvoid task_a(void *param) {xSemaphoreTake(xLock, portMAX_DELAY);access_shared_peripheral(); // Task A is in the critical sectionxSemaphoreGive(xLock);}void task_b(void *param) {// Task B can "unlock" Task A's lock -- no ownership check!xSemaphoreGive(xLock); // Returns pdTRUE! Semaphore count goes to 1.// Now another task can take the semaphore and enter the critical// section while Task A is still inside it -- mutual exclusion broken.}
The binary semaphore has no ownership, so any task can give it at any time. If Task B calls xSemaphoreGive(xLock) while Task A is in its critical section, the semaphore count becomes 1 (available). Any other task that calls xSemaphoreTake will succeed and enter the critical section while Task A is still inside — breaking mutual exclusion entirely. Additionally, no priority inheritance occurs, leaving the system exposed to unbounded priority inversion. The fix is to use a mutex:
// CORRECT: Mutex protects the shared resourceSemaphoreHandle_t xLock = xSemaphoreCreateMutex();void task_a(void *param) {xSemaphoreTake(xLock, portMAX_DELAY);access_shared_peripheral();xSemaphoreGive(xLock); // Only Task A can release}void task_b(void *param) {xSemaphoreGive(xLock); // Returns pdFALSE -- Task B doesn't own it}
Sometimes a function that holds a mutex calls another function that needs the same mutex — for example, a public API that wraps an internal helper. A regular (non-recursive) mutex would deadlock in this scenario because the owning task blocks waiting for itself. FreeRTOS provides recursive mutexes to handle this:
SemaphoreHandle_t xRecMutex = xSemaphoreCreateRecursiveMutex();void public_api(void) {xSemaphoreTakeRecursive(xRecMutex, portMAX_DELAY);internal_helper(); // Also takes the same mutex -- no deadlockxSemaphoreGiveRecursive(xRecMutex);}void internal_helper(void) {xSemaphoreTakeRecursive(xRecMutex, portMAX_DELAY);access_hardware();xSemaphoreGiveRecursive(xRecMutex);}
The recursive mutex tracks the nesting count internally. The owning task can take it multiple times without blocking; the mutex is only released to other tasks once it has been given the same number of times it was taken. Like standard mutexes, recursive mutexes support priority inheritance in FreeRTOS. However, recursive locking increases critical section complexity and makes deadlock analysis harder — prefer restructuring code to avoid recursive locking when possible.
The mutex vs semaphore decision comes down to one question: does the primitive need to have an owner?
If you are protecting a shared resource and need priority inheritance to bound priority inversion, use a mutex. If you are signaling between tasks or ISRs, or counting resources, use a semaphore. Never use a binary semaphore as a substitute for a mutex — the lack of ownership breaks mutual exclusion and eliminates priority inheritance, opening the door to unbounded priority inversion and subtle concurrency bugs.
In FreeRTOS specifically: xSemaphoreCreateMutex() for mutual exclusion, xSemaphoreCreateBinary() for ISR-to-task signaling, and xSemaphoreCreateCounting() for resource pools. Choose the right tool, and your RTOS design will be simpler, safer, and more predictable.
xSemaphoreCreateMutex, xSemaphoreCreateBinary, priority inheritance, and the ownership model that distinguishes mutexes from semaphores.Quick Links
Legal Stuff





