ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

CPU不认识main:STM32F411上电启动链路与排障实战

CPU不认识main:STM32F411上电启动链路与排障实战 手上这块 WeAct STM32F411 小板子买回来在抽屉里躺了半个月某天晚上插上 Type-CPC13 的灯没亮。我盯着它看了两秒脑子里冒出一个挺反常识的念头这颗 Cortex-M4 从上电到真正跑进main()中间没有任何一步是CPU 认识 main的。对内核来说main这三个字母和0x08000abc没有任何区别它只认地址。很多人第一次接触裸机开发时会下意识觉得main是个神圣的入口CPU 一上电就直奔它而去——这个错觉会让你在上电不跑、点一下 Run 才跑这类问题上绕很久的弯路。这篇东西就是把这层窗户纸捅破。我打算从 STM32F411 上电那一瞬间讲起把复位向量、启动文件里的汇编、链接脚本、时钟树、Flash 等待周期这些平时被 IDE 藏起来的东西全部摊开并且在 WeAct 这块板子上真刀真枪地验证一遍。适合刚摸 MCU 的朋友建立底层直觉也适合已经能跑点灯、但一遇到程序不启动HardFault换块板子就挂就抓瞎的人。全程用命令行工具链演示不依赖任何 IDE 的魔法。1. 上电那一瞬间CPU 眼里其实只有一个地址1.1 Cortex-M4 复位时只做两件固定动作先把最核心的事实说清楚。Cortex-M 系列的内核在复位释放之后硬件层面只做两件事而且是被写死在硅片里的从地址0x00000000读一个 32 位字装进主栈指针 MSP再从地址0x00000004读一个 32 位字装进程序计数器 PC。装完之后内核就从 PC 指向的地方开始取指执行。注意这里读的是0x00000000不是0x08000000。STM32 在片内做了一层地址别名映射把启动区域映射到了 0 地址具体映射谁由复位那一刻采样的 BOOT 引脚决定。STM32F411 上有 BOOT0专用引脚和 BOOT1复用 PB2采样发生在 NRST 上升沿附近之后你再改这两个脚也不会影响本次启动。BOOT0BOOT1被别名到 0x00000000 的区域典型用途0x主 Flash0x08000000正常运行自己的程序10系统存储器0x1FFF0000出厂 ROM 引导程序USB DFU 下载11片内 SRAM0x20000000调试场景一般不用所以如果0x08000000处的头两个字不是合法的栈顶地址和复位入口地址内核取完 PC 之后就会飞到一个乱七八糟的地方表现出来就是上电没反应。这也是后面排查章节的起点。1.2 main 在 ELF 里只是一个普通符号把编译产物丢给nm看一眼你会发现main和SystemInit、HAL_Init是同一个级别的符号都是.text段里某个偏移量上的一个名字arm-none-eabi-nm -n build/firmware.elf | head -20 # 080002a0 T Reset_Handler # 080002d0 T SystemInit # 08000340 T mainReset_Handler的地址比main更靠前是因为它被链接脚本安排在了.text段最前面紧跟着向量表。换句话说main之所以能被执行纯粹是因为有人主动bl main跳了过去。这个人就是启动文件里那段几十行的汇编。还有一个容易被忽略的点真正决定能不能链接成功的是工具链对main这个符号的约定。GCC 的 C 运行时会引用main如果你在-nostartfiles之外的情况下把入口改名成app_mainESP-IDF 就是这么干的它在启动代码里包了一层链接器就会报undefined reference to main。反过来你写void main()在某些严谨的编译选项下会被警告甚至报错因为标准约定是int main(void)或int main(int argc, char **argv)。所谓编译器未包含 main说的就是这套约定没被满足。1.3 真正认识 main 的是三层契约把这段链路拆开看其实是三层东西在配合工具链约定C 运行时需要一个名为 main 的入口、启动代码startup_stm32f411xe.s里手写的跳转、链接脚本决定各段放在哪、栈顶在哪。三者缺一上电跑到 main这件事就不成立。你换编译器、换链接脚本、换启动文件任何一层不同步症状都是程序不启动。顺带说一句main这个词在不同体系里含义天差地别Docker 镜像标签里的:main指的是主分支构建产物Java 抛出的Exception in thread main是 JVM 约定的入口线程名Hadoop 那句did not find winutils.exe是运行环境缺失导致的告警。它们共用一个词但和嵌入式里CPU 不认识 main这件事毫无关系别被搜索结果混淆。2. 在 WeAct STM32F411 上把这条链路亲眼验证一遍光讲理论没意思我在这块板子上把整条链路都跑了一遍下面的步骤你可以照着复现。2.1 最小工程与工具链准备工具链就三样arm-none-eabi-gcc交叉编译器、make、以及一个下载器。WeAct 这块板子自带 SWD 排针我用的是 CMSIS-DAP 调试器配 OpenOCD也可以用 ST-Link。如果你手头什么都没有还有个零成本办法把 BOOT0 按下去再点一下 NRST板子会以出厂 ROM 引导程序启动通过 USB Type-C 直接枚举成一个 DFU 设备用dfu-util就能烧写。# 编译 make -j8 # 看段布局 arm-none-eabi-size build/firmware.elf # 启动 OpenOCDCMSIS-DAP openocd -f interface/cmsis-dap.cfg -f target/stm32f4x.cfgsize的输出很有信息量text是 Flash 占用data是既要占 Flash 又要占 RAM的部分bss是只占 RAM、上电清零的部分。这三个数对应的就是后面Reset_Handler要干的三件事。2.2 用 readelf 和 objdump 看向量表前八个字节不用连板子直接看 ELF 文件就能确认向量表长什么样arm-none-eabi-objdump -s -j .isr_vector build/firmware.elf | head -5 # 08000000 00000220 01000008 ...第一行前 8 个字节按小端解释0x20000200是栈顶对于 128KB SRAM 的 F411栈顶就是0x20000000 0x20000 0x20020000这里只是示例数值第二个字0x08000101是复位入口地址——最低位是 1 是因为 Thumb 指令集要求函数指针最低位为 1实际地址是0x08000100。这个小细节经常让人困惑为什么nm显示Reset_Handler在0x08000100向量表里写的却是0x08000101就是这个原因。紧接着的第 3、4 个字是 NMI 和 HardFault 的处理函数地址后面依次是其余外设中断。你要是发现自己写的中断函数死活进不去先来看这张表里有没有你的函数名比在代码里一行行加打印快得多。2.3 用 GDB 从 Reset_Handler 单步走到 main这是最有意思的一步。连上调试器在Reset_Handler下断点然后单步arm-none-eabi-gdb build/firmware.elf (gdb) target extended-remote :3333 (gdb) monitor reset halt (gdb) info registers sp pc # sp 0x20020000 # pc 0x08000100 Reset_Handler (gdb) x/8i $pc你会亲眼看到内核停在Reset_Handler的第一条指令上SP 已经被硬件装好了PC 也指向了向量表里的那个地址。这两件事都不是软件干的是复位逻辑干的。2.4 打印 _sidata / _sdata / _ebss 验证搬移过程启动文件里引用了四个链接器符号它们不在任何 C 代码里定义而是链接脚本算出来的地址。在main开头把它们打出来extern uint32_t _sidata, _sdata, _edata, _sbss, _ebss; printf(sidata%08lx sdata%08lx edata%08lx\r\n, (unsigned long)_sidata, (unsigned long)_sdata, (unsigned long)_edata); printf(sbss%08lx ebss%08lx\r\n, (unsigned long)_sbss, (unsigned long)_ebss);典型输出是_sidata大约在0x0800xxxxFlash 里_sdata到_edata落在0x20000000附近SRAM 里_sbss/_ebss紧随其后。有了这几个数再回头看下一节的汇编整段代码的意图就一目了然了。3. Reset_Handler 里那几十行汇编写了什么这是 CMSIS 模板启动文件的核心部分各家芯片的写法几乎一致转述如下已化简注释Reset_Handler: ldr sp, _estack /* 保险起见再设一次栈顶 */ ldr r0, _sdata /* RAM 里 .data 的起始地址 */ ldr r1, _edata /* RAM 里 .data 的结束地址 */ ldr r2, _sidata /* Flash 里 .data 初值的起始地址 */ 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 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 bl SystemInit bl __libc_init_array bl main bx lr3.1 数据段搬移为什么初值必须从 Flash 复制到 RAMint counter 5;这样的全局变量运行时必须能被改写所以它必须待在 RAM 里。但 RAM 掉电即失上电时里面全是随机值5这个初值必须存在一个非易失的地方——也就是 Flash。于是就有了两套地址Flash 里的加载地址LMA由_sidata指向和 RAM 里的运行地址VMA由_sdata到_edata覆盖。上面那段循环干的事情就是逐字搬运从 Flash 读一个字写进 RAM指针加 4直到搬完。类比一下就像你把一份模板文件从只读的系统盘复制到桌面再编辑——直接改系统盘里的文件既改不动也会污染原件。这里有个实测中经常踩的坑_sidata、_sdata这些符号必须用取地址而且声明类型无所谓习惯上用uint32_t因为它们代表的是地址不是变量本身。写成extern uint32_t _sdata;然后直接用_sdata会拿到链接器算出来的地址值看起来像能用但语义完全不同换链接脚本或者开优化就可能翻车。3.2 bss 清零和那个让人头大的 -fno-common.bss段里的变量初值都是 0比如int buf[1024];既然全是 0就没必要在 Flash 里存 1024 个零浪费空间。链接脚本只记录从哪到哪需要清零启动代码执行第二个循环挨个写 0。这也是为什么.bss只占 RAM 不占 Flash。顺便说一个 GCC 10 之后变化带来的实际麻烦老版本 GCC 默认-fcommon多个.c文件里重复定义同名全局变量不会报错链接器会把它们合并成一个。GCC 10 起默认改成-fno-common同样的代码会直接报multiple definition。很多从旧工程搬过来的代码在新工具链上第一关就卡住报错位置还挺迷惑——它只会告诉你两个.o里都有这个符号不告诉你这是版本策略变化导致的。解决办法很干净把重复定义改成extern声明加单点定义别图省事加-fcommon把问题按回去。3.3 SystemInit 到底改了什么Reset_Handler里第三个动作是bl SystemInit。这个函数在system_stm32f4xx.c里主要干四件事设置向量表偏移寄存器SCB-VTOR指向 Flash 基址、复位 RCC 时钟寄存器回到默认状态、解除备份域写保护、配置外部存储器控制器F411 上基本没用。它不做的事情很关键它不会把时钟拉到 100MHz。F4 系列的SystemInit只把系统时钟切回复位默认的 HSI也就是 16MHz 内部 RC 振荡器。真正的 96MHz 或 100MHz 时钟配置是在main()里调用HAL_Init()和SystemClock_Config()之后才完成的。这意味着一个反直觉的事实进入 main 的那一刻CPU 还在 16MHz 上跑如果你在 main 开头就初始化了一个依赖精确延时的外设比如软件模拟的单总线时序而此时时钟还没配好时序就会整体慢六倍。这个问题我在调一颗单总线温度传感器时被坑过一次现象是数据偶尔能读出来、偶尔全是 0xFF查了半天才发现是初始化顺序的问题。3.4 __libc_init_array 和main 返回之后bl main之前还有个bl __libc_init_array这是 newlib 提供的函数会遍历.preinit_array和.init_array段把里面的函数指针挨个调用一遍。C 的全局对象构造函数、标了__attribute__((constructor))的函数都是靠它执行的。所以 C 工程如果.init_array段被链接脚本漏掉了症状就是全局对象构造没跑访问它们时状态全是零。再往下看最后一行bx lr如果main真的返回了LR 里是什么复位时 LR 的值是未定义的常见是0xFFFFFFFF所以从 main 返回等于跳到一个非法地址通常是 HardFault。CubeMX 生成的代码总是以while (1) {}结尾这不是风格问题是必须的。真想让 main 返回有意义得先用-nostartfiles接管入口或者把Reset_Handler里的bl main改成bl main后接一个死循环加看门狗复位。4. 链接脚本决定了 main 能不能被找到4.1 STM32F411CEU6 的内存布局WeAct 这块板子用的是 STM32F411CEU6512KB Flash、128KB SRAM都是单 bank。链接脚本里的 MEMORY 块大致长这样MEMORY { RAM (xrw) : ORIGIN 0x20000000, LENGTH 128K FLASH (rx) : ORIGIN 0x08000000, LENGTH 512K }ORIGIN那行是硬事实不能随便改LENGTH如果写小了链接时会出现region FLASH overflowed的报错。我见过有人为了让编译通过把 LENGTH 随手改成 1024K程序能编译能烧但一跑到超出真实容量就被硬件丢弃现象是执行到某处突然跑飞。LENGTH 只能等于真实容量超了就得裁剪功能或者换型号。4.2 栈顶为什么总在 SRAM 末尾链接脚本末尾有一句_estack ORIGIN(RAM) LENGTH(RAM);也就是0x20020000。Cortex-M 的栈是满递减栈从高地址往低地址生长所以栈顶放在 RAM 最高处能用的空间最大。栈和堆如果启用了_sbrk从两端相向生长中间撞上了就是栈溢出典型症状是某个局部大数组一用就 HardFault。启动文件第一句ldr sp, _estack其实是冗余的——硬件已经从向量表第一个字装好了 SP。但保留它有实际意义当你用调试器软复位或者从 bootloader 跳转过来时有时 SP 不是硬件装的手动设一次能救回一些玄学问题。这也是排查上电不跑、点 Run 才跑时经常被忽略的一环。4.3 VTOR、IAP 与向量表重定位只要涉及 bootloaderIAP 升级就一定会碰到两个必须改的地方链接脚本的 FLASH ORIGIN和VTOR。假设 bootloader 占前 16KB应用从0x08004000开始FLASH (rx) : ORIGIN 0x08004000, LENGTH 496K同时在应用早期把SCB-VTOR 0x08004000;。为什么必须改 VTOR因为中断发生时内核是从 VTOR 指向的基址加上中断号乘 4 去取处理函数的如果 VTOR 还指着0x08000000bootloader 的向量表你的应用中断就会跳到 bootloader 的中断处理里。症状极其诡异主循环跑得好好的一进定时器中断就变成了另一种行为。另外提醒一句VTOR 要求向量表按大小对齐最小 128 字节所以偏移量要用0x4000这种对齐值别用0x0810之类。跳转前记得关掉所有中断、反初始化外设、把 MSP 重设一次这三件事漏一件都会留下难以复现的偶发故障。5. 实测踩坑上电不跑点一下 Run 反而正常这套症状到手先别急着怀疑代码。它有一个非常明确的指向性调试器的 Run 按钮通常会先复位内核、设置 PC 和 SP、再放行执行它绕过了硬件取向量表这一步。所以点 Run 能跑说明代码本身和链接都没问题问题在启动路径上。5.1 一条从上到下的排查链路我习惯按下面这个顺序走每一步都尽量用可观测的证据确认而不是猜排查项验证方法典型异常表现BOOT 引脚电平万用表量 BOOT0确认上电瞬间为低BOOT0 悬空被拉高进了 ROM 引导Flash 前 8 字节用下载器读0x08000000比对 ELF 里的向量表前两个字是0xFFFFFFFF说明根本没烧进去烧写地址确认下载地址是0x08000000不是0x08004000下到错误偏移硬件取不到合法向量NRST 电路量复位脚确认没有电容太大导致释放过慢上电时卡在复位态HSE 起振切到 HSI 兜底看是否能跑晶振不起振代码卡在等待 HSERDY看门狗关掉 IWDG看现象是否消失上电后没及时喂狗被反复复位供电量 VDD尤其 USB 供电的板子3.3V 不稳Flash 读取出错第 3 条我想多说一句。有人用 ST-Link Utility 手动指定地址烧写把应用下到了0x08004000但硬件上电还是从0x08000000取向量那里是空白的0xFF。0xFFFFFFFF作为栈顶会被截断成0xFFFFFFFCPC 会变成0xFFFFFFFF内核直接锁死。这种问题用调试器 CtrlR 是发现不了的因为调试器会自己设 PC。第 5 条也非常常见。WeAct 这块板子用的是25MHz晶振不是很多人习惯的 8MHz。如果你直接抄了一份 8MHz 的SystemClock_ConfigHSE_VALUE 宏和实际晶振对不上PLL 参数算出来是错的HAL_RCC_OscConfig会在等待 HSE 就绪的超时里打转最后要么超时返回错误而 CubeMX 生成的代码里如果没检查返回值就会带着错误时钟继续跑要么跑在一个完全不对的频率上串口波特率全部跑偏。改这块板子的第一个动作就是确认HSE_VALUE是25000000。5.2 点 Run 才跑的另外几个隐藏原因除了上面表格里的还有几个更阴的第一种是调试器把芯片停在了某个状态下没释放直接拔线再上电就好了——听起来像段子但我确实遇到过调试会话异常断开后芯片一直被 hold 住的情况。第二种是选项字节option bytes被改过。比如读保护级别、nWRP 写保护、BOR 级别设置错误都可能导致上电行为异常。用 CubeProgrammer 读一下 OB 区域恢复默认值再试。第三种是启动文件根本没进工程。有些模板工程把startup_stm32f411xe.s放在CMSIS目录里用 IDE 新建工程时忘记加入编译链接器找不到Reset_Handler和向量表虽然会报错但如果同时用了自定义的-T脚本可能链接出一个没有.isr_vector的 ELF。这种固件烧进去前 8 字节是别的段的内容行为完全随机。5.3 HardFault 与 printf 重定向的连环坑跑进 main 之后立刻 HardFault也有一个和启动流程强相关的经典原因浮点单元。Cortex-M4F 的 FPU 默认是关闭的任何浮点指令都会触发 UsageFault 并升级为 HardFault。启动代码里通常有一段SystemInit内部的SCB-CPACR | 0x00F00000来打开 FPU如果这份SystemInit被替换成了不含该配置的版本或者链接到了错误的system_stm32f4xx.c一执行浮点运算就崩。另一个是printf。newlib 的printf会走_write系统调用裸机上没有_write链接会报一堆undefined reference to _write/_sbrk/_close。有人随手加了-specsnosys.specs把系统调用桩全部塞成空的结果printf不输出也不报错——因为它往一个空实现里写了。正确姿势是自己实现_write把数据塞进串口并且注意printf是阻塞的在中断里调用会有重入风险。还有浮点格式输出需要加-u _printf_float否则%.2f打印出来是空的这个坑几乎每个人都会踩一次。6. 25MHz 晶振下的时钟算术96MHz 还是 100MHz搞清楚启动流程之后下一个绕不开的就是时钟。STM32F411 的最高主频是 100MHz但很多网上流传的配置是 96MHz这里面有个很实在的取舍。6.1 把 PLL 参数实际算一遍F4 的 PLL 是这样一条链HSE / M得到 PLL 输入要求 1~2MHz建议 2MHz 附近乘N得到 VCO 输出要求 100~432MHz再分别除以P得到 SYSCLK、除以Q得到 USB 用的 48MHz 时钟。用 25MHz 晶振、要 96MHz 主频加 48MHz USB参数取值计算过程结果M2525MHz / 251MHz 输入N1921MHz × 192192MHz VCOP2192MHz / 296MHz SYSCLKQ4192MHz / 448MHz正好给 USBFlash 等待3 WS96MHz 90MHz需 3 个等待周期这一组参数的好处是所有频率都能整除USB 完全合规代价是主频比上限低 4%。6.2 为什么 25MHz 晶振下 100MHz 与 48MHz USB 不可兼得想上 100MHzSYSCLK VCO / P 100所以 VCO 必须是 100、200、300、400 之一。同时 USB 要 48MHzVCO / Q 48Q 是 2~15 的整数所以 VCO 必须是 96、144、192、240、288、336、384、432 之一。这两个集合没有任何交集。结论很干脆25MHz 晶振下100MHz 和 48MHz USB 时钟不可能同时精确成立。所以你要做选择要 USB 就用 96MHz不要 USB 就放开跑 100MHz但要接受 USB 外设不可用或者能枚举但传输出错。我之前有个项目为了 4% 的性能把主频提到 100MHz结果 USB CDC 虚拟串口通信隔三差五丢包抓了很久才发现是时钟源的问题——USB 对 48MHz 的精度要求是 ±0.25%差一点就会出现间歇性错误而且现象极不规律很容易被误判成上位机的问题。6.3 Flash 等待周期和 ART 加速器主频提上去之后Flash 的读取速度就跟不上了。F411 在 2.7~3.6V 供电下的等待周期要求是0 WS 到 30MHz、1 WS 到 64MHz、2 WS 到 90MHz、3 WS 到 100MHz。96MHz 落在最后一段同样要用 3 WS。配置顺序有个硬性要求先加等待周期再提升主频。反过来的话在等待周期还不够的那一瞬间CPU 从 Flash 取指会读到错误数据直接跑飞。CubeMX 生成的SystemClock_Config里HAL_RCC_ClockConfig会先写FLASH_LATENCY_x再切时钟源这个顺序不是随便排的。另外 F411 有 ART 加速器包含指令缓存、数据缓存和预取缓冲。开启方式是FLASH-ACR | FLASH_ACR_ICEN | FLASH_ACR_DCEN | FLASH_ACR_PRFTEN。在等待周期都是 3 的情况下开不开 ART 对实际性能影响很大尤其是有大量小循环的代码。实测同一个算法开 ART 能快 30% 上下。唯一要注意的是如果你把 Flash 当成数据存储用比如自己写 Flash 模拟 EEPROM在擦写前需要先关掉数据缓存否则读回来的可能是缓存里的旧值。7. 这套认知迁移到别的芯片和别的入口约定7.1 换芯片时哪些东西会变换到别的 Cortex-M 芯片向量表的结构基本一致但下面几处几乎必然要改栈顶地址和 RAM 大小F411 是 128KB换到 20KB 的型号链接脚本就得改、启动文件的文件名和中断向量数量、SystemInit的实现有的芯片在这里就把时钟配好了有的什么都不做、以及厂商的启动代码是否帮你调用了__libc_init_array。换到芯片厂商自己的工具链时最省事的做法是先用厂商模板工程点亮一颗 LED确认启动链完整再把自己的业务代码搬进去而不是把旧工程的启动文件复制过去。7.2 用 -nostartfiles 自己掌控入口如果你想彻底搞明白最有效的练习是自己写一个最小工程加-nostartfiles和自定义链接脚本然后把入口直接设成自己的函数。arm-none-eabi-gcc -mcpucortex-m4 -mthumb -nostartfiles \ -Wl,--entrymy_entry -T stm32f411.ld -o demo.elf demo.o--entry只影响 ELF 头里的入口字段对 Cortex-M 的硬件启动没用——硬件只看向量表。所以这个练习的正确做法是自己用一个.s文件拼出前 8 个字节写好向量表然后在my_entry里手动设栈、搬数据、清 bss。做完这一遍你对整条链路的理解会彻底不一样。我当年就是这么练的写完那个 30 行的汇编之后CPU 不认识 main这件事再也没困扰过我。7.3 什么时候该自己写启动代码大多数项目用厂商模板就够了不必造轮子。但三种情况建议动手改需要把向量表放到 RAM 里以便动态改中断比如做固件升级时切换两套应用、需要在启动阶段就把 SRAM 的一部分初始化成特殊布局比如把关键变量放到带奇偶校验的区域、以及做安全启动需要在跳转到 main 之前做完整性校验。这三种场景下厂商那套默认启动代码都会成为阻碍。最后分享一个我自己常用的调试小技巧拿不准固件到底有没有正常启动时先别急着连调试器。在Reset_Handler的第一条指令位置放一个最简单的活体信号——比如在链接脚本里单独开一个放在.data段最前面的变量然后用调试器直接读内存地址看它有没有被改。这样能一秒区分根本没跑到启动代码和跑到了但后面挂了比任何日志都快。
返回列表