Skip to Content
Course content

181: Interrupt Handling in Embedded Rust

Click on the "Edit" button in the top corner of the screen to edit your slide content.

Coming from a C background, the first instinct when dealing with interrupts is to throw everything into a global variable. You create a static mut variable for your peripheral, wrap your access in an unsafe block, and call it a day. It feels efficient, and for a while, it actually works. But in Rust, this is a recipe for disaster.

Take a look at this snippet. This is exactly how I see people try to implement a simple button-triggered LED toggle on a Cortex-M microcontroller:

// ⚠️ BUGGY CODE: The "C-Style" approach in Rust
static mut LED_PIN: Option<GpioPin> = None;

#[interrupt]
fn EXTI0() {
    // I'm just trying to toggle the LED when the button is pressed!
    unsafe {
        if let Some(ref mut pin) = LED_PIN {
            pin.toggle();
        }
    }
}

#[entry]
fn main() -> ! {
    let peripherals = Peripherals::take().unwrap();
    let pin = peripherals.GPIOA.pa5.into_push_pull_output();
    
    unsafe {
        LED_PIN = Some(pin);
    }
    
    loop {
        // Main loop does nothing, just waits for interrupt
        asm::wfi();
    }
}

The Danger of the Static Mut Trap

At first glance, this looks fine. But we have a massive problem: static mut is fundamentally unsafe because it provides no guarantee against data races. Even on a single-core microcontroller, an interrupt is essentially a second thread of execution that can preempt your main code at any millisecond.

If your main loop were doing something more complex with that LED_PIN—perhaps changing its configuration—and the EXTI0 interrupt fired right in the middle of that operation, you'd have two different parts of your program trying to mutate the same memory location simultaneously. This is Undefined Behavior (UB). The compiler might optimize the code in ways that assume LED_PIN never changes unexpectedly, leading to heisenbugs that only appear in --release mode.

Safe State Sharing with Mutex and RefCell

To do this the "Rust way," we need to ensure that the data is accessed exclusively. In embedded Rust, we use a combination of cortex_m::interrupt::Mutex and core::cell::RefCell. I know, it looks like a lot of nesting, but there's a very specific reason for each layer.

  • Mutex: Ensures that the data can only be accessed inside a "critical section" (where interrupts are disabled), preventing the preemption race condition I mentioned.
  • RefCell: Since the Mutex gives us a shared reference, we need RefCell to provide "interior mutability," allowing us to mutate the pin even when it's stored in a static.
  • Option: Since we can't initialize the hardware peripheral at compile-time, we start with None and move the peripheral into it during main.

Here is the corrected, thread-safe version of that logic:

use core::cell::RefCell;
use cortex_m::interrupt::{free, Mutex};

// We wrap the pin in a Mutex and a RefCell
static G_LED: Mutex<RefCell<Option<GpioPin>>> = Mutex::new(RefCell::new(None));

#[interrupt]
fn EXTI0() {
    // 'free' creates a critical section, providing the 'cs' token
    free(|cs| {
        if let Some(ref mut pin) = G_LED.borrow(cs).borrow_mut().as_mut() {
            pin.toggle();
        }
    });
}

#[entry]
fn main() -> ! {
    let peripherals = Peripherals::take().unwrap();
    let pin = peripherals.GPIOA.pa5.into_push_pull_output();
    
    // Safely move the pin into the global mutex
    free(|cs| {
        G_LED.borrow(cs).replace(Some(pin));
    });
    
    loop {
        asm::wfi();
    }
}

Why This Fix Actually Works

The magic here is the cs (Critical Section) token. You'll notice that G_LED.borrow(cs) requires that token to work. The only way to get a cs token is to call interrupt::free(|cs| { ... }).

Inside interrupt::free, the processor physically disables interrupts. This means the EXTI0 handler cannot fire while the main loop is accessing the G_LED. Because the interrupt is blocked, the "race" is impossible. Once the closure finishes, interrupts are re-enabled. We've traded a tiny bit of latency for a mathematical guarantee that our memory is safe.

I'll be honest: the syntax borrow(cs).borrow_mut() feels clunky at first. But it's a conscious design choice. It forces you to acknowledge exactly when you are entering a critical section and when you are mutating shared state. In the embedded world, that visibility is what keeps your firmware from crashing at 3:00 AM in a production environment.




📋 Practical Task

Exercise: Implementing a Heartbeat Toggle via Timer Interrupt

Your goal is to create a "heartbeat" LED that toggles every time a timer interrupt fires. Instead of toggling the LED in the main loop, you must move the LED peripheral into a global Mutex<RefCell<Option<T>>>>.

Requirements:

  • Initialize a system timer (e.g., TIM2) to trigger an interrupt at a regular interval (e.g., 500ms).
  • Declare a global static G_LED using the Mutex and RefCell pattern.
  • In the timer's interrupt handler, use interrupt::free to access the LED and toggle its state.
  • In main, initialize the LED and the timer, move the LED into the global storage, and then enter a low-power sleep loop using wfi.

Verification: Your code should compile without any unsafe blocks in the interrupt handler or the main loop's peripheral assignment.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.