1. 上电复位后,CPU 看到的只有两个 32 位数字
手里这块 WeAct STM32F411 到手之后,多数人做的第一件事就是插上 Type-C,看板子上的电源灯亮不亮。灯亮一半心就放下了,觉得 CPU 已经在跑自己的程序。但如果你把调试器挂上去,在 main() 的第一行下个断点,再按一下复位,会看到一个挺反直觉的事实:CPU 从 STM32F411 上电到真正走进 main() 之间,隔着一小段完全不属于 C 语言的汇编代码,而且这段代码里出现的每一个符号,CPU 都不认识。
这件事第一次想明白的时候,我对"编译"和"链接"的理解才算真正落地。我们平时写代码,习惯性地把 main() 当成程序的起点,觉得 CPU 上电之后应该直奔它而去。实际上 CPU 是一个极度"短视"的东西,它没有函数名的概念,没有变量的概念,甚至不知道什么叫做"程序入口"。它能做的只有一件事:从固定的地址取一个数,把它当成指针跳过去,然后照着机器码一条一条执行下去。
在 Cortex-M4 这份架构说明书里,复位这件事被规定得死死的。上电复位释放的那一刻,内核硬件会做两个动作:从地址 0x00000000 读一个 32 位字,装进主栈指针 MSP;从地址 0x00000004 再读一个 32 位字,装进程序计数器 PC。这两个动作是硬件电路干的,不经过任何指令,也不经过任何编译器。你写的 main() 在这个时间点上,只是 Flash 里一段还没人知道地址的二进制。
1.1 0x08000000 和 0x08000004 里装的到底是什么
STM32F411 的 Flash 起始地址是 0x08000000,容量 512K。在 BOOT0 引脚拉低、nBOOT1 选项位为 1 的正常启动模式下,这块 Flash 会被映射到 0x00000000,所以硬件读的那两个地址,本质上读的就是 0x08000000 和 0x08000004。
打开启动文件startup_stm32f411xe.s,最前面就是一张中断向量表:
.section .isr_vector,"a",%progbits .type g_pfnVectors, %object g_pfnVectors: .word _estack /* 0x08000000:栈顶地址 */ .word Reset_Handler /* 0x08000004:复位入口 */ .word NMI_Handler .word HardFault_Handler .word MemManage_Handler .word BusFault_Handler .word UsageFault_Handler ...表里的第一项_estack,是链接脚本算出来的一个数。F411 的 SRAM 一共 128K,从 0x20000000 排到 0x2001FFFF,栈是向下生长的,所以栈顶取整个 SRAM 的最高地址再加一,也就是0x20020000。这个值会被原封不动地放在 Flash 最开头的四个字节里,硬件复位时读出来直接装进 MSP。也就是说,从第一个时钟周期开始,栈就已经可用了,这一点很多人以为要靠代码去设置,其实硬件已经替你做了。
表里的第二项Reset_Handler,同样是一个数值,只不过它是函数地址。注意 Cortex-M 只支持 Thumb 指令集,所以所有函数指针的最低位必须是 1,用来告诉内核"跳到这个地址时按 Thumb 解码"。实际烧进 Flash 的内容就是这个地址加了 1 之后的模样。这也是为什么用调试器直接改 PC 去跳函数时,忘了或上这个第 0 位,程序立刻跑飞。
1.2 main 是链接器造出来的符号,CPU 根本不知道它存在
main这个词在整个工具链里扮演的角色,其实只是一个约定。C 语言标准规定,程序的入口是一个签名为int main(void)或int main(int argc, char *argv[])的函数,链接器看到这个约定,就把它当作一个必须存在的符号。你在启动文件里写bl main,汇编器并不会把"main"这三个字母放进机器码,它只是生成一条BL指令,操作数是一个相对于当前 PC 的偏移量,这个偏移量要等链接阶段把 main 的真实地址算出来之后才能填进去。
所以如果某个工程里没有 main,报错不会出现在运行阶段,而是链接阶段就炸了:undefined reference to 'main'。反过来,如果你的工程里有 main,但启动文件被误删了,或者中断向量表没有正确放到 Flash 开头,那链接一样能过,可烧进去之后 CPU 读到的第一个字是 0xFFFFFFFF 之类的东西,MSP 指向一个不存在的地址,第一次压栈就触发 HardFault,现象就是"板子插上电跟死了一样"。这两种失败模式长得完全不一样,区分清楚能省下大量瞎猜的时间。
还有一个特别容易混的点:很多人以为编译器知道 main 是入口,会为它做特殊处理。实际上现代 GCC 对 main 没有任何魔法,它就是一个普通的全局函数,唯一的特殊之处是 C 运行时库需要它存在。你甚至可以把 main 声明成 static 之外的其他形式,链接器照样能找到它,只是行为上不再符合标准,属于自己给自己挖坑。
1.3 从复位到 main 之间的真实调用链
把这条链子完整画出来,大概是下面这个顺序。它不是某一个标准规定的,而是"硬件规定 + 启动文件 + 运行时库 + 你自己的代码"四层叠出来的结果:
| 阶段 | 执行者 | 干了什么 |
|---|---|---|
| 1 | 内核硬件 | 从 0x08000000 装 MSP,从 0x08000004 装 PC |
| 2 | Reset_Handler | 设栈顶(冗余)、搬 .data、清 .bss |
| 3 | SystemInit | 开 FPU、复位时钟寄存器、设置 SCB->VTOR |
| 4 | __libc_init_array | 调用 C++ 全局构造函数、执行 init_array 段 |
| 5 | main | 你的 HAL_Init、SystemClock_Config、while(1) |
注意第 3 步和第 4 步的位置。很多人以为时钟配置是在 main 里做的,这没错,但那指的是系统主频。SystemInit 里做的"复位 RCC",是把时钟切回内部 16MHz HSI,同时把 PLL 关掉、分频器清掉,保证不管上一个程序把时钟折腾成什么样,复位之后都有一个确定的起点。这个动作极其重要,因为如果你用调试器做软件复位,而没走 SystemInit 这条路径,寄存器状态可能就是脏的。
理解了这条链子,再回头看标题里的那句话就顺了:CPU 不认识 main(),它只认识地址;启动文件也不认识 main() 之外的东西,它只是把一堆必须做完的体力活干完之后,用一条bl把控制权交出去。main() 之所以存在,是因为我们人类需要一个语义清晰的起点,这个需求由链接器和 C 运行时库共同满足,跟硬件没有半点关系。
2. WeAct STM32F411 启动文件里的四件体力活
真正把启动文件从头到尾读一遍,会发现它短得可怜,一百多行汇编,去掉中断向量表和一堆Default_Handler的占位,核心逻辑就几十行。可就是这几十行,决定了你后面写的所有 C 代码能不能正常工作。我在第一次手写启动文件的时候,漏掉了 .data 搬运,结果所有带初值的全局变量读出来都是随机数,排查了两个小时才反应过来。
2.1 设置栈顶:那条看起来多余的 ldr sp, =_estack
启动文件的第一条指令通常是:
Reset_Handler: ldr sp, =_estack前面说过,硬件复位时已经从向量表第一个字把 MSP 装好了,那这句是不是多余的?从功能上讲,确实冗余。但在工程上它有两个实际价值。第一,它是双保险,万一有人改动了向量表布局,或者用了某些特殊的调试手段直接跳进 Reset_Handler(比如调试器强制改 PC),栈指针依然是正确的。第二,可读性——读代码的人一眼就知道这段代码假定栈顶在哪,不用回头翻链接脚本。
真正需要留意的是栈的大小。链接脚本里会写_Min_Stack_Size,GCC 的 F4 模板一般是 0x400,也就是 1K。1K 的栈在裸机点灯程序里绰绰有余,但你一旦用上 printf、浮点运算、递归,或者某个 HAL 函数里头有几十字节的局部数组,就很容易溢出。栈溢出在 Cortex-M 上的表现极具迷惑性:它不是立刻 HardFault,而是悄悄把相邻的 .bss 变量改掉,等你在别的地方发现某个标志位莫名其妙变了,才会想到是栈的问题。我的习惯是把最小栈和最小堆都调到 0x800 起步,反正 F411 有 128K SRAM,省这点空间没意义。
顺带说一句 .fpu 的声明。ST 官方的 F411 启动文件里写的是.fpu softvfp,意思是按软件浮点 ABI 编译。如果你在编译器选项里开了硬浮点(-mfpu=fpv4-sp-d16 -mfloat-abi=hard),而启动文件没跟着改,理论上 ABI 不一致可能导致传参错乱。实测中大多数情况能跑,但遇到浮点参数传递出问题时,第一个要检查的就是这两处是不是一致。
2.2 搬运 .data 与清零 .bss:全局变量的初值从哪来
这是启动文件里最有信息量的一段,也是最容易被跳过的部分。先想一个问题:你写int g_count = 5;,这个 5 存在哪?答案有点分裂——它同时存在于两个地方。在 Flash 里,这个 5 作为初值被存了一份;在运行时,变量本身住在 SRAM 里。这两者之间的搬运工作,就是启动文件的活儿。
/* 把 .data 段的初值从 Flash 搬到 SRAM */ ldr r0, =_sdata /* SRAM 中 .data 的起始地址 */ ldr r1, =_edata /* SRAM 中 .data 的结束地址 */ ldr r2, =_sidata /* Flash 中初值区的起始地址 */ movs r3, #0 b LoopCopyDataInit CopyDataInit: ldr r4, [r2, r3] str r4, [r0, r3] adds r3, r3, #4 LoopCopyDataInit: adds r4, r0, r3 cmp r4, r1 bcc CopyDataInit_sidata这个符号比较特别,它是LOADADDR(.data),也就是 .data 段在 Flash 中的装载地址(LMA),而不是运行地址(VMA)。链接脚本里写>RAM AT> FLASH,意思就是这段数据的运行位置在 RAM,装载位置在 Flash,两者分开。这个"AT"是理解整个机制的钥匙。
清零 .bss 就更简单了,直接往一块区域里写 0:
ldr r2, =_sbss ldr r4, =_ebss movs r3, #0 b LoopFillZerobss FillZerobss: str r3, [r2] adds r2, r2, #4 LoopFillZerobss: cmp r2, r4 bcc FillZerobss这里有个细节值得说:C 标准规定未初始化的全局变量和静态变量初值为 0,但这个"0"不是编译器在 Flash 里存了一堆 0 再搬过去,而是显式地清零。原因很实在——如果 Flash 里存 0,Brake 段就会白白占掉一大块空间。你可以试试声明一个uint8_t buf[65536];不加初值,编译出来 bin 文件几乎不涨;一旦写成= {1},文件立刻胖了 64K。
配合调试器验证这段逻辑非常直观:在LoopFillZerobss上打个断点,观察_sbss和_ebss的值,直接在内存窗口跳到这两个地址,你会看到清零前的随机内容和清零后的全 0。这一步做完,你对"全局变量为什么能用"这件事就不会再有模糊感了。
2.3 SystemInit、__libc_init_array 与 main 的先后顺序
搬运和清零做完之后,启动文件连续调用三个函数:
bl SystemInit bl __libc_init_array bl main bx lr顺序不能换。SystemInit 放在最前面,是因为它要做三件跟"运行环境"有关的事。第一件是打开 FPU——F411 是带 FPU 的 Cortex-M4F,但复位之后 FPU 默认是关闭的,CP10 和 CP11 两个协处理器访问位都是禁止状态。这时候如果执行任何浮点指令,会直接抛 UsageFault,进而升级成 HardFault。第二件是复位 RCC,把时钟拉回一个干净的默认状态。第三件是设置SCB->VTOR,告诉内核中断向量表放在哪。如果你把程序放到 Flash 里但起始地址不是 0x08000000(比如做 Bootloader + APP 的双区设计),VTOR 必须跟着改,否则一进中断就跳到错误的地方。
__libc_init_array是 newlib 提供的,负责遍历.preinit_array、.init_array段里的函数指针。C 语言程序里这些段通常是空的,所以你会觉得它好像什么都没干;但在 C++ 工程里,全局对象的构造函数就挂在这里。如果少了这一步,所有全局对象的成员变量都是未定义值,调用虚函数表直接飞。踩过这个坑的人,估计都有一段"为什么 C++ 能编过但一上电就死"的回忆。
2.4 GCC 的 bl main 和 Keil 的 bl __main 不是一回事
这一段是我见过最多的认知混淆点之一。用 GCC(STM32CubeIDE、Makefile、CLion 都算)时,启动文件里的最后一句是bl main,直接跳到你的主函数。但用 Keil MDK(ARM Compiler)时,启动文件里写的是bl __main,注意前面有两个下划线。
这里的__main不是main的笔误,它是 ARM 编译器自己提供的一个运行时入口。它会先执行 scatter loading(分散加载),把 RW 段从加载域搬到运行域,把 ZI 段清零,然后才调用 main。换句话说,Keil 把这部分搬运工作从汇编启动文件挪到了__main里面,用 C 库统一处理。所以如果你在 Keil 下想让 main 正常被调用,两端都要对齐:启动文件里必须调__main,不能只调main;反过来 GCC 工程里如果写了bl __main,链接时会找不到符号。
这条差异带来的实际后果是:跨工具链移植工程时,启动文件绝对不能直接复制粘贴。我见过有人把 Keil 的启动文件搬到 GCC 工程里,编译时提示__main未定义,于是随手改成main,结果 .data 段没人搬,所有带初值的变量全错。当时的排查过程非常痛苦,因为代码看起来完全正常,只是行为古怪。
3. 把链接脚本、map 文件和启动文件对着看
光看启动文件是看不出全局的,因为里面所有的_sdata、_edata、_sidata、_sbss、_ebss都是从别处借来的名字,它们的值由链接脚本决定。把这三个东西——启动文件、链接脚本、map 文件——放在一起对照,整个启动过程才真正闭合。
3.1 F411 的 512K Flash 和 128K SRAM 是怎么被切开的
链接脚本的 MEMORY 部分先把物理地址描述清楚:
ENTRY(Reset_Handler) /* 栈顶取 RAM 的最末端 */ _estack = ORIGIN(RAM) + LENGTH(RAM); _Min_Heap_Size = 0x800; _Min_Stack_Size = 0x800; MEMORY { RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 128K FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K }注意这里没有把 SRAM 切成几块。STM32F411 只有一块 128K 的 SRAM,地址连续,不像某些 F4 型号那样还有一块 CCM RAM 挂在 0x10000000 上。所以你在看别人的 F407 链接脚本时看到的CCMRAM段,在 F411 上直接照抄会链接失败。
SECTIONS 部分才是真正决定布局的地方:
SECTIONS { .isr_vector : { . = ALIGN(4); KEEP(*(.isr_vector)) . = ALIGN(4); } >FLASH .text : { . = ALIGN(4); *(.text) *(.text*) *(.rodata) *(.rodata*) . = ALIGN(4); _etext = .; } >FLASH /* .data 的装载地址,也就是它初值在 Flash 中的起点 */ _sidata = LOADADDR(.data); .data : { . = ALIGN(4); _sdata = .; *(.data) *(.data*) . = ALIGN(4); _edata = .; } >RAM AT> FLASH .bss : { . = ALIGN(4); _sbss = .; *(.bss) *(.bss*) *(COMMON) . = ALIGN(4); _ebss = .; } >RAM }KEEP(*(.isr_vector))里的 KEEP 很关键。默认情况下链接器会做垃圾回收(section GC),如果某个段没有任何符号引用,就把它丢掉。中断向量表恰恰是一个"没人引用"的段,全靠硬件去读,如果不加 KEEP,开了-ffunction-sections -fdata-sections -Wl,--gc-sections之后它会被整段删掉,编译链接一路无警告,烧进去就是死机。这个坑相当经典,很多"为什么我开了 gc-sections 之后单片机不跑了"的问题都出在这里。
3.2 map 文件里找 _sidata、_sdata、_sbss 的真实地址
编译时加上-Wl,-Map=build/app.map,链接完就能拿到一份完整的地址清单。在 map 文件里搜这几个符号,能看到它们的真实取值:
.data 0x0000000020000000 0x1c load address 0x08000a3c 0x0000000020000000 _sdata = . 0x000000002000001c _edata = . .bss 0x000000002000001c 0x3e4 0x000000002000001c _sbss = . 0x00000000200003fc _ebss = .从这几行能读出很多信息。.data的运行地址是 0x20000000,也就是 SRAM 的最开头,装载地址是 0x08000a3c,在 Flash 里。两个地址一减,_sidata对应的就是 Flash 里那一小段初值数据。_edata - _sdata = 0x1c,说明整个工程里带非零初值的全局变量只有 28 个字节,这是很典型的裸机工程。.bss从 0x2000001c 开始,长度 0x3e4,接近 1K,这里面装的是各种全局缓冲区和 HAL 的内部句柄。
再看一眼 map 文件顶部的内存汇总表:
Memory Configuration Name Origin Length Attributes FLASH 0x08000000 0x00080000 xr RAM 0x20000000 0x00020000 xrwFlash 用掉的部分通常在"Total"那一栏里写得很清楚。养成每次改完工程看一眼这个数字的习惯,对容量吃紧的项目很有用。F411 有 512K Flash,一般情况下不用担心,但如果你加了 FatFS、USB 协议栈、字库,几万行代码堆进去,涨到 200K 以上是很正常的。
3.3 FPU 和 VTOR:自己写启动代码最容易漏掉的两件事
如果你打算不用 CubeMX,从零搭一个工程,那么SystemInit里这两件事必须自己补上。第一件是 FPU 使能:
#if (__FPU_PRESENT == 1) && (__FPU_USED == 1) SCB->CPACR |= ((3UL << (10 * 2)) | (3UL << (11 * 2))); #endifCP10 和 CP11 分别对应 FPU 的两个"协处理器编号",每个占 2 位,写 3 表示全权限。少了这一段,任何一条VMOV、VADD指令都会触发 UsageFault。而更让人难受的是,编译器在优化时可能悄悄插入浮点指令,比如你把一个 float 乘 0 优化掉,反而侥幸跑过去了,换成乘 1.5 就炸,现象毫无规律。
第二件是向量表偏移:
#ifdef VECT_TAB_SRAM SCB->VTOR = SRAM_BASE | VECT_TAB_OFFSET; #else SCB->VTOR = FLASH_BASE | VECT_TAB_OFFSET; #endif在单区程序里,VTOR 的默认值恰好就是 0x08000000,所以即使不写也"看起来正常"。可一旦你做了 Bootloader,APP 从 0x08010000 开始,而 SystemInit 还在把 VTOR 设成 0x08000000,那么 APP 里任何一个中断都会跳到 Bootloader 的向量表里去,执行一段完全不相干的代码。这个问题极其隐蔽,因为主循环跑得好好的,只有中断里的功能会莫名失效,甚至直接把程序带飞。
4. "断电不跑、点 Run 就跑"这类问题的排查链路
热词榜上有一类问题反复出现:GD32 上电不能自动运行,用 J-Link 点一下 Run 就正常;或者 STM32 烧完程序拔了调试器就没反应。这类现象和启动流程关系极大,值得单独拆开说。我把它总结成一条从"问题出在哪个阶段"开始往下切的排查链路,而不是一上来就瞎换板子。
4.1 第一步:先判断问题出在复位阶段还是 main 之后
判断方法非常简单:在Reset_Handler的第一条指令、SystemInit入口、main入口各打一个断点,然后正常上电(不是用调试器复位按钮,是真拔电再插)。三个断点分别对应三个结论:
- 一个都停不下来,程序在启动文件之前就出问题了,方向指向硬件复位、BOOT 引脚、供电、调试器连接方式。
- 停在 Reset_Handler 但进不了 main,问题在搬运或时钟配置,通常是卡在等待 HSE 稳定的循环里。
- 进了 main 但功能不对,问题在 main 之后的代码,跟启动流程无关,别在这上面浪费时间。
这一步看起来简单,但能省掉一半以上的排查功夫。我见过太多人一上来就怀疑芯片坏了,实际上连问题属于哪一层都没分清。
4.2 BOOT0、NRST、供电与晶振:上电时序上的四个疑点
把矛头指向启动阶段之后,按下面这张表顺序过一遍:
| 现象 | 可能原因 | 验证手段 |
|---|---|---|
| 拔掉调试器就完全不跑,插着能跑 | BOOT0 悬空被拉高,进了系统 Bootloader | 万用表量 BOOT0,确认为 0V |
| 上电偶发不启动,复位一下就好 | 电源上升时间太慢或纹波大,复位未完全释放 | 示波器看 3.3V 上升沿和 NRST 波形 |
| 程序停在时钟等待循环 | 晶振未起振或负载电容不匹配 | 示波器测 HSE 引脚有无正弦波;先用 HSI 跑通 |
| 烧录后第一次能跑,断电再上电就跑飞 | 选项字节里的 nBOOT1、看门狗或读保护设置不对 | 用烧录工具读选项字节 |
BOOT0 这一条最值得展开。STM32F411 的启动模式由 BOOT0 引脚加上 nBOOT1 选项位共同决定:BOOT0 = 0 时从主 Flash 启动;BOOT0 = 1 且 nBOOT1 = 1 时从系统存储器启动,也就是进出厂 Bootloader;BOOT0 = 1 且 nBOOT1 = 0 时从内置 SRAM 启动。WeAct 板上有 BOOT0 按键,按下时 BOOT0 被拉高进入下载模式,松开后应该回到低电平。如果这个引脚没有下拉电阻,或者你手焊的板子上漏了,就会出现"按住 BOOT0 上电进下载模式,松开后不重启"这类诡异现象。
晶振的问题也很常见。WeAct F411 用的是 25MHz 无源晶振,负载电容一般配 20pF 左右。如果换了不同负载电容的晶振,或者焊盘上的电容贴错,起振时间会明显变长,而 HAL 里HAL_RCC_OscConfig等待 HSERDY 是有超时的,默认值一般在 100ms 量级。超时之后,如果你的代码没有处理返回值,直接往下走,后面切 PLL 就会失败,程序停在一个看起来很正常的 while 循环里,实际主频还是 16MHz HSI。
4.3 卡在时钟等待循环里的典型现象
这一类的表现非常有辨识度:程序不跑飞,不断电,调试器连上去看 PC,停在一个b .或者某个 HAL 函数的内部循环里。用调试器看一下 RCC->CR 的值,重点看 HSERDY 这一位。如果是 0,说明 HSE 没稳定,往上查晶振和供电。
还有一种情况是 PLL 配置本身超出了芯片能力。STM32F411 的最高主频是 100MHz,不是 168MHz(那是 F407/F429 的天花板)。有人从 F407 的工程里直接复制时钟配置,PLLN 设成 336,出来 168MHz,结果芯片跑一会儿就死。这个"跑一会儿才死"特别迷惑人,因为它不是立刻失败,而是超频之后温升和时序裕量不够,运行几秒钟到几分钟才崩。
把 25MHz HSE 配到 100MHz 的完整参数如下,可以对照着检查:
void SystemClock_Config(void) { RCC_OscInitTypeDef osc = {0}; RCC_ClkInitTypeDef clk = {0}; __HAL_RCC_PWR_CLK_ENABLE(); /* 100MHz 必须用 Scale1,Scale2 上限只有 84MHz */ __HAL_PWR_VOLTAGESCALING_CONFIG(PWR_REGULATOR_VOLTAGE_SCALE1); osc.OscillatorType = RCC_OSCILLATORTYPE_HSE; osc.HSEState = RCC_HSE_ON; osc.PLL.PLLState = RCC_PLL_ON; osc.PLL.PLLSource = RCC_PLLSOURCE_HSE; osc.PLL.PLLM = 25; /* 25MHz / 25 = 1MHz */ osc.PLL.PLLN = 200; /* 1MHz * 200 = 200MHz */ osc.PLL.PLLP = RCC_PLLP_DIV2; /* 200 / 2 = 100MHz */ osc.PLL.PLLQ = 4; /* 200 / 4 = 50MHz,USB 用 */ if (HAL_RCC_OscConfig(&osc) != HAL_OK) { Error_Handler(); } clk.ClockType = RCC_CLOCKTYPE_HCLK | RCC_CLOCKTYPE_SYSCLK | RCC_CLOCKTYPE_PCLK1 | RCC_CLOCKTYPE_PCLK2; clk.SYSCLKSource = RCC_SYSCLKSOURCE_PLLCLK; clk.AHBCLKDivider = RCC_SYSCLK_DIV1; /* AHB = 100MHz */ clk.APB1CLKDivider = RCC_HCLK_DIV2; /* APB1 = 50MHz,上限 50 */ clk.APB2CLKDivider = RCC_HCLK_DIV1; /* APB2 = 100MHz,上限 100 */ if (HAL_RCC_ClockConfig(&clk, FLASH_LATENCY_3) != HAL_OK) { Error_Handler(); } }注意最后那个FLASH_LATENCY_3。Flash 访问速度跟不上 CPU 速度,必须插入等待周期。VOS = Scale1 且 2.7V 以上时,90MHz 到 100MHz 这一档要 3 个等待周期。如果你用了默认的 0 等待周期,CPU 在 100MHz 下读 Flash 会读到错误的指令,表现就是随机崩溃、变量取值莫名其妙。这个参数和电压档位、主频三者必须一起匹配,改一个不改另一个,迟早出问题。
4.4 一个能快速缩小范围的最小化验证
排查到这一步如果还没定位,我一般会写一个最小工程:关掉所有外设,只留启动文件、时钟配置、一个空 while 循环,然后在 PC13 上以肉眼可见的频率翻转电平。这个工程的目的是把变量降到最少,如果它能稳定跑,说明启动流程没问题,故障在应用代码里;如果它也不行,那就把晶振换成 HSI 再试一遍,能跑就锁定在晶振,不能跑就往供电和芯片本身查。
PC13 在 WeAct F411 上是板上 LED 的引脚,注意它属于备份域相关的 IO,驱动能力和普通 IO 略有差别,推挽输出下可以正常点灯,但拉电流能力有限,别拿它驱动大电流负载。用来做"我还活着"的心跳指示是够用的。
5. 手写一份最小启动流程之后,我改了这些习惯
把启动流程从头捋一遍最大的收获,不是记住了几个符号名,而是对"程序到底长什么样"有了一个物理层面的认知。以前调程序靠猜,现在调程序靠位置——先确定问题在启动前、启动中还是启动后,再往下切。这个思路上的变化,比学会任何一个具体技巧都值钱。
5.1 不用 CubeMX 时最少要写哪些东西
如果你决定脱离生成器,自己搭一个能在 F411 上跑起来的工程,最少需要这几样:
- 一份链接脚本,定义 MEMORY 和关键段,导出
_estack、_sidata、_sdata、_edata、_sbss、_ebss。 - 一份启动汇编,包含中断向量表、Reset_Handler、默认中断处理函数。
- 一个
SystemInit,负责开 FPU、复位 RCC、设置 VTOR。 - 一个
main,里面至少先把系统时钟配好,再进循环。 - 编译参数里要带上
-mcpu=cortex-m4 -mthumb -mfpu=fpv4-sp-d16 -mfloat-abi=hard或者 soft,两者要和启动文件一致。 - 链接参数里带上
-T xxx.ld -Wl,-Map=xxx.map --specs=nano.specs。
这六样凑齐,一个能跑的最小工程就成立了,剩下的外设驱动都可以慢慢加。我第一次这么搭的时候花了大概半天,比用 CubeMX 慢得多,但之后遇到任何启动相关的问题,心里都有底。
5.2 堆栈水位、map 文件与 HardFault 定位
堆栈溢出在裸机开发里是个老大难。有一个不用额外工具的办法:把栈区域先用 0xA5 填满,运行一段时间后,从_estack往下扫,看多少个字节还是 0xA5,就能算出历史最深用到哪。做法是在 .bss 之后单独开一段._user_heap_stack,在启动文件里加一小段填充循环,或者干脆在 main 开头手动填一次:
#define STACK_FILL_PATTERN 0xA5A5A5A5UL extern uint32_t _estack; static void stack_paint(uint32_t bytes) { uint32_t *p = &_estack - (bytes / 4); uint32_t i; for (i = 0; i < bytes / 4; i++) { p[i] = STACK_FILL_PATTERN; } } static uint32_t stack_used_bytes(uint32_t bytes) { uint32_t *p = &_estack - (bytes / 4); uint32_t i; for (i = 0; i < bytes / 4; i++) { if (p[i] != STACK_FILL_PATTERN) { return bytes - i * 4; } } return 0; }HardFault 的定位则是另一套。Cortex-M4F 有 HardFault、MemManage、BusFault、UsageFault 多个异常,默认情况下 MemManage 等被屏蔽,全部升级为 HardFault。写一个 HardFault_Handler,从栈帧里把入栈的 PC、LR、R0-R3 取出来,就能知道是哪条指令出的事。这里的难点在于判断出错时用的是 MSP 还是 PSP——如果错误发生在中断里,用的是 MSP;发生在任务里,可能是 PSP。用 LR 的 bit2 可以判断:
void HardFault_Handler(void) { __asm volatile ( "tst lr, #4 \n" "ite eq \n" "mrseq r0, msp \n" "mrsne r0, psp \n" "b HardFault_Report \n" ); }拿到 PC 之后,用arm-none-eabi-addr2line -e app.elf 0x08000abc就能反查出源码行号。这套流程走通一次之后,之后遇到任何跑飞的问题都能快速定位,不用再靠删代码猜。
5.3 几个只有踩过才知道的细节
先说 .data 搬运和擦写 Flash 的关系。在做 IAP 升级的时候,很多人的做法是:APP 收到新固件,写进 Flash 的备份区,然后跳转。这时候如果 APP 的 .data 段在搬运过程中被中断打断,或者搬运源地址在擦除范围内,就出问题了。搬运 .data 的时候必须保证 Flash 不被操作,这是启动阶段极度敏感的一段代码,任何外部干扰都不能有。
再说一个关于_estack的细节。如果你把栈顶设在 SRAM 最末端,那么栈向下生长的第一件事就是往下压一个 32 位字。如果此时发生了中断,硬件会自动压入 8 个寄存器,占用 32 字节。所以栈的最末端实际上是被中断帧反复踩踏的区域,绝不能放任何变量。有些链接脚本里会写._user_heap_stack,把栈显式地放在 .bss 之后而不是 RAM 顶端,这样栈溢出就是踩到堆或者 .bss,反而更容易被发现。两种布局各有取舍,关键是心里清楚栈在哪个位置,以及在什么情况下会溢出。
最后一个关于调试器的。用 J-Link 或 ST-Link 的时候,"点 Run 就能跑"这件事本身可能就是一个误导信号,因为调试器在连接时会做很多事情,包括给芯片上电、复位、初始化时钟,甚至在某些配置下帮忙加载了向量表。所以一个程序"插着调试器正常、拔下来不正常",千万不要以为程序没问题,恰恰相反,问题很可能就在最开始的几百个时钟周期里,而调试器把它掩盖掉了。真正可靠的验证方式,永远是真断电、真上电、然后靠板上那个心跳灯说话。