Hello! Welcome to the next lesson in our exploration of FreeRTOS on the ESP32.
In the last lesson, we mastered FreeRTOS queues, which are perfect for safely passing copies of data from one task to another. This is a powerful pattern for decoupling tasks. However, we ended with a question: what happens when multiple tasks need to use a single, shared resource that cannot be easily copied, such as a hardware peripheral like the I2C bus, the SPI bus, or even the serial port for printing debug messages?
If one task begins a multi-step operation on a peripheral and is then preempted by another task that also tries to use it, the result can be corrupted data, a peripheral left in an invalid state, or a full system crash. This is a classic concurrency problem known as a race condition.
This lesson is dedicated to solving that problem. You will learn how to protect shared resources from race conditions using one of the most fundamental synchronization tools in an RTOS: the mutex.

1. The Problem: Understanding Race Conditions
Before we can fix the problem, we must understand it. A race condition occurs when the behavior of a system depends on the unpredictable sequence or timing of events, like how the RTOS scheduler interleaves tasks. When multiple tasks try to read, modify, and write to a shared resource, the final state of that resource can become incorrect if the operations are not atomic (indivisible).
Let's watch a video that perfectly illustrates this issue. It starts with a simple program where two tasks increment the same global variable, a classic recipe for a race condition.
Introduction to RTOS Part 6 - Mutex | Digi-Key Electronics
This video from Digi-Key demonstrates both the theory and practice of race conditions. You'll see how easily things can go wrong when multiple tasks access a shared variable without protection.
Watch the video from the beginning until 05:24. Focus on: The diagram explaining how two tasks incrementing a variable can lead to an incorrect final value (00:02:18 - 00:04:07). The practical demonstration on an ESP32, where the output counter behaves erratically, sometimes repeating values instead of incrementing correctly (00:04:07 - 00:05:24).
As you saw, even a seemingly simple operation like shared_variable++ is not atomic at the machine-code level. It involves three steps: read from memory, increment the value in a CPU register, and write back to memory. If a context switch happens between these steps, another task can read the old value, leading to corrupted data.
The segment of code that accesses the shared resource (e.g., the global variable in the video) is known as a critical section. To prevent race conditions, we must ensure that only one task can execute its critical section at any given time. This principle is called mutual exclusion.
2. The Solution: Mutexes for Mutual Exclusion
A Mutex (short for Mutual Exclusion) is a synchronization primitive designed specifically to enforce this rule. It acts as a lock or a token. Before entering a critical section, a task must acquire (or "take") the mutex. Once it has the mutex, it is guaranteed exclusive access to the shared resource. Any other task that tries to take the same mutex will be blocked by the RTOS (without consuming CPU time) until the first task releases (or "gives back") the mutex.
The video you just watched has a great analogy for this.
Introduction to RTOS Part 6 - Mutex | Digi-Key Electronics
Let's continue with the Digi-Key video to see a simple analogy and a step-by-step diagram of how a mutex solves the race condition.
Watch the segment from 06:53 to 09:32. Pay attention to the 'restroom key' analogy and the diagram showing how Task B is forced to wait when Task A holds the mutex.
This take-use-give pattern is the fundamental way we use mutexes to protect shared resources.
3. Using Mutexes in FreeRTOS
Now, let's translate this concept into FreeRTOS code. The API is straightforward and consists of three main functions:
- Create:
xSemaphoreCreateMutex()- This function creates a mutex and returns a handle of type
SemaphoreHandle_t. You should store this handle in a global variable accessible to all tasks that need to share the resource.
- This function creates a mutex and returns a handle of type
- Take:
xSemaphoreTake(SemaphoreHandle_t xSemaphore, TickType_t xTicksToWait)- This attempts to acquire the mutex.
xSemaphore: The handle of the mutex you want to take.xTicksToWait: The maximum time the task should block while waiting for the mutex. UsingportMAX_DELAYwill cause the task to wait indefinitely.- It returns
pdTRUEif the mutex was successfully taken, andpdFALSEif the timeout expired.
- Give:
xSemaphoreGive(SemaphoreHandle_t xSemaphore)- This releases the mutex, making it available for another waiting task.
Let's see how these are used to fix the race condition from the first video.
Introduction to RTOS Part 6 - Mutex | Digi-Key Electronics
The final part of the Digi-Key video shows the complete FreeRTOS code to correctly implement the mutex and protect the shared variable.
Watch from 09:32 to 11:24. Observe how the critical section is wrapped by xSemaphoreTake() and xSemaphoreGive(). Note the final, corrected output on the serial monitor.
The basic structure is always:
// Outside the task, typically in app_main()
mutexHandle = xSemaphoreCreateMutex();
// Inside the task
void sharedResourceTask(void *parameters) {
for (;;) {
// Attempt to take the mutex
if (xSemaphoreTake(mutexHandle, portMAX_DELAY) == pdTRUE) {
// --- CRITICAL SECTION START ---
// Safely access the shared resource here
// e.g., Serial.println("This is a protected message");
// --- CRITICAL SECTION END ---
// Release the mutex for other tasks
xSemaphoreGive(mutexHandle);
}
vTaskDelay(pdMS_TO_TICKS(10)); // Yield to other tasks
}
}
Test your understanding!
Imagine you have two tasks, Task_I2C_TempSensor and Task_I2C_Display, that both need to communicate with different devices on the same I2C bus. Where in the code for each task would you place xSemaphoreTake() and xSemaphoreGive() to prevent them from corrupting the I2C communication?
Show answer
You would create a single mutex, say i2c_bus_mutex. In both tasks, you would call xSemaphoreTake(i2c_bus_mutex, ...) immediately before the block of code that begins I2C communication (e.g., Wire.beginTransmission()). You would then call xSemaphoreGive(i2c_bus_mutex) immediately after the I2C transaction is completely finished (e.g., after Wire.endTransmission() and any Wire.requestFrom()). This ensures that one task's entire multi-step transaction completes without being interrupted by the other.
4. Mutex vs. Binary Semaphore: Priority Inheritance
At this point, you might be thinking, "This sounds a lot like a binary semaphore, which we learned about two lessons ago. Why not just use one of those?"
This is an excellent question. While a binary semaphore can be used to achieve mutual exclusion, a mutex has a crucial, built-in feature that makes it superior for protecting shared resources: Priority Inheritance.
This feature solves a dangerous problem called Priority Inversion. To understand this, let's dive into an article that explains it clearly.
ESP32 FreeRTOS Inter Task Communication Tutorial
This article from Controllerstech.com gives a fantastic explanation of priority inversion and how mutexes solve it automatically.
Read the following parts of the article: Start at the heading 'Mutexes in ESP32 FreeRTOS'. Pay attention to the table comparing Semaphores and Mutexes. Continue to 'Priority Inheritance in FreeRTOS'. Study the diagrams and explanation of what priority inversion is. Read how Priority Inheritance solves the problem. Finally, look at the 'Example — Shared Resource Protection' code and its output. See how the LowPriorityTask's priority is temporarily boosted to 3 when it holds the mutex that the HighPriorityTask needs. Skip the section on Event Groups.
To summarize this critical concept:
- Priority Inversion: A high-priority task gets stuck waiting for a low-priority task, but a medium-priority task (that doesn't need the resource) keeps preempting the low-priority task, preventing it from releasing the resource. The high-priority task is effectively blocked by a medium-priority one.
- Priority Inheritance: When a high-priority task blocks on a mutex held by a low-priority task, the RTOS temporarily boosts the low-priority task's priority to that of the high-priority task. This ensures the low-priority task runs without interruption from any medium-priority tasks, finishes its critical section quickly, and releases the mutex.
Because of this automatic protection against priority inversion, you should always prefer mutexes over binary semaphores for guarding shared resources. Use semaphores for signaling and synchronization.
| Feature | Binary Semaphore | Mutex |
|---|---|---|
| Purpose | Signaling & Synchronization | Guarding Shared Resources |
| Ownership | No ownership | Owned by the task that takes it |
| Priority Inheritance | Not supported | Supported |
5. A Better C++ Approach: RAII and Lock Guards
The xSemaphoreTake() / xSemaphoreGive() pattern is functional, but it has a weakness common in C-style APIs. What happens if your critical section has multiple exit points?
void processData() {
if (xSemaphoreTake(myMutex, portMAX_DELAY) == pdTRUE) {
if (!readSensor()) {
xSemaphoreGive(myMutex); // Don't forget to give it back!
return; // Exit point 1
}
if (!processSensorData()) {
xSemaphoreGive(myMutex); // And here too!
return; // Exit point 2
}
updateDisplay();
xSemaphoreGive(myMutex); // And here!
}
}
Forgetting to call xSemaphoreGive() on any single path results in a deadlock—the mutex is never released, and any other task waiting for it will be blocked forever.
With your background and goal of mastering C++, we can use a powerful C++ idiom to make this much safer and cleaner: RAII (Resource Acquisition Is Initialization).
The idea is simple: we create a small wrapper class. Its constructor takes the mutex, and its destructor gives the mutex back.
This blog post on 'C++ On Embedded Systems' discusses the power of RAII for managing resources like mutexes. It provides a perfect, real-world example of this pattern.
Read the section titled 'RAII'. Focus on the C++ pseudo-code example for a MutexLock class and see how it simplifies the calling function by automatically unlocking the mutex.
Let's implement a simple version of this LockGuard class ourselves.
// A simple RAII Lock Guard for a FreeRTOS Mutex
class LockGuard {
public:
// Constructor: Takes the mutex
LockGuard(SemaphoreHandle_t mutex) : _mutex(mutex) {
xSemaphoreTake(_mutex, portMAX_DELAY);
}
// Destructor: Gives the mutex back
~LockGuard() {
xSemaphoreGive(_mutex);
}
private:
SemaphoreHandle_t _mutex;
};
Now, look how our previous example is simplified. The C++ compiler guarantees that the destructor (~LockGuard()) is called whenever the lock object goes out of scope, no matter how the function exits.
// BEFORE: Manual locking
void processData_manual() {
if (xSemaphoreTake(myMutex, portMAX_DELAY) == pdTRUE) {
// ... critical section with multiple returns ...
// ... must call xSemaphoreGive() at every exit point ...
}
}
// AFTER: Using RAII Lock Guard
void processData_RAII() {
LockGuard lock(myMutex); // Mutex is taken here
// --- CRITICAL SECTION ---
if (!readSensor()) {
return; // Destructor called automatically, mutex released!
}
if (!processSensorData()) {
return; // Destructor called automatically, mutex released!
}
updateDisplay();
} // Destructor called automatically here too, mutex released!
This RAII pattern is far more robust and is a hallmark of professional C++ embedded development. It eliminates a whole class of bugs related to unreleased resources.
Conclusion
In this lesson, you've tackled one of the most fundamental challenges in multitasking programming: protecting shared resources. By understanding and using mutexes, you can build complex, multi-task applications that operate reliably without data corruption or unpredictable behavior.
Key Takeaways:
- Race conditions occur when multiple tasks access a shared resource without synchronization, leading to unpredictable outcomes.
- Code that accesses a shared resource is called a critical section.
- A mutex provides mutual exclusion, ensuring only one task can be in its critical section at a time using a
take/givemechanism. - Mutexes solve the problem of priority inversion through built-in priority inheritance, making them safer than binary semaphores for resource protection.
- Using the C++ RAII pattern with a "lock guard" class provides a robust, exception-safe way to manage mutexes, automatically releasing the lock when the guard goes out of scope.
In our next lesson, we will explore Task Notifications, a lightweight and highly efficient FreeRTOS feature for sending simple signals or values directly from one task (or an ISR) to another, often with less overhead than queues or semaphores.