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

MCU 上电后到底干了什么?很多人觉得就是「跑 main()」。其实没那么简单。从复位引脚拉高,到第一条用户指令执行,中间藏着不少门道。我调试 Bootloader 时,有七成的问题都出在这个阶段——要么向量表配错了,要么代码没搬完就跑飞了。

咱们一步步拆开看。

2.1 上电复位流程:从硬件到软件的接力

按下复位键,或者芯片刚上电,MCU 内部会走一套固定的流程。我把它分成三个阶段:

阶段 谁在干活 干了什么 典型耗时
1. 电源稳定 POR / BOR 电路 检测 VDD 是否达到工作阈值 几十 μs ~ 几 ms
2. 时钟就绪 内部 RC 振荡器 / PLL 等待主时钟稳定输出 几百 μs ~ 几 ms
3. 取复位向量 CPU 内核 从向量表偏移 0x0000_0004 读取 PC 初值 几个时钟周期

前两个阶段是硬件自动完成的,你改不了。但第三个阶段——取复位向量——是软件介入的起点。这里有个坑:如果向量表地址配错了,CPU 会从错误的地方取 PC,直接跑飞。我在 STM32F4 上遇到过,链接脚本里把 VECT_TAB_OFFSET 设成了 0x200,结果复位后 PC 指向了 RAM 区,里面全是随机值……板子直接死掉。

关键点:复位后 CPU 固定从 0x0000_0000 取 MSP(主堆栈指针),从 0x0000_0004 取 PC。这是 Cortex-M 内核的硬连线行为,任何 MCU 都一样。

2.2 向量表与复位向量:CPU 的第一张地图

向量表说白了就是一张「中断服务函数地址表」。CPU 遇到异常或中断时,会查这张表,找到对应的处理函数跳过去。复位是优先级最高的异常,它的入口就是复位向量。

以 Cortex-M3/M4 为例,向量表布局长这样:

; 向量表示例(汇编)
__Vectors       DCD     __initial_sp          ; 0x0000_0000: MSP 初值
                DCD     Reset_Handler         ; 0x0000_0004: 复位向量
                DCD     NMI_Handler           ; 0x0000_0008: NMI
                DCD     HardFault_Handler     ; 0x0000_000C: 硬 fault
                ; ... 后面跟着其他异常和中断

注意看:向量表的第一项不是代码,是栈指针初值。CPU 复位后先加载 MSP,再跳转到 Reset_Handler。如果 MSP 给错了,一进中断就栈溢出,连 HardFault 都跑不进去。

我的习惯:在链接脚本里显式指定向量表起始地址,并在 startup 文件中放一个 .isr_vector 段。这样不管怎么优化,向量表都不会被挪走。

实际项目中,Bootloader 和 App 各有各的向量表。Bootloader 的向量表在 Flash 起始地址,App 的向量表在偏移地址(比如 0x0801_0000)。切换时需要通过 SCB->VTOR 寄存器告诉 CPU:「嘿,向量表搬家了」。我见过有人忘了改 VTOR,App 里一开中断就 HardFault——因为 CPU 还在查 Bootloader 的向量表。

2.3 从 Flash 到 RAM 的代码搬运:谁搬?搬什么?

代码为什么要搬?因为 Flash 慢,RAM 快。尤其是频繁调用的函数、中断服务程序,放在 Flash 里跑会拖慢系统。所以 Bootloader 和 App 启动时,通常会把 .text(代码段)和 .data(已初始化全局变量)从 Flash 搬到 RAM。

搬运工作由 Reset_Handler 里的启动代码完成。流程如下:

  1. 清零 .bss 段:把未初始化全局变量所在的 RAM 区域全部写 0。
  2. 搬运 .data 段:从 Flash 的 _sidata 复制到 RAM 的 _sdata,长度 _edata - _sdata。
  3. 初始化堆栈:设置堆指针 __heap_start 和栈限制 __stack_limit。
  4. 调用 main():跳转到 C 世界的入口。

这段代码通常写在 startup_xxx.s 里,用汇编实现。我贴一段典型的搬运逻辑:

; .data 段搬运(汇编)
    ldr     r0, =_sidata       ; Flash 源地址
    ldr     r1, =_sdata        ; RAM 目标地址
    ldr     r2, =_edata        ; RAM 结束地址
    subs    r2, r1             ; 计算长度
    beq     .L_loop_done       ; 长度为 0 则跳过
.L_loop:
    ldrb    r3, [r0], #1       ; 从 Flash 读一个字节
    strb    r3, [r1], #1       ; 写入 RAM
    subs    r2, #1
    bne     .L_loop
.L_loop_done:

我曾经踩过的坑:某次把 _sidata 和 _sdata 在链接脚本里写反了,结果搬运时把 RAM 里的数据当源地址,Flash 里的数据当目标地址。一上电,Flash 内容被覆盖,Bootloader 直接变砖。从那以后,我每次都会在搬运前后加 CRC 校验。

2.4 整体流程可视化

下面这张图把上电到 main() 的完整路径画出来了。你可以对照着看,每一步对应哪个阶段。

MCU 上电复位 → main() 完整流程 1. 上电 / 复位 POR 检测 → 时钟就绪 2. 取复位向量 0x0000_0000 → MSP 0x0000_0004 → PC 3. Reset_Handler 汇编启动代码 清零 .bss 搬运 .data 初始化堆栈 4. main() C 世界入口 ⚠️ 常见问题 • 向量表偏移(VTOR)未更新 → 中断跑飞 • .data 搬运源/目标地址写反 → Flash 被覆盖 • .bss 未清零 → 全局变量初值随机 • MSP 初值错误 → 一进中断就栈溢出

2.5 避坑指南:启动阶段最容易翻车的三个点

  • 向量表对齐:Cortex-M 要求向量表按 512 字节对齐(0x200 的倍数)。如果偏移地址没对齐,VTOR 写不进去,CPU 会忽略你的设置。我习惯在链接脚本里加 ALIGN(512) 强制对齐。
  • 搬运长度别算错:链接脚本里 _sdata、_edata、_sidata 这三个符号,任何一个写错都会导致搬运异常。建议编译后查看 map 文件,确认各段的起始和结束地址。
  • 中断使能时机:在 .bss 清零和 .data 搬运完成之前,绝对不要开全局中断。否则中断一来,访问到的全局变量可能是垃圾值。Reset_Handler 里默认关中断,但如果你在 startup 里提前调了 __enable_irq()……嗯,后果自负。

一个小技巧:在搬运完成后、跳转到 main() 之前,加一段自检代码——检查 .data 段的前几个字节是否与 Flash 中的源数据一致。如果不对,直接进错误处理循环。这个习惯帮我抓到了三次链接脚本配置错误。