编写者:LCKFB-YZH
版本:v1.0
日期:2026-11-06
更新记录:
- v1.0(2026-11-06):初始版本。
阅读说明:本文由笔者借助 AI 辅助编写。强烈建议大家在学习时也多问问AI。
一 本章简介
写过几个 STM32 程序以后,很多人心里会默认程序是从 main() 开始跑的。那按下复位键以后,CPU 执行的第一条指令,是不是 main() 里的 HAL_Init()?
不是。复位刚结束时,CPU 连栈放在哪里都不知道;uint32_t SystemCoreClock = 16000000; 这样的全局变量,在 RAM 里也还是一个不确定的值。C 语言里我们觉得理所当然的东西,都要靠 main() 之前的一段代码一项一项准备出来。
【13】基础HAL库文件梳理 的第五部分给过一条路线:Reset_Handler → SystemInit() → __main → main(),当时只看了个轮廓。这一章继续用第 11 章的点灯工程,把中间这一段拆开来看:
(概念示意图。图中的 0x20020000 是 GNU 工具链模板常见的栈顶位置,本章 Keil 工程实际是 0x20000678,原因见 2.4 节;SystemInit() 在本工程里只打开 FPU 访问权限,见 3.2 节。)
【阅读本章前,一定要已经能在 Keil 里编译、下载第 11 章的工程,并且读过第 13 章,不要打没准备的仗】
1.1 本章使用的工程
| 项目 | 本章条件 |
|---|---|
| 板卡 | 天空星 STM32F407VET6 核心板 + 筑基学习板 |
| 工程 | /14-BASIC-HOW-THE-SYSTEM-STARTS/fdb_hal_led,直接使用第十一章的工程,只在 main.c 里加了 2.1.5 节的复位原因记录代码 |
| IDE 与编译器 | µVision V5.38,Arm Compiler 5(ArmCC V5.06 update 7) |
| 生成工具与驱动 | CubeMX 6.14.0,STM32Cube FW_F4 V1.28.3,HAL V1.8.5,CMSIS-Device V2.6.11 |
| 器件支持包 | Keil.STM32F4xx_DFP 3.1.1 |
文中出现的地址都取自这个工程加入复位记录代码后的一次 Rebuild(0 错误):fdb_hal_led.hex、fdb_hal_led.axf 和 Keil 自动生成的 scatter 文件 fdb_hal_led.sct(见 4.2.2)在 MDK-ARM/fdb_hal_led/ 下,fdb_hal_led.map 在 MDK-ARM/DebugConfig/ 下(原因见 4.6.1)。这几个文件第一次出现时都会简单说明,详细内容在第四部分。你的工程只要改过一行代码、换了编译器版本或优化等级,地址就大概率不一样,这很正常。要学会去自己工程的 Map 里查。
1.2 学习目标
| 序号 | 学完能做到 | 重要程度 |
|---|---|---|
| 1 | 说出 STM32F407 有哪几种复位,知道给板子上电启动本身就会有一次复位 | ⭐⭐⭐⭐⭐ |
| 2 | 从 HEX 文件或 Memory 窗口读出向量表前两项,说出它们分别变成了 MSP 和 PC | ⭐⭐⭐⭐⭐ |
| 3 | 说清 Reset_Handler、SystemInit()、__main、main() 的先后顺序,以及每一步解决了什么问题 | ⭐⭐⭐⭐⭐ |
| 4 | 读懂 Keil 的 scatter 文件和 Map,找到一个全局变量的运行地址和初值存放地址 | ⭐⭐⭐⭐⭐ |
| 5 | 在 µVision 里关闭 Run to main(),从复位入口单步观察初值复制和清零 | ⭐⭐⭐⭐ |
| 6 | 程序进不了 main(),或者刚进去就出问题时,能判断故障出在启动链路的哪一层 | ⭐⭐⭐⭐ |
第八部分“工程进阶”是选读,包括 GNU 工具链对照、Bootloader 跳转、跨复位保存诊断信息、栈预算等内容。第一次学可以跳过,以后用到时再回来翻。
1.3 先记住几件事
- 上电本身就会有一次复位。芯片每次通电,都会先被内部的上电复位电路按住,电压稳定后才放开,所以“上电启动复位”和“按复位键复位”走的是同一条启动路径(靠控制复位引脚)。
- 复位后 CPU 并不认识 C 语言,它只按固定规则读向量表:第 0 项装进主栈指针(Main Stack Pointer,MSP),第 1 项装进程序计数器(Program Counter,PC),然后从 PC 指向的地方开始执行。
main()是在 C 运行环境准备好以后才被调用的,准备工作由启动文件和 Arm C 运行库一起完成。- RAM(内存) 复位后的内容是不确定的。全局变量有初值,是因为运行库把存在 Flash 里的初值复制了过去;没写初值的全局变量等于 0,是因为运行库主动把那片 RAM 清零了。
- 启动文件、scatter 文件(4.2 节)和运行库按同一套约定配合,改了其中一个,另外两个也要一起看。
二 从复位开始
2.1 复位是什么,有哪些复位
2.1.1 复位就是“回到起跑线”
复位(Reset)可以理解成让芯片回到一个确定的起点:CPU 停下手里的一切,首先把内核和外设寄存器恢复成手册规定的默认值,等复位信号释放以后,再从头开始执行程序。
为什么需要这样一个起点?刚通电那一刻,电源电压还在往上爬,晶振还没稳定,芯片内部的触发器处于什么状态谁也说不准。如果这时候 CPU 就开始取指令,执行到哪里全凭运气。复位的作用,就是先把芯片“按住”,等条件满足了,再让它从最开始的位置出发。
这个比喻有一个点要强调一下:复位恢复的是寄存器,不是 RAM。复位不会把 SRAM 清零,RAM 里的内容要靠后面第四部分讲的启动代码去整理。
2.1.2 上电本身就是一次复位
这一点很多同学没意识到:我们每次给板子通电,芯片都会自动经历一次复位,并不需要谁去按按键。
STM32F407 内部有上电复位(Power-On Reset,POR)和掉电复位(Power-Down Reset,PDR)电路。上电时,只要电源电压还没升到阈值以上,这个电路就一直把芯片保持在复位状态;电压够了,再等一小段固定的延时,复位才被释放,CPU 开始去读向量表。阈值和延时的具体数值见 DS8626 Rev 12 的电气特性章节。
所以 上电启动 和 按复位键重启 ,后面走的是同一条路:复位释放 → 读向量表 → Reset_Handler → …… → main()。本章后面讲的所有内容,每次上电都会完整地走一遍。
NOTE
记住一句话:芯片没有“不经过复位就开始运行”的情况。 不管是插上 USB 线、打开电源开关,还是下载完程序自动运行,CPU 都是先经历一次复位,再从向量表开始执行的。以后看到“上电后程序从哪里开始跑”这类问题,答案都从复位说起。
日常开发里,这种【自动复位】还有好几个来源。比如 Keil 里勾选了 Flash Download 页的 Reset and Run,下载完成后调试器会主动让芯片复位一次,程序才跑起来;没勾选的话,就得自己按一下复位键(见【3】学习前的准备工作)。
2.1.3 天空星核心板上的复位按键
天空星核心板上有一颗黑色的复位按键(原理图中的 SW1),它直接连在芯片的 NRST 引脚上:

电路很简单:
- R24(10 kΩ)把 NRST 上拉到 3.3V,平时 NRST 是高电平,芯片正常运行;
- 按下 SW1,NRST 被直接拉到 GND,芯片进入复位,一直按着就一直停在复位状态;
- 松开按键,R24 把 NRST 拉回高电平,复位释放,程序从头开始;

按住 BOOT 再按一下复位键能进入系统 Bootloader,靠的也是这一点:BOOT0 只在复位释放的那一刻被采样(见 2.2 节)。
2.1.4 STM32F407 有哪几种复位
按 RM0090 Rev 21 的复位章节,STM32F407 的复位分三类,影响的范围不同:
| 类别 | 由什么触发 | 复位的范围 |
|---|---|---|
| 电源复位 | 上电复位(POR)、掉电复位(PDR)、欠压复位(BOR),以及从待机模式唤醒 | 除备份域外的所有寄存器 |
| 系统复位 | NRST 引脚被拉低、独立看门狗(IWDG)超时、窗口看门狗(WWDG)超时、软件复位、低功耗管理复位 | 除 RCC_CSR 中的复位标志和备份域外的所有寄存器 |
| 备份域复位 | 软件置位 RCC_BDCR 的 BDRST 位,或者 VDD 和 VBAT 都掉电后重新上电 | 只复位备份域(RTC、备份寄存器等) |
各个复位源分别对应这些场景:
| 复位源 | 什么时候会发生 |
|---|---|
| 上电/掉电复位(POR/PDR) | 每次通电时自动发生;电压掉到阈值以下时也会触发 |
| 欠压复位(Brown-Out Reset,BOR) | 电源电压跌落到设定的 BOR 门限以下,比如电机启动瞬间把电源拉低 |
| 外部复位(NRST) | 按下复位键,或者调试器通过 RST 线拉低 NRST |
| 软件复位 | 程序调用 HAL_NVIC_SystemReset(),向内核请求一次系统复位 |
| 独立看门狗复位(IWDG) | 程序在规定时间内没有“喂狗” |
| 窗口看门狗复位(WWDG) | 喂狗太晚,或者在窗口打开之前就喂了 |
| 低功耗管理复位 | 选项字节配置为“进入待机或停机时复位”后,程序执行了进入这些模式的操作 |
不管是哪一种,复位释放以后的启动过程都一样:CPU 重新去读向量表。区别只在于哪些寄存器被恢复了、复位标志记下了什么。
还有一个细节:按 RM0090 Rev 21 的复位电路图,芯片内部的复位源(看门狗、软件复位等)发生时,也会在 NRST 引脚上输出一个低电平脉冲,用来顺便复位板上接在 NRST 上的其他器件。所以 NRST 不只是输入,它在内部复位时也会被芯片自己拉低。
2.1.5 怎样知道上一次是哪种复位
每次复位,芯片都会在 RCC_CSR 寄存器的高 8 位里留下记录。系统复位不会动这些标志,只有电源复位会把它们恢复成默认值,或者软件写 RMVF 主动清除,所以程序跑起来以后还能读到“上一次是怎么复位的”:
| 位 | 标志 | HAL 宏 | 含义 |
|---|---|---|---|
| 31 | LPWRRSTF | RCC_FLAG_LPWRRST | 低功耗管理复位 |
| 30 | WWDGRSTF | RCC_FLAG_WWDGRST | 窗口看门狗复位 |
| 29 | IWDGRSTF | RCC_FLAG_IWDGRST | 独立看门狗复位 |
| 28 | SFTRSTF | RCC_FLAG_SFTRST | 软件复位 |
| 27 | PORRSTF | RCC_FLAG_PORRST | 上电/掉电复位 |
| 26 | PINRSTF | RCC_FLAG_PINRST | NRST 引脚复位 |
| 25 | BORRSTF | RCC_FLAG_BORRST | 欠压复位 |
| 24 | RMVF | __HAL_RCC_CLEAR_RESET_FLAGS() | 写 1 清除上面所有标志 |
读的时候有两点要注意:
- 标志是累积的。上一次复位后没有清除,下一次复位的标志会叠加上去,所以读完记得清除,下次才能看到新的原因。
- 上一节说过,内部复位源也会拉低 NRST,所以看门狗复位、软件复位时,PINRSTF 往往也会跟着置位;上电时 PORRSTF、BORRSTF 和 PINRSTF 也通常一起出现。判断时先看 IWDGRSTF、WWDGRSTF、SFTRSTF、PORRSTF 这些更具体的标志,只有它们都没置位、只剩 PINRSTF 时,才是真正有人拉低了 NRST。各标志的置位条件和寄存器复位值以 RM0090 Rev 21 的 RCC_CSR 说明为准。
下图是我在Keil中,进入调试状态后观察到的寄存器的值【这里是不准的,因为各种复位标志没有进行过清除】:

截图里 CSR 的值是 0x1E000000,bit25~bit28 四个标志同时置位:BORRSTF、PINRSTF、PORRSTF 和 SFTRSTF。上电留下了 POR、BOR 和 PIN 三个标志,SFTRSTF 很可能来自调试器连接或下载时发出的系统复位请求(取决于 Debug Settings 里选的 Reset 方式)。几次复位的记录叠在一起,所以单看这个值,判断不出 上一次 到底是哪种复位。
另外,Keil 外设窗口把 bit26 显示为 PADRSTF,它和 CMSIS 头文件里的 PINRSTF 是同一位,都表示 NRST 引脚复位。
想自己实际看一眼,可以在 main.c 的 USER CODE BEGIN PV 里定义一个变量,再在 USER CODE BEGIN 1 里加两行:
/* USER CODE BEGIN PV */
volatile uint32_t g_reset_csr; /* 上次复位时 RCC_CSR 的原始值,供调试器观察 */
/* USER CODE END PV */
int main(void)
{
/* USER CODE BEGIN 1 */
g_reset_csr = RCC->CSR; /* 先保存,再清除,否则原因就丢了 */
__HAL_RCC_CLEAR_RESET_FLAGS(); /* 写 RMVF,为下一次复位腾出干净的标志 */
/* USER CODE END 1 */
HAL_Init();
/* ...... */
}2
3
4
5
6
7
8
9
10
11
12
13
14
在 Watch 窗口里观察 g_reset_csr,分别试一试“断电重新上电”和“按复位键”,看高 8 位有什么不同。
下面是我实际截图的,我在成功启动在线调试后,按了一下天空星核心板上的复位按键,就能看到这里这个变量的第26位,也就是 RCC_FLAG_PINRST 置1了,说明这个变量成功记录了上次复位原因是 NRST引脚复位。

这张截图还能和后文对上:Registers 窗口里 R13(SP)是 0x20000678,正是 2.4 节说的栈顶;R0 是 0x40023874,也就是 RCC->CSR 的地址,4.6.5 节会在 Map 里再见到它,读到那里时可以再回来对照一下。
IMPORTANT
如果程序一进 main() 就清掉了全部复位标志,又没提前把它们存下来,设备在现场出了问题以后, 看门狗复位 和 有人按了复位键 就再也分不清了。产品固件最好在启动早期先把复位原因读出来存好,再决定是否清除。看门狗相关的实验会在后面的看门狗章节展开。
2.1.6 平时说的 重启 不是一回事
我们平时说的 重启 其实有好几种。它们最终都会让 PC 回到复位入口,留给软件的现场却不太一样,做实验时要清楚自己用的是哪一种:
| 动作 | 适合验证什么 | 容易误判的地方 |
|---|---|---|
| 完全断电后重新上电 | 上电复位、BOOT 引脚采样、脱离调试器的独立运行 | 电容没放完电,或者调试器、串口线反向供电,实际上并没有真正掉电,最常见的就是调试器的串口给主控灌电 |
| 拉低 NRST 后释放 | 外部复位电路,启动代码重新执行 | 以为 RAM 会因此全部清零 |
| 软件请求系统复位 | 故障恢复、复位原因记录 | 复位前没处理完的日志、Flash 写入或外设事务被截断 |
| 调试器 Reset / Restart | 快速回到断点、单步启动代码 | 调试器可能自动设置 PC/SP、冻结外设,或者直接运行到 main() |
复位后哪些寄存器回到默认值、哪些复位域不受影响,以 RM0090 Rev 21 的复位与时钟控制(Reset and Clock Control,RCC)章节为准。
2.2 BOOT 引脚决定地址 0 指向哪里
Cortex-M4 复位后固定从地址 0x00000000 开始读向量表。STM32F407 会根据复位时采样到的 BOOT0、BOOT1 电平,把不同的存储器映射到这段地址上:
| BOOT1 | BOOT0 | 地址 0 映射到 |
|---|---|---|
| 任意 | 0 | 主 Flash(从 0x08000000 开始) |
| 0 | 1 | 系统存储器,里面是 ST 出厂固化的 Bootloader |
| 1 | 1 | 内置 SRAM |
这张对应关系见 RM0090 Rev 21 的 Boot configuration 小节。“映射”的意思是同一片存储器可以用两个地址访问:从主 Flash 启动时,读 0x00000000 和读 0x08000000 拿到的是同样的内容,程序本身仍然存放在 Flash 里。
天空星核心板上,BOOT1 与 PB2 复用,板上用 10 kΩ 电阻下拉(见【10】实现自己的GPIO库),平时保持从主 Flash 启动。用户可以在启动前按下BOOT按键来让天空星进入芯片bootloader里面,进入后可以通过usb或者串口直接给单片机下载程序而不需要外部硬件调试器。
还有一点要留心:调试器下载完程序后,可以直接把 PC 设到程序入口,所以 用调试器能跑 并不能说明 BOOT 配置一定没问题。拔掉调试器、重新上电还能正常运行,才算真正验证了启动配置。

2.3 向量表的前两项
向量表(Vector Table)是一张地址表,Cortex-M4 对它的格式有固定规定:

(图片截自 PM0214 Rev 10。)
Keil 启动文件 startup_stm32f407xx.s 里,这张表是这样写的:
__Vectors DCD __initial_sp ; Top of Stack
DCD Reset_Handler ; Reset Handler
DCD NMI_Handler ; NMI Handler
DCD HardFault_Handler ; Hard Fault Handler
; ……后面是其余系统异常和外设中断的入口2
3
4
5
DCD 的意思是“在这里放一个 32 位数”。表里大多数项是中断处理函数的地址,前两项比较特殊:复位时,内核把第 0 项装进 MSP,把第 1 项装进 PC。这两个数的具体值不是启动文件写死的,是链接器算好以后填进去的。
这里出现了两个后面会反复用到的词,先简单交代一下:
- 链接器(Linker):编译器把每个
.c、.s文件分别编译成一个.o目标文件,好比一个个零件。链接器(Keil 里叫 armlink)负责把这些零件拼成一份完整的固件,并给每个函数、每个变量分配最终的地址。“链接器算好以后填进去”说的就是这一步。 - 段(Section):每个
.o文件里,代码、常量、变量不是混在一起的,而是分门别类装进不同的“格子”,每个格子都有名字,这就是段。链接器拼装固件时,是以段为单位来摆放的。
链接器把哪个段放到哪个地址,要按一份“摆放说明书”来,这份说明书叫 scatter 文件。它是什么、本工程的这份从哪里来、怎么打开,4.2 节会专门讲。这里先看其中决定向量表位置的一行:
下面这个图片是本工程的 fdb_hal_led.sct 文件

*.o (RESET, +First)RESET 是启动文件里向量表所在段的名字,+First 要求它排在最前面,所以向量表一定从 0x08000000 开始。
从 HEX 文件里把这两个数读出来。
用文本编辑器打开 fdb_hal_led.hex,前两行是:
:020000040800F2
:1000000078060020290200085B0D0008DD0C0008BE2
Intel HEX 文件并不是简单地把 Flash 中的字节依次写成文本。它还需要告诉烧录工具:这些字节应该写入 Flash 的哪个地址。
先看第一行:
:020000040800F2可以将它拆分为:
::一条 HEX 记录的开始。02:本条记录包含 2 字节数据。0000:本条记录的地址字段。04:记录类型,表示扩展线性地址记录。0800:设置后续数据记录使用的地址高 16 位。F2:校验和,用于检查记录是否完整。
这里最重要的是 04 和 0800。它们共同告诉烧录工具:后续普通数据记录的目标地址,高 16 位使用 0x0800。
接着看第二行:
:1000000078060020290200085B0D0008DD0C0008BE其中:
10:这一行包含0x10,也就是 16 字节数据。0000:该数据记录的低 16 位地址。00:普通数据记录,后面跟着需要写入存储器的数据。78060020...:实际的 16 字节数据。BE:校验和。
那么,这 16 字节应该存放在哪里?
将第一行指定的高 16 位 0x0800 与第二行提供的低 16 位 0x0000 组合,就得到了完整的 32 位地址:
高 16 位 低 16 位
0800 0000
└──────┬───────┘
↓
0x080000002
3
4
5
所以,第二行的 16 字节数据对应 Flash 中的 0x08000000~0x0800000F。
这里还要注意:HEX 文件中的地址信息与实际存储的数据是两回事。 地址决定数据写到哪里,数据本身则决定写入什么内容。
在 STM32F407 中,Flash 通常从 0x08000000 开始。因此,我们看到的这条记录,恰好对应 Flash 起始位置的中断向量表。接下来就可以按照小端存储规则,把这些字节还原为初始栈指针和各个异常处理函数的入口地址。
STM32 是采用小端存储的,每 4 个字节要倒过来读:
| Flash 地址 | HEX 中的 4 字节 | 32 位值 | 含义 |
|---|---|---|---|
0x08000000 | 78 06 00 20 | 0x20000678 | __initial_sp,初始 MSP |
0x08000004 | 29 02 00 08 | 0x08000229 | Reset_Handler 入口 |
0x08000008 | 5B 0D 00 08 | 0x08000D5B | NMI_Handler 入口 |
0x0800000C | DD 0C 00 08 | 0x08000CDD | HardFault_Handler 入口 |
在 Map 文件里搜这两个符号,能找到一样的值:
__initial_sp 0x20000678 Data 0 startup_stm32f407xx.o(STACK)
Reset_Handler 0x08000229 Thumb Code 8 startup_stm32f407xx.o(.text)2
Map 文件是链接器生成的一份文本报告,记录了每个函数、每个变量最终放在哪个地址。本工程的 Map 在 MDK-ARM/DebugConfig 文件夹下,用记事本或 VS Code 这类文本编辑器就能打开:

Map 为什么放在这个文件夹、整份文件怎么读,4.6 节会完整讲一遍。上面这两行在 Map 的 Image Symbol Table 部分,对应 4.6.5 节。现在只要知道“在 Map 里搜名字能查到地址”就够了。
为什么函数地址是奇数?
在 STM32F407 中,Reset_Handler 的第一条指令实际存放在 0x08000228,但 HEX 文件的向量表和 Map 文件中记录的却是 0x08000229,为什么多了一个 1?
这是因为 Cortex-M4 只能执行 Thumb 指令。ARM 规定,函数入口值的最低位(bit0)用于标识 Thumb 状态:
- bit0 = 1:表示使用 Thumb 指令集。
- bit0 = 0:表示非 Thumb 状态,Cortex-M4 不支持。
由于 Thumb 指令至少需要按 2 字节对齐,因此实际指令的起始地址一定是偶数,最低位可以用来存放这个标志。
例如,0x08000229 可以拆成两部分:
0x08000228:函数第一条指令的实际存储地址。- 最低位
1:告诉 CPU 使用 Thumb 状态执行。
CPU 复位时会读取向量表中的 0x08000229,根据最低位设置 Thumb 状态,然后从 0x08000228 开始执行指令。
因此,Map 文件中 Thumb 函数的入口值通常是奇数,而调试器的 Disassembly 窗口显示的实际指令地址是偶数。两者并不矛盾,只是一个包含 Thumb 状态标志,另一个表示真正的代码存储位置。
2.4 栈顶为什么是 0x20000678
第一次看到这个数,很多人会觉得奇怪:STM32F407 的主 SRAM 有 128 KB,栈顶不是应该在 0x20020000 吗?
Keil 的做法不一样。启动文件开头用汇编“申请”了一块栈空间:
Stack_Size EQU 0x400
AREA STACK, NOINIT, READWRITE, ALIGN=3
Stack_Mem SPACE Stack_Size
__initial_sp2
3
4
5
这几行定义了一个名叫 STACK 的段,大小 0x400(1 KB),__initial_sp 是这块空间末尾的标号。链接器把它当成一个普通的 RAM 段,和全局变量排在一起。Map 里 RAM 的实际布局是:
0x20000000 .data 20 字节 g_reset_csr、HAL 变量、SystemCoreClock
0x20000014 .bss 96 字节 C 运行库内部使用的 libspace
0x20000074 PAD 4 字节 填充,让堆按 8 字节对齐
0x20000078 HEAP 512 字节 堆
0x20000278 STACK 1024 字节 栈
0x20000678 ← __initial_sp,栈从这里开始往低地址长2
3
4
5
6
栈从 0x20000678 往下长,用到底是 0x20000278,再往下是堆,然后是运行库的数据和 SystemCoreClock 这些全局变量。
这个地址会随全局变量的多少而变。第 11 章原版工程里栈顶是 0x20000670,加了 2.1.5 节那个 4 字节的 g_reset_csr 以后,前面多了 4 字节数据,又因为对齐多出 4 字节填充,栈就整体往后挪了 8 字节。这也是为什么文中一直提醒大家去自己的 Map 里查。
WARNING
这种布局下,栈用超了不会立刻报错,而是悄悄往下覆盖。先被踩到的是堆,工程里没用 malloc() 时根本察觉不到;再多超 512 字节,就轮到 SystemCoreClock、uwTick 这些 HAL 变量,表现成延时不准、HAL_Delay() 卡死,看起来完全不像栈的问题。大的局部数组、深层递归、在中断里调用 printf(),都是常见的诱因。
Stack_Size 和 Heap_Size 可以直接改启动文件里的数字,也可以在 Keil 里打开启动文件后,切到编辑区下方的 Configuration Wizard 标签页修改。改完 Rebuild,再到 Map 里确认一下。

那剩下一百多 KB 的 RAM 呢?scatter 文件里其实划了两块。这两行是 Keil 按 Target 页的 IRAM1、IRAM2 设置自动生成的,怎么生成、怎么打开,4.2.2 节会讲:
RW_IRAM1 0x20000000 0x0001C000 ; 112 KB
RW_IRAM2 0x2001C000 0x00004000 ; 16 KB2
它们对应 STM32F407 主 SRAM 中的 SRAM1(112 KB)和 SRAM2(16 KB),见 RM0090 Rev 21 的存储器映射。这个工程的变量很少,全都排在 IRAM1 的前 1.6 KB 里,IRAM2 是空的。芯片另外还有 64 KB 的 CCM RAM(0x10000000 起,DMA无法访问的内存),这份 scatter 文件里没有它,链接器默认不会往里放任何东西。
GNU 工具链的 ST 模板正好相反,把栈顶放在 RAM 的最末端,8.1 节会简单讲一下。
三 Reset_Handler 只做了两件事
3.1 读一读复位入口
Reset_Handler PROC
EXPORT Reset_Handler [WEAK]
IMPORT SystemInit
IMPORT __main
LDR R0, =SystemInit
BLX R0
LDR R0, =__main
BX R0
ENDP2
3
4
5
6
7
8
9
10
真正执行的只有 4 条指令:
LDR R0, =SystemInit:把SystemInit函数的地址装进 R0。等号是汇编器的伪指令写法,意思是“装入这个符号的值”,不是去这个地址读内存。BLX R0:调用 R0 指向的函数,同时把返回地址存进链接寄存器(Link Register,LR),SystemInit()执行完会回到下一条指令。LDR R0, =__main和BX R0:跳到__main。这里用的是BX而不是BLX,没有保存返回地址,这一跳就没打算回来。
Reset_Handler 自己没有设置栈指针,因为进入它之前,硬件已经用向量表第 0 项把 MSP 装好了。[WEAK] 表示这是一个弱定义,应用里写一个同名函数就能把它替换掉,平时用不到。
在 Disassembly 窗口里看这一段,和源码一一对应:

08000228 <Reset_Handler>:
8000228: ldr r0, [pc, #36] ; 从文字池取出 SystemInit 的地址
800022a: blx r0
800022c: ldr r0, [pc, #36] ; 取出 __main 的地址
800022e: bx r02
3
4
5
一条 Thumb 指令放不下一个完整的 32 位地址,汇编器就把 SystemInit、__main 这些地址写在函数后面的几个字里,再用 ldr r0, [pc, #36] 按“当前位置往后数 36 字节”去取。4.6.5 节在 Map 里还会看到它占用的空间。
3.2 SystemInit() 这时候能做什么
SystemInit() 在 Core/Src/system_stm32f4xx.c 里:
void SystemInit(void)
{
/* FPU settings ------------------------------------------------------------*/
#if (__FPU_PRESENT == 1) && (__FPU_USED == 1)
SCB->CPACR |= ((3UL << 10*2)|(3UL << 11*2)); /* set CP10 and CP11 Full Access */
#endif
#if defined (DATA_IN_ExtSRAM) || defined (DATA_IN_ExtSDRAM)
SystemInit_ExtMemCtl();
#endif /* DATA_IN_ExtSRAM || DATA_IN_ExtSDRAM */
/* Configure the Vector Table location -------------------------------------*/
#if defined(USER_VECT_TAB_ADDRESS)
SCB->VTOR = VECT_TAB_BASE_ADDRESS | VECT_TAB_OFFSET; /* Vector Table Relocation in Internal SRAM */
#endif /* USER_VECT_TAB_ADDRESS */
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
这个工程没有定义 DATA_IN_ExtSRAM 和 USER_VECT_TAB_ADDRESS,后两段不参与编译,只剩第一段:把 SCB->CPACR 的 CP10、CP11 设成完全访问,也就是打开浮点运算单元(Floating-Point Unit,FPU)的访问权限。编译出来一共 5 条指令:

08000dfc <SystemInit>:
8000dfc: ldr r0, [pc, #8] ; r0 = 0xE000ED88,即 SCB->CPACR 的地址
8000dfe: ldr r1, [r0, #0]
8000e00: orr.w r1, r1, #0xF00000 ; 置位 bit20~23
8000e04: str r1, [r0, #0]
8000e06: bx lr2
3
4
5
6
开 FPU 为什么要排在 __main 前面?4.5 节会看到,Arm 运行库在调用 main() 之前会执行一个 _fp_init,里面有一条 vmsr fpscr, r0 浮点指令。FPU 访问权限没打开就执行它,会触发 UsageFault(没有单独使能 UsageFault 时会升级成 HardFault)。这个顺序是有讲究的。
WARNING
SystemInit() 执行的时候,全局变量的初值还没复制,零初值变量也还没清零,RAM 里是复位前的残留值或随机值。往 SystemInit() 里加代码时,只用寄存器、常量和局部变量,不要读写全局变量,也不要调用 HAL 函数。
名字很像的 SystemClock_Config() 在 main.c 里,由 main() 调用,负责把系统时钟切到 168 MHz。两者的区别第 13 章 5.4 节讲过,这里不再重复。
3.3 __main 不是 main
__main 是 Arm C 运行库提供的入口,和我们自己写的 main() 是两个不同的函数。它很短,就是依次调用两段代码:

__main 的实现通常位于 Arm 编译器自带的预编译运行库中,不会作为普通 .c 源文件出现在 Keil 工程目录里。不过,我们仍然可以在 Keil 中查看__main 的反汇编指令。
08000188 <__main>:
8000188: bl __scatterload ; 初始化 RAM 中的全局变量
800018c: bl __rt_entry ; 设置栈和堆、初始化运行库,然后调用 main()2
3
把整个启动过程连起来,括号里是这次构建的地址:
flowchart TD
A["复位
MSP = 0x20000678
PC = 0x08000228"] --> B["Reset_Handler"]
B --> C["SystemInit()
打开 FPU 访问权限"]
C --> D["__main
0x08000188"]
D --> E["__scatterload
按链接器生成的地址表处理 RAM"]
E --> F["__scatterload_copy
复制 20 字节初值到 0x20000000"]
F --> G["__scatterload_zeroinit
从 0x20000014 起清零 1636 字节"]
G --> H["__rt_entry
设置栈和堆、初始化运行库"]
H --> I["main()
0x08000E30"]
接下来分别看 __scatterload 和 __rt_entry 做了什么。
四 全局变量的初值是谁写进 RAM 的
4.1 编译器把数据分成几类
每次编译完,Keil 的 Build Output 窗口都会打印一行:

Program Size: Code=3314 RO-data=442 RW-data=20 ZI-data=1636这四个数对应四类内容:
| 类别 | 放什么 | 运行时在哪 | 固件里存不存 |
|---|---|---|---|
| Code | 程序指令 | Flash | 存 |
| RO-data | const 常量、字符串、查找表 | Flash | 存 |
| RW-data | 有非零初值的全局变量和静态变量,例如 int mode = 3; | RAM | 存它的初值 |
| ZI-data | 初值为 0 的全局变量和静态变量,以及栈和堆 | RAM | 不存,启动时清零 |
RO 是 Read-Only,RW 是 Read-Write,ZI 是 Zero-Initialized。GNU 工具链的资料里,它们通常叫 .text/.rodata、.data 和 .bss,本章两种叫法都会出现,说的是同一回事。
有了这几个数,就能算出工程的实际占用:
- Flash:Code + RO-data + RW-data = 3314 + 442 + 20 = 3776 字节。RW-data 也要算进来,因为它的初值得跟着固件存在 Flash 里。
- RAM:RW-data + ZI-data = 20 + 1636 = 1656 字节。ZI-data 里有 1024 字节栈、512 字节堆、96 字节运行库数据和 4 字节对齐填充,真正属于全局变量的很少。
Map 文件末尾的 Total ROM Size 和 Total RW Size 给出的就是这两个数。
函数里的普通局部变量不在这几类里。它们放在栈上,每次进入函数才分配,不会被自动清零,没赋值就读,读到什么都有可能。
4.2 读懂 scatter 文件
上一节把数据分成了四类,可编译器只管分类,不管放哪。每一类最后落在 Flash 还是 RAM、从哪个地址开始,由链接器说了算,而链接器又要照着一份说明书来排,这份说明书就是 scatter 文件。
4.2.1 先打个比方:搬家的两张单子
想象一次搬家。家当先要打包装车,到了新家再拆箱,书放上书架,衣服挂进衣柜。整个过程要用到两张单子:
- 装车清单:每样东西装在车厢的什么位置。它对应加载域(Load Region),描述固件下载以后在 Flash 里是怎么排的。
- 摆放图:每样东西到了新家放进哪个房间。它对应执行域(Execution Region),描述程序运行时每样东西在哪个地址被访问。
scatter 文件(Scatter-Loading Description File,分散加载描述文件,Keil 里后缀是 .sct)就是写给链接器的这两张单子。后面几节都会用到它:
- 链接时,链接器照着它给每个段分配地址;
- 启动时,
__scatterload照着链接器生成的清单Region$$Table拆箱,把该搬的数据搬进 RAM(4.4 节); - 链接器把最后的摆放结果写进 Map 文件,相当于搬完家以后的验收单(4.6 节)。
这个比方有两处和真实情况不一样。一是搬家只搬一次,MCU 却是每次复位都重新拆一遍箱。二是不是所有东西都要搬:代码和常量一直待在 Flash 里,原地使用;真正需要搬进 RAM 的,只有 RW-data 的初值,ZI-data 则是在 RAM 里直接清零,什么也不用搬。
4.2.2 本工程的 scatter 文件从哪里来
翻遍工程目录,你也找不到一份我们自己写的 .sct 文件。本工程的内存布局,是在 Options for Target → Target 页里填的:

| Target 页的设置 | 本工程填的值 | 生成到 scatter 文件里变成 |
|---|---|---|
| IROM1(片内 Flash) | Start 0x8000000,Size 0x80000 | LR_IROM1 和 ER_IROM1,0x08000000 起,512 KB |
| IRAM1(片内 RAM 第一块) | Start 0x20000000,Size 0x1C000 | RW_IRAM1,0x20000000 起,112 KB |
| IRAM2(片内 RAM 第二块) | Start 0x2001C000,Size 0x4000 | RW_IRAM2,0x2001C000 起,16 KB |
这几个数是选芯片型号时 Keil 按器件支持包自动填好的,平时很少有人动它。
关键在 Linker 页的 Use Memory Layout from Target Dialog 这个选项,它默认是勾选的,意思是“内存布局就用 Target 页填的那些”。勾选时,Keil 会按 Target 页的这几个数,在输出目录里自动生成一份 scatter 文件交给链接器。本工程生成的就是 MDK-ARM/fdb_hal_led/fdb_hal_led.sct,链接器的命令行里能看到它:
--strict --scatter "fdb_hal_led\fdb_hal_led.sct"所以 scatter 文件一直都在,只是由 Keil 在后台自动维护,平时没人去看它,很多同学也就不知道有这么一份文件。 Keil 并不是每次编译都会重写它。Target 页的设置一直没改,它的内容和 Target 页也就一直对得上。想确认的话,把它和 Target 页的数值对照一下就行。

4.2.3 让它变成“我们自己的” scatter 文件
想亲手打开这份文件看一看,或者以后要自己改布局,可以这样做:
- 先复制一份工程,在副本上操作。布局改错了,程序可能连
main()都进不去,有原工程在手边好对比。 - 打开 Options for Target → Linker 页,取消勾选 Use Memory Layout from Target Dialog。
- 看下方的 Scatter File 一栏,确认它指向
.\fdb_hal_led\fdb_hal_led.sct,也就是刚才那份自动生成的文件。如果这一栏是空的,点右边的浏览按钮选中这个文件。 - 点 OK,然后 Rebuild。编译 0 Error,说明链接器已经改用这份文件了。
- 再打开 Linker 页,点 Scatter File 旁边的 Edit 按钮,Keil 就会在编辑器里把它打开。
取消勾选以后,有两点变化要心里有数:
- Keil 不再根据 Target 页覆盖这份文件,它从此就是一份需要我们自己维护的源文件。这时再去 Target 页改 IROM、IRAM 的数值,链接结果不会跟着变,要改就直接改
.sct。 - 想恢复原样,把这个选项重新勾上就行,Keil 又会按 Target 页重新生成。
TIP
普通工程保持勾选最省心。需要自己写 scatter 文件的,一般是这几种情况:给 Bootloader 和应用划分区(8.2 节)、留一块复位后不被清零的 RAM(8.3 节)、把某些函数放进 RAM 里运行。真要自己维护时,建议把 .sct 复制到工程目录下、改个好认的名字,再把 Scatter File 一栏指向它,和源码一起纳入版本管理。输出目录里放的大多是编译产物,随时可能被清理掉,自己维护的文件不要留在那里。本工程自带的 clean_keil_gcc.bat 特意把删除 *.sct 那一行注释掉了,就是为了不误删它。

4.2.4 逐行读一遍
打开以后,内容是这样的:
; *************************************************************
; *** Scatter-Loading Description File generated by uVision ***
; *************************************************************
LR_IROM1 0x08000000 0x00080000 { ; load region size_region
ER_IROM1 0x08000000 0x00080000 { ; load address = execution address
*.o (RESET, +First)
*(InRoot$$Sections)
.ANY (+RO)
.ANY (+XO)
}
RW_IRAM1 0x20000000 0x0001C000 { ; RW data
.ANY (+RW +ZI)
}
RW_IRAM2 0x2001C000 0x00004000 {
.ANY (+RW +ZI)
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
分号后面是注释,第一行就写着“generated by uVision”,说明它是 Keil 自动生成的。格式上就两层大括号:外面一层是加载域,里面几层是执行域,每个名字后面跟着起始地址和最大长度。
| 这一行 | 用大白话说 |
|---|---|
LR_IROM1 0x08000000 0x00080000 { | 装车清单:固件装在 Flash 的 0x08000000 起、最多 512 KB 的地方,大括号里的东西都装在这一段里 |
ER_IROM1 0x08000000 0x00080000 { | 第一个房间。它的运行地址和装车位置一样,东西放在哪里就在哪里用,不用搬 |
*.o (RESET, +First) | 从所有 .o 文件里找名叫 RESET 的段,也就是向量表,摆在最前面(2.3 节) |
*(InRoot$$Sections) | 运行库里一批必须原地运行的段,包括 __main、__scatterload 和清单 Region$$Table 本身 |
.ANY (+RO) | 剩下所有只读内容,也就是代码和常量。.ANY 表示不管来自哪个文件,由链接器自己安排顺序 |
.ANY (+XO) | 只能执行、不能读的代码(eXecute-Only),本工程用不到 |
RW_IRAM1 0x20000000 0x0001C000 { | 第二个房间在 RAM 的 0x20000000,最多 112 KB |
.ANY (+RW +ZI) | RW-data 和 ZI-data 都放进这个房间 |
RW_IRAM2 ... | 第三个房间,规则相同。本工程的数据很少,全都放进了 IRAM1,这里是空的 |
InRoot$$Sections 这一行最容易看不懂,用搬家来想就明白了:负责拆箱的工人(__scatterload)和搬家清单(Region$$Table)不能装在待拆的箱子里,不然根本没人去拆箱。所以它们必须待在“运行地址等于存放地址”的根区域(Root Region),也就是 ER_IROM1 里。这条规则的完整说明见 armlink 用户指南。
还有一个细节:RW_IRAM1 只写了运行地址 0x20000000,没写它的初值存在 Flash 的哪里。这种情况下,链接器会把它的初值顺着排在同一个加载域里前一个执行域的后面。这就是下一小节 Load base: 0x08000eac 的来历:ER_IROM1 正好在 0x08000EAC 结束。
4.2.5 同一份数据的两个地址
ER_IROM1 里的东西下载到哪里就在哪里运行,不用搬。RW_IRAM1 就不一样了:它运行在 RAM,可它的内容(RW 变量的初值)又得跟着固件一起存进 Flash,因为断电以后 RAM 里什么都留不下。于是同一份数据有了两个地址:
- 存放地址:在 Flash 里,紧跟在代码和常量后面,好比衣服在行李箱里的位置;
- 运行地址:在 RAM 里,程序读写变量时用的是这个地址,好比衣服挂进衣柜后的位置。
Map 里 RW_IRAM1 这一行把两个地址都写出来了:
Execution Region RW_IRAM1 (Exec base: 0x20000000, Load base: 0x08000eac, Size: 0x00000678, Max: 0x0001c000, ABSOLUTE)Exec base 是运行地址,Load base 是存放地址。启动时把数据从 Load base 搬到 Exec base,就是 __scatterload 的工作。GNU 资料里把这两个地址分别叫作 VMA(Virtual Memory Address)和 LMA(Load Memory Address),意思一样。这一行在 Map 里的位置和其他字段的含义,4.6.6 节会再细讲。
4.3 跟着 SystemCoreClock 走一遍
system_stm32f4xx.c 里有这样一行:
uint32_t SystemCoreClock = 16000000;它有非零初值,属于 RW-data。在 Map 的 RW_IRAM1 明细里能找到它所在的段:
Exec Addr Load Addr Size Type Attr Section Name Object
0x20000000 0x08000eac 0x00000004 Data RW .data main.o
0x20000004 0x08000eb0 0x0000000c Data RW .data stm32f4xx_hal.o
0x20000010 0x08000ebc 0x00000004 Data RW .data system_stm32f4xx.o2
3
4
SystemCoreClock 运行时在 0x20000010,初值存在 Flash 的 0x08000EBC。回到 HEX 文件,找到地址 0EB0 这一行:
:100EB0000100000010000000000000000024F40009从 0x08000EBC 开始的 4 个字节是 00 24 F4 00,倒过来是 0x00F42400,换成十进制正好是 16000000。初值确实存在 Flash 里,等着启动时被搬到 0x20000010。
调试界面该地址的数据如下所示:

这一行前面 12 个字节属于 stm32f4xx_hal.o,依次是 uwTickFreq(1)、uwTickPrio(16)和 uwTick(0)。再往前的 0x08000EAC~0x08000EAF 在上一行(0EA0 行)的末尾,是 main.o 里 g_reset_csr 的初值 00 00 00 00。
uwTick 没写初值,为什么也在 RW 里。
HAL 里 uwTick 是这样定义的:
__IO uint32_t uwTick;按 C 语言规则,它的初值是 0,照理应该进 ZI。可 Map 显示它在 .data 里,那个 0 也老老实实存进了 Flash。这是 ArmCC 的默认行为:不超过 8 字节的零初值全局变量,会直接放进 RW 段,对应的编译选项是 --bss_threshold,默认值为 8(见 armcc 用户指南:--bss_threshold)。
我们在 2.1.5 节自己加的 g_reset_csr 也是这样:4 字节、没写初值,Map 里却出现在 main.o(.data),那 4 个字节的 0 同样存进了 Flash。
这对程序结果没有影响,但提醒了我们:一个变量最后落在哪个段,要看 Map,不能只凭“有没有写初值”去猜。
4.4 __scatterload 照着一张表干活
回到 4.2.1 节的搬家比方,__scatterload 就是那个拆箱的工人。它并不知道工程里有哪些变量,只认一张由链接器按 scatter 文件生成的清单 Region$$Table。Map 里能找到这张表的位置:
Region$$Table$$Base 0x08000e8c Number 0 anon$$obj.o(Region$$Table)这张表在 Flash 里占 32 字节,每 16 字节描述一块 RAM 的初始化方式。HEX 文件里它横跨 0E80、0E90、0EA0 三行:
:100E8000000001020304060708090000AC0E000878
:100E90000000002014000000C4010008C00E00087B
:100EA0001400002064060000E001000800000000BB2
3
从 0x08000E8C 开始(0E80 行的最后 4 个字节)按 32 位小端拆开:
| 表项 | 源地址 | 目标地址 | 长度 | 处理函数 |
|---|---|---|---|---|
| 1 | 0x08000EAC | 0x20000000 | 0x14(20 字节) | 0x080001C4,__scatterload_copy |
| 2 | 0x08000EC0 | 0x20000014 | 0x664(1636 字节) | 0x080001E0,__scatterload_zeroinit |
0E80 行前面那 12 个字节不属于这张表:前 10 个是 system_stm32f4xx.c 里 AHBPrescTable 常量数组的后半段,后 2 个是让表按 4 字节对齐的填充(4.6 节的 Memory Map 里能看到这一行 PAD)。
__scatterload 逐项取出这四个数,再调用对应的处理函数:
- 第 1 项:从 Flash
0x08000EAC复制 20 字节到 RAM0x20000000,也就是把 RW-data 的初值搬过去; - 第 2 项:把 RAM 从
0x20000014开始的 1636 字节清零,这一段就是 ZI-data。清零用不上源地址,这一列可以忽略。
__scatterload_copy 的实现很直接,用 LDM/STM 一次搬 16 字节,最后再处理不足 16 字节的尾巴:

080001c4 <__scatterload_copy>:
80001c4: subs r2, #16 ; 剩余长度减 16
80001c6: itt cs
80001c8: ldmiacs r0!, {r3, r4, r5, r6} ; 从源地址读 16 字节
80001ca: stmiacs r1!, {r3, r4, r5, r6} ; 写到目标地址
80001cc: bhi.n 80001c4 ; 还没搬完就继续
; ……之后处理剩下的 8 字节和 4 字节,最后 bx lr 返回2
3
4
5
6
7
r0 是源地址,r1 是目标地址,r2 是长度,正好对应表里的三列。
清零范围里包括了栈和堆。
0x664 拆开是 0x60 + 0x4 + 0x200 + 0x400,也就是运行库数据 96 字节、对齐填充 4 字节、堆 512 字节、栈 1024 字节。启动文件里虽然把 STACK、HEAP 标成了 NOINIT,Map 里它们的类型仍然是 Zero,链接后落在 ZI 区里,跟着一起被清零。
这带来两个实际影响:
- 进入
main()时,栈里没用到的部分都是 0。估算栈实际用了多少时,可以利用这一点(见 8.4)。 - 想让某块 RAM 在软件复位后保留内容,比如保存崩溃记录,光给变量换个段名不够,还得在 scatter 文件里给它单独开一个带
UNINIT属性的执行域,让__scatterload跳过它(见 8.3)。
RW 数据较多时,armlink 可能会把初值压缩后再存进 Flash,这时表里的处理函数会换成解压函数。原理一样,判断方法还是看 Map文件。
4.5 __rt_entry:栈、堆和运行库
处理完 RAM,__main 接着调用 __rt_entry。这一段的反汇编:
08000208 <__rt_entry>:
8000208: bl __user_setup_stackheap ; 设置栈和堆
800020c: mov r1, r2
0800020e <__rt_entry_li>:
800020e: bl __rt_lib_init ; 初始化运行库
08000212 <__rt_entry_main>:
8000212: bl main ; 终于调用 main()
8000216: bl exit ; main() 返回后才会走到这里2
3
4
5
6
7
8
__user_setup_stackheap 会回过头调用启动文件里的 __user_initial_stackheap:
__user_initial_stackheap
LDR R0, = Heap_Mem
LDR R1, =(Stack_Mem + Stack_Size)
LDR R2, = (Heap_Mem + Heap_Size)
LDR R3, = Stack_Mem
BX LR2
3
4
5
6
7
四个寄存器把栈和堆的边界告诉运行库:R0 是堆的起点 0x20000078,R1 是栈顶 0x20000678,R2 是堆的终点 0x20000278,R3 是栈底 0x20000278。malloc() 能分配的空间,就是这里给出的 512 字节。
__rt_lib_init 在这个工程里只做了一件事:调用 _fp_init,往浮点状态控制寄存器 FPSCR 写入初值,也就是 3.2 节提到的那条浮点指令。工程里用了 C++ 全局对象或者某些库功能时,这里还会调用对应的初始化代码。
走到这里,main() 被调用时,C 语言需要的环境都齐了:栈指针有效,带初值的全局变量有了初值,零初值的变量是 0,FPU 也可以用了。
如果工程勾选了 Use MicroLIB,启动文件会走另一个分支:直接导出 __initial_sp、__heap_base、__heap_limit 给 MicroLIB 使用,不再提供 __user_initial_stackheap,__main 内部的步骤也比标准库少。本工程没有勾选,遇到勾选了的工程,以它自己的 Map 为准。
4.6 读懂 Map 文件
前面几节一直在引用 Map 里的某一行,这一节把整份 Map 从头到尾过一遍。它是链接器交给我们的“验收报告”:源码写的是意图,Map 记录的是链接器最后真正做了什么。启动出问题、Flash 不够用、断点打不上、某个函数“明明写了却没执行”,答案多半都在这份文件里。
4.6.1 Map 文件在哪里
本工程的 Map 不在 MDK-ARM/fdb_hal_led/ 下,而在 MDK-ARM/DebugConfig/fdb_hal_led.map。原因是 Options for Target → Listing 页的 Select Folder for Listings 被设成了 .\DebugConfig\,链接器命令行里能看到对应的参数:
--list ".\DebugConfig\fdb_hal_led.map" -o fdb_hal_led\fdb_hal_led.axf同一页下半部分的 Linker Listing 勾选项,决定了 Map 里包含哪些内容。本工程全部勾选,对应的链接参数是:
--map --load_addr_map_info --xref --callgraph --symbols
--info sizes --info totals --info unused --info veneers2
WARNING
改过 Listing 目录以后,旧目录里可能还留着以前生成的 Map,而且不会再更新。打开 Map 时先看一眼文件的修改时间,确认它和这次 Rebuild 的时间一致,不然对着旧 Map 查地址,越查越糊涂。

4.6.2 Map 的五个部分
用文本编辑器打开,这份 Map 一共 1107 行,按 ==== 分隔线分成五个部分:
| 部分 | 起始行 | 回答什么问题 | 对应的 Listing 选项 |
|---|---|---|---|
| Section Cross References | 5 | 谁引用了谁,某个段为什么被链接进来 | Cross Reference |
| Removing Unused input sections | 247 | 哪些代码没被用到,被链接器删掉了 | Unused Sections Info |
| Image Symbol Table | 454 | 每个函数、变量最后落在哪个地址、多大 | Symbols |
| Memory Map of the image | 894 | Flash 和 RAM 里从头到尾放了什么 | Memory Map |
| Image component sizes | 1025 | 每个文件各占多少 Flash 和 RAM | Size Info、Totals Info |
文件第一行写着生成它的工具版本:Component: ARM Compiler 5.06 update 7 (build 960) Tool: armlink [4d3601]。拿别人的 Map 排查问题时,先看这一行是不是同一个编译器。
4.6.3 交叉引用:顺着引用链找到启动过程
第一部分每一行的格式都是“A 段 refers to B 段 for 某个符号”。在里面搜 startup_stm32f407xx.o,启动过程的骨架就出来了:
startup_stm32f407xx.o(RESET) refers to startup_stm32f407xx.o(STACK) for __initial_sp
startup_stm32f407xx.o(RESET) refers to startup_stm32f407xx.o(.text) for Reset_Handler
startup_stm32f407xx.o(.text) refers to system_stm32f4xx.o(i.SystemInit) for SystemInit
startup_stm32f407xx.o(.text) refers to __main.o(!!!main) for __main2
3
4
逐行翻译过来就是:向量表(RESET 段)里用到了栈顶 __initial_sp 和 Reset_Handler,Reset_Handler 所在的代码段又用到了 SystemInit 和 __main。这正是 2.3 到 3.3 节讲的那条链路,链接器在这里把它原样记了下来。
括号里的段名有几种常见写法:
RESET、STACK、HEAP:启动文件里用AREA定义的段;i.SystemInit、i.main:每个函数单独一个段,以i.开头加函数名。这是因为工程勾选了 Options for Target → C/C++ 页的 One ELF Section per Function;.data、.bss、.text:编译器默认的数据段和代码段;!!!main、!!!scatter:Arm C 运行库内部的段,名字前面的感叹号让它们在排序时靠前。
想知道某个函数为什么会被链接进固件,就在这一部分搜 for 函数名,看是谁引用了它。
4.6.4 被删除的段:写了却没进固件的代码
第二部分列出链接器删掉的代码,结尾有一行汇总:
Removing main.o(i.Error_Handler), (4 bytes).
Removing stm32f4xx_hal_gpio.o(i.HAL_GPIO_DeInit), (360 bytes).
Removing bsp_led.o(i.BSP_LED_Set), (32 bytes).
......
200 unused section(s) (total 10456 bytes) removed from the image.2
3
4
5
链接器从程序入口和向量表出发,把能被引用到的段保留下来,其余的全部删掉。这个工程里,BSP_LED_Set() 写了但 main() 没有调用,所以被删了;HAL_GPIO_DeInit() 也一样。这一份清单一共删掉了 10456 字节,比最终固件本身(3776 字节)还大,HAL 库里大部分函数其实都没有用上。
能删得这么细,靠的就是上一节说的 One ELF Section per Function:每个函数是一个独立的段,可以单独删除。如果关掉这个选项,一个 .c 文件里只要有一个函数被用到,整个文件的代码都会被保留。

Error_Handler 也出现在这份清单里。它明明被 SystemClock_Config() 调用了,为什么还会被删除?这就涉及编译器的一种优化技术:函数内联(Inlining)。
正常情况下,调用一个函数时,CPU 需要跳转到该函数所在的地址执行,执行完毕后再返回。而内联就是编译器直接把被调用函数的代码放到调用位置,省去函数调用和返回的开销。
例如,原本的执行过程是:
SystemClock_Config()
↓ 调用
Error_Handler()
↓ 返回
SystemClock_Config()2
3
4
5
内联优化后,Error_Handler() 的代码可能直接成为 SystemClock_Config() 的一部分,不再需要跳转到独立的 Error_Handler() 函数。
这样一来,如果没有其他地方需要调用独立的 Error_Handler(),链接器就可以把原来的函数体删除,以节省 Flash 空间。
需要注意,内联并不等于函数没有执行,只是它的代码被放到了其他位置。而且,编译器可能自动决定是否内联,并不一定需要在函数前面添加 inline 关键字。
不过,仅凭删除清单还不能确定函数一定被内联了,也可能是相关代码被优化掉了,需要结合反汇编确认。
所以,遇到“函数断点打不上”的情况,可以先在 Map 文件中搜索函数名,检查独立函数体是否被删除,再通过 Disassembly 窗口确认是否发生了内联。6.4 节会进一步介绍相关优化行为。
4.6.5 符号表:每个名字最终落在哪里
第三部分分成 Local Symbols 和 Global Symbols 两段。Local 主要是各个段的起始位置和文件内部的符号,Global 是所有对外可见的函数和变量。每一行有六列:
Symbol Name Value Ov Type Size Object(Section)
Reset_Handler 0x08000229 Thumb Code 8 startup_stm32f407xx.o(.text)
main 0x08000e31 Thumb Code 56 main.o(i.main)
EXTI0_IRQHandler 0x08000243 Thumb Code 0 startup_stm32f407xx.o(.text)
g_reset_csr 0x20000000 Data 4 main.o(.data)
SystemCoreClock 0x20000010 Data 4 system_stm32f4xx.o(.data)
__Vectors_Size 0x00000188 Number 0 startup_stm32f407xx.o ABSOLUTE2
3
4
5
6
7
Type 这一列最值得留意:
Thumb Code:函数,地址带 Thumb 位,是奇数;Data:变量,地址就是它在 RAM 或 Flash 里的真实位置;Number:一个常数,不占任何存储空间。比如__Vectors_Size是0x188,也就是 392 字节,除以 4 得到 98 项,正好是 16 个系统异常加上 STM32F407 的 82 个外设中断;Section:出现在 Local Symbols 里,表示一个段的起始地址,比如i.main是0x08000e30,不带 Thumb 位。
Size 也能读出不少东西。main 的大小是 56 字节,可下一节的 Memory Map 里 i.main 段占了 0x40,也就是 64 字节。多出来的 8 字节是文字池(Literal Pool),存着 main() 要用的两个地址:RCC->CSR 的地址 0x40023874 和 g_reset_csr 的地址 0x20000000。2.1.5 节加的那两行代码,在这里留下了痕迹。
EXTI0_IRQHandler 的地址和其他几十个中断一样,都是 0x08000243,大小为 0。这说明它们都还是启动文件里的弱定义,指向同一个默认处理函数,7.3 节就是靠这一点判断中断函数名有没有写错。
4.6.6 内存映射:把 Flash 和 RAM 从头排到尾
第四部分是整份 Map 里信息量最大的,开头先给出程序入口:
Image Entry point : 0x08000189这个入口是 __main,而不是 Reset_Handler。它是写给调试器、下载工具看的 ELF 入口,HEX 文件最后一条类型为 05 的记录 :040000050800018965 存的也是它。CPU 复位时并不看这个值,而是去读向量表,所以真正的第一条指令仍然在 Reset_Handler。
接下来按加载域、执行域的顺序,逐段列出 Flash 和 RAM 的内容。ER_IROM1 的开头和结尾是这样的:
Execution Region ER_IROM1 (Exec base: 0x08000000, Load base: 0x08000000, Size: 0x00000eac, Max: 0x00080000, ABSOLUTE)
Exec Addr Load Addr Size Type Attr Idx E Section Name Object
0x08000000 0x08000000 0x00000188 Data RO 3 RESET startup_stm32f407xx.o
0x08000188 0x08000188 0x00000008 Code RO 1501 * !!!main c_w.l(__main.o)
0x08000190 0x08000190 0x00000034 Code RO 1670 !!!scatter c_w.l(__scatter.o)
......
0x08000e30 0x08000e30 0x00000040 Code RO 15 i.main main.o
0x08000e70 0x08000e70 0x0000000a Code RO 1610 x$fpl$fpinit fz_wm.l(fpinit.o)
0x08000e7a 0x08000e7a 0x00000010 Data RO 1424 .constdata system_stm32f4xx.o
0x08000e8a 0x08000e8a 0x00000002 PAD
0x08000e8c 0x08000e8c 0x00000020 Data RO 1668 Region$$Table anon$$obj.o2
3
4
5
6
7
8
9
10
11
12
13
各列的含义:
| 列 | 含义 |
|---|---|
| Exec Addr | 运行时的地址 |
| Load Addr | 存放在 Flash 里的地址;ZI 段不需要存放,这一列显示 - |
| Size | 段的大小 |
| Type | Code 代码、Data 数据、Zero 零初始化数据、PAD 对齐填充 |
| Attr | RO、RW 属性 |
| E | 标 * 的是程序入口所在的段 |
| Section Name / Object | 段名和它来自哪个目标文件或库 |
拿它对照前面几节,所有结论都能在这里得到印证:向量表 RESET 占据 Flash 开头的 0x188 字节;__main 紧跟其后,从 0x08000188 开始;Flash 里的最后一段是 32 字节的 Region$$Table,结束于 0x08000EAC;RW 初值从 0x08000EAC 开始存放(见 4.3 节)。
再和 HEX 对一下:Map 里 Load Region LR_IROM1 的 Size 是 0x0ec0,也就是 Flash 用到 0x08000EBF 为止;HEX 文件最后一条数据记录正好是 0EB0 那一行,覆盖 0x08000EB0~0x08000EBF。Map 和 HEX 首尾都对得上。
每个执行域标题里的 Size 和 Max 放在一起看,就是这块区域的使用率。RW_IRAM1 用了 0x678(1656 字节),上限是 0x1C000(112 KB)。以后工程变大,Flash 或 RAM 快不够时,最先从这里看出来。
4.6.7 文件占用统计:谁吃掉了 Flash 和 RAM
第五部分按目标文件逐个统计占用,挑几行看:
Code (inc. data) RO Data RW Data ZI Data Debug Object Name
216 18 0 4 0 637298 main.o
64 26 392 0 1536 860 startup_stm32f407xx.o
1280 48 0 0 0 4138 stm32f4xx_hal_rcc.o
16 4 16 4 0 1183 system_stm32f4xx.o2
3
4
5
6
main.o的 RW Data 是 4,就是g_reset_csr;startup_stm32f407xx.o的 RO Data 392 是向量表,ZI Data 1536 是栈和堆(1024 + 512);stm32f4xx_hal_rcc.o的代码最多,1280 字节,占了整个固件代码的三分之一以上,时钟配置确实是点灯工程里最复杂的部分;system_stm32f4xx.o的 RO Data 16 是AHBPrescTable,RW Data 4 是SystemCoreClock;Code (inc. data)里的“inc. data”指代码段内的文字池,已经包含在 Code 里了;Debug列是调试信息,只留在 AXF 文件里,不会烧进芯片。
最后是三组合计:
Code (inc. data) RO Data RW Data ZI Data Debug
3314 224 442 20 1636 694604 Grand Totals
3314 224 442 20 1636 694604 ELF Image Totals
3314 224 442 20 0 0 ROM Totals
Total RO Size (Code + RO Data) 3756 ( 3.67kB)
Total RW Size (RW Data + ZI Data) 1656 ( 1.62kB)
Total ROM Size (Code + RO Data + RW Data) 3776 ( 3.69kB)2
3
4
5
6
7
8
9
Grand Totals 那一行就是 Build Output 里 Program Size 的来源。ROM Totals 的 ZI Data 是 0,因为零初始化数据不用存进 Flash。最后三行给出 4.1 节算过的结果:烧进 Flash 的是 Total ROM Size 3776 字节,运行时占用的 RAM 是 Total RW Size 1656 字节。
RO Data 一共 442 字节,拆开是向量表 392 字节、AHBPrescTable 16 字节、链接器生成的 Region$$Table 32 字节,再加 2 字节对齐填充。Map 的 Object Totals 下面有一行 (incl. Generated),那 32 字节指的就是这张表。
4.6.8 常用查法
| 想知道 | 在 Map 里怎么找 |
|---|---|
| 某个变量或函数在哪个地址 | 在 Image Symbol Table 里搜它的名字 |
| 函数写了,断点却打不上 | 在 Removing Unused 里搜函数名,看是被删除还是被内联 |
| 某个函数为什么被链接进来 | 在 Cross References 里搜 for 函数名 |
| 中断函数名有没有写对 | 在符号表里看它的地址是不是和默认处理函数相同 |
| Flash、RAM 还剩多少 | 看 Memory Map 里各执行域的 Size 和 Max |
| 栈和堆具体在哪 | 在 Memory Map 的 RW 执行域里找 STACK、HEAP 两个段 |
| 哪个文件最占空间 | 看 Image component sizes,按 Code 或 RO Data 排序比较 |
五 动手:用调试器走一遍启动
5.1 实验准备
准备天空星核心板、筑基学习板和 立创DAPLink 调试器,打开 14-BASIC-HOW-THE-SYSTEM-STARTS/fdb_hal_led/MDK-ARM/fdb_hal_led.uvprojx,先 Rebuild 一次,确认 0 Error。
建议先复制一份工程再做实验。后面要改调试设置,在副本上折腾,原工程随时能拿来对比。
TIP
每次 Rebuild 以后,先打开新的 Map 看一眼 __initial_sp、Reset_Handler、Region$$Table$$Base 和 SystemCoreClock 的地址。下面的步骤里,凡是写了具体地址的地方,都换成你 Map 里的值。
5.2 让调试器停在复位入口
µVision 默认会在进入调试后自动运行到 main(),这样启动过程在我们看到之前就执行完了。打开 Options for Target → Debug,取消勾选 Run to main()。

然后点 Start/Stop Debug Session 进入调试。正常情况下,程序会停在 Reset_Handler 的第一条指令上。如果还是停在 main(),检查调试初始化脚本里有没有 g, main 之类的自动运行命令;也可以在 Command 窗口输入 RESET 让芯片复位并停住。
5.3 观察一:向量表、MSP 和 PC
停在复位入口后,打开 Memory 窗口,在地址栏输入 0x08000000,右键把显示格式改成 Unsigned → Int(按 32 位显示)。再看 Registers 窗口里的 R13(SP)、MSP 和 R15(PC)。
| 观察项 | 按本次 Map 的预期值 | 怎么判断 |
|---|---|---|
Memory 0x08000000 | 0x20000678 | 等于 Map 里的 __initial_sp |
Memory 0x08000004 | 0x08000229 | 去掉最低位后是 Reset_Handler 的地址 |
| MSP | 0x20000678 | 和向量表第 0 项相同 |
| PC | 0x08000228 | 停在 Reset_Handler 第一条指令 |
MSP(SP) 和 PC 的值,正是 CPU 复位时从向量表里取出来的那两个数。

5.4 观察二:初值复制
想确认 __scatterload_copy 真的复制了初值,单看复制后的结果不够:调试器下载程序前,芯片很可能已经跑过一遍旧程序,SystemCoreClock 本来就是 16000000。我们先主动把它改脏,再看它有没有被恢复。
- 在 Disassembly 窗口里找到
__scatterload_copy(本次地址0x080001C4),右键插入断点;也可以在 Command 窗口输入BS __scatterload_copy。 - 按 F5 运行,程序停在复制开始前。
- 在 Watch 窗口添加
SystemCoreClock,把它的值改成0xA5A5A5A5。也可以在 Memory 窗口直接改地址0x20000010。 - 再在
__scatterload_zeroinit(0x080001E0)下一个断点,按 F5 运行到那里。 - 回头看
SystemCoreClock,它应该已经变回16000000(0x00F42400)。

5.5 观察三:清零
清零的验证方法一样:先写一个非零值,再看它有没有被清成 0。
ZI 区里能拿来做观察的位置有很多,这里选堆里的 0x20000100。这个工程没用 malloc(),这个地址在整个运行过程中都不会被使用,结果最干净。
- 程序停在
__scatterload_zeroinit的断点上(接着 5.4 节第 4 步)。 - 在 Memory 窗口把
0x20000100改成0xA5A5A5A5,改完回读一次确认写进去了。 - 在
main(0x08000E30)下断点,按 F5 运行。 - 再看
0x20000100,应该已经是0x00000000。

TIP
这种“先改脏、再看恢复”的做法很值得养成习惯。只看结果的话,RAM 恰好本来就是这个值时,实验永远“成功”,但它什么也没证明。
5.6 观察四:进入 main()
停在 main() 的第一条指令时,C 运行环境已经全部就绪。可以在 Watch 窗口里确认:
SystemCoreClock是16000000。这时系统还跑在内部 16 MHz 的 HSI 上,PLL 还没开;uwTick是 0,uwTickPrio是 16,uwTickFreq是 1;- MSP 仍然是
0x20000678(2.1.5 节的第二张截图里能看到)。__user_setup_stackheap会按__user_initial_stackheap返回的 R1 重新设置一次栈指针,而这份构建的main()开头没有压栈:它永远不返回,编译器省掉了保存寄存器的步骤。如果你的main()反汇编开头有PUSH,SP 就会比栈顶小一点。
接着单步越过 SystemClock_Config(),SystemCoreClock 会变成 168000000。全速运行后,核心板 PB2 和筑基学习板 PB8 上的两颗 LED 每 500 ms 翻转一次。

实验结束后,记得把 Run to main() 勾回去,平时调试会方便很多。
六 进入 main() 之后
6.1 初始化顺序
int main(void)
{
g_reset_csr = RCC->CSR;
__HAL_RCC_CLEAR_RESET_FLAGS();
HAL_Init();
SystemClock_Config();
MX_GPIO_Init();
BSP_LED_AllOff();
while (1)
{
BSP_LED_Toggle(BSP_LED_CORE);
BSP_LED_Toggle(BSP_LED_FDB);
HAL_Delay(500U);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
(省略了 CubeMX 生成的 USER CODE 注释。最前面两行是 2.1.5 节加的复位原因记录,它只读写 RCC 寄存器,不依赖时钟和 HAL,放在最前面最稳妥。)
这几行的顺序是有依赖关系的:
HAL_Init():打开 Flash 预取和指令、数据缓存,把中断优先级分组设为组 4,再调用HAL_InitTick()按当前的SystemCoreClock(16 MHz)配置 SysTick,产生 1 ms 中断,最后调用HAL_MspInit()。从这以后HAL_Delay()才能用。SystemClock_Config():打开 HSE,配置 PLL,把系统时钟切到 168 MHz。切换完成后,HAL_RCC_ClockConfig()会更新SystemCoreClock,并再调用一次HAL_InitTick(),按新频率重新配置 SysTick,所以切频以后 1 ms 依然是 1 ms。MX_GPIO_Init():打开 GPIOB 时钟,配置 PB2、PB8 为输出。BSP_LED_AllOff():两颗 LED 的有效电平相反,由 BSP 统一处理,见第 10、11 章。
如果把 GPIO 或串口初始化挪到 SystemClock_Config() 前面,编译照样通过,但外设会先按 16 MHz 配置,切频以后波特率之类依赖时钟的参数就全错了。这种顺序问题编译器查不出来,只能靠我们自己心里有数。
6.2 时钟算一遍
SystemClock_Config() 里的 PLL 参数是:
RCC_OscInitStruct.PLL.PLLM = 8;
RCC_OscInitStruct.PLL.PLLN = 336;
RCC_OscInitStruct.PLL.PLLP = RCC_PLLP_DIV2;
RCC_OscInitStruct.PLL.PLLQ = 4;2
3
4
天空星核心板的 HSE 是 8 MHz(见【12】RCC时钟配置),代入计算:
PLL 输入 = 8 MHz ÷ 8 = 1 MHz
VCO 输出 = 1 MHz × 336 = 336 MHz
SYSCLK = 336 MHz ÷ 2 = 168 MHz
PLL48CLK = 336 MHz ÷ 4 = 84 MHz
HCLK = 168 MHz(AHB 不分频)
PCLK1 = 168 MHz ÷ 4 = 42 MHz
PCLK2 = 168 MHz ÷ 2 = 84 MHz
Flash 等待周期 = 52
3
4
5
6
7
8
6.3 main() 为什么不能返回
回看 4.5 节的反汇编,main() 后面紧跟着 bl exit。顺着 exit 往下走,最后到达的是:
08000410 <_sys_exit>:
8000410: ldr r1, [pc, #4]
8000412: movs r0, #24
8000414: bkpt 0x00ab
8000416: b.n 80004162
3
4
5
bkpt 0xab 是 Arm 半主机(semihosting)调用,本意是通知连接着的调试器“程序结束了”。接着调试器时,程序会停在这里;脱离调试器单独运行时,CPU 遇到 BKPT 指令却没有调试器响应,会进入 HardFault。
裸机程序没有操作系统接收 main() 的返回值,所以 main() 里要有一个永远不退出的循环。以后用了 RTOS,main() 会在启动调度器以后不再返回,道理是一样的。
6.4 Error_Handler 只会死循环
SystemClock_Config() 里,HSE 起振失败或者 PLL 锁不住时,会调用 Error_Handler():
void Error_Handler(void)
{
__disable_irq();
while (1)
{
}
}2
3
4
5
6
7
关中断、原地死循环,什么也不告诉你。而且这份构建里,编译器把它内联进了 SystemClock_Config(),Map 里能看到这一行:
Removing main.o(i.Error_Handler), (4 bytes).反汇编里,两次失败检查后面各跟着一对 cpsid i 和 b.n 自身。所以在 Error_Handler 这个函数名上下断点是停不住的,要在 SystemClock_Config() 的反汇编里找到 cpsid i 那两行下断点。
产品代码里,Error_Handler() 至少应该能告诉你“死在了哪一步”,比如记一个阶段码、点亮一个错误指示灯。
6.5 为什么不在 Reset_Handler 里 printf
调试启动问题时,很自然会想在 Reset_Handler 一开头打印点什么。可 printf() 要用到栈、C 库的内部数据、已经配好的 USART(比如 USART1 的 PA9/PA10)、正确的时钟和波特率,这些在 Reset_Handler 刚开始时一样都没有准备好。就算勉强打印出来,启动故障和串口故障也会搅在一起,更难查。
越靠近复位入口,越要用依赖少的观察手段:断点、Registers 窗口、Memory 窗口最可靠;其次是翻转一个 GPIO 当标记;完整的格式化日志放在时钟和串口都初始化好以后再用。
七 程序没跑起来,按层排查
7.1 先确定停在了哪一层
启动链路是一层一层往下走的,排查时也按层来:
CPU 取向量表
↓
进入 Reset_Handler
↓
SystemInit() 返回
↓
__scatterload 完成
↓
进入 main()
↓
HAL_Init() / SystemClock_Config() 完成
↓
进入 while (1)2
3
4
5
6
7
8
9
10
11
12
13
关闭 Run to main() 后,在每一层的入口下断点,程序最远停在哪一层,问题就在那一层和下一层之间。程序连 main() 都没进的时候,去改串口代码找日志是没有用的。
下面几个是最常见的情况,按“现象 → 原因 → 检查 → 解决”整理。
7.2 一进调试就停在 main(),看不到 Reset_Handler
现象:下载后第一次暂停已经在 main() 里。
原因:Debug 设置里勾选了 Run to main(),调试器在后台把启动代码跑完了才停。
检查:看 Options for Target → Debug 里的 Run to main(),以及调试初始化脚本里有没有自动运行命令。
解决:取消勾选,重新进入调试。
7.3 中断一来,程序就卡在启动文件里的死循环
现象:写好的中断服务函数从来不执行,暂停后发现 PC 停在启动文件里一个 B . 循环上。
原因:启动文件给每个中断都提供了弱定义,没被替换的全部指向同一个默认处理函数。中断函数名拼错一个字母,链接器不会报错,只是悄悄用了默认版本。
检查:先算出这个中断在向量表里的位置。外设中断的表项地址是:
表项地址 = 向量表基址 + 4 × (IRQn + 16)以 EXTI0_IRQn = 6 为例,偏移是 4 × 22 = 0x58。在本工程的 HEX 里,0x08000058 处是 0x08000243,Map 显示这个地址就是默认处理函数:
EXTI0_IRQHandler 0x08000243 Thumb Code 0 startup_stm32f407xx.o(.text)
WWDG_IRQHandler 0x08000243 Thumb Code 0 startup_stm32f407xx.o(.text)2
所有没实现的中断都共用这一个地址。自己实现了某个中断以后,Rebuild 再查一次:它的地址应该变成你的函数,所属目标文件也应该是你的 .o。
解决:从启动文件里复制函数名,不要手敲;确认实现这个函数的 .c 文件加入了工程;用 C++ 写时加上 extern "C"。
7.4 全局变量的初值不对,或者零初值变量不是 0
现象:带初值的全局变量一进 main() 就不对,或者没写初值的变量不是 0。
原因:可能是 __scatterload 没正确执行,但更常见的是它执行得好好的,之后又被别的东西改坏了,比如栈溢出、数组越界、DMA 写过界、野指针。
检查:用时间二分来区分。在 __scatterload_zeroinit 返回后和 main() 第一行,分别看同一个变量:
- 两个时间点都对,后来才坏:启动代码没问题,去查栈、越界和 DMA;
__scatterload结束时就不对:去 Map 里核对这个变量落在哪个执行域,以及Region$$Table里有没有覆盖它的表项。
解决:栈不够就在启动文件里加大 Stack_Size;自定义了 scatter 文件的,确认新增的 RAM 区域也被 __scatterload 处理到了。
7.5 还没进 main() 就进了 HardFault
现象:复位后很快停在 HardFault_Handler。
原因:常见的有向量表第 0 项不在有效 RAM 范围内、复位入口的 Thumb 位不对、FPU 没打开就执行了浮点指令、SystemInit() 里访问了不存在的外设或外部存储器。
检查:
- Memory 窗口看
0x08000000和0x08000004两个值是否合理; - 在
SystemInit()返回处下断点,看能不能走到; - 检查 Options for Target → Target 里的 Floating Point Hardware 设置和
__FPU_USED是否一致; - 对照 Map,看
__main之后最远执行到了哪个函数。
解决:按检查结果修正启动文件、scatter 文件或编译选项。HardFault 现场的详细分析方法,会在后续的 HardFault 专题里展开。
7.6 接着调试器能跑,拔了调试器就不跑
现象:调试器下载后一切正常,断电重新上电后 LED 不闪。
原因:调试器下载后会直接设置 PC 和 SP,绕过了 BOOT 引脚采样;脱离调试器后,真正的启动条件才起作用。另外,程序如果执行到了 main() 返回后的 bkpt 或者其他 BKPT 指令,接着调试器只是暂停,脱机就会进 HardFault。
检查:BOOT0 是否为低电平;HSE 在冷启动时能否起振;代码里有没有依赖调试器打开的功能(比如 DWT 计数器);Debug 和 Release 配置是不是同一份固件。
解决:每次改了启动相关的东西,都做一次“断电、拔调试器、重新上电”的测试,这才是真正的启动验收。
7.7 HAL_Delay() 卡死,或者延时时间不对
现象:程序加了一些功能以后,HAL_Delay() 永远不返回,或者延时明显变长、变短。
原因:除了 SysTick 中断优先级被改坏这类常见原因,还要想到 2.4 节说的布局:栈溢出会往低地址覆盖,在这个工程里最后踩到的正是 uwTick、SystemCoreClock 这些 HAL 变量。
检查:在 Memory 窗口看 0x20000278 往上的一段,如果栈底附近已经有大量非零数据,说明栈已经用到了底;再看 uwTick 是否还在正常递增。
解决:减小局部数组,或者加大 Stack_Size;把大缓冲改成静态变量,让它进入 ZI 区,占用一目了然。
7.8 程序隔一会儿就自己重新开始
现象:没人碰板子,LED 却时不时从头开始闪,或者初始化代码被反复执行。
原因:芯片在反复复位。常见来源有看门狗没按时喂、程序进了 HardFault 后由软件触发了复位、电源跌落触发了欠压复位,或者 NRST 上有干扰。
检查:按 2.1.5 节的方法,在 main() 一开头保存 RCC_CSR,看是哪个标志置位:
- IWDGRSTF / WWDGRSTF:查程序在哪里卡住、喂狗的周期是否太长;
- SFTRSTF:搜一下代码里所有调用
HAL_NVIC_SystemReset()的地方; - BORRSTF 单独出现、或者伴随电机、继电器动作出现:用示波器看 3.3V 电源有没有跌落;
- 只有 PINRSTF:检查 NRST 走线、复位按键和外接调试器的 RST 线。
解决:先根据标志确定复位来源,再到对应的方向上查,不要一上来就改启动文件。
八 工程进阶(选读)
这一部分写给已经能独立完成工程、准备做产品的同学。第一次学可以先跳过,遇到对应问题时再回来翻。
8.1 GNU 工具链的启动模板
展开:同一件事,GNU 工具链怎么做
用 CLion、VS Code + CMake 或 STM32CubeIDE 时,工程用的是 GNU Arm 工具链(arm-none-eabi-gcc)。启动文件同名,内容却完全不同:初值复制和清零没有交给运行库,而是直接写在启动汇编里。
栈顶在 RAM 末端。
ST 的 GNU 链接脚本里,栈顶由一个符号算出来:
_estack = ORIGIN(RAM) + LENGTH(RAM);RAM 区域从 0x20000000 开始、长 128 KB 时,_estack 就是 0x20020000。向量表第 0 项放的是它,栈从 RAM 最高处往下长,和 Keil 把栈放在全局变量后面的做法正好相反。链接脚本里的 _Min_Stack_Size 只用来在链接时检查剩余空间够不够,并不限制运行时栈能用多少。
.data 的两个地址。
_sidata = LOADADDR(.data);
.data :
{
_sdata = .;
*(.data)
*(.data*)
_edata = .;
} >RAM AT>FLASH2
3
4
5
6
7
8
9
>RAM 是运行地址(VMA),AT>FLASH 是存放地址(LMA),LOADADDR(.data) 取出存放地址交给启动汇编使用。这和 Keil scatter 文件里的 Exec base、Load base 是一回事。
复制和清零写在汇编里。
GNU 模板的 Reset_Handler 主干如下:
Reset_Handler:
ldr sp, =_estack
bl SystemInit
ldr r0, =_sdata /* RAM 目标起点 */
ldr r1, =_edata /* RAM 目标终点 */
ldr r2, =_sidata /* Flash 中的初值 */
movs r3, #0 /* 偏移 */
b LoopCopyDataInit
CopyDataInit:
ldr r4, [r2, r3]
str r4, [r0, r3]
adds r3, r3, #4
LoopCopyDataInit:
adds r4, r0, r3
cmp r4, r1
bcc CopyDataInit /* 无符号小于才继续,_edata 本身不写 */
ldr r2, =_sbss
ldr r4, =_ebss
movs r3, #0
b LoopFillZerobss
FillZerobss:
str r3, [r2]
adds r2, r2, #4
LoopFillZerobss:
cmp r2, r4
bcc FillZerobss
bl __libc_init_array /* C++ 静态构造函数等 */
bl main
bx lr2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
几个细节值得留意:
- 先跳到
LoopCopyDataInit做判断再复制,.data长度为 0 时一个字也不会多写; - 两个循环都是左闭右开区间
[start, end),结束符号指向最后一个对象之后; - 每次按 4 字节搬运,依赖链接脚本把段边界按 4 字节对齐,自己改链接脚本时要保住这个前提;
__libc_init_array()对应 Keil 运行库里的初始化步骤,C++ 全局对象的构造函数在这里被调用。链接时如果用了--gc-sections,链接脚本里要用KEEP()保住.init_array和.isr_vector,否则它们可能因为“没被调用”而被删掉。
CCM RAM 的初值不会自动复制。
ST 的 GNU 链接脚本里定义了 .ccmram 段,也给出了它的存放地址,但模板的 Reset_Handler 只复制 .data、只清零 .bss。把带初值的变量放进 .ccmram,进了 main() 它就是随机值,要自己补复制代码。另外,STM32F407 的 DMA 访问不了 CCM RAM(见 RM0090 Rev 21 的总线架构),DMA 缓冲区不能放在这里。
用命令行工具查 ELF。
GNU 工程生成的 .elf 和 Keil 的 .axf 是同一种格式,下面这些命令对两者都能用:
# 每个段的大小和运行地址
arm-none-eabi-size -A firmware.elf
# 向量表的原始字节
arm-none-eabi-objdump -s -j .isr_vector firmware.elf
# 符号最终落在哪里,按地址排序
arm-none-eabi-nm -n -S firmware.elf
# 运行地址和存放地址的对应关系
arm-none-eabi-readelf -l firmware.elf
# 反汇编,确认 Reset_Handler 实际生成了什么指令
arm-none-eabi-objdump -d firmware.elf2
3
4
5
6
7
8
9
10
11
12
13
14
拿本章的 Keil 工程试一下,readelf -l fdb_hal_led.axf 能看到一个 FileSiz=0x00ec0、MemSiz=0x01524 的 LOAD 段:文件里只存了 Flash 的内容,多出来的部分就是运行时要在 RAM 里准备的空间。
两套工具链的职责对照。
| 启动职责 | Keil(Arm Compiler 5) | GNU Arm |
|---|---|---|
| 向量表放置 | RESET 段 + scatter 的 +First | .isr_vector 段 + 链接脚本 KEEP() |
| 初始栈顶 | __initial_sp,栈段末尾 | _estack,RAM 末端 |
| 复制 RW 初值 | __scatterload_copy | 启动汇编 CopyDataInit 循环 |
| 清零 ZI | __scatterload_zeroinit | 启动汇编 FillZerobss 循环 |
| 运行库与构造函数 | __rt_entry、__rt_lib_init | __libc_init_array() |
| 布局描述 | .sct scatter 文件 | .ld 链接脚本 |
迁移工程时,按这张表逐项确认职责有没有落实,不用逐行去找同名函数。
8.2 向量表重定位与 Bootloader 跳转
展开:应用不从 0x08000000 开始时要处理什么
CPU 响应中断时,是按系统控制块(System Control Block,SCB)里的向量表偏移寄存器(Vector Table Offset Register,VTOR)去找向量表的,不是去找函数名。单个应用从 0x08000000 启动时,VTOR 的默认值 0 正好指向映射后的向量表,什么都不用做。
工程里加了 Bootloader,应用往后挪到比如 0x08010000 时,至少要同时改三处:
- 把应用的起始地址改成
0x08010000:保持勾选 Use Memory Layout from Target Dialog 时改 Target 页的 IROM1,用自己的 scatter 文件时直接改.sct(两者的关系见 4.2.2、4.2.3 节); - 在
system_stm32f4xx.c里打开USER_VECT_TAB_ADDRESS,把VECT_TAB_OFFSET设成0x10000,让SystemInit()改写 VTOR; - Bootloader 侧按这个地址跳转。
system_stm32f4xx.c 的注释要求 VECT_TAB_OFFSET 是 0x200 的整数倍。应用分区起始地址选在扇区边界,一般就自然满足了。只改了第 1 处,程序能从复位入口跑起来,第一次进中断就会跳到 Bootloader 的向量表里,这是 Bootloader 工程里最常见的“一进中断就跑飞”。
Bootloader 跳到应用也不只是一次函数调用。跳转前要:
- 校验应用镜像,确认向量表第 0 项落在 RAM 范围内、第 1 项落在应用分区内且最低位为 1;
- 停掉 SysTick,关闭不再使用的外设中断,清除 NVIC 里挂起的中断;
- 按需要把 Bootloader 用过的时钟和外设恢复到应用能接受的状态;
- 设置应用的 VTOR,用应用向量表第 0 项设置 MSP,再跳到第 1 项保存的复位入口。
具体顺序和屏障指令要结合 PM0214 Rev 10 的异常模型和应用自己的启动代码确认,不要直接照搬别的系列的跳转代码。
8.3 跨复位保存诊断信息
展开:让一块 RAM 不被启动代码清零
想在软件复位或看门狗复位后知道“上次死在哪里”,可以留一块不被 __scatterload 处理的 RAM,专门保存诊断记录。
在 scatter 文件里开一个 UNINIT 区。
Keil 的做法是按 4.2.3 节的步骤取消勾选 Use Memory Layout from Target Dialog,改用自己的 scatter 文件,单独划出一段带 UNINIT 属性的执行域,例如把 IRAM2 末尾的 256 字节留出来:
RW_NOINIT 0x2001FF00 UNINIT 0x00000100 {
*(.bss.noinit)
}2
3
C 代码里把记录放进这个段:
typedef struct {
uint32_t magic;
uint16_t version;
uint16_t length;
uint32_t sequence;
uint32_t reset_flags;
uint32_t startup_stage;
uint32_t fault_pc;
uint32_t fault_lr;
uint32_t crc32;
} startup_record_t;
__attribute__((section(".bss.noinit")))
static startup_record_t g_startup_record;2
3
4
5
6
7
8
9
10
11
12
13
14
Rebuild 后到 Map 里确认两件事:这个变量确实落在 0x2001FF00 开始的区域;Region$$Table 里没有任何表项覆盖这段地址。原来的 RW_IRAM2 要相应缩短,两个区域不能重叠。ArmCC 的 section 属性写法、UNINIT 的具体语义,以 armlink 用户指南 为准。
不能只看 magic 就相信它。
上电或掉电复位后,这块 RAM 可以是任意值,碰巧和 magic 相同的概率不高,但不是零。读取时依次检查 magic、版本、长度和 CRC,全部通过才采用。写新记录时先写内容,最后写 CRC,这样复位发生在写入中途时,旧记录会因为 CRC 不对而被丢弃,不会被误用。结构体字段改了顺序或长度,就换一个版本号。
6.4 节提到的阶段码也可以放进这个结构:每完成一步初始化就更新 startup_stage,Error_Handler() 里再记下当时的 PC 和 LR。下次启动读出来,就知道上次停在了哪一步。
8.4 栈到底要留多少
展开:静态分析、水位测量和异常现场
启动文件里的 Stack_Size = 0x400 只是一个预留值,链接器不会检查程序运行时到底用了多少。
先看 Keil 的静态分析。
MDK-ARM/fdb_hal_led/fdb_hal_led.htm 是链接器生成的调用图,开头一行是:
Maximum Stack Usage = 176 bytes + Unknown(Functions without stacksize, Cycles, Untraceable Function Pointers)176 字节是能静态追踪到的最深调用链,这里是 main → SystemClock_Config → HAL_RCC_ClockConfig → HAL_RCC_GetSysClockFreq → __aeabi_uldivmod。后面的 Unknown 很重要:汇编函数、递归、函数指针、中断嵌套,静态分析都算不进去。
再算上中断。
Cortex-M4 进入异常时,硬件会自动压栈 8 个字(32 字节);如果被打断的代码正在用 FPU,压栈会扩展到 26 个字(104 字节),见 PM0214 Rev 10 的异常入栈说明。所有中断和异常都用 MSP,最坏情况要按“主程序最深调用 + 每一级可能嵌套的中断各自的深度和压栈”加起来算。
最后用水位实测。
4.4 节提到,启动时整块栈都被清成了 0。进入 main() 以后,在主循环里跑完各种最坏场景,再用 Memory 窗口从栈底 0x20000278 往上找第一个非零的字,它离栈顶 0x20000678 的距离就是实际用过的最大深度。0 是很常见的数据,这样测会偏乐观;想更准,可以在 main() 一开头把栈里没用到的部分填成 0xA5A5A5A5 之类的特征值,再按同样方法找。
经验上,实测峰值再留出 50% 以上的余量比较稳妥。用了 RTOS 以后,每个任务都有自己的栈(使用 PSP),要分别预算。
8.5 启动时间
展开:main() 之前花了多少时间
__scatterload 的耗时和要处理的字节数成正比。本工程只复制 20 字节、清零 1636 字节,时间可以忽略。可要是在全局变量里放一个几十 KB 的显示缓冲,它虽然不增加 HEX 大小,每次启动却都要清零一遍。
估算时要注意,__scatterload 执行时系统还跑在 16 MHz 的 HSI 上,Flash 等待周期也还是复位值,不能拿 168 MHz 去算。要精确数字,可以在 Reset_Handler 开头和 main() 开头各翻转一次 GPIO,用示波器或逻辑分析仪量两次翻转的间隔。
启动时间敏感的产品(比如上电后很快就要响应的设备),可以把不需要清零的大缓冲放进 8.3 节那样的 UNINIT 区,在真正使用前再按需初始化。
8.6 发布固件时留好 AXF 和 Map
展开:现场出了 HardFault,靠什么定位
HEX 和 BIN 只有要烧进 Flash 的内容,没有符号和调试信息。设备在现场报出一个 HardFault 时的 PC 值,只有对照当时那一版的 AXF 和 Map,才能还原到具体函数和源码行。
每次发布,建议把这些一起归档:
- 带调试信息的
.axf(或 GNU 的.elf)和.map; - 实际烧录的
.hex/.bin及其哈希值; - 源码版本号、编译器版本和编译选项;
- Flash 分区表(有 Bootloader 时尤其重要)。
AXF 里的调试段只留在电脑上,不会烧进芯片,文件再大也不占 MCU 的 Flash。判断 Flash 占用,看 Map 末尾的 Total ROM Size。
九 练习与小结
9.1 练习一:只凭两个 32 位数判断能不能起跑
某个工程的向量表前两项是:
word[0] = 0x2001FFF8
word[1] = 0x080101352
已知这个工程的主 SRAM 是 [0x20000000, 0x20020000),应用的代码区是 [0x08010000, 0x08070000)。这两个值能不能作为初始 MSP 和复位入口?还要再核对什么?
参考分析
0x2001FFF8 在主 SRAM 范围内,并且是 8 的倍数,具备作为初始栈顶的基本条件。它是不是这个工程预期的栈顶,还要去 Map 里核对 __initial_sp(或 GNU 的 _estack)。
0x08010135 最低位是 1,满足 Thumb 要求;去掉最低位得到 0x08010134,落在应用代码区里。它是不是真的指向 Reset_Handler,要用 Map 或反汇编确认。
此外,应用从 0x08010000 开始,说明前面有 Bootloader,还要确认 VTOR 是否改到了 0x08010000、Bootloader 的跳转是否按 8.2 节的要求处理,并在真板上实际跑一次。
9.2 练习二:自己解析一次 Region$$Table
假设某次构建的 Map 显示 Region$$Table$$Base = 0x08001200,从这个地址开始的 32 字节依次是:
40 12 00 08 00 00 00 20 24 00 00 00 C4 01 00 08
64 12 00 08 24 00 00 20 00 08 00 00 E0 01 00 082
写出两个表项各自的源地址、目标地址、长度和处理函数,说明这次构建 RW-data 和 ZI-data 各有多少字节。
参考分析
| 表项 | 源地址 | 目标地址 | 长度 | 处理函数 |
|---|---|---|---|---|
| 1 | 0x08001240 | 0x20000000 | 0x24(36 字节) | 0x080001C4,复制 |
| 2 | 0x08001264 | 0x20000024 | 0x800(2048 字节) | 0x080001E0,清零 |
RW-data 是 36 字节,初值从 Flash 0x08001240 复制到 RAM 0x20000000。ZI-data 是 2048 字节,从 0x20000024 开始清零。第一项的源地址加上长度正好等于第二项的源地址,说明链接器把 ZI 的“源地址”顺排在了 RW 初值后面,但清零函数并不读它。
细心的话会发现,表里的处理函数地址是偶数,和 2.3 节说的“Thumb 函数地址都是奇数”对不上。这是因为表里存的是函数代码的实际位置,__scatterload 在跳过去之前会用一条 orr.w r3, r3, #1 把 Thumb 位补上。
9.3 练习三:设计一个验证清零的最小实验
要求:不修改启动文件,不依赖串口,不假设复位后 RAM 的初始内容。写出实验步骤、观察对象和成功标准。
参考方案
- 在
main.c的USER CODE BEGIN PV里定义一个大于 8 字节的零初值数组,例如volatile uint32_t g_zi_probe[4];。超过 8 字节是为了避开 4.3 节提到的--bss_threshold,让它确实进入 ZI 区。 - 在
USER CODE BEGIN 2里读一次g_zi_probe[0],防止它被链接器当作未使用而删除。 - Rebuild,在 Map 里确认
g_zi_probe的地址落在Region$$Table第 2 项的清零范围内。 - 关闭 Run to main(),进入调试,在
__scatterload_zeroinit处下断点并运行到这里。 - 在 Memory 窗口把
g_zi_probe[0]的地址改成0xA5A5A5A5,回读确认。 - 在
main处下断点运行,再读同一地址。
成功标准:第 6 步读到 0,且 PC 确实已经越过 __scatterload_zeroinit。
9.4 本章小结
回到开头那个问题:按下复位键以后,CPU 执行的第一条指令不在 main() 里,而在 Reset_Handler 里。
一切都从复位开始。上电、按键、看门狗、软件请求,来源不同,复位释放以后走的都是同一条路,复位原因则留在 RCC_CSR 里等我们去读。从复位到 main(),中间发生的事情可以串成一句话:
CPU 从向量表取出栈顶 0x20000678 和复位入口 0x08000229;Reset_Handler 调用 SystemInit() 打开 FPU,再跳到 Arm 运行库的 __main;__main 照着链接器生成的 Region$$Table,把 20 字节初值从 Flash 复制到 RAM,把 1636 字节 ZI 区清零;__rt_entry 设置好栈和堆、初始化运行库,最后才调用我们的 main()。
这些地址不用背。启动文件写了做什么,scatter 文件和 Map 告诉你东西放在哪,调试器让你亲眼确认每一步。以后遇到“程序没跑起来”,按第七部分的层次一层层往下查,大部分问题都能定位到具体哪一步。
十 参考资料
- PM0214 Rev 10:STM32 Cortex-M4 编程手册。查看向量表、MSP/PSP、异常入栈和 SCB 寄存器。
- RM0090 Rev 21:STM32F405/407 等系列参考手册。查看存储器映射、启动配置、复位与时钟控制、总线架构。
- DS8626 Rev 12:STM32F405xx/STM32F407xx 数据手册。核对 Flash、SRAM、CCM RAM 容量。
- AN2606:STM32 系统存储器 Bootloader 说明。核对系统存储器启动模式和支持的接口。
- UM1725 Rev 8:STM32F4 HAL 与 LL 驱动说明。核对
HAL_Init()、HAL_InitTick()和 RCC HAL 的行为。 - armlink 用户指南:Arm Compiler 5 链接器文档,scatter 文件语法、执行域属性和
__scatterload。 - armcc 用户指南:--bss_threshold:小的零初值变量放入 RW 段的规则。
- ST STM32F4 CMSIS-Device 源码:各工具链的启动文件模板与
system_stm32f4xx.c。