深入__main:ARM编译器视角的运行时初始化

说实话,很多嵌入式工程师写了几年C代码,却从来没认真看过__main这个入口函数。我刚开始做ARM开发时也一样,总觉得程序从main开始,到main结束。直到有一次调试一个诡异的启动失败问题,才被迫翻开map文件,发现__main里藏着这么多门道。

今天我们就从ARM编译器的视角,把__main这个"幕后黑手"彻底扒干净。

__main到底是谁?

你写的C代码里从来没有调用过__main,但它确实存在。ARM编译器(ARMCC或ARMCLANG)在链接阶段会自动插入这个函数。它位于复位向量和你的main()之间,负责把"原始内存"变成"C运行时环境"。

说白了,__main就是C语言的"助产士"。没有它,你的全局变量不会初始化,堆栈不会设置,C++静态构造函数也不会执行。

关键认知: __main不是用户代码,而是编译器生成的运行时初始化序列。它执行完毕后才会跳转到你的main()

__main干了哪些事?

我习惯把__main的工作分成四个阶段。每个阶段都有明确的职责,也都有常见的坑。

  1. 复制RW段:把可读写数据从Flash拷贝到RAM
  2. 清零ZI段:把未初始化数据区(BSS)全部填0
  3. 初始化堆栈和堆:设置栈指针,初始化堆管理器
  4. 调用C++全局构造器:执行__cpp_initialize__aeabi_

嗯,这里要注意:ARM编译器把__main拆成了多个子函数。你可以在map文件里看到__scatterload__rt_entry__cpp_initialize__aeabi_这些名字。它们都是__main的一部分。

从复位到main的完整路径

让我们画一张图,看清楚整个调用链:

ARM复位到main的调用链 复位向量 → Reset_Handler SystemInit() — 时钟/外设初始化 __main() — 运行时初始化 main() — 用户代码 __main内部展开 1. __scatterload └─ 复制RW段 + 清零ZI段 2. __rt_entry └─ 初始化堆栈/堆/库 3. __cpp_initialize └─ 调用全局构造器

你看,__main夹在SystemInit()和你的main()之间。我见过不少新手在SystemInit()里访问全局变量,结果发现值是乱的——因为此时__main还没跑,RW段还没复制过来。

__scatterload:数据搬运工

这是__main里最核心的环节。ARM编译器使用"分散加载"机制(scatter loading),把程序的各个段分配到不同的内存区域。

举个例子,你的代码里有个全局数组:

// 全局变量,初始值非0
int g_buffer[1024] = {0x55, 0xAA, 0x12345678};

// 全局变量,初始值为0
int g_zero_array[2048];

在Flash里,g_buffer的初始值已经烧好了。但RAM上电后是随机值。__scatterload要做的,就是把Flash里的初始值拷贝到RAM里对应的位置。

至于g_zero_array,它属于ZI段(Zero Initialized)。__scatterload会调用__aeabi_memclr把它全部清零。

我的经验: 曾经在一个项目中,我定义了一个超大的全局数组(约500KB),结果程序启动慢得离谱。后来发现是__scatterload在逐个字节清零。解决办法?改用动态分配,或者用链接脚本把这个数组放到单独的段,手动管理初始化时机。

__rt_entry:运行时环境搭建

数据搬完了,接下来要搭"运行台"。这部分由__rt_entry负责,主要做三件事:

任务 说明 常见问题
设置栈指针 配置SP寄存器指向栈顶 栈大小不足导致溢出
初始化堆 调用__rt_heap_initialize 堆区与栈区重叠
初始化C库 设置errno、浮点环境等 使用浮点运算时崩溃

我个人习惯在链接脚本里明确指定栈和堆的大小。ARM的默认值往往偏小,尤其是堆,默认可能只有几百字节。如果你用了malloc,一定要检查堆大小是否够用。

注意:__rt_entry阶段,中断还是关闭的。直到main()执行后,你才应该打开全局中断。我曾经见过有人在SystemInit()里就开了中断,结果__scatterload还没跑完,中断服务程序访问了未初始化的全局变量——直接跑飞。

__cpp_initialize:C++的"最后一公里"

如果你用了C++,或者C代码里用了__attribute__((constructor)),那么__main的最后一步就是调用全局构造器。

ARM编译器会生成一个特殊的段,里面存放了所有构造函数的地址。__cpp_initialize__aeabi_会遍历这个段,逐个调用。

// 这段代码由编译器生成,你不需要写
// 但理解它有助于调试
void __cpp_initialize__aeabi_(void) {
    // 遍历 .init_array 段
    for (func_ptr = __init_array_start; 
         func_ptr < __init_array_end; 
         func_ptr++) {
        (*func_ptr)();
    }
}

嗯,这里有个坑:构造函数的执行顺序。ARM编译器按照链接顺序调用,但不同目标文件之间的顺序是不确定的。如果你的构造函数之间有依赖关系,一定要小心。

如何验证__main的执行情况?

我调试启动问题时,最常用的手段是看map文件和反汇编。

第一步:看map文件

在ARMCC下,map文件会列出__main相关的所有符号。你可以找到__scatterload__rt_entry的地址,确认它们是否被正确链接。

第二步:反汇编启动代码

fromelf --text -a -c output.axf > disasm.txt生成反汇编。重点关注__main入口处的指令序列:

// 典型的__main反汇编片段
0x08000100:  ldr    r0, [pc, #0x20]    ; 加载Load Region起始地址
0x08000104:  ldr    r1, [pc, #0x20]    ; 加载执行区起始地址
0x08000108:  ldr    r2, [pc, #0x20]    ; 加载段大小
0x0800010c:  bl     __scatterload_copy ; 调用拷贝函数

如果你看到__scatterload_zeroinit被调用,那就是在清零ZI段。

调试技巧:__main入口处设置硬件断点。然后单步执行,观察内存变化。你会发现,在__scatterload执行前后,RAM里的全局变量从随机值变成了你设定的初始值。这个过程非常直观,建议你亲自试一次。

常见问题与避坑

这些年我踩过的坑,总结成几条:

  • 全局变量初始化顺序:同一个文件内的全局变量按定义顺序初始化,但不同文件之间顺序不确定。别让全局变量的初始化互相依赖。
  • 栈溢出检测:在__rt_entry之后、main()之前,栈已经开始使用。如果栈太小,程序可能在进入main()之前就崩了。我习惯在栈底填充一个特殊模式(如0xDEADBEEF),然后在运行时检查。
  • 半主机模式:ARM的C库默认启用半主机模式(semihosting),用于printf等I/O操作。但在裸机环境下,这会导致程序卡死。记得在链接时加上--specs=nosys.specs--specs=nano.specs
  • 分散加载描述文件:如果你的内存布局比较复杂(比如有DDR、SRAM、TCM多个区域),一定要自己写scatter文件。默认的链接脚本可能把数据放到错误的位置。

说白了,__main就是C语言从"二进制镜像"到"可执行环境"的桥梁。理解它,你才能真正掌控嵌入式系统的启动过程。下次遇到程序"死在main之前"的问题,你就知道该从哪里下手了。


公众号:蓝海资料掘金营,微信deep3321