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+ 外设中断 由芯片厂商定义
避坑指南:我曾经在一个项目里,因为链接脚本把向量表放到了0x08000000(Flash起始地址),但芯片默认从0x00000000取向量表。结果一上电就进HardFault。后来发现需要配置VTOR寄存器,把向量表重定位到Flash地址。这个坑,踩过一次就忘不了。

3.2 堆栈初始化:给C语言搭好舞台

Cortex-M使用两种堆栈模式:主堆栈(MSP)和进程堆栈(PSP)。复位后默认使用MSP。你想想看,如果堆栈指针指向一个不存在的地址,函数调用时压栈压到哪去?

堆栈初始化其实就做两件事:

  • 确定栈顶地址——通常放在SRAM的最高地址,向下生长
  • 把栈顶地址写入SP寄存器——硬件自动从向量表第0项加载

我个人习惯把栈大小设为至少1KB,调试阶段可以设到2KB。为什么?因为调试时你会加很多打印语句,栈用得快。等产品稳定了再优化。

小技巧:在startup.s里,通常用这样的方式定义栈:
Stack_Size EQU 0x00000400
AREA STACK, NOINIT, READWRITE, ALIGN=3
Stack_Mem SPACE Stack_Size
__initial_sp EQU .
注意那个ALIGN=3,保证8字节对齐——Cortex-M要求栈必须对齐到8字节边界,否则某些指令会异常。

3.3 启动文件(startup.s)逐段分析

启动文件说白了就是一段汇编代码,完成C语言运行前的准备工作。我习惯把它分成五个阶段:

  1. 定义向量表——把各个异常和中断的入口地址列出来
  2. 初始化堆栈指针——加载__initial_sp到MSP
  3. 清零BSS段——把未初始化的全局变量区域清零
  4. 拷贝数据段——从Flash把初始化数据搬到SRAM
  5. 调用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 启动流程全景图

下面这张图,我把整个启动流程画出来了。你看一眼,就能把前面讲的内容串起来。

ARM Cortex-M 启动流程全景图 芯片上电复位 从0x00000000加载向量表 SP ← 向量表[0] PC ← 向量表[1] 执行 Reset_Handler SystemInit() 配置时钟 __main() 数据段/BSS初始化 进入 main() 用户程序 说明: • 向量表必须4字节对齐 • 栈顶地址必须是SRAM最高地址 • SystemInit可自定义

这张图把整个启动链条串起来了。你仔细看,从复位到main(),中间经过了5个关键步骤。每一步出问题,芯片都跑不起来。我个人建议,调试启动问题时,用调试器单步跟踪Reset_Handler,看SP和PC的值对不对——这是最快定位问题的方法。