2. 启动流程分析:MCU上电启动流程、向量表与复位处理、从Flash到RAM的代码搬运机制
MCU 上电后到底发生了什么?
很多人写了好几年嵌入式代码,却从没认真看过启动文件。说实话,这其实是个坑。我见过不少同事,项目跑飞了第一反应是查逻辑,结果折腾半天,最后发现是启动配置出了问题。
这一节,我们就把它彻底讲透。
2.1 MCU 上电启动流程
MCU 上电后,硬件会自动执行一套固定的流程。这套流程不依赖你的 C 代码,它由芯片内部逻辑完成。
核心流程:
- 上电复位(POR):内部电压检测电路确认 VDD 稳定后,释放复位信号。
- 系统时钟启动:内部 RC 振荡器起振,提供初始时钟(通常是几 MHz 到十几 MHz)。
- 从固定地址取指:CPU 从
0x00000000读取栈顶指针(MSP),从0x00000004读取复位向量地址。 - 跳转到复位向量: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)就知道了。典型流程是:
- 关闭所有中断(防止初始化过程中被中断打断)
- 初始化 .data 段(从 Flash 复制到 RAM)
- 清零 .bss 段
- 调用
SystemInit()配置系统时钟 - 跳转到
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
这段代码做了三件事:
- 从
_sidata(Flash 地址)开始读 - 写到
_sdata(RAM 地址) - 直到写满
_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"))),然后在链接脚本里定义对应的加载域和运行域。
下面这张图,把整个启动流程串起来了:
这张图把硬件自动流程和软件初始化流程分开了。左边是硬件行为,右边是启动文件行为。做移植时,你只需要关注右边这部分——修改链接脚本和启动文件,让搬运地址对上实际的内存布局。
总结一下关键点:
- 复位向量必须指向有效的代码入口,且地址对齐
- .data 段的 Flash 源地址和 RAM 目标地址由链接脚本定义,启动文件负责搬运
- 如果 Bootloader 需要做 Flash 擦写,务必把擦写函数搬到 RAM 里执行
- 调试启动问题时,先检查向量表内容是否正确,再检查搬运后的 RAM 数据
我记得有一次,客户反馈说他们的设备偶尔启动失败。我远程看了他们的启动文件,发现 .bss 清零循环里用了 strb 指令,但目标地址没对齐,导致总线错误。改成 str 按字写入后,问题就解决了。你看,启动代码里的细节,往往就是稳定性的分水岭。