3. ARM Cortex-M启动详解:中断向量表、堆栈初始化、启动文件(startup.s)分析、跳转到main()
好,咱们今天聊点硬核的。芯片上电之后,第一条指令到底在哪?谁告诉CPU该跑哪段代码?说白了,这一切都藏在启动文件里。我当年刚接触Cortex-M时,总觉得启动文件是编译器自动生成的,没必要看。直到有一次板子死活跑不起来,查了三天才发现是堆栈指针初始化错了。从那以后,我养成了一个习惯——每换一个芯片,先把启动文件通读一遍。
3.1 中断向量表:芯片的“电话本”
Cortex-M内核规定,0x00000000地址处必须放一张表,叫中断向量表。这张表就像芯片的“电话本”——每个中断源对应一个号码,号码后面存着对应的处理函数地址。
向量表的前16个是系统异常,从第16个开始才是外设中断。嗯,这里要注意:向量表的第0项存的是栈顶地址,第1项才是复位向量。我见过有人把这两个搞反,结果芯片一上电就跑飞了。
| 偏移地址 | 异常号 | 名称 | 说明 |
|---|---|---|---|
| 0x00 | - | SP_initial | 主堆栈指针初始值 |
| 0x04 | 1 | Reset_Handler | 复位后第一条指令地址 |
| 0x08 | 2 | NMI_Handler | 不可屏蔽中断 |
| 0x0C | 3 | HardFault_Handler | 硬错误,我最怕看到它 |
| 0x10~0x3C | 4~15 | 其他系统异常 | MemManage、BusFault等 |
| 0x40+ | 16+ | 外设中断 | 由芯片厂商定义 |
3.2 堆栈初始化:给C语言搭好舞台
Cortex-M使用两种堆栈模式:主堆栈(MSP)和进程堆栈(PSP)。复位后默认使用MSP。你想想看,如果堆栈指针指向一个不存在的地址,函数调用时压栈压到哪去?
堆栈初始化其实就做两件事:
- 确定栈顶地址——通常放在SRAM的最高地址,向下生长
- 把栈顶地址写入SP寄存器——硬件自动从向量表第0项加载
我个人习惯把栈大小设为至少1KB,调试阶段可以设到2KB。为什么?因为调试时你会加很多打印语句,栈用得快。等产品稳定了再优化。
Stack_Size EQU 0x00000400AREA STACK, NOINIT, READWRITE, ALIGN=3Stack_Mem SPACE Stack_Size__initial_sp EQU .注意那个
ALIGN=3,保证8字节对齐——Cortex-M要求栈必须对齐到8字节边界,否则某些指令会异常。
3.3 启动文件(startup.s)逐段分析
启动文件说白了就是一段汇编代码,完成C语言运行前的准备工作。我习惯把它分成五个阶段:
- 定义向量表——把各个异常和中断的入口地址列出来
- 初始化堆栈指针——加载__initial_sp到MSP
- 清零BSS段——把未初始化的全局变量区域清零
- 拷贝数据段——从Flash把初始化数据搬到SRAM
- 调用SystemInit和main——进入C世界
下面是一个典型的startup.s核心片段,我加了中文注释:
; 向量表定义
__Vectors DCD __initial_sp ; 栈顶地址
DCD Reset_Handler ; 复位处理函数
DCD NMI_Handler ; NMI
DCD HardFault_Handler ; 硬错误
; ... 其他异常向量 ...
; 复位处理函数
Reset_Handler PROC
EXPORT Reset_Handler [WEAK]
IMPORT __main ; 来自C库的入口
IMPORT SystemInit ; 系统时钟初始化
LDR R0, =SystemInit
BLX R0 ; 先初始化时钟
LDR R0, =__main
BX R0 ; 跳转到C库的__main
ENDP
你可能会问:为什么不是直接跳转到main(),而是跳转到__main?其实__main是C库函数,它负责完成数据段拷贝、BSS清零、C库初始化,最后才调用main()。这个细节,很多教程都没讲清楚。
3.4 跳转到main():最后一步
当启动文件执行完所有初始化,最后一条指令就是跳转到main()。但这里有个关键点:跳转前必须确保系统时钟已经配置好。我习惯在SystemInit里至少做三件事:
- 配置Flash等待周期(跟主频有关)
- 设置PLL锁相环,把系统时钟升到目标频率
- 使能必要的GPIO时钟(至少调试用的串口)
为什么?因为如果main()里第一条语句就是操作外设,而时钟还没开,那外设寄存器根本写不进去。我曾经在调试一个I2C驱动时,折腾了两天才发现是SystemInit里忘了使能I2C时钟——嗯,这种低级错误,犯过一次就长记性了。
上电 → 硬件从0x00000000加载SP → 从0x00000004加载PC → 执行Reset_Handler → SystemInit → __main(数据段拷贝+BSS清零) → main()
3.5 启动流程全景图
下面这张图,我把整个启动流程画出来了。你看一眼,就能把前面讲的内容串起来。
这张图把整个启动链条串起来了。你仔细看,从复位到main(),中间经过了5个关键步骤。每一步出问题,芯片都跑不起来。我个人建议,调试启动问题时,用调试器单步跟踪Reset_Handler,看SP和PC的值对不对——这是最快定位问题的方法。