
这次我们直接进入正题看 flipperzo 这个嵌入式 STM32 实战教程系列里的第 06 个阶段代码开发中的启动流程。很多初学者在 STM32 上写程序打开 Keil 点一下 Download板子跑起来就以为完事了但如果你不知道芯片上电之后 CPU 到底先执行哪条指令、堆栈指针从哪来、SystemInit 在进 main 之前做了什么后面遇到 HardFault、程序跑飞、时钟配置错误这类问题时会非常被动。这一课就是把 STM32 的启动流程彻底拆开从复位向量到 main 函数的第一行代码一条链路讲清楚并且直接在 flipperzo 项目里做代码验证。flipperzo 这个项目不是给你一套“能跑就行”的 Demo而是按真实嵌入式项目的开发节奏来组织的实战教学。它围绕 STM32 硬件平台展开每一节都对应一个具体的开发阶段第 06 节正好落在“代码开发”这个环节。也就是说前面你可能已经完成了原理图阅读、开发环境搭建、芯片选型甚至画好了最小系统板现在到了真正开始写第一行工程代码的时候。而启动流程就是嵌入式 C 项目进入 main 之前的那段“隐形代码”它决定了你的程序能不能从复位状态稳定运行到应用逻辑。本文会带读者完成四件事理解 STM32 复位后的完整执行路径读懂启动文件 startup 和链接脚本的关键段掌握 SystemInit 与 __main 在进入 main 前做了什么最后通过 LED、串口和调试器三种手段验证启动流程是否走通。内容偏基础但信息密度高适合正在学 STM32 嵌入式开发、准备从“抄代码”过渡到“理解代码”的读者。1. flipperzo 核心能力速览先把项目对应的这一节内容做一个速览方便你判断它和自己当前水平的匹配度。以下能力描述针对 flipperzo 系列中“代码开发-启动流程”这一部分。项目能力项说明项目定位基于 STM32 的嵌入式实战教学项目系列化推进硬件与代码开发本节主题STM32 上电复位到 main 入口的完整启动链路核心知识点向量表、复位处理函数、SystemInit、C 运行时初始化、堆栈配置硬件平台以 STM32F1 系列为主流思路同样适用于 F4 / F0 / L4 等 Cortex-M 内核芯片开发工具Keil MDK、STM32CubeMX、FlyMCU、ST-LINK / DAP-Link / J-Link运行依赖需要一块可烧录的 STM32 最小系统板外接 LED 或串口用于验证启动方式调试器在线烧录支持硬件断点与寄存器观察是否支持 API不涉及服务器接口属于裸机底层开发范畴是否支持批量任务无批量任务概念但启动配置是批量生成固件工程的基础适合人群STM32 入门进阶、嵌入式应届生、从 Arduino/STM32CubeMX 自动生成工程转向手写工程的开发者从表格可以看出来这一节不解决应用层功能它解决的是“程序能不能正常启动”这个最底层的问题。你在 flipperzo 后续任何外设开发中遇到跑飞、卡死、复位异常最终都要回到这一节排查。2. 为什么要单独学启动流程STM32 这类 Cortex-M 内核芯片本质上是一个“顺序执行指令的机器”。它从 Flash 里取指令按地址递增或者跳转的方式执行代码。但这个“顺序”的起点在哪里答案是从复位向量开始的。如果你只用 STM32CubeMX 生成工程IDE 会帮你把启动文件和系统初始化代码自动配好你打开工程直接写 main 里的业务逻辑就行。这种方式前期效率高但有一个明显问题当你的工程从“能亮灯”变成“驱动屏幕、读取传感器、跑 RTOS”时一旦程序启动异常你根本不知道从哪里查起。因为你没看过启动时发生了什么。flipperzo 这个阶段故意把启动流程单列成一课目的就是逼你从“使用者”变成“开发者”。你要能回答下面这些问题芯片上电后CPU 从哪个地址取第一条指令栈顶地址 _estack 是怎么确定的Reset_Handler 为什么要在 main 之前调用 SystemInit__main 和 main 有什么区别C 语言的全局变量、未初始化变量在启动阶段是谁负责搬运和清零的BOOT0、BOOT1 引脚如何影响启动介质这些问题任何一个都可能在实战中影响你的调试效率。特别是当你从 F103 换到 F407启动文件名从 startup_stm32f10x_hd.s 变成 startup_stm32f407xx.s如果不知道这两个文件之间的关系就很容易出现“编译通过、烧录成功、但是程序跑飞”的现象。3. 启动流程完整链路3.1 从一次上电开始我们以最常用的 STM32F103C8T6 为例。芯片上电复位后硬件会自动从地址 0x00000000 和 0x00000004 处读取两个 32 位数据0x00000000 处的数据初始栈指针 SP0x00000004 处的数据复位向量即 Reset_Handler 的地址Cortex-M3 内核的架构规定0x00000000 存放的是 MSP 初始值0x00000004 存放的是复位向量。硬件复位后内核把读到的第一个 32 位数据装载到 SP把第二个 32 位数据装载到 PC然后开始执行指令。这个设计绕过了 X86 架构里“从固定地址取第一条指令”的限定而是通过向量表间接跳转方便芯片厂商做存储映射。有一点要注意虽然你看到的启动文件写在 0x08000000 开始的 Flash 区域但芯片内部会自动把它映射到 0x00000000 地址空间。也就是说从 CPU 的角度看它就是从 0x00000000 启动的。BOOT0 和 BOOT1 引脚决定的是“映射源”而不是“启动地址”。BOOT0BOOT1启动介质作用0任意用户 Flash正常运行的启动方式10系统存储器进入内置 Bootloader常用于串口下载11内置 SRAM调试用掉电丢失3.2 Reset_Handler 做了什么复位向量指向的 Reset_Handler 是汇编代码它负责进入 main 前的必要准备。在标准启动文件 startup_stm32f10x_hd.s 中Reset_Handler 的核心逻辑是这样的简化示意AREA RESET, CODE, READONLY EXPORT Reset_Handler [WEAK] IMPORT SystemInit IMPORT __main Reset_Handler ; 1. 设置栈指针。正常情况下启动文件已经做了这里用于保险 LDR R0, _estack MOV SP, R0 ; 2. 初始化时钟与 Flash 等待周期 LDR R0, SystemInit BLX R0 ; 3. 进入 C 运行时初始化之后会调用 main LDR R0, __main BX R0 ENDP如果你用的是 GCC 工具链启动文件的写法略有不同但逻辑一致.section .text.Reset_Handler .weak Reset_Handler .type Reset_Handler, %function Reset_Handler: ldr sp, _estack bl SystemInit bl __main关键点有三个。第一先设置栈指针因为调用 SystemInit 本身也要用栈。第二调用 SystemInit 配置时钟树这步不执行后面的外设时钟可能会出现不可预料的时序问题。第三跳转到 __main而不是直接跳 main。__main 是 C 库提供的入口它完成 RW 段拷贝、ZI 段清零、堆栈初始化后才真正调用我们的 main 函数。3.3 SystemInit 与时钟树在标准外设库和 HAL 库的工程里SystemInit 定义在 system_stm32f10x.c 中它负责三件事开启外部高速晶振 HSE或者直接使用内部高速时钟 HSI。配置 PLL 锁相环把时钟倍频到目标频率比如 F103 的 72MHz。配置 Flash 等待周期和总线时钟分频。代码骨架像是这样void SystemInit (void) { /* 复位 RCC 时钟配置 */ RCC-CR (uint32_t)0x00000081; /* 配置时钟HSE 作为 PLL 输入倍频到 72MHz */ RCC-CFGR (uint32_t)0x0F013142; /* 配置 Flash 等待周期 */ FLASH-ACR (uint32_t)0x00000032; }在很多工程里SystemInit 会被#ifdef包裹或者交由SystemClock_Config函数在 main 里再次修改。但无论如何启动阶段它一定要被调用一次否则芯片会按复位默认的 HSI 8MHz 运行外设如果按照 72MHz 去配置串口波特率、定时器定时周期就会全部偏掉。3.4 __main 与 C 运行时初始化你可能会问为什么启动文件不直接调 main因为 C 语言程序在 main 执行之前有大量的环境初始化工作。__main 是 C 库函数它内部完成以下工作从加载域把 RW 数据段初始值不为 0 的全局变量拷贝到运行域。把 ZI 段初始值为 0 的全局变量、未显式初始化的变量清零。设置堆栈。调用 __rt_entry最终进入 main。这个阶段对嵌入式开发非常重要。如果你写过uint8_t flag 1;这种语句这个flag的初值 1 就是在 __main 的拷贝阶段写进去的。如果你在调试时看到某个全局变量值不对首先要怀疑是不是启动文件里的分散加载配置和实际硬件不符而不是程序逻辑问题。在 ARM Compiler 环境下__main是一个编译器内置符号你在源码里不需要也不应该自行实现它。在 GCC 环境下对应的入口是_start最终通过__libc_init_array和main完成类似工作。flipperzo 项目如果使用 Keil默认就是 ARM Compiler 的启动链路。4. 启动文件与链接脚本解读4.1 向量表结构启动文件的第一部分是一张向量表。向量表本质上是一个函数指针数组按中断号排列。第 0 项是初始栈指针第 1 项是 Reset_Handler后面依次是 NMI、HardFault、MemManage、BusFault、UsageFault再到各种外设中断。F103 高密度芯片的向量表开头是这样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 ; ... 后续外设中断__initial_sp就是栈顶地址它由链接脚本指定下面会展开。4.2 链接脚本与栈顶地址在 Keil 工程中栈顶地址由启动文件的Stack_Size和Heap_Size定义默认情况下栈从 RAM 顶端向下生长Stack_Size EQU 0x00000400 AREA STACK, NOINIT, READWRITE, ALIGN3 Stack_Mem SPACE Stack_Size __initial_sp如果你使用 GCC 链接脚本栈顶地址由.ld脚本显式指定_estack ORIGIN(RAM) LENGTH(RAM); MEMORY { FLASH (rx) : ORIGIN 0x08000000, LENGTH 64K RAM (rwx) : ORIGIN 0x20000000, LENGTH 20K }F103C8T6 的 RAM 是 20KB所以_estack就是0x20000000 0x5000 0x20005000。栈顶紧接着内核指针向下增长。调试时如果发现 SP 不是这个值说明启动文件或链接脚本没有正确参与链接。4.3 为什么要区分 MD、HD、CLSTM32F1 的启动文件有 startup_stm32f10x_md.s、startup_stm32f10x_hd.s、startup_stm32f10x_cl.s 等版本。它们的区别不只是名字而是中断向量表条目数不同。低密度、中密度、高密度芯片的中断源数量不一样如果你把 HD 的启动文件用在 MD 芯片上部分中断向量会指向错误的处理器地址。编译阶段不一定报错运行到对应中断时会异常。flipperzo 这类实战项目如果选型是 F103C8T6就对应startup_stm32f10x_md.s如果选型是 F103ZET6就要用startup_stm32f10x_hd.s。选错启动文件的坑很多开发者踩过一次就忘不掉。5. 开发环境与工程准备5.1 工具链清单本节代码建议在以下环境之一中验证环境适用工程调试方式Keil MDK 5.x STM32CubeMX标准外设库或 HAL 库工程ST-LINK / DAP-Link 在线调试arm-none-eabi-gcc OpenOCD VS CodeGCC 工具链工程OpenOCD GDBSTM32CubeIDEHAL 库工程内置调试器支持如果你已经安装了 Keil并且安装了 STM32F1 的器件支持包Keil.STM32F1xx_DFP可以直接在工程中加入startup_stm32f10x_md.s再把system_stm32f10x.c添加到源码目录。具体工程创建方式可以使用 STM32CubeMX 生成基础工程然后手动替换启动文件和系统文件观察替换前后的行为差异。5.2 最小验证电路启动流程验证不需要复杂外设但你需要保证最小系统能正常工作。建议准备STM32F103C8T6 最小系统板一块确认 BOOT0 接地BOOT1 随意。一个 LED接在某个 GPIO 上建议串一个 330 欧姆电阻。一个串口转 USB 模块用于 printf 输出调试信息。ST-LINK 或 DAP-Link 调试器用于烧录和断点调试。如果手上只有核心板大部分核心板已经板载了 LED 和 USB 转串口可以直接用。务必确认一点3.3V 供电是否稳定。启动阶段如果供电不足芯片可能反复复位或者进入奇怪的异常状态。这是最容易被忽略的硬件问题。5.3 创建一个最小 Keil 工程创建一个最小测试工程的通用流程如下新建工程目录规划Core、Startup、User三个子目录。用 STM32CubeMX 生成一个基础工程选择芯片型号配置 RCC 为外部晶振。配置一个 GPIO 为输出模式用于 LED 验证。配置一个串口比如 USART1用于打印信息。生成代码后打开 Keil 工程检查启动文件是否被正确加入。如果你选择纯手写工程也可以参考下面的结构flipperzo_startup_demo ├── Core │ └── main.c ├── Startup │ └── startup_stm32f10x_md.s ├── System │ └── system_stm32f10x.c └── Makefile6. 实战验证让启动流程可见启动流程是“隐形”的但可以通过三种手段把它“拖到明面上”来验证调试器、LED、串口。6.1 用调试器观察 PC 与 SP烧录后进入在线调试模式不要急着运行先复位一次。这时查看内核寄存器SP 应该等于0x20005000对于 20KB RAM 的 F103C8T6。PC 应该指向 Reset_Handler 附近的地址。单步几次执行观察 PC 是否依次经过 SystemInit、__main、main。如果你使用的调试器支持 Keil 的周期精准调试直接单步即可。更快的做法是在SystemInit、main入口处各打一个硬件断点然后全速运行看断点是否能按顺序命中。如果 main 入口断点命中说明启动链路是通的。6.2 LED 状态作为启动标志在 main 的第一行代码里点亮一个 LEDint main(void) { /* 使能 GPIOA 时钟F103 的 PA1 输出 */ RCC-APB2ENR | RCC_APB2ENR_IOPAEN; GPIOA-CRL ~(GPIO_CRL_CNF1 | GPIO_CRL_MODE1); GPIOA-CRL | GPIO_CRL_MODE1_1; /* 推挽输出2MHz */ GPIOA-BSRR GPIO_BSRR_BS1; /* PA1 输出高电平 */ while (1) { /* 循环等待 */ } }如果你看到 LED 在烧录后立即点亮说明程序确实完整地走完了启动流程。如果 LED 没亮按优先级依次排查时钟是否使能、GPIO 模式配置是否正确、芯片是否停留在复位状态、启动文件是否参与链接。6.3 串口打印启动阶段信息串口输出是嵌入式开发里最实用的调试手段之一。在 main 的开始阶段初始化 USART1然后把 printf 重定向到串口#include stdio.h int fputc(int ch, FILE *f) { /* 等待发送寄存器为空 */ while ((USART1-SR USART_SR_TXE) 0) { } USART1-DR ch; return ch; } void USART1_Init(void) { RCC-APB2ENR | RCC_APB2ENR_USART1EN | RCC_APB2ENR_IOPAEN; /* PA9 TX, PA10 RX复用推挽输出 */ GPIOA-CRH ~(GPIO_CRH_CNF9 | GPIO_CRH_MODE9); GPIOA-CRH | GPIO_CRH_CNF9_1 | GPIO_CRH_MODE9_1; USART1-BRR 0x4E2; /* 72MHz 下对应 9600 波特率具体值按实际时钟计算 */ USART1-CR1 USART_CR1_UE | USART_CR1_TE; } int main(void) { USART1_Init(); printf(flipperzo startup demo\r\n); printf(SystemInit - __main - main OK\r\n); while (1) { } }打开串口助手波特率设置为 9600复位开发板如果能正常打印出两行信息说明从复位到 main 的整条链路没有断。7. 启动流程中的资源占用观察启动流程虽然是一段“一次性代码”但它决定了固件的资源布局。通过编译器生成的 map 文件可以观察三类信息Flash 占用Reset_Handler、向量表、SystemInit 数据被放在 Flash 的起始区域。RAM 占用栈空间和堆空间在启动阶段静态占用不能让用户代码的局部变量超出栈范围。中断向量表位置向量表默认起始地址是0x08000000如果使用 Bootloader App 的结构App 的向量表需要通过SCB-VTOR重映射后面在做 OTA 时会用到。在 Keil 中编译后点击Browse Information可以生成.map先看Execution Region段落重点检查 RW 段、ZI 段和 STACK 段的位置。如果发现_initial_sp的值和硬件 RAM 顶端不匹配链接脚本一定有问题。一个小建议启动流程里不要放置大型全局数组初始化因为 ZI 段清零是在进入 main 之前执行数组越大启动时间越长复位后到 main 执行的“黑屏时间”就越久。如果产品有低功耗需求还要注意启动阶段外设默认上电状态这里不做展开。8. 常见问题与排查方法以下问题是我在 STM32 调试高频遇到的情况整理成清单按“现象-原因-排查-解决”顺序看。问题现象可能原因排查方式解决方案烧录成功但程序不运行LED 不亮BOOT0 被拉高进入系统存储器万用表量 BOOT0 引脚电平BOOT0 接地后重新复位程序运行到 HardFault_Handler向量表选错型号或跳转到非法地址查看 PC 值反汇编确认位置替换正确的 startup 文件检查函数指针单步执行时 SP 不是 RAM 顶端启动文件或链接脚本未参与链接查看 linker map 中 _estack 值确保 startup 文件和 .ld 配置正确main 中全局变量初值不对__main 的 RW 段拷贝失败或 ZI 段未清零查看 map 文件烧录域运行域地址检查分散加载文件修改时钟配置后串口乱码SystemInit 没有按预期配置 PLL查看 RCC_CR 寄存器的 PLLRDY 位确认外部晶振型号检查 SystemInit 配置在线调试时断点停在 Reset_Handler 无法继续调试器复位类型设置问题切换复位类型或者手动设置 PC 到 main使用硬件复位避免软件复位VSCode 打开工程报找不到 .h 文件include 路径未配置检查 c_cpp_properties.json 或 Makefile INCLUDE 变量添加对应头文件搜索路径程序在启动阶段反复复位看门狗提前使能或供电不稳示波器观察 VDD检查 IWDG/WWDG 配置先禁用看门狗排除硬件供电问题进入 Bootloader 而不是 AppBOOT 引脚组合错误或 Flash 地址写错检查 Boot0/Boot1查看烧录地址设置 Boot0 为 0重新烧录 0x080000009. 最佳实践与后续规划把启动流程吃透之后建议在 flipperzo 项目中做三件固化工作。第一把工程里的启动文件固定成模板。把一个能正常启动的 Keil 工程打包保存后续任何新外设模块都基于这个模板开发不再从零创建。模板里要包含正确的启动文件、SystemInit 配置、调试下载设置、串口重定向函数。第二写一个启动自检函数。在 main 入口做基本硬件自检比如检查 RAM 区域的读写是否正确、SysTick 是否按预期计数、串口 TX 是否正常。这样每次上电至少能用 LED 状态和串口信息快速判断“底板是否正常”避免把定位时间浪费在“是程序问题还是硬件问题”的判断上。第三深入理解向量表重映射。等你接触到 IAP 在线升级、Bootloader 开发时会用到SCB-VTOR APP_ADDRESS;这行代码。它的本质就是告诉 CPU“新的中断向量表在这里”而这个逻辑背后的基础正是启动流程这一课里讲的向量表机制。下一阶段的实战规划建议沿着“系统时钟精调 - GPIO 外设框架 - 中断与事件驱动 - 状态机设计”这个顺序推进。启动流程只是嵌入式工程的地基但地基稳了后面写外设驱动、调传感器、接通信协议时才会少踩很多坑。建议先把今天讲的最小验证流程在你的开发板上跑一遍遇到启动异常不要慌按表格里的排查顺序逐步定位能够自己解决一次启动问题比多写一百行业务代码更有价值。