资讯动态

STM32上电启动流程深度解析:从复位向量到main函数的底层机制

发布时间:2026/10/18 17:32:02 来源:尧图企业网站定制
1. 上电之后芯片到底干了什么很多人第一次接触 STM32都是从点灯开始的。写几行代码编译下载LED 亮了任务完成。但如果你问一句“芯片从上电到 main 函数之间到底经历了什么”能完整说清楚的人其实不多。我在带新人的时候特别喜欢问这个问题因为它是理解嵌入式系统运行机制的分水岭——搞懂了启动流程后面遇到 HardFault、变量未初始化、中断不响应、程序跑飞这类问题排查起来就有了底层依据而不是靠猜。STM32 的上电启动流程说白了就是芯片从复位向量取指开始到你的main()函数被调用为止中间那段“看不见的代码”所做的事情。这段代码主要由启动文件通常是startup_stm32xxxx.s和系统初始化代码system_stm32xxxx.c承担再加上链接脚本.ld文件在背后默默安排内存布局。三者配合才让你的 C 代码有了一个“像模像样”的运行环境。这篇文章我会从复位电路释放的那一瞬间讲起逐行拆解启动文件里的汇编代码把每一段的作用、为什么这么写、不这么写会出什么问题全部摊开来讲。适合已经能跑通 STM32 工程、但对底层机制一知半解的开发者也适合想从 51 单片机过渡到 ARM Cortex-M 的嵌入式爱好者。看完之后你至少能做到拿到任何一份启动文件都能读懂它在干什么甚至能自己裁剪、修改它。2. 启动流程的整体框架与设计思路2.1 从复位到 main 的四个阶段在深入每一行汇编之前先把整个流程的骨架搭起来。STM32以 Cortex-M 内核为例从上电到进入main()大致经历四个阶段第一阶段是硬件取向量。复位信号释放后内核从地址0x00000000处读取两个字第一个字是初始栈顶指针MSP第二个字是复位向量Reset_Handler 的地址。注意这里读的是“映射到 0x00000000 的存储区”可能是 Flash也可能是被重映射的 SRAM 或系统存储器具体取决于 BOOT 引脚的配置。第二阶段是执行启动文件的复位处理程序。Reset_Handler会先调用SystemInit()配置时钟然后执行数据段搬运和 BSS 段清零最后跳转到main()。第三阶段是运行时环境准备。这一步的核心是把存放在 Flash 里的已初始化全局变量拷贝到 RAM把未初始化变量所在区域清零让 C 语言里“全局变量默认是 0”这个假设成立。第四阶段是进入用户代码。调用__main注意不是main由 C 库完成最后的初始化再调用你写的main()。这四个阶段环环相扣任何一环出问题现象都不一样。比如栈顶指针设错了程序一上电就 HardFaultBSS 段没清零全局变量就是随机值时钟没配好串口波特率全是乱的。2.2 为什么启动文件要用汇编写有人会问都什么年代了为什么启动文件还用汇编不能用 C 写吗答案是在 C 运行环境建立之前C 代码根本无法可靠运行。C 函数调用依赖栈而栈指针还没设置C 的全局变量依赖数据段搬运而搬运工作本身就是启动代码要做的。这是一个“先有鸡还是先有蛋”的问题。所以启动的最初几步必须用汇编完成等栈建好了、数据段搬完了才能跳到 C 世界。不过实际工程里启动文件也不是纯汇编。像SystemInit()就是 C 函数在汇编里通过BL指令调用。这种“汇编打底、C 收尾”的混合模式是 ARM 生态里的标准做法。2.3 链接脚本与启动文件的配合关系启动文件里会出现一堆符号比如_sidata、_sdata、_edata、_sbss、_ebss。这些符号不是汇编里定义的而是链接脚本里定义的。链接器在把所有目标文件拼成最终镜像时会根据链接脚本的规则把这些符号的地址填进去。理解这一点非常关键。很多人看启动文件时看到_sidata一脸懵翻遍整个工程也找不到定义就是因为它在.ld文件里。链接脚本负责“布局”启动文件负责“搬运”两者是分工协作的关系。你改了链接脚本的内存布局启动文件里的搬运逻辑可能就要跟着调整。3. 启动文件逐行拆解与核心细节3.1 栈和堆的定义第一段代码在干什么打开任意一份 STM32 的启动文件最开头通常是这样的Stack_Size EQU 0x00000400 AREA STACK, NOINIT, READWRITE, ALIGN3 Stack_Mem SPACE Stack_Size __initial_sp这几行的含义我逐条说。Stack_Size EQU 0x00000400定义栈大小为 1KB0x400 1024 字节。这个值是可以改的栈不够用的时候最常见的原因就是局部数组太大或者递归太深。我一般建议新手至少给到 0x800也就是 2KB尤其是用了一些带浮点运算或者printf的工程。AREA STACK, NOINIT, READWRITE, ALIGN3声明一个名为 STACK 的段。NOINIT表示不初始化不需要在 Flash 里存初值READWRITE表示可读写ALIGN3表示按 2 的 3 次方即 8 字节对齐。ARM 要求栈指针至少 4 字节对齐8 字节对齐是为了兼容双字访问和浮点运算。Stack_Mem SPACE Stack_Size就是真正分配这 1KB 空间。__initial_sp是一个标号紧跟在栈空间之后。由于栈是“向下增长”的所以这个标号的值就是栈顶地址。它会被链接器放到向量表的第一个位置。堆的定义类似Heap_Size EQU 0x00000200 AREA HEAP, NOINIT, READWRITE, ALIGN3 __heap_base Heap_Mem SPACE Heap_Size __heap_limit堆主要用于malloc/free。裸机工程里如果不用动态内存堆可以设得很小甚至为 0。但要注意某些 C 库函数比如printf的某些实现可能会偷偷用堆所以完全去掉有时会出问题。注意栈和堆在内存里是相向增长的一个向下一个向上。如果两者撞上了就会出现莫名其妙的死机。所以栈和堆的大小要根据实际 RAM 容量合理分配别把 20KB 的 RAM 里塞 16KB 的栈。3.2 向量表芯片的“紧急联系人名单”接下来是向量表AREA RESET, DATA, READONLY EXPORT __Vectors EXPORT __Vectors_End EXPORT __Vectors_Size __Vectors DCD __initial_sp DCD Reset_Handler DCD NMI_Handler DCD HardFault_Handler DCD MemManage_Handler DCD BusFault_Handler DCD UsageFault_Handler DCD 0 DCD 0 DCD 0 DCD 0 DCD SVC_Handler DCD DebugMon_Handler DCD 0 DCD PendSV_Handler DCD SysTick_Handler ; 后面是外设中断向量向量表本质上就是一个函数指针数组。第一个条目是栈顶地址第二个是复位处理函数从第三个开始依次是各种异常和中断的处理函数入口。这里有几个细节值得说。第一DCD是“分配一个字4 字节并初始化”的伪指令。所以每个条目占 4 字节向量表在 32 位系统里就是 4 字节对齐的。第二中间那几个DCD 0是保留位。Cortex-M 的异常编号是固定的0 号是栈顶1 号是复位2 号 NMI3 号 HardFault4 号 MemManage5 号 BusFault6 号 UsageFault7 到 10 号保留11 号 SVC12 号 DebugMon13 号保留14 号 PendSV15 号 SysTick。从 16 号开始才是外设中断。这些保留位不能省否则后面的中断向量全部错位。第三向量表的位置由SCB-VTOR寄存器决定。默认情况下如果从 Flash 启动VTOR 指向 0x08000000如果做了重映射可能指向别处。做 Bootloader 跳转 App 的时候必须在 App 里重新设置 VTOR否则中断会跳到 Bootloader 的向量表里去这是很多人踩过的坑。3.3 Reset_Handler启动流程的总调度复位向量指向的就是Reset_Handler这是整个启动流程的“总指挥”Reset_Handler PROC EXPORT Reset_Handler [WEAK] IMPORT __main IMPORT SystemInit LDR R0, SystemInit BLX R0 LDR R0, __main BX R0 ENDP看起来只有几行但信息量很大。IMPORT __main和IMPORT SystemInit告诉汇编器这两个符号在别的文件里定义链接的时候去找。SystemInit在system_stm32xxxx.c里__main在 C 库启动代码里。LDR R0, SystemInit把SystemInit的地址加载到 R0。注意这里的是伪指令汇编器会把它变成“从字面量池加载”或者“MOVW/MOVT 组合”具体取决于编译器优化。BLX R0带链接跳转把返回地址存到 LR然后跳到SystemInit执行。执行完返回后继续往下走。LDR R0, __main和BX R0则是跳转到__main注意是BX不是BLX因为不需要返回了——main函数理论上不应该返回如果返回了行为是未定义的。这里有个非常容易混淆的点__main和main是两个不同的东西。main是你写的用户入口__main是 C 库提供的启动入口。__main会做两件事一是调用__scatterload完成数据段搬运和 BSS 清零如果你用的是 ARM 编译器二是调用__rt_entry初始化 C 库最后才调用main。那为什么在 GCC 环境下看不到__main呢因为 GCC 用的是_start或者直接在启动文件里手写搬运逻辑不走 ARM 那套 scatter loading。这也是为什么 STM32CubeMX 生成的 GCC 工程和 Keil 工程的启动文件长得不一样。3.4 数据段搬运与 BSS 清零GCC 版本的实现在 GCC 的启动文件里Reset_Handler通常长这样Reset_Handler: ldr r0, _sstack mov sp, r0 bl SystemInit 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 bl main这段代码是整个启动流程里最核心的部分我逐段拆。设置栈指针ldr r0, _sstack把链接脚本里定义的栈顶地址加载到 r0mov sp, r0写入 SP 寄存器。这一步必须在调用任何 C 函数之前完成否则bl SystemInit一执行就崩了。调用 SystemInit配置时钟树、外部晶振、PLL、Flash 等待周期等。这一步因芯片型号而异但目的都是让系统跑在一个稳定的高频时钟上。数据段搬运_sdata是 RAM 中数据段的起始地址_edata是结束地址_sidata是数据段在 Flash 中的存储地址。循环做的事就是从_sidata处按字读取写到_sdata开始的位置直到写满_edata - _sdata个字节。为什么需要这一步因为全局变量和静态变量如果有初值这些初值在编译时是存在 Flash 里的掉电不丢但程序运行时它们必须在 RAM 里可读写。所以启动时要把初值从 Flash“搬”到 RAM。BSS 清零_sbss到_ebss是 BSS 段存放未初始化的全局变量和静态变量。C 标准规定它们的初值是 0但 RAM 上电后是随机值所以要手动清零。循环用str r3, [r2]每次写一个字 0直到写满整个 BSS 段。跳转 mainbl main调用用户入口。注意这里用的是bl而不是bx因为 GCC 环境下main返回后通常会进入一个死循环或者调用exit。实操心得如果你发现全局变量的初值不对比如int flag 100;结果读出来是 0 或者随机值第一个要怀疑的就是数据段搬运有没有正确执行。常见原因是链接脚本里的_sidata地址写错了或者 Flash 起始地址和实际不符。3.5 中断服务函数的弱定义启动文件的后半部分是一大堆中断服务函数的弱定义WEAK Default_Handler EXPORT NMI_Handler NMI_Handler PROC EXPORT NMI_Handler [WEAK] B . ENDP[WEAK]表示这是一个弱符号。如果用户在 C 文件里定义了同名的NMI_Handler链接器会用用户的版本覆盖这个弱版本。如果用户没定义就用这个默认的——而默认版本就是一个死循环B .。这个设计非常巧妙。它保证了向量表里每个条目都有一个合法的地址不会因为某个中断没写处理函数就跳到非法地址。同时如果程序不小心触发了未处理的中断就会卡死在B .这里方便调试时定位。我见过不少新手遇到程序“跑飞”后卡住不动用调试器一看 PC 停在某个Default_Handler里就知道是触发了未处理的中断。这时候去查对应的中断使能位和外设标志位基本就能找到问题。4. 完整实操从零验证启动流程4.1 实验环境与工程搭建光看代码不够得动手验证。我设计了一个最小实验用 STM32F103 系列最常见的入门型号来观察启动流程的每个阶段。工程结构很简单一个启动文件startup_stm32f103.s一个链接脚本stm32f103.ld一个main.c一个system_stm32f103.c。不依赖任何 HAL 库纯寄存器操作这样能看得最清楚。链接脚本的关键部分MEMORY { FLASH (rx) : ORIGIN 0x08000000, LENGTH 64K RAM (xrw) : ORIGIN 0x20000000, LENGTH 20K } SECTIONS { .isr_vector : { . ALIGN(4); KEEP(*(.isr_vector)) . ALIGN(4); } FLASH .text : { . ALIGN(4); *(.text) *(.text*) . ALIGN(4); _etext .; } FLASH .data : AT(_etext) { . ALIGN(4); _sdata .; *(.data) *(.data*) . ALIGN(4); _edata .; } RAM .bss : { . ALIGN(4); _sbss .; *(.bss) *(.bss*) *(COMMON) . ALIGN(4); _ebss .; } RAM _sidata LOADADDR(.data); }这里的关键是AT(_etext)和LOADADDR。.data段的运行地址VMA在 RAM但加载地址LMA在 Flash 的_etext处。_sidata就是加载地址启动文件用它作为搬运的源地址。4.2 用调试器观察每个阶段编译下载后打开调试器在Reset_Handler的第一条指令处下断点。复位后单步执行观察寄存器和内存的变化。第一步看 SP 寄存器的值。复位刚结束时SP 应该等于__initial_sp也就是栈顶地址。在我的工程里RAM 起始是 0x20000000栈大小 0x400所以__initial_sp应该是 0x20000400。如果调试器里看到的 SP 不是这个值说明向量表第一个条目有问题。第二步单步过SystemInit。执行前后对比时钟相关寄存器比如 RCC_CFGR能看到 PLL 使能位、系统时钟切换位的变化。这一步验证时钟配置是否生效。第三步在数据段搬运循环前后对比 RAM 里某个已初始化全局变量的值。搬运前它应该是 0 或者随机值搬运后应该等于代码里写的初值。我在main.c里定义了一个volatile int test_data 0x12345678;搬运后读内存 0x20000000 附近应该能看到这个值。第四步BSS 清零前后对比一个未初始化全局变量的值。清零前是随机值清零后是 0。第五步跳转到main后确认 PC 指向用户代码。这套流程走一遍启动文件的每一行代码就都“活”了比看十遍文档都管用。4.3 参数计算栈大小到底该给多少栈大小不是拍脑袋定的。我一般用两种方法估算。方法一静态分析。看编译生成的.su文件GCC 用-fstack-usage选项里面列出了每个函数的栈使用量。把调用链上最深的路径加起来再留 30% 余量。比如main调用funcAfuncA调用funcB三者栈用量分别是 32、64、128 字节那最坏情况是 224 字节加上中断嵌套每个中断至少 32 字节的硬件压栈给 512 字节比较稳妥。方法二运行时填充检测。启动时把整个栈区域填成一个特定模式比如 0xDEADBEEF运行一段时间后检查栈底还有多少没被覆盖就知道实际用了多少。这个方法能发现静态分析漏掉的路径。// 在 main 开头填充栈区域需要知道栈的起止地址 extern uint32_t __initial_sp; void stack_fill(void) { uint32_t *p (uint32_t *)__initial_sp - 1; while (p (uint32_t *)0x20000000) { *p-- 0xDEADBEEF; } }注意这个方法会破坏当前函数的栈帧必须在最早期调用或者用汇编实现。实际工程里更推荐用调试器的内存观察功能手动检查栈区域的使用情况。5. 常见问题与排查技巧实录5.1 程序一上电就 HardFault 的排查顺序这是最经典的问题。我的排查顺序是第一检查向量表第一个条目。用调试器看 0x08000000 处的值应该是 RAM 的高地址栈顶。如果是 0xFFFFFFFF 或者别的奇怪值说明向量表没正确烧录或者链接脚本有问题。第二检查栈顶地址是否合法。栈顶必须在 RAM 范围内且 8 字节对齐。如果栈顶指向 Flash 区域一压栈就 HardFault。第三检查SystemInit里的时钟配置。有些芯片如果外部晶振没起振而代码里又死等 HSE 就绪标志会卡在SystemInit里。现象是程序不跑但也不是 HardFault。如果 PLL 配置参数超出芯片规格可能导致取指失败触发 HardFault。第四检查中断向量表偏移。如果用了 BootloaderApp 的 VTOR 没设置对中断触发时会跳到错误地址。5.2 全局变量初值不对的三种原因现象int flag 100;但运行时读到 0 或随机值。原因一数据段搬运没执行。可能是启动文件被裁剪掉了搬运代码或者链接脚本里_sidata定义错误。原因二搬运源地址和目标地址重叠。如果链接脚本把.data的 LMA 和 VMA 设成了同一个地址搬运就变成了自己搬自己初值可能被覆盖。原因三变量被放在了错误的段。比如用了自定义段属性但链接脚本没处理变量可能被放到了 BSS 段初值丢失。排查方法在调试器里看_sidata、_sdata、_edata三个符号的地址确认_sidata在 Flash 范围_sdata和_edata在 RAM 范围且_sidata处的内容确实是初值。5.3 中断不响应的排查清单现象可能原因排查方法中断完全不进向量表地址错误检查 SCB-VTOR 和实际烧录地址中断进一次就不再进中断标志未清除在 ISR 里检查并清除标志位中断偶尔丢失优先级配置冲突检查 NVIC 优先级分组和抢占优先级中断进但执行异常栈溢出增大栈或减少 ISR 里的局部变量中断进但变量不对未加 volatile中断里修改的全局变量必须加 volatile这张表是我这些年调试中断问题的经验浓缩基本上按顺序查一遍90% 的问题都能定位。5.4 启动文件裁剪的注意事项有些项目为了省 Flash 空间会裁剪启动文件。裁剪时要注意向量表不能裁。即使某个中断不用也要保留条目否则后面的中断全部错位。数据段搬运和 BSS 清零不能裁。除非你确定没有已初始化全局变量且所有全局变量都手动赋初值。SystemInit可以简化但时钟配置不能省。默认的 HSI 时钟精度和频率可能不满足外设需求。弱定义的中断处理函数可以合并用一个Default_Handler代替所有但调试时会丢失“哪个中断触发了”的信息。我个人的做法是保留完整的向量表和搬运逻辑只把不用的中断处理函数统一指向一个带断点的Default_Handler这样既省空间又不丢调试信息。6. 启动流程的延展与进阶方向搞懂了基本启动流程之后有几个方向可以继续深入。Bootloader 设计。Bootloader 本质上就是一段特殊的启动代码它先运行判断是否需要升级然后跳转到 App。跳转前要关中断、设 MSP、设 VTOR这三步缺一不可。我见过太多 Bootloader 跳转后 App 中断不响应的问题根源都是 VTOR 没设对。RTOS 的启动差异。FreeRTOS 这类 RTOS 会在main里创建任务然后启动调度器。调度器启动时会切换栈指针到第一个任务的栈这涉及到 PSP进程栈指针和 MSP主栈指针的切换。理解启动流程才能理解为什么 RTOS 里中断用 MSP、任务用 PSP。低功耗模式的唤醒。STM32 从待机模式唤醒时相当于一次复位会重新走一遍启动流程。但 STOP 模式唤醒不会它从进入低功耗的地方继续执行。这两种模式的行为差异直接影响你的状态保存策略。安全启动与校验。在一些对可靠性要求高的场景启动时会先校验固件的 CRC 或签名校验通过才跳转到 App。这部分代码通常放在启动文件之后、main之前或者独立成一个 Bootloader。我在实际项目里遇到过一次因为启动文件里栈大小设得太小导致printf浮点格式化时栈溢出程序跑飞的问题。当时排查了很久最后用栈填充检测才发现是栈不够。从那以后我养成了一个习惯任何新工程先把栈调到 2KB等稳定了再根据实际用量往下调。这个习惯帮我省了很多调试时间。启动流程这东西平时感觉不到它的存在但一旦出问题就是最底层、最难查的问题。花时间把它吃透是一笔非常划算的投资。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑