第2章 启动流程与复位向量表:ARM Cortex-M/M-A复位序列、向量表重定位、栈指针初始化、跳转到main()之前的准备工作
说实话,很多做嵌入式开发的朋友,写了几年代码,却从来没仔细看过芯片上电后到底发生了什么。我见过不少工程师,遇到程序跑飞、启动异常,第一反应就是怀疑编译器优化或者硬件坏了。其实,大部分启动问题都出在复位序列和向量表配置上。
这一章,我们就来把ARM Cortex-M和Cortex-M/A的启动流程彻底讲透。你想想看,芯片从复位到执行你的main()函数,中间这几十微秒到底干了什么?搞懂了这些,以后调试Bootloader、定位启动故障,你心里就有底了。
2.1 ARM Cortex-M复位序列:硬件自动完成的"三连击"
Cortex-M系列(M0/M3/M4/M7等)的复位序列非常固定。硬件复位后,CPU自动做三件事,不需要任何软件干预:
- 读取栈指针:从地址
0x00000000加载主栈指针(MSP)初始值 - 读取复位向量:从地址
0x00000004加载复位中断服务函数地址 - 跳转执行:CPU直接跳转到该地址开始执行代码
嗯,这里要注意:Cortex-M的复位向量表里,第一个字是栈指针,第二个字才是复位入口。这和传统的ARM7/ARM9不一样,传统ARM架构复位后从 0x00000000 取第一条指令。我刚开始从ARM9转到Cortex-M3时,就因为这个差异踩过坑——把复位向量直接写成了指令,结果CPU一上电就飞了。
2.2 向量表的结构与重定位
向量表本质上是一个函数指针数组。Cortex-M支持最多256个中断,每个中断占用4字节。所以完整的向量表占用1KB空间(256 × 4字节)。
| 偏移地址 | 异常/中断号 | 内容 | 说明 |
|---|---|---|---|
| 0x00 | - | MSP初始值 | 主栈指针,由链接脚本或启动文件定义 |
| 0x04 | Reset | Reset_Handler | 复位中断入口,CPU上电后第一个执行的函数 |
| 0x08 | NMI | NMI_Handler | 不可屏蔽中断 |
| 0x0C | HardFault | HardFault_Handler | 硬错误,最常见的是访问非法地址 |
| 0x10 - 0x3FC | 其他异常/中断 | 对应中断服务函数 | SysTick、外设中断等 |
向量表重定位是什么意思?说白了,就是告诉CPU:向量表不在 0x00000000 了,挪到别的地方去了。Cortex-M3/M4/M7提供了 VTOR(Vector Table Offset Register)寄存器,你可以把向量表放在Flash的任何位置,甚至放在RAM里。
我在做Bootloader升级方案时,就经常用到向量表重定位。Bootloader在Flash的起始地址,应用程序在后面的地址。应用程序启动前,必须把 VTOR 指向自己的向量表地址,否则中断来了CPU还是去Bootloader的向量表找入口,那不乱套了吗?
0x00000000,所以Bootloader方案要更复杂一些。
2.3 栈指针初始化:别让CPU"无家可归"
栈指针初始化是启动流程中最容易被忽视的一环。CPU复位后,硬件自动从向量表第一个字加载MSP。如果这个值指向了不存在的RAM地址,或者指向了代码区,那第一个函数调用就会触发HardFault。
正确的做法是:在链接脚本中定义栈顶地址,然后在启动文件的向量表中引用这个地址。以GCC工具链为例:
/* 链接脚本片段 */
_estack = ORIGIN(RAM) + LENGTH(RAM); /* 栈顶指向RAM最高地址 */
/* 启动文件片段 */
.section .isr_vector, "a"
.globl g_pfnVectors
g_pfnVectors:
.word _estack /* 栈指针初始值 */
.word Reset_Handler /* 复位向量 */
.word NMI_Handler
.word HardFault_Handler
/* ... 其他中断向量 ... */
我个人习惯把栈顶放在RAM的最高地址,这样栈向下增长时,不会轻易和堆区、全局变量区冲突。当然,如果你的RAM很小,或者有特殊的内存布局需求,也可以把栈放在中间位置,但一定要保证栈空间足够大。
2.4 跳转到main()之前的"幕后工作"
从Reset_Handler入口到main()函数,中间还有几件重要的事要做。这些事通常由启动文件(startup_xxx.s)或C运行时库(__main)完成:
- 初始化BSS段:清零未初始化的全局变量和静态变量
- 初始化DATA段:将Flash中的初始值复制到RAM中
- 初始化堆:如果使用了malloc,需要设置堆的起始地址和大小
- 调用构造函数:C++全局对象的构造函数
- 设置系统时钟:配置PLL、分频器等,让CPU跑在目标频率
- 初始化FPU:如果芯片带浮点单元,需要使能并设置工作模式
你想想看,如果BSS段没清零,一个未初始化的全局变量可能带着随机值,程序逻辑直接就错了。我调试过一个案例,产品偶尔开机死机,查了半个月,最后发现是启动文件里BSS段长度计算有误,少清零了4个字节。
- 栈指针指向有效的RAM区域
- 所有全局变量已正确初始化
- 系统时钟已配置到目标频率
- 中断处于关闭状态(除非你有特殊需求)
2.5 ARM Cortex-A复位序列:更复杂的"多级跳转"
Cortex-A系列(A7/A8/A9/A53等)的复位序列比Cortex-M复杂得多。A系列通常运行Linux或大型RTOS,启动流程涉及MMU、Cache、异常向量表等多个子系统。
Cortex-A复位后的典型流程:
- 从复位向量取指令:A系列没有向量表的概念,复位后直接从
0x00000000或0xFFFF0000(由SCTLR.V位决定)取第一条指令 - 设置异常向量表基址:通过
VBAR寄存器设置异常向量表地址 - 初始化栈指针:分别设置SVC、IRQ、FIQ、ABT、UND等模式下的栈指针
- 关闭MMU和Cache:在初始化阶段,通常先关闭MMU和Cache,使用物理地址访问
- 初始化SDRAM控制器:配置DDR时序、频率等
- 搬运代码:将代码从Flash/NOR/NAND搬运到SDRAM中
- 跳转到main():设置好C运行环境后,跳转到主函数
我在调试i.MX6ULL的Bootloader时,就遇到过一个问题:SDRAM初始化时序不对,导致代码搬运到DDR后执行出错。这种问题很难定位,因为CPU在Flash里跑得好好的,一跳到DDR就挂了。后来我用示波器量了DDR的时钟和数据线,才发现时钟频率配置高了50MHz。
2.6 启动流程全景图
下面这张图,把Cortex-M和Cortex-A的启动流程放在一起对比,方便你理解两者的异同:
2.7 常见启动故障与定位方法
搞启动调试,说白了就是和CPU的"第一口奶"较劲。我总结了几种最常见的启动故障,以及对应的定位思路:
| 故障现象 | 可能原因 | 定位方法 |
|---|---|---|
| 上电后无任何反应 | 复位电路异常、电源不稳、晶振不起振 | 示波器测复位引脚、电源纹波、晶振波形 |
| 进入HardFault | 栈指针错误、访问非法地址、中断向量表偏移 | 查看LR寄存器、堆栈回溯、检查VTOR值 |
| 全局变量值不对 | BSS/DATA段初始化异常、链接脚本错误 | 查看map文件、检查链接脚本中的段地址 |
| 中断不响应 | 向量表未重定位、中断使能未开、优先级配置错 | 检查VTOR、NVIC配置、中断服务函数地址 |
| 代码搬运后跑飞 | SDRAM时序不对、Cache一致性、MMU配置错 | 单步调试、检查SDRAM控制器寄存器、关闭Cache测试 |
嗯,启动流程这部分内容,看起来简单,但实际调试中坑特别多。我建议你每做一个新项目,都花点时间把启动代码从头到尾读一遍,确认每个步骤都正确。尤其是向量表地址、栈指针初始值、BSS/DATA段初始化这三个关键点,出错的概率最高。