ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

STM32启动流程详解:main函数之前到底发生了什么?

STM32启动流程详解:main函数之前到底发生了什么? 写这篇东西的起因是我团队里一个新来的同学问了我一个问题他在桌面上写 C 语言程序就是int main()进去然后return 0出来可到了 STM32 上同样是int main()为什么点 Debug 之后程序光标第一次停住的地方不是main第一行而是某个反汇编窗口里的奇怪地址顺着这个问题我跟他讲了差不多一个下午从启动文件讲到了__main从栈顶讲到了中断向量表。后来我索性把这些话整理成文就有了你现在看到的这篇。1. main 在标准 C 里到底算什么先说一个可能颠覆很多人认知的事实在 C 语言标准里main只是一个“约定俗成的入口点”它不是编译器硬性要求的魔法函数更不是 CPU 通电后第一个执行的代码。1.1 编译器并不认识 main链接器才认识把下面这段代码交给你电脑上的 gcc#include stdio.h int main(void) { printf(hello\n); return 0; }编译器 gcc 做的事情是把这个 C 文件翻译成汇编、再翻译成目标文件.o。在这个过程中main对编译器来说就是一个“普通的外部符号”跟你自己写的foo()、bar()没有任何本质区别。真正对main有特殊要求的是链接器。链接器拿到目标文件之后要决定最终可执行文件里那一个“程序的起点”。桌面 Linux 上你用gcc hello.c -o hello编译实际执行的链接命令大概是这样的可以加-v看详细过程gcc -v hello.c -o hello你会看到最后一步调用了collect2然后collect2会调用ld并且在命令行里带着一个关键的对象文件通常是/usr/lib/x86_64-linux-gnu/crt1.o这个crt1.o就是 C 运行时启动文件的“桌面版”。它里面定义了一个叫_start的符号那才是操作系统把控制权交到进程手里之后真正执行的第一行代码。而main只是_start后续调用链上的其中一个函数而已。所以main之所以是“入口”是因为链接器默认把_start当作入口并约定_start会去调用main。这不是 C 语言语法层面的事这是工具链和运行时库的契约。提示编译裸机或嵌入式程序时如果你看到链接错误 “undefined reference tomain”或者是 “multiple definition ofmain”本质上都是链接器在找那个约定的符号而不是编译器在检查你的函数名。1.2 标准里还有两个特殊之处C 标准对main的特殊要求主要有两个程序启动环境hosted environment中main是入口函数可以带argc和argv参数。main可以不需要显式return如果函数执行到末尾没写returnC99 起默认返回 0表示正常退出。问题来了桌面上你写argc、argv理所当然因为操作系统和 C 运行时会帮你把命令行参数准备好、把环境变量准备好再调用你的main。可 STM32 这种裸机环境呢既没有操作系统也没有谁去解析命令行。那岂不是说main的两个“标准福利”在单片机上统统没有确实没有。STM32 上的main被调用时传进去的参数实际上是“未定义”的编译器一般会把寄存器或栈上残留的随机值塞给argc、argv但没人会真的去读它们。2. 桌面程序里 main 之前发生了什么为了搞清楚嵌入式里 MCU 是怎么“伺候”你的main的我们有必要先看一眼桌面世界里那条更复杂一点的链路。这样你才会理解裸机环境下缺了哪些东西需要我们自己补上。2.1 从 _start 到 main 的一路接力Linux 上一个用 glibc 编译出来的可执行文件启动链路大概是这样的内核 execve → 载入 ELF → 跳转到 e_entry也就是 _start → _start 设置栈帧、调用 __libc_start_main → __libc_start_main 做初始化locale、stdio 等 → 调用全局构造函数.init_array → 调用 main(argc, argv, envp) → 调用 exit → 执行全局析构对你没看错C 语言里那些“文件开头定义的全局对象”、“__attribute__((constructor))修饰的函数”它们的执行时机全都在main之前。也就是说main并不是程序的“第一现场”它只是“初始化完毕之后正式开演”的那个舞台。2.2 裸机为什么没有这套STM32 上不存在“内核进程”“动态链接器”“环境变量”这些概念。CPU 上电之后世界是一片空白的栈指针SP还没设置。PC 指针不知道要去哪里取指令。全局变量可能没有被初始化。C 库里那些依赖底层write、sbrk的机制如果没有移植层根本工作不了。所以在裸机世界里没有任何“天降的运行时”来替你准备舞台一切都得在main之前由一个叫做“启动文件”的东西手动做好。3. STM32 的世界里main 是终点而不是起点下面是全文最核心的部分当我们双击 Debug让 Keil 或 STM32CubeIDE 把程序下载到 Flash然后按下复位芯片内部到底发生了什么3.1 第一行代码不是 C 写的STM32 上电复位之后CPU 硬件会做两件非常固定的事情这两件事不依赖任何 C 代码从地址0x00000000处读取初始栈指针的值加载到SP。注意Cortex-M 内核把这个地址映射到了 Flash 的0x08000000也就是我们存放中断向量表的地方。从地址0x00000004处读取复位向量的值加载到PC然后从那个地址取指令执行。所以真正意义上的“第一行代码”是存放在中断向量表第二个位置偏移 4 字节的那个复位向量指向的代码。在 STM32 工程里这个向量通常叫Reset_Handler它的实现放在启动文件里比如startup_stm32f103xe.s。一个典型的向量表开头长这样__Vectors DCD __initial_sp ; 0x00000000 栈顶 DCD Reset_Handler ; 0x00000004 复位向量 DCD NMI_Handler ; 0x00000008 DCD HardFault_Handler ; 0x0000000C ...注意__initial_sp通常是链接脚本里指定的一个地址比如0x20005000。它不一定非得是 RAM 的物理末尾但一定得是“有效可用的栈顶”。很多新手把__initial_sp错改成 0结果程序一跑就 HardFault问题就在这里。3.2 Reset_Handler 的三件大事复位之后Reset_Handler要“手动”完成所有运行时准备。典型的启动文件里Reset_Handler的汇编逻辑是这样的Reset_Handler PROC EXPORT Reset_Handler IMPORT SystemInit IMPORT __main LDR R0, SystemInit BLX R0 LDR R0, __main BX R0 ENDP这里有两个关键符号SystemInitC 语言写的一个函数一般在system_stm32f1xx.c里。它的作用是配置时钟树。因为 STM32 上电后默认用内部 HSI高速内部时钟频率不高而且很多外设的时钟源还没接好。不先把系统时钟切到外部晶振、配置好 PLL你后面所有关于串口波特率、定时器周期的计算全都是错的。__main注意它不是 C 里的main而是 C 运行时库的初始化入口。这个名字是 ARM 编译器armcc / armclang约定的它负责做 C 程序运行前的最后准备下面单独讲。所以在 STM32 上你写的main函数其实是在整个启动链路的最后一环才被调用的。可以这么说main更像是“开头仪式结束后的第一句正式对白”而不是“整台戏的开幕”。3.3 __main 不是 mainARM 编译环境里__main是一个很出名但容易混淆的符号。它做了以下几件关键事情初始化零初始化区.bss。拷贝已初始化数据.data从 Flash 到 RAM。如果使用了 MICROLIB会设置好堆、栈。调用分散加载文件scatter file里描述的加载/执行域关系。最后调用__rt_entry再跳转到用户的main。这一整套在桌面 Linux 上由 glibc 的__libc_start_main做在 STM32 的裸机世界则由 ARM C 库的__main来做。名字里都带“main”却是两个完全不同的东西。实操体会不少人在写自带 Bootloader 的工程时会在自己的跳转代码里直接((void (*)(void))APP_ADDR)();跳到 App 的main。这个做法在小工程里能跑但严格来说不完整。因为 App 如果用了中断向量表偏移你需要先设置SCB-VTOR而且 App 的__main还没有执行.data没拷贝、.bss没清零静态变量全是一堆神秘值。正确做法是要么复位跳转要么自己想办法完成编译器运行时初始化。这是个非常经典的坑。3.4 一张图讲完全流程我不太喜欢画太复杂的架构图但这段流程我们用一个线性列表就能说清楚上电/复位 ↓ 硬件从 0x08000000 取 __initial_sp → 设置 SP 硬件从 0x08000004 取 Reset_Handler → 设置 PC ↓ Reset_Handler: ① 调用 SystemInit() → 配置时钟 ② 调用 __main() → ARM C 库初始化 ├── 拷贝 .data 到 RAM ├── 清零 .bss ├── 初始化堆栈 └── 调用 __rt_entry 进入用户 main ↓ 用户的 main()你会发现一个有意思的事情整个启动链路上都是“初始化、初始化、再初始化”直到main出现才进入真正的业务逻辑。4. 启动文件里到底藏了多少细节很多新手拿到 STM32 工程模板第一反应是把startup_stm32f103xe.s当成“上帝给的晦涩汇编”不敢碰。其实它没有想象中那么可怕拆开看就是几段固定格式。4.1 中断向量表的全貌在startup_xxx.s里你会看到一串DCD指令。这是数据定义指令意思是“在这里放一个 32 位数值”。它不是在写代码而是在建一张地址表。Cortex-M 内核要求这张表必须放在 Flash 起始地址或你设置的VTOR指向的地址表里的每一项对应一个中断或异常的处理函数地址。举个例子F103 系列的手册会告诉你中断向量表的顺序第 0 项是栈顶第 1 项是复位第 2 项是 NMI第 3 项是 HardFault……后面才是各种外设中断。这张表我们可以想象成一个“紧急联络簿”CPU 每遇到一个异常或中断就会跑到联络簿里查对应的电话号码处理函数入口地址然后拨过去。如果你把表里的地址改错了轻则某个中断不正常重则一上电就飞。4.2 Reset_Handler 为什么用 BX 而不是 BL之前给的示例代码里最后一跳用的是BX R0也就是“分支并切换状态”不是普通的BL调用。为什么因为BL会保存返回地址到 LR意味着你希望__main执行完后还能回来继续跑Reset_Handler后面的代码。但现实是__main初始化完以后会直接进入你的main而main中如果写了死循环就永远回不来了就算main返回了也会被__rt_entry转到exit这和Reset_Handler也没关系了。所以这里用BX更语义化“跳过去别再等我回来。”类似的细节还有在进入main前有些编译器还会要求确保R0、R1、R2这些寄存器不被依赖。少数启动文件里会清掉这几个寄存器这也是一种严谨的做法。4.3 弱定义和中断函数名启动文件里每个中断向量后面都会对应一个xxx_Handler。你不实现它也没关系因为启动文件里默认给它们写了一个“弱定义”的死循环版本HardFault_Handler PROC EXPORT HardFault_Handler [WEAK] B . ENDP说白了就是“出事就原地打转”。你如果自己在 C 文件里实现一个同名的HardFault_Handler由于启动文件里做了[WEAK]标记链接器会优先采用你写的强符号版本。这就是为什么你在工程里随便写个void HardFault_Handler(void) { ... }就能拦截硬件错误中断的原因。实用建议我刚做 STM32 那会儿碰到HardFault一脸懵后来习惯性在HardFault_Handler里加一个断点配合 IDE 的 Call Stack 查看是哪个函数触发的。这个方法救了我很多次比瞎猜强一百倍。5. __main 的三大件数据、零区和栈我们已经知道__main是跳向用户main前最后一步的“总调度”。现在拆开看它到底怎么干活。5.1 .data 为什么要从 Flash 拷到 RAM先看现象。你在 C 文件里写下int global_counter 100;这个100是一个初始化值。问题在于单片机的 RAM 掉电就丢而 STM32 上电之后RAM 里的值是随机的。所以这个100必须存放在非易失的 Flash 里等开机后再从 Flash 复制到 RAM 中对应的地址。这就是.data段的行为。链接脚本分散加载文件会把.data的“初始映像”放在 Flash 的某个地址加载域同时指定它执行时要被拷贝到 RAM 的某个地址执行域。__main负责这段搬运。5.2 .bss 为什么要清零再看现象int global_flag;你没写初始值C 标准默认它是 0。那上电后 RAM 里的随机值显然不能要所以启动代码必须把这段区域清零。这个区域叫.bss。有人会问为什么不把清零也拖到 Flash 去不可能因为 Flash 只能挨个写、速度慢而且.bss里面本来就是没初始化的变量没必要占 Flash 空间。直接在 RAM 上清零即可。5.3 堆和栈__main还会初始化堆和栈栈Stack局部变量、函数调用返回地址压栈用向下生长。栈顶在向量表第一个位置__initial_sp指定。堆Heap你用malloc()申请内存时从堆里分配向上生长。嵌入式里如果没有用malloc或 RTOS 的pvPortMalloc堆大小甚至可以设为 0。一个常见的工程错误是把栈设得特别小然后在中断里开了一个巨大的局部数组一进中断栈就溢出了函数还没跑完程序就飞了。排查这种问题最简单粗暴的办法是做个“栈填充哨兵”比如启动时把 RAM 区域填成0xAA跑一段时间后检查栈顶附近的0xAA是否被踩掉。很多 IDE 调试器自带的 Stack Watermark 功能就是这个原理强烈建议开启。6. 用调试器亲眼看见程序“去了哪里”理论讲完来点实操。我建议每个人都自己动手走一遍这个启动流程保证会有种“原来如此”的开窍感。6.1 在 Reset_Handler 下一棒打开一个 STM32 工程我在 STM32CubeIDE 里操作是这样的编译工程进入 Debug 模式。打开startup_stm32f103xe.s在Reset_Handler那一行设置断点。点击复位重启Debug 菜单里的 Restart或按复位按钮程序会停在Reset_Handler开头。此刻你观察一下寄存器窗口会发现SP已经指向__initial_sp指定的地址。PC停在Reset_Handler。R0、R1这些寄存器大概率还是随机值。这意味着CPU 硬件已经帮我们完成了最原始的栈指针装载但还没有执行任何用户代码、没拷贝任何数据、没初始化任何 C 变量。6.2 单步走入 SystemInit然后单步执行LDR R0, SystemInit BLX R0这时会跳进system_stm32f1xx.c里的SystemInit函数。你可以在这里面看它操作 RCC 寄存器把系统时钟源从 HSI 切换到 HSE再通过 PLL 倍频到想要的频率比如 72MHz。我遇到过的典型现象是新手在SystemInit里单步走的时候看到一堆寄存器操作很困惑“这跟我的业务代码有什么关系”关系太大了没有这一步串口的波特率是按内部 8MHz 之类算的结果你配的明明是115200实际出来的全是乱码。6.3 进入 __main 之后的时间单步进入__main这个函数是 ARM 库的代码你通常看不到源码只能看到反汇编。你会看到一堆对Image$$RW$$、Image$$ZI$$这类符号的引用这些符号是链接器生成的Image$$RW$$Base和Image$$RW$$Limit告诉__main要拷贝的数据段从哪到哪。Image$$ZI$$Base和Image$$ZI$$Limit告诉__main要清零的零初始化区从哪到哪。这些符号定义在分散加载文件或链接脚本里是链接器和你运行时之间的一种“交接协议”。注意如果你自己写链接脚本比如.ld文件忘记定义这些符号或者__main找不到它们程序可能直接 HardFault或者全局变量全是垃圾值。这个坑在从标准库切换到-nostdlib的时候特别常见。6.4 最后到达用户的 main当__main把活干完会跳转到你写的那个main。这个时候你再观察RAM 里的.bss变量已经清零了带初始值的全局变量也已经变成你写的初始值。如果你在这之前就在main入口放了一个断点然后查看某个全局变量你会发现它已经是正确的初始化状态这就是前面所有启动代码的功劳。7. 我踩过的几个“启动链”上的坑以下问题都是我在实际项目里遇到过的有些很隐蔽。7.1 中断向量表没有放在正确位置用 STM32 标准外设库时默认向量表是在 Flash 起始0x08000000。但如果你搞了一个 Bootloader把 App 放在0x08008000却忘了设置SCB-VTOR 0x08008000那么中断来了之后CPU 还是会去 Flash 起始位置查向量表结果查到的还是 Bootloader 的向量表进了 Bootloader 里的默认 Handler程序就乱套了。解决方法是在 App 的启动代码里尽早设置中断向量表偏移通常放在SystemInit()之后、main最开始的地方都行SCB-VTOR 0x08008000;7.2 堆栈地址写到了不可用 RAM有的芯片 RAM 分多个区域比如 F103 的0x20000000是 SRAM但有些型号有 CCM RAM如 F407 的0x10000000。CCM RAM 不能直接被 DMA 访问。如果你把栈顶指到了 CCM RAM恰好程序里又用 DMA 传输一进 DMA 中断或 DMA 操作就出诡异问题你会排查到怀疑人生。所以看 BMI内存映射图判断栈地址是否合理真不是只见于书本的知识。7.3 启动文件被优化掉导致全局变量异常有些 IDE 在 Release 模式下开了较高优化或者你自己设了-ffreestanding、-nostartfiles可能导致标准启动文件没有被链接进来。这时候你会发现main确实能跑但所有全局变量简直是一团乱麻。原因是.data没人去拷贝、.bss没人去清零。遇到这种情况优先检查链接过程里是否包含了startup_xxx.o以及是否定义了__main标号。7.4 main 返回之后去了哪里你写了int main(void) { // ... return 0; }然后呢STM32 上没有任何操作系统接收这个返回值。__rt_entry会在main返回后调用exit而裸机 C 库的exit通常什么都不做或者是个死循环。本质上裸机环境下main返回等于裸奔死循环所以绝大多数裸机程序都长这样while (1) { // 主循环 }就算业务逻辑真跑完了最后也建议留一个while(1);不要 return否则程序行为完全不可预期。8. 进阶当 main 之后不再只有“死循环”很多嵌入式老手对这块的理解会进一步延伸到 RTOS、Bootloader、甚至动态加载。简单说说两条进阶路径帮你把知识网串起来。8.1 引入 RTOS 后main 变成了“铺垫”如果你用的是 FreeRTOS你会发现main依然是入口但它现在承担的任务变成了SystemInit之后在main里创建几个任务、初始化外设然后调用vTaskStartScheduler()。这个函数启动调度器之后就不会返回RTOS 接管了 CPU 的执行权。所以你可以这么理解裸机main是你的业务“主战场”。RTOSmain是“赛前热身区”真正的业务被拆到各个任务函数里main只是把这些任务挂好然后让出控制权给调度器。有趣的是FreeRTOS 的vTaskStartScheduler()内部会自己分配任务栈所以你提交给编译器的那块“主栈”可能只用来跑启动代码和main前期的准备工作。如果你把主任务所有的大数组、深递归全部压在main的栈上那你已经把“启动栈”和“任务栈”混为一谈了迟早溢出。8.2 自定义入口点把 main 换掉可行吗严格来说链接器入口可以不是main。比如在链接脚本里设置ENTRY(My_Entry)或者在 ARM 分散加载文件里指定入口。很多 Bootloader 工程为了瘦身会干脆不用 C 库的__main而是自己写一个极简的Reset_Handler手动拷贝几个段然后跳进业务代码。这种做法能大幅减少启动代码体积但对开发者的要求也高你得自己确保.data、.bss、堆栈全都正确。我个人的建议是初学者不要为了“炫技”去魔改启动文件。先用标准启动链跑通项目等你能把启动文件每一行的含义讲清楚、能单手反汇编读懂__main的行为什么时候你嫌它笨重了再动手剪裁也不迟。9. 总结一下代码不止从 main 开始而是“被带到” main回到开头的问题你的代码后来去了哪里这个问题的答案其实不是一个坐标而是一条链CPU 上电 → 硬件取向量表 → 跳到Reset_Handler→ 调用SystemInit配置时钟 → 进入__main初始化 C 运行时 → 最终才抵达你写在main里的业务逻辑。你写的每一行 C 代码都是在这条链路的末端才真正“活”起来的。但反过来说正因为有了前面这些“看不见”的准备工作你的main才能真正安心地使用全局变量、调用库函数、配置外设。我在实际调试中养成的一个习惯是每个新工程的第一次 Debug我都会在Reset_Handler、SystemInit、__main和main各下一个断点然后整个跑一遍看它是怎么一步步过来的。这比任何文档都直观。等你哪一天能不看启动文件就能把这条链路脱口而出的时候STM32 在你眼里就不再是“一个能点灯的神秘单片机”而是一台你完全清楚它每一拍在干什么的计算机了。最后再分享一个小技巧调试时如果发现程序跑飞先别急着打断点查逻辑。直接去反汇编窗口看当前 PC 跑到哪了。如果 PC 停在0xFFFFFFFE或某个HardFault_Handler的死循环里八成就是中断向量表有问题或者栈溢出了。把这条启动链的每个环节过一遍你排查问题的时间至少省一半。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进