Race Conditions

Resource management in embedded applications is critical to ensuring system stability and responsiveness. Designers must carefully manage peripheral devices such as sensors and communication interfaces while also maintaining accurate state information that drives the system’s control logic. Embedded applications typically consist of a collection of tasks, each responsible for handling specific resources. However, these tasks often need to share data or communicate changes in system state with one another. A straightforward approach to inter-task communication involves using global variables, where shared data is paired with a signaling mechanism—commonly a Boolean flag—to indicate when the data is ready to be consumed.

 

Consider a task that samples an ADC pin every 10 milliseconds until 20 samples have been collected. Once the 20th sample is ready, the task sets a global Boolean flag to true, signaling that the data is ready for other tasks to use.

Other tasks in the system monitor this flag. When they detect that the flag is true, they read the data, reset the flag to false, and wait for the next batch of samples.

Using traditional global variables as a method of transferring data between tasks is tempting because of its simplicity, but it has several major drawbacks. The first is that the tasks waiting for the data to become available must periodically poll the global variables to see if the data is ready. Waking up a task to poll for data that is not ready wastes CPU cycles that could be used by other tasks with actual work to perform.

The second, and more critical drawback, is that when multiple tasks try to access the same global variables, a situation called a race condition can occur. A race condition occurs when two or more tasks access shared data and try to change it at the same time. The tasks are essentially racing to access or modify the data, and whoever gets there first can modify the result before the other task has finished examining the data.

Here is an example to illustrate this point.

 

One of the important things to note is that modifying the counter variable requires 3 assembly instructions to complete (read from memory, modify the value, write to memory). If both tasks have the same priority, Task1 might read counter as 0 and increment it to 1. Before it writes the result back, the scheduler could switch to Task2, which also reads counter as 0, increments it to 2, and writes it back. When Task1 resumes, it writes its value (1) back to memory, overwriting the value set by Task2. The result is incorrect—Task2’s update is lost.

To fix this problem, we need to ensure that the modification of the counter variable happens atomically. An atomic operation is a sequence of assembly instructions that occur without interruption.

Critical Sections of Code 

If multiple tasks access the same global variable, they must ensure that any modifications to the variable occur within a critical section—a portion of code that must execute atomically, without interruption. In FreeRTOS, this is typically achieved using the taskENTER_CRITICAL() and taskEXIT_CRITICAL() API calls. These functions disable and re-enable interrupts. Since preemptive context switching in FreeRTOS only occurs during interrupts, a task that calls taskENTER_CRITICAL() is guaranteed to remain in the Running state until it calls taskEXIT_CRITICAL(), ensuring the critical section is executed without interference from other tasks or interrupts. 

One major drawback of using critical sections in a FreeRTOS task is that interrupts are globally disabled while the critical section is executing. This can degrade system responsiveness, especially in applications that depend on timely interrupt handling for tasks like sensor sampling, communication, or control loops.

Additionally, critical sections are not scheduler-aware, meaning the FreeRTOS scheduler cannot preempt or switch tasks during their execution—even if a higher-priority task becomes ready. To minimize these risks, developers should keep critical sections as short and efficient as possible.

To support less disruptive task-level synchronization, FreeRTOS offers several thread-safe primitive types that perform operations atomically, making them well-suited for coordinating tasks and preventing race conditions. These primitives include semaphores, event groups, queues, and task notifications—each designed for inter-task communication and synchronization.