Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 38

Semaphore Synchronization in RTOS

Recommended resources

Hello! Let's continue our dive into RTOS concepts.

In our last lesson, we drew a firm line between mutexes and semaphores. We established that mutexes are for resource protection, armed with the priority inheritance protocol to defeat priority inversion. Today, we'll focus on the primary, and equally important, role of semaphores: signaling.

This lesson is dedicated to the learning outcome: Implement task signaling and synchronization between tasks and ISRs using binary and counting semaphores. Mastering this is fundamental for writing efficient, responsive, and robust embedded software. This pattern of handling asynchronous hardware events is a cornerstone of RTOS-based design and a topic you can definitely expect to discuss in a technical interview.

1. The Challenge: Handling Asynchronous Events

In embedded systems, the world outside the microcontroller is asynchronous. A user presses a button, a sensor has a new reading, or a byte of data arrives on the UART port at any moment. The hardware notifies the CPU of these events using interrupts.

A naive approach might be to perform all the processing related to the event directly inside the Interrupt Service Routine (ISR). However, this is poor practice for several reasons:

  • ISRs Must Be Fast: While an ISR is executing, other interrupts (of the same or lower priority) are typically blocked. Long ISRs increase system latency and can cause other critical events to be missed.
  • Limited API Access: ISRs run in a special, non-task context. Many standard RTOS functions, especially those that can block (like vTaskDelay or waiting for a mutex), are illegal to call from an ISR. Doing so would crash the system.

The professional solution is a pattern called Deferred Interrupt Processing. The ISR does the absolute minimum work required and then "defers" the longer processing to a regular task. Semaphores are the primary tool for creating this link between the ISR and the task.

FreeRTOS-Kernel-Book/ch07.md

For a formal introduction to this concept, let's turn to the official FreeRTOS documentation. It clearly explains the 'why' behind this design pattern.

Read section '7.3 Deferred Interrupt Processing'. Focus on the reasons provided for keeping ISRs short and the diagrams that illustrate how processing is handed off from the ISR to a task.


2. Binary Semaphores: Signaling a Single Event

The simplest way to synchronize a task with an ISR is using a binary semaphore. Think of it as a simple flag.

The pattern works like this:

  1. The Task: A dedicated handler task attempts to "take" the semaphore using xSemaphoreTake(), with its block time set to portMAX_DELAY. Since the semaphore is initially unavailable, the task enters the Blocked state and consumes zero CPU cycles.
  2. The Interrupt: A hardware event triggers an interrupt. The CPU suspends the current task and executes the ISR.
  3. The ISR: The ISR performs its minimal, time-critical work (e.g., reads a value from a peripheral data register). It then "gives" the semaphore to signal the waiting task.
  4. The Scheduler: Giving the semaphore unblocks the handler task, moving it to the Ready state. If it is now the highest-priority task in the Ready state, the scheduler will run it as soon as the ISR completes.
Semaphore Operations for Task and ISR Synchronization
This diagram illustrates the core concept of signaling. An ISR can 'Give' a semaphore, which can then be 'Taken' by a thread (or task). This is the fundamental mechanism for synchronizing a task with an asynchronous hardware event.

The Critical ISR-Safe API

As we've mentioned, you cannot call blocking functions from an ISR. FreeRTOS provides special "interrupt-safe" versions of many API functions, which are identifiable by the ...FromISR suffix.

For giving a semaphore from an interrupt, you must use xSemaphoreGiveFromISR(). This function has a specific signature that is crucial to use correctly:

BaseType_t xSemaphoreGiveFromISR( SemaphoreHandle_t xSemaphore, BaseType_t *pxHigherPriorityTaskWoken );

The pxHigherPriorityTaskWoken parameter is the key to maintaining real-time responsiveness.

  • The API function uses this pointer to inform you if giving the semaphore unblocked a task that has a higher priority than the task that was currently running when the interrupt occurred.
  • If it did, the function sets the value pointed to by pxHigherPriorityTaskWoken to pdTRUE.
  • At the very end of your ISR, you must use this information to optionally request a context switch.

Here is the full, non-negotiable pattern for an ISR that gives a semaphore:

void ADC_IRQHandler(void) {
    BaseType_t xHigherPriorityTaskWoken = pdFALSE; // Must be initialized to pdFALSE.
    uint32_t adc_value;

    // 1. Perform minimal hardware work
    adc_value = ADC->DR; // Read data register
    ADC->SR &= ~ADC_SR_EOC; // Clear interrupt flag

    // Store data for the task (e.g., in a global variable)
    g_latest_adc_value = adc_value;

    // 2. Give the semaphore, passing the address of our variable.
    xSemaphoreGiveFromISR(xAdcSemaphore, &xHigherPriorityTaskWoken);

    // 3. If xHigherPriorityTaskWoken is now pdTRUE, a context switch should be
    // requested to ensure the interrupt returns to the highest priority task.
    portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}

FreeRTOS-Kernel-Book/ch07.md

The FreeRTOS documentation provides the definitive explanation for this ISR-safe API and the xHigherPriorityTaskWoken parameter.

Read section '7.2.1 The Interrupt Safe API' through '7.2.5 The portYIELD_FROM_ISR() ... Macros'. This explains why a separate API is needed. Then, read section '7.4 Binary Semaphores Used for Synchronization'. Pay close attention to 'Example 7.1' and how its ISR (Listing 7.8) correctly implements this pattern.

Introduction to RTOS Part 9 - Hardware Interrupts | Digi-Key Electronics

To see this in action, the Digi-Key video on hardware interrupts provides a great practical demonstration.

Watch the section on using a binary semaphore from an ISR, starting around 07:57. The narrator explains and shows the use of xSemaphoreGiveFromISR and the 'task woken' parameter, reinforcing the pattern we just discussed.


3. Counting Semaphores: Handling Event Bursts

Binary semaphores are excellent, but they have one significant limitation: they can't count. If an interrupt fires and gives the semaphore, and then a second interrupt fires before the handler task has had a chance to run and take the semaphore, the second xSemaphoreGiveFromISR call will fail. The semaphore is already "full". The second event is lost.

This is a common problem with high-frequency or "bursty" events, such as receiving characters from a UART.

Counting semaphores solve this problem. They work like binary semaphores but can hold a value greater than 1.

  • You create a counting semaphore with a maximum count and an initial count (usually 0 for event handling).
  • Each call to xSemaphoreGiveFromISR increments the semaphore's internal count.
  • Each call to xSemaphoreTake by the handler task decrements the count. If the count is zero, the task blocks.

This allows the ISR to queue up multiple events. The handler task can then process them one by one until the semaphore's count returns to zero.

RTOS Task Synchronization with Semaphores
This Gantt chart shows tasks interacting with a semaphore. The 'Semaphore' value at the bottom tracks its count. An `osSemaphoreRelease` (like 'give') increments the count, while an `osSemaphoreWait` (like 'take') decrements it. This visualizes how a counting semaphore can track multiple available events or resources.

Code Example: Robust UART Reception

Let's refine our deferred processing pattern for a UART receive interrupt, which is a classic use case for a counting semaphore.

// Global state
#define RX_BUFFER_SIZE 128
char g_rx_buffer[RX_BUFFER_SIZE];
volatile int g_rx_write_index = 0;
int g_rx_read_index = 0;
SemaphoreHandle_t xUartRxSemaphore;

// The handler task
void uart_processing_task(void *pvParameters) {
    for (;;) {
        // Wait for a signal that a character has arrived.
        if (xSemaphoreTake(xUartRxSemaphore, portMAX_DELAY) == pdTRUE) {
            // Process the character from the buffer
            char c = g_rx_buffer[g_rx_read_index];
            g_rx_read_index = (g_rx_read_index + 1) % RX_BUFFER_SIZE;
            
            // ... do something with character 'c' ...
        }
    }
}

// The ISR
void UART1_IRQHandler(void) {
    BaseType_t xHigherPriorityTaskWoken = pdFALSE;

    if (UART1->STATUS & UART_RX_NOT_EMPTY) {
        // Read character and place in circular buffer
        g_rx_buffer[g_rx_write_index] = UART1->DATA;
        g_rx_write_index = (g_rx_write_index + 1) % RX_BUFFER_SIZE;

        // Give the counting semaphore. Even if the task hasn't run yet,
        // this will increment the count, latching the event.
        xSemaphoreGiveFromISR(xUartRxSemaphore, &xHigherPriorityTaskWoken);
    }
    
    portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}

void main(void) {
    // ... system init ...

    // Create a counting semaphore. Max count = buffer size, initial count = 0.
    xUartRxSemaphore = xSemaphoreCreateCounting(RX_BUFFER_SIZE, 0);

    // Create the handler task
    xTaskCreate(uart_processing_task, ...);

    // ... start scheduler ...
}

This design is robust. Even if a burst of 10 characters arrives before uart_processing_task gets to run, the ISR will simply increment the semaphore's count to 10. The task will then wake up and call xSemaphoreTake 10 times, processing all the buffered characters without losing any.

FreeRTOS Tutorial 4 || Counting Semaphore || STM32 || NO CMSIS

The following video provides a practical look at creating and using a counting semaphore, including giving it from an ISR.

Watch the segment starting around 11:12, titled 'releasing the semaphore from ISR'. It demonstrates the exact xSemaphoreGiveFromISR and portYIELD_FROM_ISR pattern we need, but for a counting semaphore.

Test your understanding!

You are designing the software for a motor controller. A hardware quadrature encoder provides pulses on two channels (A and B) to track the motor's rotation. The interrupts from these channels can occur at a very high frequency. You need a task to calculate the motor's speed and position based on these pulses.

Would you use a binary or a counting semaphore to signal the task from the encoder's ISRs? Why? What is a potential risk with your chosen approach?

Show answer

A counting semaphore is the correct choice.

Why: Encoder interrupts can happen in rapid succession, far faster than a task can be scheduled, run, and block again. If you used a binary semaphore, you would almost certainly miss pulses. A counting semaphore allows the ISRs to quickly increment the count for each pulse, effectively latching every single event. The processing task can then deplete the count, knowing exactly how many pulses it needs to process.

Potential Risk: The main risk is a semaphore overflow. The counting semaphore is created with a maximum count value. If the rate of incoming pulses is so high that the processing task cannot keep up, the semaphore's count could eventually hit its maximum limit. Any subsequent calls to xSemaphoreGiveFromISR would fail, and you would begin to lose pulses, defeating the purpose. This indicates that either the task is not processing events fast enough, or the CPU is simply not powerful enough to handle the maximum possible event rate.


4. Interview Preparation: Key Questions

Given your experience, an interviewer will likely probe your understanding of the design trade-offs.

  • Question: "Describe the 'deferred interrupt processing' pattern and explain why it's important in real-time systems."

    • Your Answer: "It's a design pattern where an ISR performs only the most time-critical, minimal work—like reading a hardware register and clearing an interrupt flag—and then signals a lower-priority task to handle the bulk of the processing. It's crucial because it keeps ISRs short and fast, minimizing the time that other interrupts are disabled and thus improving the overall responsiveness and determinism of the system. The signaling is typically done using an RTOS primitive like a semaphore."
  • Question: "You're signaling a task from an ISR using a semaphore. What specific FreeRTOS function must you use, and what is the full sequence of operations you need to perform in the ISR to ensure correctness?"

    • Your Answer: "You must use an interrupt-safe API, like xSemaphoreGiveFromISR. The correct sequence is:
      1. Declare a BaseType_t variable, xHigherPriorityTaskWoken, and initialize it to pdFALSE.
      2. After handling the hardware, call xSemaphoreGiveFromISR, passing it the semaphore handle and the address of xHigherPriorityTaskWoken.
      3. At the very end of the ISR, call portYIELD_FROM_ISR() and pass it xHigherPriorityTaskWoken. This ensures that if a higher-priority task was unblocked, the scheduler runs it immediately upon exiting the interrupt, preserving the system's real-time behavior."
  • Question: "When would you choose a counting semaphore over a binary semaphore for ISR-to-task signaling?"

    • Your Answer: "I would use a counting semaphore when there's a possibility of multiple events occurring in a burst, faster than the handler task can be scheduled and run. A binary semaphore can only signal the occurrence of one event; subsequent signals before the first is processed are lost. A counting semaphore solves this by incrementing its count for each event, effectively queuing the events and preventing any from being missed, assuming the count doesn't exceed its configured maximum."

Conclusion

You have now mastered the essential RTOS pattern for handling asynchronous events. This combination of a minimal ISR and a deferred-processing task, synchronized by a semaphore, is a powerful and efficient technique you will use constantly in driver development.

Key Takeaways:

  • Use the Deferred Interrupt Processing pattern to keep ISRs short and the system responsive.
  • Binary semaphores are ideal for signaling single, infrequent events from an ISR to a task.
  • Counting semaphores are robust against event bursts and should be used when multiple events might occur before the handler task can run.
  • Always use the ...FromISR versions of FreeRTOS APIs inside an ISR.
  • Correctly implement the xHigherPriorityTaskWoken and portYIELD_FROM_ISR pattern to ensure proper scheduling.

Next Lesson Preview:
While semaphores are perfect for signaling that an event happened, they don't carry any data. Our current examples used a risky global variable to pass the data from the ISR to the task. In our next lesson, we will learn a much safer and more powerful mechanism: message queues. We will see how to implement inter-task communication by passing data directly and safely through a queue, often eliminating the need for semaphores and global variables entirely.