如何定义中断向量表?

解读

在国内嵌入式/系统级 Rust 岗位面试中,“中断向量表” 不仅是裸机启动的入口,更是考察候选人能否把 Rust 的 “零成本抽象”“内存安全” 落地到最底层硬件的试金石。
面试官真正想听的是:

  1. 清楚向量表在链接阶段的绝对地址要求(通常 0x0000_0000 或 0x0800_0000);
  2. 会用 Rust 语法把表项声明成 repr(C) 的函数指针数组,并强制链接到 .vector_table 段
  3. 理解 Rust 的 unsafe 边界:中断向量表必须在 unsafe 块里填充,但后续业务逻辑可以安全地 Rust 化;
  4. 知道用 cortex-m-rt 或 riscv-rt 等官方 crate 时,#[entry]、#[exception]、#[interrupt] 这些属性只是语法糖,底层仍然是你自己提供的 vector table
  5. 能解释为什么 Rust 不需要汇编 stubs 也能做到“零开销”:通过链接脚本把符号直接放到向量地址,再用 extern "C" fn 保证 ABI 一致。

知识点

  1. repr(C):保证结构体布局与 C 完全一致,避免 Rust 字段重排序。
  2. 链接脚本(link.x):通过 PROVIDE 与 KEEP(*(.vector_table)) 把表钉死在指定 Flash 地址。
  3. extern "C" fn():中断向量表项必须是裸函数指针,禁止 Rust 的隐式 prologue/epilogue。
  4. #[no_mangle]:防止符号被哈希,确保链接脚本能精确引用。
  5. #[link_section = ".vector_table"]:把静态数组硬塞进命名段,替代汇编 .section 指令。
  6. cortex-m-rt::reset_handler:官方 crate 已帮你写好向量表,但面试要求你“手写”一个最小可运行的版本,以证明你理解其原理。
  7. unsafe 的边界:填充函数指针数组时必须 unsafe,但真正的 ISR 内部可以用 safe Rust,只要不开全局可变静态变量。
  8. 编译器屏障:在修改 VTOR 寄存器前后需要 asm!("dmb sy", options(nostack, preserves_flags)),防止指令重排导致 CPU 取到半更新向量表。

答案

下面给出一份可在 STM32F103C8T6 上直接烧录运行的最小中断向量表定义,完全用纯 Rust 完成,不依赖 cortex-m-rt,方便在面试白板或 IDE 里当场演示:

// memory.x 节选(链接脚本)
// MEMORY
// {
//   FLASH : ORIGIN = 0x08000000, LENGTH = 64K
//   RAM   : ORIGIN = 0x20000000, LENGTH = 20K
// }
// SECTIONS
// {
//   .vector_table ORIGIN(FLASH) :
//   {
//     KEEP(*(.vector_table))
//   } > FLASH
//   /* 其余段略 */
// }

#![no_std]
#![no_main]

use core::arch::asm;
use core::panic::PanicInfo;

// 1. 向量表项类型:裸函数指针
type Vector = extern "C" fn();

// 2. 向量表本身:16 个内核向量 + 68 个外设向量(STM32F1 68 IRQn)
#[repr(C)]
pub struct VectorTable {
    pub initial_sp: u32,
    pub reset: Vector,
    pub nmi: Vector,
    pub hard_fault: Vector,
    pub mem_manage: Vector,
    pub bus_fault: Vector,
    pub usage_fault: Vector,
    pub _reserved0: [u32; 4],
    pub svcall: Vector,
    pub debug_mon: Vector,
    pub _reserved1: u32,
    pub pendsv: Vector,
    pub systick: Vector,
    pub irq: [Vector; 68],
}

// 3. 默认空处理函数:无限循环,方便调试
extern "C" fn default_handler() -> ! {
    loop {
        unsafe { asm!("wfi") }
    }
}

// 4. 真正的复位入口
extern "C" fn reset_handler() -> ! {
    // 4.1 把数据段从 Flash 搬到 RAM
    extern "C" {
        static mut _sdata: u32;
        static mut _edata: u32;
        static _sidata: u32;
    }
    let count = (&mut _edata as *mut u32 as usize - &mut _sdata as *mut u32 as usize) / 4;
    let src = &_sidata as *const u32;
    let dst = &mut _sdata as *mut u32;
    unsafe {
        for i in 0..count {
            dst.add(i).write_volatile(src.add(i).read_volatile());
        }
    }
    // 4.2 跳到 main
    main();
}

#[inline(never)]
fn main() -> ! {
    // 用户逻辑:点亮 LED 等
    loop {}
}

// 5. 定义并钉死向量表
#[link_section = ".vector_table"]
#[no_mangle]
pub static VECTOR_TABLE: VectorTable = VectorTable {
    initial_sp: 0x2000_5000,          // 20K RAM 顶
    reset: reset_handler,
    nmi: default_handler,
    hard_fault: default_handler,
    mem_manage: default_handler,
    bus_fault: default_handler,
    usage_fault: default_handler,
    _reserved0: [0; 4],
    svcall: default_handler,
    debug_mon: default_handler,
    _reserved1: 0,
    pendsv: default_handler,
    systick: default_handler,
    irq: [default_handler; 68],
};

// 6. 恐慌处理
#[panic_handler]
fn panic(_: &PanicInfo) -> ! {
    default_handler()
}

关键点回顾

  • repr(C) + link_section 组合 把结构体钉死在 Flash 起始地址,满足 CM3 硬件对向量表的绝对地址要求。
  • extern "C" fn() 保证 ABI 一致,编译器不会插入 Rust 特有的 prologue,从而做到“零成本”。
  • 所有表项在编译期完成填充,没有运行时开销,也没有堆分配,符合 Rust 的“零成本抽象”承诺。
  • unsafe 只集中在数据段搬运与向量表定义两处,其余业务逻辑可完全用 safe Rust 编写,体现内存安全。

拓展思考

  1. 动态重定位向量表:在 BootLoader 升级场景,需要把向量表搬到 RAM 并修改 SCB->VTOR。此时必须用 write_volatile 写 VTOR,并在前后插入 dmb sy 编译屏障,防止 CPU 取到旧向量。
  2. Rust 中断嵌套与临界区:虽然裸机没有 std::sync::Mutex,但可以用 cortex_m::interrupt::free(|cs| { ... }) 关闭全局中断,配合 AtomicBool 实现无锁临界区,既满足 Rust 的 Send/Sync 规则,又保证实时性。
  3. 与 C 的混合链接:若公司存量 HAL 是 C 语言,可用 extern "C" { fn HAL_GPIO_EXTI_IRQHandler(pin: u16); } 直接在中断向量表里填 C 符号,再用 #[no_mangle] static IRQ_HANDLER: Vector = HAL_GPIO_EXTI_IRQHandler 实现 Rust 侧统一封装。
  4. 安全封装:可以设计一个 macro_rules! vector_table! 宏,让业务组只需写 handler!(TIM2, tim2_irq, 28) 就能自动生成表项,宏内部自动做 #[no_mangle] extern "C" fn tim2_irq() { ... },把 unsafe 细节彻底隐藏,达到“编译通过即正确”的终极目标。