CPU不认识main!揭秘STM32F411从复位到main的启动流程
2026/9/14 14:36:20 网站建设 项目流程

第一次接触 WeAct 的 STM32F411 小板子时,我习惯先按住复位键,再点下载,等固件烧完,松开复位。这个动作看起来有点仪式感,实际是在手动触发一次完整的复位时序,让 CPU 从真正的“上电起点”开始跑。很多人以为上电后 CPU 会先去找main(),然后从第一行执行,其实不是这样。CPU 根本不认识main(),它只认识一个固定地址,而main()对你来说是入口,对 CPU 来说只是启动代码最后顺路调用的一个普通函数。这篇文章想借 WeAct STM32F411 这块板,把"CPU 从复位到进入 main 之前到底发生了什么"讲透。适合刚入门单片机、手上有 WeAct 或者类似 F4 开发板的朋友,也适合那些遇到"上电不跑、用调试器点 Run 却跑"的困惑,想从头理清启动逻辑的人。

1. CPU 上电后看到的第一个“字”不是 main,而是向量表

1.1 CPU 只认地址,不认函数名

main()是 C 语言世界里给程序员看的名字,编译器把它翻译成一个符号地址后,链接器会将它放在 Flash 的某个地方。但是 CPU 执行指令时只关心"取指地址",它不会去解析符号列表,更不会因为某个地址上标着"main"就高看它一眼。Cortex-M 内核在复位后会按照硬件设计强制从0x00000000取第一个字作为初始栈指针(SP),从0x00000004取第二个字作为复位向量,也就是复位后第一条要执行的指令地址。这两个地址必须提前准备好,否则 CPU 连"下一步去哪"都不知道。

这是一个经常被误解的地方:很多人以为 STM32 的程序都是从0x08000000开始执行的,这句话对也不对。拿到 STM32F411 和主流 STM32 芯片,用户代码确实存放在以0x08000000开始的主 Flash 中,但 CPU 复位后访问的却是0x00000000这个地址。之所以你能正常运行,是因为内部存储映射把0x00000000映射到了0x08000000对应的 Flash 区域。换句话说,CPU 在0x00000000读到的,就是 Flash 起点处的数据,也就是向量表。

理解这个机制有很多实际价值。比如在调试时,你打开反汇编窗口,看到0x08000000处放的并不是Reset_Handler的代码,而是一个看起来像数据的数值,其实就是初始 SP。这是正常现象。还有一次,我把 F411 的 BOOT0 拉高,板子启动后没有运行我的固件,却还能被调试器识别。原因就是 BOOT0 影响的是"从哪个存储区启动",改变了0x00000000究竟映射到哪一块,而不是改变 CPU 的取指规则。

1.2 WeAct STM32F411 的上电时序与 BOOT0

WeAct STM32F411 核心板用的是 F411CEU6,在很多渠道也被叫做 Black Pill。这块板子体积小、价格低、性能足够,很适合做嵌入式实验。板子默认的启动方式是从主 Flash 启动,这靠 BOOT0 引脚的电平决定。BOOT0 拉低时,CPU 从主 Flash 启动,即刚才说的0x00000000映射到0x08000000;BOOT0 拉高时,CPU 进入系统存储器,也就是芯片出厂内置的 Bootloader。

上电到执行 C 代码之间,硬件大致要经历这几个环节:电源电压爬升到稳定阈值、复位引脚解除复位、时钟开始起振、CPU 到固定地址读取向量表,然后跳转到复位处理函数。这个过程很短,对调试者来说却很容易踩坑。WeAct 板上 BOOT0 默认跳线帽是接在低电平侧,但如果你之前动过跳线帽,或者用杜邦线临时改了 BOOT0,就很容易出现"固件明明烧进去了,上电却不运行"的情况。

提示:遇到固件烧录成功但上电不跑,先检查 BOOT0 是不是被拉高了。尤其是用过系统 Bootloader、做过串口下载、或者把跳线帽拔下来过的情况下,这个原因比想象中常见。

2. 是谁把 CPU 从“不认识 main”带到了“调用 main”

2.1 启动文件的三件事:建栈、铺向量表、入场

从复位向量到main(),中间隔着一个经常被忽略的文件:启动文件。Keil、IAR、GCC 工具链里都有这个文件,STM32F411 的工程里一般叫startup_stm32f411xe.s或者使用 CubeMX 自动生成的.s文件。不同工具链语法不同,但职责几乎一样:第一,设置初始栈指针;第二,建立向量表;第三,调用SystemInit和 C 运行时初始化函数,最终把控制权交给main()

很多新手的第一个工程是这么做出来的:新建一个main.c,写一个main(),然后编译下载,点灯。之所以能跑,是因为编译器或 IDE 自动把启动文件加进去了。如果你用的是 Keil 新建空工程,忘记添加启动文件,链接时通常会报错,最常见的是找不到Reset_Handler或者__initial_sp这类符号。这个报错恰恰说明了一个事实:main()不是 CPU 认识的门,Reset_Handler才是真正的入口。

可以这样理解:启动文件是一段"领路人"汇编代码,CPU 复位后先看到它,它负责把周围环境整理干净,然后才把现场交给 C 世界里的main()。如果领路人缺失,main()写得再漂亮,CPU 也到不了那里。这也回答了很多人的疑问:为什么空工程不能只写一个main()?不是编译器不支持,而是 CPU 根本没有约定好的入口函数名。

2.2 向量表其实是一张地址清单

向量表是一段存放在 Flash 起始位置的表格,里面的每一项都是一个 32 位地址。第一项是初始栈指针 SP,第二项是复位向量,后面依次是 NMI、HardFault、MemManage、BusFault、UsageFault 等异常入口,再往后是外设中断入口。当 CPU 收到一个中断信号时,它会根据中断号去向量表里查找对应的处理函数地址,然后跳转过去。

在 F411 上,向量表默认放在0x08000000。每一个中断函数在启动文件中都会以弱符号(weak symbol)的形式暴露出来,比如Default_Handler。你在 C 代码里写了USART1_IRQHandler后,中断向量表里对应的那条记录会覆盖弱符号地址,中断就能跳到你写的函数。如果某个中断没有写处理函数,那就默认进Default_Handler,通常是一个死循环。

这里有一个容易出问题的细节:向量表不仅要存在,而且地址要和实际代码一致。如果启动文件里向量表第一个字不是 SP,第二个字不是Reset_Handler,CPU 第一次取指就会飞掉。另外,在用到 Bootloader 跳转、或者在 SRAM 中调试代码时,需要手动修改 SCB->VTOR 寄存器的值,让 CPU 找到新的向量表。F411 从此以后所有中断都会从新的向量表取地址,这个问题我们在后面还会展开。

2.3 极简启动文件实战:自己陪 CPU 走一遍“找 main”的路

看得再多,不如手里有一段真实可跑的启动代码。下面这个汇编文件是给你理解启动过程用的最小演示,不依赖 CubeMX,适合用 arm-none-eabi-gcc 这类工具链配合链接脚本使用。它只做了一个最基本的动作:把栈顶地址设置给 SP,然后直接调用main()

.syntax unified .cpu cortex-m4 .thumb .section .isr_vector, "a", %progbits .word _estack .word Reset_Handler .section .text .thumb_func .global Reset_Handler Reset_Handler: ldr r0, =_estack mov sp, r0 bl main b . .end

这段代码只放了两个向量表项,真实项目里还要放完整异常向量表,否则中断来了找不到入口。其中_estack由链接脚本定义,对 WeAct STM32F411 来说,SRAM 从0x20000000开始,容量 128KB,所以_estack一般为0x20020000ldr r0, =_estack是把栈顶地址加载到 R0,mov sp, r0设置栈指针,之后bl main调用 C 入口。

你可能想问:为什么不直接写b main?因为bl会把返回地址压到栈里,main()正常返回后会回到这条b .死循环。这是模仿标准启动文件的常见写法。如果想更“正统”,在调用main()之前还需要调用SystemInit和 C 库初始化函数,但核心思想已经在这里体现:CPU 复位后拿到的是地址,不是符号。

3. main 是最后一步:在它之前,C 运行时已经把“地基”铺好了

3.1 栈指针为什么如此重要?

main()是 C 函数,只要是函数调用,就离不开栈。函数调用时,返回地址要压栈,局部变量也要在栈上分配空间。ARM Cortex-M 内核使用满递减栈,也就是栈指针指向最后压入的数据,入栈时先减少 SP 再写入。如果启动文件没有把 SP 设置到合法的 RAM 区域,第一次调用函数时返回地址就会写到不可预期的位置,程序会迅速进入 HardFault。

这就是为什么我们在启动文件里做的第一件事就是设置 SP。对 F411 来说,片内 SRAM 地址范围是0x200000000x2001FFFF,共 128KB。栈顶可以设在0x20020000,也就是超出 SRAM 末尾一个地址单位的“假想边界”。实际压栈时,SP 先减到0x2001FFFC,第一个 32 位数据写到这个位置,完全合法。如果不设置 SP,或者 SP 指向了一个 Flash 地址,任何函数调用都可能把返回地址写到只读区域,程序不崩才怪。

从调试器里最直观的现象是:单步进入Reset_Handler后,如果你发现 SP 寄存器不是一个0x200xxxxx形如 RAM 地址的值,那启动文件多半没有执行。很多“上电不跑”的问题,追到源头不是没有main(),而是启动文件没把栈指针初始化好。这一点新手看不出来,老手却会第一个检查。

3.2 SystemInit 与 __main 的分工

Keil 环境里,启动文件叫完栈之后通常会调用SystemInit,然后再调用__mainSystemInit是 STM32 标准外设库和 HAL 库里都会提供的函数,负责做基础的时钟初始化:配置 Flash 等待周期、选择时钟源、配置 PLL、把 SYSCLK 跑到目标频率。F411 最高可以跑到 100MHz,而默认复位后启动的可能是内部 HSI 16MHz 时钟。如果省略SystemInit,芯片也能跑,但很多外设时序会不对,比如串口乱码、定时器时间差很多倍。

__main这个名字是 ARM 编译器库里面的一个约定叫法,不是 C 标准里的main()。它负责把 RW 数据从 Flash 复制到 RAM,把 ZI 段(未初始化全局变量)清零,然后初始化堆栈库,最后才跳转到用户写的main()。GCC 工具链里对应的工作一部分由链接脚本和启动代码完成,比如需要自己清 BSS。但不管工具链差异多大,本质都一样:在main()执行之前,所有全局变量必须处于可用的初始状态。

如果忽略这一步会发生什么?最常见的是:一个全局变量在定义时赋值为 0,程序运行后却变成随机数;或者一个非零初值的数组内容全是乱的。你可能会怀疑编译器优化、怀疑内存损坏,其实只是启动代码没有执行 C 运行时初始化。知道了这个原理,很多“怪问题”就不再怪了。

3.3 “手搓 main 函数”导致的各种迷之问题

“手搓 main 函数”这个词很有画面感,指的往往是自己搭最小工程,不依赖 IDE 默认配置,而是手动写启动文件、链接脚本,然后期望一切顺利。能一次点亮水晶灯的人不多,因为这个过程中提高了很多隐藏细节。我见过几种典型翻车现场,在这里列出来供你避坑。

第一种翻车是不初始化向量表,直接写了 Reset_Handler 就开始调 main,结果中断一来就死机。原因是向量表没有完整定义,外部中断或 SysTick 触发时找不到正确地址。第二种翻车是不清 BSS,函数外面定义的全局变量初始值全是 0 的假设直接失效,程序跑到一半才暴露出奇怪行为。第三种翻车是启动文件放在工程里,但编译器没有把它作为翻译单元编译进去,链接器依然报找不到符号。

还有一个比较隐蔽的问题:有些芯片上电后默认读保护是关闭的,但如果你之前用某些烧录工具打开过读保护(RDP)或者修改过选项字节,Flash 里明明有程序,CPU 却因为读保护状态异常而无法正常启动。这个问题在 WeAct F411 上不常见,但在二手板子或者频繁做 OTA 实验的板子上会突然冒出来。排查方法是用调试器读取 OptionBytes 状态,或者直接用 ST-Link Utility 全片擦除后再试。

4. 排查实录:为什么“插上电不跑,点一下 Run 又跑”?

4.1 Debugger 的 Run 不是上电

有相当多的人遇到过这个怪象:用 J-Link 或者 ST-Link 连上板子,在 IDE 里点击 Run,程序正常跑;拔掉调试器,重新上电,程序却不跑。第一反应是固件没烧进去,于是重新烧录,还是如此。这时要意识到,调试器并不能完全模拟一次干净的上电复位。

调试器连接时,一般会做这几件事:暂停 CPU、把 PC 设置到某个入口、准备好调试环境,然后在点击 Run 时让它继续。有些调试器会在连接时把程序加载到 RAM 的调试区域,或者直接设置好 SP 和 PC。这样一来,即使你的启动文件有缺陷,比如 SP 初始化逻辑少了,调试器也可能用自己设置的 SP 暂时掩盖问题。而真正断电再上电,CPU 只能依赖硬件向量表和启动文件,问题立刻暴露。

我见过最典型的一个案例:启动文件里向量表第二项指向的地址因为链接脚本出错,落在了 Flash 中的一段全 0xFF 区域。用调试器手动加载固件后,调试器会修正 PC,所以立刻能跑;但一断电,复位向量落在 0xFFFFFFFF,CPU 取指失败,程序完全不起飞。遇到这种情况,不要把时间浪费在检查main()上,先去看向量表和启动文件。

4.2 针对 WeAct F411 的上电排查清单

如果你手上的 WeAct STM32F411 出现了“上电不跑,调试器点 Run 能跑”,我建议按顺序检查几个位置,很多问题都是出在这些基础环节。

检查项怎么查常见原因
BOOT0 电平万用表量 BOOT0 引脚,或者看板子跳线帽位置BOOT0 被拉高,进入系统 Bootloader,用户程序没机会运行
复位电路示波器看 NRST 引脚上电时有没有正常拉低再拉高的复位脉冲复位电容过大导致复位时间极长,或者复位引脚被长线干扰
电源电压上电瞬间量 VDD 是否快速稳定在 3.3V供电不足、电源开关瞬间抖动、电流不够导致芯片反复复位
选项字节用 ST-Link Utility 或 CubeProgrammer 检查 RDP 等级先前打开了读保护,Flash 正常但不从用户区启动
启动文件打开工程确认.s文件已经加入编译,没有重复或遗漏新建空工程时漏掉启动文件,链接器找不到向量表
Vector Table调试器运行后检查 SCB->VTOR 值是否为 0x08000000从 Bootloader 跳到 App 时没设置 VTOR,中断全部跑飞

这六项里,启动文件和 BOOT0 是我在 WeAct F411 上踩过最多的两类坑。有一次我把板子的 BOOT0 杜邦线接到高电平,忘了拔掉,结果来回烧录了好几次,一直以为芯片出了问题。后来冷静下来量了一下引脚,几秒钟就定位了。检查这类问题,一定要养成“先看硬件状态,再怀疑固件”的习惯。

4.3 用三个寄存器判断“卡在哪一步”

如果手里有 ST-Link 或者 J-Link,有比反复试更好的定位方法。连接调试器后,先不要点 Run,让 CPU 停在复位位置,然后看三个关键信息:SP、PC、HardFault 状态。这三个寄存器就能告诉你程序卡在启动流程的哪个环节。

第一步看 SP。如果 SP 是0x20020000这类 RAM 地址,说明向量表第一个字已经被正确加载,启动文件至少没把栈搞错。如果 SP 是 0 或者一个 Flash 地址,说明向量表第一项就是错的,或者你还没真正跳到用户代码。第二步看 PC。正常执行时 PC 应该停留在启动文件或main()附近,如果 PC 停在了HardFault_Handler,程序大概率在 main 之前的初始化过程触发了异常。第三步看 SCB->VTOR。如果你的程序要从 Bootloader 跳转,或者运行在 SRAM,这个寄存器必须指向所在位置的向量表;否则即使 PC 跑对了,任何中断都会跳到旧向量表,表现就是“刚上电不久就死机”。

我在调试“上电不跑”时最喜欢用的组合拳是:先在复位向量处打断点,单步看 SP 和 PC 是否进入Reset_Handler;如果没有,直接怀疑向量表和链接脚本。如果进入了,则继续单步看SystemInit和 C 库初始化。整个过程并不复杂,但比盲猜可靠得多。

5. 从“CPU 不认识 main”延伸到更复杂的启动场景

5.1 IAP/OTA 的本质就是“换一个向量表”

理解了 CPU 只认向量表后,很多进阶玩法就通了。比如 IAP 固件升级,用户程序从0x08000000放到后面一段地址,Bootloader 和 App 共用一个 CPU。从 Bootloader 跳转到 App,不能简单用函数指针调用,因为 App 的向量表不在复位时硬件默认读取的位置。正确做法是:先检查目标地址的第一个字是否是一个可信的栈顶值,然后把新的栈顶赋值给 MSP,再设置 SCB->VTOR 指向 App 向量表,最后用一个函数指针跳转到 App 的复位向量。

这个流程里最容易漏掉的是 SCB->VTOR。Bootloader 运行期间,所有中断都从0x08000000找入口;跳到 App 时如果不改 VTOR,App 里使能了中断,中断却还会跑到 Bootloader 的向量表,导致程序逻辑完全错乱。很多人在做 STM32F411 的 Ymodem 串口升级或者 OTA 时踩到“跳过去后一开中断就死机”,八成就是 VTOR 没设置。

从本文的角度看,IAP 不过是把上电流程重新演了一遍:设置栈、选一个向量表、跳转。掌握了启动文件原理,IAP 就不再是魔法,而是一次有条件的人工复位。

5.2 为什么不同芯片的“上电不跑”解法不一样

把视角从 STM32 移到其他芯片,比如国产 GD32F103 和 GD32F303,启动流程大体类似,但细节并不完全相同。GD32 本身兼容部分 STM32 指令,但它的上电复位行为、BOOT 引脚定义、选项字节和读保护可能在具体实现上有差异。比如某些 GD32 型号默认是从内部 Flash 启动,但如果你之前改过选项字节,或者通过串口 ISP 下载过代码,再上电时可能不会自动运行用户程序。网上也经常看到“GD32F103RCT6 上电不能自动运行,用 J-Link 点 Run 却正常”的求助,这往往就是启动配置、选项字节、向量表几方面共同作用的结果。

所以我一直建议,不要拿一套“STM32 上电流程”直接套用所有芯片。遇到“上电不跑”时,先把芯片型号、参考手册、选项字节、BOOT 引脚状态这四样东西摆出来,再对照实际情况一一排除。芯片之间微小的差异,在复用移植代码时容易变成“玄学”,但底层逻辑依旧是 CPU 找向量表、启动文件建环境、最后才进入main()

5.3 拿到新板子的第一步:先认识它的启动文件

踩过几次坑之后,我现在拿到一块新板子,第一件事不是急着写点灯程序,而是先打开它的启动文件和链接脚本,看一眼栈顶地址、向量表存放位置、以及启动文件中调用了哪些初始化函数。看起来只是几十行汇编或链接脚本,却能避免之后很多个晚上的排查时间。对于 WeAct STM32F411 这类开发板,官方示例和 CubeMX 生成的启动文件都比较规范,我会先照着默认配置跑通一个点灯工程,确认硬件没有暗病,然后再做自己的改造。

我也建议新手不要跳过启动文件这部分,哪怕暂时看不太懂汇编,也要知道它的存在。很多问题并不是你不会写 C 代码,而是你对 C 代码“落地的场地”不够了解。当你亲手把启动文件、栈指针、向量表这几个概念串起来,再回头去看调试器里的 PC、SP、HardFault 状态,会有一种豁然开朗的感觉。以后遇到“上电不跑”,你也能在几分钟内按路线找到根因,而不是一遍遍地怀疑芯片、怀疑编译器。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询