2、启动模式解析:ARM Cortex-M的启动流程、启动文件(startup.s)的作用、中断向量表
大家好,我是你们的Bootloader讲师。今天咱们聊聊ARM Cortex-M的启动流程。说实话,我见过不少工程师,写了好几年嵌入式代码,却对芯片上电后那几百微妙里发生了什么一无所知。嗯,这其实挺危险的——你连系统怎么“活过来”的都不清楚,怎么敢说自己懂底层?
这一节,我会把Cortex-M的启动流程掰开揉碎了讲。包括启动文件(startup.s)到底在干什么,中断向量表为什么是“命根子”。
2.1 上电瞬间:CPU的第一口“奶”
ARM Cortex-M处理器复位后,硬件会自动做两件事:
- 从0x00000000读取栈顶指针(SP),初始化主堆栈指针MSP。
- 从0x00000004读取复位向量(Reset_Handler),跳转到那里执行第一条指令。
就这么简单?对,就这么简单。但这里有个坑——向量表必须放在起始地址。我曾经在一个项目里,因为链接脚本写错了,把向量表偏移到了0x2000,结果芯片死活跑不起来。查了两天,最后发现是向量表位置不对。
核心要点:Cortex-M复位后,硬件自动从0x00000000和0x00000004取数。这是硬编码行为,不可更改(除非你使能了VTOR重定位)。
2.2 启动文件(startup.s)到底在干什么?
很多初学者觉得startup.s是“天书”,一堆汇编看不懂。其实它就是个“保姆”,负责在main()之前把环境搭好。我习惯把它的工作分成四步:
- 定义中断向量表——告诉CPU每个异常/中断的入口地址。
- 初始化栈指针——设置好C语言运行需要的栈空间。
- 搬运数据段——把RW段从Flash搬到RAM,把ZI段清零。
- 调用SystemInit()和main()——初始化时钟、外设,然后进入主循环。
你想想看,如果没有startup.s,你的C代码连个全局变量都访问不了——因为RAM还没初始化呢。
我的习惯:在startup.s里,我会把未使用的中断向量都指向一个死循环函数(Default_Handler)。这样万一触发了未使能的中断,系统不会跑飞,而是停在原地等你调试。这个习惯救过我至少三次。
2.3 中断向量表:系统的“电话簿”
中断向量表,说白了就是一张地址映射表。每个中断源对应一个表项,表项里存的是中断服务函数(ISR)的入口地址。
Cortex-M的向量表前16个是系统异常,后面是外设中断。以STM32F103为例:
| 偏移地址 | 异常/中断 | 优先级 | 说明 |
|---|---|---|---|
| 0x00 | 栈顶指针(SP) | - | 初始MSP值 |
| 0x04 | Reset_Handler | -3(最高) | 复位后第一条指令 |
| 0x08 | NMI_Handler | -2 | 不可屏蔽中断 |
| 0x0C | HardFault_Handler | -1 | 硬错误,最常见 |
| 0x10~0x3C | 其他系统异常 | 可配置 | MemManage、BusFault等 |
| 0x40+ | 外设中断 | 可配置 | TIM、UART、DMA等 |
这里有个关键点:向量表必须4字节对齐,因为每个表项是一个32位地址。我曾经见过有人把向量表定义成char数组,结果地址错位,系统一进中断就跑飞。
避坑指南:如果你用VTOR重定位了向量表,记得新地址必须按128字节对齐(Cortex-M3/M4要求)。我踩过这个坑——把向量表放在0x20000100,结果中断响应全乱套了。
2.4 启动流程完整图解
下面这张图,是我自己总结的Cortex-M启动流程。每次带新人,我都会先让他们看这张图:
2.5 一个典型的startup.s片段
光说不练假把式。我贴一段我常用的startup.s核心代码,注释都写好了:
; 中断向量表定义
__Vectors DCD __initial_sp ; 栈顶指针
DCD Reset_Handler ; 复位向量
DCD NMI_Handler ; NMI
DCD HardFault_Handler ; 硬错误
DCD MemManage_Handler ; 内存管理错误
DCD BusFault_Handler ; 总线错误
DCD UsageFault_Handler ; 用法错误
; ... 其他异常向量 ...
DCD TIM2_IRQHandler ; TIM2中断
DCD USART1_IRQHandler ; USART1中断
; ... 更多外设中断 ...
; 复位处理函数
Reset_Handler PROC
EXPORT Reset_Handler
; 1. 搬运RW段从Flash到RAM
LDR r0, =_sidata
LDR r1, =_sdata
LDR r2, =_edata
BL copy_data
; 2. 清零ZI段
LDR r1, =_sbss
LDR r2, =_ebss
BL zero_bss
; 3. 调用SystemInit
BL SystemInit
; 4. 进入main
BL main
; 5. 如果main返回,死循环
B .
ENDP
这段代码里,_sidata、_sdata这些符号是链接脚本定义的。它们告诉启动文件:RW段在Flash里的起始地址、在RAM里的目标地址、以及长度。ZI段同理。
个人经验:我习惯在startup.s里加一个WDT_Feed调用——如果系统有看门狗,在初始化过程中定期喂狗。否则,如果SystemInit()里配置PLL耗时较长,看门狗可能先复位了。这个细节,我见过至少三个项目因此翻车。
2.6 中断响应:硬件自动压栈
Cortex-M的中断响应和传统ARM7/9不一样。它硬件自动压栈——进入中断时,CPU自动把R0-R3、R12、LR、PC、xPSR这8个寄存器压到当前栈上。退出中断时,硬件自动出栈恢复。
这意味着什么?意味着你的ISR里不需要手动保存/恢复寄存器了。但要注意:如果ISR里调用了其他函数,编译器仍然会为那些函数生成压栈代码。别搞混了。
我曾经在一个项目里,因为ISR里调用了printf(),导致栈溢出。为什么?因为printf()本身用了大量栈空间,加上硬件自动压栈的8个寄存器,栈直接爆了。从那以后,我定了个规矩:ISR里不调用复杂函数,只设标志位,主循环里处理。
2.7 总结一下
这一节的内容,说白了就三件事:
- 启动流程:复位→取向量→跳Reset_Handler→初始化→进main。
- startup.s:负责搬运数据、清零BSS、调用初始化函数。
- 中断向量表:一张地址表,告诉CPU中断来了该去哪。
嗯,这些是Cortex-M启动的基石。你把这些搞透了,后面讲Bootloader的跳转、中断重定向,就会轻松很多。记住:底层没有魔法,只有你没读懂的文档。