MCU 启动流程深度解析:从复位到 main,以及用 CRT 库接管初始化
关键词:MCU、启动文件、向量表、VTOR、.data、.bss、CRT、crt0、picolibc、STM32F103、链接脚本
如果你有 MCU 开发经验,应该对"启动文件"不陌生。MCU 程序从上电到 main 函数之间,有一段代码是必须提前跑完的——那就是启动文件(Startup File) 里的初始化流程。
本文将从 硬件复位行为 出发,层层拆解 MCU 的启动过程,并最终探讨如何用 CRT 库(C Runtime) 标准化地接管初始化工作。
完整视频流程:
【MCU C Runtime Bring-up】 https://www.bilibili.com/vide...
一、初始化的本质:两件事
启动阶段的初始化,本质上就两件事:
- 给 C 语言建立运行时环境
- 给 MCU 建立硬件运行环境
二、C 语言运行时环境的建立
为了让 C 代码能正常运行,启动阶段需要完成以下流程:
1. 初始化栈指针(SP)
C 函数调用需要栈。栈顶指针在复位时由硬件自动从向量表第一项加载到 SP——但前提是链接脚本提供了栈顶位置。
2. 拷贝 .data 段(从 Flash 到 RAM)
程序中有初始值的全局或静态变量(.data section),其初始值存储在 Flash 中,但运行时必须位于 RAM。因此启动时需要把这些初始值从 Flash 拷贝到 RAM。
3. 清零 .bss 段
未初始化的全局或静态变量(.bss section),编译器只在链接时记录它的大小和位置,运行时需要将对应的 RAM 区域全部清零。
三、MCU 硬件启动:复位行为
3.1 ARMv7-M 的复位流程
ARMv7-M 架构参考手册用伪代码描述了复位行为,主要包括:
- 重置部分寄存器
- 清除异常状态
- 从向量表起始地址读取 4 字节 数据 → 即栈顶地址(向量表第一项)
- 从向量表偏移 4 处读取 4 字节 → 即复位中断函数地址(中断号 1)
- 跳转到复位中断函数执行
关键点:向量表的第一项是栈顶地址,第二项是复位中断处理函数地址。
3.2 向量表为什么必须在 Flash 起始位置?
这里有一个关键问题:向量表是软件提供的,MCU 启动时怎么知道去哪里找?
芯片内核通过 VTOR 寄存器 获取向量表起始地址。ARMv7-M 规定:
VTOR 复位值为 0x00000000,所以复位瞬间内核固定从地址 0 取向量。但 MCU 程序烧录在 Flash 中,向量表也在 Flash 里,而 Flash 的起始映射地址通常并不是 0。那复位流程如何通过地址 0 访问到 Flash 内容?
3.3 STM32F103 的硬件解决方案
这个问题由芯片厂商解决。STM32F103 手册在"启动配置"中说明:
当从内部 Flash 启动时,内部 Flash 会被 alias(映射)到地址 0 的启动内存空间。
此时通过地址 0 或 Flash 的原始映射地址,都能访问到内部 Flash。因此,只要在 Flash 起始位置放置向量表,MCU 复位时就能通过地址 0 顺利取到向量表。
总结复位流程:
上电/复位
→ 内核复位相关寄存器
→ 从地址 0(被 aliase 到 Flash)读取向量表
→ 取栈顶地址 → 加载到 SP
→ 取复位中断函数地址
→ 跳转到复位中断函数执行四、进入复位中断函数后发生了什么
下面基于 STM32F103 工程来分析(CubeMX 生成)。
4.1 启动文件结构
第一步:定义复位中断函数(稍后分析其内部逻辑)。
第二步:定义中断向量表——
- 第一项为栈顶地址(由链接文件提供)
- 其后按中断号顺序排列各中断函数符号
- 整个向量表被放到特定 section,链接文件会将该 section 置于 Flash 起始位置
这样硬件复位时才能顺利取到向量表。
4.2 复位中断函数内部流程
进入复位中断函数后,依次执行:
| 步骤 | 操作 | 说明 |
|---|---|---|
| 1 | 调用 SystemInit | 设置 VTOR 寄存器,将向量表重定位到 Flash 原始地址(运行期按真实映射地址访问) |
| 2 | 拷贝 .data 段 | 把 Flash 中已初始化的全局/静态变量值拷贝到 RAM |
| 3 | 清零 .bss 段 | 将 RAM 中未初始化变量区域清零 |
| 4 | 调用 __libc_init_array | 调用全局构造函数(C++ / __attribute__((constructor))) |
| 5 | 跳转到 main | C 运行环境建立完成 |
关于
__libc_init_array:
- 它会调用所有全局构造函数,C++ 项目必备,
__attribute__((constructor))修饰的函数也会被调用,目的是在main之前执行初始化。- 纯 C 项目一般用不到,删掉也没问题;但如果用了
constructor属性,则必须保留。
至此,C 语言运行环境建立完成,main 函数被调用。
五、CubeMX 启动方式的思考
以上就是 CubeMX 生成工程的启动流程——它几乎在汇编启动文件中处理了所有初始化工作。
但这里有个值得思考的问题:
拷贝.data、清零.bss这些工作,工具链提供的 CRT 库(C Runtime)其实已经实现了。 CubeMX 的启动文件只是用汇编重写了一遍而已。
那 CRT 是怎么实现的呢?
六、CRT 库的实现:以 picolibc 为例
arm clang 工具链默认使用 picolibc 库,它会提供配套的 crt0 库。crt0 源码主要包含:
- 一个公共头文件
- 一个平台相关的源文件
6.1 入口函数 _start
- M 核架构的入口函数是
_start - 通过条件编译预处理适配不同资源(multilib 机制下,不同目录的库就是由这些宏条件编译生成)
- 最终都会调用
__start(内联函数)
__start 的核心职责:
__start:
→ .data 数据拷贝
→ .bss 清零
→ 调用 main同样存在一堆条件编译宏,根据不同配置生成 crt 库的多个变种,但核心逻辑一致。
6.2 选择 crt0-minimal
编译脚本中,crt0-minimal 通过将编译选项 CONSTRUCTORS 设为 0,把 __libc_init_array 的调用通过条件编译剔除——对纯 C 项目通常没问题。若要使用 C++,则需选择其它 crt 库变体。
七、改用 CRT 库接管:实操步骤
⚠️ 注意:crt 库源码中用到的那些符号(如 _sidata、_sdata、_edata、_sbss、_ebss 等),同样是由链接脚本提供的,链接脚本需要把这些符号与具体运行地址绑定。
改造要点(共 2 步)
1. 修改复位中断函数
删掉原本复位中断函数中 .data 拷贝 和 .bss 清零 的操作,直接调用 _start:
; 改造前(伪代码)
Reset_Handler:
bl SystemInit
; ... 拷贝 .data ...
; ... 清零 .bss ...
bl __libc_init_array
bl main
; 改造后(伪代码)
Reset_Handler:
bl SystemInit
bl _start ; 交由 CRT 库接管剩余初始化并跳转到 main2. 确保链接脚本提供 crt 库所需符号
经检查,CubeMX 默认生成的链接脚本已经定义了相关符号,是兼容 crt0 库的,因此通常无需改动。
编译与验证
- 复制一份汇编启动文件进行修改
- 在
app/CMakeLists.txt中指定新的汇编文件 - 在链接选项中指定要链接的运行时库(如
-lcrt0-minimal) - 编译工程
- 在生成的 map 文件 中确认 crt 库被正确链接 ✅
- 烧录程序,复位开发板 —— 运行正常 ✅
八、两种方式对比与总结
| 维度 | 纯汇编启动 | CRT 库接管 |
|---|---|---|
| 初始化实现 | 汇编手写 .data/.bss | crt0 库标准化实现 |
| 可读性/维护性 | 较直观但冗余 | 更标准、简洁 |
| 扩展 C++ | 需手动补充构造调用 | 切换 crt 变体即可 |
| 换芯片/移植 | 改动较大 | 改动小,接口统一 |
| 耦合链接脚本 | 是 | 是(不可避免) |
结论:
纯汇编启动 和 CRT 库接管 两种方式都可行。但 CRT 库方式更标准化——后续如果加入 C++ 代码或更换芯片,改动都会小很多。
同时要注意:使用 CRT 库不可避免会接触到链接脚本。如果对链接脚本不够熟悉,建议先补充相关知识(可参考配套的链接脚本讲解视频)。
附:启动流程总览图(文字版)
[上电 / 复位]
│
▼
[内核复位寄存器 & 清异常]
│
▼
[从地址 0 (aliase → Flash) 读向量表]
│
├── 取栈顶地址 → SP
└── 取复位中断地址
│
▼
[Reset_Handler / _start]
│
├── SystemInit() → 设置 VTOR
├── 拷贝 .data (Flash → RAM)
├── 清零 .bss
├── __libc_init_array() (可选, C++/构造函数)
│
▼
[main()]本文基于 STM32F103 + arm clang + picolibc 工具链分析,其它 Cortex-M 芯片思路相通。