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
vTaskDelayor 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.
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:
- The Task: A dedicated handler task attempts to "take" the semaphore using
xSemaphoreTake(), with its block time set toportMAX_DELAY. Since the semaphore is initially unavailable, the task enters theBlockedstate and consumes zero CPU cycles. - The Interrupt: A hardware event triggers an interrupt. The CPU suspends the current task and executes the ISR.
- 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.
- The Scheduler: Giving the semaphore unblocks the handler task, moving it to the
Readystate. If it is now the highest-priority task in theReadystate, the scheduler will run it as soon as the ISR completes.

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
pxHigherPriorityTaskWokentopdTRUE. - 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);
}
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
xSemaphoreGiveFromISRincrements the semaphore's internal count. - Each call to
xSemaphoreTakeby 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.

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:- Declare a
BaseType_tvariable,xHigherPriorityTaskWoken, and initialize it topdFALSE. - After handling the hardware, call
xSemaphoreGiveFromISR, passing it the semaphore handle and the address ofxHigherPriorityTaskWoken. - At the very end of the ISR, call
portYIELD_FROM_ISR()and pass itxHigherPriorityTaskWoken. 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."
- Declare a
- Your Answer: "You must use an interrupt-safe API, like
-
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
...FromISRversions of FreeRTOS APIs inside an ISR. - Correctly implement the
xHigherPriorityTaskWokenandportYIELD_FROM_ISRpattern 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.