FreeRTOS and the Pi Pico: interrupts, semaphores and notifications

One of the reasons why an embedded application developer might choose to build their code on top of a real-time operating system like FreeRTOS is to emphasise the event-driven nature of the application. For “events” read data coming in on a serial link or from an I²C peripheral, or a signal to a GPIO from a sensor that a certain threshold has been exceeded. These events are typically announced by interrupting whatever job the host microcontroller is engaged upon, so interrupts are what I’ve chosen to examine next in my exploration of FreeRTOS on the Raspberry Pi RP2040 chip.

So what is an interrupt? Skip ahead a couple of paragraphs if you know this, but when a voltage is applied to certain microprocessor pins — or removed, depending on configuration — the chip halts what it’s doing, preserves its current state in RAM, and begins executing code at a pre-set location in memory. When this subroutine, formally called an Interrupt Service Routine (ISR), reaches its end, the CPU reloads the stored state, restoring it to exactly how it was the moment before the interrupt occurred. The interrupt is usually referred to as an Interrupt Request (IRQ). ARM chips have what’s called a “Link Register” which is set when the CPU jumps to a subroutine and is used to tell it where in memory to return to when the subroutine completes.

IRQs can be triggered by external sources like a signal on one of the CPU’s IRQ pins or in software. Some IRQs can be “masked“ so that they are ignored by the processor — others can’t be and are described as Non-Maskable Interrupts (NMIs). Others still store and restore only a part of the CPU’s state so that there’s less time spent writing and reading from RAM, which is not an instantaneous process. These are called Fast IRQs, or FIRQs.

Modern CPUs like the ARM Cortex-M0+ in the RP2040 can be configured for many different IRQs, all of which are managed by a dedicated unit, the Nested Vectored Interrupt Controller (NVIC). “Nested” because when you have more that one possible interrupt, IRQs can interrupt already in-play IRQs, and the NVIC has to know how to managed these situations. This is done by giving interrupts priority settings so that higher priority IRQs trump lower priority ones.

Figuring out how to apply all this to a non-trivial application is one of the reasons why embedded development is not easy.

Interrogating interrupts

To play around with interrupts and figure out how to apply them, I took the code I wrote to try FreeRTOS’ scheduling and extended it be interrupt driven. The scheduling example reads local temperature data from an MCP9808 sensor. This peripheral also has an alert pin which is pulled to low (0v) when pre-programmed upper, lower or critical temperature limits are crossed. This pin is therefore a perfect source for IRQs.

Here’s a revision of the scheduling circuit with the MCP9808 ALRT pin hooked up to GPIO 16 and a second LED connected for visual IRQ-has-occurred feedback. I’ve omitted voltage lowering resistors for the LEDs, but there is one resistor, 22kOhm, in the circuit: a pull-up to keep GPIO 15 high (3V3) and ALRT pins high until the MCP9808 pulls the latter to GND as its alert signal.

The upgrade interrupt-exploration circuit

Now to the code. Here’s the ISR, triggered when GPIO 16 goes low:

void gpio_isr(uint gpio, uint32_t events) {
  // Clear the URQ source
  enable_irq(false);
  // The value to add to the queue
  static bool state = 1;
  // Signal the alert clearance task
  xQueueSendToBackFromISR(irq_queue, &state, 0);
}

It’s very short, as ISR code should be. First it stops the RP2040 from responding to any more interrupts of this kind, otherwise it will fire continually and the application will freeze. Next, it drops a bool value into a FreeRTOS queue of the kind used in the scheduling example. I’ll talk more about queues and other cross-tasks signalling methods in a moment.

Note the use of the “fromISR” version of the FreeRTOS call that posts to the queue. FreeRTOS provides ISR versions of many such functions — these versions are safe to be called from inside your interrupt code.

Here’s the code to enable (or disable) the interrupt:

void enable_irq(bool state) {
    gpio_set_irq_enabled_with_callback(ALERT_SENSE_PIN,
                                       GPIO_IRQ_LEVEL_LOW,
                                       state,
                                       &gpio_isr);
}

This uses a standard Pico SDK function to register an interrupt on a given pin, specified in the first parameter. The second parameter indicates what pin state will trigger the interrupt: here it’s that the pin has to be low. Parameter three is a Boolean: true to enable the interrupt, or false to disable it. Lastly we supply a pointer to the ISR we’ll call when an IRQ occurs.

What task reads the irq_queue? This new one:

void task_sensor_alrt(void* unused_arg) {
  uint8_t passed_value = 0;
  while (true) {
    // Wait for event: is a message pending in the IRQ queue?
    if (xQueueReceive(irq_queue, &passed_value, portMAX_DELAY) == pdPASS) {
      if (passed_value_buffer == 1) {
        log_debug("IRQ detected");
        gpio_put(ALERT_LED_PIN, true);
        // Set and start a timer to clear the alert
        set_alert_timer();
      }
    }
  }
}

It’s loaded in the usual way, in main():

BaseType_t status_task_alrt = xTaskCreate(task_sensor_alrt, 
                                          "ALERT_TASK", 
                                          128, 
                                          NULL, 
                                          1, 
                                          &handle_task_alrt);

The task checks for an enqueued value. If there is one it, it creates and starts a FreeRTOS timer which, when it fires, calls code that re-enables the IRQ. Whether there’s a queue item or not, FreeRTOS pre-empts the task at the end of each pass through the task’s while() loop just like the other tasks already do.

The timer-firing code also clears the alert on the MCP9808. The sensor’s driver code needs to be modified too, to set the upper, lower and critical limits on start up. My code does this, but at first set only the upper and lower limits. It’s not clearly documented, but the MCP9808 will not issue alerts unless the critical temperature is set too, even if you don’t care what it is. It took me a while to spot that, but once the critical value is set, alerts will come through.

Keep a cup of hot water handy you can gently rest on top of the sensor to raise the temperature quickly above the code-set upper limit of 25°C.

The timer code fires after ten seconds and, if the temperature is back below the threshold, resets the MCP9808 alert, clears the indicator LED and re-enables the interrupt.

void timer_fired_callback(TimerHandle_t timer) {
  log_debug("Timer fired");
  if (read_temp < (double)TEMP_UPPER_LIMIT_C) {
    gpio_put(ALERT_LED_PIN, false);
    alert_timer = NULL;
    sensor.clear_alert(true);
    enable_irq(true);
  } else {
    // Start the timer again
    set_alert_timer();
  }
}

Once again, all the code is the my RP2040-FreeRTOS repo, in the App-IRQs directory. The project is set up to build it alongside the other examples and demos. Please try it out.

Task-to-task signalling

I first used a FreeRTOS queue to signal the interrupt to the relevant task, but that’s overkill. All I care about is being told when an IRQ has occurred. I don’t need to be told explicitly that no event has taken place, and there’s certainly no data associated with either state to pass from task to task. A simple flag will do.

FreeRTOS has a mechanism for this: the binary semaphore. One is already set up in main():

semaphore_irq = xSemaphoreCreateBinary();

This call returns a handle for the semaphore, and we can tweak the ISR to use it in place of the queue:

void gpio_isr(uint gpio, uint32_t events) {
  // Clear the URQ source
  enable_irq(false);
  // Signal the alert clearance task
  static BaseType_t higher_priority_task_woken = pdFALSE;
  xSemaphoreGiveFromISR(semaphore_irq, &higher_priority_task_woken);
  // Exit to context switch if necessary
  portYIELD_FROM_ISR(higher_priority_task_woken);
}

I’ve also added the macro portYIELD_FROM_ISR(), which is how you notify FreeRTOS that the ISR is done. Its single parameter indicates whether FreeRTOS needs to make a context switch on exit. Usually you might this with the value written into higher_priority_task_woken by xSemaphoreGiveFromISR(), but that’s when you have tasks with different priorities. All my tasks are the same priority, but it’s good to get into the habit of using FreeRTOS calls correctly.

How does the alert handler task need to change? Here’s an updated version:

void task_sensor_alrt(void* unused_arg) {
  while (true) {
    // Wait for event: is there a semaphore?
    if (xSemaphoreTake(semaphore_irq, portMAX_DELAY) == pdPASS) {
      log_debug("IRQ detected");
      gpio_put(ALERT_LED_PIN, true);
      // Set and start a timer to clear the alert
      set_alert_timer();
    }
  }
}

No need to maintain or check variable to determine if an IRQ occurred, reading the semaphore is sufficient.

Task notifications

FreeRTOS provides another, faster alternative to queues and semaphores: direct task notifications. Here’s how the two functions change to use tasks notifications instead:

void gpio_isr(uint gpio, uint32_t events) {
  // Clear the URQ source
  enable_irq(false);
  // Signal the alert clearance task
  static BaseType_t higher_priority_task_woken = pdFALSE;
  vTaskNotifyGiveFromISR(handle_task_alrt, &higher_priority_task_woken);
  
  // Exit to context switch if necessary
  portYIELD_FROM_ISR(higher_priority_task_woken);
}

and:

void task_sensor_alrt(void* unused_arg) {
  while (true) {
    // Block until a notification arrives
    ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
    log_debug("IRQ detected");
    gpio_put(ALERT_LED_PIN, true);
    // Set and start a timer to clear the alert
    set_alert_timer();
  }
}

This time FreeRTOS just pings the target task, referenced by its handle, directly rather than via a ‘third-party’, whether that’s a queue or a semaphore. Just as a task can block while it awaits a semaphore, so it can block on a task notification.

More FreeRTOS on RP2040