
上电之后板子没反应烧了程序按复位也没反应可调试器一连接、点一下 Run程序跑得比兔子还快。这种问题我遇到太多次了而且每次都有新手一脸认真地问我是不是 main 函数没写对main 函数真没写对但不是你想象的那种写错。要说清楚这个问题得从标题这个梗讲起CPU 压根不认识 main()。它只认识地址和机器码。你写的 main 不过是被编译器、链接器翻译之后变成 Flash 里某个地址上的一段二进制。CPU 上电之后做的事情跟 main 一点关系都没有。这篇文章我拿 WeAct STM32F411CEU6 这块板子把从按下复位到进入 main 的全过程给你扒开看一遍顺便聊一个很多老手都踩过的坑为什么上电不跑用 JLink 点 Run 却正常跑。1. 上电那一刻PC 指向的是地址不是函数名1.1 三条“出厂设定”向量表、复位向量、启动模式Cortex-M 内核的启动行为和传统单片机不太一样它不会去 Flash 里主动找什么 main。内核被复位释放之后会做两件非常机械的事情从地址 0x00000000 读出初始栈指针 SP 的值再从地址 0x00000004 读出复位向量的值然后把这个复位向量的值写入程序计数器 PC从那个地址开始取指执行。这里有个容易混淆的地方。STM32F411 的 Flash 是在 0x08000000 地址上但 CPU 上电时访问的是 0x00000000 和 0x00000004。之所以能对上是因为芯片内部做了别名映射当 BOOT0 引脚为低电平时Flash 被映射到 0x00000000 起始的地址空间。所以实际上 CPU 读到的内容来自 0x08000000 和 0x08000004。这两个 word 才是整个程序的“真正入口”跟 main 没有半毛钱关系。WeAct F411 核心板上有 BOOT0 跳线默认是短接到 GND也就是从主 Flash 启动。如果把 BOOT0 拉高、BOOT1 拉低芯片会从系统存储器启动进入 ST 出厂自带的 bootloader那里面跑的是 ST 的代码同样也找不到你的 main。这就是启动模式影响入口的第一课。我举个例子一个烧录好的 F411 程序你读 0x08000000通常会看到类似0x20020000的值这是 SRAM 最高地址加 1也就是栈顶。读 0x08000004会看到一个类似0x08010469的奇数地址这是 Reset_Handler 的地址。因为 Thumb 指令集最低位固定为 1CPU 拿这个值之后会把最低位清掉再跳转。这个时候程序还没有碰到任何 C 语言层面的东西。1.2 硬件上电顺序NRST 释放之前不要谈 main软件流程之外硬件时序也必须满足否则程序永远走不到启动文件。MCU 上电后需要等待电源电压爬到工作范围内部 LDO 稳定外部 NRST 引脚释放复位然后时钟系统起振。如果 NRST 拉低时间不够或者上电复位电路设计得有问题CPU 可能一直处于复位状态main 自然一次都不会执行。我见过有人画的板子 NRST 上拉到 VDD 的电阻没焊还配了个大电容结果每次上电都要按一下复位键才跑后来量了波形才发现复位时间长达好几秒。WeAct F411 核心板本身集成度很高外部晶振通常接 25MHzUSB 和 PLL 都靠它。板子的复位电路、BOOT 跳线都给你做好了用起来确实省心。但如果你自己画板子这三个硬件条件必须核对供电电压在 MCU 要求范围内且纹波不要太大、NRST 有合法的上电复位时序、BOOT0 和 BOOT1 引脚电平符合预定的启动模式。硬件时序不合格调试器往往会拉高复位并强行使芯片运行于是出现“上电不跑JLink 一点就活”的经典现象。2. main() 之前的世界启动文件到底干了什么2.1 启动文件是一张写好的“任务清单”如果你打开 WeAct 官方例程会在工程里看到startup_stm32f411xe.s这个汇编文件。没有它你的 main 根本不会被执行。这个文件做的事情可以拆成四块规定堆栈大小、定义向量表、实现复位处理函数 Reset_Handler、给所有中断提供默认处理函数。Reset_Handler 是 CPU 真正跳转到的第一个函数它内部的逻辑大致是设置栈指针 SP然后调用 SystemInit 初始化时钟再调用 C 库的初始化入口__main由它完成数据段和 BSS 段的搬运清零、标准 I/O 和堆的初始化最终才调用你写的 main。所以 main 在整个链条里排在最后面是“被请出来的”那个角色。这里要特别提醒我们常说的__main并不是你写的 main。它是编译器 C 库提供的初始化入口。CPU 到这一步时依然不知道自己执行的是一个叫 main 的函数它只是执行了一串已经编译好的机器码。如果你用的不是 Keil 的 ARMCC而是 GCC 工具链这一层入口叫_start逻辑大同小异。不管叫__main还是_start本质都是 C 运行时初始化。2.2 SystemInit时钟系统没起来main 再正确也没用SystemInit 通常是 CMSIS 库里提供的函数主要完成 Flash 等待周期设置、PLL 配置、系统时钟切换到 PLL 等操作。在官方启动文件里SystemInit 在__main之前被调用这一步如果卡住你的 main 永远没机会出场。举个很典型的例子你在 C 代码里把 RCC 寄存器写错或者外部晶振没焊、没起振SystemInit 里等待 HSE 超时会一直死循环或者 PLL 一直锁定不上程序就停在启动文件里。你断点打在 main 上永远断不到但如果用调试器查看 PC会发现 PC 停在 SystemInit 的某条循环指令附近。所以遇到“程序不跑”别只盯着 main 排查先看 PC 停在哪一层这能省下半天时间。WeAct 的例程默认把 F411 超频到 100MHz 或者更高PLL 配置在 SystemInit 里完成。如果你换了外部晶振或改库文件务必核对分频系数和倍频系数。250MHz 的 HSE 和 8MHz 的 HSE 配出来的 PLL 参数完全不同搞错了一律死在启动阶段。2.3 链接脚本谁把“main”翻译成地址启动文件解决的是“第一条指令从哪来”链接脚本解决的是“这些代码放在 Flash 的哪个地址、哪些段要加载到 RAM”。GCC 工具链里对应.ld文件Keil 里对应分散加载文件或者 Target 选项卡里的 ROM/RAM 地址配置。链接脚本里常见这么几行_estack ORIGIN(RAM) LENGTH(RAM);.flash : { .isr_vector : { *(.isr_vector) } .text : { *(.text*) } } FLASH你写的int main(void)最终会被编译成一个符号链接器把这个符号的地址填进启动文件里调用 main 的那条指令中。整个程序打包烧录后Flash 里只有机器码和立即数不存在函数名的概念。你在 map 文件里能看到0x08000200这种地址对应main但 CPU 只是把 PC 设置成了 0x08000200 而已。这就是“手搓 main 函数”这个热词背后的真实含义你完全可以绕过标准启动流程自己定义一个不是 main 的入口只要把入口地址放到 0x08000004CPU 一样跑得飞起。后面第 4 节我会做个实验来验证这件事。3. 用调试器看真相从第一行指令到 main3.1 复位后先看 PC 和 SP别急着找 main不信的话你可以亲自验证。连接 ST-Link 或 JLink 进入调试模式用 OpenOCD 或 GDB 看一眼复位瞬间的寄存器值。以 ST-Link 加 OpenOCD 为例连接和生产相关命令大概是openocd -f interface/stlink.cfg -f target/stm32f4x.cfg连接上之后在 GDB 里执行monitor reset halt x/2wx 0x08000000 info registers sp pc如果程序烧录正常你会看到 0x08000000 处是0x20020000样子的栈顶值0x08000004 处是 Reset_Handler 的地址PC 就在 Reset_Handler 地址附近。此时 SP 已经指向 RAM 顶部main 的影子都没有。如果你想看 Reset_Handler 到 main 之间的调用链可以在反汇编文件里找。GCC 编译后用arm-none-eabi-objdump -d firmware.elf查看启动代码段Keil 也可以在调试模式里 View - Disassembly Window 单步查看。你会看到一条条指令先加载栈指针、跳转 SystemInit、跳转__main、最后才碰到 main。3.2 上电不自动运行JLink 一点 Run 却正常我在很多板子上遇到过一模一样的问题按复位键程序不跑串口没打印LED 不闪用 J-Link 连接点一下 Run 就全正常了。这个现象特别容易让人误判成“程序没烧进去”或者“main 有问题”实际上真正原因多半不在 main。排查顺序我建议这样来第一步断开调试器单独给板上电用万用表或示波器量 NRST 引脚电平。如果 NRST 一直被拉低程序永远处于复位状态。常见原因是复位电容太大、复位芯片输出异常、或者调试器断开后复位信号悬空。第二步量 BOOT0 引脚的电压。如果 BOOT0 不是 0V 而是被拉高或悬空芯片启动后不会从主 Flash 启动自然跑不了你的程序。JLink 连接后执行 reset 并强制从当前向量表运行相当于“人工纠正”了启动位置所以看起来正常。WeAct 板子的 BOOT0 跳线帽如果没插好或者焊盘有锡连也会出这种问题。第三步检查程序里是否开了独立看门狗 IWDG。如果代码在 SystemInit 或启动早期就使能了看门狗而且没有及时喂狗芯片会一直被不断的复位打断。你看到的效果就是“上电之后反复复位main 从来没有稳定执行过”。JLink 连接后调试器会暂停目标反而掩盖了喂狗不及时的问题。这个现象的技术内核其实就是 CPU 只认启动条件不认 main 这个符号。Boot0 引脚、复位电路、看门狗这些硬件或外设层面的东西决定了 CPU 有没有机会走到 main。3.3 单步跟一遍 Reset_Handler比背十篇文档都有效拿到一块新板子或者怀疑启动有问题我推荐做一次最笨但最有效的操作在 Keil 里把断点打在 SystemInit 第一行复位后单步跟一遍。你会看到 PC 跳出启动文件进入 system_stm32f4xx.c再单步几轮看到 PLL 锁定标志置位、系统时钟切换完成。继续运行后断点打在 main 第一行等它命中。这个过程走一遍你对“启动链路”会有肌肉记忆。以后再遇到程序不跑你的第一反应不再是“main 写错了吧”而是“PC 现在停在哪了、SP 有没有指向正确位置、时钟切换有没有卡住”。这个思维转换很关键。4. 一个实验把 main 删掉LED 照样闪4.1 最小“无 main”工程结构为了证明 CPU 不认识 main我直接在 WeAct F411 上做了一个实验整个工程里没有任何名为 main 的函数连标准的启动文件都不用。我只提供一个极简的向量表、一个 Reset_Handler、一个叫 not_main 的普通函数最终 LED 正常闪烁。原理上vectors 表第一个 word 给栈顶第二个 word 给 Reset_Handler 地址Reset_Handler 里设置 SP、跳转到 not_main然后死循环。用 GCC 工具链工程只需要三个文件汇编启动文件、C 文件、链接脚本。汇编启动文件我写的是.syntax unified .cpu cortex-m4 .thumb .section .isr_vector,a,%progbits .word 0x20020000 .word Reset_Handler .section .text .thumb_func .global Reset_Handler Reset_Handler: ldr sp, 0x20020000 ldr r0, not_main blx r0 b .这里 0x20020000 是 F411 的 128KB SRAM 最高地址加 1也就是栈顶。没有它not_main 一旦使用局部变量就会压栈压飞。C 文件也很简单直接用寄存器操作驱动板载 LED#include stm32f411xe.h void not_main(void) { RCC-AHB1ENR | RCC_AHB1ENR_GPIOCEN; GPIOC-MODER ~(3UL (2 * 13)); GPIOC-MODER | (1UL (2 * 13)); while (1) { GPIOC-BSRR (1UL 13); for (volatile uint32_t i 0; i 500000; i); GPIOC-BSRR (1UL (13 16)); for (volatile uint32_t i 0; i 500000; i); } }注意这个函数里不能依赖任何全局变量初始值因为我们没有做 .data 拷贝和 .bss 清零全局变量初值完全不可信任。我只能用局部变量做延时函数一开始也不要调用任何库函数。这是故意为之通过这个限制去理解启动代码为什么存在反而是最有收获的。链接脚本只需要声明 Flash 和 RAM 区域并把向量表放到 0x08000000MEMORY { FLASH (rx) : ORIGIN 0x08000000, LENGTH 16K RAM (rwx) : ORIGIN 0x20000000, LENGTH 8K } SECTIONS { .isr_vector : { *(.isr_vector) } FLASH .text : { *(.text*) } FLASH }编译命令用-nostartfiles -nostdlib -ffreestanding这类选项避免链接器强制找标准入口。这么烧进板子按下复位后 LED 就会开始闪。用调试器看PC 在 not_main 的死循环里转全程没有任何 main 出现。4.2 从反汇编看向量表就是一切编译完成后用arm-none-eabi-objdump -d打开 elf 文件你会看到类似这样的反汇编输出Reset_Handler: ldr sp, [pc, #8] ldr r0, [pc, #8] blx r0 b .第一条指令将栈顶地址填入 SP第二条取 not_main 地址第三条跳过去。这个过程和有没有 main 一点关系都没有。再看 0x08000000 地址的原始数据向量表里只有两个 word0x20020000和Reset_Handler的地址。CPU 上电后就是靠这两个数字启动的。烧写这个 bin 文件之后你可以在 STM32CubeProgrammer 里直接检查内存能看到这段数据。这个实验让我更确信一件事所有“程序不跑”的问题最终都能归因到“CPU 从正确的地址取到了错误的指令或者根本没取到指令”这个层面。要么向量表坏了要么 Reset_Handler 内部卡死要么时钟没有准备好要么 SP 失效导致压栈即飞。4.3 从这个实验能学到什么如果你经常做嵌入式开发裁掉标准启动文件的机会可能不多但理解无 main 启动的底层逻辑对排查问题帮助很大。比如做 Bootloader 跳转 App 时跳转操作本身跳的不是 main而是 App 的复位向量任务切换时 RTOS 调度器切换的是任务的入口地址也不是 main。理解了“入口地址 栈指针”这个最小模型你会更容易看懂各类启动代码。另外这个实验还能帮你理解为什么有些优化选项会导致全局变量初始化异常。当你不使用标准启动文件却还写非零初值的全局变量编译器产生的代码会尝试调用拷贝或初始化过程但你的工程里没有这些环节程序看起来就会“莫名其妙跑飞”。明白启动文件的作用后遇到这类问题不会再一头雾水。5. 常见问题速查与经验补充5.1 为什么我的板子“上电不跑点 Run 才跑”我制作了一个速查表遇到这类问题可以直接对照排查这几个方向基本覆盖了 90% 的“上电不跑”现场现象可能原因排查思路上电无反应JLink 点 Run 能跑BOOT0 电平不对或悬空量 BOOT0 电压确保低电平从主 Flash 启动上电无反应JLink 点 Run 能跑NRST 复位时序异常断开调试器量 NRST检查复位电容和上拉上电反复重启独立看门狗在启动早期开启且未喂狗临时屏蔽 IWDG 初始化观察复位间隔程序停在 SystemInit 或启动文件HSE 未起振 / PLL 配置错误查看 PC 位置检查晶振是否焊接正常main 执行了但外设不工作RCC 对应外设时钟未开启检查 RCC-AHB1ENR / APB1ENR / APB2ENR全局变量初值全部为 0.data 段没有被拷贝检查启动文件和链接脚本配置从 Bootloader 跳 App 后死机App 的 VTOR 未设置中断向量表错乱在 App 初始化时配置 SCB-VTOR这里我想再强调一下“点 Run 能跑”背后的调试器行为。JLink 在点击 Run 时会先对目标执行复位然后强制把 PC 设置到用户程序入口甚至会把 CPU 从低功耗状态唤醒。这种“强制接管”会掩盖 BOOT0 错误、复位电路异常等硬件问题。所以排查时必须断开调试器单独给板上电用示波器或万用表观测真实的上电环境这样才能看到问题的本来面目。5.2 几个该背下来的地址和寄存器如果你要手动分析启动流程F411 这几个地址和寄存器建议记一下地址 / 寄存器作用0x08000000主 Flash 起始地址向量表默认位置0x00000000CPU 上电访问的向量表别名映射启动模式决定它映射到哪0x1FFF0000系统存储器出厂 bootloader 所在区域0x20000000SRAM 起始地址0x20020000 是 F411 的栈顶SCB-VTOR0xE000ED08向量表偏移寄存器IAP 跳转后必须重设SCB-VTOR值得多说一句。在做 YMODEM 固件升级这类 IAP 方案时Bootloader 把新固件写到 0x08008000 这类偏移地址然后跳转过去。如果没有在 App 工程里把SCB-VTOR设为 0x08008000中断向量表仍然指向 Bootloader 所在区域一旦触发中断程序就会跑飞到错误的地方。这跟“上电不跑”表面不同本质是同一个道理CPU 找的是地址地址错了一切都错。5.3 从 Bootloader 跳转 App 的正确姿势很多做 OTA 的人会把跳转写成“调用 App 的 main”这是概念错了。正确的做法是从 App 的复位向量开始typedef void (*pFunction)(void); uint32_t app_addr 0x08008000; uint32_t app_sp *(volatile uint32_t *)app_addr; uint32_t app_pc *(volatile uint32_t *)(app_addr 4); SCB-VTOR app_addr; __set_MSP(app_sp); pFunction jump (pFunction)app_pc; jump();跳转之前还要把全局中断关掉避免跳转过程中产生中断触发旧的向量表跳转完成后App 内部会在 SystemInit 和启动代码里重新配置时钟和中断。如果你在 Bootloader 里打开了外设中断却忘了关闭App 启动后极有可能第一时间进入 HardFault这也是“跳转后死机”的高频原因。5.4 编译器报错与“手搓入口”的补充有时候代码没问题但编译阶段就在报错。比如 Keil 工程里启动文件被误删或者入口设置异常可能出现Undefined symbol __main、compiler does not include main type这类提示。这不代表你的 main 写错了而是工程给编译器提供的信息不完整。检查启动文件是否在工程里检查 C/C 选项里入口函数设置检查是否勾选了 MicroLIB 导致标准库初始化行为不同基本能解决。我在做无 main 实验时也踩过一次“编译过了但烧录后没反应”的坑。后来发现是链接脚本里忘了把向量表段放最前面程序被链接到了其他地址0x08000000 位置根本不是有效向量表。后来每次改链接脚本我都会先用objdump -h或 STM32CubeProgrammer 看内存确认前 8 个字节对不对。这个习惯救了我很多次。最后再多说两句调试过无数“程序不跑”的问题之后我最大的体会是遇到问题先把启动链路在脑子里过一遍比瞎改 main 函数高效太多。复位向量对不对、栈指针在不在有效 RAM 范围、SystemInit 有没有卡住、启动文件有没有被链接进来这些才是决定程序能不能跑起来的关键。main 更像是整个流程的最后一棒它很显眼但从来不控制起点。另外再分享一个我常用的排查小技巧遇到上电不跑先用调试器读 PC然后把 PC 地址抄下来在 map 文件或反汇编里搜。你马上就能知道程序死在启动链路哪一环。这个动作熟练之后十分钟定位问题不是开玩笑的。如果你手头正好有一块 WeAct F411建议照着第 4 节的实验试一次亲眼看一眼没有 main 的程序是怎么跑的比看十篇文章都管用。