第2章 启动流程与复位向量表:ARM Cortex-M/M-A复位序列、向量表重定位、栈指针初始化、跳转到main()之前的准备工作

说实话,很多做嵌入式开发的朋友,写了几年代码,却从来没仔细看过芯片上电后到底发生了什么。我见过不少工程师,遇到程序跑飞、启动异常,第一反应就是怀疑编译器优化或者硬件坏了。其实,大部分启动问题都出在复位序列和向量表配置上。

这一章,我们就来把ARM Cortex-M和Cortex-M/A的启动流程彻底讲透。你想想看,芯片从复位到执行你的main()函数,中间这几十微秒到底干了什么?搞懂了这些,以后调试Bootloader、定位启动故障,你心里就有底了。

核心认知: 复位向量表是CPU的"第一反应"。CPU复位后,硬件自动从向量表首地址加载栈指针(MSP)和复位向量(Reset_Handler入口地址)。向量表配错了,CPU连第一步都走不出去。

2.1 ARM Cortex-M复位序列:硬件自动完成的"三连击"

Cortex-M系列(M0/M3/M4/M7等)的复位序列非常固定。硬件复位后,CPU自动做三件事,不需要任何软件干预:

  1. 读取栈指针:从地址 0x00000000 加载主栈指针(MSP)初始值
  2. 读取复位向量:从地址 0x00000004 加载复位中断服务函数地址
  3. 跳转执行:CPU直接跳转到该地址开始执行代码

嗯,这里要注意:Cortex-M的复位向量表里,第一个字是栈指针,第二个字才是复位入口。这和传统的ARM7/ARM9不一样,传统ARM架构复位后从 0x00000000 取第一条指令。我刚开始从ARM9转到Cortex-M3时,就因为这个差异踩过坑——把复位向量直接写成了指令,结果CPU一上电就飞了。

个人习惯: 我每次新建工程,都会先检查链接脚本中向量表的起始地址是否正确。对于Cortex-M,向量表必须4字节对齐,且起始地址的低2位必须为0。

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的向量表找入口,那不乱套了吗?

避坑指南: 我曾经在STM32F4上调试一个Bootloader,应用程序死活不进中断。查了两天才发现,应用程序的启动代码里没有重定位VTOR。Cortex-M0/M0+没有VTOR寄存器,向量表必须固定在 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个字节。

关键检查点: 跳转到main()之前,必须确保:
  • 栈指针指向有效的RAM区域
  • 所有全局变量已正确初始化
  • 系统时钟已配置到目标频率
  • 中断处于关闭状态(除非你有特殊需求)

2.5 ARM Cortex-A复位序列:更复杂的"多级跳转"

Cortex-A系列(A7/A8/A9/A53等)的复位序列比Cortex-M复杂得多。A系列通常运行Linux或大型RTOS,启动流程涉及MMU、Cache、异常向量表等多个子系统。

Cortex-A复位后的典型流程:

  1. 从复位向量取指令:A系列没有向量表的概念,复位后直接从 0x00000000 或 0xFFFF0000(由SCTLR.V位决定)取第一条指令
  2. 设置异常向量表基址:通过 VBAR 寄存器设置异常向量表地址
  3. 初始化栈指针:分别设置SVC、IRQ、FIQ、ABT、UND等模式下的栈指针
  4. 关闭MMU和Cache:在初始化阶段,通常先关闭MMU和Cache,使用物理地址访问
  5. 初始化SDRAM控制器:配置DDR时序、频率等
  6. 搬运代码:将代码从Flash/NOR/NAND搬运到SDRAM中
  7. 跳转到main():设置好C运行环境后,跳转到主函数

我在调试i.MX6ULL的Bootloader时,就遇到过一个问题:SDRAM初始化时序不对,导致代码搬运到DDR后执行出错。这种问题很难定位,因为CPU在Flash里跑得好好的,一跳到DDR就挂了。后来我用示波器量了DDR的时钟和数据线,才发现时钟频率配置高了50MHz。

经验之谈: Cortex-A的启动代码,建议分阶段调试。先让CPU在内部SRAM里跑通,再逐步使能外部SDRAM、MMU、Cache。每一步都确认无误后,再进行下一步。不要试图一步到位,否则出了问题你都不知道从哪查起。

2.6 启动流程全景图

下面这张图,把Cortex-M和Cortex-A的启动流程放在一起对比,方便你理解两者的异同:

ARM Cortex-M vs Cortex-A 启动流程对比 Cortex-M 复位序列 ① 硬件加载MSP(0x00000000) ② 加载Reset_Handler地址(0x00000004) ③ 跳转到Reset_Handler执行 ④ 初始化BSS/DATA段 ⑤ 配置系统时钟/FPU ⑥ 跳转到main() Cortex-A 复位序列 ① 从0x00000000取第一条指令 ② 设置异常向量表基址(VBAR) ③ 初始化各模式栈指针 ④ 关闭MMU/Cache ⑤ 初始化SDRAM控制器 ⑥ 搬运代码到SDRAM → main() 关键区别:Cortex-M硬件自动加载向量表,Cortex-A需要软件手动设置异常向量表基址 Cortex-M启动更简单、更安全;Cortex-A启动更灵活,但需要开发者做更多初始化工作

2.7 常见启动故障与定位方法

搞启动调试,说白了就是和CPU的"第一口奶"较劲。我总结了几种最常见的启动故障,以及对应的定位思路:

故障现象 可能原因 定位方法
上电后无任何反应 复位电路异常、电源不稳、晶振不起振 示波器测复位引脚、电源纹波、晶振波形
进入HardFault 栈指针错误、访问非法地址、中断向量表偏移 查看LR寄存器、堆栈回溯、检查VTOR值
全局变量值不对 BSS/DATA段初始化异常、链接脚本错误 查看map文件、检查链接脚本中的段地址
中断不响应 向量表未重定位、中断使能未开、优先级配置错 检查VTOR、NVIC配置、中断服务函数地址
代码搬运后跑飞 SDRAM时序不对、Cache一致性、MMU配置错 单步调试、检查SDRAM控制器寄存器、关闭Cache测试
我曾经踩过的坑: 有一次调试STM32H7的Bootloader,应用程序在内部Flash里跑得好好的,搬到外部QSPI Flash后就死机。查了三天,最后发现是QSPI的指令缓存(ICache)没有刷新。代码搬完后,CPU读到的还是缓存里的旧指令。解决办法很简单:搬运完成后,执行一次ICache无效化操作。

嗯,启动流程这部分内容,看起来简单,但实际调试中坑特别多。我建议你每做一个新项目,都花点时间把启动代码从头到尾读一遍,确认每个步骤都正确。尤其是向量表地址、栈指针初始值、BSS/DATA段初始化这三个关键点,出错的概率最高。