Skip to main content

Welcome to a Grasp lesson

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

Lesson 51

Event-Driven Task Synchronization with Binary Semaphores

Recommended resources

Hello! Welcome back.

In our last lesson, we mastered FreeRTOS queues, a powerful tool for safely passing data between tasks. You built a system where a producer task could send structured data to a consumer task efficiently using dynamic memory allocation.

Now, we'll address a different, but equally important, challenge: what if you don't need to send data, but simply need to notify a task that an event has occurred? This is a problem of synchronization.

This lesson directly addresses this by focusing on the learning outcome: Use binary semaphores to synchronize task execution with events (e.g., an interrupt). We will implement a classic and essential embedded systems pattern: using a hardware interrupt to wake up a task that is waiting to do work.

1. The Pattern: Deferred Interrupt Processing

An Interrupt Service Routine (ISR) must execute as quickly as possible. Any significant delay inside an ISR can cripple the real-time performance of your system and lead to missed deadlines or other interrupts. A common professional practice to keep ISRs short is called deferred interrupt processing.

The idea is simple:

  1. The ISR does the absolute minimum work required: it captures the event, clears the interrupt flag, and quickly signals a task that the event occurred.
  2. A dedicated FreeRTOS task, which has been patiently waiting, "wakes up" upon receiving the signal.
  3. This task then performs the longer, more complex processing associated with the event.

This pattern keeps your ISRs short and fast while leveraging the RTOS scheduler to handle the bulk of the work. The perfect signaling mechanism for this is a binary semaphore.

FreeRTOS Binary Semaphore to synchronize a task with an interrupt

To understand this concept better, let's start with a clear definition from Microcontrollerslab. This article explains the role of binary semaphores and the motivation for deferred interrupt processing.

Please read the section titled "FreeRTOS Binary Semaphore to synchronize a task with an interrupt" and the sub-section "Synchronizing task with Interrupt using Binary Semaphore". Focus on why we want to defer processing from an ISR to a task.

A binary semaphore is conceptually like a queue that can only hold one item. It has two states: available ("given") or unavailable ("taken"). It is the ideal tool for event signaling.


2. Visualizing the Synchronization Flow

Understanding the sequence of events and task state changes is key. The timing diagram below illustrates the entire process perfectly.

RTOS Task and Interrupt Synchronization with Semaphore Timing Diagram
This timing diagram shows how an interrupt unblocks a handler task using a semaphore. The numbers correspond to the steps explained below.

Let's walk through the sequence shown in the diagram:

  1. At the beginning, only the lowest-priority Idle task is running. The Handler Task (our worker task) is in the Blocked state, waiting to "take" a semaphore.
  2. At time t1, an event occurs (e.g., a button is pressed), triggering a hardware Interrupt.
  3. The CPU immediately suspends the Idle task and executes the ISR. The ISR is very short; its only job is to "give" the binary semaphore.
  4. Giving the semaphore immediately moves the Handler Task from the Blocked state to the Ready state, because the event it was waiting for has now occurred.
  5. When the ISR finishes, the FreeRTOS scheduler runs. It sees that the Handler Task, now in the Ready state, has a higher priority than the Idle task. It therefore performs a context switch.
  6. The Handler Task now enters the Running state. It executes its code (e.g., toggles an LED, processes sensor data).
  7. Once finished, the Handler Task loops back and attempts to "take" the semaphore again. Since the semaphore was already taken and hasn't been given again, the task re-enters the Blocked state, waiting for the next interrupt. The Idle task resumes running.

3. The FromISR API and Context Switching

When working with ISRs and FreeRTOS, there is one critical rule: you must only use FreeRTOS API functions that end in ...FromISR. These are special versions designed to be safely called from an interrupt context. They are typically non-blocking and have deterministic execution times.

For our purpose, we will use xSemaphoreGiveFromISR().

BaseType_t xSemaphoreGiveFromISR(
    SemaphoreHandle_t xSemaphore,
    BaseType_t *pxHigherPriorityTaskWoken
);

The second parameter, pxHigherPriorityTaskWoken, is crucial for optimizing system responsiveness. Here’s how it works:

  • You create a local variable, BaseType_t xHigherPriorityTaskWoken = pdFALSE;, and pass its address to the function.
  • If giving the semaphore unblocks a task, AND that task has a higher priority than the task that was currently running when the interrupt occurred, xSemaphoreGiveFromISR() will set xHigherPriorityTaskWoken to pdTRUE.
  • At the end of your ISR, you check this value. If it's pdTRUE, you request a context switch. This ensures the newly-ready high-priority task runs immediately after the ISR, rather than waiting for the next scheduler tick.

Let's watch a video that explains this critical mechanism.

FreeRTOS Interrupt Processing using Semaphore

This video from Preet explains the importance of the FromISR API and provides an excellent breakdown of the pxHigherPriorityTaskWoken parameter.

Please watch these two key parts: Using xSemaphoreGiveFromISR (02:20 - 03:04): The presenter explains why you must use the ...FromISR version of the API to avoid system crashes. Forcing a Context Switch (04:11 - 07:07): This is a fantastic explanation of the pxHigherPriorityTaskWoken parameter and how vPortYieldFromISR() (or portEND_SWITCHING_ISR in STM32 HAL) can be used to make your system more responsive.


4. Hands-On: Button Press Interrupt Synchronization

Let's build a system that implements this pattern. We will use the user button on your development board. When you press it, an external interrupt (EXTI) will fire. The ISR will give a semaphore, which will unblock a task that then toggles the onboard LED.

This exercise is based on the excellent guide from Embedded-DBL, which we'll use for reference.

Using a binary semaphore to synchronize a task with an interrupt

This article provides a complete, practical example for the STM32 platform, including the crucial interrupt priority configuration. We will follow this guide to build our project.

Read through the section "Using a binary semaphore to synchronize a task with an interrupt". Pay close attention to the STM32CubeMX configuration for the interrupt priority and the code for both the task and the ISR callback.

Step 1: CubeMX Configuration

  1. Open your project's .ioc file.
  2. Find the GPIO pin connected to your board's user button. On many Nucleo and Discovery boards, this is PC13. It might be different on your Blue Pill kit; a common choice is PA0. Let's assume PA0 for this example. Configure it as GPIO_EXTI0.
  3. In the GPIO settings for PA0, ensure it is set to trigger on a Rising edge or Falling edge (depending on your button's wiring, a falling edge is common for buttons pulled high). Enable the internal pull-up or pull-down resistor as appropriate.
  4. Go to System Core > NVIC. Enable the EXTI line0 interrupt.
  5. This is the most important step. Set the Preemption Priority for this interrupt. Any ISR that calls an ...FromISR function must have a priority numerically equal to or greater than configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY. The default value for this is 5. Therefore, set the interrupt priority to 5 or higher (e.g., 6, 7...). A lower number means a higher urgency, so a priority of 4 would be invalid here.
  6. Generate the code.

Step 2: Code Implementation

Now, let's write the code in STM32CubeIDE.

  1. Declare a global semaphore handle at the top of main.c:

    /* USER CODE BEGIN PV */
    SemaphoreHandle_t xButtonSemaphore;
    /* USER CODE END PV */
    
  2. Create the binary semaphore in main() before the scheduler starts:

    /* USER CODE BEGIN 2 */
    xButtonSemaphore = xSemaphoreCreateBinary();
    
    if(xButtonSemaphore == NULL)
    {
        // Not enough heap memory to create the semaphore.
        // Handle error, perhaps by blinking an LED in a loop.
        while(1);
    }
    /* USER CODE END 2 */
    
  3. Create the handler task function:

    /* USER CODE BEGIN 4 */
    void vButtonHandlerTask(void *pvParameters)
    {
        while(1)
        {
            // Block indefinitely, waiting for the semaphore to be given by the ISR.
            if(xSemaphoreTake(xButtonSemaphore, portMAX_DELAY) == pdTRUE)
            {
                // We successfully took the semaphore, meaning the button was pressed.
                // Now we do the "deferred processing".
                printf("Button pressed! Toggling LED.\r\n");
                HAL_GPIO_TogglePin(LD2_GPIO_Port, LD2_Pin); // Use your LED's definition
            }
        }
    }
    /* USER CODE END 4 */
    
  4. Add the task to the scheduler in main():

    /* USER CODE BEGIN RTOS_TASKS */
    xTaskCreate(vButtonHandlerTask, "ButtonTask", 128, NULL, 2, NULL);
    /* USER CODE END RTOS_TASKS */
    
  5. Implement the ISR callback. STM32 HAL provides a weak-linked callback function for all EXTI interrupts. We just need to define it.

    /* USER CODE BEGIN 4 */
    // ... vButtonHandlerTask from above ...
    
    void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
    {
        // Check if the interrupt came from our button's pin
        if(GPIO_Pin == B1_Pin) // Use your button's pin definition (e.g., GPIO_PIN_0)
        {
            BaseType_t xHigherPriorityTaskWoken = pdFALSE;
    
            // 'Give' the semaphore from the ISR.
            xSemaphoreGiveFromISR(xButtonSemaphore, &xHigherPriorityTaskWoken);
    
            // If xHigherPriorityTaskWoken is now set to pdTRUE, we should yield.
            portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
        }
    }
    /* USER CODE END 4 */
    

    Note: portYIELD_FROM_ISR() is a macro that handles the context switch request. On some STM32 HAL versions, you might see portEND_SWITCHING_ISR(xHigherPriorityTaskWoken) used, which serves the same purpose.

  6. Compile, flash, and run the code. Open your serial terminal. The program should be idle. Now, press the user button. You should see the message "Button pressed!" appear in the terminal and the LED should toggle its state.

You have successfully synchronized a task to a hardware interrupt!


Conclusion

You have now added a second fundamental RTOS tool to your skill set. While queues are for transferring data, semaphores are for signaling and synchronization. This distinction is crucial for clean and correct RTOS-based design.

Key Takeaways:

  • Binary semaphores are lightweight signaling mechanisms, ideal for synchronizing a task with an event.
  • The deferred interrupt processing pattern (ISR signals, task works) is a professional standard that keeps ISRs short and your system responsive.
  • You must always use the ...FromISR version of an API function when calling it from within an ISR.
  • The pxHigherPriorityTaskWoken parameter allows you to trigger an immediate context switch to a newly-unblocked high-priority task, minimizing latency.
  • Forgetting to set the correct interrupt priority in an RTOS environment is a common and difficult-to-debug error.

In the next lesson, we will address our final core concurrency problem. We've passed data and we've synchronized events. But what if two tasks need to use the same, single resource (like a non-reentrant peripheral driver or a shared data structure) without interfering with each other? For this, we will learn to use mutexes to ensure exclusive access.