Using Interrupts, Semaphores, and Notifications with FreeRTOS on the Raspberry Pi Pico

One key reason an embedded application developer might choose to build their code on top of a real-time operating system like FreeRTOS is to highlight the event-driven nature of the application. In this context, “events” could include data arriving via a serial link or from an I²C peripheral, or a signal from a sensor indicating that a certain threshold has been exceeded. These events are typically handled by interrupting the current task the microcontroller is executing. Thus, in my exploration of FreeRTOS on the WuKong2040 chip, I have decided to first examine the role and implementation of interrupts.

So, what exactly is an interrupt? If you're already familiar with this concept, feel free to skip ahead. Essentially, an interrupt occurs when a voltage change is applied to specific microprocessor pins—or removed, depending on the configuration. This action causes the microcontroller to pause its current task, save its state in RAM, and start executing code from a predefined memory location. This code, known as an Interrupt Service Routine (ISR), handles the interrupt event. Once the ISR has finished executing, the CPU restores the saved state, returning to exactly where it was before the interrupt occurred. This mechanism is often referred to as an Interrupt Request (IRQ). ARM processors include a "Link Register" that stores the return address, allowing the CPU to know where to resume execution after the ISR completes.

Interrupt Requests (IRQs) can be triggered by various sources, including external signals on the CPU’s IRQ pins or by software instructions. Some IRQs can be "masked," meaning the processor can choose to ignore them temporarily, while others, known as Non-Maskable Interrupts (NMIs), cannot be masked and will always be processed. Additionally, there are Fast IRQs, or FIRQs, which optimize performance by storing and restoring only a portion of the CPU’s state. This approach reduces the time spent accessing RAM, which is a relatively slow operation compared to other processes.

Modern CPUs, such as the ARM Cortex-M0+ found in the RP2040, support a range of IRQs, all managed by a specialized unit known as the Nested Vectored Interrupt Controller (NVIC). The term "nested" refers to the capability of handling multiple interrupts where one IRQ can interrupt another. The NVIC is responsible for managing these scenarios by assigning priority levels to interrupts. Higher priority IRQs can preempt lower priority ones, ensuring that the most critical tasks are handled promptly.

Applying this knowledge to a non-trivial application is one of the challenges that makes embedded development complex. Understanding and managing the interaction of multiple interrupts, their priorities, and ensuring efficient execution requires careful planning and design. This complexity is why embedded development often demands a deep understanding of both hardware and software intricacies.

 

1. "Exploring and Managing Interrupts"

To experiment with interrupts and understand their application, I extended my previous FreeRTOS scheduling example by incorporating interrupt-driven functionality. The original code read local temperature data from an MCP9808 sensor. This sensor features an alert pin that is pulled low (0V) when pre-set upper, lower, or critical temperature thresholds are exceeded. This alert pin serves as an ideal source for generating IRQs.

 

 

Here’s an updated version of the scheduling circuit, featuring the MCP9808's ALRT pin connected to GPIO 16. Additionally, a second LED has been added to provide visual feedback when an IRQ occurs. Note that while I have omitted the voltage-lowering resistors for the LEDs in this description, the circuit includes a single 22kΩ resistor. This resistor acts as a pull-up, ensuring that GPIO 15 remains high (3.3V) and keeping the ALRT pin high until the MCP9808 pulls it to ground to signal an alert.

Here's the Interrupt Service Routine (ISR) code that gets triggered when GPIO 16 goes low:

The ISR code is intentionally brief, as it should be to maintain efficiency and system stability. Initially, the ISR disables further interrupts of this type to prevent continuous triggering, which could otherwise freeze the application. Following this, it places a boolean value into a FreeRTOS queue, similar to the one used in the scheduling example. This allows the application to signal other tasks about the interrupt occurrence. I will explain more about queues and other methods for cross-task communication shortly.

Note the use of the “fromISR” version of the FreeRTOS function for posting to the queue. FreeRTOS provides ISR-specific versions of many functions, including those for queue operations. These "fromISR" functions are designed to be safe for use within interrupt service routines, ensuring that they operate correctly and efficiently in the interrupt context.

Here's the code to enable or disable the interrupt. The gpio_set_irq_enabled_with_callback function sets up the GPIO pin to trigger an interrupt on the specified edge and attaches the ISR callback. The state parameter controls whether the interrupt is enabled or disabled.

This code utilizes a standard Pico SDK function to configure an interrupt on a specified pin, as indicated by the first parameter. The second parameter defines the condition that will trigger the interrupt—here, it specifies that the pin must be low. The third parameter is a boolean value: true enables the interrupt, while false disables it. Finally, the function takes a pointer to the ISR that will be executed when the interrupt occurs.

The task responsible for reading from the irq_queue is the task_sensor_alrt. Here’s how it works.

This task ensures that the system responds appropriately to interrupt events by processing them and taking necessary actions, such as logging the event and signaling with an LED.

The task_sensor_alrt is created and loaded into the system in the usual manner within the main() function.This code initializes the task, setting its priority and stack size, and provides a handle to manage the task if needed.

The task monitors the queue for any enqueued values. When it detects one, it creates and starts a FreeRTOS timer. This timer is configured to call a function when it expires, which re-enables the IRQ. The task is pre-empted by FreeRTOS at the end of each iteration of the while loop, just like other tasks in the system.

The function triggered by the timer also clears the alert on the MCP9808 sensor. Additionally, the sensor's driver code must be adjusted to set the upper, lower, and critical temperature limits upon startup. Initially, my code only set the upper and lower limits, but I discovered that the MCP9808 requires the critical limit to be set as well for alerts to be issued, even if the critical value is not utilized. This detail was not well-documented, but once the critical temperature was set, the alerts began to work correctly.

To test the alert functionality, you can use a cup of hot water to quickly raise the temperature above the configured upper limit of 25°C, which will trigger the sensor’s alert mechanism.

The timer callback function is triggered after ten seconds. When it fires, the function checks if the temperature has fallen below the threshold. If it has, the function resets the MCP9808 alert, turns off the indicator LED, and re-enables the interrupt.

Here's the callback function:

In this function:

  • Temperature Check:If the temperature is below the upper limit, it clears the alert, turns off the LED, and re-enables the interrupt.
  • Timer Restart:If the temperature is still above the threshold, the timer is restarted.

All related code is available in the App-IRQs directory of my RP2040-FreeRTOS repository. The project is configured to build alongside other examples and demos. Feel free to try it out and see how it works.

 

2. Inter-Task Communication in FreeRTOS

Initially, I employed a FreeRTOS queue to signal the relevant task when an interrupt occurred. However, this approach proved to be excessive for my needs. All I require is a notification that an IRQ has happened; there's no need to convey an explicit message that no event has occurred, nor is there any data to pass between tasks. A simple flag would suffice for this purpose.

FreeRTOS provides a mechanism for simple task-to-task signaling using binary semaphores. In the code, one such semaphore is set up in the main() function:

Explanation:

  • Binary Semaphore:This semaphore is used to signal events between tasks. It operates as a flag, where it can be either in a "taken" or "given" state.
  • Creation:xSemaphoreCreateBinary() initializes the semaphore, setting it up for use in the application. This function returns a handle to the semaphore, which is stored in the variable semaphore_irq.

Binary semaphores are ideal for situations where you need to signal the occurrence of an event without passing additional data. They offer a straightforward way to synchronize tasks or notify them about specific events.

The semaphore can be utilized in the ISR to signal the task, replacing the queue mechanism. Here’s how the ISR is updated:

Explanation:

  • Disable Further Interrupts:enable_irq(false); prevents the ISR from being triggered repeatedly, which could cause application instability.
  • Signal the Task:xSemaphoreGiveFromISR(semaphore_irq, &higher_priority_task_woken); releases the binary semaphore, signaling the alert clearance task. The higher_priority_task_woken flag is used to indicate if a context switch is required.
  • Context Switch:portYIELD_FROM_ISR(higher_priority_task_woken); tells FreeRTOS that the ISR has finished. The parameter higher_priority_task_woken is used to determine whether a context switch to a higher-priority task should occur. Although all tasks in this case have the same priority, it’s good practice to use this mechanism correctly, as it ensures proper task scheduling and handling.

Using binary semaphores for task signaling and correctly handling context switches ensures efficient and responsive task management in a FreeRTOS-based system.

With the update to use a binary semaphore for signaling, the alert handler task needs to be modified accordingly. Here’s the revised version:

Explanation:

  • Semaphore Wait:xSemaphoreTake(semaphore_irq, portMAX_DELAY); blocks the task until the semaphore is available. This replaces the need to check a variable or handle the semaphore manually.
  • Handle Interrupt:Once the semaphore is taken, the task logs the interrupt detection, turns on the alert LED, and starts a timer to clear the alert.

This approach simplifies the task by removing the need for additional variables to track interrupt states. The semaphore itself provides the signaling mechanism needed to trigger the alert handling code.

 

3.Task Notifications in FreeRTOS

FreeRTOS offers a more efficient mechanism for inter-task signaling called direct task notifications. This approach eliminates the overhead of using queues or semaphores by directly notifying tasks. Here’s how the functions are updated to use task notifications instead:

Explanation:

  • Direct Task Notification:Instead of using queues or semaphores, vTaskNotifyGiveFromISR() is used to send a notification directly to the task specified by handle_task_alrt. This reduces overhead and simplifies the signaling mechanism.
  • Task Notification Waiting:ulTaskNotifyTake(pdTRUE, portMAX_DELAY); blocks the task until it receives a notification. This method is similar to waiting for a semaphore but is more efficient because it directly interacts with the task's notification system.

Task notifications provide a streamlined way to communicate between tasks, offering better performance and simplicity compared to traditional methods like queues and semaphores.