STM32 上电后到底先干什么?相信很多写了两三年单片机代码的同学,被面试官问一句“请描述 STM32 的启动过程”,都会卡在那里。这段过程不是简单一句“从 main 开始跑”就能回答的,它涉及硬件复位时序、启动模式引脚配置、启动文件汇编逻辑、中断向量表加载、C 运行时环境初始化、时钟系统切换和分散加载文件等一整条链路。这篇文章把 STM32 启动过程完整拆开,从 Boot 引脚电平一路看到 main 函数里第一条用户代码,再补上启动过程相关的常见面试题和排查手段。
1. 核心知识点地图
| 项目项 | 说明 |
|---|---|
| 适用芯片 | STM32F1/F4 系列为主,也适用大部分 Cortex-M3/M4 内核芯片 |
| 核心脉络 | 上电复位 → 读取向量表 → 设置栈顶 SP → 跳转 Reset_Handler → SystemInit → __main → main |
| 启动文件 | startup_stm32f10x_hd.s 或 startup_stm32f4xx.s |
| 三要素 | 初始 SP、初始 PC、中断向量表 |
| 启动模式 | 由 BOOT0/BOOT1 引脚电平决定 |
| 常见工具 | Keil MDK、STM32CubeIDE、IAR、STM32CubeProgrammer |
| 易错点 | 向量表偏移、堆栈大小设置、BOOT 引脚误配置、SystemInit 未执行 |
| 适用场景 | 嵌入式求职面试、裸机开发、Bootloader/IAP 开发、低功耗唤醒分析 |
2. 为什么面试官必问启动过程
STM32 的启动过程不是单纯考察记忆力,它是判断一个人有没有建立“嵌入式系统整体观”的典型考点。一个长期只调用 HAL 库函数写业务逻辑的人,很难回答清楚下面这些连环问题:
- 芯片上电后从哪里取第一条指令?
- 栈顶地址是谁告诉 CPU 的?
- 为什么不是直接进 main?
- 进入 main 之前,全局变量为什么已经被初始化好了?
- 改变 BOOT 引脚电平为什么能决定程序从哪个地址启动?
这些问题背后对应的是三个嵌入式开发核心能力:看芯片数据手册的能力、看汇编启动文件的能力、理解链接脚本的能力。
启动过程还直接关系到实际开发和调试中的疑难问题:
- 程序下载后不运行,怀疑 BOOT 引脚配置错误。
- 调试器一连接就报
No target connected,怀疑复位时序和向量表异常。 - 自己写了 Bootloader 做 IAP 升级,跳转到 App 后死机,本质就是二次启动过程没有处理对。
- 低功耗唤醒后程序跑飞,可能就是对复位向量和中断向量理解不深。
所以,这不是背一道八股题,而是嵌入式底层的必备知识。
3. 上电之后,STM32 的第一条指令在哪里
要理解 STM32 启动过程,先要确立一个和 51 单片机完全不同的内核模型。
51 单片机上电后 CPU 直接从固定地址 0x0000 开始取指令,程序计数器 PC 被硬件设置为 0x0000,后面就是一锅端顺序执行。
Cortex-M 内核不一样,它的取指规则是:
- CPU 从地址
0x00000000读取初始栈顶地址,写入主堆栈指针 MSP。 - CPU 从地址
0x00000004读取复位向量值,写入程序计数器 PC。 - CPU 从 PC 指向的地址开始取指执行。
这里有个容易搞混的点:0x00000004存放的不是指令,而是复位向量的地址值。CPU 不是跳到 0x00000004 执行,而是把 0x00000004 里的内容当作一个地址,跳转过去。
为了把问题说得更具体,用 STM32F103 系列举例。这款芯片的内部 Flash 起始地址是0x08000000,而 CPU 上电后默认从0x00000000读取向量表。
那问题来了:0x00000000地址上的内容哪里来的?
这里涉及 STM32 的物理映射机制。从系统存储角度看,启动时芯片会依据 BOOT 引脚电平选择存储介质,然后把选中的存储介质映射到0x00000000。也就是说,0x00000000不是一个真实存储设备,而是一个映射窗口。
如果从 Flash 启动,芯片内部把0x08000000镜像到0x00000000,所以:
- 地址
0x00000000的内容等于 Flash 起始内容。 - 地址
0x00000004的内容等于 Flash 偏移 4 字节处的内容。
大多数情况下,这两个地址存放的就是启动文件开头定义的__initial_sp和Reset_Handler。
4. 三种启动模式与 BOOT 引脚
STM32 上电时,BOOT0和BOOT1引脚的电平组合决定了从哪个物理存储介质启动。
| BOOT0 | BOOT1 | 启动介质 | 说明 |
|---|---|---|---|
| 0 | 任意 | 主 Flash | 正常程序启动模式,最常用 |
| 1 | 0 | 系统存储器 | 用于串口 ISP 下载引导(内置 Bootloader) |
| 1 | 1 | 内嵌 SRAM | 调试模式,程序掉电即丢 |
注意,BOOT 引脚对启动模式的影响是在上电复位瞬间被锁存的。在程序运行过程中,即使你强制把 BOOT0 引脚拉高,程序也不会立刻切换启动来源。
这里需要重点强调 Flash 启动和系统存储器启动的本质区别:
- Flash 启动:CPU 执行用户自己烧录到主 Flash 里的程序。
- 系统存储器启动:CPU 执行 STM32 出厂固化在 ROM 区的一段 Bootloader 程序,这段程序的功能是接收串口或 USB 数据,并把用户程序写入主 Flash。这就是 ISP 下载的基础原理。
- SRAM 启动:CPU 直接在 SRAM 里执行程序,适合调试和验证,掉电后程序消失。
在做 Bootloader/IAP 开发时,经常需要处理启动模式切换。常见做法是:Bootloader 运行在 Flash 启动模式下,收到升级包后写入 Flash 的 App 分区,最后通过修改 PC 跳转地址完成“软件启动切换”,而不是强行改变 BOOT 引脚。
5. 启动文件源码级拆解
Keil MDK 新建 STM32F103 工程时,会自动添加一个startup_stm32f10x_hd.s汇编文件。不要把这个文件当成可有可无的模板,它承担了 Cortex-M3 上电后整个启动逻辑。
5.1 栈和堆的定义
启动文件开头一般是这样:
Stack_Size EQU 0x400 AREA STACK, NOINIT, READWRITE, ALIGN=3 Stack_Mem SPACE Stack_Size __initial_sp这段代码定义了一块大小为 0x400(1KB)的栈空间,并把栈顶地址暴露为__initial_sp。这个地址会被放在向量表首位置。
堆的定义类似:
Heap_Size EQU 0x200 AREA HEAP, NOINIT, READWRITE, ALIGN=3 Heap_Mem SPACE Heap_Size栈和堆的大小直接决定程序可用内存。如果局部变量过多、递归层数太深、或者中断嵌套频繁,栈溢出会导致程序跑飞甚至 HardFault。面试常问的“栈溢出怎么排查”,根源就在这里。
5.2 中断向量表
向量表是启动文件最重要的部分:
AREA RESET, DATA, READONLY __Vectors DCD __initial_sp ; 栈顶地址 DCD Reset_Handler ; 复位向量 DCD NMI_Handler DCD HardFault_Handler DCD MemManage_Handler DCD BusFault_Handler DCD UsageFault_Handler ... DCD WWDG_IRQHandler DCD PVD_IRQHandler DCD TAMPER_IRQHandler DCD RTC_IRQHandler DCD FLASH_IRQHandler DCD RCC_IRQHandler ...向量表的第一项是__initial_sp,第二项是Reset_Handler。后面的每一项都对应一个中断服务函数入口地址,顺序必须和芯片手册中中断向量分配表完全一致。这就是为什么不能随意改启动文件中断项顺序的原因。
需要注意DCD不是一条指令,它是一段数据定义伪指令,作用是在当前地址分配 4 字节空间并填入指定值。
5.3 Reset_Handler 做了什么
先看 Cortex-M3 内核的行为:
- 从
0x00000000取出栈顶地址写入 MSP。 - 从
0x00000004取出复位向量值写入 PC。 - CPU 跳转到复位向量指向的地址,也就是进入
Reset_Handler。
Reset_Handler是启动阶段真正干活的函数:
Reset_Handler PROC EXPORT Reset_Handler [WEAK] IMPORT SystemInit IMPORT __main LDR R0, =SystemInit BLX R0 LDR R0, =__main BX R0 ENDP这段汇编完成两件事:
- 调用
SystemInit函数配置系统时钟。 - 跳转到
__main,进入 C 运行环境初始化,最终调用main()。
这里注意:__main不是 main 函数本尊,而是 C 库提供的入口函数,它负责后续一堆初始化工作。
6. 时钟初始化 SystemInit 与启动过程的关系
很多初学者第一次看到SystemInit()时,以为这只是个普通配置函数。实际上,它必须在main之前执行,原因有二:
第一,芯片上电默认使用内部 HSI 振荡器,频率一般为 8MHz(F1 系列)或 16MHz(F4 系列)。如果不切换或者配置 PLL,CPU 直接跑内部高速时钟,外设的波特率、定时器时间基准、PWM 频率全都对不上。
第二,main函数里所有外设初始化代码,都默认前提是系统时钟已经配置到预期频率。如果SystemInit没有正确执行,UART 波特率计算、定时器分频、CAN 位时序全部错乱。
从代码逻辑看,SystemInit做的事情大致是:
- 设置 Flash 等待周期。
- 使能外部高速晶振 HSE。
- 等待 HSE 就绪。
- 配置 PLL 倍频系数。
- 使能 PLL 并等待锁定。
- 切换系统时钟源到 PLL。
- 更新全局变量
SystemCoreClock。
F1 系列中,典型配置是 8MHz HSE 倍频到 72MHz 主频。F4 系列则多一步,需要把主频倍频到 168MHz 甚至 180MHz。
这里要特别提醒一点:如果你用 STM32CubeMX 生成工程,SystemClock_Config()是在main函数里执行的,这是标准库和 HAL 库的一个差异。HAL 库模式下,启动文件同样会调用SystemInit,但时钟树的具体配置延后到 main 里完成。面试时如果说“启动阶段配置时钟”,要补充说明标准库和 HAL 库的差异,回答会更完整。
7. __main 与 C 运行环境初始化
从Reset_Handler跳转进入__main后,代码正式由汇编过渡到 C 库运行时。__main主要完成以下工作:
7.1 加载 RW 段
场景是这样的:可执行文件被烧录到 Flash 里,但程序中的已初始化全局变量如uint8_t g_flag = 1;存放在 Flash 的只读区域。运行后这些变量必须在 SRAM 里读写,所以启动阶段要把这段数据从 Flash 拷贝到 SRAM。这个过程称为 RW 段加载。
7.2 清零 ZI 段
未初始化全局变量如static uint8_t buffer[1024];默认值必须为 0。启动代码会把对应内存区域全部清 0。这就是为什么 C 语言标准规定全局变量默认值为 0 的底层原因,也是启动阶段不需要用户干预但是必须完成的内存准备工作。
7.3 调用析构函数或构造函数相关初始化
在 C 工程里这部分基本没有实质内容。如果使用 C++,__main会调用全局对象的构造函数,然后才进入main。
完成以上工作后,__main通过跳转指令进入main(),用户程序才真正开始执行。
| 段名 | 存储位置 | 运行位置 | 内容 |
|---|---|---|---|
| RO 段 | Flash | Flash | 代码、常量 |
| RW 段 | Flash | SRAM | 已初始化全局变量 |
| ZI 段 | 无初始内容 | SRAM | 未初始化全局变量,运行时清零 |
8. 分散加载文件与启动地址映射
启动过程能正常运行,还离不开链接脚本的帮助。Keil 工程里叫分散加载文件.sct,GCC 工程里叫链接脚本.ld,IAR 里是.icf。
F1 系列在 Keil MDK 下典型的分散加载配置:
LR_IROM1 0x08000000 0x00080000 { ER_IROM1 0x08000000 0x00080000 { *.o (RESET, +First) *(InRoot$$Sections) .ANY (+RO) } RW_IRAM1 0x20000000 0x00010000 { .ANY (+RW +ZI) } }关键点:
- 复位向量被放在
+First位置,保证启动文件生成的向量表在 Flash 起始地址。 - 运行起始地址
0x08000000对应主 Flash 起始地址。 - RAM 起始地址
0x20000000对应 SRAM。 - 如果项目使用 Bootloader,App 的
LR_IROM1起始地址要改为0x08010000或0x08008000等偏移地址,App 向量表也要跟随偏移,否则中断跳转会错乱。
App 侧跳转时需要设置向量表偏移:
#define APP_ADDR 0x08010000 void jump_to_app(void) { uint32_t app_sp = *(volatile uint32_t *)APP_ADDR; uint32_t app_pc = *(volatile uint32_t *)(APP_ADDR + 4); typedef void (*app_func)(void); app_func jump = (app_func)app_pc; // 关闭全局中断,清理外设状态 __disable_irq(); // 设置主栈指针 __set_MSP(app_sp); // 跳转前设置向量表偏移(仅限 Cortex-M3/M4) SCB->VTOR = APP_ADDR; // 跳转 jump(); }这段代码体现了对启动过程的深层理解:跳转 App 本质上是人为构造了一次复位向量加载过程,手动设置 MSP 和 PC。
9. 中断向量表重定向
大多数 STM32 应用不使用 Bootloader,向量表固定放在 Flash 起始地址,无需处理。但一旦涉及 IAP、OTA、二次引导,中断向量表重定向就是必考题。
Cortex-M3/M4 内核提供一个VTOR寄存器,位于系统控制块 SCB 中。通过修改它可以把中断向量表从0x08000000移动到0x08010000。这样,中断发生后 CPU 会从新的向量表位置读取对应的中断服务函数地址。
F1 系列和 F4 系列对VTOR的要求略有不同:
- F4 系列直接写
SCB->VTOR = APP_ADDR即可。 - F1 系列某些芯片和库版本存在对齐限制,需要按 0x100 对齐。
HAL 库中,常用:
SCB->VTOR = APP_ADDR & (uint32_t)0x1FFFFF80;或者用宏封装:
#define FLASH_BASE_ADDR 0x08000000 #define APP_START_ADDR 0x08010000 void set_vector_table_offset(uint32_t addr) { SCB->VTOR = addr & (uint32_t)0x1FFFFF80; }启动向量表偏移设置错误时,最典型的症状是:程序能跳转到 App,但任何中断一触发,CPU 就跳到一个错误地址,最终进入 HardFault。现象表现为串口能打印、GPIO 能翻转,但定时器中断一开就死机。
10. 硬件复位、看门狗复位与软件复位的启动差异
启动过程并不只出现在上电瞬间。一次完整的复位包括多种来源:
| 复位源 | 触发方式 | 启动后的异同 |
|---|---|---|
| 上电复位 POR | 芯片供电上升 | 完整启动流程 |
| 外部复位 NRST | 拉低复位引脚 | 流程同上 |
| 看门狗复位 IWDG/WWDG | 看门狗超时 | 复位标志不同 |
| 软件复位 NVIC_SystemReset | 写 AIRCR 寄存器 | 流程同上 |
| 低功耗唤醒 | Standby/Stop 唤醒 | 从唤醒向量或复位流程进入 |
不同复位源在 RCC 控制寄存器中留下不同的复位标志位,Bootloader 可以通过这些标志判断本次启动原因。比如:
if (__HAL_RCC_GET_FLAG(RCC_FLAG_IWDGRST) != RESET) { // 独立看门狗复位 } __HAL_RCC_CLEAR_RESET_FLAGS();Standby 唤醒则不完全等价于复位,CPU 从唤醒向量恢复执行,RAM 内容保留。这点在低功耗产品设计中很关键。
11. 启动过程的实测验证方法
如果只是想看现象,理解启动过程最直接的方法是仿真调试。
在 Keil MDK 中,把断点打在 Reset_Handler 入口:
- 进入 Debug 模式。
- 打开寄存器窗口,观察 SP 和 PC 初值。
- 单步执行,观察 R0 寄存器值变化。
- 逐步进入 SystemInit、__main、main。
在main入口处暂停时,打开内存窗口:
- 查看
0x08000000的值,应该等于程序栈顶地址。 - 查看
0x08000004的值,应该等于 Reset_Handler 地址。 - 查看 SRAM 中 RW 段和 ZI 段是否正常。
也可以用 ST-Link Utility 或 STM32CubeProgrammer 直接读取 Flash 起始地址内容验证向量表。
调试时重点观察这几项:
| 观察项 | 预期值 |
|---|---|
| SP(MSP) | 等于向量表首地址中的值 |
| PC | 等于 Reset_Handler 地址 |
| 向量表偏移 | 0x08000000 或 Bootloader 配置的 App 地址 |
| 时钟寄存器 | PLL 就绪且为主时钟源 |
| main 是否执行 | 断点能进入 |
12. 启动过程常见问题与排查方法
启动阶段出问题,程序可能完全没有任何现象。这时候与其反复改业务代码,不如按下面表格排查。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 下载后程序没运行 | BOOT0/BOOT1 引脚配置不当 | 测量 BOOT 引脚电平 | BOOT0 接 GND,从 Flash 启动 |
| 调试器连接失败 | 芯片处于睡眠状态、复位电路异常 | 检查 NRST 波形、供电电压 | 手动复位后快速连接 |
| 跳转 App 后 HardFault | 向量表偏移未设置或设置错误 | 查看 VTOR 寄存器 | 设置SCB->VTOR为 App 地址 |
| 程序启动后无中断响应 | 中断使能前未设置向量表偏移 | 检查 SCB->VTOR 值 | Bootloader 跳转前配置 VTOR |
| 全局变量初值不是预设值 | RW 段加载失败 | 检查分散加载文件和启动文件 | 确认启动文件被正确链接 |
| 局部变量多导致栈溢出 | Stack_Size 设置偏小 | 查看栈指针位置 | 增大栈空间,减少递归深度 |
| 时钟配置不对导致外设乱码 | SystemInit 或时钟配置未执行 | 检查系统时钟寄存器 | 确认 SystemInit 被调用,PLL 配置正确 |
| 从 SRAM 启动后程序不工作 | SRAM 掉电丢失,程序未重烧 | 确认启动模式 | 正常烧录到 Flash 后改回 Flash 启动 |
| Keil 工程提示找不到启动文件 | 文件未添加或路径错误 | 检查工程文件列表 | 重新添加 startup 文件 |
13. 面试考点与标准回答思路
下面是几个启动过程最常见的面试串问:
问:STM32 上电后到 main 之间发生了什么?
回答思路分四层:
- 硬件层:上电复位后 CPU 从 0x00000000 取栈顶地址,从 0x00000004 取复位向量。
- 启动文件层:跳入 Reset_Handler。
- 时钟层:调用 SystemInit 配置时钟,或者等待上层配置。
- C 运行环境层:跳入 __main,拷贝 RW 段、清零 ZI 段,最后进入 main。
问:为什么启动文件要定义中断向量表?
回答:Cortex-M 内核处理中断时,要把中断号映射为向量表偏移,再从表中取出服务函数地址。没有向量表,中断机制无从谈起。
问:全局变量初始化的值是谁赋的?
回答:不是 C 编译器在编译期直接写入 SRAM,而是启动代码在进入 main 之前把 Flash 中的 RW 段数据拷贝到 SRAM。如果启动文件缺失,全局变量初始化会失败。
问:从 Bootloader 跳转 App,应该注意什么?
回答:必答三点:
- 关闭或重新初始化全局中断。
- 设置新的 MSP。
- 设置 SCB->VTOR 偏移到 App 向量表地址。
问:能不能不修改 VTOR 也完成跳转?
回答:可以,把新建工程时 App 的向量表放置在固定地址,配合链接脚本和编译选项实现。但这种做法不适合所有芯片系列,也不推荐在带中断的工程中使用。
问:栈大小设多少合适?
回答:没有标准答案。要结合最大局部变量、函数嵌套深度、中断嵌套层数和 RTOS 任务栈综合评估。可以用栈填充法实测最大占用。
14. 从启动过程看嵌入式学习路径
启动过程不是一个孤立知识点。把它理解透彻后,像链接脚本、内存布局、中断优先级、看门狗复位、IAP 跳转、RTOS 任务切换这些内容都会串联起来。
建议的学习路径是:
- 先精读对应芯片的启动文件,逐行注释,弄清楚每段汇编的作用。
- 打开调试器,在 Reset_Handler 和 __main 各下断点,看汇编单步执行。
- 阅读分散加载文件
.sct,理解代码段、数据段的存放位置。 - 写一个简单的 Bootloader,实现 Flash 启动跳转 App,亲手验证 VTOR 的作用。
- 用压栈法检测任务栈和系统栈使用情况,加深对栈空间分配的认识。
对面试来说,能结合自己的实践经历说出“我在做 IAP 升级时,因为忘记设置 VTOR 导致跳转后中断死机,后来通过检查 SCB->VTOR 发现问题”这种回答,远比背题库更有说服力。
15. 总结
从 BOOT 引脚电平到main里的第一条用户代码,STM32 的启动过程是一条完整清晰的链路:
- 引脚决定启动介质。
- 向量表决定初始 SP 和 PC。
- Reset_Handler 完成时钟和 C 环境初始化。
__main准备好内存和运行环境。- 最终进入
main,用户代码才开始执行。
面试要能讲清楚这条链路,开发时要知道怎么查问题,做 Bootloader 时要知道怎么处理二次跳转。建议收藏备用,并在 Keil 中把startup_stm32f10x_hd.s打开,逐行对照这篇文章梳理一遍。真正动手调一次启动流程,比背十遍面试题都管用。