The Game Boy Advance (GBA) is a beloved handheld console with a unique hardware architecture that demands careful management of interrupts for tasks like rendering, timing, and input handling. In Rust, implementing interrupt handlers can be tricky due to the language’s emphasis on safety, lifetimes, and ownership. Traditional approaches often rely on global mutable state to share data with handlers — but this makes the code harder to reason about and avoiding multiple mutable references becomes a mess.

In this post, I’ll walk through an interrupt implementation I’ve developed for the GBA in Rust. The key strengths are:

  • Stack-allocated interrupt handler closures: Handlers can capture variables from the caller’s stack without global state, ensuring safety and ease of use, while relying on the borrow checker to avoid multiple mutable references.
  • Nested and temporary IRQ handlers: Support for overriding handlers temporarily (e.g., within a scope) and enabling nested interrupts, even of the same type, with options to prevent self-recursion.

This system is designed to be ergonomic for high-level use while maintaining low overhead for performance-critical GBA code. I’ll start by describing the user-facing interface, then dive into the internals of how it works.

All while keeping a very clean, high-level interface.

The User Interface — How You Actually Use It

use craboy::interrupt::Interrupt;

// Simple example: count frames
let mut frame_count = 0u32;
let mut cx = None;

Interrupt::VBlank.enable(&mut cx, || {
    frame_count = frame_count.wrapping_add(1);
    // update palette, sprites, etc.
});

// handler stays active until cx drops...

More advanced control with chaining:

let mut cx = None;

Interrupt::VBlank
    .enable_nested()           // ← allow other interrupts during this handler
    .ack_bios()                // ← also clear REG_IFBIOS (needed for IntrWait)
    .enable(&mut cx, || {
        // This handler can now be interrupted by timers, hblank, etc.
        render_game();
    });

// This sleeps the cpu (IntrWait) until the next vblank interrupt.
// Requires ack_bios to wake the cpu.
loop {
    display::sleep_until_vblank_irq_with_ack_bios();
}

And the really powerful part — temporary overrides with nesting:

let mut outer = None;
Interrupt::VBlank.enable_nested().enable(&mut outer, || {
    {
        let mut inner = None;
        Interrupt::VBlank
            .enable_nested()
            .disable_self_nested()     // ← prevent recursion of same interrupt
            .enable(&mut inner, || {
                // ← If vblank ran for too long, handle it here
                handle_vblank_ran_for_too_long();
            });
        
        // Do your work
        handle_vblank();

        // Vblank executed for too long (2+ frames) 
        wait_until_vblank();
        wait_until_vblank();
        // ← inner handler is active until here
    }
    // ← inner dropped → original outer handler automatically restored
});
// ← outer dropped → interrupt fully disabled, previous state restored

The interface stays clean and composable while giving you powerful control over lifetime and nesting behavior.

How It Works Under the Hood (Simplified)

The entire system is built around two small global tables:

static mut IRQ_CALLBACKS:  [*mut (); 14] = [ptr::null_mut(); 14];   // pure fn() entry points for each interrupt
static mut IRQ_HANDLERS:   [*mut (); 14] = [ptr::null_mut(); 14];   // points to InteruptHandler<F> when capturing

The following code is a simplified version. The actual implementation is more complex and handles details such as disable interrupts while modifying these tables to avoid race conditions, and compile time optimizations to avoid unnecessary work at runtime. But the core idea is the same.

When you call .enable(&mut cx, closure) the following happens conceptually:

1. Enabling a handler — (Simplified)

fn enable<F: FnMut() + 'a>(
    kind: InterruptKind,
    cx: &'a mut Option<InteruptHandler<F>>,   // usually &mut None
    user_closure: F
) {
    let idx = kind.index(); // 0..13

    // 1. Remember what was there before (safe to do early)
    let old_cb  = unsafe { IRQ_CALLBACKS[idx] };
    let old_hdl = unsafe { IRQ_HANDLERS[idx] };

    // 2. First create and store the handler struct *inside* the Option
    //    → this is the only place where it lives, and its address is now stable
    *cx = Some(InteruptHandler {
        closure:      user_closure,
        old_callback: old_cb,
        old_handler:  old_hdl,
    });

    // 3. Now it's safe to take a pointer — because the value is pinned inside cx
    if size_of::<F>() > 0 {
        // Get mutable reference to the just-created struct
        let handler_ref = cx.as_mut().unwrap();  // safe: we just put Some there

        unsafe {
            IRQ_HANDLERS[idx] = handler_ref as *mut InteruptHandler<F> as *mut ();
        }
    }

    // 4. Create the kind-specific tiny wrapper function
    let wrapper: fn() = match kind {
        InterruptKind::VBlank  => vblank_wrapper::<F>,
        InterruptKind::HBlank  => hblank_wrapper::<F>,
        InterruptKind::Timer0  => timer0_wrapper::<F>,
        // ... one per kind
        _ => unreachable!(),
    };

    // 5. Install the wrapper into the callback table
    unsafe { IRQ_CALLBACKS[idx] = wrapper as *mut () };

    // 6. Configure hardware (timers, DISPSTAT, KEYCNT, etc.)
    configure_hardware_for(kind);
}

2. What happens when the interrupt actually fires

Each interrupt kind has its own tiny wrapper (placed in fast IWRAM):

#[link_section = ".iwram"]
extern "C" fn vblank_wrapper<F: FnMut()>() {
    // Get our state back (only used if closure captured variables)
    let ptr = unsafe { IRQ_HANDLERS[VBLANK_IDX] };
    let handler = unsafe { &mut *(ptr as *mut InteruptHandler<F>) };

    // RAII guard that implements the requested behavior
    let _guard = create_guard_based_on_decorators(); // ← nested / self-disable / bios ack

    // Call the user's actual code!
    (handler.closure)();
}

The _guard is a small RAII struct that does exactly one thing depending on which decorators were used:

  • .enable_nested() → clears I-bit in CPSR on entry, sets it back on exit, allowing interrupts to happen while current interrupt is being handled.
  • .disable_self_nested() → disables own bit if this kind in IE on entry, re-enables + acks on exit. This disables this interrupt while it’s being handled.
  • .ack_bios() → writes to REG_IFBIOS after the user closure. This is required to wake up cpu after an interrupt if it was sleeping.

3. Cleanup — When cx Drops

impl<F> Drop for InteruptHandler<F> {
    fn drop(&mut self) {
        let idx = self.kind_index();

        // Put everything back the way it was
        unsafe {
            IRQ_CALLBACKS[idx] = self.old_callback;
            IRQ_HANDLERS[idx] = self.old_handler;
        }

        // If there was no previous handler → fully disable this interrupt
        if self.old_callback.is_null() {
            disable_hardware_for(self.kind());
        }
    }
}

This pattern gives you safe, expressive, closure-based interrupt handling with almost zero boilerplate — while still being fast enough for real GBA games. If you’re writing Rust code for GBA, I hope this pattern makes your life easier.