第二章 MCU启动流程详解:从复位向量到main函数
芯片上电那一刻,到底发生了什么?
很多人写嵌入式代码好几年,却从没认真想过这个问题。我刚开始做Bootloader时也这样——反正编译器帮我搞定了,我只要写main函数就行。直到有一次,我在一个国产MCU上做OTA升级,发现程序死活跑不起来。查了三天,最后发现是数据段拷贝的地址配错了。
从那以后,我养成了一个习惯:必须把启动流程吃透。今天咱们就把这条链路彻底走一遍。
2.1 复位向量——芯片的第一口气
MCU上电后,CPU会去一个固定地址取第一条指令。这个地址,就是复位向量。
| 架构 | 复位向量地址 | 说明 |
|---|---|---|
| Cortex-M3/M4 | 0x00000000 | 栈顶指针 |
| Cortex-M3/M4 | 0x00000004 | 复位中断向量 |
| ARM7/9 | 0x00000000 | 直接跳转指令 |
| RISC-V | 0x00000000 / 0x80000000 | 可配置 |
以Cortex-M系列为例,芯片上电后,硬件自动做两件事:
- 从0x00000000读取栈顶指针,写入SP寄存器
- 从0x00000004读取复位向量地址,写入PC寄存器
说白了,就是先搭好栈,再跳过去执行。这个设计很巧妙——你想想看,如果连栈都没有,函数怎么调用?
关键点:复位向量里存的不是代码,而是地址。这个地址指向的是Reset_Handler函数。
2.2 Reset_Handler——真正的启动总管
CPU跳进Reset_Handler后,才开始执行我们熟悉的启动代码。这段代码通常由芯片厂商提供,放在启动文件(startup_xxx.s)里。
它的核心任务有三个:
- 初始化堆栈——给C语言运行时搭好舞台
- 清零BSS段——把未初始化的全局变量置0
- 拷贝数据段——把初始化的全局变量从Flash搬到RAM
我见过不少工程师,在BSS段清零上栽过跟头。有一次,一个同事在Bootloader里加了个大数组,没注意BSS段大小,结果清零时把栈区给覆盖了。程序一跑就死,查了两天才找到原因。
避坑指南:我曾经在STM32F103上遇到过一个问题——BSS段和栈区紧挨着,如果BSS段大小算错了,清零操作会直接破坏栈空间。建议大家在链接脚本里给BSS段和栈区之间留点余量。
2.3 堆栈初始化——C语言的生存基础
堆栈初始化,说白了就是告诉CPU:你的栈顶在哪,堆从哪里开始。
在启动文件里,你会看到类似这样的代码:
; 栈配置
Stack_Size EQU 0x00000400
AREA STACK, NOINIT, READWRITE, ALIGN=3
Stack_Mem SPACE Stack_Size
__initial_sp
; 堆配置
Heap_Size EQU 0x00000200
AREA HEAP, NOINIT, READWRITE, ALIGN=3
__heap_base
Heap_Mem SPACE Heap_Size
__heap_limit
这里有几个关键点:
__initial_sp——栈顶指针,复位向量里用的就是它__heap_base——堆起始地址__heap_limit——堆结束地址
我个人习惯把栈大小设为至少1KB,堆大小根据实际需求来。如果用到malloc,堆就得留够;如果只用静态分配,堆甚至可以设为0。
小技巧:在调试阶段,可以在栈区填充0xDEADBEEF之类的魔数。程序跑一段时间后检查栈空间,看看有没有被踩到。这个方法帮我抓过好几次栈溢出。
2.4 BSS段清零——别让全局变量带着垃圾值跑
C语言标准规定:未初始化的全局变量和静态变量,默认值为0。这个"默认值0"不是硬件给的,是启动代码清零的结果。
BSS段清零的典型实现:
; 清零BSS段
LDR R0, =__bss_start
LDR R1, =__bss_end
MOVS R2, #0
CMP R0, R1
BEQ .L_loop_bss_done
.L_loop_bss:
STR R2, [R0], #4
CMP R0, R1
BCC .L_loop_bss
.L_loop_bss_done:
这段代码的逻辑很简单:从__bss_start到__bss_end,每个字(4字节)写0。
但有个坑要注意——如果BSS段很大,清零会占用不少时间。我在一个项目里遇到过BSS段超过100KB的情况,清零花了将近10ms。对于实时性要求高的系统,这个时间不能忍。
避坑指南:我曾经在一个Bootloader项目里,把BSS段清零放在中断使能之后。结果清零过程中来了个中断,中断服务函数里访问了一个还没清零的全局变量……嗯,后果你懂的。建议:先清零,再开中断。
2.5 数据段拷贝——把初始值从Flash搬到RAM
初始化了的全局变量,比如int count = 100;,初始值存在Flash里。但运行时,这些变量必须在RAM里才能读写。所以启动代码要把初始值从Flash拷贝到RAM。
数据段拷贝的典型实现:
; 拷贝数据段
LDR R0, =__data_load
LDR R1, =__data_start
LDR R2, =__data_end
CMP R1, R2
BEQ .L_loop_data_done
.L_loop_data:
LDR R3, [R0], #4
STR R3, [R1], #4
CMP R1, R2
BCC .L_loop_data
.L_loop_data_done:
这里要注意三个符号:
__data_load——数据在Flash中的存储位置__data_start——数据在RAM中的目标起始地址__data_end——数据在RAM中的目标结束地址
你想想看,如果__data_load和__data_start指向同一个地址,那就不需要拷贝了。有些链接器支持这种优化,叫"ROM化数据段"。
关键点:数据段拷贝的源地址和目标地址,都是在链接脚本里定义的。链接脚本写错了,拷贝就会出问题。我建议大家在调试时,先打印这三个地址,确认一下。
2.6 完整的启动链路图
下面这张图,把整个启动流程串起来了:
2.7 链接脚本——启动流程的幕后导演
上面提到的所有符号(__bss_start、__data_load等),都是在链接脚本里定义的。没有链接脚本,启动代码就是无头苍蝇。
一个典型的链接脚本片段:
SECTIONS
{
.isr_vector : {
. = ALIGN(4);
KEEP(*(.isr_vector))
. = ALIGN(4);
} > FLASH
.text : {
. = ALIGN(4);
*(.text*)
. = ALIGN(4);
} > FLASH
.data : {
. = ALIGN(4);
__data_start = .;
*(.data*)
__data_end = .;
} > RAM AT > FLASH
__data_load = LOADADDR(.data);
.bss : {
. = ALIGN(4);
__bss_start = .;
*(.bss*)
__bss_end = .;
} > RAM
}
注意.data段后面的AT > FLASH——它告诉链接器:这个段运行时在RAM,但初始数据存在Flash里。链接器会自动生成__data_load这个符号,指向Flash中的存储位置。
小技巧:我习惯在链接脚本里加上__stack_top和__heap_size这样的符号,方便启动代码直接引用。这样改栈大小时,只需要改链接脚本,不用动汇编代码。
2.8 常见启动失败场景与排查方法
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 上电后无反应 | 复位向量地址错误 | 检查.map文件,确认复位向量位置 |
| 程序跑飞 | 栈指针未正确初始化 | 调试器查看SP寄存器值 |
| 全局变量值不对 | 数据段拷贝失败 | 比较Flash和RAM中对应地址的数据 |
| 全局变量非零 | BSS段未清零 | 检查__bss_start和__bss_end地址 |
| 中断不响应 | 中断向量表未正确映射 | 确认VTOR寄存器设置 |
嗯,这张表基本覆盖了我这些年遇到的大部分启动问题。每次排查,我都会先看复位向量,再看栈指针,最后查数据段和BSS段。按这个顺序来,90%的问题都能定位到。
总结一下:MCU启动流程,说白了就是硬件搭台,软件唱戏。硬件负责把CPU引导到正确的位置,软件负责把C语言的运行环境搭好。每一步都环环相扣,任何一个环节出问题,main函数都跑不起来。
下一章,咱们会深入Bootloader的架构设计。但在此之前,我建议你把本章的启动流程在自己手头的板子上跑一遍,用调试器单步跟踪,亲眼看看每一步是怎么执行的。相信我,这个习惯会让你少踩很多坑。