How to Handle Interrupts in Embedded Rust

Handle interrupts in embedded Rust by defining functions with target-specific extern ABIs like "avr-interrupt" or "riscv-interrupt-m" and marking them with #[no_mangle].

When the hardware yells

You are building a motor controller. The main loop calculates PID values and updates PWM duty cycles. Every millisecond, a timer peripheral overflows and triggers an interrupt. If your main loop is busy crunching numbers, it cannot check the timer flag in time. The hardware needs to pause your program, run a tiny snippet of code to reset the timer, and resume execution without missing a beat.

In C, you write an interrupt service routine, mark it volatile, and hope you didn't corrupt a global variable that the main loop was using. In Rust, you get to keep the safety guarantees. The borrow checker treats the interrupt handler like a parallel thread. If the handler and the main code try to touch the same mutable data, the compiler rejects the program. You have to prove that access is exclusive, or you have to lock the data down. Interrupts are the hardware's way of demanding attention. Your job is to handle that demand without breaking Rust's rules.

The interrupt contract

Think of your main program as a chef chopping vegetables. The interrupt is the fire alarm. When the alarm rings, the chef stops chopping, runs to the alarm, checks it, resets it, and returns to the knife. The danger happens if the chef is holding the knife when the alarm rings. If the alarm handler also needs the knife, and the chef doesn't put it down, you have a collision.

Rust's ownership model forbids two mutable references to the same data. An interrupt introduces a second mutable reference by default. The hardware can trigger the handler at any time, even while the main code holds a reference to a peripheral register. The compiler cannot see the hardware trigger. It only sees code. You have to bridge that gap. You must use abstractions that tell the compiler "this data is shared, but only one place can touch it at a time."

The mechanism relies on three ingredients. The function needs a specific ABI so the CPU saves the right registers. The symbol name must match the vector table entry so the linker knows where to jump. The function body must be unsafe because the compiler cannot verify the hardware contract. You provide the proof in a // SAFETY: comment.

Minimal handler

Here is the raw structure of an interrupt handler. This example uses AVR syntax, but the pattern applies to any architecture. The #[no_mangle] attribute prevents the compiler from changing the function name. The linker looks for a symbol with this exact name in the vector table. The extern attribute sets the ABI. The "avr-interrupt" ABI tells the compiler to save all registers and handle the return sequence correctly.

/// Handles the timer overflow interrupt.
#[no_mangle]
unsafe extern "avr-interrupt" fn timer_overflow_handler() {
    // SAFETY: This function is the entry point for the hardware interrupt.
    // 1. The symbol name matches the device's interrupt vector table entry.
    // 2. The ABI matches the AVR interrupt calling convention.
    // 3. No shared mutable state is accessed; this handler is stateless.

    // Toggle the LED pin to indicate the interrupt fired.
    // Direct register access is unsafe because the compiler cannot track side effects.
    // In production code, wrap this in a safe HAL abstraction.
    unsafe {
        // AVR-specific register write.
        // This syntax is hardware-specific and will not compile on other targets.
        core::ptr::write_volatile(0x05 as *mut u8, 0xFF);
    }

    // Clear the interrupt flag.
    // If you skip this, the interrupt fires again immediately on return.
    unsafe {
        core::ptr::write_volatile(0x05 as *mut u8, 0x00);
    }
}

The unsafe block is a contract. You are telling the compiler "I know what I'm doing." If you access a global variable here without protection, the compiler won't stop you. The hardware won't stop you. You will get data races and undefined behavior. Treat the // SAFETY: comment as a proof. If you cannot write the invariants, you do not have a safe handler.

What the CPU does

When the hardware triggers an interrupt, the CPU performs a context switch. It saves the current program counter and status register onto the stack. It jumps to the address in the vector table. Your handler runs. When the handler returns, the CPU restores the saved context and resumes the main code.

The ABI determines how much state the CPU saves. The "avr-interrupt" ABI saves all general-purpose registers. The "C" ABI saves only the callee-saved registers. If you use the wrong ABI, the handler might clobber registers that the main code expects to be preserved. This leads to subtle corruption that is nearly impossible to debug. Always use the interrupt-specific ABI provided by your target architecture.

The #[no_mangle] attribute is mandatory. Rust mangles function names by default to support overloading and namespaces. The linker for embedded targets expects unmangled symbols that match the datasheet. If you omit #[no_mangle], the linker cannot find the handler, and the interrupt vector points to garbage. The system will crash or hang.

Shared state and the borrow checker

Real interrupt handlers rarely do nothing. They read sensors, update counters, or signal the main loop. This requires shared state. A global variable is the natural place to store this data. Rust forbids static mut variables because they allow data races. You must use synchronization primitives.

The standard pattern in embedded Rust is Mutex<RefCell<T>>. The Mutex provides exclusive access. The RefCell provides interior mutability. This combination allows you to share data between the main code and the interrupt handler while keeping the borrow checker happy.

The Mutex in embedded crates like cortex-m is not a runtime lock. It does not spin or block. It is a compile-time wrapper that enforces exclusive access through a critical section token. You must wrap access in interrupt::free to get the token. This disables interrupts briefly, ensuring no other handler can touch the data.

use core::cell::RefCell;
use cortex_m::interrupt::Mutex;
use cortex_m_rt::interrupt;

// Shared state protected by a Mutex.
// The Mutex here is for interrupt synchronization, not thread synchronization.
static COUNTER: Mutex<RefCell<u32>> = Mutex::new(RefCell::new(0));

/// Handles the SysTick interrupt.
#[interrupt]
fn SysTick() {
    // SAFETY: We access shared state via the Mutex.
    // 1. The Mutex ensures exclusive access during the interrupt.
    // 2. RefCell provides interior mutability.
    // 3. We restore the interrupt state after the critical section.
    unsafe {
        cortex_m::interrupt::free(|cs| {
            // Increment the counter inside the critical section.
            // No other interrupt can touch this data right now.
            let counter = COUNTER.borrow(cs);
            let mut value = counter.borrow_mut();
            *value += 1;
        });
    }
}

The #[interrupt] macro is a convenience provided by runtime crates. It handles #[no_mangle], the ABI, and vector table registration. It also checks that the interrupt name matches the device definition. If you misspell the name, the compiler rejects the code. This is safer than raw extern functions.

The interrupt::free closure takes a CriticalSection token. You pass this token to borrow and borrow_mut. The token proves that interrupts are disabled. The borrow checker uses this to guarantee exclusive access. If you try to borrow the data without the token, the compiler rejects the code with E0277 (trait bound not satisfied).

Pitfalls and errors

Interrupt handlers introduce unique failure modes. The most common is reentrancy. If the interrupt fires while the handler is already running, you have a reentrant call. If the handler holds a RefCell borrow, and the reentrant call tries to borrow again, the program panics. RefCell detects double borrows at runtime and aborts. This is a feature. It prevents data races by crashing the system rather than corrupting memory. You can disable this check with borrow_mut_unchecked, but that removes the safety net. Do not do it.

Another pitfall is blocking. Interrupt handlers must return quickly. If you call a function that blocks or sleeps, you hold off all other interrupts. The system becomes unresponsive. Keep handlers short. Read the data, clear the flag, and return. If the work is heavy, signal a flag and let the main loop do the processing.

If you try to access a mutable global from the main loop while the interrupt holds a reference, the compiler will reject the code. You will see E0502 (cannot borrow as mutable because it is also borrowed as immutable). This error means the borrow checker detected a potential overlap. You must restructure the code to use the Mutex pattern. If you hide the overlap behind unsafe, you bypass the check and invite undefined behavior.

Convention aside: The community convention for // SAFETY: comments in interrupt handlers is to list three items. First, the symbol name matches the vector table. Second, the ABI matches the architecture. Third, no shared mutable state is accessed, or access is proven safe via Mutex. If you cannot write these three points, your handler is not safe.

Decision matrix

Use the #[interrupt] macro from your runtime crate when you want type-safe interrupt names and automatic registration in the vector table. Use raw extern ABI functions with #[no_mangle] when you are writing a bare-metal runtime or need precise control over the symbol name and ABI for a custom architecture. Use interrupt::free to create a critical section when you need to read or write shared state without blocking other interrupts unnecessarily. Use Mutex<RefCell<T>> for shared state when multiple interrupts or the main code need to access the same data, ensuring the compiler enforces exclusive access. Reach for a flag-based signaling pattern when the interrupt work is heavy; set a boolean in the handler and process the data in the main loop.

Keep handlers short. Signal, don't compute.

Where to go next