
大一大二那会儿我在机房里敲下#include stdio.h、int main()、printf(hello world)编译、运行黑色控制台蹦出一行字那一刻觉得自己已经掌握了一门语言。后来转去做嵌入式第一次在 STM32 工程里点开main.c看到的还是那个int main(void)还是那对花括号可我把断点打在main第一行的时候发现事情完全不对劲——时钟早就被改过了某个全局变量已经有了初值甚至连灯都可能闪了一下。同样是main一个前面站着操作系统一个前面什么都没有只有一片刚上电的硅片和一张向量表。这篇文章就从 C语言 里那个最熟悉的main出发一路走到 STM32 的main把中间那几百毫秒里发生的事拆开讲清楚。如果你正在学 C语言、正在折腾 STM32 的启动文件、或者被undefined reference to main这类报错卡住过这篇内容应该能帮你把这条链路一次性理顺。1. 两个 main 到底差在哪先把问题问对1.1 从一行 hello world 说起先回忆一下最经典的场景。你写一个main.c内容无非就是包含头文件、定义main、调printf、return 0。然后执行gcc main.c -o main跑起来屏幕上出现hello world。整个过程顺畅得让人产生一种错觉main就是程序的开头CPU 从main的第一行开始执行。这个错觉之所以能成立是因为有一层你看不见的东西替你把地面铺平了。程序被加载起来的时候谁给main准备了栈谁把argc、argv塞进寄存器printf依赖的那套缓冲区和文件描述符表是谁初始化的C 标准库里的那些全局状态是谁摆好的答案都不在main里而在main之前。我后来给新人讲这块内容时习惯用一个比喻main不是家它只是客厅。你能在客厅里会客、吃饭、看电视是因为有人提前把水电煤气都通好了、把门锁装好了、把家具搬进来了。C 语言的main就是这样一个已经装修好的客厅STM32 的main则是毛坯房——你进去的时候水电煤气得自己接。理解这个差别后面所有细节才有落脚点。1.2 为什么 STM32 的 main 看起来什么都还没做很多人第一次看 STM32 的main.c会觉得它很空。CubeMX 生成的工程里main开头那几行通常是HAL_Init()、SystemClock_Config()、MX_GPIO_Init()然后进while(1)。看着像是什么都没干其实这几行已经把整个系统的地基重新夯了一遍。关键在于单片机上电时硬件状态是原始的。Cortex-M 内核复位后时钟是内部高速时钟频率不是你想要的外设时钟默认全关GPIO 默认是模拟输入或者浮空SysTick 没配置中断优先级是默认值。这些东西没人替你设置因为它们不属于语言运行时的一部分而属于芯片厂商定义的环境。桌面操作系统会帮你做这些单片机没有操作系统哪怕跑 RTOS那也是进main之后才起来的所以得你自己来。这就是为什么我一直坚持一个观点看 STM32 的main不能只看main里写了什么得看它是从哪儿被谁叫进来的。1.3 我的分析思路顺着启动链条往回走面对代码后来去了哪里这个疑问最有效的办法不是往前推而是往回倒。具体做法是从main这个符号出发去链接产物里找谁引用了它再顺着调用链一层层往前追直到追到复位向量。这条链路在桌面上和在单片机上各有名字但骨架是同一套硬件或加载器把控制权交给某个约定好的入口入口负责初始化运行时环境运行时环境准备好了才去调用mainmain返回后还得有人负责收尾。桌面端这条链叫_start→__libc_start_main→mainSTM32 上叫Reset_Handler→__main或__libc_init_array→main。名字不一样思路一模一样。下面两章我分别把这两条链拆开。2. C 语言 main 之前的那条隐形链条2.1 编译、汇编、链接main 只是一个符号先说一个反直觉的事实在编译器的眼里main一点特殊性都没有。你写int main(void)和写int foo(void)编译器生成的汇编几乎一样区别只是函数名不同。main的特殊地位是链接器给的——链接器在生成可执行文件时会把一个名为_start的入口设置为程序入口点而_start里会调用main。如果你把main改名成foo链接器立刻报错/usr/bin/ld: /usr/lib/x86_64-linux-gnu/Scrt1.o: in function _start: (.text0x1b): undefined reference to main这条报错信息信息量很大。它告诉你两件事一是main必须存在二是引用main的地方在Scrt1.o里。Scrt1.o是 C 运行时C runtime简称 crt的一部分通常由glibc或者你的工具链提供编译器编译你的代码时根本没管它是链接阶段才把它拼进来的。所以当你看到编译器未包含 main 类型这类说法时要意识到这是个不太准确的表述。真正的情况是链接器找不到符合约定的main符号或者找到的main签名跟启动代码期望的对不上。工具链不会去检查 main 的类型它只是按符号名去找找不着就报错。2.2 _start 与 __libc_start_main 干了什么_start是真正的程序入口它由crt1.o提供。反汇编看一眼大概是这个样子不同架构和 libc 实现略有差异0000000000401040 _start: 401040: xor %ebp,%ebp 401042: mov %rdx,%r9 401045: pop %rsi ; argc 401046: mov %rsp,%rdx ; argv 401049: and $0xfffffffffffffff0,%rsp 40104d: push %rax 40104e: push %rsp 40104f: xor %r8d,%r8d 401052: xor %ecx,%ecx 401054: mov $0x401060,%rdi ; main 的地址 40105b: call 401030 __libc_start_mainplt看最后两行就明白了main的地址被当作第一个参数%rdi传给__libc_start_main。也就是说main是被人传进去的而不是被跳过去的。__libc_start_main拿到这个函数指针之后先做一大堆初始化工作最后才call *%rdi。__libc_start_main里做的几件关键事包括初始化线程局部存储TLS、设置栈保护stack canary、注册atexit和__cxa_atexit的退出处理链、加载并初始化动态链接相关的结构、调用.init_array段的构造函数C 的全局对象构造就靠这个、设置argc/argv/environ最后才是调用main。main返回之后它还会接住返回值跑exit流程冲刷stdio缓冲区、调用atexit注册的函数、跑.fini_array的析构。这里面最容易被忽略的是缓冲区冲刷。为什么你的程序跑到main最后忘了return或者提前exit输出有时候会丢因为数据还在用户态的缓冲区里靠exit流程把它写出去。手动调_exit直接进内核缓冲区就没人管了。这就是运行时替你做事的价值。2.3 命令行参数是怎么变出来的argc和argv的来源也值得说一句。程序刚被内核加载的时候栈顶的布局是内核按约定摆好的先是命令行参数个数然后是一串参数字符串指针最后是环境变量指针数组。_start只做了一件很轻的活把当前栈指针当成argv的起点传给__libc_start_main顺手把栈对齐到 16 字节。所以你在main里拿到的argc、argv并不是谁计算出来的而是内核直接摆在栈上、运行时帮你转手的。这也解释了一个现象如果你绕开main自己写一个入口函数想拿到环境变量就得自己按这个布局去解析栈一不小心就踩到对齐问题。2.4 用 objdump 亲手验证一遍光看理论容易飘我建议你自己跑一遍。先写个最简程序#include stdio.h int main(int argc, char *argv[]) { printf(argc %d\n, argc); return 0; }编译的时候加上-static可以少一些动态链接的干扰如果你用的工具链支持的话然后gcc main.c -o main objdump -d main | grep -A 20 _start readelf -h main | grep Entry nm main | grep -E (_start|main)$readelf -h输出的Entry point address应该指向_start而不是main。这一条就能彻底打破程序从 main 开始的误解。我当年在机房用gdb断点打在main然后bt看调用栈发现栈底躺着一个__libc_start_main那一瞬间才算真正把书上的话变成了自己的理解。提示如果你在 Windows 上用 MSVC入口函数名是mainCRTStartup或者WinMainCRTStartup逻辑是一样的只是初始化函数叫__scrt_common_main_seh同样是在里面才去调main。3. STM32 上电到 main硬件与启动文件接力3.1 复位那一刻从向量表开始桌面程序靠加载器把控制权交给_startSTM32 没有加载器靠的是内核的复位行为。Cortex-M 内核在复位时会做两件极其固定的事从地址0x00000000映射到实际的启动存储区通常是 Flash 的0x08000000读第一个 32 位字把它装进主栈指针MSP从0x00000004读第二个 32 位字作为复位处理函数的地址跳过去执行。这就是向量表存在的意义。它不是什么软件约定而是内核硬性规定的行为。所以你的工程里那张g_pfnVectors表第一项必须是栈顶地址第二项必须是Reset_Handler。写反了程序上来就飞。我用一个检查方法给新人做演示在 Keil 或者 GCC 工程里编译完后看.map文件找RESET段的地址应该是0x08000000再打开startup_stm32xxxx.s确认第一行.word _estack第二行.word Reset_Handler。这两条对上了向量表就没错。3.2 startup 文件逐行拆解启动文件是这段链路里最常被跳过不看的部分但它恰恰是答案所在。以 GCC 风格.s用 GNU as 语法为例核心结构大概是.section .isr_vector,a,%progbits g_pfnVectors: .word _estack .word Reset_Handler .word NMI_Handler .word HardFault_Handler /* ... 其他异常和外设中断 ... */ .text Reset_Handler: ldr r0, _estack mov sp, r0 bl SystemInit bl __libc_init_array bl main bx lr四步每一步都有明确意图。第一时间重新加载栈指针是为了防止某些调试器或者 bootloader 留下的栈状态不一致第二调SystemInit通常负责配置时钟树、使能 FPU如果有、设置向量表偏移SCB-VTOR第三调__libc_init_array把.init_array段里的函数指针逐个执行C 的全局对象构造、attribute((constructor))标注的函数都在这里被调用最后才bl main。KeilARM Compiler的 startup 文件看起来不一样它在Reset_Handler里调的是__main而不是mainReset_Handler PROC EXPORT Reset_Handler [WEAK] IMPORT __main IMPORT SystemInit LDR R0, SystemInit BLX R0 LDR R0, __main BX R0 ENDP这个__main很容易被误认为是用户的 main。它其实是 ARM C 库的入口函数负责执行分散加载scatter loading——把.data段从 Flash 拷贝到 RAM、把.bss段清零然后才跳转到用户的main。这就是为什么 Keil 工程里全局变量初值能正确出现、未初始化变量确实是零。注意__main和main是两个不同的符号差两个下划线。我见过有人手写启动文件时把IMPORT __main写成IMPORT main编译能过运行起来全局变量全是乱的。这种坑不看 map 文件根本查不出来。3.3 SystemInit 与时钟树SystemInit这个名字在不同厂商的库里有不同实现但目标一致把芯片从复位默认状态带到可以正常工作的状态。最典型的工作是切换时钟源。以 STM32F1 为例复位后默认用内部 8MHz 的 HSI而大多数工程希望跑在 72MHz 的外部晶振 PLL 上。SystemInit里会去使能 HSE、等待稳定、配置 PLL 倍频系数、切换系统时钟源、更新SystemCoreClock变量。这里有个细节特别值得注意SystemCoreClock这个全局变量在很多例程里被SysTick配置、延时函数、串口波特率计算依赖。如果时钟切换失败比如晶振没起振程序通常会退回到 HSI 继续跑这时候SystemCoreClock的值和你的预期不一致表现就是串口打出来一堆乱码、延时严重不准。我调试过一块板子现象是串口偶发乱码查了半天最后发现是晶振负载电容选得不合适起振时间偏长偶尔超时回退到 HSI。硬件问题伪装成软件问题这种事在嵌入式里太常见了。3.4 Keil 的 __main 和 GCC 的 __libc_init_array前面提到 Keil 走__mainGCC 走__libc_init_array这两者承担的角色有重叠也有差异我把它们放在一起对比一下对比项KeilARM CompilerGCCarm-none-eabi启动文件里的调用__main__libc_init_array后接main数据段拷贝由__main内部通过分散加载完成由启动文件或链接脚本配合完成常见做法是在Reset_Handler里手动拷贝零初始化由__main完成视启动文件实现通常需要显式清零.bssC 全局构造由__main内部调用由__libc_init_array调用配置来源.sct分散加载文件.ld链接脚本这张表的实用价值在于当你在两种工具链之间迁移工程时知道哪些事是自动的、哪些事得自己补。我自己从 Keil 迁到 GCC 的时候就吃过亏——GCC 的链接脚本里如果没写好.data的加载地址和运行地址全局变量初值就是错的而且编译器不会报任何警告。3.5 分散加载与链接脚本谁把变量搬到 RAM这一节是很多人的知识盲区我尽量说清楚。全局变量分三类有初值且初值非零的放.data段。它的初值存在 Flash 里运行时必须被拷到 RAM因为变量要可写。有初值但初值是零的以及没写初值的放.bss段。为了省 Flash 空间它不占 Flash只需要在 RAM 里留出空间并清零。加const的放.rodata留在 Flash 里就行。Keil 的分散加载文件长这样LR_IROM1 0x08000000 0x00010000 { ER_IROM1 0x08000000 0x00010000 { *.o (RESET, First) *(InRoot$$Sections) .ANY (RO) } RW_IRAM1 0x20000000 0x00005000 { .ANY (RW ZI) } }LR_IROM1是加载区ER_IROM1是执行区RW_IRAM1是 RAM 区。那句*(InRoot$$Sections)就是把__main需要的搬运代码段固定进来的关键删了它程序就跑不起来。GCC 的链接脚本里对应的写法是给.data段配一个加载地址.data : { _sdata .; *(.data*) _edata .; } RAM AT FLASH .bss : { _sbss .; *(.bss*) *(COMMON) _ebss .; } RAMRAM AT FLASH这句是重点运行地址在 RAM加载地址在 Flash。然后在启动文件里得有人把 Flash 里的数据搬到 RAM 的_sdata到_edata之间再把_sbss到_ebss清零。这段搬运代码Keil 帮你写好了GCC 下往往得自己写在Reset_Handler里。这就是很多人移植工程后全局变量初值不对的根本原因。4. 动手实操用最小工程验证整个启动链路4.1 工程搭建与关键配置理论讲完来点能直接抄的。我在验证启动链路时习惯搭一个最小工程只点一个灯外加一个串口输出关键信息不引入任何 RTOS 和中间件。工具链用arm-none-eabi-gcc加 Makefile因为这样每一层都暴露在你眼前看得清楚。关键配置有这几处链接脚本里的ENTRY(Reset_Handler)、栈顶符号_estack、堆栈大小定义、Flash 和 RAM 的起止地址。栈顶地址一般设在 RAM 末尾比如 RAM 是0x20000000起、大小 64KB那_estack 0x20010000。这个值会出现在向量表第一项和Reset_Handler的开头两处必须一致。我踩过的一个坑是把_estack设在 RAM 中间忘了上面还有别的段结果程序跑一会儿栈就长进.bss里把某个变量覆盖了。现象非常诡异功能偶尔正常偶尔乱用调试器全速跑看不出来单步又一切正常。这类问题只能靠map文件加栈使用量分析来定位。4.2 反汇编与 map 文件解读工程编译完之后第一件事是确认入口和向量表arm-none-eabi-objdump -d build/firmware.elf | head -60 arm-none-eabi-nm build/firmware.elf | grep -E Reset_Handler|_estack| main$|SystemInit arm-none-eabi-size build/firmware.elfobjdump开头就能看到向量表前面两个 32 位数分别是_estack和Reset_Handler的地址。nm用来确认符号存在且地址合理。size命令输出text、data、bss三个值其中data就是需要搬运的那部分大小。如果data比 0 大很多而你的启动文件里没有搬运代码那全局变量初值必然是错的。map文件里我重点看三样RESET段是否在0x08000000、.data段的加载地址和运行地址是否分别指向 Flash 和 RAM、.bss的边界符号_sbss/_ebss是否都有定义。这三样对了启动链路基本就稳了。4.3 串口打印验证 boot 阶段想在运行时确认每个阶段都执行到了最土也最有效的办法是用 GPI 翻转或者串口打点。串口在这时候有个麻烦printf依赖 C 库和缓冲区而你想验证的恰恰是 C 库的初始化。所以我一般用两级方案在Reset_Handler里初始化好后直接操作 UART 寄存器发几个字节不经过printf验证进 main 之前在main里再用printf打一遍验证进 main 之后。如果只有前者有输出、后者没有问题就锁定在 C 库初始化或者main调用上。这个方法我用来排查过好几次程序卡在启动阶段的问题比盲猜几下断点靠谱得多。打断点有个前提是调试器能正常握手而有些启动异常会连调试连接都拉不起来。提示用寄存器直接发 UART 的方式只适合极短信息比如发一个字符 A。别在里面写字符串格式化那又要拖进库函数逻辑就绕回去了。4.4 一个可控的 HardFault 实验我还建议做一次故意犯错的实验因为只有见过故障排查时才有直觉。做法很简单在main里对着一个非法地址写数据比如*(volatile uint32_t *)0x10000000 0xAA;然后观察现象。程序会进HardFault_Handler如果这个处理函数是个空while(1)你就只能看到程序不动了。这时候把处理函数改一下在里面把LR、PC、PSR这些值读出来存进全局变量或者直接通过串口打出来再重新跑。你会看到出错的位置指向你那行非法访问。这套从栈帧里捞出出错 PC的方法我强烈建议每个做 STM32 的人都练一遍因为现场调试时没有 IDE 的图形界面只有寄存器。我自己的HardFault_Handler里常年放这么一段判断LR的 bit2 决定出错前用的是 MSP 还是 PSP然后按对应栈结构取出压栈的R0-R3、R12、LR、PC、xPSR把PC打出来。这段代码不到三十行救过我无数次命。5. 常见问题与排查速查5.1 现象、原因、排查手段对照表启动阶段的故障有个特点现象五花八门原因高度集中在少数几处。我把遇到过的整理成表方便对照。现象常见原因排查手段链接报undefined reference to mainmain拼写错误、被条件编译排除、库入口符号不一致nm看符号表检查ENTRY配置程序一上电就进 HardFault向量表地址错、栈顶地址非法、时钟配置越界读SCB-CFSR检查_estack和VTOR全局变量初值不对.data段没搬运、分散加载文件缺段看map文件加载地址与运行地址未初始化变量不是零.bss段没清零检查_sbss/_ebss是否定义并被执行中断进不去向量表没随地址重定位、中断未使能、优先级配置错误检查VTOR查NVIC_ISER寄存器程序跑一会儿乱掉栈溢出、堆冲突、中断里做了阻塞操作用栈填充法测栈峰值检查_Min_Stack_Sizeprintf没输出缓冲区没冲刷、重定向未实现、时钟不对导致波特率错加\n或手动fflush核对时钟频率这张表我基本是贴在自己工作台边上的。里面中断进不去那条我遇到最多的具体场景是程序里有 bootloader跳转到应用区之后忘了把SCB-VTOR改成应用区的向量表地址。结果中断来了还去 bootloader 的向量表里找处理函数跳到一个莫名其妙的地址直接飞掉。5.2 全局变量初值不对三个必查点这个问题值得单独拎出来讲因为它的迷惑性太强。程序能跑逻辑看着也对就是某个配置变量的值不是你写的那个。必查的地方有三个。第一链接脚本里.data段有没有配AT加载地址。只写了RAM那段数据就没有存放的位置链接器可能会把它当成只有运行地址的段处理。第二启动文件里有没有搬运循环。GCC 下这段代码得自己写从_sidata开始的 Flash 区域拷_edata - _sdata个字节到_sdata。少写一行初值就全丢。第三初始化顺序。如果某个全局变量的初值表达式依赖另一个模块的初始化结果那就要考虑用构造函数或者显式的初始化函数别指望静态初始化替你解决顺序问题。我当年做第一个 GCC 工程时就是漏了搬运循环结果一个存着校准参数的全局数组全是零排查了整整一个下午。5.3 中断进不去向量表偏移的那个坑除了前面说的VTOR问题还有一个更隐蔽的情况在带 FreeRTOS 或者其他 RTOS 的工程里SysTick的中断处理函数符号名必须和向量表里的名字严格一致。GCC 下如果你在 C 文件里定义了SysTick_Handler但启动文件里.weak声明的名字是SysTick_Handler却因为大小写或者拼写差一个字母链接器会静默地保留弱符号版本也就是死循环那个不报错。程序能跑就是进不了你的处理函数调度器起不来。这类问题的排查方法很直接用nm看最终 elf 里SysTick_Handler的地址再和map文件里向量表第 15 项Cortex-M 的 SysTick 位置的地址比对。不一致就是名字没对上。5.4 我踩过的几个真实坑说点文档里不会写的。第一个坑是调试器的影响。有些调试器在连接时会改写VTOR或者停在外设初始化中间导致单步和全速跑的行为不一致。我遇到过一次单步正常、全速死机最后发现是调试器连接时复位了外设全速跑没有这个复位某个外设的时钟使能位依赖上电默认值。结论是涉及时钟和复位的代码不要过于依赖单步观察多用打点法。第二个坑是__libc_init_array和构造函数里的硬件操作。如果你在某个全局对象的构造函数里就去操作外设而这段代码在SystemInit之后、时钟还没完全稳定行为就不可预期。我的做法是把所有硬件相关初始化都收进显式的Init函数构造函数只做纯数据初始化。第三个坑是栈大小。默认的_Min_Stack_Size在很多例程里是 0x400也就是 1KB。你在main里调一层层嵌套的函数、用几个大数组很容易就超了。测栈峰值的方法是把栈区域预先填上固定模式比如 0xDEADBEEF跑一段时间后从栈底往上看哪里被改写过被改写的最深处就是峰值。这个方法我至今每做一个新项目都会跑一次。注意栈溢出和堆冲突的表现有时和 HardFault 完全一样。别一看到跑飞就怀疑指针很多时候是栈先没了然后你精心构造的指针正好踩到别的变量上误导你往错误的方向查。6. 把这套思路用到更多场景这套顺着启动链往回走的分析方法其实不限于 STM32。你要换到其他 ARM Cortex-M 厂商的芯片上差别只在启动文件写法、库函数名字和分散加载配置的语法上骨架完全一样向量表取栈顶和复位入口、时钟初始化、数据搬运、零初始化、跳到main。你要换到 RISC-V 上入口从向量表变成了_start加若干 CSR 配置但运行时准备这个环节依然存在只是换了个名字。甚至你回到桌面端也一样能用。我后来排查过一个桌面程序的启动崩溃思路就是先看_start、再看__libc_start_main里的init_array阶段、最后才看用户main结果问题出在某个全局对象的构造函数里。如果当时只在main里打断点是永远找不到的。我个人在实际操作中的体会是遇到程序行为和我写的不一样这类问题先别急着怀疑业务逻辑先确认你脚下的地面是不是你想象的那块。main之前发生的事决定了main里那些代码能在什么样的世界里运行。把这条链路摸清楚一次往后每一次换芯片、换工具链、换 RTOS你都会快很多。