第2章 启动流程分析:ARM Cortex-M内核的启动流程、复位向量表、堆栈指针初始化、跳转到main函数
做嵌入式开发,尤其是移植Bootloader,第一关就是搞懂芯片上电后到底干了什么。很多人拿着开发板直接点灯,觉得启动流程无所谓——嗯,我以前也这么想,直到有一次调试一个诡异的死机问题,折腾了两天,最后发现是向量表偏移配错了。
从那以后,我养成了一个习惯:移植任何新平台,先把启动流程手动画一遍。今天我们就来拆解Cortex-M内核的启动过程,看看从复位到main函数之间,到底发生了什么。
2.1 复位后的第一件事:读取向量表
Cortex-M内核和传统的ARM7/ARM9不一样。老架构上电后从0x00000000开始执行第一条指令,但Cortex-M更聪明——它直接从向量表取数据。
芯片复位后,硬件自动做两件事:
- 从地址 0x00000000 读取 栈顶指针(MSP),写入主堆栈指针寄存器
- 从地址 0x00000004 读取 复位向量(Reset_Handler),写入PC寄存器
说白了,硬件帮你把栈和入口地址都准备好了。你想想看,这比老架构省了多少事?
关键点:向量表的前两个字必须是MSP和Reset_Handler。如果这里放错了,芯片根本跑不起来。我在项目中遇到过有人把中断函数地址写到了0x00000000,结果上电直接HardFault。
2.2 向量表的结构
向量表本质上是一个地址数组,每个元素对应一个异常或中断的入口地址。Cortex-M把前16个向量定义为系统异常,从第16个开始是外部中断。
下面这张图展示了标准的向量表布局:
每个向量占4个字节,所以第n个向量的地址就是 0x00000000 + n * 4。比如第15个向量(SysTick)的地址是 0x0000003C。
我的习惯:写启动文件时,我会在向量表末尾留一个哨兵值(比如0xDEADBEEF),用来检测向量表是否被意外覆盖。调试时特别有用。
2.3 堆栈指针初始化
Cortex-M有两个堆栈指针:MSP(主堆栈指针)和PSP(进程堆栈指针)。复位后默认使用MSP。
硬件从0x00000000读取的值直接写入MSP。这个值必须是合法的RAM地址,通常是SRAM的末尾地址。为什么是末尾?因为栈是向下生长的,从高地址往低地址压栈。
举个例子,如果你的芯片有64KB SRAM,起始地址是0x20000000,那么栈顶指针应该设为 0x20000000 + 0x10000 = 0x20010000。
; 典型的启动文件片段(汇编)
__Vectors DCD __initial_sp ; 栈顶指针
DCD Reset_Handler ; 复位向量
DCD NMI_Handler ; NMI
DCD HardFault_Handler ; HardFault
; ... 其他向量 ...
__initial_sp EQU 0x20010000 ; 假设64KB SRAM
我曾经踩过的坑:有一次我把栈顶指针设在了SRAM的中间位置,结果程序跑起来后栈和堆打架,数据被互相覆盖,查了整整一天。记住:栈顶指针一定要指向SRAM的最高地址,除非你有特殊的内存分区需求。
2.4 Reset_Handler 的执行流程
硬件跳转到Reset_Handler后,软件正式接管。这个函数通常由启动文件(startup_xxx.s)提供,主要做三件事:
| 步骤 | 操作 | 说明 |
|---|---|---|
| 1 | 初始化 .bss 段 | 将全局/静态未初始化变量清零 |
| 2 | 初始化 .data 段 | 将已初始化全局变量从Flash复制到RAM |
| 3 | 调用 SystemInit() | 配置系统时钟、PLL、Flash等待周期等 |
| 4 | 跳转到 main() | 进入C语言世界 |
你想想看,如果没有这些初始化,你在main里定义的全局变量全是随机值,那程序根本没法跑。
下面这张流程图展示了完整的启动链路:
2.5 跳转到main函数
初始化完成后,启动文件最后会调用 main()。这里有个细节:Cortex-M的 BL 指令会修改LR寄存器,所以main函数返回后还能回到启动文件。但实际嵌入式应用中,main函数通常是个死循环,不会返回。
看一个典型的启动文件结尾:
; 跳转到main
LDR R0, =main
BLX R0
; 如果main返回,进入死循环
B .
注意那个 B .,意思是跳转到当前地址,也就是死循环。这是为了防止main意外返回后程序跑飞。
我建议:在移植Bootloader时,可以在跳转到main之前加一段自检代码,比如检查CRC、验证向量表完整性。这样即使main出了问题,也能在启动阶段就发现。
2.6 常见问题与避坑
- 栈顶指针不对:如果MSP指向了无效地址,上电直接HardFault。用调试器看SP寄存器的值,确认是否在RAM范围内。
- 向量表偏移:Bootloader跳转到APP前,必须通过VTOR寄存器设置新的向量表地址。我见过有人忘了这步,APP里一发生中断就跑回Bootloader的向量表,死得莫名其妙。
- .bss段未清零:全局变量默认是0,如果启动文件没清零,这些变量就是随机值。调试时打印出来看看,能省很多时间。
- SystemInit缺失:有些芯片的SystemInit里配置了Flash等待周期,如果没调用,Flash读取可能出错,程序跑着跑着就挂了。
我曾经遇到一个案例:某款国产MCU,启动文件里忘了调用SystemInit,结果PLL没配置,芯片跑在内部低速振荡器上。串口打印全是乱码,我还以为是波特率配错了,折腾了两小时才发现是时钟问题。
2.7 小结
启动流程说白了就是:硬件帮你搭好栈和入口,软件负责把C语言环境准备好,最后跳转到main。每一步都很关键,任何一个环节出错,程序都跑不起来。
移植Bootloader时,我建议你先把启动文件从头到尾读一遍,确认向量表、栈指针、段初始化都没问题。这一步做好了,后面的移植工作会顺利很多。