前阵子帮一个从 Arduino 转到 STM32 的朋友调 WeAct F411 小板,他写了三行点灯代码,IDE 编译一次通过,烧录也提示成功,但板子就是没反应。他翻来覆去查时钟树、查引脚定义、查 BOOT0 跳线,最后把工程整个发给我。我打开反汇编,先看了一眼 0x08000000 开头的 8 个字节,基本就猜到问题在哪了。他当时说了一句话让我印象很深:"我的 main 函数明明在里面,CPU 怎么就不执行?"
这句话其实戳中了一个刚入门嵌入式时最容易忽略的事实——CPU 从头到尾不认识 main()。main这个符号是 C 语言标准、是编译器、是链接脚本里的规矩,但绝对不是 CPU 的启动协议。CPU 上电后只认硬件定死的几个动作:到某地址取一个栈指针,再到某地址取一个复位向量,然后从那个地址开始取指令。至于那个地址对应的函数叫Reset_Handler还是main,它根本不在乎。
这篇文章就以 WeAct 那款最常见的 STM32F411CEU6 BlackPill 为例,把从上电复位到进入 main 的完整链路拆开讲一遍,包括向量表、启动文件、链接脚本、SystemInit,以及当程序真的到不了 main 时该怎么定位。适合刚接触 STM32 裸机、被"程序为什么跑不起来"困扰的朋友。最后一部分给了可以直接抄的验证步骤,想动手看反汇编和 map 文件的,不需要额外硬件也能复现。
1. 上电一瞬,CPU 要找的是"复位向量",不是 main()
1.1 复位后 CPU 的固定动作
Cortex-M4 内核上电复位的动作就那么几步:读取向量表偏移寄存器(VTOR)默认值 0x00000000,然后从 0x00000000 地址读出初始栈指针(MSP),从 0x00000004 读出复位向量,再把程序计数器(PC)指向复位向量。整个过程由硬件完成,不需要软件参与。
问题在于,STM32F411 的内部 Flash 基地址是 0x08000000,而不是 0x00000000。那硬件怎么知道要去 Flash 里取?答案是"内存地址重映射"。芯片会根据 BOOT0 / BOOT1 引脚的电平,把不同存储介质映射到 0x00000000 起始的别名区。默认情况下 BOOT0 = 0,映射的就是主 Flash,所以你代码里看到的 0x08000000 这个地址,在 CPU 眼里等效于 0x00000000。这是 STM32 启动的第一个隐藏机制。
可以用一张表说清楚 F411 的启动模式选择:
| BOOT0 引脚 | BOOT1 引脚 | 启动介质 | 别名区对应 |
|---|---|---|---|
| 0 | 任意 | 主 Flash(0x08000000) | 0x00000000 映射到 Flash |
| 1 | 0 | 系统存储器(Bootloader) | 0x00000000 映射到 ROM |
| 1 | 1 | 内嵌 SRAM(0x20000000) | 0x00000000 映射到 RAM |
WeAct F411 小板上通常有一个 BOOT0 跳线,短接时上电会进系统存储器里的串口/USB Bootloader,断开时正常运行你的 Flash 程序。很多朋友板子没反应,第一块绊脚石就是这里。
1.2 main 不是程序的起点
理解这个机制后,再看"CPU 不认识 main"就更清楚了。main只是 C 运行时约定的入口,启动代码在完成一系列初始化后才会调用它。在裸机工程里,你完全可以把main改名为my_entry,同时把启动文件里的bl main改成bl my_entry,CPU 眼皮都不会抬一下。编译器只是在你没提供启动文件、用-nostartfiles之类选项时,默认把 ELF 入口设成main或_start,但 CPU 根本不看 ELF 头。
真正的入口是向量表第二个 32 位字。它存放的是Reset_Handler的地址,CPU 复位后第一行执行的代码,就在这里。
2. 向量表里的"两张名片":栈顶与复位函数
2.1 0x08000000 开头的 8 个字节决定一切
打开任何一份 STM32F411 的启动文件(比如 STM32CubeIDE 生成的startup_stm32f411xe.s),在最前面都有一段 vector 表定义。我挑核心部分来说明:
.section .isr_vector,"a",%progbits g_pfnVectors: .word _estack .word Reset_Handler .word NMI_Handler .word HardFault_Handler .word MemManage_Handler .word BusFault_Handler .word UsageFault_Handler这里_estack是链接脚本里定义的 RAM 最高地址,Reset_Handler是启动函数。当烧录工具把 hex 文件写进 Flash 后,0x08000000 处就摆着_estack的数值,0x08000004 处摆着Reset_Handler的地址。CPU 上电就是按这个顺序把数据读进来,然后把 PC 跳过去。
注意一点:Cortex-M 系列只支持 Thumb 指令,所以向量表里的函数地址最低位必须是 1。比如Reset_Handler实际在 0x0800012C,向量表里存的就是 0x0800012D。这个细节由汇编器的.thumb_func声明和链接器自动完成。如果你手动改向量表或者从别人那里抄了一段不带.thumb_func的汇编,跳转就会触发 UsageFault 里的 INVSTATE 位,程序直接进 HardFault。
2.2 栈指针为什么也在向量表里
很多新手不理解为什么栈顶要放在向量表开头。原因是 CPU 在跳到Reset_Handler之前,硬件就要把 SP 寄存器设置好。因为Reset_Handler第一条指令可能就是压栈、调用函数,没有栈就没法执行 C 代码。
_estack的值通常是ORIGIN(RAM) + LENGTH(RAM),对 F411CEU6 来说就是 0x20000000 + 0x20000 = 0x20020000。这个值必须 8 字节对齐,因为 AAPCS 调用约定要求栈在函数调用边界保持 8 字节对齐,否则浮点库或者一些系统库可能出现诡异行为。
如果你用调试器连接板子,在复位后立刻读寄存器,会看到 SP 已经等于_estack,PC 等于Reset_Handler。这两个值不是 CPU 自己猜的,正是从 Flash 开头读的。
3. Reset_Handler 到 main() 之间的"三件家务"
3.1 搬 .data:把初始值从 Flash 挪到 RAM
C 语言里非零初始化的全局变量、静态变量,在编译后并不会直接存在于 RAM 中。Flash 不可能运行时改写,RAM 上电内容又是随机的,所以编译器把初始值放在 Flash 的只读区域,用链接脚本标记为_sidata。启动代码在进 main 之前,要把这段数据从 Flash 搬回 RAM 的实际地址。
启动文件里对应的典型循环:
Reset_Handler: ldr r0, =_sdata ldr r1, =_edata ldr r2, =_sidata copy_loop: cmp r0, r1 bcs copy_done ldr r3, [r2], #4 str r3, [r0], #4 b copy_loop copy_done:这里_sdata和_edata是 RAM 里的加载地址(LMA),_sidata是 Flash 里的源地址。如果你不用启动文件,或者链接脚本里这三个符号定义错,最常见的现象是全局变量初始值全乱,或者复位后直接 HardFault。
3.2 清 .bss:给未初始化变量一个干净的零
紧接着启动代码会把.bss段清零。C 标准保证未初始化的全局变量和静态变量值为 0,这个"保证"不是硬件给的,而是启动代码一字节一字节写出来的。_sbss到_ebss区间逐字清零。
跳过去会怎样?RAM 里残留的是上次程序运行的数据,或者上电随机值。一个定义为int counter;的变量可能初始是 0xFFFFFFFF,然后你的逻辑里counter++溢出、判断错乱,查半天都查不到源头。所以.bss清零不是可选项,而是 C 运行环境的地基。
3.3 SystemInit 和 __libc_init_array:进 main 前的最后两步
数据搬完、BSS 清完,紧接着Reset_Handler会调用SystemInit。这个函数在system_stm32f4xx.c里,职责是配置 Flash 等待周期、开启 PLL、把系统时钟切到想要的频率。F411 上电默认走内部 HSI 16MHz,频率低不打紧,但外设定时器参数、串口波特率全是按目标时钟算的,SystemInit不执行或执行出错,后面所有外设初始化都会错位。
SystemInit返回后,GCC 工具链的启动文件通常还会调用__libc_init_array,用来触发 C++ 全局对象的构造函数。纯 C 工程这一步几乎是空跑,但不能去掉。最后才是:
bl main bx lrmain一旦返回,bx lr会跳到调用main之前的 LR 地址,通常是一个死循环或__exit。这也是为什么裸机工程的 while(1) 不能丢——与其让 main 返回后行为未定义,不如就让控制流永远停在循环里。
Keil/armcc 的路径略有不同:它的启动代码会跳到 C 库的__main,由库函数完成 scatter-loading(相当于 .data 搬运和 .bss 清零)后再进用户的main。思路一致,工具链实现路径不同,排查时不要拿一套启动流程硬套两家。
4. 链接脚本的"合同条款":入口点和段地址
4.1 ENTRY 只对 ELF 有意义,真正生效的是段布局
WeAct F411 工程的链接脚本通常叫STM32F411CEUX_FLASH.ld,开头一般长这样:
ENTRY(Reset_Handler) MEMORY { FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 128K }ENTRY(Reset_Handler)指定的是 ELF 入口点,供链接器、调试器、部分烧录工具参考。真正让 CPU 找到入口的不是这个声明,而是.isr_vector段必须恰好落在 FLASH 的 ORIGIN 处。链接脚本通过:
SECTIONS { .isr_vector : { . = ALIGN(4); KEEP(*(.isr_vector)) . = ALIGN(4); } >FLASH把向量表强制放到 0x08000000。如果这个段地址错了,或者向量表符号没被保留,烧进去的程序开头就不是_estack和Reset_Handler,复位后一切行为都不可控。
4.2 LMA 和 VMA:理解 .data 搬运的关键
.data段的定义是链接脚本里最容易看晕的部分:
.data : { . = ALIGN(4); _sdata = .; *(.data) *(.data*) _edata = .; } >RAM AT> FLASH这里的>RAM是 VMA(程序运行时在 RAM 的地址),AT> FLASH是 LMA(烧录时在 Flash 的地址)。启动代码之所以需要_sidata,就是因为.data在 Flash 里的存储位置和 RAM 里的运行位置不一样,必须手动复制。这也是嵌入式里"初始化过的全局变量占两份空间"的原因。
新手如果自己写链接脚本,最容易犯的错是只写>RAM不写AT> FLASH,结果.data初始值被放进 RAM 的地址区间,烧录时根本不会出现在 Flash 里,上电后搬运源地址全是垃圾,程序直接跑飞。
4.3 栈和堆在链接脚本里的实际位置
F411 工程里_estack的常见定义是:
PROVIDE ( _estack = ORIGIN(RAM) + LENGTH(RAM) );也就是 RAM 的最高地址。堆区如果使用 malloc,通常在.bss之后向上增长,栈则从 RAM 顶向下增长,两者相向而行。如果堆增长到和栈撞上,程序不会立刻报错,而是静默写坏栈上的返回地址,随后在一个完全不相关的函数里 HardFault。
排查这类问题,可以看链接器生成的.map文件末尾,里面有堆栈符号的最终地址。如果_Min_Heap_Size/_Min_Stack_Size调得很大,而 RAM 总共只有 128K,就要认真算一下余量。
5. 程序到不了 main() 的典型故障链路
5.1 一次完整的排查顺序
遇到"烧录成功但没反应",我建议按下面这条链路走,而不是先去改逻辑:
- 先用调试器连接,复位板卡,让 CPU 停在复位向量处,看 PC 和 SP 值。
- 如果 SP 是 0xFFFFFFFF,PC 也是 0xFFFFFFFF,说明 Flash 里根本没有有效向量表——芯片可能是空片,或者烧录地址错了。
- 如果 PC 停在一个看起来随机的地址,先在
Reset_Handler第一行下断点,确认向量表里的地址是否指到了错误位置。 - 单步执行,观察是否每次都能执行到
SystemInit,再单步到main。 - 如果中间进
HardFault_Handler,读出 SCB->CFSR、HFSR 和 BFAR/MMFAR,按 bit 定位原因。
其中 0xFFFFFFFF 这个现象很经典。把芯片内部全部擦除后,Flash 读出来全是 0xFF。CPU 复位后从 0x08000000 读到 0xFFFFFFFF 作为初始 SP 还行,但把 0xFFFFFFFF 当成复位向量地址去跳转,最低位是 1 满足 Thumb 条件,可这个地址本身不对,取指失败直接进 HardFault。
5.2 常见原因对照
| 症状 | 最可能原因 | 验证方法 |
|---|---|---|
| 完全无反应,SP/PC 全 0xFF | Flash 空片或烧录偏移错误 | 检查烧录起始地址是否为 0x08000000 |
| 板子像死机,偶尔 Random 跳转 | 启动文件缺失或向量表被覆盖 | openocd 复位后读 x/8xw 0x08000000 |
| 一进 Reset_Handler 就 HardFault | RAM 地址配置错误,栈指针非法 | 检查链接脚本 RAM ORIGIN 和 LENGTH |
| 运行一会儿才 HardFault | 栈溢出或堆栈碰撞 | 查看 map 文件,缩小堆或栈验证 |
| 外部中断完全无响应 | VTOR 被改错或中断向量缺失 | 检查代码里是否操作了 SCB->VTOR |
| 点灯正常但串口乱码 | SystemInit 时钟配置和外围不匹配 | 实测 SYSCLK 频率,核对 PLL 参数 |
我遇到过一种很隐蔽的情况:用户代码里主动把SCB->VTOR重定位到了某个 RAM 缓冲区的向量表,但表只复制了前两个异常向量,没有复制外设中断向量。结果复位正常、main 也进去了,一开串口中断就 HardFault。这种问题靠看逻辑很难发现,还是要回到向量表本身去查。
5.3 用调试器和 map 文件定位 main
如果是调试环境,最直接的方式是在main打断点,如果断点无效,说明程序流根本经过不了这里。这时看链接器生成的.map文件,能确认main最终落在哪个地址:
.text.main 0x08000a8c 0x48 ./Src/main.o 0x08000a8c main再用nm命令看关键符号分布,判断启动文件和链接脚本是否都生效:
arm-none-eabi-nm -n build/firmware.elf | grep -E '(_estack|Reset_Handler|main|_sidata)'正常情况下,_estack应该是 0x20020000(RAM 顶),Reset_Handler应该落在 Flash 区间的开头附近,main紧随其后。如果Reset_Handler的地址完全不在 Flash 范围内,基本可以断定链接脚本的 FLASH ORIGIN 或向量表放置有问题。
6. 在 WeAct F411 上手动走一遍启动链路
6.1 准备最小工程并点灯
不需要 CubeMX,也可以手工构造一个最小工程来验证整个启动流程。目录结构大致是:
f411_min/ ├── startup_stm32f411xe.s ├── main.c ├── system_stm32f4xx.c └── STM32F411CEUX_FLASH.ldmain.c写一个最简单的 PC13 翻转程序,WeAct F411 板载 LED 就在 PC13:
#include "stm32f4xx.h" int main(void) { RCC->AHB1ENR |= RCC_AHB1ENR_GPIOCEN; GPIOC->MODER &= ~(3UL << 26); GPIOC->MODER |= (1UL << 26); while (1) { GPIOC->ODR ^= (1UL << 13); for (volatile uint32_t i = 0; i < 1000000; i++) ; } }用arm-none-eabi-gcc编译时,这几条关键选项一个都不能少:
arm-none-eabi-gcc -mcpu=cortex-m4 -mthumb -mfpu=fpv4-sp-d16 \ -mfloat-abi=hard -nostartfiles -T STM32F411CEUX_FLASH.ld \ startup_stm32f411xe.s system_stm32f4xx.c main.c -o firmware.elf-nostartfiles的作用是告诉链接器不要自动加 C 库的启动文件,因为我们已经显式提供了startup_stm32f411xe.s。这个参数不加可能导致链接器尝试把_start之类符号塞进来,和自定义启动文件冲突。
6.2 用反汇编验证启动序列
编译完成后,先看各关键符号的地址:
arm-none-eabi-size firmware.elf arm-none-eabi-nm -n firmware.elf | grep -E '(_estack|_sdata|_edata|_sbss|_ebss|Reset_Handler|main)'然后反汇编Reset_Handler,理论上能看到.data搬运、BSS 清零、调用SystemInit和main的完整指令序列:
arm-none-eabi-objdump -d firmware.elf | sed -n '/<Reset_Handler>:/,/^$/p'这里有个很实用的验证点:看搬迁.data时用的源地址_sidata,它应该落在 Flash 地址范围内,比如 0x0800xxxx;而目的地址_sdata应该落在 RAM 地址范围内,比如 0x2000xxxx。如果两者都在 Flash 里,说明链接脚本的AT>部分写错了。
6.3 实际调试时的断点建议
用 ST-Link 连接 WeAct 板子的 SWD 引脚,复位后先在Reset_Handler第一行下一发条件断点,再单步到main。如果用的是 OpenOCD,命令大致是:
openocd -f interface/stlink.cfg -f target/stm32f4x.cfg然后在 GDB 里:
monitor reset halt x/8wx 0x08000000 b *Reset_Handler continue复位后立刻读 0x08000000 开始的 8 个字,如果第一项不是栈顶、第二项不是Reset_Handler,说明你的烧录文件起始位置就有问题。这是判断"CPU 是否真的认识你的程序"的最快方法,比任何代码审查都直接。
我对这类"程序没反应"问题的处理习惯,总结起来就一句话:第一轮先不怀疑业务逻辑,而是从复位向量开始把启动链路走一遍。PC 该跳到哪、SP 该是多少、main的地址在不在 Flash 里,这几个硬指标对上了,再回头看代码。很多时候,问题不是 bug,而是程序压根没被放置到 CPU 预期的地方。这个思路放在任何 Cortex-M 芯片上都通用,F411 只是其中一个最顺手的上手样本。