在上一期中,我们讲到 CPU 执行完 SystemInit() 后,时钟已经跑到了最高速(比如 170MHz),然后准备跳转到 __main。
注意,这里的 __main(双下划线)不是你写的 int main()。它是编译器提供的 C 运行时库 (C Runtime, CRT) 入口。
为什么不能直接跳到你的 main()? 因为此时 RAM 里全是随机的垃圾数据 。你定义的 int g_Val = 10; 现在可能是 -9999,int 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. 总结:完整的启动时间轴
-
Reset: 硬件加载 MSP, PC。
-
SystemInit: 开启 PLL,配置时钟(汇编调 C)。
-
__main (CRT):
-
Copy .data: Flash -> RAM。
-
Zero .bss: RAM 清零。
-
Init Heap: 初始化
malloc的堆管理器(如果有)。 -
C++ Init: 执行静态构造。
-
-
User main: 终于轮到你了。
理解了这个流程,你就明白了一个高级技巧 : 如何定义"复位不丢失"的变量? (即:Soft Reset 后,变量值保持不变,用于保存崩溃前的错误码)。
方法: 在链接脚本里定义一个 .noinit 段(不放入 .bss 也不放入 .data)。 告诉启动代码:"这个房间不要打扫,也不要搬东西进去,保持原样!"
// Keil 写法
int g_CrashCode attribute((section(".noinit"), zero_init));