第 5 讲 | 启动文件与 __main 到底做了什么
本文是《Keil MDK + ARMCC 工具链深度实战》第 5 讲。
你写的
main()并不是第一个执行的函数。从上电到main()之间,工具链偷偷干了一堆活------搞清这些活,你才能解释"为什么全局变量初值是 0""为什么复位后数据还在""为什么启动就崩"。
一、上电到 main() 的完整链路
先看全景(Cortex-M + ARMCC):
上电 / 复位
│
▼
① 硬件取向量表前两个字:
- [0x00000000] → 初始 SP(主栈指针 MSP)
- [0x00000004] → Reset_Handler 地址
│
▼
② Reset_Handler (startup_xxx.s 里,你的工程文件)
- 调用 SystemInit() (system_xxx.c 里,配时钟/看门狗)
- 跳转 __main
│
▼
③ __main (工具链库函数,你搜不到源码)
├── __scatterload → 把 .data 从 Flash 拷到 RAM,把 .bss 清零
├── 初始化栈 / 堆
└── 跳转 __rt_entry
│
▼
④ __rt_entry (C 库入口)
- 初始化 C 库(如 stdio、malloc)
- 调用 main()
│
▼
⑤ main() (你的代码)
关键认知 :__main 不是 main,只差两个下划线,但完全是两个东西。
main是你写的__main是 ARMCC 库里的,没有源码 (在armlib里),它负责所有"编译器级别的初始化"
你去搜 __main 的定义,永远搜不到------它不是一个 C 函数,是一段由链接器提供的汇编/机器码。
二、逐段拆解:Reset_Handler 做了什么
打开 startup_py32f071.s,找到 Reset_Handler:
asm
Reset_Handler PROC
EXPORT Reset_Handler [WEAK]
IMPORT __main
IMPORT SystemInit
LDR R0, =SystemInit
BLX R0 ; ① 调用 SystemInit
LDR R0, =__main
BX R0 ; ② 跳转到 __main(不返回)
ENDP
两件事:
-
SystemInit()------ 厂商提供的函数,通常在system_xxx.c里,干这些活:- 配置时钟(把 HSI 切到 HSE/PLL,设置到目标频率)
- 关闭看门狗(很多芯片复位后看门狗是开的,不关就会在初始化阶段复位)
- 使能 FPU(M4F)、配置向量表地址等
-
跳转
__main------ 交给工具链做内存初始化
新手常见坑 :
SystemInit里如果没有及时喂狗或关狗,芯片会反复复位 。现象是"复位后几毫秒又复位",串口打印永远不完整。排查第一步就是看SystemInit里有没有处理看门狗。
为什么用 BLX R0 而不是直接 BL __main? 因为 __main 的地址在链接期才确定,LDR R0, =__main 让链接器填绝对地址,这是标准的"跨区跳转"写法。
三、核心:__main 的 __scatterload 干了什么
这是本讲最值钱的部分。__main 最主要的工作叫分散加载初始化 (__scatterload),它做三件事:
1. 把 .data 从 Flash 拷到 RAM(RW 段的搬运)
第 4 讲的 map 文件里,RAM 里的 RW 段是这样:
0x20000000 0x0800d344 0x00000004 Data RW .data app.o
↑执行地址 ↑加载地址
- 加载地址在 Flash (
0x0800d344)------ 因为初值必须存在非易失存储器 - 执行地址在 RAM (
0x20000000)------ 因为变量要能改写
__scatterload 负责把 Flash 里的初值段,整块拷贝到 RAM。 这就是"全局变量 int g = 5; 初值为什么是 5"的原因。
2. 把 .bss 清零(ZI 段)
c
int g_zero; // 未初始化
static char buf[1024]; // 未初始化
这些变量不占 Flash ,但占 RAM 。它们的初值"应该是 0",但芯片上电后 RAM 里是随机值------所以 __scatterload 要把整块 ZI 区手动清零。
这就是"为什么 C 标准规定全局变量默认初值为 0"的实现方式------不是硬件给的,是启动代码清零的。
3. 初始化栈和堆
- 栈指针 SP:其实上面第①步硬件已经从向量表第一个字加载了初始 SP
- 堆 :
__main会设置堆的起止地址(由 scatter 里的ARM_LIB_HEAP或默认值决定)
四、一个真实案例:为什么复位后数据还在 / 不在
理解了 __scatterload,很多"诡异现象"就有答案了。
现象 A:普通全局变量复位后变回 0
c
uint32_t g_counter = 0;
void main(void) {
g_counter++;
// 每次复位后 g_counter 都是 0(除了第一次运行的 1)
}
原因 :复位时 __scatterload 把 .bss 清零了,g_counter 归零。这是设计如此。
现象 B:UNINIT 变量复位后保留了值
第 3 讲那个共享标志区:
c
volatile uint32_t g_boot_confirm __attribute__((section(".shared_flag"), zero_init));
它放在 scatter 的 RW_IRAM_SHARED ... UNINIT 区里,__scatterload 会跳过这个区 ,所以复位后值保留。这是"复位保留数据"的正确实现------比硬写绝对地址安全。
现象 C:内存踩踏导致的偶发死机
有个真实项目,固件跑几小时后偶发死机。用 map + Watch 发现:某全局数组被写越界,踩掉了后面的变量。
根源是:__scatterload 把 ZI 区清零了,但你以为某块 RAM 是空的可以直接用 ------实际上那块地址已经分给了别的 ZI 变量。这就是为什么要用 scatter 明确划区,而不是凭感觉用绝对地址。
教训:任何"手动用某块 RAM"的行为,都必须在 scatter 里显式声明,让链接器知道"这块被占用了"。否则两个东西打架,问题极难查。
五、MicroLib:另一个影响启动的东西
Keil 里有个选项:Options → Target → 勾选 "Use MicroLIB"。它直接改变 __main 之后的行为。
| 对比项 | 标准 C 库(armlib) | MicroLib |
|---|---|---|
| 体积 | 大(几十 KB) | 小(几 KB) |
printf 浮点 |
支持 %f |
默认不支持浮点格式化 |
malloc |
完整 | 简化版,依赖堆配置 |
| 线程安全 | 完整 | 简化 |
| 启动行为 | __rt_entry 完整初始化 |
精简 |
MicroLib 的实际影响:
printf("%f", 3.14)输出乱码或空 ------因为 MicroLib 默认不链接浮点格式化。要用就得加--use_float_io或者干脆用整数。malloc行为不同 ------MicroLib 的堆更简单,Heap_Size配太小会直接返回 NULL。- 启动更快、体积更小------这也是为什么 16KB RAM 的项目几乎都开 MicroLib。
实践建议 :裸机 / RTOS 项目默认开 MicroLib ,省体积。但如果要用
printf浮点,记得处理格式化问题(第 8 讲会讲"用整数代替浮点"的体积收益)。
六、__user_initial_stackheap:栈堆地址谁定的
如果你的工程里有这个函数(老工程常见):
c
__value_in_regs struct __initial_stackheap __user_initial_stackheap(
unsigned R0, unsigned SP, unsigned R1, unsigned R2)
{
struct __initial_stackheap config;
config.heap_base = 0x20001000;
config.heap_limit = 0x20002000;
config.stack_base = ...;
config.stack_limit = ...;
return config;
}
它是用来手动指定栈和堆地址的。 但如果你用了 scatter 里的 ARM_LIB_STACK / ARM_LIB_HEAP 区域,这个函数就不该再定义------两者会冲突。
现在的推荐做法是在 scatter 里定义:
RW_IRAM1 0x20000000 0x00004000 {
.ANY (+RW +ZI)
ARM_LIB_HEAP +0 EMPTY 0x00000400 { } ; 堆:1KB
ARM_LIB_STACK +0 EMPTY 0x00000800 { } ; 栈:2KB
}
EMPTY表示"只占位,不初始化" ------这两块不生成数据,只是给链接器留出地址范围。这样Heap_Size/Stack_Size就由 scatter 决定,而不是Options → Target页里的数字。
七、怎么亲手验证启动流程
光看不练没用。三个动手实验:
实验 1:在 Reset_Handler 打断点
在 startup_xxx.s 的 Reset_Handler 第一行打断点,复位,单步:
- 你会看到
SystemInit被调用 - 然后
BX R0跳到__main - 但
__main没有源码 ,Keil 会显示汇编(在armlib的某个.s里,反汇编形式)
实验 2:反汇编 .axf,找 __main
bash
fromelf --text -c -o app.dis app.axf
搜索 __main 和 __scatterload,你能看到真实的搬运指令(LDM/STM 循环)。
实验 3:对比 -O0 和 -Os 下 main 的入口
bash
fromelf --text -c -o app.dis app.axf
搜 main,你会看到 main 的地址。用 map 里的符号表对照 ,确认它前面的符号是哪个(通常是 __rt_entry 相关的库函数)。
做完这三个实验,你就不会再觉得"上电到 main"是个黑盒了。
八、常见启动相关问题速查
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 复位后立即又复位 | 看门狗没关 / 硬件看门狗 | 检查 SystemInit 里的看门狗处理 |
main 之前就 HardFault |
向量表没放最前 / InRoot$$Sections 漏了 |
回看第 3 讲 scatter 必备行 |
| 全局变量初值不对 | .data 搬运失败 / scatter 写错 |
检查 RW 段加载地址 |
| 复位后数据丢失(期望保留) | 忘了 UNINIT / zero_init |
检查 scatter 区定义 |
printf 浮点不输出 |
MicroLib 不支持浮点格式化 | 开 --use_float_io 或用整数 |
malloc 返回 NULL |
堆太小 / 没用 scatter 定义 | 检查 ARM_LIB_HEAP 或 Heap_Size |
| 栈溢出死机 | 栈配太小 / 中断嵌套太深 | 提高 Stack_Size,用 HighWaterMark 测(第 9 讲) |
九、本讲小结
- 完整链路 :上电取向量 →
Reset_Handler→SystemInit→__main→__scatterload→__rt_entry→main。 __main≠main:前者是库里的,负责内存初始化。__scatterload干三件事 :.data搬运、.bss清零、栈堆初始化。- 这解释了三件事 :全局变量初值为 0、
UNINIT保留数据、手写 RAM 地址会踩踏。 - MicroLib 改变启动行为和库体积,裸机项目默认开。
下一讲预告
前面五讲都在讲"编译和链接"。第 6 讲换个方向------怎么把固件的运行状态"看"出来。
我会讲三种调试手段的取舍:RTT 为什么比串口快 100 倍、ITM/SWO 怎么不占串口、逻辑分析仪怎么抓出"软延时不准"。附上 RTT 的完整配置步骤和一段"用逻辑分析仪定位 SPI 时序错误"的真实案例。
本专栏是《Keil MDK + ARMCC 工具链深度实战》第 5 讲,共 10 讲。
点赞+收藏+关注,欢迎进入观看其他专栏。