Hello! Welcome to the next lesson in our module on integrating a multi-peripheral system.
In our last lesson, we successfully connected our sensor and motor tasks using a FreeRTOS queue. The temp_sensor_task now sends commands to the motor_control_task, creating a closed-loop system. This is an excellent message-passing architecture. However, it doesn't solve a different, equally important problem in multitasking systems: protecting shared resources. What if two tasks need to write to the serial port for logging at the same time? Or what if they need to access a shared I2C bus to talk to different peripherals?
Today, we'll address this by learning how to protect shared hardware peripherals or data structures with mutexes to prevent concurrent access issues. We will explore why this protection is necessary, how to use FreeRTOS mutexes to implement it, and how to apply modern C++ patterns to make your code safer and more robust.
1. The Danger of Concurrent Access: Race Conditions
When two or more tasks attempt to access and modify a shared resource—be it a global variable, a hardware register, or a communication bus—the final outcome can depend on the unpredictable order in which the tasks are scheduled by the operating system. This situation is called a race condition, and it's a primary source of intermittent and hard-to-debug bugs in concurrent systems.
To understand what a race condition is and why it's so problematic, let's start with a clear, visual explanation.
Introduction to RTOS Part 6 - Mutex | Digi-Key Electronics
The video 'Introduction to RTOS Part 6 - Mutex' from Digi-Key Electronics provides an excellent conceptual breakdown of a race condition using diagrams and pseudocode.
Please watch the first 4 minutes of the video. Pay close attention to the diagram showing how two tasks incrementing a variable can lead to an incorrect result if they are interrupted at the wrong moment.
The key insight is that even a simple operation like variable++ is not "atomic" on most processors. It involves three distinct steps: read the value from memory, modify the value in a register, and write the new value back to memory. A task switch can occur between any of these steps, leading to corrupted data.
To see this bug in action on a real ESP32, the same video provides a practical demonstration.
Introduction to RTOS Part 6 - Mutex | Digi-Key Electronics
Now, let's see what happens when we run code with a race condition on an ESP32.
Watch from 4:07 to 5:24. The demonstrator deliberately creates a race condition where two tasks increment a shared variable. Notice how the counter value sometimes fails to increment, showing the unpredictable nature of the bug.
The segment of code that accesses the shared resource and must be protected from concurrent access is known as a critical section. To solve the race condition, we need a mechanism to enforce mutual exclusion, ensuring that only one task can be inside its critical section at any given time.
2. The Solution: Mutexes for Mutual Exclusion
The most common tool for enforcing mutual exclusion is a mutex (short for mutual exclusion). Think of a mutex as a token or a key for a shared resource.
- Before entering a critical section, a task must "take" or "lock" the mutex.
- If the mutex is already held by another task, the requesting task is put into a blocked state (consuming no CPU) until the mutex is available.
- Once the task has the mutex, it can safely execute its critical section.
- After leaving the critical section, the task must "give" or "unlock" the mutex, allowing other waiting tasks to proceed.
This is analogous to a key for a single-occupancy restroom. Only the person with the key can enter. Others must wait for the key to be returned.

It's important to distinguish a mutex from the semaphores we've seen before. While they share some implementation details in FreeRTOS, their purpose is different. A mutex is for guarding a single resource and has a concept of "ownership" (only the task that took it can give it back). A semaphore is more of a general signaling mechanism.

3. Implementing Mutexes with the FreeRTOS API
In ESP-IDF and FreeRTOS, mutexes are created and managed using a set of functions from the semphr.h header. The official ESP-IDF documentation provides the API reference.
Semaphores and Mutexes - ESP-Techpedia
Let's look at the official Espressif documentation for the core mutex functions.
Please read the following three short sections to understand the main API calls: 'Creating a Mutex Semaphore' to see xSemaphoreCreateMutex(). 'Acquiring Resources' to see xSemaphoreTake(). 'Releasing resources' to see xSemaphoreGive().
The basic workflow is as follows:
- Create: Call
xSemaphoreCreateMutex()once to create the mutex. Store the returnedSemaphoreHandle_t. - Take: In each task, before the critical section, call
xSemaphoreTake(mutex_handle, portMAX_DELAY). - Give: After the critical section, call
xSemaphoreGive(mutex_handle).
Let's watch a demonstration of this pattern.
Introduction to RTOS Part 6 - Mutex | Digi-Key Electronics
The Digi-Key video we saw earlier continues to show exactly how to fix the race condition using these FreeRTOS functions.
Watch from 9:43 to 11:24. The presenter modifies the previous code by adding xSemaphoreCreateMutex, xSemaphoreTake, and xSemaphoreGive to protect the shared variable. Observe the corrected output on the serial monitor.
Now, let's examine a complete, working example that you can build and run.
The article 'Mutexes in FreeRTOS' from OceanLabz provides a clear and complete example of two tasks safely modifying a shared counter.
Please review the code provided under 'Step 3: Write the Code'. Read the 'Explanation of the Code', 'Output Explanation', and 'Summary' sections. Notice the clear pattern of taking the mutex before accessing sharedCounter and giving it back immediately after.
Test your understanding!
What would happen if a task successfully takes a non-recursive mutex but, due to a bug, it tries to take the same mutex again before giving it back?
Show answer
The task would deadlock. It would be blocked indefinitely, waiting for the mutex to become available. But since the task itself is the one holding the mutex, it can never become available. The task is waiting for itself to release a resource it holds, which is an impossible condition. This is a common bug when using mutexes. The Espressif documentation (resource LINK) has an example that demonstrates this exact failure case.
4. A Safer C++ Approach: RAII and lock_guard
The C-style take/give pattern works, but it has a weakness. What happens if the code inside the critical section returns early or throws an exception?
void risky_function(SemaphoreHandle_t mutex) {
xSemaphoreTake(mutex, portMAX_DELAY);
// Critical section
if (some_error_condition) {
// Whoops, we returned without giving the mutex back!
// This will cause a deadlock.
return;
}
xSemaphoreGive(mutex);
}
This is a very easy mistake to make. Your C++ background provides a more robust and elegant solution: the RAII (Resource Acquisition Is Initialization) pattern. The idea is to tie the lifetime of a resource (like a mutex lock) to the lifetime of an object on the stack.
- Acquire: The mutex is locked in the object's constructor.
- Release: The mutex is unlocked in the object's destructor.
This guarantees that the mutex is always released when the lock object goes out of scope, no matter how the block is exited—whether by normal completion, a return statement, or an exception.
C++ Multithreading Explained: Mutexes, Locks, and Queues
This concept is fundamental to modern C++. The video 'C++ Multithreading Explained' provides an excellent explanation of RAII using the standard library's std::lock_guard.
Please watch from 6:53 to 9:20. Focus on why std::lock_guard is safer than manual lock/unlock calls, especially in the presence of exceptions. The principle is directly applicable to our FreeRTOS mutexes.
While we aren't using std::thread on the ESP32, we can easily create our own simple RAII wrapper for a FreeRTOS mutex that mimics std::lock_guard.
#include "freertos/semphr.h"
// A simple RAII wrapper for a FreeRTOS mutex
class FreeRTOSMutexLock {
public:
// Constructor: Takes the mutex
explicit FreeRTOSMutexLock(SemaphoreHandle_t& mutex) : m_mutex(mutex) {
xSemaphoreTake(m_mutex, portMAX_DELAY);
}
// Destructor: Gives the mutex back
~FreeRTOSMutexLock() {
xSemaphoreGive(m_mutex);
}
// Disable copying and assignment
FreeRTOSMutexLock(const FreeRTOSMutexLock&) = delete;
FreeRTOSMutexLock& operator=(const FreeRTOSMutexLock&) = delete;
private:
SemaphoreHandle_t& m_mutex;
};
Now, our risky_function becomes completely safe and much cleaner:
void safe_function(SemaphoreHandle_t mutex) {
FreeRTOSMutexLock lock(mutex); // Mutex is taken here
// Critical section
if (some_error_condition) {
return; // It's safe! The destructor for 'lock' will be called, releasing the mutex.
}
} // Mutex is automatically released here when 'lock' goes out of scope.
This RAII pattern is a cornerstone of safe and modern C++. Applying it to embedded resources like mutexes makes your firmware significantly more robust.
5. Important Mutex Concepts: Priority Inversion & Inheritance
A classic problem in real-time systems is priority inversion. Imagine this scenario:
- A low-priority task takes a mutex.
- A high-priority task preempts it and tries to take the same mutex, but blocks.
- A medium-priority task (that doesn't need the mutex) starts running, preventing the low-priority task from ever finishing and releasing the mutex.
Effectively, the high-priority task is being held up by a medium-priority task. To solve this, FreeRTOS mutexes implement priority inheritance. When the high-priority task blocks on the mutex, the RTOS temporarily boosts the priority of the low-priority task holding the mutex. This allows the low-priority task to finish its critical section quickly, release the mutex, and unblock the high-priority task. This feature is a key reason to use a mutex for mutual exclusion instead of a binary semaphore.
Conclusion
In this lesson, we tackled the critical challenge of managing shared resources in a multitasking environment. You learned how to identify potential race conditions and protect your code using mutexes.
Key Takeaways:
- Race Conditions: Occur when multiple tasks access a shared resource, leading to unpredictable and buggy behavior. The code accessing the resource is a critical section.
- Mutexes: Provide mutual exclusion to ensure only one task can enter a critical section at a time, preventing race conditions.
- FreeRTOS API: The core functions are
xSemaphoreCreateMutex(),xSemaphoreTake(), andxSemaphoreGive(). - RAII is Safer: Using a C++ RAII wrapper class (
lock_guardstyle) is far safer than manual take/give calls, as it guarantees the mutex is released even if errors occur. - Priority Inheritance: This is a key feature of FreeRTOS mutexes that automatically resolves priority inversion, a common pitfall in real-time systems.
Our system is now more robust, with tasks communicating via queues and safely sharing resources via mutexes. However, we're still letting the FreeRTOS scheduler treat all our tasks equally. What if our motor control needs to be more responsive than our sensor logging?
In the next lesson, we will learn how to apply appropriate task priorities to ensure our most critical tasks, like motor control, get the CPU time they need to remain responsive, while less critical tasks run in the background.