
很多人在学习 STM32 时都会遇到一个困惑代码明明是从main()函数开始写的为什么点下复位按键后程序却能自动跳转到正确的位置为什么有时候中断进不去、程序跑飞查来查去问题出在启动阶段这些问题的背后都指向同一个基础知识点——STM32 的启动过程。这篇文章我会把启动过程完整拆开来讲从硬件上电开始一直到main()函数执行中间每一步做了什么、为什么要做、代码在哪都会逐个解释。内容主要面向正在准备单片机面试的读者以及刚接触 STM32、想深入理解底层机制的开发者。通过这篇文章你会掌握以下内容STM32 的三种启动方式及 BOOT 引脚配置。中断向量表在启动过程中的作用。启动文件startup_stm32f10x_hd.s的关键代码逻辑。从复位到main()的完整调用链。常见启动异常与排查方法。面试中常见的启动过程题目问答思路。文章中会以 STM32F103 系列为例因为这是目前学习、面试、项目中接触最多的型号。其他 Cortex-M3/M4 内核的 STM32 启动逻辑基本一致理解了 F1 也就理解了整个家族的基本框架。1. 什么是 STM32 启动过程很多初学者会把“启动过程”想得很神秘其实它的本质非常朴素处理器在上电或复位后如何找到第一条要执行的指令以及如何把运行环境准备好最终进入用户写的 C 代码。无论 PC 还是单片机都存在这个过程。PC 上有 BIOS/UEFI 引导操作系统STM32 则通过内核自带的固定机制从地址0x00000000处读取初始值完成最基本的初始化。专业一点说STM32 基于 ARM Cortex-M 内核Cortex-M 系列内核在硬件层面规定上电复位后处理器会从向量表中取出栈顶地址和复位向量随后跳转到复位向量指向的代码去执行。这个动作不需要软件参与是芯片硬件自动完成的。STM32 的启动过程主要包括三个阶段硬件初始化设置栈指针、取复位向量、跳转执行。软件初始化执行启动文件中的Reset_Handler完成向量表拷贝、变量初始化、时钟初始化。进入 C 世界调用__main最终进入main()。用一张简化的流程表示上电/复位 │ ▼ 从 0x00000000 读取栈顶地址 → 赋给 MSP │ ▼ 从 0x00000004 读取复位向量 → 得到 Reset_Handler 地址 │ ▼ 跳转执行 Reset_Handler │ ▼ SystemInit()初始化时钟 │ ▼ 进入 __main完成 RW 数据拷贝、ZI 区清零、堆栈初始化 │ ▼ 进入 main()整个链路中启动文件汇编文件是核心后面会专门拆解。2. 动手前的必备概念存储结构与启动方式在分析启动代码之前有两个概念必须先弄清楚存储器映射和启动方式。这两个概念在面试中也很常考而且直接影响你对 BOOT0、BOOT1 引脚的理解。2.1 STM32 的存储器映射STM32F103 的内存空间并不是一块内存用到底而是把 Flash、SRAM、外设寄存器等统一映射到 4GB 的地址空间中。Cortex-M3 内核规定其中一部分地址范围用于特定用途。常见且重要的地址区域如下地址范围作用典型用途0x08000000 ~ 0x0807FFFF片内 Flash存放代码、只读常量、初始化的全局变量副本0x20000000 ~ 0x2000FFFF片内 SRAM存放栈、堆、全局变量、局部变量0x40000000 ~ 0x5FFFFFFF外设寄存器GPIO、USART、SPI、定时器寄存器0xE0000000 ~ 0xE00FFFFFCortex-M3 内部外设NVIC、SysTick、调试组件其中最关键的是 Flash 和 SRAMFlash程序代码存放在这里上电后 CPU 直接从 Flash 取指令执行。SRAM运行时的数据空间栈指针 SP 指向这里的某块地址。很多初学者会问Flash 和 SRAM 都叫“存储器”到底有什么区别简单理解Flash 就像硬盘断电不丢数据但写入慢SRAM 就像内存速度很快但断电数据消失。程序放在 Flash变量运行时放在 SRAM。这一知识点直接关系到启动过程启动文件在做变量初始化时会把 Flash 中的初始值拷贝到 SRAM 中。2.2 三种启动方式STM32F103 通过 BOOT0 和 BOOT1 引脚决定从哪块存储区启动。这个在数据手册中有明确描述下面用表格整理BOOT0BOOT1启动模式说明0X从主 Flash 启动正常运行模式绝大多数应用使用10从系统存储器启动用于串口下载程序ISP11从内置 SRAM 启动调试模式用于 RAM 中调试代码值得注意的是Cortex-M3 内核固定从0x00000000取向量表但 STM32 通过引脚选择把不同存储区域“映射”到了0x00000000这个地址。也就是说从 Flash 启动时地址0x00000000被映射到 Flash 的首地址0x08000000。从系统存储器启动时0x00000000被映射到系统存储器的首地址。从 SRAM 启动时0x00000000被映射到 SRAM 的首地址0x20000000。代码层面的地址不需要变化因为硬件帮你做了地址重映射。这就是为什么你在 Keil 中看到的代码都是从0x08000000开始的但 CPU 上电时仍然能正确启动。2.3 为什么要弄启动方式启动方式不是一个“多余的设计”。它的实际价值在于开发阶段可以通过 BOOT 引脚选择从系统存储器启动用串口下载程序不需要额外的下载器。产品生产批量烧录时可能利用 ISP 方式。调试阶段可以从 SRAM 启动加快调试循环避免频繁擦写 Flash。在实际项目中调试完成后 BOOT0 和 BOOT1 通常都接地选择主 Flash 启动保证上电直接运行用户程序。3. 启动文件到底做了什么项目工程中启动文件通常叫startup_stm32f10x_hd.s。不同型号芯片对应不同启动文件比如startup_stm32f10x_md.s中等容量64KB 到 128KB Flash。startup_stm32f10x_hd.s高容量256KB 到 512KB Flash。startup_stm32f10x_xl.s超高容量768KB 到 1MB Flash。如果是 F4 系列则对应startup_stm32f40xx.s或startup_stm32f429xx.s。启动文件虽然是汇编写的但逻辑并不复杂主要做以下几件事。3.1 定义栈区和堆区栈Stack和堆Heap的大小在启动文件开头定义。比如Stack_Size EQU 0x00000400 AREA STACK, NOINIT, READWRITE, ALIGN3 Stack_Mem SPACE Stack_Size __initial_sp这段汇编的意思是Stack_Size EQU 0x00000400定义栈大小为 1KB。AREA STACK, NOINIT, READWRITE开辟一个名为 STACK 的段不初始化可读可写。SPACE Stack_Size保留 1KB 空间。__initial_sp这个标号指向栈顶后面会放入向量表的第一项。堆的定义方式类似Heap_Size EQU 0x00000200 AREA HEAP, NOINIT, READWRITE, ALIGN3 Heap_Mem SPACE Heap_Size __initial_heap_top堆主要给malloc这类动态内存分配函数使用。在裸机开发中如果没用到大小可以设置得比较小。这里需要注意栈的大小直接关系到程序的局部变量、函数调用嵌套深度、中断嵌套深度。如果栈设置太小程序运行复杂后很容易栈溢出导致 HardFault。实际项目中要根据需求调整不要盲目用默认值也不要随意改大因为 SRAM 总容量有限。3.2 定义中断向量表向量表是启动文件中非常核心的部分Cortex-M3 内核依赖它完成中断分发。它的本质是一个函数指针数组里面按顺序存放各个中断服务函数的地址。起始部分代码经过精简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 ; 硬件错误中断 DCD MemManage_Handler ; 内存管理错误 DCD BusFault_Handler ; 总线错误 DCD UsageFault_Handler ; 使用错误 ; ... 后续是各种外设中断 __Vectors_End如果按地址来看偏移内容0x00初始栈指针__initial_sp0x04复位向量Reset_Handler0x08NMI 异常入口0x0CHardFault 异常入口...其他异常和外设中断这是整个启动流程的第一关键Cortex-M3 复位后硬件固定从地址 0x00000000 读取栈顶地址并从地址 0x00000004 读取复位向量地址然后跳转过去。3.3 编写复位处理函数复位处理函数是启动过程中软件真正开始接管硬件的入口。核心代码如下精简Reset_Handler PROC EXPORT Reset_Handler [WEAK] IMPORT SystemInit IMPORT __main LDR R0, SystemInit BLX R0 LDR R0, __main BX R0 ENDP这五行的逻辑非常清晰加载SystemInit函数的地址到 R0 寄存器。调用BLX R0执行SystemInit。加载__main的地址到 R0 寄存器。调用BX R0跳转到__main。其中SystemInit在system_stm32f10x.c文件中实现主要完成恢复 RCC 寄存器到复位默认值。配置 Flash 预取缓冲区。设置系统时钟比如 PLL 倍频到 72MHz。更新SystemCoreClock全局变量。__main不是用户写的main()而是编译器中 C 库的初始化入口。它做了这些事把 Flash 中的 RW 数据拷贝到 SRAM。把 ZI 数据段清零。调用__rt_entry完成 C 运行环境初始化。最后调用用户的main()。有人会疑惑main()之前居然有这么多步骤为什么不能直接跳进main()因为你的 C 代码里可能有初始值不为 0 的全局变量它们在程序编译后存放在 Flash 中但运行时要放在 SRAM 中如果不拷贝全局变量的初始值就是错的。同理初始值为 0 的全局变量ZI 区如果不先清零运行结果也是未定义的。这些脏活都在__main中完成。3.4 中断服务函数的弱定义启动文件的末尾会为每个中断定义一个弱函数NMI_Handler PROC EXPORT NMI_Handler [WEAK] B . ENDP这里的[WEAK]关键字意思是“弱定义”。如果你在自己的 C 代码里重新写了同名函数链接时以你的函数为准如果没有写就使用启动文件里这个空函数。空函数只有一句B .也就是死循环。这样设计的意义是任何意外触发的中断如果用户没有正确处理程序会停在原地方便排查而不是跳到一个非法地址导致硬件错误。4. 从工程角度看启动链接过程启动文件不是单独存在的它要配合链接脚本分散加载文件才能正确运行。STM32 工程中常见的.sct文件Keil 中自动生成或 GCC 环境下的.ld文件起到这个作用。STM32F103C8T6_FLASH.sct文件典型内容LR_IROM1 0x08000000 0x00010000 { ER_IROM1 0x08000000 0x00010000 { *.o (RESET, First) *(InRoot$$Sections) .ANY (RO) } RW_IRAM1 0x20000000 0x00005000 { .ANY (RW ZI) } }逐行解读LR_IROM1加载域描述程序在 Flash 中的分布起始地址0x08000000大小 64KB0x00010000。ER_IROM1执行域代码运行时也在 Flash 中执行冯诺依曼与哈佛的混合架构下STM32 支持从 Flash 直接取指执行。*.o (RESET, First)链接时把启动文件中 RESET 段放在最开始也就是向量表要放在 Flash 首地址位置。RW_IRAM1运行域RW 数据已初始化全局变量和 ZI 数据未初始化全局变量放在0x20000000起始的 SRAM 中。这个链接顺序很重要。如果向量表不放在 Flash 开头硬件复位后从0x00000000读到的就不是正确的栈顶和复位向量程序必然跑飞。在 Keil 工程中如果你使用的是自定义链接脚本务必保留First这段设置否则会出现下载后程序不运行的诡异现象。5. 完整实战借助调试器验证启动过程下面通过实际调试来验证启动过程每一步是否真的像前面分析的那样。示例平台以常见的 STM32F103C8T6 最小系统板为例开发环境使用 Keil MDK调试器使用 ST-Link。5.1 创建测试工程新建一个标准库工程目录结构如下stm32_startup_demo/ ├── Core/ │ ├── inc/ │ │ ├── main.h │ │ └── stm32f10x_conf.h │ └── src/ │ ├── main.c │ └── stm32f10x_it.c ├── Driver/ │ └── inc/ ├── Library/ │ ├── inc/ │ └── src/ ├── Startup/ │ └── startup_stm32f10x_hd.s └── Project/实际使用 CubeMX 生成也可以原理相同。核心是确认启动文件、系统文件、链接脚本都存在。5.2 编写测试代码写一个最简单的测试程序在main()中设置一个 LED 翻转循环// 文件路径Core/src/main.c #include stm32f10x.h void Delay(void) { volatile uint32_t i; for (i 0; i 1000000; i); } int main(void) { GPIO_InitTypeDef GPIO_InitStructure; /* 使能 GPIOC 时钟 */ RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOC, ENABLE); /* 配置 PC13 为推挽输出 */ GPIO_InitStructure.GPIO_Pin GPIO_Pin_13; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOC, GPIO_InitStructure); while (1) { GPIO_WriteBit(GPIOC, GPIO_Pin_13, Bit_RESET); Delay(); GPIO_WriteBit(GPIOC, GPIO_Pin_13, Bit_SET); Delay(); } }这代码本身并不复杂作用是在 PC13 引脚输出翻转方波对应最小系统板上的 LED 闪烁。5.3 在启动文件设置断点在 Keil 中首先编译工程并进入调试模式。然后在启动文件Reset_Handler处设置断点。具体操作打开startup_stm32f10x_hd.s文件。找到Reset_Handler这行。点击行号列的灰色区域设置断点。点击 “Debug” 按钮进入调试模式。按下复位按钮后程序会停在Reset_Handler处。这时观察 Keil 右下角的寄存器窗口SP栈指针已经指向__initial_sp对应的地址。PC程序计数器停在Reset_Handler所在地址。这直接验证了“硬件自动读取向量表并跳转复位向量”这个过程。5.4 单步跟踪启动代码点击单步执行仔细观察第一步执行LDR R0, SystemInit此时 R0 被加载为 SystemInit 的地址。第二步执行BLX R0跳进 SystemInit 内部。这里可以查看 RCC 相关寄存器变化。从 SystemInit 返回后执行LDR R0, __main。执行BX R0进入__main。单步跟踪到__main内部时看到的都是编译器生成的汇编代码。如果对库函数比较熟悉可以看到最终会跳到main()。5.5 观察内存中的初始化效果为了更直观地理解 RW 数据拷贝可以在 main.c 中定义一个初始值不为 0 的全局变量// 文件路径Core/src/main.c uint32_t g_test_value 0x12345678;编译后你会注意到在 Flash 地址区域这个变量对应位置存了0x12345678。在进入main()之前__main把它拷贝到了 SRAM 中。在main()内部查看变量地址地址位于0x20000000之后的 SRAM 区。如果在main()入口处设置断点然后查看变量的实际地址和值就能确认启动过程完成了数据拷贝。5.6 验证时钟初始化在SystemInit内部设置断点查看 RCC_CR 寄存器复位后 RCC_CR 的值通常是0x00000083表示 HSI内部高速时钟开启。SystemInit 执行完毕后外部 HSE 可能已经启动PLL 锁相环开始工作。系统时钟最终被配置到 72MHz。这一步骤验证了“启动代码负责把默认 8MHz 内部时钟切换到外部 8MHz 晶振并倍频到 72MHz”的过程。6. 常见启动问题排查启动过程涉及硬件、链接脚本、启动文件、系统初始化多个环节任何一个环节出问题程序都跑不起来。下面是实际开发中最高频的问题与排查方法。6.1 程序下载成功但完全没有运行可能原因排查方向BOOT0/BOOT1 配置错误确认 BOOT0 0从主 Flash 启动链接脚本中向量表没有放在最前检查First设置代码量超出发行容量查看编译输出 ROM 使用量复位引脚被外部拉低检查 NRST 引脚电路6.2 一运行就进入 HardFault可能原因排查方向栈指针错误检查启动文件 Stack_Size 是否够用函数指针非法检查函数指针初始化访问了不存在的地址检查外设地址是否正确时钟配置失败检查 SystemInit 中 HSE 外部晶振是否正常中断服务函数未实现启动文件的弱函数是死循环检查中断是否意外触发进入 HardFault 后可以在调试器中查看 Fault 状态寄存器判断是总线错误、内存管理错误还是用法错误再针对性地排查。6.3 有中断但进不去可能原因排查方向NVIC 未配置使能检查 NVIC_Init 配置中断函数名称不匹配启动文件中的弱函数名称必须与中断向量表名称一致中断服务函数被优化掉中断函数不要定义为 static优先级分组配置错误检查 NVIC_PriorityGroupConfig6.4 变量初始化值不对主要原因是启动过程的 RW 数据拷贝或 ZI 区清零没有正确执行。如果使用的链接脚本有问题或者跳过了__main直接调用main()就会出现这种问题。6.5 调试器连不上芯片可能原因芯片进入低功耗模式。调试接口被复用为普通 IO。BOOT 引脚设置导致代码跳转异常。解决方案通常是先拉低 BOOT0 让芯片从系统存储器启动或者按住复位键后点击下载再松开复位键。某些情况下需要先擦除整个 Flash。7. 面试回答思路与常见问题启动过程真的是面试高频考点下面把常见的面试问题整理一下并给出回答要点。7.1 请简述 STM32 的启动过程回答模板STM32 上电后硬件根据 BOOT 引脚选择启动介质。以主 Flash 启动为例Cortex-M3 内核从0x00000000地址读取栈顶地址赋给 MSP然后从0x00000004地址读取复位向量得到Reset_Handler的入口地址并跳转执行。Reset_Handler首先调用SystemInit()完成时钟初始化然后进入编译器的__main由__main完成 RW 数据拷贝、ZI 区清零、堆栈初始化后最终调用用户写的main()。这个回答把“硬件动作”“软件动作”“最终目标”都涵盖了面试官听到这里通常就会认可底层基础。7.2 为什么要启动文件不用启动文件行不行回答要点启动文件负责构建 C 运行环境。C 代码运行的前提是栈已初始化、全局变量已就位。没有启动文件程序即便能从 Flash 开始执行也会因为没有栈指针、没有初始化变量而无法正常工作。理论上可以自己用汇编编写这两段初始化逻辑但启动文件已经把这部分标准化了直接使用更可靠。7.3SystemInit()和main()是什么关系回答要点SystemInit()是在main()之前被启动文件调用的主要完成系统时钟初始化。它不是一个用户必须调用的函数但绝大多数工程都会使用它因为进入main()之前芯片运行速度要处于一个确定状态外设总线频率也要正确。部分低功耗应用会在main()里重新配置时钟但 SystemInit 中的默认配置可以保证刚上电时程序能够稳定运行。7.4 中断向量表为什么要从 Flash 开头放回答要点Cortex-M3 内核规定上电后从0x00000000读取栈顶地址和复位向量而 STM32 默认把 Flash 地址0x08000000重映射到0x00000000。因此 Flash 开头必须是向量表否则处理器第一次取指就取到了错误数据。7.5 Stack 和 Heap 有什么区别回答要点Stack 由编译器自动管理用于函数调用、局部变量、中断现场保存栈空间向低地址增长Heap 由程序员通过 malloc/free 管理向高地址增长裸机开发中如果不用动态内存Heap 可以设置得比较小。二者共用一段 SRAM 区域堆栈溢出会造成程序行为不可预期。7.6 如果向量表需要放到 RAM应该怎么做回答要点Cortex-M3 提供了向量表偏移寄存器 VTOR通过设置SCB-VTOR把向量表重定位到 SRAM 指定地址并将新的向量表拷贝到该地址。这种方式常用于 IAP 在线升级或引导程序场景需要在启动后手动重映射同时注意向量对齐要求Cortex-M3 要求偏移量按 128 字节对齐M4 要求按 256 字节对齐。8. 工程中的最佳实践建议了解了启动过程之后实际开发中有几点建议可以提前收藏。不要随意修改启动文件。标准库和 HAL 库提供的启动文件经过大量验证修改栈大小、堆大小必须经过评估。如果确实要修改建议在副本上修改并保留原文件。合理设置栈大小。裸机小程序默认 1KB 可能够用但当你引入大型协议栈、嵌入式 RTOS、FATFS 文件系统后任务栈是单独分配的系统栈可能变成中断和异常的专用栈此时要格外关注栈空间是否充足。偏保守的做法是先用默认配置跑通功能再根据实际情况调优。外部晶振和时钟配置要匹配。启动代码中的 SystemInit 会按照system_stm32f10x.c中定义的宏来配置 PLL 倍频系数。如果你的板子外部晶振不是 8MHz必须修改HSE_VALUE宏否则串口波特率、定时器定时值全部会偏差。开启 CRC 校验或看门狗前先理解启动时序。独立看门狗一旦开启不能软件关闭启动过程如果耗时较长可能导致看门狗在main()之前就溢出复位。所以看门狗初始化要放在main()中尽早的位置并且喂狗周期要覆盖整个初始化流程。使用 HAL 库时注意HAL_Init()的调用位置。HAL 库工程中main()开头会先调用HAL_Init()它在内部会设置 SysTick、NVIC 分组等。这个函数依赖前面的启动流程提供正确的时钟和栈环境不要在它之前做复杂外设初始化。调试时善用断点和寄存器窗口。当怀疑启动过程出现问题时可以在Reset_Handler、SystemInit、__main处分别设置断点通过断点是否被命中快速二分定位问题在哪个阶段。这种排查效率远高于盯着代码反复看。9. 从启动过程延伸出去的学习路径启动过程并不是一个孤立的知识点它和你后续学习的很多内容都有强关联。如果这次把启动过程理解透了下一步可以顺着这些方向继续深挖中断系统理解向量表以后学习 NVIC、中断优先级、中断服务函数的响应流程会轻松很多。时钟树SystemInit 做的事情本质就是顺着时钟树配置各个时钟源和分频系数这份知识直接决定外设能否正确工作。链接脚本与内存布局深入理解分散加载文件能帮你解决很多工程移植问题。RTOS 移植FreeRTOS 等系统移植时启动文件中关于堆栈的设置、PendSV 和 SysTick 中断向量配置都和启动过程的知识有关。IAP 与 Bootloader实现固件在线升级时必须掌握向量表重映射、Flash 读写、引导跳转等能力这些全都建立在启动过程的基础上。一次把启动过程学扎实后续可以省下大量排查底层问题的时间。面试时讲到启动过程也不只是背答案而是真正理解芯片从上电到用户代码之间的那段“幕后工作”这样的理解深度在写代码、查 bug、做方案时都会体现出价值。