2. 启动流程剖析:从芯片复位到main函数,中间发生了什么?
很多嵌入式开发者写了几年代码,却从来没认真想过一个问题:芯片上电之后,到底是怎么跑到我的 main 函数里的?
说实话,我刚开始做嵌入式那会儿也觉得这玩意儿不重要。反正编译器都帮我搞定了,我写好 main 就完事了呗。直到有一次,我在一个 STM32 项目里遇到了启动异常——板子死活跑不起来,调试器连不上,串口没输出。折腾了两天,最后发现是中断向量表偏移设错了。
嗯,从那以后,我再也不敢跳过启动流程了。
这一章,我们就来把「从复位到 main」这条路彻底走一遍。你想想看,芯片上电后,CPU 连内存都还没初始化,它是怎么执行代码的?这里面藏着不少门道。
2.1 复位后的第一件事:CPU 在找什么?
芯片复位后,CPU 会做一件极其简单的事——从固定地址读取数据。
以 ARM Cortex-M 系列为例,复位后 CPU 自动执行以下两步:
- 从地址
0x00000000读取 栈顶指针(MSP),存入 SP 寄存器 - 从地址
0x00000004读取 复位向量,存入 PC 寄存器
说白了,CPU 就是个「听话的打工人」。你告诉它从哪开始执行,它就照做。而这两个地址,就是芯片厂商和链接脚本约定好的「暗号」。
关键理解:复位向量不是 main 函数的地址,而是 Reset_Handler 的入口地址。main 函数是在 Reset_Handler 里被调用的。
2.2 启动文件:谁在背后默默干活?
每个 MCU 工程里都有一个启动文件,名字通常是 startup_xxx.s。我见过不少新手直接把它从工程里删了,然后问我为什么编译报错……
这个文件虽然不起眼,但它干的事可不少:
- 定义中断向量表——所有中断服务函数的入口地址
- 初始化栈指针——为 C 语言运行时准备好栈空间
- 调用 SystemInit——配置系统时钟、PLL、Flash 等待周期
- 搬运数据段——把 .data 段从 Flash 复制到 RAM
- 清零 BSS 段——把未初始化的全局变量区域清零
- 调用 main——终于,轮到你的代码上场了
我习惯把启动文件比作「舞台搭建工」。你作为演员(main 函数)上台之前,人家已经把灯光、音响、道具全摆好了。
2.3 一张图看懂启动全流程
下面这张图,是我自己画的一个简化版启动流程。每次带新人时我都会先让他们看这个,比看几百页芯片手册管用多了。
2.4 数据段搬运:为什么不能直接在 Flash 里跑?
这个问题我经常被问到。Flash 里明明存着数据,为什么还要费劲搬到 RAM 里?
原因有两个:
- 速度——RAM 的读写速度远快于 Flash,尤其是频繁修改的全局变量
- 可写性——Flash 不能像 RAM 那样随意按字节改写
启动文件里有一段关键代码,专门做这件事。我拿一个典型的 ARM 启动代码片段给你看:
; 搬运 .data 段(从 Flash 复制到 RAM)
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
strlo r3, [r1], #4
blo copy_loop
; 清零 .bss 段
ldr r1, =_sbss
ldr r2, =_ebss
movs r3, #0
zero_loop:
cmp r1, r2
itt lo
strlo r3, [r1], #4
blo zero_loop
你看,这段代码其实很简单。就是三个指针来回倒腾。但它的执行时机非常关键——必须在任何 C 代码访问全局变量之前完成。
我曾经踩过的坑:有一次我在 SystemInit 函数里就访问了一个全局变量,结果读出来的值是随机的。查了半天才发现,SystemInit 执行时 .data 段还没搬运完。从那以后,我给自己定了个规矩:不要在 SystemInit 里依赖任何全局变量。
2.5 中断向量表:一张决定命运的表格
中断向量表,说白了就是一张「地址对照表」。每个中断源对应一个函数地址,CPU 根据中断号查表跳转。
一个典型的向量表结构如下:
| 偏移地址 | 中断号 | 中断服务函数 | 说明 |
|---|---|---|---|
| 0x0000_0000 | - | 栈顶指针(MSP) | 初始栈地址 |
| 0x0000_0004 | 1 | Reset_Handler | 复位入口 |
| 0x0000_0008 | 2 | NMI_Handler | 不可屏蔽中断 |
| 0x0000_000C | 3 | HardFault_Handler | 硬错误处理 |
| 0x0000_0010 | 4 | MemManage_Handler | 内存管理错误 |
| ... | ... | ... | ... |
这里有个细节很多人不知道:向量表可以重定位。Cortex-M 系列提供了 SCB->VTOR 寄存器,你可以把向量表搬到 RAM 或其他地址。这在做 BootLoader + App 升级时特别有用——App 的向量表通常不在 0x0000_0000,而是在偏移后的地址。
我个人的习惯:在做 OTA 项目时,我会在 BootLoader 的最后一步,跳转到 App 之前,先把 VTOR 寄存器设好。否则一旦发生中断,CPU 会跑到 BootLoader 的向量表里去找中断函数,那可就乱套了。
2.6 不同架构的启动差异
虽然核心逻辑相似,但不同架构的启动细节差别不小。我整理了一张对比表:
| 特性 | ARM Cortex-M | RISC-V | 8051 |
|---|---|---|---|
| 复位入口 | 0x0000_0004 读取向量 | 0x0000_0000 或 MTVEC | 0x0000 |
| 栈初始化 | 硬件自动加载 MSP | 软件初始化 SP | 软件初始化 SP |
| 中断向量表 | 函数指针数组 | 跳转指令表 | 固定地址跳转 |
| C 环境准备 | 启动文件自动完成 | 启动文件自动完成 | 需手动初始化 |
| 向量表重定位 | VTOR 寄存器 | MTVEC 寄存器 | 不支持 |
你看,ARM Cortex-M 在这方面做得最「省心」。硬件自动帮你做了很多事。而 8051 就原始得多,很多初始化得你自己写汇编搞定。
2.7 一个容易被忽略的细节:堆栈设置
启动文件里除了搬运数据,还会设置堆栈。堆和栈是两回事,但很多人混着用。
- 栈(Stack):用于函数调用、局部变量、中断现场保护。向下增长。
- 堆(Heap):用于动态内存分配(malloc)。向上增长。
在启动文件里,通常这样定义:
Stack_Size EQU 0x400 ; 1KB 栈空间
Heap_Size EQU 0x200 ; 512B 堆空间
AREA STACK, NOINIT, READWRITE, ALIGN=3
Stack_Mem SPACE Stack_Size
__initial_sp
AREA HEAP, NOINIT, READWRITE, ALIGN=3
Heap_Mem SPACE Heap_Size
我见过不少项目因为栈空间设得太小,导致函数嵌套调用时栈溢出,程序跑飞。而且这种问题特别难排查——因为栈溢出不会立刻报错,而是悄悄地把其他变量的值给覆盖了。
避坑指南:我曾经在一个 RTOS 项目里,把栈设成了 256 字节。结果任务切换几次后就死机了。查了两天才发现是栈溢出。后来我养成了一个习惯:在开发阶段把栈空间设大 2~3 倍,等功能稳定后再优化。
2.8 从 Reset_Handler 到 main:最后一步
启动文件的最后,通常会这样写:
; 调用 main 函数
bl main
; 如果 main 返回了(理论上不应该)
LoopForever:
b LoopForever
注意这个细节:main 函数后面跟了一个死循环。为什么?
因为嵌入式系统的 main 函数理论上不应该返回。如果它返回了,说明你的程序出大问题了。这个死循环就是最后的「安全网」——让 CPU 停在一个已知状态,方便调试器连接。
嗯,说到这儿,从芯片复位到 main 函数的这条路,我们就走完了。你可能会觉得,就这么几步,有什么好讲的?但恰恰是这几步,决定了你的系统能不能稳定运行。