3、复位向量与异常处理:ARM Cortex-M的向量表机制

各位同学,今天我们来聊聊Cortex-M启动的核心——向量表。说实话,这玩意儿我刚开始接触的时候也觉得挺玄乎的,不就是个地址表嘛。但后来在项目里吃过亏,才真正明白它的分量。

3.1 向量表到底是什么?

ARM Cortex-M的向量表,说白了就是一张「异常处理函数地址清单」。芯片上电后,硬件会自动从这个表里取数据,决定程序从哪里开始跑、遇到异常该跳到哪里去。

这张表固定存放在Flash或RAM的开头。默认情况下,它从地址0x00000000开始。但你可以通过设置VTOR(向量表偏移寄存器)把它挪到别的位置。我在一个OTA升级项目里就干过这事儿——把向量表从Flash搬到RAM,方便动态更新中断服务函数。

向量表的核心内容:

  • 第1个word(4字节):栈顶指针(MSP初始值)
  • 第2个word:复位向量(Reset_Handler地址)
  • 后续word:各种异常和中断的服务函数地址

3.2 复位向量地址——芯片的第一口气

芯片上电后,CPU做的第一件事是什么?

嗯,它先读0x00000000处的值,作为主栈指针MSP。然后读0x00000004处的值,作为复位向量——也就是复位后第一条指令的地址。然后CPU直接跳过去执行。

我曾经遇到一个坑:同事把向量表放到了0x08010000(Flash偏移),但忘了更新VTOR。结果芯片一上电就跑飞了。查了两天才发现,硬件默认从0x00000000读向量表,而那里是空的。所以,VTOR必须和实际存放向量表的地址一致

注意:复位向量地址必须是奇数(LSB为1),表示Thumb状态。如果写成偶数地址,CPU会进入错误状态。我见过新手直接写函数地址,忘了加1,结果芯片死活不跑。

3.3 异常优先级——谁说了算?

Cortex-M的异常优先级分两种:

  • 系统异常:复位、NMI、硬fault等。优先级固定,不可更改。
  • 外部中断:由NVIC管理,优先级可编程。

优先级数值越小,优先级越高。复位优先级最高(-3),NMI次之(-2),硬fault第三(-1)。普通中断的优先级范围是0~255(具体取决于芯片实现,比如STM32只用了4位,所以是0~15)。

异常类型 优先级 说明
复位 -3(最高) 不可屏蔽,不可抢占
NMI -2 不可屏蔽
硬fault -1 其他fault未使能时汇聚于此
SVCall 可编程 SVC指令触发
PendSV 可编程 常用于RTOS任务切换
SysTick 可编程 系统滴答定时器
外部中断 可编程 0~255,具体看芯片

你想想看,如果两个中断同时发生,NVIC怎么决定先处理谁?就是比优先级数值。数值小的先执行。如果优先级相同,那就看中断号,中断号小的先执行。

我的习惯:在RTOS项目中,我会把SysTick优先级设为最低(数值最大),PendSV设为次低。这样系统滴答不会打断关键任务,任务切换也只在空闲时进行。这个配置我在FreeRTOS和RT-Thread里都用过,效果不错。

3.4 中断服务函数编写规范

写中断服务函数(ISR),有几个铁律必须遵守。我见过太多因为ISR写得不好导致系统崩溃的案例了。

3.4.1 函数命名

Cortex-M的向量表里,每个中断都有固定的名字。比如:

void USART1_IRQHandler(void);   // USART1中断
void TIM2_IRQHandler(void);     // 定时器2中断
void EXTI0_IRQHandler(void);    // 外部中断0

这些名字在启动文件里已经定义好了。你只需要实现同名函数即可。如果名字写错了,中断来了就找不到处理函数,直接跑进HardFault。

3.4.2 函数体要短小精悍

ISR里不要做复杂运算,不要调用printf,不要等待。原则是:能多快就多快

// 好的写法
void EXTI0_IRQHandler(void)
{
    // 清除中断标志
    EXTI->PR = (1 << 0);
    
    // 设置一个标志位,主循环处理
    g_exti0_flag = 1;
}

// 坏的写法
void EXTI0_IRQHandler(void)
{
    // 千万别这么干!
    delay_ms(100);
    printf("interrupt occurred\n");
    complex_function();
}

3.4.3 中断嵌套与抢占

Cortex-M支持中断嵌套。高优先级中断可以打断低优先级中断。但要注意:

  • 不要在ISR里长时间关中断
  • 如果多个中断共享资源,要用临界区保护
  • RTOS环境下,ISR里不能调用可能引起任务切换的API(除非明确允许)

我曾经踩过的坑:在一个电机控制项目里,我在ISR里调用了malloc分配内存。结果中断频繁触发时,堆管理出现了竞态条件,直接导致系统死机。从那以后,我定了个规矩:ISR里绝对不用动态内存分配。

3.4.4 使用__attribute__((interrupt))

有些编译器支持用属性声明中断函数:

void __attribute__((interrupt)) USART1_IRQHandler(void)
{
    // 编译器会自动生成入栈出栈代码
}

不过我个人习惯直接用普通函数声明,因为Cortex-M硬件会自动保存和恢复上下文。只要函数名和向量表一致就行。

3.5 向量表重定向——实战技巧

前面提到VTOR可以重定向向量表。这在Bootloader+App架构里特别有用。

// 假设App从0x08010000开始
#define APP_VECTOR_TABLE_ADDR  0x08010000

void jump_to_app(void)
{
    // 1. 设置向量表偏移
    SCB->VTOR = APP_VECTOR_TABLE_ADDR;
    
    // 2. 获取App的栈顶指针和复位向量
    uint32_t app_stack = *(uint32_t*)APP_VECTOR_TABLE_ADDR;
    uint32_t app_reset  = *(uint32_t*)(APP_VECTOR_TABLE_ADDR + 4);
    
    // 3. 设置主栈指针
    __set_MSP(app_stack);
    
    // 4. 跳转到App
    void (*app_main)(void) = (void (*)(void))app_reset;
    app_main();
}

这里有个细节:跳转前要关掉所有中断,跳转后由App重新初始化外设。不然App启动时,外设还处于Bootloader配置的状态,容易出问题。

我的建议:在跳转前,把用过的外设都复位到默认状态。比如把GPIO设回输入模式,关掉定时器,清空DMA缓冲区。这样App启动时就是一张白纸,不会继承任何奇怪的状态。

3.6 异常处理的常见陷阱

最后,我总结几个新手容易犯的错误:

  1. 忘记实现中断服务函数——中断来了直接HardFault
  2. ISR里没清中断标志——中断反复触发,系统卡死
  3. 优先级设置不合理——低优先级中断永远得不到执行
  4. 在ISR里调用阻塞函数——破坏实时性
  5. 向量表地址没对齐——VTOR要求512字节对齐

嗯,向量表这部分内容就讲到这里。下一章我们会深入分析启动代码的完整执行流程,看看从复位向量到main函数之间到底发生了什么。到时候我会拿一个实际项目的启动文件来逐行讲解,保证你听完就能自己写启动代码。