
上周有个朋友拎着一块 WeAct 的 STM32F411 板子来找我说代码在 IDE 里编译一点红都没有main()第一行就是点灯烧进去灯就是不亮他怀疑是不是板子坏了。我让他把main()整个删掉只留一个空的死循环重新烧灯照样不亮——他就愣住了。其实问题根本不在main()里CPU 从复位到执行你写的第一行 C 代码之间隔着一整条链子而这条链子上任何一环断了main()都永远不会被调用。很多刚上手 STM32 的人把main()当成程序的起点觉得它是天经地义的存在但从硬件的角度看CPU 根本不认识 main() 这三个字母它只认识地址 0x00000000 上的两个 32 位数字。我打算从 WeAct STM32F411 这块板子的上电瞬间说起把这条链子一节一节拆开复位取向量、BOOT0 与地址重映射、startup 文件里的 Reset_Handler、时钟树、C 运行时的初始化、链接脚本里 .data/.bss 的搬运最后落回实操——当板子不启动时我一般按什么顺序排查。适合已经能写点 STM32 代码、但从来没认真看过 startup 文件和链接脚本的人也适合那些被编译通过但灯不亮折磨过的人。1. 复位释放后的那几个时钟周期CPU 眼里只有两个地址1.1 Cortex-M4 的复位行为硬件只做两件事不做任何初始化按下复位键松手或者上电电源稳定后 NRST 释放Cortex-M4 内核做的第一件事不是启动操作系统也不是初始化外设而是两个纯粹的硬件读操作。第一个是把地址0x00000000上的一个 32 位字读出来写进MSP主栈指针第二个是把地址0x00000004上的一个 32 位字读出来写进PC程序计数器。顺序是先 SP 后 PC这两步做完内核才开始按 PC 指向的地址取指令执行。整个过程不需要栈因为它是硬件直接读取的不经过任何函数调用——CPU 连函数这个概念都还没有。我特别想强调 SP 在 PC 之前这件事。它意味着向量表的前 8 个字节不是可选的元数据或者给调试器看的信息而是硬件协议的一部分。你可以把向量表想象成一张火车时刻表第一格写着你的行李放哪儿栈顶地址第二格写着你去哪个站台复位处理函数的地址列车长内核上车第一件事就是看这两格别的格子后面再说。如果第一格填错了你甚至还没开始执行任何指令栈就指向了不该指的地方如果第二格填错了PC 一跳出去就是取指失败直接进 HardFault。这两格里任何一个字出问题表现都是烧进去什么都不跑而且没有任何串口输出可以告诉你原因因为那时候串口还没初始化。还有一个细节值得拎出来写进 PC 的那个地址最低位必须是 1。Cortex-M 只支持 Thumb 指令集地址最低位在这里被当作状态位来解读硬件会把这一位清掉再跳到实际地址。所以你用 objdump 看向量表看到复位处理函数的地址是 0x080001XX 这种奇数那是对的不是笔误。反过来如果你自己手工往向量表里写了一个偶数地址自己写 bootloader 跳转到 app 的时候经常干这事跳过去马上 HardFault而且这个错误特别隐蔽因为你的代码本身一行问题都没有。我见过至少三次这种情况每次都是盯着跳转代码看了半天最后发现忘了| 1。1.2 BOOT0 决定了 0x00000000 背后站着的到底是谁既然复位时 CPU 硬要从 0x00000000 取数那我们程序明明烧在 0x08000000这中间是怎么接上的答案在 STM32 的启动模式选择和地址重映射上。STM32F411 有两根启动配置脚 BOOT0以及共用的 BOOT1在 F4 上通常是 PB2 或选项字节复位时芯片会采样这两根脚然后把启动存储区整块映射到 0x00000000 这个地址上。BOOT1BOOT00x00000000 映射到实际用途x0主 Flash物理在 0x08000000正常运行用户程序绝大多数情况01系统存储器0x1FFF0000ST 出厂固化的 bootloader支持 USB DFU / 串口 / I2C / SPI 下载11内嵌 SRAM0x20000000调试或特殊用途正常开发几乎不用注意第一行那个 x它表示 BOOT1 无关紧要——正常开发时 BOOT0 拉低就够了。而主 Flash 被映射到 0x00000000这句话的含义是主 Flash 物理上住在 0x08000000但因为它同时出现在 0x00000000 处所以链接脚本把向量表放在 0x08000000复位时 CPU 从 0x00000000 取取到的其实是同一块内存里的同一批字节。这个双重身份就是为什么链接地址和启动地址看起来不一致、但程序却能跑起来的原因。很多人第一次看到链接脚本里FLASH : ORIGIN 0x08000000都会疑惑那 CPU 从 0 取向量不就不对了答案就在这里。再补充一个容易记混的点地址重映射只发生在启动存储区这一小段区域换句话说0x00000000 这块地方被借给了 Flash 或系统存储器但 Flash 在 0x08000000 的原地址始终是可用的。另外复位时的第一次取向量永远来自 0x00000000这一点连 VTOR 寄存器也改不了——VTOR向量表偏移寄存器只影响复位之后异常和中断的取向量位置。所以在写 bootloader app 那种跳转逻辑的时候正确做法是先把 app 的栈顶设进 MSP再写SCB-VTOR APP_ADDR最后跳过去顺序错了第一个中断一进来就跑到 bootloader 的向量表里去了。1.3 WeAct F411 这块板子上的实际操作BOOT0 与 DFU 恢复WeAct 这块板子的硬件配置是新手最该先记住的主控 STM32F411CEU6512KB Flash 128KB SRAM板载一颗25MHz 无源晶振不是常见的 8MHz一颗贴片 LED 接在PC13 且低电平点亮一个复位按键USB-C 座直连 PA11/PA12也就是 USB FS 的 D-/D还有一个 BOOT0 排针板上已经有下拉电阻。这些参数在排查问题时会反复用到尤其是 25MHz 这个数字后面算时钟树的时候它是分子。进 DFU 恢复的标准动作是把 BOOT0 排针短接到 3V3按一下 NRST然后把 BOOT0 的短接拆掉这一步很多人会忘其实拆不拆不影响当前这次启动但拆了更保险此时设备管理器里会冒出一个 STM32 BOOTLOADER 设备用 CubeProgrammer 连上就能擦除、下载、读芯片信息。这个模式下跑的代码是 ST 出厂固化在系统存储器里的 bootloader它同样不是从main()开始的它是 ST 自己写死的代码只认 USB 和串口协议功能就是把 Flash 当硬盘用。这里有几个真实的坑。第一BOOT0 忘记恢复成低电平你烧完代码发现板子还是不跑因为你一直在系统存储器里打转你烧进去的程序根本没被启动——现象和程序没烧进去一模一样的。第二WeAct 上有 USB-C但如果你用的是自己画的板子晶振是 8MHz 却照抄了 WeAct 的时钟配置HSE 起振失败后会退回 HSI而代码里的超时判断很可能卡在Error_Handler()的while(1)里——最终现象还是灯不亮但原因跟main()一点关系都没有。第三板子如果没焊晶振有些批次或自己贴的板子HSE 永远是未就绪情况同上。2. 从 0x08000004 指向的那条指令到 main() 的第一行2.1 startup 文件里的 Reset_Handler 才是真正的第一行代码CubeMX 生成工程的时候会在根目录丢一个startup_stm32f411xe.s这个文件平时你根本不会打开但它才是真正的入口。里面大致长这样我做了删减保留了关键部分.section .isr_vector,a,%progbits .type g_pfnVectors, %object g_pfnVectors: .word _estack .word Reset_Handler .word NMI_Handler .word HardFault_Handler ... .section .text.Reset_Handler .weak Reset_Handler .type Reset_Handler, %function Reset_Handler: ldr sp, _estack bl SystemInit bl __libc_init_array bl main bx lrg_pfnVectors就是那张向量表被链接脚本安排到 0x08000000。它的第一个.word _estack就是复位时 CPU 读进 MSP 的那个数第二个.word Reset_Handler就是被读进 PC 的地址。注意.weak这个修饰它是弱符号的意思如果你在别的地方自己写了一个名为Reset_Handler的函数链接器会优先用你的你没写它就用这个默认的。这是 ST 留的一个后门极少用但知道有这回事挺好。然后看最后四行。ldr sp, _estack是重新把栈顶装一遍其实硬件在复位时已经装过一次了这一行在比较新的 CubeMX 版本里才有老版本没有属于重复劳动但无害。bl SystemInit配置时钟和 FPU。bl __libc_init_array跑 C 运行时的初始化。bl main是整份工程里唯一出现 main 的地方就这一行。CPU 从头到尾不知道 main 是个什么东西它只知道跳到这个地址去执行。所以如果 main 这个符号不存在报错的不是 CPU也不是编译器是链接器——因为这一行的bl指令产生了一个对main符号的未定义引用。最后那个bx lr很值得说。它说明 ST 的这个模板假定 main 不会返回。事实上main里那个while(1)一旦去掉bx lr就会执行而 lr 在复位后是 0xFFFFFFFFbx会把 PC 设成 0xFFFFFFFE取指失败 → BusFault → 升级成 HardFault → 死在 HardFault_Handler 里的死循环。有的版本的 startup 在bl main后面写的是b .原地跳自己那样会直接死循环比 HardFault 稍微礼貌一点。无论哪种结论都是裸机的 main 必须以死循环结尾这不是风格问题是正确性问题。2.2 SystemInit 里藏着的时钟树算账过程SystemInit()在system_stm32f4xx.c里它主要做三件事打开 FPU、设置 VTOR、配置系统时钟。第一件事新手几乎都不知道但很关键STM32F411 是带 FPU 的 Cortex-M4F可是复位后 FPU 是关闭的必须往SCB-CPACR里写0x00F00000才能让协处理器 10 和 11 可用。如果不打开代码里任何一条浮点指令都会触发 HardFault——某天你在main里加了一句float x 1.5f;板子就不跑了原因在这里。CubeMX 生成的工程默认会通过__FPU_PRESENT和__FPU_USED宏在SystemInit里完成这一步但如果自己手搓工程这一步很容易漏掉。第二件事是时钟。WeAct 板子上是 25MHz 晶振配置目标是跑到 100MHzF411 的额定上限。算账过程是这样的参数取值计算结果HSE25 MHz输入参考PLLM25VCO 输入 25 / 25 1 MHz需落在 1~2MHz 区间PLLN400VCO 输出 1 × 400 400 MHz需落在 100~432MHz 区间PLLP4SYSCLK 400 / 4 100 MHzAHB 分频1HCLK 100 MHzAPB1 分频2PCLK1 50 MHz上限 50MHzAPB2 分频1PCLK2 100 MHz上限 100MHzVCO 输入必须落在 1~2MHz 这个区间是 F4 系列 PLL 的硬件约束不是建议值VCO 输出必须落在 100~432MHz也是硬约束。所以 PLLM 不能随便写25MHz 除以 25 刚好得到 1MHz这是有讲究的。同时要记得把HSE_VALUE这个宏改成 25000000否则 HAL 后面算串口波特率、算HAL_Delay全都按 8MHz 折算串口出来的就是乱码。第三件事也是最容易被忽略的是Flash 等待周期。100MHz 的 HCLK 在 VDD 为 2.7~3.6V 时Flash 需要 3 个等待周期同时建议打开预取、指令缓存和数据缓存F4 系列带 ART 加速器缓存开着对 100MHz 下的取指效率影响很大。等待周期设小了Flash 跟不上 CPU 的取指速度会读到错误的数据现象是有时能跑有时死而且换一块板子可能就好了最难查。经验做法是改主频的时候PLL_M/N/P/Q和FLASH_LATENCY一定要成对修改改一个不改另一个迟早出事。提示具体等待周期数值以对应型号参考手册的表格为准不同电压区间和不同温度范围下要求不一样建议直接对着手册里那张表填别凭记忆。2.3 __libc_init_array 不只是跑构造函数__libc_init_array是 newlib 提供的函数很多人以为它就是调用 C 全局对象的构造函数其实它还负责把 C 运行时拉起来。它的实现大致是遍历.preinit_array和.init_array两个段里存放的函数指针数组逐个调用。所以__attribute__((constructor))修饰的函数、C 的全局对象构造、以及某些库的初始化钩子都在这一步执行。这里有个自己写链接脚本时必踩的坑这两个符号__preinit_array_start/__preinit_array_end以及 init_array 那对必须由链接脚本定义它们是指向数组首尾的地址。如果链接脚本里没写链接阶段就会报undefined reference to __libc_init_array相关的错或者更隐蔽地——数组首尾都指向 0运行时直接跑飞。我见过有人把链接脚本简化得太狠把.init_array整个段都删了结果 C 工程在main之前就挂了。还要理清一个顺序问题.data段的搬运和.bss段的清零不在这里做它们在 Reset_Handler 之后的汇编循环里做第 3 节细说。顺序一定是先把 Flash 里的初值搬到 RAM、把 BSS 清零因为 C 代码依赖已初始化的全局变量然后跑构造函数最后才进main。如果哪个工程把顺序搞反了构造函数里读到的全局变量就是随机值——这种 bug 的表现是局部变量都对全局变量是垃圾非常折磨人。2.4 用 objdump 把这条链子一句一句验一遍上面讲的都是应该如此验证方法是把编译出来的 ELF 拆开看。我常用的几条命令# 看各个段的地址和大小重点确认 .isr_vector 的 VMA 是不是 0x08000000 arm-none-eabi-objdump -h build/weact.elf # 直接把向量表的内容打出来 arm-none-eabi-objdump -s -j .isr_vector build/weact.elf # 反汇编复位处理函数附近的代码 arm-none-eabi-objdump -d --start-address0x08000200 --stop-address0x080002a0 build/weact.elf # 看 ELF 头里记录的入口点 arm-none-eabi-readelf -h build/weact.elf | grep -i entryobjdump -s -j .isr_vector的输出长这样小端序要倒着读Contents of section .isr_vector: 08000000 00000220 19030008 ...前四个字节00 00 02 20组合成 32 位字就是 0x20020000正好是 128KB SRAM 的栈顶0x20000000 0x20000后四个字节19 03 00 08组合起来是 0x08000319也就是 Reset_Handler 的地址最低位是 1符合 Thumb 要求。这两个数只要对得上向量表这一步就排除了。顺便说readelf -h里的 Entry point 字段它通常显示 0x080001XX 之类。很多从 Linux 转过来的人会以为这就是程序的入口然后去改它。在裸机上这个字段没有任何作用硬件不读 ELF 头它只读 0x00000004。这一点我们在第 4 节还会展开。3. 链接脚本才是那个决定谁站在 0x08000000的人3.1 .isr_vector 必须排在最前面而且必须 KEEP链接脚本.ld文件里最容易被忽略的两行就是向量表的位置和保留ENTRY(Reset_Handler) MEMORY { RAM (xrw) : ORIGIN 0x20000000, LENGTH 128K FLASH (rx) : ORIGIN 0x08000000, LENGTH 512K } SECTIONS { .isr_vector : { . ALIGN(4); KEEP(*(.isr_vector)) . ALIGN(4); } FLASH .text : { . ALIGN(4); *(.text) *(.text*) ... } FLASH }FLASH加上把它写在 SECTIONS 的最前面保证了向量表被放在 0x08000000 开头。而KEEP()是干什么的当你打开了--gc-sectionsCubeMX 默认就开链接器会做垃圾回收把没人引用的段整个丢掉。g_pfnVectors这个符号从来没有任何 C 代码引用它链接器会很合理地认为它可以被删——删掉的后果是 0x08000000 上站着的变成了别的函数复位时 CPU 把那个函数的头几个字节当栈顶读进 MSP把后面几个字节当复位地址读进 PC结果就是栈指针是个垃圾值、PC 跳到未知区域。这种问题的现象是上电就死连断点都进不去而且因为它跟你的业务代码毫无关系排查时特别容易被忽略。验证方法很直接objdump -h的输出里找.isr_vector那一行VMA 必须是 0x08000000。不是的话回去看你的链接脚本。顺便说ENTRY(Reset_Handler)这行它只是告诉链接器把 Reset_Handler 填进 ELF 头的 Entry 字段给调试器和某些加载器看的硬件不读。所以它写错也不会导致启动失败这点别搞反了。3.2 LMA 和 VMA.data 的两副面孔和那两段搬运循环.data段是链接脚本里最绕的一块因为它有两个地址。VMA虚拟地址/运行地址在 RAM 里LMA加载地址在 Flash 里。为什么要这样因为已经在.data段里的全局变量比如int counter 5;需要一个可读可写的位置RAM 才合适但上电时 RAM 里的内容是随机的没法保存初值 5。所以初值 5 存在 Flash 里上电后由代码把它搬到 RAM 对应的位置。链接脚本里对应的写法是_sidata LOADADDR(.data); .data : { . ALIGN(4); _sdata .; *(.data) *(.data*) . ALIGN(4); _edata .; } RAM AT FLASHAT FLASH就是指定 LMA 在 FlashLOADADDR(.data)拿到那个 Flash 地址赋给_sidata。而 startup 文件里配合的这两段汇编循环就是搬运工LoopCopyDataInit: ldr r0, _sdata ldr r3, _edata adds r2, r0, r1 cmp r2, r3 bcc CopyDataInit ... FillZerobss: ldr r2, _sbss ldr r4, _ebss movs r3, #0 b LoopFillZerobss这段逻辑就是从_sidata开始往_sdata到_edata之间拷贝再把_sbss到_ebss之间全部写 0。第二段特别重要——C 标准要求未显式初始化的全局变量初值为 0但 SRAM 上电时是随机的这个0是运行时帮你刷出来的不是硬件保证的。如果.bss段没清零那些你以为默认是 0 的标志位可能是任意值。我遇到过一次很典型的现象一个状态机标志位在.bss里没清零上电后有 1/3 的概率随机为 1程序启动了就直接进了错误处理分支表现是十次里有两三次不启动。顺便提一个反直觉的点你把某个全局变量显式写成int flag 0;它也可能被编译器放进.bss而不是.data因为优化器认为 0 值和未初始化等价。所以我明明初始化成 0 了这句话并不能保证它在.data里。3.3 _estack、堆和栈一个从上往下长一个从下往上长_estack的定义在链接脚本开头_estack ORIGIN(RAM) LENGTH(RAM);也就是 0x20000000 0x20000 0x20020000。MSP 从这里开始栈向下增长。堆则从.bss结束的位置_end开始向上增长。两者之间是自由区理论上谁长得快谁就先撞上对方。链接脚本里通常还有这两行_Min_Heap_Size 0x200; _Min_Stack_Size 0x400;它们的唯一作用是链接期检查如果某个段的增长让剩余空间小于这个值链接会报错。它们不会在运行时做任何保护。也就是说栈溢出不会被拦住只会安静地踩到.bss或者堆上然后现象变得千奇百怪。经验上裸机里栈最容易被吃掉的地方是printf。一个带浮点的printf(%f)调用链加上缓冲区几百字节栈是很正常的如果是中断里调用printf强烈不建议栈用量还要叠加。我的土办法是在栈区顶端往上填一段0xDEADBEEF水印跑一段时间后看被冲掉多少这是估算栈用量最直接的方式比看 map 文件推算还准。注意中断服务函数用的是 MSP主栈跟main用的是同一个栈。所以一个递归层数深的函数加上一个在中断里调用复杂库函数的 ISR栈溢出风险是叠加的不是独立的。4. 把 main 改名、让它返回、或者干脆不要它会发生什么4.1 改名实验undefined reference to main 到底是谁在报我做过一个很直接的实验把工程里的main改成app_entry其他什么都不动编译。结果是在链接阶段报undefined reference to main。这个报错经常被人说成编译器未包含 main其实不准确——编译器一点意见都没有它只是忠实地把app_entry编译成了一个函数真正出问题的是链接器因为 startup 文件里那条bl main生成了一个对main符号的外部引用而全工程找不到这个符号。这个实验说明的事很清楚main这个名字之所以是入口唯一原因是 startup 文件里写了bl main。名字本身没有魔法。想改成别的名字两种做法一是把 startup 文件里那一行改成bl app_entry二是#define main app_entry之类的宏技巧能work但强烈不推荐会让人看代码时一脸懵。我一般选第一种一目了然而且 startup 文件本来就不常改改一次管一辈子。4.2 让 main 返回LR 0xFFFFFFFF 之后的一段黑暗旅程第二个实验把main里的while(1)换成return 0;其他不动。上电后会发生什么前面说过复位后 lr 的值是 0xFFFFFFFF。bl main会把返回地址写进 lr所以 main 内部的 lr 是启动文件里bl main的下一条指令地址。当 main 返回时执行到 startup 的bx lr此时 lr 已经被恢复成 0xFFFFFFFF严格说是bl main之前保存的那个值bx会把 PC 设成 0xFFFFFFFE最低位被清掉这个地址在 Cortex-M4 的存储映射里是无效区域取指直接触发 BusFault而 BusFault 在默认配置下会被升级成 HardFault最后死在 HardFault_Handler 的死循环里。不同版本的 startup 表现略有差别有的在bl main后面写的是b .即原地跳自己那 main 返回后就安静地死循环有的写bx lr那就走上面那条路进 HardFault。无论哪种程序都不会重启或者回到开头重跑只会停在那里。所以那种main里return 0之后加个HAL_Delay想让它重新初始化的写法纯属一厢情愿。再补一个相关的坑HardFault_Handler 在默认的 startup 里只写了一个死循环什么信息都不打。如果你想排查到底是什么原因进的 HardFault得自己去读SCB-CFSR、SCB-HFSR、以及压栈的 PC/LR 值。这个属于另一个话题了但知道进 HardFault 是有寄存器可以查的这件事能省掉很多盲目猜测。4.3 一个从头到尾没有 main 的纯汇编闪灯程序为了让CPU 不认识 main这件事变得可触摸我写过一个最小的纯汇编版本不链接任何 C 运行时不经过SystemInit直接在复位处理函数里操作寄存器让 PC13 闪起来。核心部分大概是这样.syntax unified .cpu cortex-m4 .thumb .section .isr_vector,a,%progbits .word 0x20020000 初始栈顶128KB SRAM 的顶 .word _start 复位处理函数最低位保持奇数 .section .text .weak _start .type _start, %function _start: ldr r0, 0x40023830 RCC_AHB1ENR ldr r1, [r0] orr r1, r1, #(1 2) 使能 GPIOC 时钟 str r1, [r0] ldr r0, 0x40020800 GPIOC 基地址 ldr r1, [r0, #0x00] MODER bic r1, r1, #(3 26) 清掉 PC13 的模式位 orr r1, r1, #(1 26) 设为通用输出 str r1, [r0, #0x00] loop: ldr r1, [r0, #0x14] ODR eor r1, r1, #(1 13) 翻转 PC13 str r1, [r0, #0x14] ldr r2, 200000 delay: subs r2, r2, #1 bne delay b loop这段代码里从头到尾没有main没有SystemInit没有.data搬运因为没有任何已初始化全局变量也没有 C 运行时。它跑的是 HSI 16MHz 的默认时钟因为没配 PLL所以延时常数需要自己调。它存在的意义不是让你真的这么写工程而是证明一件事把门槛降到最低之后程序跑起来这件事只依赖向量表的前 8 个字节 一段不会返回的代码。其余所有东西——SystemInit、__libc_init_array、main——都是人加上去的约定。4.4 --entry 选项在裸机上没有意义但在 Linux 上意义重大最后一个实验把链接选项里的入口点改成任意一个函数比如-Wl,-e,foo重新烧。程序的行为完全不变。原因还是那句话裸机上没有任何东西读 ELF 头的 Entry 字段Cortex-M 的入口是硬件固定从 0x00000004 那 4 个字节里取出来的跟 ELF 格式无关。所以你甚至可以把这个 ELF 转成 bin 之后ELF 头都不存在了程序照样跑。对照一下 Linux 那边就更有意思了。Linux 内核加载 ELF 的时候确实会跳到 ELF 头里记录的 Entry point那里通常是_start来自 crt0/crt1然后由__libc_start_main去做初始化、最后调用main。所以 Linux 上的main也是被别人调起来的只不过那个别人是 C 运行时的启动代码而不是 startup 汇编。两边在本质上是同一件事main是 C 运行时的一个约定不是硬件或内核的约定。理解这一点之后再回头看CPU 不认识 main这句话就不会觉得是文字游戏了。5. 板子不启动的时候我按这个顺序过一遍5.1 第一步本地看头 8 个字节不打板子就能排掉一半怀疑只要板子出现烧进去了但什么都不跑我的第一步永远是本地检查不接板子。方法是把 ELF 转成 bin然后看开头arm-none-eabi-objcopy -O binary build/weact.elf build/weact.bin xxd -l 16 build/weact.bin期望看到的输出是0000 0220开头也就是 0x20020000。这里有几个判断点如果开头是 0x00000000说明链接脚本里 RAM 长度写错了或者_estack定义错了如果开头看起来像一段代码比如b5xx之类几乎可以确定是向量表被--gc-sections干掉了回去加KEEP如果第二个字是偶数说明这个地址被手工改过或者链接脚本里对这个符号做了运算丢了最低位跳过去必 HardFault。这一步的好处是它完全不依赖硬件几秒钟就能跑完而且能覆盖掉向量表错误这一大类问题。我在帮别人远程看问题的时候第一件事就是让对方把这一行命令的输出贴过来。5.2 第二步时钟和 Flash 等待周期这两件事要一起看向量表没问题之后下一步是时钟。时钟出问题的现象很有特点代码能跑但跑出来的东西不对——串口乱码、延时不准、定时器周期差几倍、USB 枚举失败。排查顺序是这样的检查项期望值出错后的典型现象HSE_VALUE宏25000000WeAct 板串口波特率偏HAL_Delay时间不对晶振实际频率25 MHzHSE 起振失败退回 HSIPLL_M/N/P/Q25 / 400 / 4 / 9系统频率不对可能上不了 100MHzFLASH_LATENCY3100MHz2.7~3.6V随机取指错误偶发死机APB1 分频2PCLK1 超过 50MHz外设行为异常其中偶发死机这一条最值得警惕。Flash 等待周期不够的时候取指偶尔会读到错的内容实际表现是程序跑几秒到几分钟后跳飞而且换个温度、换块板子可能就不复现了。看到这种跑一会儿就死死了以后断点看栈已经乱了的现象第一反应应该就是去核对FLASH_LATENCY而不是去怀疑某个变量被踩了。5.3 第三步接上调试器从 Reset_Handler 开始单步前两步都过了才轮到接调试器。我的习惯是在 Reset_Handler 的第一行下断点复位之后看 PC 是不是停在那里——如果连这个断点都进不去说明问题在启动之前BOOT0 状态、供电、SWD 引脚被程序复用、调试器配置跟代码逻辑没关系。能停住的话单步跟到main的入口同时在main第一行看 MSP 的值正常应该接近 0x20020000如果是个很小的值或者 0说明栈被谁改了。再往下就是把现象和原因做个对照这张表是我自己攒的还挺好用现象大概率原因上电毫无反应SWD 也连不上BOOT0 状态错、供电不足、SWD 引脚被复用SWD 能连上但复位后 PC 跑飞向量表内容不对、复位地址最低位是偶数能进 main但一进就进 HardFaultFPU 没开、访问了未使能时钟的外设、栈越界能跑一段时间后死机Flash 等待周期、栈溢出、中断里用了非重入函数全局变量初值不是预期的值.bss 没清零、.data 搬运循环缺失最后一行那种情况其实很多而且特别隐蔽。有一次一个朋友说他的配置结构体上电后字段全是乱码查了半天代码最后发现他自己精简过的 startup 文件里把FillZerobss那段删了——因为看起来没什么用。5.4 几个我踩过或者看别人踩过的细节第一SWD 用的 PA13/PA14如果程序里把它们初始化成了普通 GPIO 或者别的复用功能下一次就连不上了只能靠 BOOT0 进 DFU 或者系统存储器模式把 Flash 擦掉。我现在写代码的习惯是这两个脚永远不碰。第二用 HAL 库的时候HAL_Init()里会配置 SysTick 和调用HAL_MspInit如果你把HAL_Init()漏了HAL_Delay会死等一个永远不会来的计数现象是卡在延时里出不来看起来像死机其实不是。第三用 CubeProgrammer 烧录之后如果没勾选烧完自动运行芯片会停在复位状态等你手动复位这也是烧了但没跑的常见原因之一。第四也是我个人觉得最值得记住的一条启动链路是一条不能断的链子。链子的每一环——BOOT0 采样、地址重映射、向量表两个字段、Reset_Handler、SystemInit、数据搬运、构造函数、bl main——任何一环出问题最终的表现都是灯不亮。反过来说当你遇到灯不亮的时候从链子的头部往尾部一节一节验证永远比从尾部也就是你的业务代码往前猜要快得多。我后来养成的习惯是新建一个 STM32 工程之后先不写任何业务代码只让它闪灯然后把启动链上每一环用调试器和 objdump 各验证一遍确认这条链子是通的之后的开发就会顺畅很多——因为再出问题就可以把整条启动链从怀疑列表里划掉了。