4、中断向量表与异常处理:向量表重映射、异常向量定义、中断服务程序入口、FIQ与IRQ区别

中断向量表,说白了就是一张“出事了找谁”的表格。CPU 一遇到异常或中断,硬件自动去这张表里查地址,然后跳过去执行。我刚开始做嵌入式时,总觉得这玩意儿就是 ROM 里固定的一堆地址,没啥好研究的。直到有一次调试一个偶发性的系统崩溃,折腾了两天,最后发现是向量表配置错了——嗯,从那以后我再也不敢小看它了。

4.1 异常向量定义——CPU 的“紧急联络簿”

ARM Cortex-M 系列和 Cortex-A 系列的异常向量表布局不太一样,但核心思想一致:每个异常类型对应一个固定的偏移位置。以 Cortex-M 为例,向量表从地址 0x00000000 开始,前 16 个是系统异常,后面是外部中断。

偏移地址 异常类型 优先级(默认) 说明
0x00 栈顶指针 - 初始 MSP 值
0x04 Reset -3(最高) 上电/复位入口
0x08 NMI -2 不可屏蔽中断
0x0C HardFault -1 硬错误,兜底处理
0x10 MemManage 可编程 内存管理异常
0x14 BusFault 可编程 总线错误
0x18 UsageFault 可编程 用法错误
0x1C~0x2C 保留 - 预留
0x30~0x3C SVCall / PendSV / SysTick 可编程 系统服务与滴答定时器
0x40+ 外部中断(IRQ 0~n) 可编程 外设中断入口

关键点:向量表里存的不是指令,而是地址。Cortex-M 的向量表每个条目占 4 字节,存的是对应异常处理函数的入口地址。CPU 拿到地址后直接跳转,不需要额外的指令解码。

4.2 向量表重映射——Bootloader 的“移花接木”

Bootloader 启动时,CPU 默认从 0x00000000 读取向量表。但应用固件通常放在另一个地址,比如 0x08010000。这时候就需要向量表重映射——告诉 CPU:“嘿,别去 0x00000000 找了,去新地址查表。”

Cortex-M 系列提供了一个寄存器叫 VTOR(Vector Table Offset Register),专门干这个事。我个人习惯在跳转到应用之前,先把 VTOR 设置成应用固件的基地址。

// 以 STM32 为例,设置向量表偏移
#define APPLICATION_ADDRESS 0x08010000

void jump_to_application(void)
{
    // 1. 设置主栈指针
    __set_MSP(*(uint32_t*)APPLICATION_ADDRESS);
    
    // 2. 设置向量表偏移
    SCB->VTOR = APPLICATION_ADDRESS;
    
    // 3. 获取应用入口地址(Reset_Handler)
    uint32_t app_entry = *(uint32_t*)(APPLICATION_ADDRESS + 4);
    
    // 4. 跳转
    void (*app_main)(void) = (void (*)(void))app_entry;
    app_main();
}

我曾经踩过的坑:只设置了 MSP,忘了改 VTOR。结果应用里一发生中断,CPU 还是去 0x00000000 查表,找到的是 Bootloader 的中断服务程序,直接跑飞。所以记住:改 MSP 和改 VTOR 必须成对出现,顺序不能乱。

4.3 中断服务程序入口——硬件自动压栈与出栈

Cortex-M 的中断响应是硬件自动完成的,不需要软件去保存现场。这一点和传统的 ARM7/ARM9 完全不同。我刚开始从 ARM9 转到 Cortex-M 时,总觉得不踏实——软件不保存寄存器,能行吗?

实际上,硬件在进入中断时会自动压栈:R0-R3、R12、LR、PC、xPSR,一共 8 个寄存器,32 字节。退出中断时,硬件自动出栈恢复。这就是所谓的“咬尾中断”和“晚到中断”能高效工作的基础。

小技巧:如果你在中断服务程序里调用了函数,编译器会自动生成额外的压栈/出栈代码。所以中断服务程序尽量短小精悍,别在里面做复杂运算。我一般只在 ISR 里设一个标志位,主循环去处理。

4.4 FIQ 与 IRQ 区别——快中断 vs 普通中断

FIQ(Fast Interrupt Request)和 IRQ(Interrupt Request)是 ARM 公司从 ARM7 时代就引入的概念。Cortex-M 系列虽然不再区分 FIQ/IRQ,但理解这个差异对理解中断优先级设计很有帮助。

特性 FIQ IRQ
优先级 高于 IRQ 低于 FIQ
向量表位置 0x1C(固定) 0x18(固定)
寄存器备份 有专用寄存器(R8-R12) 无专用寄存器
响应延迟 更短(省去部分压栈) 相对较长
典型用途 高速数据传输、实时性要求极高 普通外设中断

为什么 FIQ 更快?因为 ARM 为 FIQ 模式提供了影子寄存器——R8 到 R12 有独立的物理副本。进入 FIQ 时,硬件自动切换到影子寄存器,不需要压栈保存这些寄存器。而 IRQ 没有这个待遇,必须软件压栈。

你想想看,如果有一个中断每秒触发几万次,每次省掉 5 个寄存器的压栈/出栈,累积下来就是可观的性能提升。

实际项目中的选择:我在做高速数据采集卡时,把 ADC 采样完成中断配成了 FIQ,把按键、串口这些慢速中断配成了 IRQ。这样即使系统在忙,采样数据也不会丢。说白了,把最紧急、最频繁的中断放在最高优先级,这是中断设计的第一原则。

4.5 中断向量表与异常处理核心流程

下面这张图展示了从异常发生到处理完成的完整路径,我把它总结为“查表→压栈→跳转→执行→出栈→返回”六步曲。

中断向量表与异常处理核心流程 中断向量表与异常处理核心流程 异常/中断发生 硬件查向量表(VTOR + 偏移) 硬件自动压栈(8个寄存器) 跳转 ISR 入口 执行中断服务程序 硬件自动出栈 → 返回主程序 FIQ vs IRQ 关键差异 • FIQ 有影子寄存器 • FIQ 响应延迟更短 • FIQ 优先级高于 IRQ • 向量表位置不同 VTOR 重映射 • Bootloader 修改 VTOR • 指向应用向量表基址

4.6 避坑指南——我这些年遇到过的向量表问题

  • 向量表对齐问题:Cortex-M 要求 VTOR 的值必须是 512 字节对齐的(有些系列是 256 字节)。我曾经设了一个非对齐的地址,结果中断一进来就跑飞。查了半天手册才发现这个限制。
  • 中断嵌套死锁:在 ISR 里调用了会触发 PendSV 的 API,导致 PendSV 无法得到执行。我建议在 ISR 里只做最轻量的事,复杂逻辑扔给任务级处理。
  • FIQ 滥用:有人把所有中断都配成 FIQ,觉得“越快越好”。结果 FIQ 把 CPU 时间占满了,IRQ 和普通任务饿死。记住:FIQ 是给真正紧急的中断用的,不是性能加速器。
  • 忘记关中断:Bootloader 跳转到应用前,最好把外设中断全部关闭,否则应用还没初始化完,中断就来了。我一般会在跳转前调用 __disable_irq(),等应用自己重新配置。

个人习惯:我在 Bootloader 里会保留一个“看门狗中断”作为最后的保底。如果应用启动失败,看门狗超时触发 NMI,Bootloader 还能抓住最后一次机会做错误记录。这个习惯帮我定位过好几个现场问题。