
1. 上电那一刻芯片到底在干什么很多人第一次接触 STM32都是从点灯开始的。写几行代码编译下载灯亮了任务完成。但如果你问一句“从按下复位键到 main 函数第一行代码执行中间发生了什么”能说清楚的人就不多了。我当年也是这么过来的直到有一次项目里遇到一个诡异的 bug——变量在 main 之前就被改了值查了两天才发现是启动文件里的堆栈配置出了问题。从那以后我把 STM32 的上电启动流程从头到尾捋了一遍今天就把这个过程逐行拆开讲清楚。这篇文章面向的是已经能用 STM32 跑通基本工程、但对底层启动机制还比较模糊的开发者。我会从复位向量讲起经过启动文件、时钟初始化、C 运行时环境搭建一直讲到 main 函数被调用。整个过程涉及汇编启动文件、链接脚本、向量表、堆栈初始化等核心概念。读完你至少能搞明白三件事为什么启动文件里要关中断、堆和栈到底在哪里分配的、以及 main 函数之前那些全局变量是怎么被初始化的。先给一个全局视角。STM32 上电或者复位之后内核首先从固定的地址取出两个值一个是初始的栈顶地址另一个是复位处理函数的入口地址。这两个值就存在向量表最前面的两个 32 位空间里。取完之后内核跳转到复位处理函数开始执行启动文件里的汇编代码。这段汇编代码做完最基本的硬件初始化之后会调用 C 库的初始化函数把数据段从 Flash 搬到 RAM、把 BSS 段清零最后才跳进 main。整个链条环环相扣任何一环出问题程序都跑不起来。2. 复位向量与内存映射芯片从哪里开始执行2.1 上电后的第一条指令从哪里来STM32 基于 ARM Cortex-M 内核复位行为是内核规范定死的。上电或复位释放后内核会从地址 0x00000000 处读取第一个 32 位值作为栈顶指针MSP再从 0x00000004 处读取第二个 32 位值作为复位处理函数的地址。注意这里说的是“地址 0x00000000”但实际映射到哪里取决于芯片的启动模式配置。以常见的 STM32F1 系列为例通过 BOOT0 和 BOOT1 两个引脚的电平组合可以把 0x00000000 映射到三个不同的物理区域主 Flash、系统存储器、或者内置 SRAM。绝大多数产品里BOOT0 拉低从主 Flash 启动这时候 0x00000000 被映射到 Flash 的起始地址 0x08000000。也就是说你烧录的程序开头那 8 个字节就是内核复位后最先读到的东西。这里有个容易踩的坑如果你用调试器直接下载程序但不复位有时候程序能跑一旦断电重新上电就不行了。这往往是因为向量表的位置或者启动模式配置有问题。我建议每次验证启动流程相关的改动都要真正断电再上电测试不要只靠调试器的软复位。2.2 向量表的结构与重定位向量表不只有复位入口它是一张完整的表每一项 4 字节依次存放各个异常和中断处理函数的入口地址。前几项的顺序是固定的栈顶值、复位处理函数、NMI 处理函数、硬件错误处理函数……后面跟着各个外设中断。这个顺序由 Cortex-M 内核规定不能改。在标准库工程里这张表定义在启动文件比如 startup_stm32f103xb.s中放在一个名为.isr_vector的段里。链接脚本会把这个段放在 Flash 的最前面。但有时候我们需要把向量表搬到 RAM 里比如做 Bootloader 跳转、或者动态修改中断入口。这时候就要用到 SCB 的 VTOR 寄存器向量表偏移寄存器把新的向量表地址写进去。注意修改 VTOR 之前新的向量表地址必须按异常数量对齐Cortex-M3/M4 要求至少 128 字节对齐M0/M0 要求更宽松但也有对齐要求。地址没对齐会导致取向量时出错直接进 HardFault。我见过一个案例某开发者在做双区升级时把 APP 的向量表放在 RAM 里但忘了设置 VTOR结果中断一来就跳到了 Bootloader 的向量表程序行为完全错乱。排查这类问题的思路很简单——在 HardFault 处理函数里打印 SCB 的 VTOR 寄存器和出错时的 PC 值基本就能定位。3. 启动文件逐行拆解从汇编到 C 世界的桥3.1 栈和堆的初始化启动文件最开头通常先定义栈的大小和堆的大小。比如标准库的启动文件里有这么两行Stack_Size EQU 0x00000400 Heap_Size EQU 0x00000200Stack_Size是栈大小默认 1KBHeap_Size是堆大小默认 512 字节。这两个值不是随便定的。栈主要给局部变量、函数调用时的返回地址和寄存器保存用。如果你用了递归、或者某个函数里定义了大数组1KB 很容易爆。栈溢出不会报错它会悄悄覆盖相邻的内存区域症状千奇百怪。我的经验是资源不太紧张的芯片栈至少给 2KB如果用了 RTOS 或者浮点运算给 4KB 以上。堆是给malloc用的。很多嵌入式项目根本不用动态内存分配那Heap_Size设成 0 也没问题链接器不会报错。但如果你用了malloc却没分配堆空间第一次调用就会返回 NULL 或者直接跑飞。定义完大小之后启动文件会开辟对应的段Stack_Mem SPACE Stack_Size __initial_sp__initial_sp这个标号就是栈顶地址。注意栈是向下生长的所以栈顶是这段空间的最高地址。这个值会被链接器放到向量表的第一个位置内核复位时自动加载到 MSP。3.2 复位处理函数的第一批操作复位处理函数Reset_Handler是启动文件的核心。它做的第一件事通常是调用SystemInit这个函数定义在 system_stm32f1xx.c 里负责配置时钟树、设置 Flash 等待周期等。很多人以为时钟初始化是在 main 里做的其实标准库工程里SystemInit在 main 之前就被调用了。Reset_Handler PROC EXPORT Reset_Handler [WEAK] IMPORT __main IMPORT SystemInit LDR R0, SystemInit BLX R0 LDR R0, __main BX R0 ENDP这段代码先加载SystemInit的地址到 R0然后跳过去执行。执行完回来再加载__main的地址跳过去。注意这里跳的是__main不是main。这两个差一个下划线区别很大。3.3 __main 和 main 之间发生了什么__main是 C 库提供的入口不是你的 main 函数。它内部会调用__scatterload之类的函数完成数据段的搬运和 BSS 段的清零然后才调用main。这个过程叫 C 运行时环境初始化。具体来说编译器把已初始化的全局变量和静态变量放在 Flash 里.data 段的加载地址运行时需要把它们复制到 RAM 里的运行地址。未初始化的全局变量和静态变量.bss 段则在 RAM 里需要清零。这些工作都是__main里的代码自动完成的不需要你手动写。但如果你自己写启动代码或者用了一些精简的运行时库这部分可能被裁掉了。我遇到过有人为了减小体积把标准库换成了自己写的启动代码结果全局变量的初始值全是乱的。原因就是他没做 .data 段搬运。所以如果你要自己写启动流程这两件事必须做搬运 .data、清零 .bss。实操心得想验证 .data 搬运是否正常可以定义一个带初值的全局变量在 main 第一行打印它的值。如果打印出来是 0 而不是你写的初值基本可以确定是启动阶段的搬运出了问题。4. 链接脚本与内存布局变量到底住在哪里4.1 Flash 和 RAM 的分工STM32 的存储空间是统一编址的。Flash 通常从 0x08000000 开始RAM 从 0x20000000 开始。链接脚本.ld 文件负责告诉链接器哪些段放 Flash哪些段放 RAM各自的起始地址和大小是多少。一个典型的链接脚本会定义 MEMORY 区域MEMORY { FLASH (rx) : ORIGIN 0x08000000, LENGTH 64K RAM (xrw) : ORIGIN 0x20000000, LENGTH 20K }然后定义各个段放在哪里。.text段放代码和常量放 Flash.data段放已初始化变量加载地址在 Flash运行地址在 RAM.bss段放未初始化变量放 RAM。这里有个细节.data段的加载地址LMA和运行地址VMA不一样。链接脚本里用AT来指定加载地址。启动代码就是根据这两个地址的差值把数据从 Flash 搬到 RAM。4.2 栈和堆在内存中的位置栈和堆都在 RAM 里但它们的生长方向相反。栈从高地址向低地址生长堆从低地址向高地址生长。如果两者相遇就是栈溢出或者堆溢出后果都很严重。在链接脚本里通常把栈放在 RAM 的最末尾堆紧跟在 .bss 段后面。这样栈向下生长、堆向上生长中间留出剩余空间作为缓冲。你可以通过查看编译生成的 .map 文件确认栈顶地址、堆起始地址、以及两者之间还剩多少空间。我习惯在项目初期就检查 .map 文件看看 RAM 用了多少、还剩多少。如果剩余空间不到 20%就要考虑优化或者换更大 RAM 的型号了。这个习惯帮我避免了好几次后期才发现 RAM 不够的尴尬。4.3 常见的内存布局错误最常见的错误是栈溢出。症状包括程序跑着跑着就 HardFault、某个变量莫名其妙被改、函数返回后跳到了奇怪的地址。排查方法是在栈顶和栈底各放一个魔术字定期检查有没有被覆盖。或者用调试器观察 MSP 的值看它有没有超出栈的范围。另一个常见错误是堆和栈冲突。如果你用了malloc分配大块内存同时栈也用得比较深两者可能在中间相遇。这种问题很难复现因为取决于运行时的具体调用深度。我的建议是能用静态分配就不用动态分配如果一定要用malloc把堆大小设得保守一点并且定期检查堆的使用情况。5. 时钟系统初始化SystemInit 里做了什么5.1 默认时钟与用户配置STM32 复位后默认使用内部的 HSI 时钟通常是 8MHz不同系列略有差异。HSI 的精度和稳定性不如外部晶振所以大多数项目都会在SystemInit里切换到 HSE 并配置 PLL把主频拉到更高。SystemInit函数在标准库工程里由 ST 提供但它的具体内容取决于你用的库版本和芯片系列。以 F1 系列为例它主要做三件事使能 HSE、配置 PLL 倍频和分频、切换系统时钟源到 PLL。配置完成后系统时钟从 8MHz 变成 72MHz。但要注意SystemInit里通常不会配置外设时钟比如 GPIO、USART 的时钟。那些是在 main 里通过 RCC_APB2ENR 之类的寄存器使能的。所以如果你在 main 之前就操作了某个外设它的时钟可能还没开操作无效。5.2 时钟配置失败的后果如果 HSE 起振失败比如晶振没焊好、负载电容不对SystemInit里的等待循环会超时然后代码通常会回退到 HSI。但有些版本的SystemInit没有做超时处理会一直卡在等待 HSE 就绪的循环里程序根本进不了 main。症状就是下载完程序没反应调试器也连不上。排查这类问题可以先用内部 HSI 跑一个最简单的点灯程序确认芯片本身没问题。然后再逐步切换到 HSE观察在哪一步卡住。如果 HSE 始终起不来检查晶振的负载电容是否匹配、PCB 走线是否太长、晶振本身是否损坏。注意有些低成本方案会省掉外部晶振直接用 HSI。HSI 的精度在常温下大概 ±1%做串口通信时波特率误差可能偏大。如果波特率要求严格还是建议用外部晶振。6. 从启动到 main 的完整链路复盘6.1 关键节点速查表把整个流程的关键节点整理成一张表方便对照排查阶段执行内容关键寄存器/地址常见问题复位加载 MSP 和复位入口0x00000000, 0x00000004启动模式配置错误向量表存放各异常入口.isr_vector 段VTOR 未设置导致跳转错误Reset_Handler调用 SystemInitR0, BLXSystemInit 卡在时钟等待__main搬运 .data、清零 .bss链接脚本 LMA/VMA搬运代码被裁剪main用户代码入口—外设时钟未使能这张表我建议打印出来贴在工位上遇到启动相关的问题从上往下逐项排查效率比盲目调试高得多。6.2 一个真实的排查案例之前有个项目程序下载后偶尔能跑、偶尔跑不起来。能跑的时候一切正常跑不起来的时候连 main 都进不去。用调试器看发现 PC 停在SystemInit里的一个等待循环里。查了半天最后发现是电源上电斜率太慢HSE 起振时间超过了SystemInit里的超时计数。把超时计数改大之后问题消失。这个案例说明启动流程里的每一个等待循环都可能有超时风险。尤其是时钟相关的等待受硬件影响很大。如果你遇到“有时候能跑有时候不能跑”的问题优先怀疑时钟初始化。6.3 自己动手验证启动流程想真正吃透启动流程光看文章不够得动手。我推荐几个实验第一个实验在启动文件的Reset_Handler里加一句点亮 LED 的汇编代码放在SystemInit之前。你会发现 LED 也能亮说明 GPIO 的复位默认状态就能驱动 LED只要硬件上 LED 接法正确。这个实验帮你理解“main 之前代码也在跑”。第二个实验定义一个带初值的全局变量在 main 第一行打印。然后修改链接脚本故意把 .data 段的加载地址改错观察打印出来的值变成什么。这个实验帮你理解 .data 搬运的重要性。第三个实验把栈大小改成 256 字节然后在 main 里定义一个 512 字节的局部数组观察程序怎么跑飞。这个实验帮你理解栈溢出的危害。做完这三个实验你对启动流程的理解就不再是纸上谈兵了。7. 几个容易忽略的细节7.1 中断向量表的弱定义启动文件里每个中断处理函数都用了[WEAK]修饰意思是“弱定义”。如果你在 C 代码里定义了同名的函数链接器会用你的版本覆盖启动文件里的版本。这个机制很巧妙让你不用改启动文件就能自定义中断处理函数。但要注意如果你拼错了函数名链接器不会报错而是默默使用启动文件里的弱定义版本——通常是一个死循环。症状就是中断来了之后程序卡死。我见过有人把USART1_IRQHandler拼成了USART1_IRQhandler大小写错了一个字母查了一下午。7.2 复位后外设的默认状态复位后大部分外设的时钟是关闭的只有少数几个比如 Flash 接口、电源管理是开着的。GPIO 的默认状态通常是浮空输入但有些引脚有特殊功能比如 JTAG 引脚。如果你用到了这些引脚做普通 IO需要在启动代码或者 main 早期禁用 JTAG 功能。另外复位后所有中断都是关闭的需要手动使能。启动文件里通常有一句CPSID I来关中断但那是为了在初始化过程中不被中断打断。初始化完成后__main或者你的代码会重新开中断。7.3 启动时间的影响因素启动时间从几毫秒到几百毫秒不等取决于几个因素时钟配置的快慢、.data 段的大小、外部晶振的起振时间。如果你做的是低功耗产品启动时间直接影响电池寿命。优化启动时间的方法包括减少 .data 段的大小少用带初值的全局变量、加快 HSE 起振选合适的负载电容、跳过不必要的初始化。但要注意有些初始化不能省。比如 Flash 等待周期如果主频高了但等待周期没配够取指会出错程序行为完全不可预测。这种问题比启动慢严重得多。8. 写在最后把 STM32 的启动流程捋清楚最大的好处不是应付面试而是遇到问题时知道往哪里看。程序跑不起来你能判断是卡在时钟初始化、还是 .data 搬运、还是栈溢出中断不响应你能想到是向量表没设置对、还是中断没使能。这种定位能力比会写多少行代码都值钱。我个人的习惯是每换一个芯片系列都先把它的启动文件和链接脚本过一遍看看和之前用的有什么差异。不同系列的向量表结构、时钟配置、内存映射都可能不一样照搬经验容易翻车。花半个小时看一遍能省下后面几天的调试时间。最后分享一个小技巧如果你怀疑启动阶段有问题但调试器又连不上可以试着把 BOOT0 拉高让芯片从系统存储器启动。这样芯片会运行出厂固化的 Bootloader不跑你的程序。如果能连上调试器说明芯片本身没坏问题出在你的程序或者启动配置上。这个技巧帮我排除过好几次“芯片是不是烧了”的疑虑。