尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

STM32启动文件深度解析:从复位到main的底层原理

STM32启动文件深度解析:从复位到main的底层原理 很多人学STM32都是这样的路径点灯、串口、中断、定时器、I2C、SPI一路用库函数或者HAL库开发项目功能做得飞起但从来没认真看过工程里那个startup_stm32f10x_hd.s文件。有次我帮一个朋友排查一个很诡异的故障现象是程序一上电就死不进main调试器连上之后PC指针停在B .这条指令上也就是死循环。折腾了半天最后发现是他自己用STM32CubeMX生成工程时手动改了启动文件里的栈大小改完之后堆栈指针初始化出了问题。那一刻我就觉得启动文件这个东西你平时不看它它不会惹事但一旦出了事你连排查的方向都没有。这篇笔记不打算把启动文件从头到尾逐行念一遍而是围绕几个核心问题展开启动文件到底干了什么、为什么要这么干、它在整个程序生命周期里扮演什么角色、出了问题时你能从它身上找到什么线索。如果你正准备从会调库往懂底层迈一步这篇内容应该能帮你把这层窗户纸捅破。1. 启动文件不是模板文件而是你程序的第一段人生很多初学者把startup_stm32f10x_hd.s这类文件当成Keil或CubeIDE自动生成的一个模板平时根本不会打开看觉得它就是个必须存在但无关紧要的东西。这个认知需要先纠正。启动文件是MCU上电复位后执行的第一段代码它决定了你的C语言世界是如何从一片混沌变成有序运行的。没有启动文件你的main函数什么都不是。1.1 从CPU的角度理解上电这件事STM32内部的Cortex-M3/M4/M7内核上电复位后硬件会自动做两件事从0x00000000地址取出初始栈指针MSP的值从0x00000004地址取出复位向量并跳转过去执行。这个机制是ARM架构规定的不需要软件干预纯粹是硬件行为。问题来了0x00000000和0x00000004这两个地址里存的到底是什么答案就是启动文件里定义的内容。启动文件在编译链接后会在Flash的开头位置布置一张中断向量表这张表的第一项是栈顶地址第二项是复位函数地址。硬件根据这张表完成最初的引导。所以说启动文件是程序的第一段人生这个说法毫不夸张。它负责了你程序最原始的初始化工作把栈指针指到合法的RAM区域、把向量表加载好、把C语言的运行环境准备好然后才把你写的main函数唤醒。1.2 没有启动文件会发生什么这里分享一个我早期的实际经历。有段时间我想精简工程觉得启动文件里很多代码用不上就尝试着自己写一个最简启动代码结果折腾了一整天。当然这个方向没错但前提是你得完全理解启动文件做了什么。如果你真的把启动文件删了或者写了一个残缺的启动流程最常见的现象就是程序一上电就跑飞PC指针不知道跑到哪里去声明了局部变量的函数变量初值全部错乱中断一触发就HardFault全局变量没有被初始化到处都是随机值这些现象背后本质上是栈、向量表、.data段、.bss段这几样东西没人替你打理了。而这些事情的源头都在启动文件里。2. 逐段拆解启动文件里的每一部分在设计什么以目前使用量很大的STM32F103系列为例Keil工程里常见的启动文件是startup_stm32f10x_hd.s这个文件是ARM汇编写的。里面主要有几个大块栈配置、堆配置、中断向量表、复位处理函数、其他中断处理函数。这里挑重点来拆。2.1 栈和堆的声明0x400和0x200是怎么定的启动文件开头会有这么一段Stack_Size EQU 0x400 AREA STACK, NOINIT, READWRITE, ALIGN3 Stack_Mem SPACE Stack_Size __initial_spStack_Size定义了栈的大小是0x400也就是1KB。Stack_Mem在RAM里划出一块1KB的空间__initial_sp就是这个栈区域的结束地址也就是栈顶。为什么要让__initial_sp指向栈的结束地址而不是起始地址因为Cortex-M的栈是满递减的——也就是栈向低地址方向生长ARM的PUSH指令先减SP再存入数据。所以栈顶要放在高地址处让栈能往低地址方向扩展。堆的大小定义逻辑一样Heap_Size一般默认0x200512字节主要用于malloc这类动态内存分配。对于绝大多数裸机开发来说512字节的堆够用了甚至很多项目根本不用动态内存分配把它可以改成0或者更小。但这里有个很多新手不明白的点Stack_Size和Heap_Size是在汇编文件里定义的它们怎么通知到链接器分配实际内存呢答案是启动文件里那个EXPORT __initial_sp。链接脚本Keil的分散加载文件.sct、GCC的.ld、IAR的.icf会引用这个符号来安排RAM布局。以我自己的实际经验来说栈大小尽量调到0x8002KB以上尤其当你用了RTOS、或者某个中断服务程序里要分配较大的局部结构体时。第一次调栈溢出问题是在一个用ESP8266做WiFi透传的项目里现象是每隔几分钟设备就无缘无故复位一次查了一圈最后用调试器看__initial_sp的值和RAM使用量才发现是栈溢出踩了全局变量区把标志位冲掉了。2.2 中断向量表一张跳转索引卡栈配置之后就是中断向量表AREA RESET, DATA, READONLY EXPORT __Vectors EXPORT __Vectors_End EXPORT __Vectors_Size __Vectors DCD __initial_sp ; Top of Stack DCD Reset_Handler ; Reset Handler DCD NMI_Handler ; NMI Handler DCD HardFault_Handler ; Hard Fault Handler DCD MemManage_Handler ; MPU Fault Handler DCD BusFault_Handler ; Bus Fault Handler DCD UsageFault_Handler ; Usage Fault Handler ; ... 下面还有外设中断向量外层用AREA RESET, DATA, READONLY声明这块区域是只读数据会被链接器放到Flash的最前面。DCD是一条伪指令作用是定义一个32位的字数据等价于C语言里const uint32_t数组中的一个元素。这个向量表里的每一项对应的是一个中断处理函数的地址。表的位置必须是Flash起始地址比如0x08000000这是ARM内核的规定。表中第一项DCD __initial_sp让硬件在复位后能立刻拿到这个值赋给MSP。第二项DCD Reset_Handler就是复位后第一个要跳过去执行的函数。我在刚学ARM汇编的时候看到DCD __initial_sp还困惑了很久为什么不是一段指令而是个数值后来理解了向量表存的是地址和初始值不是代码。它告诉硬件栈指针应该指向哪、中断来了应该跳到哪里去本质上是把硬件事件和软件函数之间建立了映射关系。还有个细节对于F103这样的Cortex-M3芯片向量表的起始位置是可配置的。通过SCB-VTOR寄存器你可以把向量表挪到RAM里或者应用程序的其他Flash位置。这也就是Bootloader跳转App之前必须做的事——App里的启动文件如果仍然让向量表放在0x08000000但App的实际代码是从0x08010000开始的那一旦发生中断CPU从向量表抓到的中断服务函数地址就不对直接HardFault。正确做法是启动文件里用VECT_TAB_OFFSET定义偏移量或者跳转前手动设置SCB-VTOR 0x08010000;这个坑甭提多少人踩过了一次自己写给IAP升级用的BootloaderApp跳转后所有中断都失效排查到深夜最后发现就是少了这条语句。2.3 Reset_Handler第一个真正执行的代码向量表之后就是启动文件的核心角色——Reset_HandlerReset_Handler PROC EXPORT Reset_Handler [WEAK] IMPORT SystemInit IMPORT __main LDR R0, SystemInit BLX R0 LDR R0, __main BX R0 ENDP这个函数做了两件事调用SystemInit()把系统时钟从默认的HSI内部高速时钟8MHz切换到HSE外部晶振再通过PLL倍频到目标主频比如72MHz同时初始化Flash等待周期。跳转到__main。注意这里的__main是C库提供的入口函数不是我们写的那个main。它的职责是完成C运行环境的初始化——把.data段从Flash拷贝到RAM、把.bss段清零、调用编译器生成的初始化函数最后才跳转到真正的main()。SystemInit是必须要有的。如果没有它芯片就默认跑在8MHz的HSI上你的SystemCoreClock、HAL_Init这些东西全都会出错。当然如果你用HAL库SystemClock_Config是在main函数里执行的SystemInit里只做了基础时钟初始化还保留着直接把系统时钟配置为72MHz的能力取决于SYSCLK_FREQ_72MHz等宏定义。顺便说下[WEAK]这个标记的妙用。启动文件里的所有中断服务函数都带[WEAK]意思是弱定义。如果整个工程里没有其他地方定义同名函数链接器就使用启动文件里的这个默认实现一般是死循环如果你在C代码里写了一个强符号的同名函数链接器会优先使用你的函数把启动文件里的弱符号覆盖掉。这就是为什么你在Keil工程里随便写一个void HardFault_Handler(void)系统在发生硬件错误时就会跳到你的函数里来。启动文件那个默认的B .死循环被你写的强符号顶替了。理解了这里你就明白了标准外设库里那个stm32f10x_it.c文件的工作原理——里面全是中断服务函数的强定义。2.4 中断服务函数的默认实现启动文件后半部分基本就是一堆弱定义的默认中断处理函数NMI_Handler PROC EXPORT NMI_Handler [WEAK] B . ENDP HardFault_Handler\ PROC EXPORT HardFault_Handler [WEAK] B . ENDP每个函数体就一条B .指令意思是跳转到当前指令地址形成死循环。如果某个中断触发了但你没有在C代码里实现对应的处理函数程序就会卡在这个死循环里。这就是为什么很多时候调试时程序卡死了打开调用栈一看发现停在汇编代码里某个B .上——那其实就是某中断没人处理或者处理函数没有被正确链接进去。找到那个卡死的函数名就等于找到了是哪路中断出了问题。3. 从复位到main这中间走了四步路把启动文件的内容拆完之后我们完整走一遍从复位到main的流程。这对排查程序怎么跑起来的这类问题特别有用。3.1 第1步硬件取向量表初始化MSP芯片上电复位后CPU从0x08000000读出第一个32位数据赋给MSP主栈指针这个值就是__initial_sp也就是栈顶地址。这是硬件行为不需要任何指令参与。3.2 第2步硬件跳转Reset_HandlerCPU从0x08000004向量表第二项读出复位函数地址直接跳到Reset_Handler去执行。这里值得注意的一个细节是Cortex-M3/M4内核的指令总线I-Bus和系统总线是不同的取向量表的操作走的是系统总线CPU一复位先从系统总线读取这两个地址不需要Flash有任何代码预取。3.3 第3步SystemInit设置时钟树Reset_Handler调用SystemInit()启动文件里通过IMPORT SystemInit声明这个函数来自外部由标准外设库或HAL库的system_stm32f10x.c提供。然后LDR R0, SystemInit和BLX R0完成函数调用。这里我补充一个容易忽略的坑如果你把SystemInit自认为不必要注释掉或者不链接对应文件程序大概率也能跑起来但所有外设的时钟频率都是错的串口波特率全是乱的定时器时间全不对而且你还很难猜到根因是时钟没配好。为什么因为SystemInit里除了配置PLL还会把Flash的等待周期FLASH-ACR设置好。主频在72MHz时Flash访问时间需要两个等待周期如果这个没设置对CPU读Flash取指令速度不匹配轻则程序运行效率低下重则取到错误的数据直接HardFault。3.4 第4步__main完成C运行时初始化__main是C库自带的入口函数它做的事情包括拷贝.data段把Flash里存储的已初始化全局变量的初值搬到RAM中对应的地址上清零.bss段把所有未初始化默认值应为0的全局变量所在RAM区域清零调用静态构造函数如果你的工程里用了C的全局对象或者使用了__attribute__((constructor))属性的函数它们在这里被调用调用main()最后跳转到你自己写的main函数理解这四步之后你就明白了为什么全局变量的初始化发生在main之前。所以不要在main函数开头抱怨为什么这个全局变量没被赋初值那是.data段的事情不在main里。还有一点可以用来排查实际问题的__main阶段如果全局变量区很大从上电到进入main之间的时间会变长这在有些需要快速响应的场合是需要关注的。4. 链接脚本如何与启动文件配合一张内存棋盘启动文件定义了栈区、堆区、向量表和函数入口点但真正的内存布局是链接器根据脚本安排的。Keil工程的分散加载文件是.sctGCC是.ldIAR是.icf它们和启动文件是配套使用的。以GCC工具链的链接脚本为例cores堆栈定义通常是这样_estack 0x20005000; MEMORY { FLASH (rx) : ORIGIN 0x08000000, LENGTH 512K RAM (xrw) : ORIGIN 0x20000000, LENGTH 20K }这里的_estack 0x200050000x20005000 0x20000000 20K也就是RAM的最高地址。这和启动文件里的__initial_sp是配合的。不过在不同工具链上这个栈顶符号的传递方式不太一样工具链栈顶符号定义位置链接脚本中的引用Keil MDK__initial_sp启动文件汇编分散加载文件用Image$$RW_IRAM1$$ZI$$Limit来自动计算IAR EWARMCSTACK启动文件汇编.icf里define symbol __ICFEDIT_intvec_start__等GCC_estack链接脚本.ld直接在链接脚本里定义启动文件里EXTERN _estack或使用_estack有些GCC模板工程里栈顶符号是在链接脚本里直接定死的比如_estack 0x20005000然后在启动汇编文件里用.word _estack来引用这个符号。这样改内存布局时得同时考虑芯片型号、链接脚本和启动文件三者的配合改一处漏了另外两处就会出大问题。真正理解了链接脚本和启动文件的关系你就可以去实现一些看起来很高级的功能比如把中断向量表放到RAM里在某些需要动态修改中断向量、或者实现OTA的场合把向量表拷贝到RAM并SCB-VTOR指向RAM就能实现运行时更新中断处理函数自定义段实现数据持久化通过__attribute__((section(.noinit)))在RAM里划分出一块上电不复位的区域实现软复位后数据不丢失把关键函数放到特定的Flash地址配合加密或者Bootloader验证这些功能从理论到实践第一步都是把启动文件和链接脚本看明白。这也是为什么我建议那些想深入做物联网、车载、工控的嵌入式开发者一定要花一个下午把这个文件啃透。5. 不同开发环境下的启动文件差异Keil、IAR与GCC的微妙区别很多人会问为什么我在Keil里用startup_stm32f10x_hd.s用CubeIDEGCC编译器就变成startup_stm32f103xe.s了同样是启动文件为什么长得不一样这涉及到不同工具链和启动文件格式的差异。5.1 三大工具链的启动文件差异下面这个表格整理了我实践中接触到的三大工具链启动文件差异对比维度Keil MDKIAR EWARMGCC (arm-none-eabi)扩展名.s.s.s汇编器语法ARMCC/armasmIAR汇编器GNU as栈顶符号__initial_spCSTACK段符号_estack中断处理默认实现B .死循环B .死循环B .或者bkpt向量表加载由硬件分散加载配合硬件自动读取由启动代码复制到RAM部分芯片.data段处理由C库__main完成由C库__iar_program_start完成由启动文件里_start执行__libc_init_array等其中IAR的启动文件有一个常见的自定义选项——它的向量表是否带CRC校验。IAR的链接器可以在向量表末尾安排一个CRC值启动时由系统库检查向量表是否被损坏这在安全相关产品里是个加分项。Keil和GCC默认没这个。还有一点值得注意GCC的启动文件里如果使用了-nostartfiles编译选项C库的启动流程不会自动加入你要手动在启动代码里初始化.data/.bss否则全局变量全是乱的。5.2 CubeMX与标准库的启动文件不同STM32CubeMX生成的工程里启动文件命名通常是startup_stm32f103xe.s和Keil标准外设库里的startup_stm32f10x_hd.s虽然都是F103的启动文件但宏定义、中断向量名称有些差异。核心原因是ST在HAL库时代统一了外设中断服务函数的命名习惯比如标准库的USART1中断函数名是USART1_IRQHandlerHAL库里一样是USART1_IRQHandler但有些外设的句柄不一样。比如标准库的定时器更新中断直接在TIM2_IRQHandler里判断标志位HAL库则是在这个函数里调用HAL_TIM_IRQHandler(htim2)。这也是为什么你有时候把标准库的启动文件混到HAL工程里也能编译通过但中断服务函数对不上导致中断不响应。建议是在同一个项目里不要混用标准库和HAL库的启动文件尤其是向量表中有没有包含某些保留向量这种小差异。忽略这些差异带来的问题是编译能过下载能跑但某个中断以下拉程序就进HardFault。6. 基于启动文件理解三个最经典的疑难问题讲完了启动文件本身的原理接下来分享一些我从实战中总结的排查经验这三个问题都和启动文件直接相关算是我给这篇文章附加的实用市价。6.1 启动即HardFault的排查链路现象程序烧录进去一上电或者点复位调试器全速运行就进HardFault单步执行到SystemInit之后就挂。排查步骤先把HardFault_Handler改成死循环前抓取堆栈现场的函数打印或读取SCB-CFSR寄存器可读值0x00008200的话一般意味着总线错误总线和用法错误同时出现单步跟踪进SystemInit看是不是时钟配置问题比如外部晶振没焊好PLL锁不上导致卡在等待HSE就绪的循环里查向量表是否搬移过、SCB-VTOR的值是否指向了正确位置查启动文件的栈大小是否太小进入SystemInit还没事进入__main时一调用memcpy就栈溢出了我踩过最经典的一次就是外部晶振起振慢SystemInit里等待HSE ready的超时时间不够导致复位后卡死在while循环里。那个项目看现象就是上电偶尔能跑起来、偶尔不能跑非常迷惑人。最后用示波器量晶振引脚波形才确定问题。6.2 .data段没拷贝或者.bss段没清零的诡异现象当你用GCC工具链和自写的链接脚本时很容易在启动流程中漏掉拷贝__data_start__到__data_end__的操作。这个现象很诡异定义并初始化为非0值的全局变量第一次读取时是0定义未初始化的全局变量第一次读取时是随机大值不是0程序功能间歇性错误比如上位机偶尔收到错位数据排查方式在启动流程或者main最开始的地方打印几个关键全局变量的地址和值和链接脚本生成的__data_start__、__data_end__、__bss_start__、__bss_end__对照一下。凡是地址在RAM范围内但值不对的多半就是.data/.bss初始化环节出了问题。标准库和HAL库的启动文件帮我们把这些活都干了所以你感觉不到。一旦你自己动手做Cortex-M的裸机工程、自己写链接脚本这个坑就很常见了。6.3 中断地址对不上向量表布局错误这个问题的经典场景是Bootloader跳App。现象Bootloader能正常跳到Appmain也进了串口也能初始化但只要一进中断比如按键触发外部中断、定时器溢出程序就炸。原因分析Bootloader跳转到App之前SCB-VTOR还在指向Bootloader的向量表0x08000000App的中断向量表其实在0x08010000假设App偏移0x10000。当中断触发CPU去0x08000000读向量表找到的还是Bootloader的中断服务函数地址——但函数代码是App里编译的函数偏移后位置完全对不上程序直接跑飞到HardFault。正确做法/* App 代码开头或者跳转前 */ SCB-VTOR APP_ADDR 0x1FFFFF80;同时要注意设置VTOR时地址要按128字节对齐。曾经有个同事跳转没对齐128字节错开一点点中断表偶尔能用偶尔不能用调试了整整两天。另外一点是App里如果使用HAL库的HAL_Init()它内部会调用HAL_InitTick()去配置SysTick和相应的中断优先级这个过程也会涉及VTOR相关的中断处理跑偏了同样出问题。7. 自己动手改启动文件一次完整的实操记录一直以来都建议读者不要只停留在看可以自己动手改一改启动文件去验证你对它的理解。下面以一个相对完整的小实验为例展示如何基于GCC的启动文件做一个可复现的改造。7.1 实验目标在启动文件中增加一段代码在进入main之前通过UART发送一个固定字节0xA5用来验证启动文件生效并在C运行时环境初始化之前执行这一事实。这个实验为什么有意义因为按照C语言标准在main之前你不能依赖任何C库函数所以直接在启动文件里操作寄存器反而是最可靠的验证方式。7.2 实验环境芯片STM32F103C8T6工具链arm-none-eabi-gcc Makefile调试器ST-Link V2代码位置startup_stm32f103c8t6.s中的Reset_Handler之前7.3 实现步骤第一步在启动文件里找到Reset_Handler在它调用SystemInit之前插入一段驱动USART1发送0xA5的代码.syntax unified .cpu cortex-m3 .thumb .global Reset_Handler Reset_Handler: /* 手动开 USART1 时钟 */ ldr r0, 0x40021000 /* RCC base */ ldr r1, [r0, #0x14] /* RCC_APB2ENR */ orr r1, r1, #(1 14) /* USART1 enable */ str r1, [r0, #0x14] /* GPIOA 时钟 */ ldr r1, [r0, #0x18] /* RCC_APB1ENR? 注意 APB2ENR 是 0x14 才对这里只是演示正确是复用 APB2 */ /* ... 实际工程请查阅手册 */为了方便验证不去手搓完整的GPIO复用配置直接做一个简化开时钟、把发送数据的寄存器塞满用一个简单的示波器或者逻辑分析仪抓引脚边沿。真正实践的时候建议直接复用.s文件里已有的头文件宏定义减少出错。第二步重新编译、烧录用示波器测量USART1_TX引脚PA9的波形。如果复位后能看到一个0xA5的低电平起始位说明启动文件的自定义代码生效了。这个实验虽然简单但做完之后你对启动文件先于C语言环境运行、直接操作硬件寄存器这个事实会有非常具象的感知。7.4 过程中的坑点记录实际操作中有几个很容易忽略的细节启动汇编文件里ldr r0, 0x40021000这种伪指令在GCC汇编器里是支持的但如果你用Keil的armasm语法会不一样。工具链不同汇编语法细节差别很大我在Keil和GCC之间切换时经常被这个坑到。如果直接操作USART1寄存器而不开GPIO复用时钟TX引脚默认不是复用输出波形出不来。所以要折腾这个实验记得把GPIOA时钟也打开并把PA9配置为复用推挽输出。单步调试时启动文件早期不能依赖调试器的HAL_Init或者任何库函数因为堆栈可能还没准备好。如果栈指针设置有问题BLX跳转后就挂了。8. 最后想说的几句实在话借这篇笔记建议大家有空的时候打开自己的启动文件一行一行看清楚每个符号的意思。花上半天时间比你迷迷糊糊调一个月的莫名奇妙Bug有效率得多。尤其未来打算做Bootloader、OTA升级、低功耗唤醒、加密固件、或者把MCU驱动做到极致性能的启动文件的每一个细节都可能成为你成败的关键。这个文件不是ST随便写写、用来占工程位置的它是整个嵌入式底层世界里最重要的第一块砖。如果非要说一个最简单的上手路径那就是先把向量表的每一项和芯片参考手册的中断向量表对应起来再把启动文件里每一个B .换成你能打印日志的死循环最后自己尝试在启动文件里加一段代码验证它在main之前执行。三步走完你对启动文件的掌握程度就已经超过绝大多数只会用库的开发人员了。
返回列表