第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));

相关推荐
island131415 分钟前
CANN GE(图引擎)深度解析:计算图优化管线、内存静态规划与异构任务的 Stream 调度机制
开发语言·人工智能·深度学习·神经网络
M1582276905515 分钟前
TCP转LORA产品说明及应用案例
网络·网络协议·tcp/ip
坚持就完事了19 分钟前
Java中的集合
java·开发语言
旖旎夜光20 分钟前
Linux(13)(中)
linux·网络
魔芋红茶23 分钟前
Python 项目版本控制
开发语言·python
来可电子CAN青年34 分钟前
CAN总线远距离传输老断网?Fx灯不闪别慌,这几招让你的通信“稳如泰山”!
网络
独行soc35 分钟前
2026年渗透测试面试题总结-18(题目+回答)
android·网络·安全·web安全·渗透测试·安全狮
飞睿科技35 分钟前
乐鑫智能开关方案解析:基于ESP32-C系列的低功耗、高集成设计
嵌入式硬件·物联网·esp32·智能家居·乐鑫科技
云小逸38 分钟前
【nmap源码解析】Nmap OS识别核心模块深度解析:osscan2.cc源码剖析(1)
开发语言·网络·学习·nmap
冰暮流星38 分钟前
javascript之二重循环练习
开发语言·javascript·数据库