2. 启动流程分析:MCU上电启动流程、向量表与复位处理、从Flash到RAM的代码搬运机制

MCU 上电后到底发生了什么?

很多人写了好几年嵌入式代码,却从没认真看过启动文件。说实话,这其实是个坑。我见过不少同事,项目跑飞了第一反应是查逻辑,结果折腾半天,最后发现是启动配置出了问题。

这一节,我们就把它彻底讲透。

2.1 MCU 上电启动流程

MCU 上电后,硬件会自动执行一套固定的流程。这套流程不依赖你的 C 代码,它由芯片内部逻辑完成。

核心流程:

  1. 上电复位(POR):内部电压检测电路确认 VDD 稳定后,释放复位信号。
  2. 系统时钟启动:内部 RC 振荡器起振,提供初始时钟(通常是几 MHz 到十几 MHz)。
  3. 从固定地址取指:CPU 从 0x00000000 读取栈顶指针(MSP),从 0x00000004 读取复位向量地址。
  4. 跳转到复位向量:CPU 跳转到复位向量指向的地址,开始执行启动代码。

嗯,这里要注意:不同架构的 MCU 取指地址可能不同。ARM Cortex-M 系列固定从 0x00000000 开始,而一些 RISC-V 内核可能从 0x80000000 开始。做移植时,第一件事就是查芯片手册的「Boot Sequence」章节。

我个人习惯,拿到一块新板子,先看启动流程的时序图。它能告诉你:复位信号维持多久、时钟稳定需要多少毫秒、Flash 初始化完成前 CPU 是否处于 Halt 状态。这些细节,在调试硬件启动失败时非常有用。

2.2 向量表与复位处理

向量表,说白了就是一张「中断服务函数地址表」。CPU 发生异常或中断时,硬件会自动从这张表里取出对应的处理函数地址,跳过去执行。

以 ARM Cortex-M4 为例,向量表的前 16 个是系统异常,从第 17 个开始是外部中断。

偏移地址 异常类型 优先级 说明
0x0000_0000 栈顶指针(MSP) - 复位后 SP 的初始值
0x0000_0004 复位(Reset) -3(最高) 上电或复位后第一条指令入口
0x0000_0008 NMI -2 不可屏蔽中断
0x0000_000C 硬错误(HardFault) -1 未处理的异常都会落在这里
0x0000_0010 MemManage 可编程 MPU 访问违例
0x0000_0014 总线错误(BusFault) 可编程 指令或数据访问异常
0x0000_0018 用法错误(UsageFault) 可编程 未定义指令、对齐错误等

我曾经踩过的坑: 有一次移植 Bootloader,发现复位后程序直接跑飞。查了半天,原来是向量表地址没对齐。ARM Cortex-M 要求向量表必须 512 字节对齐(即地址低 9 位为 0)。我把向量表放在了 0x0800_0200,结果硬件取复位向量时读到了错误的数据。

从那以后,我每次分配向量表地址,都会用 __attribute__((aligned(512))) 强制对齐。

复位处理函数里做了什么?

看启动文件(比如 startup_stm32f4xx.s)就知道了。典型流程是:

  1. 关闭所有中断(防止初始化过程中被中断打断)
  2. 初始化 .data 段(从 Flash 复制到 RAM)
  3. 清零 .bss 段
  4. 调用 SystemInit() 配置系统时钟
  5. 跳转到 main()

你想想看,如果 .data 段没搬运成功,全局变量初始值就是乱的。程序跑起来,行为完全不可预测。

2.3 从 Flash 到 RAM 的代码搬运机制

为什么要把代码从 Flash 搬到 RAM?

两个原因:

  • 速度:Flash 读取通常有等待周期(比如 5 个等待周期 @168MHz),而 RAM 是零等待。把频繁调用的函数放到 RAM 里,性能提升很明显。
  • 可写性:Flash 不能直接修改代码段,而 RAM 可以。Bootloader 做固件升级时,需要把新代码写入 Flash,这时候升级程序本身必须在 RAM 里运行。

搬运机制的核心,就是启动文件里的那段汇编代码。我把它拆解一下:

; 伪代码示意 - 实际启动文件中的 .data 段搬运
ldr   r0, =_sidata    ; Flash 中 .data 的源起始地址
ldr   r1, =_sdata     ; RAM 中 .data 的目标起始地址
ldr   r2, =_edata     ; RAM 中 .data 的结束地址

copy_loop:
  cmp   r1, r2
  itt   lo
  ldrlo r3, [r0], #4  ; 从 Flash 读 4 字节
  strlo r3, [r1], #4  ; 写入 RAM
  blo   copy_loop

这段代码做了三件事:

  1. 从 _sidata(Flash 地址)开始读
  2. 写到 _sdata(RAM 地址)
  3. 直到写满 _edata - _sdata 字节

这些 _sidata、_sdata 符号哪里来的?

链接脚本(.ld 或 .icf 文件)里定义的。比如:

/* 链接脚本片段 */
.data : ALIGN(4)
{
  _sdata = .;        /* RAM 中 .data 起始地址 */
  *(.data*)          /* 所有 .data 段数据 */
  _edata = .;        /* RAM 中 .data 结束地址 */
} > RAM AT> FLASH

_sidata = LOADADDR(.data);  /* Flash 中 .data 的存储地址 */

我的个人经验: 做 Bootloader 时,我习惯把搬运代码单独放在一个段里,比如 .ramfunc。然后在链接脚本里把这个段定位到 RAM,并在启动文件中显式搬运它。这样,Flash 擦写函数就能在 RAM 里安全执行,不会因为读 Flash 而卡死。

具体做法:在函数前加 __attribute__((section(".ramfunc"))),然后在链接脚本里定义对应的加载域和运行域。

下面这张图,把整个启动流程串起来了:

MCU 上电启动完整流程 1. 上电复位(POR) 2. 内部 RC 振荡器起振 3. 从 0x0000_0000 取 MSP 4. 从 0x0000_0004 取复位向量 5. 跳转到 Reset_Handler 搬运 .data 到 RAM 清零 .bss 段 调用 SystemInit() 跳转到 main() 注意点 不同 MCU 复位时序不同 向量表必须 512 字节对齐 .data 搬运依赖链接脚本

这张图把硬件自动流程和软件初始化流程分开了。左边是硬件行为,右边是启动文件行为。做移植时,你只需要关注右边这部分——修改链接脚本和启动文件,让搬运地址对上实际的内存布局。

总结一下关键点:

  • 复位向量必须指向有效的代码入口,且地址对齐
  • .data 段的 Flash 源地址和 RAM 目标地址由链接脚本定义,启动文件负责搬运
  • 如果 Bootloader 需要做 Flash 擦写,务必把擦写函数搬到 RAM 里执行
  • 调试启动问题时,先检查向量表内容是否正确,再检查搬运后的 RAM 数据

我记得有一次,客户反馈说他们的设备偶尔启动失败。我远程看了他们的启动文件,发现 .bss 清零循环里用了 strb 指令,但目标地址没对齐,导致总线错误。改成 str 按字写入后,问题就解决了。你看,启动代码里的细节,往往就是稳定性的分水岭。