把调试器连上一块刚焊好的STM32F103板子 在全速运行之前先给main()打个断点。这种操作我做过太多次但很长一段时间里我都没认真想过在那行C代码真正执行之前芯片自己究竟已经走过了多少路。这段路用时往往不到一毫秒可一旦某个环节出问题可能表现为LED死活不亮、程序卡死在启动阶段、或者整个系统不停地复位。这篇文章要做的就是把STM32从上电复位到main()之间的完整启动流程拆到寄存器和汇编指令的粒度逐行看明白。无论你是刚入手STM32的开发者还是正在写Bootloader、做低功耗唤醒、想把启动时间压到极致的老手这段被IDE藏起来的过程都值得彻底吃透。1. 上电后的最初几百微妙复位向量和启动模式先定方向1.1 内核从地址0读出的第一笔数据直接决定一切ARM Cortex-M3内核有一个非常“固执”的习惯系统复位后处理器不会像很多人想象的那样“从0x08000000取第一条指令”而是先完成两次固定地址的读取。从地址0x00000000读取一个字写入主栈指针MSP从地址0x00000004读取一个字作为复位向量写入程序计数器PC。也就是说上电后的第一条指令地址来自向量表里的第二个32位字而不是用户程序入口本身。这张向量表是中断向量表它的前两项是固定的栈顶地址和复位函数入口。所以启动文件的第一句必然是__initial_sp紧跟其后的才是Reset_Handler。这是硬件架构决定的跟用哪家IDE、哪套固件库都无关甚至跟你是否使用嵌入式实时操作系统RTOS也无关。我见过不少人把向量表第一项误写成某个函数指针结果一上电程序直接跑飞。对这个机制的敬畏就应该从这一刻开始。1.2 启动模式引脚0地址到底指向哪一块物理存储既然处理器从0x00000000读向量表那么“0地址映射到谁”就成了启动流程的第一个分叉点。STM32F103把决定权交给了BOOT0和BOOT1引脚在复位上升沿采样之后就开始工作BOOT0BOOT1启动存储区物理基址典型用途0任意主闪存存储器0x08000000正常运行用户程序10系统存储器0x1FFF0000ISP串口下载、出厂Bootloader11内嵌SRAM0x20000000调试/临时运行RAM程序当BOOT0接地时主Flash被别名映射到0x00000000此时访问0x00000000和访问0x08000000在芯片内部看到的是同一块物理存储。很多人用调试器查看地址0x00000000的前几个字发现内容跟Flash里的向量表一模一样就是这个原因。这里藏着一个实用技巧如果你发现代码无论如何都无法启动先量一下BOOT0引脚上的电平。我在调试某块自绘板子时曾经花了整整一下午排查启动问题最后发现是BOOT0通过排阻被意外上拉了整块板全走ISP模式。这种低级问题用调试器看寄存器很难看出来只有查硬件才能定位。1.3 冷启动的初始时钟状态从HSI开始的“草台班子”还有一个容易忽略的硬件事实STM32上电后系统时钟先工作在内部高速RC振荡器HSI频率8MHz左右精度一般上而不是外部晶振。PLL还没配置Flash控制器也还没有按高主频设置等待周期。这样的状态只能“凑合着跑”它不足以支撑完整的系统功能但足够让向量表和启动代码正常工作。因此上电初期的时序大致是电源稳定——内部复位电路释放——BOOT引脚采样——内核从0地址取向量——跳转Reset_Handler。在Reset_Handler里第一件正事就是调用系统初始化函数把时钟切到PLL并配好Flash等待周期。这段内容我会在第4章展开这里先明确一个概念启动流程不是从main()开始的甚至不是从Reset_Handler全部代码开始的最底层的部分由硬件用两次固定地址读取就完成了。2. 时钟树和电源域启动初期最容易“翻车”的底料2.1 复位源识别是启动的第一步排查GPIO初始化之前你需要知道芯片是从哪种复位中醒来的。STM32F103的RCC_CSR寄存器专门记录复位原因上电复位、外部NRST引脚复位、看门狗复位、软件复位等。不同复位源对启动流程的影响不同上电复位所有寄存器回到默认值启动流程最完整需要等待晶振稳定。外部复位引脚复位内部逻辑大部分复位但备份域寄存器可能保留。看门狗复位独立看门狗IWDG超时触发程序复位后如果没有及时“喂狗”会再次复位形成“不断重启”现象。软件复位通过NVIC_SystemReset()触发跟外部复位类似。在实际调试中判断复位源是启动流程诊断的第一步。我调试一个低功耗项目时设备每隔几秒重启一次一开始以为是看门狗后来通过读取RCC_CSR确认是上电复位引脚因为电源纹波被反复拉低电源问题跟固件毫无关系。2.2 为什么HSI是“备胎”但启动时离不开它HSI在这个阶段被启用的原因很朴素外部晶振HSE需要上电后起振起振时间受晶体参数、负载电容、PCB走线影响最快也要几百微秒慢的甚至要几毫秒。把它作为启动时钟会让上电流程变得不可控因此芯片设计者选择让HSI先跑等系统初始化代码主动切换。知道了这个背景就能理解SystemInit里的一段经典流程先等待HSE就绪若HSE超时或不可用系统并不一定进入死循环而是继续留在HSI上运行。这时候你若用调试器看到主频只有8MHz别先怀疑PLL配置错了先查外部晶振有没有起振。2.3 Flash预取缓冲与等待周期的连锁反应STM32F1的Flash存储器读取速度跟不上最高主频因此需要根据主频设置等待周期并开启预取缓冲。主频在0到24MHz区间Flash等待周期为0。主频在24到48MHz区间Flash等待周期为1。主频在48到72MHz区间Flash等待周期为2。如果在启动初期就把主频切到72MHz而忘了设置Flash等待周期程序会从Flash读到错误指令呈现出的现象往往是启动后立刻HardFault或者代码随机乱跳。几乎所有ST官方初始化代码都会在切换系统时钟前把FLASH_ACR寄存器写好但如果你是手写启动文件或精简了启动流程这一步就是最容易踩的深坑。我个人的习惯是凡是裁剪启动代码先保留SystemInit里的Flash配置逻辑其他都可以砍这个不能砍。3. 启动文件逐行考古从__initial_sp到Reset_Handler3.1 栈和堆的尺寸声明ARM汇编文件的开头三段打开STM32F103的启动文件比如startup_stm32f103xe.s最前面几行代码定义了栈和堆的大小Stack_Size EQU 0x00000400 ; 栈大小 1KB AREA STACK, NOINIT, READWRITE, ALIGN3 Stack_Mem SPACE Stack_Size __initial_sp Heap_Size EQU 0x00000200 ; 堆大小 512B AREA HEAP, NOINIT, READWRITE, ALIGN3 __heap_base Heap_Mem SPACE Heap_Size __heap_limit这里有个重要的符号__initial_sp它是栈顶地址也就是栈内存区域的最高地址。ARM的栈是向下生长的压栈时栈指针递减__initial_sp位于栈的顶端也就是起始地址加上栈大小后的那个位置。启动文件把__initial_sp导出并把它的地址放到向量表第一项。内核复位后处理器把向量表第一个字加载进MSP栈就“活”了。我在给一块RAM很小的芯片裁剪启动文件时把栈大小从1KB减到256B结果程序一调用稍深一点的函数就死机现象是随机HardFault和变量被莫名其妙改写。检查后发现是栈溢出这种问题在启动阶段隐藏性极强因为刚上电时调用链条不深栈压力看起来不大但一旦进入正常工作中断嵌套加上函数调用256B根本撑不住。后来我定下一个规矩除非RAM极度紧张栈至少给1KB并且用调试器的栈高水位标记观察实际峰值。3.2 向量表的DCD排列一项对一项顺序一个都不能乱启动文件中间部分是最庞大的向量表。每个中断向量占4字节用DCD指令定义AREA RESET, DATA, READONLY EXPORT __Vectors EXPORT __Vectors_End EXPORT __Vectors_Size __Vectors DCD __initial_sp ; 栈顶地址 DCD Reset_Handler ; 复位向量 DCD NMI_Handler ; NMI DCD HardFault_Handler ; Hard Fault DCD MemManage_Handler ; 内存管理 DCD BusFault_Handler ; 总线错误 DCD UsageFault_Handler ; 使用错误 ; 后续依次是各外设中断向量表的顺序由ARM Cortex-M3内核硬件固化外设中断编号也与芯片型号绑定。如果你在移植启动文件时把某一个外设中断的位置调错那这个中断触发后将跳到错误入口。即便是简单的“复制粘贴另一个型号的启动文件”也可能因为中断数量不一致导致后期异常。判断向量表是否完整的一个笨办法查看__Vectors_Size的值。它由链接器计算通常等于中断总数乘以4。若这个数与芯片参考手册中的中断源数量不符就要重新检查启动文件。还有一点启动文件中的每个外设中断处理函数都会用WEAK声明为弱符号意味着它先给一个默认实现你若在C代码里写了同名函数链接器会自动用强符号替换弱符号中断就指向你的函数了。3.3 Reset_Handler的“三连跳”IMPORT之后两次寄存器加载启动文件的核心是Reset_Handler完整代码其实很短Reset_Handler PROC EXPORT Reset_Handler [WEAK] IMPORT SystemInit IMPORT __main LDR R0, SystemInit ; 把SystemInit地址加载到R0 BLX R0 ; 调用SystemInit LDR R0, __main ; 把__main地址加载到R0 BX R0 ; 跳转到__main ENDP这段汇编只有四件事加载SystemInit的地址到R0带返回地跳转调用再加载__main的地址到R0直接跳转过去。SystemInit由C语言实现负责时钟和Flash控制器初始化__main是C库提供的入口负责C运行环境搭建最终会调用你的main()。很多人困惑为什么不直接跳到main()原因很简单C语言程序需要先建立运行环境比如全局变量的初始值、零初始化数据、堆的可用性这些都需要提前准备。__main就是C库负责“支起这个摊子”的角色。后面第4章我会拆解它到底做了什么。顺便提醒一下不同IDE的启动文件可能略有差异但核心逻辑都是“先SystemInit再__main”。3.4 默认中断处理函数为什么启动后意外中断会进死循环启动文件末尾是一大串默认中断处理函数代表实现如下Default_Handler PROC EXPORT Default_Handler [WEAK] B . ENDP这个B .是ARM汇编里“原地跳转”的写法等同于一个无限死循环。也就是说如果一个中断向量没有被你自己实现那么中断一旦触发程序会直接跳进Default_Handler并卡死在那里。这是启动阶段最典型的“死机现场”上电后外设引脚处于浮动状态恰好满足某个外部中断触发条件中断使能后CPU响应中断跳转到默认处理函数进入B .死循环用户看到的现象是程序卡在某处一动不动main()根本没有执行。解决这类问题可以在Reset_Handler最早的位置暂时屏蔽全局中断等SystemInit和关键外设配置完成后再打开也可以给关键中断写好真正的处理函数哪怕里面只清中断标志位也行。4. 被IDE藏起来的SystemInit与__main从HSI到PLL的迁移之路4.1 SystemInit的标准动作HSE起振、PLL配置、时钟切换系统初始化函数SystemInit虽然来自固件库但它做的事情非常明确我按实际顺序拆解一遍设置FLASH_ACR根据主频配置Flash等待周期并开启预取缓冲。使能HSE外部高速晶振向RCC_CR的HSEON位写1。等待HSE就绪循环检查HSERDY标志位超时则放弃。配置PLL在RCC_CFGR中选择PLL时钟源为HSE设置倍频系数典型值是9倍搭配8MHz晶振得到72MHz主频。使能PLL向RCC_CR的PLLON位写1等待PLLRDY标志位置位。切换系统时钟把RCC_CFGR的SW位设为PLL然后确认SWSTS状态。如果HSE超时很多版本的SystemInit并不会进入死循环而是保持HSI运行。这时候如果你在调试器里查看RCC_CFGR会看到系统时钟源仍然是HSI主频只有8MHz。不要急着怀疑PLL倍频系数先检查晶振电路和引脚。4.2 时钟切换失败后的隐藏风险时钟切换失败后系统虽然还在运行但外设配置可能完全错误。比如USART波特率是按72MHz算的而实际主频是8MHz串口数据就会变成乱码。更隐蔽的是定时器时间基准全部偏慢延时函数实际耗时会比预期大好几倍程序看起来“慢吞吞”但又不完全死掉。排查思路是先读RCC_CFGR的SWS状态位确认系统时钟源到底是HSI还是PLL。然后再读RCC_CR的HSERDY标志位确认HSE是否起振。这两个寄存器一眼就能定位时钟问题。我在某次硬件调试中遇到一个奇葩现象用ST-Link连接时一切正常拔掉调试器单独上电就卡死。最终查出来是晶振两端的负载电容焊接不对称导致HSE在无探头电容的情况下起振失败。调试器连接时探头电容改变了振荡条件反而掩盖了问题。这个故事说明启动流程中的每一个等待超时处理都值得认真对待。4.3 __main的幕后操作RW段“搬家”与ZI段“清零”从Reset_Handler跳进__main后接下来是C运行环境的搭建核心工作有两个RW段复制程序中带有初始值的全局变量如int g_x 5;初始值本身存放在Flash的只读区域__main会把这些值从Flash复制到SRAM中的变量地址。这个过程叫“加载域到执行域”的搬运。ZI段清零没有初始值的全局变量如int g_y;它们默认值应为0。链接器把这些变量集中放在一个区域__main负责把这片SRAM全部清零。这两个动作缺一不可。如果跳过RW复制你的全局变量初始值就是乱的如果跳过ZI清零那些“本应是0”的变量可能带着上电时的随机值。这也是为什么我们的程序不能在ARM汇编级直接跳到main()必须经过__main。顺带解释一下微库MicroLIB的差别启用微库后__main被大幅精简很多C库标准功能不再支持但启动速度更快、RAM占用更低。在内存极小的场景下这是个好选择但如果你的代码依赖某些标准库函数就要谨慎使用。4.4 全局变量“提前被用”的坑顺序决定命运SystemInit本身是纯汇编调用之前的C运行环境尚未建立而SystemInit的C代码里如果使用了一个带初始值的全局变量会发生什么事答案是这个变量可能还没被赋值读到的是乱值。举一个典型场景我在某个项目里把“系统时钟频率值”定义成了带初始值的全局变量并在SystemInit内部读取它来计算外设分频系数。结果程序一启动外设配置全乱。原因就是SystemInit执行时__main还没来得及复制RW段那个全局变量还停留在SRAM中不可预测的状态上。正确做法是SystemInit内部尽量只操作寄存器不依赖任何全局变量如果确实需要把值定义成常量或者改在__main之后再读取。这种坑极其隐蔽因为它的触发条件跟编译器优化也有关系断点调试时变量可能碰巧有值而且每次上电的随机值都在变。5. 用.map文件和调试器验证你的芯片到底走了哪条启动路径5.1 .map文件里启动符号应该长什么样链接器生成的.map文件是验证启动流程最直接的工具。在Keil中勾选“Listing”选项卡里的“Map”选项就能生成。打开后搜索Reset_Handler、SystemInit、__initial_sp、__Vectors这些符号你会看到类似的地址信息__Vectors 0x08000000 Data 132 startup_stm32f103xe.o __initial_sp 0x20005000 Data 1 startup_stm32f103xe.o Reset_Handler 0x080001cd Code 28 startup_stm32f103xe.o SystemInit 0x08000208 Code 736 system_stm32f10x.o __main 0x08000241 Code 8 __main.o这组地址能说明很多问题__Vectors位于0x08000000与主Flash起始地址一致__initial_sp是RAM的最高端或你定义的栈顶位置Reset_Handler紧跟向量表之后说明代码没有被额外塞进奇怪的位置。如果发现__Vectors不落在Flash开头那要么是链接脚本错误要么是使用了自定义分散加载文件需要仔细核对。5.2 调试器实测寄存器窗口里看SP和PC的初值连接调试器后在复位状态下打开寄存器窗口你会看到复位后瞬间的寄存器状态SP主栈指针等于__initial_sp的地址也就是向量表第一个字。PC等于Reset_Handler的地址也就是向量表第二个字。这个瞬间非常有意义它直观证明了硬件确实按向量表完成了初始化。你可以在Reset_Handler入口处设置断点然后把这两个寄存器与.map文件里的地址对照。如果对不上说明向量表本身出了问题或者启动文件与链接脚本不匹配。我常用的验证流程是复位后暂停先看SP是否为RAM顶部PC是否落在Flash区域再在SystemInit入口、__main入口各设断点逐级确认程序确实按顺序执行最后在main()第一行设断点全速运行后停下。这样基本能确认整个启动链路通畅。5.3 从反汇编里看Reset_Handler的完整跳转指令有时候C代码和汇编符号还不够直观直接看反汇编窗口更清楚。在调试器里定位Reset_Handler你会看到类似这样的指令流0x080001CD ldr r0, [pc, #0x10] ; 加载SystemInit地址 0x080001D1 blx r0 ; 调用SystemInit 0x080001D3 ldr r0, [pc, #0x0C] ; 加载__main地址 0x080001D5 bx r0 ; 跳转到__main 0x080001D7 nop ; 对齐填充 0x080001D8 ...ARM Cortex-M3是Thumb-2架构BLX跳转时会把返回地址保存到LR寄存器因此SystemInit返回后还能继续往下走BX跳转不保存返回地址这就是为什么__main之后不需要再回到Reset_Handler。理解了指令本身再看这些跳转就会非常通透。5.4 一个更“野”的验证方法临时改阈值变量如果你想确认ZI清零真的发生在main之前可以在main()第一行定义一个带初始值的全局变量并在启动前修改它在调试器里手动把该变量地址上的值改成一个非零数然后全速运行并观察它是否在进入main前被恢复。这个方法很反直觉但能直观验证__main的初始化动作前提是你用的是标准C库而不是微库。对微库而言行为可能不同要视版本而定。6. 启动环节的经典翻车现场与规避方法6.1 中断在SystemInit期间提前触发程序莫名其妙卡死启动阶段的中断是最容易被忽略的“杀手”。我之前遇到过一种现场板上某个GPIO直接接到机械开关开关悬空时引脚电平抖动上电瞬间恰好拉低触发了外部中断。当时程序的中断向量表用的还是Default_Handler于是CPU在SystemInit执行途中跳进了死循环main()永远没机会运行。排查方法说起来也简单在Reset_Handler入口屏蔽全局中断用CPSID i指令等外设、时钟和中断控制器都配置完毕再执行CPSIE i开中断。如果你使用的是标准启动文件可以手动在Reset_Handler开头加一句CPSID i或者在进入main后立即关闭不需要的外设时钟。这就是为什么很多成熟项目的启动代码最早“关中断、配时钟、开中断”顺序非常讲究。6.2 在C库初始化之前用全局变量读回一坨乱值这个问题我前面已经提过但值得单独给出一个典型场景你在SystemInit里这样写extern int g_error_code; int SystemInit(void) { if (g_error_code 0) { // 做一些处理 } }此时g_error_code所在的SRAM还没被__main清零它可能是上电后的随机值也可能被调试器初始化过。严格来说在C运行环境建立之前普通C代码里的全局变量就是不安全的。如果不依赖任何工具链特性最稳妥的规避方法是让SystemInit保持“纯寄存器操作”或者把启动环境搭建逻辑放到__main之后。这也是为什么一些精简系统会从一开始就禁止在早期初始化函数里访问全局状态。6.3 Bootloader跳转App时为什么必须手动写一次MSPIAP在线升级是非常常见的场景而Bootloader跳转App的启动流程和冷启动并不完全一样。跳转之前Bootloader已经在运行RAM里可能还有旧的栈数据App的向量表可能不在0x08000000比如在0x08010000。如果只是简单用函数指针跳转到App的Reset_Handler没有重新设置栈指针App启动后会继承Bootloader残留的SP一旦栈深度超过Bootloader预留的空间直接溢出。正确跳转流程如下typedef void (*pFunction)(void); void jump_to_app(uint32_t app_addr) { uint32_t app_sp *(volatile uint32_t *)app_addr; uint32_t app_pc *(volatile uint32_t *)(app_addr 4); pFunction app_reset (pFunction)app_pc; __set_MSP(app_sp); app_reset(); }这段代码有两个关键点第一从App向量表偏移0处读取新栈顶并写入MSP第二从App向量表偏移4处读取复位函数地址并跳转。别忘了还要关闭全局中断因为Bootloader阶段可能已经使能了一些中断跳过去后App的中断处理可能还没准备好。我在开发一个OTA升级项目时Bootloader跳转后App总在几分钟内偶发死机最后定位为跳转前SysTick中断没有关闭App初始化完成后又开启SysTick两个中断配置打架。加了__disable_irq()并等待App自己配置完成后问题消失。6.4 向量表重定向把App扔到偏移地址后的另一个隐藏开关有些朋友在Bootloader跳转成功后发现App中断响应依然异常。这里的关键是Cortex-M3内核默认只从0x00000000读取向量表如果你的App被链接到了0x08010000那么中断发生后CPU想读向量表去的还是0x00000000也就是Bootloader的向量表。中断响应完全错乱。解决办法是修改向量表偏移寄存器SCB-VTOR把它设置为App的向量表基址SCB-VTOR APP_FLASH_BASE;在STM32F1上向量表偏移量通常需要按64字节对齐。设置之后CPU中断时会去0x08010000找向量表App的中断处理才真正生效。这一步在IAP场景里几乎必做但恰恰是很多人跳转后“App能跑但中断不灵”的根源。顺带一提如果你把程序直接烧写在0x08000000上跑无偏移场景下可以不设VTOR因为默认值就是0。6.5 低功耗唤醒后的“假启动”复位路径和冷启动不完全一样最后讲一个特别容易被误判的场景系统从待机模式唤醒时实际上也会走复位流程但它的路径和真正的冷启动有微妙区别。待机唤醒会引发一次系统复位但部分备份域寄存器和RTC状态会保留。唤醒后的启动流程同样要经过Reset_Handler、SystemInit和__main所以当初用全局变量标志来区分“是冷启动还是唤醒”的办法在很多情况下并不可靠因为__main已经把所有普通全局变量清零了。如果确实要区分我建议使用RCC_CSR中的复位标志位并在main早期把标志读出来保存到一个不被清零的备份寄存器中。这个做法需要芯片支持备份域寄存器STM32F1的备份寄存器可以在待机模式下保持数据。这样就能在启动流程早期拿到“我是谁、我从哪里来”的信息而不受C库清零动作影响。启动流程这块知识平时刻意去看的时候觉得都是理所当然一旦出了问题就要靠对整个链条的完整理解才能快速定位。我的习惯是拿到一块新板子先不看业务代码先花十分钟走一遍启动链路的验证流程——看复位向量、看SP初值、看系统时钟源、看.map符号、确认VTOR指向。这套动作做完基本上能把七成以上的启动类问题提前摁死。剩下的大多就是晶振焊没焊好、复位引脚有没有被拉低、电源纹波够不够稳这类硬件问题了。启动流程背后其实是一条非常清晰、环环相扣的链路希望这篇文章能帮你把它彻底看穿。