如何定义中断向量表?
解读
在国内嵌入式/系统级 Rust 岗位面试中,“中断向量表” 不仅是裸机启动的入口,更是考察候选人能否把 Rust 的 “零成本抽象” 与 “内存安全” 落地到最底层硬件的试金石。
面试官真正想听的是:
- 你清楚向量表在链接阶段的绝对地址要求(通常 0x0000_0000 或 0x0800_0000);
- 你会用 Rust 语法把表项声明成 repr(C) 的函数指针数组,并强制链接到 .vector_table 段;
- 你理解 Rust 的 unsafe 边界:中断向量表必须在 unsafe 块里填充,但后续业务逻辑可以安全地 Rust 化;
- 你知道用 cortex-m-rt 或 riscv-rt 等官方 crate 时,#[entry]、#[exception]、#[interrupt] 这些属性只是语法糖,底层仍然是你自己提供的 vector table;
- 你能解释为什么 Rust 不需要汇编 stubs 也能做到“零开销”:通过链接脚本把符号直接放到向量地址,再用 extern "C" fn 保证 ABI 一致。
知识点
- repr(C):保证结构体布局与 C 完全一致,避免 Rust 字段重排序。
- 链接脚本(link.x):通过 PROVIDE 与 KEEP(*(.vector_table)) 把表钉死在指定 Flash 地址。
- extern "C" fn():中断向量表项必须是裸函数指针,禁止 Rust 的隐式 prologue/epilogue。
- #[no_mangle]:防止符号被哈希,确保链接脚本能精确引用。
- #[link_section = ".vector_table"]:把静态数组硬塞进命名段,替代汇编 .section 指令。
- cortex-m-rt::reset_handler:官方 crate 已帮你写好向量表,但面试要求你“手写”一个最小可运行的版本,以证明你理解其原理。
- unsafe 的边界:填充函数指针数组时必须 unsafe,但真正的 ISR 内部可以用 safe Rust,只要不开全局可变静态变量。
- 编译器屏障:在修改 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 编写,体现内存安全。
拓展思考
- 动态重定位向量表:在 BootLoader 升级场景,需要把向量表搬到 RAM 并修改 SCB->VTOR。此时必须用 write_volatile 写 VTOR,并在前后插入 dmb sy 编译屏障,防止 CPU 取到旧向量。
- Rust 中断嵌套与临界区:虽然裸机没有 std::sync::Mutex,但可以用 cortex_m::interrupt::free(|cs| { ... }) 关闭全局中断,配合 AtomicBool 实现无锁临界区,既满足 Rust 的 Send/Sync 规则,又保证实时性。
- 与 C 的混合链接:若公司存量 HAL 是 C 语言,可用 extern "C" { fn HAL_GPIO_EXTI_IRQHandler(pin: u16); } 直接在中断向量表里填 C 符号,再用 #[no_mangle] static IRQ_HANDLER: Vector = HAL_GPIO_EXTI_IRQHandler 实现 Rust 侧统一封装。
- 安全封装:可以设计一个 macro_rules! vector_table! 宏,让业务组只需写 handler!(TIM2, tim2_irq, 28) 就能自动生成表项,宏内部自动做 #[no_mangle] extern "C" fn tim2_irq() { ... },把 unsafe 细节彻底隐藏,达到“编译通过即正确”的终极目标。