Library Design Recommendation: Interrupt Handler Pattern

Background: How ARM Cortex-M Interrupts Work

On ARM Cortex-M processors, interrupt handling is driven by a vector table - an array of function pointers located at a fixed memory address (typically 0x00000000). When an interrupt fires, the hardware:

  1. Looks up the corresponding entry in the vector table
  2. Jumps directly to that address
  3. Executes the Interrupt Service Routine (ISR)

The vector table is typically defined by the PAC (Peripheral Access Crate) using a static array with extern "C" function declarations:

// From a typical PAC (Peripheral Access Crate)
#[link_section = ".vector_table.interrupts"]
#[no_mangle]
pub static __INTERRUPTS: [Vector; N] = [
    Vector { handler: uart0 },
    Vector { handler: uart1 },
    Vector { handler: timer0 },
    Vector { handler: spi },
    // ... N interrupt vectors
];

extern "C" {
    fn uart0();
    fn uart1();
    fn timer0();
    fn spi();
    // ... declared but NOT defined
}

The PAC declares these symbols but does not define them. Someone must provide the actual function implementations.

The Problem: Symbol Ownership Conflict

Scenario 1: Standalone Driver Application

When building a standalone application with just the driver library:

[PAC] --declares--> uart0()
[Driver] --defines---> #[no_mangle] pub extern "C" fn uart0() { ... }

This works perfectly. The driver provides the ISR, the linker resolves the symbol, interrupts work.

Scenario 2: Driver + Kernel/RTOS

When integrating with a kernel that manages its own vector table:

[PAC] --declares--> uart0()
[Driver] --defines---> #[no_mangle] pub extern "C" fn uart0() { ... }  ❌ CONFLICT
[Kernel Entry] --defines--> #[no_mangle] pub extern "C" fn uart0() { ... }  ❌ CONFLICT

Result: error: symbol 'uart0' multiply defined

Why Kernels Need ISR Ownership

A kernel/RTOS needs to own ISR entry points for several reasons:

  1. Context Saving: The kernel may need to save thread context before handling the interrupt
  2. Priority Management: The kernel scheduler may need to run after the ISR
  3. Statistics: Track interrupt counts, latency, etc.
  4. Safety Boundaries: Validate that the interrupt is expected before dispatching

The kernel's ISR wrapper typically looks like:

#[no_mangle]
pub extern "C" fn uart0() {
    // Kernel housekeeping
    kernel::enter_interrupt();
    
    // Dispatch to actual handler
    my_driver::uart::uart0_irq_handler();
    
    // Kernel housekeeping  
    kernel::exit_interrupt();
}

The Fundamental Issue

A #[no_mangle] symbol is a global, unique identifier in the final binary.

When a library unconditionally exports #[no_mangle] symbols, it:

  • Claims exclusive ownership of those symbol names
  • Prevents any other code from defining them
  • Forces a specific interrupt handling strategy
  • Makes the library unusable in any context that needs ISR control

Solution: Separate Logic from Export

1. Provide callable handler functions (always available)

/// UART0 interrupt handler - call this from your ISR
#[inline]
pub fn uart0_irq_handler() {
    // Actual interrupt handling logic
}

2. Conditionally export ISR symbols via feature flag

#[cfg(feature = "isr-handlers")]
#[no_mangle]
pub extern "C" fn uart0() {
    uart0_irq_handler();
}

3. Default the feature to off

[features]
default = []
isr-handlers = []  # Export ISR symbols - disable for kernel integration

Benefits

Standalone UseKernel Integration
Enable isr-handlers featureLeave feature disabled
Library provides vector table entriesKernel provides ISR stubs
Zero integration workKernel calls handler functions

Integration Example

Kernel entry point calls library handlers:

#[no_mangle]
pub extern "C" fn uart0() {
    my_driver::uart::uart0_irq_handler();
}

#[no_mangle]
pub extern "C" fn timer0() {
    my_driver::timer::timer0_irq_handler();
}

Guidelines

  1. Never export #[no_mangle] symbols unconditionally in libraries intended for embedded use
  2. Provide public handler functions that encapsulate interrupt logic
  3. Use feature flags to control symbol export
  4. Document the pattern so integrators know how to wire up handlers
  5. Apply consistently to all ISR exports (timers, peripherals, DMA, etc.)

Example Implementation

A driver crate implementing this pattern for UART handlers:

// src/uart.rs

/// UART0 interrupt handler - call this from your ISR
#[inline]
pub fn uart0_irq_handler() {
    dispatch_irq(0);
}

/// UART1 interrupt handler - call this from your ISR
#[inline]
pub fn uart1_irq_handler() {
    dispatch_irq(1);
}

// ISR exports - only when isr-handlers feature is enabled
#[cfg(feature = "isr-handlers")]
#[no_mangle]
pub extern "C" fn uart0() {
    uart0_irq_handler();
}

#[cfg(feature = "isr-handlers")]
#[no_mangle]
pub extern "C" fn uart1() {
    uart1_irq_handler();
}

Cargo.toml:

[features]
default = []
isr-handlers = []  # Export ISR handlers with #[no_mangle] - disable for kernel integration

Kernel integration (entry.rs):

#[unsafe(no_mangle)]
pub extern "C" fn uart0() {
    my_driver::uart::uart0_irq_handler();
}

#[unsafe(no_mangle)]
pub extern "C" fn uart1() {
    my_driver::uart::uart1_irq_handler();
}

Why Not Use Weak Symbols?

An alternative approach is to use weak linkage:

#[linkage = "weak"]
#[no_mangle]
pub extern "C" fn uart0() {
    default_handler();
}

This allows the symbol to be overridden. However, this approach has drawbacks:

  1. Implicit behavior: It's not obvious that the symbol can/should be overridden
  2. Toolchain support: Weak linkage behavior varies across linkers
  3. No compile-time feedback: You won't know if override worked until runtime
  4. Still pollutes symbol namespace: The library still exports the symbol

The feature flag approach is explicit, portable, and provides clear compile-time control.

Repository Structure: Separate Test Crates

Test code that defines ISRs should live in a separate crate, not inside the library. This keeps the library clean and avoids leaking test infrastructure to consumers.

Visual Overview

┌─────────────────────────────────────────────────────────────────────────────┐
│                         aspeed-rust (workspace)                             │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│  ┌─────────────────────────────┐     ┌─────────────────────────────────┐   │
│  │   aspeed-ddk (lib crate)    │     │  aspeed-ddk-tests (bin crate)   │   │
│  │                             │     │                                 │   │
│  │  src/                       │     │  src/                           │   │
│  │  ├── lib.rs                 │     │  ├── main.rs  ◄── single entry  │   │
│  │  ├── uart.rs                │     │  │   │                          │   │
│  │  │   └── uart0_irq_handler()│◄────┼───────┤ calls test_uart()       │   │
│  │  ├── timer.rs               │     │  │   │ calls test_timer()       │   │
│  │  │   └── timer0_irq_handler()◄────┼───────┤ calls test_i2c()        │   │
│  │  ├── i3c/                   │     │  │   │ calls test_i3c()         │   │
│  │  │   └── i3c_irq_handler()  │◄────┼───────┤ ...                     │   │
│  │  └── i2c/                   │     │  │   └── loop {}                │   │
│  │      └── i2c0_irq_handler() │◄────┼───────┤                         │   │
│  │                             │     │  │                              │   │
│  │  (NO test code here)        │     │  ├── tests/                     │   │
│  │  (NO #[no_mangle] ISRs)     │     │  │   ├── mod.rs                 │   │
│  │                             │     │  │   ├── uart_test.rs           │   │
│  └─────────────────────────────┘     │  │   ├── timer_test.rs          │   │
│                                      │  │   ├── i2c_test.rs            │   │
│                                      │  │   ├── i3c_test.rs            │   │
│                                      │  │   └── gpio_test.rs           │   │
│                                      │  │                              │   │
│                                      │  └── isr.rs ◄── all ISRs here   │   │
│                                      │      ├── #[no_mangle] uart0()   │   │
│                                      │      ├── #[no_mangle] timer()   │   │
│                                      │      ├── #[no_mangle] i3c()     │   │
│                                      │      └── #[no_mangle] i2c()     │   │
│                                      │                                 │   │
│                                      └─────────────────────────────────┘   │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘

Directory Structure

aspeed-rust/
├── Cargo.toml                  # workspace definition
├── aspeed-ddk/                 # lib crate - clean, no test ISRs
│   ├── Cargo.toml
│   └── src/
│       ├── lib.rs              # handler functions + conditional exports
│       ├── uart.rs
│       ├── timer.rs
│       ├── i3c/
│       └── i2c/
├── aspeed-ddk-tests/           # bin crate - owns all ISRs
│   ├── Cargo.toml              # depends on aspeed-ddk
│   └── src/
│       ├── main.rs             # single test binary entry point
│       ├── isr.rs              # all #[no_mangle] ISR definitions
│       └── tests/
│           ├── mod.rs
│           ├── uart_test.rs
│           ├── timer_test.rs
│           ├── i2c_test.rs
│           ├── i3c_test.rs
│           └── gpio_test.rs
└── xtask/                      # optional build tooling

Why Separate Crates?

Inside LibrarySeparate Crate
Test code ships to consumersClean library, no test leakage
Feature flags guard test modulesNo feature complexity for tests
Test deps pollute library depsIsolated dependency trees
pub mod tests in lib.rsTests are a normal bin crate

Test Crate Cargo.toml

[package]
name = "aspeed-ddk-tests"
version = "0.1.0"
edition = "2021"

[[bin]]
name = "functional-tests"
path = "src/main.rs"

[dependencies]
aspeed-ddk = { path = "../aspeed-ddk" }

Test Crate main.rs

#![no_std]
#![no_main]

use aspeed_ddk::uart;
use aspeed_ddk::i3c::ast1060_i3c;

mod tests;
mod isr;

#[cortex_m_rt::entry]
fn main() -> ! {
    // Initialize hardware...
    
    // Run tests
    tests::uart_test::run_uart_tests();
    tests::timer_test::run_timer_tests();
    tests::i3c_test::run_i3c_tests();
    
    loop {}
}

Test Crate isr.rs

use aspeed_ddk::uart;
use aspeed_ddk::i3c::ast1060_i3c;

// Test crate owns all ISRs
#[no_mangle]
pub extern "C" fn uart0() {
    uart::uart0_irq_handler();
}

#[no_mangle]
pub extern "C" fn i3c() {
    ast1060_i3c::i3c_irq_handler();
}

#[no_mangle]
pub extern "C" fn timer() {
    // Test-specific timer handling
}

This mirrors how kernel integration works - the integrating crate (kernel or test) depends on the driver library and wires up its own ISRs.

Common Mistakes to Avoid

1. Embedding test code in the library

// BAD: Test code with ISRs inside the library
// src/lib.rs
pub mod tests;  // Contains #[no_mangle] ISRs!

Fix: Move tests to a separate crate in the workspace.

2. Forgetting peripheral ISRs beyond the obvious ones

Libraries often have ISRs for:

  • Timers (timer, timer1, timer2, ...)
  • Communication (i2c, spi, uart, ...)
  • DMA channels
  • Error handlers

Fix: Audit all #[no_mangle] exports with:

grep -rn "#\[no_mangle\]" src/

3. Mixing ISR logic with ISR export

// BAD: Logic embedded in no_mangle function
#[no_mangle]
pub extern "C" fn uart0() {
    let status = read_status_register();
    if status & IRQ_PENDING != 0 {
        // 50 lines of handling logic
    }
}

Fix: Put logic in a callable function, export just calls it:

pub fn uart0_irq_handler() {
    // All the logic here
}

#[cfg(feature = "isr-handlers")]
#[no_mangle]
pub extern "C" fn uart0() {
    uart0_irq_handler();
}

Checklist for Library Contributors

  • [ ] Identify all #[no_mangle] ISR exports in your library
  • [ ] Create a feature flag (e.g., isr-handlers) that defaults to off
  • [ ] Refactor each ISR into: handler function + conditional export
  • [ ] Move test code with ISRs to a separate crate in the workspace
  • [ ] Document the integration pattern in your README
  • [ ] Provide example code showing kernel integration

Related Patterns

This pattern applies beyond interrupt handlers to any #[no_mangle] symbol:

  • Panic handlers (#[panic_handler])
  • Exception handlers (HardFault, MemManage, etc.)
  • Entry points (#[entry], main)
  • Allocator hooks (#[global_allocator])

The principle is the same: libraries should provide functionality, not claim global symbols.