恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
STM32上电启动流程深度拆解:从复位向量到main函数的每一步
首页
资讯中心
/
STM32上电启动流程深度拆解:从复位向量到main函数的每一步
STM32上电启动流程深度拆解:从复位向量到main函数的每一步
发布时间:2026/10/19 0:12:56
1. 上电那一瞬间芯片内部到底在忙什么很多人第一次接触STM32的时候都会有一个疑问我写的main()函数到底是怎么被调用的明明代码里没有一行去调用它为什么程序一上电就能跑起来这个问题看起来简单但如果你真的能把从上电到main()之间的每一步都讲清楚那说明你对ARM Cortex-M架构和STM32的启动机制已经有了相当扎实的理解。我自己当年也是模模糊糊过来的。刚开始写STM32的时候只知道启动文件很重要不要乱动至于里面那些汇编代码到底在干什么完全是一头雾水。后来因为一个项目里遇到了变量未初始化的问题才逼着自己把启动流程从头到尾捋了一遍。捋完之后发现很多之前觉得玄学的问题其实都能在这个流程里找到答案。这篇文章就是把我自己拆解STM32上电启动流程的过程完整记录下来。从复位向量怎么取、栈指针怎么设、SystemInit做了什么、__main和main有什么区别一直到C运行时环境是怎么建立起来的每一步我都会尽量讲清楚为什么是这样而不只是是这样。适合有一定STM32开发基础、想深入理解底层启动机制的朋友。如果你还在纠结为什么我的全局变量初值不对为什么中断还没配置就进来了这类问题那这篇内容应该能帮你把思路理顺。整篇内容我会以STM32F1系列Cortex-M3内核为主要参考但核心流程在F0、F4、H7等系列上是相通的差异点我会单独指出来。2. 从复位到第一条指令硬件层面发生了什么2.1 复位向量的取址过程STM32上电或者按下复位键之后CPU并不是从地址0x00000000开始执行代码的。Cortex-M内核的复位行为是从地址0x00000000处取出一个字4字节把这个值加载到主栈指针MSP中然后从地址0x00000004处再取出一个字把这个值加载到程序计数器PC中然后从PC指向的地址开始执行。这两个地址里存的东西就是所谓的中断向量表的前两项。第一项是栈顶地址第二项是复位处理函数的入口地址。这里有一个很多人容易搞混的点STM32的Flash物理起始地址是0x08000000但为什么复位后是从0x00000000取向量这是因为STM32内部做了地址重映射。根据BOOT引脚的不同配置0x00000000这个地址会被映射到不同的物理存储区域BOOT1BOOT0映射区域典型场景x0主Flash (0x08000000)正常运行01系统存储器出厂Bootloader11内置SRAM调试/特殊用途所以你在启动文件里看到的向量表最终会被链接器放到Flash的最前面而硬件通过重映射机制让CPU在0x00000000处就能读到它。2.2 栈顶地址是怎么确定的向量表的第一个字是栈顶地址。在STM32的启动文件比如startup_stm32f103xb.s里通常能看到这样的定义__initial_sp EQU 0x20005000这个值是怎么来的它其实就是RAM的结束地址。以STM32F103C8T6为例它有20KB的SRAM起始地址0x20000000结束地址就是0x20005000。栈是从高地址向低地址生长的所以栈顶设在RAM的最末尾。但实际项目中这个值不是固定的。链接器会根据你使用的RAM大小、是否有其他区域占用等因素通过链接脚本自动计算栈顶位置。在Keil中这个值由分散加载文件.sct决定在GCC中由链接脚本.ld决定。注意如果你在项目中使用了外部SRAM或者对内存布局做了特殊安排一定要确认栈顶地址是否合理。栈顶设错了程序可能在第一次压栈的时候就跑飞。2.3 复位处理函数的第一批操作取完栈顶和复位向量之后CPU就开始执行复位处理函数了。在启动文件里这个函数通常叫Reset_Handler。它做的第一批事情非常关键我逐行拆一下Reset_Handler: LDR R0, SystemInit BLX R0 LDR R0, __main BX R0看起来只有四行但信息量很大。第一行把SystemInit的地址加载到R0寄存器第二行跳过去执行。SystemInit是CMSIS标准里定义的一个函数主要做时钟系统的初始化。第三、四行则是跳转到__main注意这里是__main而不是main两者不是一回事后面会详细讲。有些启动文件在调用SystemInit之前还会做一些额外操作比如使能FPU对于Cortex-M4F/M7、设置向量表偏移等。这些操作因芯片系列而异但核心逻辑是一致的。3. SystemInit里藏了哪些容易被忽略的细节3.1 时钟配置的默认状态SystemInit这个函数定义在system_stm32f1xx.c文件中不同系列文件名不同。它最主要的工作是配置系统时钟。但这里有一个很多人不知道的事实在SystemInit执行之前STM32跑的是内部HSI时钟通常是8MHzF1系列或者16MHzF4系列。也就是说从复位到SystemInit执行完这段时间CPU是以一个相对较低的频率在运行的。这就是为什么有些对时序敏感的外设操作如果在SystemInit之前执行可能会得到意想不到的结果。SystemInit内部通常会做这几件事复位RCC寄存器到默认状态根据宏定义选择时钟源HSI/HSE配置PLL倍频参数切换系统时钟到PLL输出更新SystemCoreClock变量以F1系列为例如果你用的是8MHz外部晶振SystemInit会把PLL配置成9倍频最终系统时钟跑到72MHz。这个过程涉及对RCC_CFGR、RCC_CR等寄存器的精确操作每一步都有时序要求。3.2 向量表偏移寄存器的设置在Cortex-M3/M4/M7中有一个叫VTORVector Table Offset Register的寄存器。SystemInit里通常会根据VECT_TAB_OFFSET宏来设置这个寄存器。为什么需要这个因为在实际项目中你的程序可能不是从Flash的0x08000000开始存放的。比如你做了一个BootloaderApp的架构App的起始地址可能是0x08008000。这时候中断向量表也跟着偏移了如果不设置VTOR中断发生时CPU还是会去0x00000000处找向量表结果就跑到Bootloader的中断处理函数里去了。SCB-VTOR FLASH_BASE | VECT_TAB_OFFSET;这行代码看起来简单但如果你在做OTA升级或者多固件切换忘了设这个寄存器就会出现中断响应了但执行的是错误的函数这种非常难排查的问题。3.3 一个常见的误解很多人以为SystemInit是必须的其实不是。你完全可以在启动文件里不调用它自己写一个时钟配置函数。但这样做的前提是你清楚地知道自己在干什么并且确保在main()之前系统时钟已经配置到你期望的状态。我见过有项目为了优化启动时间把SystemInit里的时钟配置简化了结果某些外设初始化时因为时钟频率不对波特率算出来偏差很大通信一直不稳定。所以我的建议是除非你有明确的理由和充分的测试否则不要轻易改动SystemInit的默认行为。4. __main不是mainC运行时环境的建立过程4.1 为什么需要__main这是整个启动流程里最容易让人困惑的地方。启动文件的最后是跳转到__main但你的C代码里写的是main。这两个到底什么关系__main是ARM编译器armcc/armclang提供的一个库函数它不属于你的代码而是编译工具链的一部分。它的核心职责是建立C运行时环境然后才调用你写的main函数。具体来说__main会做这几件事代码和数据段的搬移把Flash中的初始化数据复制到RAM中ZI段的清零把未初始化的全局变量和静态变量所在区域清零堆栈的初始化设置堆的起始和结束地址调用__rt_entry最终进入C库的入口再调用main如果你用的是GCC工具链对应的入口是_start功能类似但实现方式不同。4.2 数据段搬移的详细过程这是启动流程里最核心的一步也是很多玄学问题的根源。你的C代码里写了int global_var 42; int global_var2;global_var有初值它会被放在.data段global_var2没有初值它会被放在.bss段也叫ZI段。问题来了.data段的初值存在哪里答案是存在Flash里。程序运行时.data段必须在RAM中所以启动代码需要把Flash里的初值复制到RAM中对应的位置。而.bss段不需要复制只需要全部清零。这个过程在__main里自动完成具体是通过链接器生成的分散加载信息来指导的。在Keil中你可以通过查看.map文件来确认各个段的地址和大小。段名存放位置是否占用Flash启动时操作.textFlash是无需操作.dataFlashRAM是从Flash复制到RAM.bssRAM否清零StackRAM否设置栈顶HeapRAM否设置堆边界4.3 一个真实的踩坑案例我之前遇到过一个项目代码在Debug模式下运行正常Release模式下就出问题。排查了很久才发现是因为Release模式下编译器做了优化把一些变量的初始化顺序调整了而代码中有一个全局变量在另一个全局变量的初始化表达式中被引用导致使用了未初始化的值。这个问题的根源就在于对启动流程中数据段搬移的顺序理解不够。C标准并没有规定不同编译单元之间全局变量的初始化顺序所以如果你的代码依赖于这种顺序那就是在走钢丝。解决办法很简单不要写依赖于全局变量初始化顺序的代码。如果确实需要用一个显式的初始化函数在main开头调用。5. 中断向量表与启动文件的配合关系5.1 向量表的完整结构中断向量表不只有复位向量它包含了所有异常和中断的入口地址。Cortex-M3的向量表结构如下偏移地址内容说明0x00栈顶地址初始化MSP0x04Reset_Handler复位处理0x08NMI_Handler不可屏蔽中断0x0CHardFault_Handler硬件错误.........0x3CSysTick_Handler系统滴答定时器0x40外设中断IRQ0, IRQ1, ...在启动文件里这些处理函数通常被定义成弱符号weak symbol。这意味着如果你在C代码里定义了一个同名的函数链接器会用你的版本覆盖启动文件里的默认版本。默认版本通常就是一个死循环Default_Handler: B .这个设计非常巧妙如果你没有实现某个中断处理函数但该中断被触发了程序会停在那里方便你用调试器发现问题。而不是跳到一个随机地址跑飞。5.2 启动文件中的栈和堆配置启动文件里除了向量表和复位处理还会定义栈和堆的大小Stack_Size EQU 0x00000400 Heap_Size EQU 0x00000200Stack_Size默认是1KBHeap_Size默认是512字节。这两个值在很多项目中都需要调整。栈大小的估算是一个经验活。你需要考虑最深的中断嵌套层数、每个中断处理函数里的局部变量大小、函数调用深度等因素。我一般会这样估算基础函数调用预留256字节每个中断嵌套层级预留64-128字节如果有递归或大局部数组额外增加堆的大小则取决于你是否使用了malloc。如果你从来不用动态内存分配可以把Heap_Size设为0省出RAM给其他用途。但要注意某些C库函数如printf的某些实现可能会隐式使用堆。提示在Keil中如果栈溢出不一定会立刻报错。它可能会覆盖相邻的变量区域导致一些莫名其妙的问题。建议在调试阶段开启栈检查功能或者手动在栈顶和栈底填充特征值定期检查是否被改写。5.3 中断优先级与启动的关系有一个容易被忽略的点在main()执行之前中断优先级分组和各个中断的优先级都是默认值。Cortex-M3复位后所有中断优先级都是0优先级分组也是默认的。这意味着如果你在SystemInit或者更早的阶段就使能了某个中断它的优先级可能不是你期望的。所以标准的做法是在main()里先配置优先级分组再配置各个中断的优先级最后才使能中断。我见过有项目在SystemInit里就打开了SysTick中断结果因为优先级没配置好导致其他中断响应异常。这种问题排查起来非常痛苦因为现象可能是不确定的。6. 不同工具链下的启动差异与移植要点6.1 Keil MDK与GCC的启动文件对比Keil MDK使用的是ARMCC/ARMCLANG编译器启动文件是汇编格式.s入口是__main。GCC工具链使用的是GNU汇编格式入口是_start通常由crt0.o提供。两者在功能上是对等的但细节上有不少差异对比项Keil MDKGCC启动文件格式ARM汇编GNU汇编C库入口__main_start数据段搬移由__main自动完成由启动代码或链接脚本处理向量表定义汇编中直接定义通常在C文件中定义弱符号语法__weak__attribute__((weak))如果你需要把Keil工程移植到GCC比如用STM32CubeIDE或者Makefile构建启动文件是必须替换的。但更重要的是理解两者在C运行时初始化上的差异否则可能会遇到变量未初始化、构造函数未调用等问题。6.2 C全局对象构造的问题如果你的项目用到了C那启动流程还多了一步全局对象的构造函数调用。在Keil中__main会遍历.init_array段依次调用其中保存的构造函数指针。在GCC中类似的操作由__libc_init_array完成。这里有一个坑如果你自己写了启动代码但忘了调用构造函数初始化那所有全局C对象的构造函数都不会执行。表现就是对象的状态不对但编译和链接都不会报错。// 在启动代码中需要确保调用了类似这样的函数 extern void __libc_init_array(void); __libc_init_array();6.3 移植时的检查清单当你需要把一个STM32工程从一个工具链迁移到另一个或者从一个芯片系列迁移到另一个时启动相关的检查清单如下启动文件是否与目标芯片匹配中断向量数量和名称栈顶地址是否正确RAM大小和起始地址堆大小是否满足需求向量表偏移是否设置正确系统时钟配置是否与硬件匹配晶振频率C全局构造函数是否被正确调用弱符号定义是否与编译器匹配这份清单看起来简单但每一条我都见过有人踩坑。特别是第5条换了晶振但忘了改SystemInit里的宏定义导致系统时钟跑到了一个完全不对的频率所有基于时钟的外设全部异常。7. 几个值得深挖的启动相关调试技巧7.1 用调试器观察启动过程在Keil或IAR中你可以在启动文件的第一条指令处设置断点然后单步执行观察寄存器和内存的变化。具体操作是在Reset_Handler的第一行设置断点复位芯片然后单步。重点观察这几个时刻SystemInit执行前后RCC相关寄存器的值__main执行前后.data段和.bss段的内容变化进入main时栈指针的值这种观察方式比看文档直观得多而且能帮你建立起对启动流程的肌肉记忆。7.2 通过map文件确认内存布局.map文件是链接器生成的里面详细记录了每个段、每个函数、每个变量的地址和大小。在启动流程调试中我主要关注这几个信息Image$$RW_IRAM1$$Base和Image$$RW_IRAM1$$LimitRAM中数据段的边界Image$$ER_IROM1$$BaseFlash中代码段的起始地址Load Region和Execution Region加载域和执行域的对应关系理解了这些符号的含义你就能清楚地知道启动代码到底把哪些数据从哪里搬到了哪里。7.3 启动时间的测量与优化有些低功耗项目对启动时间有要求。测量启动时间的方法很简单在main的第一行翻转一个GPIO用示波器看从上电到GPIO翻转的时间。如果启动时间过长可以从这几个方面优化减少SystemInit中的等待时间比如HSI启动比HSE快关闭不必要的初始化把一些初始化推迟到main之后按需执行检查是否有大的.data段需要搬移但要注意优化启动时间不能以牺牲稳定性为代价。我见过为了省几毫秒而跳过时钟稳定等待时间的做法结果在低温环境下系统根本起不来。7.4 一个关于启动文件的常见误区最后说一个我经常看到的问题有人觉得启动文件是系统文件不应该去动它。这个观念在大多数情况下是对的但如果你不理解它就永远无法真正掌握STM32的启动过程。我的建议是至少完整读一遍你所用芯片的启动文件把每一行都搞懂。遇到不认识的汇编指令就查ARM的指令集手册。这个过程可能花你半天时间但收获是巨大的。之后遇到任何启动相关的问题你都能有一个清晰的排查思路而不是靠猜。启动流程是嵌入式系统的第一性原理之一。把它搞透了很多上层的问题都会变得有迹可循。我在实际项目中越来越体会到底层知识不是用不到而是用到了但你不知道。希望这篇拆解能帮你把这块知识补上。