ARTICLE DETAIL

资讯详情

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

从Reset_Handler到main:STM32启动流程与链接脚本解析

从Reset_Handler到main:STM32启动流程与链接脚本解析 大一那年在机房敲下int main()的时候我完全没想过一个问题为什么这个函数在电脑上跑完就自己退出了而后来到 STM32 上写的那个int main()一旦进去就好像掉进了一个出不来的房间永远见不到它的返回。更奇怪的是两个函数的签名一模一样都是int main(void)都是 C 语言甚至可以在同一个编辑器里并排打开可它们的人生轨迹完全不同。这件事困扰了我挺长时间——直到我被迫打开了那个一直被我忽略的startup_stm32f10x_md.s才第一次看清main从来不是程序的起点它只是别人铺好路之后把你放上去的那个位置。这篇东西想聊的就是这条路的全程你的 C 代码从文本变成二进制被链接器按地址摆放到 Flash 和 RAM 里上电后 CPU 从向量表里取走第一条指令时钟被一点点拉起来.data段从 Flash 被搬到 RAM.bss段被清零C 运行时被初始化然后才轮到main出场main之后如果是裸机它会被while(1)困住如果上了 RTOS它会变成一个只负责创建任务的启动器。文章里会带上链接脚本、启动文件、MAP 文件、反汇编、HardFault 定位这些实打实的东西也会拿温湿度计、鱼缸控制器这类常见小项目当例子。适合刚把 C 语言从 PC 端搬到单片机上的朋友也适合那些一直用现成工程模板、从没点开过启动文件的人——因为很多莫名其妙的跑飞答案就藏在那几百行汇编里。1. 两个 main 摆在一起看同一个名字两套人生1.1 PC 上的 main 是被谁叫起来的在桌面环境里写int main()你其实是在跟操作系统签一份合同。编译器把源文件编成目标文件链接器再把它们和一个叫crt0C runtime startup或者_start的东西拼在一起。真正被内核execve拉起来执行的入口是_start它在glibc里通常由一段汇编写成负责把argc、argv、envp从栈上取出来对齐栈指针初始化线程局部存储然后才调用__libc_start_main由它再回调你写的main。这条链子的关键在于main的调用者是一段你看不见但确实存在的运行时代码而这段代码之所以存在是因为有人替你把它链接进来了。main返回之后会发生什么同样是合同的一部分——返回值被当作退出码交给exit()exit()去跑atexit注册的函数冲刷stdio缓冲区最后调_exit系统调用把进程交还给内核。所以你在 PC 上写完return 0;进程就真的结束了。这里有个容易忽略的细节main的返回类型是intPC 上这个返回值有明确用途会被 shell 拿到echo $?能看见。而很多嵌入式工程里main写的是int main(void)最后那个return 0完全是为了让编译器闭嘴因为根本没人会收到这个返回值。同一个语法在两边承担的责任不一样。1.2 STM32 上的 main 为什么走不出去STM32 上没有操作系统替你收尾。CPU 复位之后它只知道两件事从地址0x00000000取一个值塞进主栈指针 MSP从0x00000004取一个值塞进程序计数器 PC。取出来的那个 PC 值就是复位处理函数Reset_Handler的地址。接下来的所有事情——时钟怎么配、内存怎么初始化、C 库怎么准备——都得由你自己或者你用的工程模板提供的那段启动代码来完成。等Reset_Handler把该做的都做完它会执行一条bl mainARM 指令里bl是带链接的跳转相当于函数调用。main这时候才登场。问题在于它返回之后没有内核可以交还控制权。启动代码里main后面通常跟着的是这样的东西bl main LoopForever: b LoopForever也就是说就算你的main老老实实return 0了控制权也只是掉进一个死循环CPU 在那儿空转。所以裸机工程里main的标准写法是while (1) { ... }——不是风格问题是物理上没有出口。ARM Cortex-M 的main文档里甚至直接写明本函数不应返回因为返回之后的行为是无定义的。1.3 一张表看清两条路径的分岔点把两边的流程摊开对比分岔点其实非常清楚环节PC 桌面环境STM32 裸机二进制格式ELF可执行文件ELF/AXF/HEX/BIN烧录镜像谁来加载操作系统内核 动态链接器无代码直接在 Flash 原地执行真正的入口_startcrt0Reset_Handler启动文件入口地址从哪里来ELF 头的e_entry字段向量表第二个字0x00000004调用main之前的准备栈对齐、TLS 初始化、__libc_start_main时钟配置、.data搬移、.bss清零、__libc_init_arraymain返回后exit()→ 冲刷缓冲区 → 进程结束掉进b .死循环什么都不会发生内存布局由谁定链接器默认脚本 内核加载规则你自己写的链接脚本或分散加载文件看懂这张表很多困惑就散了。比如为什么我在 STM32 上printf没输出是因为stdio的缓冲区从来没有被冲刷过——PC 上有exit()帮你做这件事STM32 上没有。又比如为什么我的全局变量初值不对是因为.data段的搬移工作没做或者做错了这在 PC 上由内核的加载器自动完成你压根不用操心。2. 编译与链接代码被安排到了哪块地址上2.1 从 .c 到 .elf/.axf 的四步流水线很多人把编译当成一个动作实际上是四个阶段串起来的。第一步是预处理处理#include、#define、条件编译产出.i文件第二步是编译把 C 翻译成汇编产出.s第三步是汇编把汇编翻译成机器码产出.o目标文件第四步是链接把所有.o、启动文件、库文件按地址拼成一个整体。前三步和写 PC 程序完全一样分岔出现在第四步。链接器要回答的问题是每个函数、每个变量最终住在哪个地址上。在 PC 上这个问题由链接器的默认脚本和一个叫ld.so的动态加载器共同回答而且通常用虚拟地址程序觉得自己独占整块内存。在 STM32 上地址是物理的、写死的Flash 从0x08000000开始SRAM 从0x20000000开始以 STM32F1 为例具体型号会有差别你不能随便挪因为芯片的地址译码器就认这几个区间。所以嵌入式工程里必须有一份文件告诉链接器Flash 有多大、RAM 有多大、代码放哪、数据放哪。这份文件在 GCC 工具链里叫链接脚本.ld在 Keil 的 ARM Compiler 里叫分散加载文件.sct。2.2 链接脚本和分散加载文件才是真正的户型图一份简化过的 GCC 链接脚本大概长这样MEMORY { FLASH (rx) : ORIGIN 0x08000000, LENGTH 64K RAM (rwx) : ORIGIN 0x20000000, LENGTH 20K } SECTIONS { .isr_vector : { KEEP(*(.isr_vector)) } FLASH .text : { *(.text*) } FLASH .rodata : { *(.rodata*) } FLASH .data : { *(.data*) } FLASH /* 注意存的地址在 Flash */ _sidata LOADADDR(.data); .bss : { _sbss .; *(.bss*); _ebss .; } RAM }这里面有个反直觉的地方值得单独说.data段虽然最终要待在 RAM 里但它的初始值必须存在 Flash 里。原因很简单——RAM 掉电就丢你的int g_counter 5;里那个 5总得有个地方存着。所以链接器给.data段安排了两个地址一个是加载地址LMA在 Flash一个是运行地址VMA在 RAM。程序运行前得有一段代码专门把这个值从 Flash 抄到 RAM。这段抄写工作在 PC 上由内核自动完成在 STM32 上得自己干。Keil 的分散加载文件思路一样写法不同LR_IROM1 0x08000000 0x00010000 { ER_IROM1 0x08000000 0x00010000 { *.o (RESET, First) *(InRoot$$Sections) .ANY (RO) } RW_IRAM1 0x20000000 0x00005000 { .ANY (RW ZI) } }RESET段被强制放在最前面保证向量表落在0x08000000这是硬件要求。*(InRoot$$Sections)这一行是 ARM Compiler 特有的它就是__main函数里负责数据搬移的那部分代码如果你手贱把它删了程序能编译能烧录但全局变量全是垃圾值。ZeRO Initialized 简称 ZI对应 GCC 里的.bss也就是那些未初始化或初值为 0 的全局变量它们不占 Flash 空间但必须在启动时清零——因为上电后 RAM 里的内容是随机的。2.3 段的概念.text/.rodata/.data/.bss 各自住在哪把这几个段的位置记清楚MAP 文件才看得懂.text函数体、常量字符串以外的可执行代码放 Flash只读。.rodataconst修饰的全局变量、字符串字面量放 Flash只读。.data有非零初值的全局变量和静态变量运行时在 RAM初值存在 Flash。.bss初值为 0 或未显式初始化的全局变量和静态变量只在 RAM 里占位不占 Flash。栈和堆位于 RAM 的高地址或低地址区域具体由链接脚本和启动文件里的_estack、_Min_Heap_Size、_Min_Stack_Size决定。基于这个分布有一个非常实用的估算公式看 MAP 文件结尾的Total RW Size之前就可以自己算Flash 占用 ≈ Code RO-data RW-dataRAM 占用 ≈ RW-data ZI-data 栈 堆。很多人遇到编译报错说 RAM 不够可我的变量明明不多一算才发现 ZI-data 有个大数组或者栈开得太大。3. 从上电到 main那几百个时钟周期里发生的事3.1 复位向量与向量表CPU 的第一口饭STM32 上电或者复位之后硬件做的事情非常机械读0x00000000的值放进 MSP读0x00000004的值放进 PC然后开始执行。注意这里说的是0x00000000不是0x08000000——因为 Cortex-M 的地址空间里有一层别名映射当 BOOT0 引脚为低电平时主 Flash0x08000000会被映射到0x00000000这个别名地址上。换启动模式的时候映射到0x00000000的可能变成系统存储器或者 SRAM。启动文件里那个向量表开头几个字就是给这个时刻准备的__Vectors: DCD __initial_sp ; 栈顶地址被塞进 MSP DCD Reset_Handler ; 复位入口被塞进 PC DCD NMI_Handler DCD HardFault_Handler DCD MemManage_Handler DCD BusFault_Handler DCD UsageFault_Handler DCD 0 ...这里有个坑值得提醒向量表里的中断处理函数都是弱定义WEAK属性如果你在 C 文件里写的中断函数名和向量表里的名字对不上链接器不会报错它会安静地把你没覆盖的那些弱符号指向一个默认的死循环Default_Handler。结果就是中断触发了程序卡在while(1)里你怎么看都看不出问题。我见过不止一个人把TIM2_IRQHandler写成Timer2_IRQHandler查了一下午。另外如果你在做 Bootloader App 的双区方案App 的向量表不在0x08000000而在比如0x08008000那就必须在 App 的main里第一时间重定位向量表SCB-VTOR 0x08000000 | 0x8000;这行代码的意思是告诉内核中断向量表现在在别的地方不然跳转过去之后任何中断都会跑到 Bootloader 的向量表里去。这是个典型的不写也能跑一有中断就崩的问题。3.2 SystemInit 与时钟树先把心跳调准Reset_Handler的第一个正式动作通常是bl SystemInit。这个函数由芯片厂商提供在system_stm32f10x.c之类的文件里干的事情主要是把时钟树从复位默认状态拉到目标频率。以 STM32F1 为例复位后默认跑的是内部 HSI 8MHz精度差、受温度影响大。常见做法是切到外部晶振 HSE 8MHz然后经过 PLL 倍频到 72MHz。算一下HSE 8MHz 作为 PLL 输入PLLXTPRE不分频PLLMUL设为 9得到 8 × 9 72MHz 的 SYSCLK。之后 AHB 不分频HPRE 1保持 72MHzAPB1 分频 2 得到 36MHz这是 APB1 的上限不能超APB2 不分频保持 72MHz。另外 APB1 上的定时器会被额外乘 2所以挂在 APB1 的定时器实际计数时钟还是 72MHz这个细节在算波特率和 PWM 频率时经常被漏掉。为什么这件事必须在main之前做因为后面所有依赖时间的初始化都在等一个准确的时钟源。如果你把时钟配置搬到main里那main之前执行的__libc_init_array和库初始化、延时函数全都在用不准确的 HSI 跑。更实际的问题是有个非常经典的现象是程序卡在SystemInit里出不来原因是外部晶振没起振代码里那个等 HSE 就绪的while (RCC_GetFlagStatus(RCC_FLAG_HSERDY) RESET)一直在等超时计数器也不够长。这时候的表现就是程序进不去 main用调试器一看 PC 停在时钟文件里。提示如果调试时发现程序停在一个陌生的while循环里先查时钟文件有没有超时退出机制再查晶振和负载电容有没有焊错。批量生产时晶振虚焊导致的部分板子不启动是产线上最常见的故障之一。3.3 数据段搬移与 BSS 清零全局变量的初值靠这段代码时钟配好之后接下来这段是整篇文章里我认为最值得亲手写一遍的代码。在 GCC 工具链的启动文件里它长这样ldr r0, _sdata ldr r1, _edata ldr r2, _sidata 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翻译成人话从_sidataFlash 里.data的加载地址开始把数据一字一字抄到_sdata到_edataRAM 里.data的运行区间然后把_sbss到_ebss之间的 RAM 全部写 0。用 C 写出来更直观extern uint32_t _sidata, _sdata, _edata, _sbss, _ebss; void runtime_init(void) { uint32_t *src _sidata; uint32_t *dst _sdata; while (dst _edata) { *dst *src; } for (dst _sbss; dst _ebss; dst) { *dst 0; } }这几个下划线开头的符号不是 C 语言语法是链接脚本里定义的地址标签编译器把它们当作地址常量来用。很多人第一次在启动文件里看到_sbss会懵其实就是链接器留的路标。为什么.bss必须清零因为 SRAM 上电后内容是随机的物理状态如果不清零你写static int flag;然后if (flag)判断第一次进来 flag 可能是 1也可能是 0x5A5A1234行为完全不可预测。这个问题在 PC 上永远不会出现因为内核加载器会给你一块清零过的内存——这也是PC 上写的东西搬到单片机上就出莫名其妙的问题的经典根源之一。我建议每个刚接触 STM32 的人都亲手把这段搬移代码在调试器里单步走一遍看着_sidata和_sdata的地址在 MAP 文件里对得上那种原来是这样的感觉比看十篇文章都管用。3.4 __libc_init_arrayC 运行时最后一块拼图段初始化做完还剩最后一件事调用__libc_init_array。这个函数属于 C 运行时它的工作是遍历.init_array和.fini_array这两个段把里面存的函数指针挨个调用一遍。这些函数是谁放进去的是编译器为全局 C 对象或者带__attribute__((constructor))标记的 C 函数自动生成的。举个具体的例子如果你的工程里有个全局对象SerialPort g_uart(1);它的构造函数就是通过.init_array被调用的而不是在main里。如果你跳过__libc_init_array直接进main这个对象的状态就全是未初始化的裸内存——调用它的成员函数可能访问到空指针然后直接 HardFault。Keil 的 ARM Compiler 把这几件事打包进一个叫__main的库函数里注意是双下划线和你写的main不是一个东西__main内部完成分散加载、库初始化最后才跳到你写的main。这也解释了一个让很多人困惑的报错编译时报undefined symbol main或者链接脚本里删掉InRoot$$Sections之后出现一堆莫名其妙的内存访问错误——因为__main的活被切掉了。到这里C 运行时算是建立完毕栈指针就位时钟就位内存初始化完毕中断还没使能。接下来一条bl main你的代码终于开始跑了。4. main 里面和 main 之后超级循环、中断与 RTOS4.1 while(1) 超级循环最朴素也最稳的骨架裸机main的标准结构就三段初始化、外设配置、无限循环。拿一个常见的温湿度计项目来举例int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_USART1_UART_Init(); MX_TIM2_Init(); DHT11_Init(); OLED_Init(); while (1) { DHT11_Read(temp, humi); OLED_ShowTempHumi(temp, humi); if (temp TEMP_ALARM_HI) { Alarm_On(); } else { Alarm_Off(); } HAL_Delay(1000); } }这段代码能跑但它的结构有个明显问题所有事情都串在一根时间线上。HAL_Delay(1000)那一秒钟里按键按下去没人理串口来的数据也可能丢。这就是超级循环的天然局限——它本质上是个单线程协作式调度器每个任务的响应时间取决于前面所有任务的耗时之和。实际项目里更靠谱的写法是把循环体拆成非阻塞的形式用HAL_GetTick()判断时间片uint32_t t_oled 0, t_key 0; while (1) { uint32_t now HAL_GetTick(); if (now - t_oled 1000) { t_oled now; DHT11_Read(temp, humi); OLED_ShowTempHumi(temp, humi); } if (now - t_key 20) { t_key now; Key_Scan(); } }这个改写看着不起眼效果差别很大按键响应从最长 1 秒变成最长 20 毫秒。这类改造是嵌入式里最常见的优化思路和 PLC 程序里手动模式 / 自动模式 / 复位程序 / 急停程序分块扫描是一个道理——主循环每轮都快速扫过所有状态分支而不是在任何一个分支里长时间停留。急停这类高优先级动作之所以通常直接挂在中断或者最高优先级任务上就是为了绕开主循环的响应延迟。4.2 中断把 main 降级成背景但要注意共享数据一旦使能了中断main里的while(1)就不再是唯一的执行流。定时器中断、串口接收中断、外部按键中断会随时打断它跑完中断服务函数再回来。这时候main变成了一个背景线程中断是前景事件。这带来一个必修课共享变量的保护。看这段代码volatile uint32_t g_tick 0; volatile uint8_t g_rx_flag 0; void TIM2_IRQHandler(void) { if (TIM_GetITStatus(TIM2, TIM_IT_Update) ! RESET) { TIM_ClearITPendingBit(TIM2, TIM_IT_Update); g_tick; } } int main(void) { ... while (1) { if (g_rx_flag) { g_rx_flag 0; Process_Data(); } if (g_tick 1000) { g_tick 0; /* 周期任务 */ } } }volatile是必须的。没有它编译器可能把g_tick缓存进寄存器循环里读到的一直是旧值看起来就像中断没进。这不是理论问题-O2优化下几乎必然发生。但volatile只保证每次都去内存读不保证原子性。像g_tick 1000然后g_tick 0这种读-改-写序列如果中断恰好在两步之间插入就会丢计数。32 位变量在 Cortex-M 上单次读写是原子的但复合操作不是。要彻底安全就用临界区__disable_irq(); uint32_t t g_tick; g_tick 0; __enable_irq();或者干脆用无锁的单生产者单消费者环形缓冲区中断里只写指针main里只读指针。这些做法在项目里比加个 volatile 就完事要靠谱得多尤其是当循环频率高、中断频率也高的时候。4.3 上了 RTOS 之后main 变成了一个启动器如果项目上了 FreeRTOS 这类实时内核main的角色会再变一次。它不再包含业务循环而是把各个任务创建出来然后启动调度器int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_USART1_UART_Init(); xTaskCreate(Task_Sensor, sensor, 256, NULL, 2, NULL); xTaskCreate(Task_Display, display, 512, NULL, 1, NULL); xTaskCreate(Task_Comm, comm, 384, NULL, 3, NULL); vTaskStartScheduler(); while (1) { /* 正常情况下永远到不了这里 */ } }vTaskStartScheduler()之后调度器会用 SVC 异常和 PendSV 异常接管控制流main的栈MSP被交给内核使用每个任务有自己的栈和 PSP。从这一刻起main作为函数已经死了它的代码段还在但再也不会被执行。你能看到的一切都发生在任务里。这解释了一个初学者常见的疑问为什么在 FreeRTOS 工程里main的最后那几行代码加了断点永远不停因为调度器一旦启动就不会返回除非所有任务都退出实际项目中不会发生或者发生了致命错误。那个while(1)是防御性写法用来兜底。RTOS 下的排查思路也得跟着变。栈溢出从整个系统栈不够变成某个任务栈不够要用uxTaskGetStackHighWaterMark()去看每个任务的剩余水位优先级反转、死锁这些问题在主循环时代根本不存在现在都成了必修课。我一个很实在的经验是任务栈一开始就多给一点比如估算值的两倍等系统跑稳了再用高水位数据往下砍比一开始抠得死死的、跑起来随机崩要省太多时间。5. 动手验证让工程自己把真相说出来5.1 用 MAP 文件核对 Flash 和 RAM 的账MAP 文件是链接器留给你的账本里面写清了每个符号落在哪个地址、每个段占了多少字节。工程选项里打开生成 MAP 文件GCC 加-Wl,-Mapoutput.mapKeil 在 Linker 页勾选编译完就能拿到。打开之后重点看两部分。第一部分是各目标文件的段贡献能看到哪个.o占的内存最多第二部分是结尾的汇总形如Total RO Size (Code RO Data) 23456 ( 22.91kB) Total RW Size (RW Data ZI Data) 5678 ( 5.54kB) Total ROM Size (Code RO Data RW Data) 24512 ( 23.94kB)前面提过的公式在这里验证烧进 Flash 的是Code RO Data RW Data因为.data的初值也得占 Flash运行时吃 RAM 的是RW Data ZI Data再加上栈和堆。如果你发现ZI Data有几千字节而你没定义过这么大的数组大概率是某个uint8_t buf[4096]被定义成全局了改成局部变量注意局部也占栈或者用动态分配注意碎片要权衡。MAP 文件还有一个高级用法定位符号被重复定义的问题。如果两个.c文件里都有int g_state;链接器可能只保留一个另一个被静默丢弃或者报多重定义错误。用 MAP 文件搜一下g_state看它出现在哪个.o里一目了然。5.2 反汇编与单步亲眼看 main 是怎么被 bl 进去的想彻底确认main是被启动文件调用的这件事反汇编是最直接的证据。GCC 工具链arm-none-eabi-objdump -d build/firmware.elf | lessKeil 的话用fromelffromelf --text -c -o disasm.txt Objects\firmware.axf在输出里搜Reset_Handler你会看到类似这样的结构080001a0 Reset_Handler: 80001a0: bl 8000xxx SystemInit 80001a4: bl 8000xxx __libc_init_array 80001a8: bl 8000020 main 80001ac: b 80001ac Reset_Handler0xc最后那条b指令的目标地址就是它自己这就是那个死循环。看到这一段main不返回这句话就从文档上的说明变成了眼睛能看见的事实。再往上看向量表你会看到080001a0这个地址出现在第二项08000000 __Vectors: 8000000: 20005000 ; 初始 MSP 8000004: 080001a0 ; Reset_Handler 8000008: 080001b1 ; NMI_Handler对照 MAP 文件确认20005000就是 RAM 的顶端地址0x20000000 0x5000一切都对上了。这种三方交叉验证源码、反汇编、MAP的习惯在排查诡异问题时特别有用。5.3 一个可以复现的小实验打印 main 的地址如果你想更直观地感受地址空间可以在main里加这么一段int main(void) { Uart_Init(115200); Uart_Printf(main 0x%08X\r\n, (uint32_t)main); Uart_Printf(Reset_Handler 0x%08X\r\n, (uint32_t)Reset_Handler); Uart_Printf(g_data_var 0x%08X\r\n, (uint32_t)g_data_var); Uart_Printf(g_bss_var 0x%08X\r\n, (uint32_t)g_bss_var); while (1) {} }前提是你之前已经重定向了printf简单的做法是实现fputc把字符丢给串口寄存器同时别开半主机模式。串口输出的地址会告诉你main和Reset_Handler落在0x0800xxxxFlashg_data_var和g_bss_var落在0x2000xxxxRAM。这个数一出来代码在 Flash 原地执行、变量在 RAM这句话就不再抽象了。注意用 ARM Compiler 时如果忘了开 MicroLIBprintf会走半主机模式程序可能在第一个字符输出前就卡死。GCC 的话需要自己实现_write或者_sbrk。这类串口没输出的问题九成出在重定向没做全。6. 常见问题与排查实录进不去 main、进去了跑飞6.1 根本进不去 main 的几类原因现象是下载成功、复位后什么都没发生或者调试器一暂停发现 PC 停在一个陌生地址。按我的经验排查顺序应该是这样第一查启动模式。BOOT0/BOOT1 引脚的组合决定了从哪块区域启动。如果 BOOT0 被拉高芯片会去跑系统存储器里的出厂引导程序你的代码根本不会被执行。表现就是烧录成功但完全不运行用调试器连上去也会发现 PC 在一个不属于你工程的地址。第二查复位和供电。万用表量一下 NRST 引脚正常应该在 3.3V 附近如果一直在低位那是复位电路的问题电容取值过大、按键短路。供电不稳也会导致时钟起振失败。第三查时钟。前面说过的 HSE 起振失败是重灾区。有些板子为了省成本用 8MHz 晶振配不匹配的负载电容批量生产时良率就出问题。调试时可以先临时切到 HSI 跑确认其他代码没问题再回头调晶振。第四查启动文件是不是被排除了。Keil 工程里如果不小心把startup_stm32f10x_md.s从工程里移除或者装了错的芯片包用了个不匹配的启动文件链接会报undefined symbol Reset_Handler之类的错。这类问题通常编译阶段就能发现。第五查链接脚本的 Flash 长度。如果脚本里写的是 64K而你的芯片只有 32K链接器可能把代码排到了物理上不存在的地址烧录工具也可能只烧了前半段。这个坑我在换芯片型号时踩过一次症状是改了无关紧要的代码之后程序就不跑了因为代码体积刚好越过了真实容量边界。6.2 进了 main 又跑飞HardFault 的定位套路能进main说明启动阶段没问题之后跑飞通常是内存访问越界、栈溢出、空指针这类。Cortex-M 提供了几个寄存器帮助定位在调试器里看CFSRConfigurable Fault Status Register和HFSRHardFault Status Register的值位 / 字段含义常见原因IBUSERR取指总线错误跳转到了非法地址、函数指针未初始化PRECISERR精确数据总线错误访问了未映射的地址、外设时钟没开IMPRECISERR不精确数据总线错误写缓冲导致的延迟报错需配合BFAR排查UNDEFINSTR未定义指令跳到了数据区、函数指针被踩INVSTATE无效状态尝试用 ARM 模式执行 Thumb 代码UNALIGNED非对齐访问强制类型转换导致的非对齐读写DIVBYZERO除零浮点或整数除法未做保护HFSR.FORCED被提升的故障上面某个可配置故障升级成了 HardFaultBFARVALID总线地址有效此时BFAR里就是出错的地址一个非常实用的技巧是看栈帧里的EXC_RETURN值压栈的 LR。如果 bit2 是 1说明异常发生在使用 PSP 的任务上下文里RTOS 场景如果是 0就是从 MSP 来的也就是main或者中断上下文。再分享一个抓现场的方法在HardFault_Handler里把栈帧里的PC、LR、xPSR取出来直接塞进一个全局数组复位后在main里打印出来然后用addr2line或者 Keil 的地址反查功能定位到具体源码行arm-none-eabi-addr2line -e firmware.elf -f -C 0x08001234如果工程里没开-g或者优化等级太高导致行号失真那就配合反汇编看指令上下文。这个方法在没法挂调试器的现场比如客户设备已经装到机柜里了特别好用等于给固件装了个简易的黑匣子。6.3 常见问题速查表把前面几节的高频问题整理成一张表方便对照现象最可能的原因排查动作烧录成功但不运行BOOT 引脚电平错、复位电路异常量 BOOT0 和 NRST 电平调试时 PC 停在陌生循环卡在 HSE 就绪等待查晶振、负载电容、超时计数器进main第一条语句就崩栈指针未初始化、RAM 长度写错对照 MAP 检查_estack和 RAM 区间全局变量值不对.data搬移被跳过或分散加载文件缺InRoot$$Sections单步Reset_Handler看搬移循环中断触发了但没反应中断函数名与向量表不匹配搜索向量表里对应的WEAK符号名中断进一次就不再进没清中断标志位检查ClearITPendingBit调用加了串口打印就崩栈不够、printf用了半主机模式加大栈、开 MicroLIB 或重定向改了无关代码就跑飞代码体积越过了真实 Flash 边界核对链接脚本的LENGTH与芯片型号优化等级调高后逻辑错缺volatile、共享数据无保护给中断共享变量加volatile和临界区7. 几个容易被忽略的细节7.1 volatile、const 与 static 的真实去向const修饰的全局变量默认会被放进.rodata落在 Flash 里这带来两个后果。好处是省 RAM一张 1KB 的字体表放 Flash 完全不心疼。坏处是你不能通过指针去改它硬改会触发总线错误——有些老代码里用(uint8_t *)const_array[0]去写在 PC 上可能不报错页表允许在 STM32 上直接 HardFault。要改的内容就老老实实放 RAM。static修饰的局部变量位置和全局变量一样在.data或.bss里只是作用域受限。这意味着它不会被重复初始化函数每次进来看到的是上次留下的值。这个特性在写状态机时很好用但也容易造成函数不可重入在多任务或者中断里调用同一个带static的函数就会出问题。volatile前面聊得比较多了补一句它解决的是编译器优化掉内存访问不解决多核/多主机的缓存一致性。在带 DMA 的场景里除了volatile通常还需要在启动 DMA 之后做一次内存屏障或者__DSB()保证数据真正写到内存里再让外设去读。这类细节在数据手册的 DMA 章节里会提到但很容易被跳过。我见过一次 ADC DMA 采样的项目读到的数据总是慢一拍最后发现就是缺了屏障指令。7.2 全局对象的构造顺序与 __libc_init_array 的关系如果你在写 C 的嵌入式代码全局对象的构造顺序是个真问题。.init_array里的函数是按链接顺序排列的而链接顺序跟编译顺序、文件在工程里的排列都有关系。如果对象 A 的构造函数里用了对象 B而 B 的构造函数还没跑那 A 拿到的是未初始化的 B。规避办法有两个。一是别在构造函数里做依赖其他全局对象的操作只做纯粹的数据初始化硬件相关的活留到main里的显式Init()调用。二是改用局部静态 首次调用初始化的写法把初始化时机推到确定的地方。Keil 和 GCC 都支持__attribute__((init_priority(N)))指定优先级但这个特性在不同工具链上行为不完全一致跨平台项目慎用。顺带说一句如果你发现全局对象的构造函数根本没被调用第一件事就是检查__libc_init_array有没有被链接进去、.init_array段有没有被链接脚本丢掉。有些手工裁剪过的链接脚本只列了.text和.data把.init_array落在了*(.text*)之外构造函数自然就没人调了。7.3 优化等级、MicroLIB 与半主机模式-O0到-Os的差别在嵌入式项目里比 PC 上明显得多。-O0下所有变量都在栈上有实体方便调试但速度快不起来、栈消耗也大-Os会把能内联的都内联、能省的栈都省掉但断点会跳得找不到北某些只在优化后出现的 bug典型的就是缺volatile会冒出来。我的习惯是开发阶段用-Og或者-O1release 前用-Os全量回归一遍尤其是中断密集的地方。MicroLIB 是 ARM Compiler 提供的一个精简 C 库体积小、不带半主机支持适合裸机项目。但它有个限制不支持某些 C99/C11 特性浮点格式化在部分版本上也不完整。如果你在 Keil 里用了printf(%f, x)却打不出小数先确认是不是 MicroLIB 的问题——很多人因此白折腾半天最后发现是库的锅。GCC 那边对应的坑是newlib-nano需要在链接选项里加--specsnano.specs同时printf的浮点支持要单独开-u _printf_float。半主机模式的坑再强调一次ARM Compiler 默认会链接半主机版本程序里第一次调printf时会触发BKPT指令等待调试器响应如果没连调试器程序就卡死在那儿。症状非常像程序在printf这里停了但其实不是printf的问题。解决办法是重定向_write或者直接切到 MicroLIB。最后聊两句个人体会从 PC 上的main走到 STM32 上的main中间其实隔着整个计算机组成原理的入门课加载器、链接器、存储层次、运行时初始化。刚转过来的时候我总觉得这些东西是工程模板里自带的不用管后来被几个诡异的问题按在地上摩擦过几轮之后才发现那些从没打开过的.s文件和.ld文件恰恰是程序和硬件之间唯一的合同。合同你看不懂程序出了事就只能靠猜而靠猜修 bug 的效率比你想象中低得多。现在每次拿到一个新的芯片或者一套新的工具链我的习惯都是先花半小时把启动文件、链接脚本、MAP 文件这三样东西过一遍把向量表、各段地址、栈顶位置在纸上画一下。这半小时经常能省掉后面好几天的排查时间。你要是也在被程序跑不起来或者跑起来一会儿就崩折磨不妨从翻出那个startup_xxx.s开始从头到尾读一遍——读懂了它你就知道自己的代码后来到底去了哪里。
返回列表