2. 嵌入式启动流程基础
芯片上电那一刻,PC指针到底指向哪里?
很多初学者以为程序从main函数开始跑。其实不是。在main之前,MCU已经完成了一大堆初始化工作。我刚开始做嵌入式那会儿,也天真地以为startup.s就是个摆设,直到一次调试死活进不了main,才老老实实把启动文件啃了一遍。
核心认知:MCU启动流程 = 硬件复位 → 引导程序(Boot) → 启动文件(startup.s) → C运行时初始化 → main函数
2.1 复位后的第一件事:向量表
ARM Cortex-M系列复位后,硬件自动做三件事:
- 从地址 0x00000000 读取栈顶指针(MSP初始值)
- 从地址 0x00000004 读取复位向量(Reset_Handler入口地址)
- 跳转到Reset_Handler执行
说白了,这就是一张硬编码的跳转表。芯片出厂就规定好了,谁也别想改。
| 偏移地址 | 内容 | 说明 |
|---|---|---|
| 0x00000000 | 栈顶地址 | 初始MSP值,通常指向SRAM末尾 |
| 0x00000004 | Reset_Handler | 复位后第一条指令入口 |
| 0x00000008 | NMI_Handler | 不可屏蔽中断 |
| 0x0000000C | HardFault_Handler | 硬错误处理 |
| ... | ... | 其他异常/中断向量 |
我曾经踩过的坑:有一次把向量表放到了0x08000000(Flash起始),但忘了配置VTOR寄存器。结果芯片复位后还是从0x00000000取向量,直接跑飞。后来才意识到,Cortex-M3/M4默认从0x00000000取向量,除非你显式重映射。
2.2 启动文件(startup.s)到底干了什么
启动文件是汇编写的,每个MCU厂商都会提供一份。我习惯把它拆成四个阶段来看:
阶段一:定义向量表
用汇编的 .word 伪指令,把各个中断服务函数的地址按顺序排列好。注意,第一个必须是栈顶地址,第二个必须是Reset_Handler。
; startup_stm32f103xe.s 片段
__Vectors DCD __initial_sp ; 栈顶指针
DCD Reset_Handler ; 复位向量
DCD NMI_Handler ; NMI
DCD HardFault_Handler ; 硬错误
DCD MemManage_Handler ; MPU错误
DCD BusFault_Handler ; 总线错误
DCD UsageFault_Handler ; 用法错误
; ... 后面还有几十个中断向量
阶段二:堆栈初始化
这里有个细节——堆和栈是两回事。栈用于函数调用、局部变量;堆用于动态内存分配(malloc)。启动文件里会定义两个符号:
; 堆栈大小定义
Stack_Size EQU 0x00000400 ; 1KB栈
AREA STACK, NOINIT, READWRITE, ALIGN=3
Stack_Mem SPACE Stack_Size
__initial_sp
Heap_Size EQU 0x00000200 ; 512B堆
AREA HEAP, NOINIT, READWRITE, ALIGN=3
__heap_base
Heap_Mem SPACE Heap_Size
__heap_limit
我个人习惯把栈设大一点,至少2KB起步。堆嘛,如果不用malloc,直接设0就行。省得浪费RAM。
阶段三:数据段和BSS段处理
这是C运行时初始化的核心。启动文件要干三件事:
- 拷贝.data段: 把Flash里初始化的全局变量搬到SRAM
- 清零.bss段: 把未初始化的全局变量区域全部填0
- 初始化堆: 如果用了malloc,需要调用
_init_alloc或类似函数
; 典型的.data段拷贝循环
LDR R0, =_sidata ; Flash中.data的起始地址
LDR R1, =_sdata ; SRAM中.data的起始地址
LDR R2, =_edata ; SRAM中.data的结束地址
CMP R1, R2
BEQ LoopCopyDataInit
CopyDataInit:
LDR R3, [R0], #4
STR R3, [R1], #4
CMP R1, R2
BCC CopyDataInit
LoopCopyDataInit:
; BSS段清零
LDR R1, =__bss_start__
LDR R2, =__bss_end__
MOVS R3, #0
CMP R1, R2
BEQ LoopFillZerobss
FillZerobss:
STR R3, [R1], #4
CMP R1, R2
BCC FillZerobss
LoopFillZerobss:
避坑指南:链接脚本(.ld或.sct)里定义的符号名,必须和启动文件里引用的名字完全一致。我见过有人把 _sidata 写成了 _sidate,结果.data段根本没拷贝,全局变量全是乱的。这种bug查起来特别隐蔽。
阶段四:跳转到main
一切准备就绪,最后调用 _start 或直接跳转到main:
BL SystemInit ; 初始化系统时钟、PLL等
BL _start ; C运行时初始化(可选)
BL main ; 终于到用户程序了
B . ; main返回后死循环
嗯,这里要注意:SystemInit 是CMSIS标准要求的,用来配置系统时钟。有些芯片还会在之前调用 FPU_Init 开启浮点单元。
2.3 完整的启动链路图
我把整个流程画成了一张图,方便你对照理解:
2.4 堆栈初始化的细节
堆栈初始化看似简单,其实有几个容易翻车的地方:
- 栈生长方向: ARM Cortex-M栈是满递减(Full Descending),即SP从高地址向低地址生长。压栈时SP先减4,再存数据。
- 栈对齐: 有些外设(如DMA)要求栈8字节对齐。启动文件里
ALIGN=3就是做8字节对齐。 - 堆栈溢出检测: 我习惯在栈底放一个魔术字(如0xDEADBEEF),运行时定期检查。如果被覆盖了,说明栈溢出了。
实用技巧:调试阶段可以把栈区全部初始化为0xCC(ARM的断点指令),这样一旦程序跑飞踩到栈,会立刻触发HardFault,方便定位。
2.5 从启动到main的时间线
我用一个表格总结一下各阶段的时间开销(以STM32F103 @72MHz为例):
| 阶段 | 执行内容 | 典型耗时 | 备注 |
|---|---|---|---|
| 硬件复位 | 取向量、跳转 | ~1μs | 硬件自动完成 |
| Reset_Handler | 堆栈初始化 | ~0.5μs | 汇编指令,极快 |
| 拷贝.data | 搬运初始化数据 | ~10μs/KB | 取决于.data段大小 |
| 清零.bss | 清零未初始化变量 | ~5μs/KB | 比拷贝快一倍 |
| SystemInit | 配置时钟、PLL | ~50μs | 等待PLL锁定 |
| 跳转main | 进入用户程序 | ~0.1μs | 最后一步 |
你想想看,从复位到main,总共也就几十微秒。但就是这几十微秒,决定了你的程序能不能跑起来。
个人经验:做Bootloader时,我习惯在Reset_Handler开头加一段汇编,检查是否有升级标志。如果有,直接跳转到Flash的APP区域,跳过C运行时初始化。这样可以实现「冷启动快速跳转」,省掉不必要的初始化时间。
2.6 常见启动失败场景
最后列几个我实际遇到过的启动失败案例:
- 栈顶指针指向无效地址: 链接脚本里RAM地址配错了,__initial_sp指向了未初始化或只读区域。复位后第一条指令就HardFault。
- Reset_Handler地址bit0为0: ARM Cortex-M要求向量地址的bit0必须是1(表示Thumb模式)。如果链接器没对齐,直接跑飞。
- 中断向量表被意外修改: 有一次我在调试时不小心写穿了数组,把向量表覆盖了。结果随便按个按键就死机。
嗯,启动流程这块,说白了就是「硬件规定动作 + 软件初始化」的组合。只要把向量表、堆栈、数据段这三个关键点搞明白,后面移植Bootloader就顺了。