2. 启动流程剖析:从芯片复位到main函数,中间发生了什么?

很多嵌入式开发者写了几年代码,却从来没认真想过一个问题:芯片上电之后,到底是怎么跑到我的 main 函数里的?

说实话,我刚开始做嵌入式那会儿也觉得这玩意儿不重要。反正编译器都帮我搞定了,我写好 main 就完事了呗。直到有一次,我在一个 STM32 项目里遇到了启动异常——板子死活跑不起来,调试器连不上,串口没输出。折腾了两天,最后发现是中断向量表偏移设错了。

嗯,从那以后,我再也不敢跳过启动流程了。

这一章,我们就来把「从复位到 main」这条路彻底走一遍。你想想看,芯片上电后,CPU 连内存都还没初始化,它是怎么执行代码的?这里面藏着不少门道。

2.1 复位后的第一件事:CPU 在找什么?

芯片复位后,CPU 会做一件极其简单的事——从固定地址读取数据。

以 ARM Cortex-M 系列为例,复位后 CPU 自动执行以下两步:

  1. 从地址 0x00000000 读取 栈顶指针(MSP),存入 SP 寄存器
  2. 从地址 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 一张图看懂启动全流程

下面这张图,是我自己画的一个简化版启动流程。每次带新人时我都会先让他们看这个,比看几百页芯片手册管用多了。

嵌入式启动流程全链路 芯片复位 → main 函数启动全流程 ① 芯片复位 / 上电 ② 读取向量表(MSP + PC) ③ 跳转 Reset_Handler ④ SystemInit(时钟配置) ⑤ 搬运 .data + 清零 .bss ⑥ 进入 main 函数 注意:不同架构(ARM/RISC-V/8051)的启动细节不同,但核心逻辑一致 —— 都是「先搭环境,再跑应用」

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 函数的这条路,我们就走完了。你可能会觉得,就这么几步,有什么好讲的?但恰恰是这几步,决定了你的系统能不能稳定运行。