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 异常处理的常见陷阱
最后,我总结几个新手容易犯的错误:
- 忘记实现中断服务函数——中断来了直接HardFault
- ISR里没清中断标志——中断反复触发,系统卡死
- 优先级设置不合理——低优先级中断永远得不到执行
- 在ISR里调用阻塞函数——破坏实时性
- 向量表地址没对齐——VTOR要求512字节对齐
嗯,向量表这部分内容就讲到这里。下一章我们会深入分析启动代码的完整执行流程,看看从复位向量到main函数之间到底发生了什么。到时候我会拿一个实际项目的启动文件来逐行讲解,保证你听完就能自己写启动代码。