第37期:启动流程(二):C Runtime (CRT) 初始化与重定位

在上一期中,我们讲到 CPU 执行完 SystemInit() 后,时钟已经跑到了最高速(比如 170MHz),然后准备跳转到 __main

注意,这里的 __main(双下划线)不是你写的 int main()。它是编译器提供的 C 运行时库 (C Runtime, CRT) 入口。

为什么不能直接跳到你的 main()? 因为此时 RAM 里全是随机的垃圾数据 。你定义的 int g_Val = 10; 现在可能是 -9999int g_Zero; 现在可能是 0xDEADBEEF

第37期,我们将揭秘这段"搬运工"代码是如何构建 C 语言运行环境的。

为什么不初始化 .bss,全局变量就不是 0?

在嵌入式开发中,这一步通常被称为 Scatter Loading (分散加载)Data Relocation (数据重定位) 。 它的核心任务只有两个:搬家打扫


1. 搬家:初始化数据段 (.data) 的复制

我们在第35期(链接脚本)中定义了:

  • LMA (Load Address): 数据存在 Flash 里的地址(比如 0x0800 5000)。

  • VMA (Virtual Address): 数据在 RAM 里运行的地址(比如 0x2000 0000)。

启动代码必须执行一个类似 memcpy 的操作。

汇编实现逻辑 (GCC / CubeIDE 风格)

startup_stm32.s 中,你会看到类似这样的代码:

/* 符号定义:来自 Linker Script (.ld 文件) */

.word _sidata /* 源地址:Flash */

.word _sdata /* 目的起始地址:RAM */

.word _edata /* 目的结束地址:RAM */

Copy_Data_Loop:

LDR R3, =_edata

LDR R2, =_sdata

CMP R2, R3

BCC Copy_Data_Init ; 如果 Start < End,开始搬运

B Zero_Bss_Init ; 否则跳去清零 BSS

Copy_Data_Init:

LDR R3, =_sidata ; 加载源地址

LDR R4, [R3], #4 ; 读取 Flash 内容,R3 自增

STR R4, [R2], #4 ; 写入 RAM,R2 自增

; 检查是否搬完... (循环跳转)

专家视角: 这就是为什么你在 main() 里修改了全局变量 g_Val = 20,复位后它又变回了 10。 因为复位后,这段代码会重新从 Flash 里把那个"10"搬运覆盖到 RAM 里。


2. 打扫:未初始化段 (.bss) 的清零

C 语言标准规定:未初始化的全局变量,默认值必须为 0。 这并不是天上掉下来的馅饼,而是启动代码辛辛苦苦用 memset 清零的结果。

为什么 Flash 里不存一堆 0?

如果在 Flash 里存 10KB 的 0,那是对存储空间的极大浪费。 Linker Script 只记录了 .bss 在 RAM 里的起始地址长度

Zero_Bss_Init:

LDR R2, =_sbss ; BSS 起始

LDR R3, =_ebss ; BSS 结束

MOVS R4, #0 ; 准备一个 0

Zero_Loop:

CMP R2, R3

BCC Zero_Write

B Call_Main ; 全都清零完了,去 main 吧

Zero_Write:

STR R4, [R2], #4 ; 把 0 写入 RAM

B Zero_Loop

致命 Bug 预警: 如果你自己写启动代码或者链接脚本写错了,导致 .bss 段没有被覆盖到。你的 int g_State; 初始值就是上一次断电前 RAM 残留的随机值。这会导致状态机上电后直接跑飞。


3. 进阶:C++ 的静态构造函数 (Static Constructors)

如果你用的是 C++ (例如 Arduino 框架或 mbedOS),在进入 main 之前还有一步: 调用 __libc_init_array

  • 场景: class LED { LED() { ON(); } }; LED myLed;

  • 原理: 全局对象的构造函数必须在 main 之前运行。编译器会把所有构造函数的指针放在一个特殊的段 .init_array 中。

  • 动作: 启动代码会遍历这个数组,依次调用这些函数指针。

对于纯 C 语言工程,这一步通常是空的。


4. 最终一跳:跳转 main()

.data 搬完了,.bss 清完了,堆栈也准备好了。 CPU 终于有资格去执行用户的业务逻辑了。

BL main

BX LR

等等,如果 main() 函数返回了(return 0),会发生什么? 虽然嵌入式程序的 main 通常是个死循环 while(1),但万一你手滑 break 了。 main 返回后,会回到启动代码。通常后面紧跟一个死循环:

LoopForever:

B LoopForever

5. 总结:完整的启动时间轴

  1. Reset: 硬件加载 MSP, PC。

  2. SystemInit: 开启 PLL,配置时钟(汇编调 C)。

  3. __main (CRT):

    • Copy .data: Flash -> RAM。

    • Zero .bss: RAM 清零。

    • Init Heap: 初始化 malloc 的堆管理器(如果有)。

    • C++ Init: 执行静态构造。

  4. User main: 终于轮到你了。

理解了这个流程,你就明白了一个高级技巧如何定义"复位不丢失"的变量? (即:Soft Reset 后,变量值保持不变,用于保存崩溃前的错误码)。

方法: 在链接脚本里定义一个 .noinit 段(不放入 .bss 也不放入 .data)。 告诉启动代码:"这个房间不要打扫,也不要搬东西进去,保持原样!"

// Keil 写法

int g_CrashCode attribute((section(".noinit"), zero_init));

相关推荐
Jackson@ML2 小时前
2026最新版Python 3.14.2安装使用指南
开发语言·python
白狐_7982 小时前
【计网全栈通关】第 6 篇:网络层路由核心——RIP、OSPF 协议原理与 Cisco 配置实战
网络·智能路由器
橘子师兄2 小时前
C++AI大模型接入SDK—ChatSDK使用手册
开发语言·c++·人工智能
txinyu的博客2 小时前
STL string 源码深度解析
开发语言·c++
Channing Lewis2 小时前
正则灾难性回溯(catastrophic backtracking)
开发语言·python
CS创新实验室2 小时前
《计算机网络》深入学:轮询和令牌传递协议
开发语言·计算机网络·考研·php·408
CS创新实验室2 小时前
《计算机网络》深入学:局域网与接入网技术
网络·计算机网络·考研·408·局域网
王干脆2 小时前
ConcurrentHashMap禁止null键值的原因
java·开发语言
longxiangam2 小时前
基于esp32p4 的掌机
单片机·嵌入式硬件