Keil工具链-第05讲-启动文件与__main到底做了什么

第 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

两件事:

  1. SystemInit() ------ 厂商提供的函数,通常在 system_xxx.c 里,干这些活:

    • 配置时钟(把 HSI 切到 HSE/PLL,设置到目标频率)
    • 关闭看门狗(很多芯片复位后看门狗是开的,不关就会在初始化阶段复位)
    • 使能 FPU(M4F)、配置向量表地址等
  2. 跳转 __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 的实际影响:

  1. printf("%f", 3.14) 输出乱码或空 ------因为 MicroLib 默认不链接浮点格式化。要用就得加 --use_float_io 或者干脆用整数。
  2. malloc 行为不同 ------MicroLib 的堆更简单,Heap_Size 配太小会直接返回 NULL。
  3. 启动更快、体积更小------这也是为什么 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 讲)

九、本讲小结

  1. 完整链路 :上电取向量 → Reset_Handler → SystemInit → __main → __scatterload → __rt_entry → main。
  2. __main ≠ main:前者是库里的,负责内存初始化。
  3. __scatterload 干三件事 :.data 搬运、.bss 清零、栈堆初始化。
  4. 这解释了三件事 :全局变量初值为 0、UNINIT 保留数据、手写 RAM 地址会踩踏。
  5. MicroLib 改变启动行为和库体积,裸机项目默认开。

下一讲预告

前面五讲都在讲"编译和链接"。第 6 讲换个方向------怎么把固件的运行状态"看"出来。

我会讲三种调试手段的取舍:RTT 为什么比串口快 100 倍、ITM/SWO 怎么不占串口、逻辑分析仪怎么抓出"软延时不准"。附上 RTT 的完整配置步骤和一段"用逻辑分析仪定位 SPI 时序错误"的真实案例。


本专栏是《Keil MDK + ARMCC 工具链深度实战》第 5 讲,共 10 讲。

点赞+收藏+关注,欢迎进入观看其他专栏。

相关推荐
shijuejianceliu6 小时前
传统规则视觉与嵌入式AI智能相机的技术差异
人工智能·嵌入式硬件·视觉检测·相机
Navigator_Z8 小时前
LeetCode //C - 1317. Convert Integer to the Sum of Two No-Zero Integers
c语言·算法·leetcode
Zw-awa9 小时前
OLED为什么能显示字:软件I2C换成硬件I2C
stm32
不脱发的程序猿10 小时前
GD32串口数据收进内存之后DMA如何处理?
单片机·嵌入式硬件·dma·嵌入式·串口通信·gd32
冻柠檬飞冰走茶10 小时前
字母移动游戏题解:贪心求最长可保留连续子串(附 C 实现)
c语言·开发语言·数据结构·算法·动态规划
FPGA小迷弟14 小时前
FPGA 与千兆以太网校验和:从原理到 Verilog 实现
嵌入式硬件·fpga开发·无人机·ai编程·fpga
Hhy_110714 小时前
《C++深度解构06》手撕 string —— 从底层内存管理到深浅拷贝彻底吃透
c语言·开发语言·c++·学习·visual studio
Eyfcom14 小时前
〖物联网终端实战 02〗RFID 批量群读防碰撞算法:整托盘入库如何做到“秒级”识别
物联网·物流系统·快递系统·跨境物流快递系统·物联网终端实战
Zw-awa1 天前
协议是人为约定:同一个字节能有三种意思
stm32