3、启动模式与向量表:ARM/STM32启动模式、中断向量表重映射、启动文件(startup.s)解析

大家好,我是你们的嵌入式系统讲师。今天我们来聊聊Bootloader开发中最基础、也最关键的一环——启动模式与向量表。说白了,就是芯片上电后,它怎么知道该从哪里开始跑代码?中断来了又该找谁?

我个人习惯,在开始任何Bootloader项目之前,都会先把这几件事理清楚。因为一旦这里出问题,后面所有的安全刷新、固件升级都是空中楼阁。

3.1 STM32的三种启动模式

STM32芯片上电后,会先读一个叫BOOT0和BOOT1的引脚电平。这两个引脚的不同组合,决定了芯片从哪个物理地址开始执行。我刚开始做项目时,有一次焊板子不小心把BOOT0拉高了,结果程序死活跑不起来,查了半天才发现是启动模式选错了。

BOOT1 BOOT0 启动模式 说明
X 0 主Flash启动 从0x08000000开始执行,正常模式
0 1 系统存储器启动 从0x1FFF0000开始,内置Bootloader
1 1 SRAM启动 从0x20000000开始,调试用

这里有个关键点:主Flash的起始地址是0x08000000,但CPU实际上是从0x00000000取向量表的。嗯,这里有点绕,我解释一下。

芯片内部有个地址重映射机制。当BOOT0=0时,0x00000000被映射到0x08000000。所以CPU读0x00000000,实际上读的是Flash里的内容。你想想看,这样设计的好处是什么?

  • 主Flash启动:正常运行时用,Bootloader和App都放在这里
  • 系统存储器启动:出厂固化的Bootloader,用于串口/USB下载
  • SRAM启动:调试阶段用,可以快速验证代码
我的经验:做Bootloader开发时,我建议把BOOT0引脚引出到跳线帽。这样量产时可以通过跳线选择进入系统Bootloader,方便固件恢复。我曾经在一个项目中没留这个接口,结果有台设备刷死了,只能拆机焊线,教训深刻。

3.2 中断向量表:CPU的紧急通讯录

中断向量表,说白了就是一张地址表。每个中断源对应一个入口地址,CPU收到中断信号后,就去这张表里查该跳到哪里去处理。

ARM Cortex-M系列的向量表长这样:

; 向量表起始地址(0x00000000 或 0x08000000)
0x00000000: 栈顶地址(MSP初始值)
0x00000004: 复位向量(Reset_Handler)
0x00000008: NMI中断入口
0x0000000C: HardFault入口
0x00000010: MemManage入口
...
0x000000XX: 外设中断入口(USART、TIM、DMA等)

这里有个非常重要的细节:向量表的第一个字是栈顶地址,第二个字才是复位向量。CPU上电后,先读第一个字到MSP寄存器,再读第二个字作为PC的初始值。我见过有新手自己写启动文件时把这两个搞反了,结果程序直接跑飞。

注意:向量表必须4字节对齐。如果对齐不对,CPU会触发硬件错误。我曾经在移植Bootloader时,因为链接脚本里忘了加对齐指令,折腾了一整天才找到原因。

3.3 向量表重映射:Bootloader的核心技术

做Bootloader时,我们面临一个现实问题:Flash里既有Bootloader,又有App,它们各自有自己的中断向量表。怎么让它们互不干扰?

答案就是向量表重映射。Cortex-M3/M4提供了一个叫VTOR(Vector Table Offset Register)的寄存器,位于SCB(系统控制块)中,地址是0xE000ED08。

工作原理很简单:

  1. Bootloader启动时,VTOR指向Bootloader自己的向量表(比如0x08000000)
  2. Bootloader完成初始化后,跳转到App之前,把VTOR改成App的向量表地址(比如0x08020000)
  3. App运行后,所有中断都从新的向量表查找入口

代码实现也很直接:

/* 设置向量表偏移 */
#define SCB_VTOR      (volatile uint32_t*)0xE000ED08
#define APP_VECTOR_ADDR  0x08020000  /* App的起始地址 */

void jump_to_app(void)
{
    uint32_t msp_value;
    void (*app_entry)(void);
    
    /* 1. 关闭全局中断 */
    __disable_irq();
    
    /* 2. 重映射向量表 */
    *SCB_VTOR = APP_VECTOR_ADDR;
    
    /* 3. 从App向量表取栈顶和入口地址 */
    msp_value = *(volatile uint32_t*)APP_VECTOR_ADDR;
    app_entry = (void (*)(void))(*(volatile uint32_t*)(APP_VECTOR_ADDR + 4));
    
    /* 4. 设置主栈指针 */
    __set_MSP(msp_value);
    
    /* 5. 跳转到App */
    app_entry();
}
关键点:跳转前一定要关中断!否则在VTOR切换的瞬间,如果有中断进来,CPU会去旧的向量表找入口,但旧表可能已经被App覆盖了,结果就是HardFault。我早期的一个项目就踩过这个坑,后来养成了跳转前必关中断的习惯。

3.4 启动文件(startup.s)深度解析

启动文件是芯片上电后执行的第一段代码。虽然现在很多IDE都自动生成了,但我建议你还是亲手读一遍。我记得第一次看启动文件时,感觉像在读天书,但看懂之后,对芯片的理解直接上了一个台阶。

一个典型的STM32启动文件包含以下几个部分:

3.4.1 向量表定义

__Vectors       DCD     __initial_sp          ; 栈顶地址
                DCD     Reset_Handler         ; 复位向量
                DCD     NMI_Handler           ; NMI
                DCD     HardFault_Handler     ; 硬错误
                DCD     MemManage_Handler     ; 内存管理
                DCD     BusFault_Handler      ; 总线错误
                DCD     UsageFault_Handler    ; 用法错误
                ; ... 其他中断向量 ...

每个DCD指令分配4字节,存放一个32位地址。注意看,第一个是栈顶,第二个才是复位入口。这个顺序是ARM设计好的,不能改。

3.4.2 复位处理函数

复位后,CPU会跳到这里。它负责三件大事:

  1. 初始化全局变量:把.data段从Flash拷贝到SRAM,把.bss段清零
  2. 初始化系统时钟:调用SystemInit()设置时钟树
  3. 跳转到main():调用__libc_init_array()后进入主程序
Reset_Handler   PROC
                EXPORT  Reset_Handler
                IMPORT  __main
                IMPORT  SystemInit
                
                LDR     R0, =SystemInit
                BLX     R0
                LDR     R0, =__main
                BX      R0
                ENDP

这里有个细节:为什么用BX跳转而不是BL?因为__main是C库的入口,它最终会调用main(),而且不会再返回。用BL的话,如果__main返回了,程序就不知道跑哪去了。

3.4.3 弱定义中断处理

NMI_Handler     PROC
                EXPORT  NMI_Handler
                B       .
                ENDP

这个B .是死循环。所有未实现的中断处理函数默认都指向这个死循环。如果你在代码里实现了某个中断函数,链接器会用你的实现覆盖这个弱定义。

我的习惯:在调试阶段,我会在HardFault_Handler里加个断点或者串口打印。这样一旦程序跑飞,我能第一时间知道。量产版本再改成复位或者安全处理。

3.5 知识结构总览

下面这张图把本章的核心知识点串起来了,你可以对照着理解:

启动模式与向量表知识结构 三种启动模式 主Flash (0x08000000) 系统存储器 (0x1FFF0000) SRAM (0x20000000) 中断向量表 0x0000: 栈顶地址 (MSP) 0x0004: 复位向量 0x0008+: 异常/中断入口 VTOR重映射 SCB->VTOR = 新地址 Bootloader→App切换 中断不冲突 启动文件 startup.s 向量表定义 DCD 指令分配地址 4字节对齐 复位处理 变量初始化 时钟配置→跳转main 弱定义中断 默认死循环 用户实现覆盖 核心:启动模式决定物理地址 → 向量表决定中断入口 → VTOR实现Bootloader/App切换

3.6 避坑指南

最后,我把自己这些年踩过的坑总结一下,希望能帮你少走弯路:

  • 向量表对齐问题:VTOR寄存器要求偏移量是512字节的整数倍(Cortex-M3/M4)。如果你把App放在0x08020000,没问题;但如果放在0x08020100,VTOR就设不了,因为不是512的倍数。
  • 中断优先级分组:跳转到App前,记得把中断优先级分组也恢复成默认值。否则App里设置优先级时可能会乱掉。
  • 外设状态清理:Bootloader用过的外设(比如串口、定时器),跳转前要彻底复位。不然App初始化时可能遇到意外状态。
我曾经踩过的坑:有一次做OTA升级,Bootloader跳转到App后,串口中断一直触发,但App里根本没开串口。查了半天发现是Bootloader里开了串口接收中断,跳转前没关。App的向量表里串口中断指向了默认的死循环,结果程序一跑就进HardFault。从那以后,我跳转前都会加一个外设去初始化函数。