STM32 学习记录:内存分布、Bootloader 与 Keil 地址配置
整理 MCU 内存这部分内容时,我比较容易混淆几个概念:Flash 地址和 RAM 地址有什么区别?Bootloader 是不是另一套程序?向量表放在 Flash 里,为什么第一项又是 RAM 地址?Keil 中不同位置的 Start、Size,是不是都在设置下载地址?
我的理解是,可以沿着“程序保存在 Flash → 复位时读取向量表 → 执行启动代码 → 进入 main → 使用 RAM 中的变量和栈”这条顺序看。下面以 STM32F103C8 为例,把地址配置、启动过程和编译后的内存统计放在一起,记录目前理解到的内容。
1. 先把地址、Flash 和 RAM 分清
MCU 的地址可以理解成 CPU 访问存储器或外设时使用的位置编号。0x表示十六进制,地址每增加 1,代表向后移动一个字节;地址范围里的空档也不一定有实际存储器。
STM32F103C8 的用户 Flash 为 64KB,SRAM 为 20KB。常见的几个区域如下,地址范围均包含两端。STM32F103C8 数据手册、ST Flash 编程手册 PM0075
| 区域 | 地址范围 | 容量 | 主要用途 |
|---|---|---|---|
| 用户 Flash | 0x08000000~0x0800FFFF | 64KB | 保存自己烧录的程序、常量、需要持久化的数据,断电保留 |
| SRAM | 0x20000000~0x20004FFF | 20KB | 保存运行时变量、缓冲区、堆和栈,断电后不能依赖其内容 |
| 系统存储器 | 0x1FFFF000~0x1FFFF7FF | 2KB | 存放 ST 出厂预置的 Bootloader |
地址范围与容量的关系是:容量 = 结束地址 - 起始地址 + 1。例如用户 Flash 的长度是0x10000字节,即 65536 字节;结束地址0x0800FFFF本身不是容量。
这里还容易混淆一个地址:0x00000000。Cortex-M3 复位时从这里读取启动向量;在 F103 选择用户 Flash 启动时,硬件会把 Flash 开头映射到这个地址。因此它可以理解成同一片内容的另一个访问入口,不是又增加了一块 Flash。
另一个问题是:静态局部变量退出函数后保存在哪里?答案仍然是 RAM。static改变了变量的存储期,让它在整个程序运行期间持续存在;并没有把它自动写入 Flash。
| 变量写法 | 通常存放位置 | 再次进入函数时 | 断电或正常重新启动后 |
|---|---|---|---|
函数内int count = 0; | 栈或寄存器 | 再次执行初始化 | 不保留上次结果 |
函数内static int count = 0; | RAM 的静态存储区域 | 保留上次调用留下的值 | 重新初始化为 0 |
| 显式写入 Flash 的计数或配置 | 用户 Flash 的指定区域 | 由程序读取使用 | 可以保留,但需处理擦写和数据有效性 |
所以“函数退出后还在”和“断电后还在”是两件事:前者可以靠静态存储期,后者需要非易失存储。
2. Bootloader、APP 和向量表怎样连起来
两套工程,对应两份固件
Bootloader 是一套独立的启动程序,APP 是实现业务的应用程序。这里的 APP 指 MCU 应用,不是手机软件。两个工程通常分别编译,各有自己的启动代码和main(),最终烧到同一颗 MCU 的不同用户 Flash 区域。
| 程序 | 主要职责 | 存放位置 |
|---|---|---|
| 自定义 Bootloader | 检查镜像、选择启动版本,按方案安装或恢复固件 | 用户 Flash 中自行预留的启动区 |
| APP | 实现采集、通信、数据处理等业务 | 用户 Flash 中自行预留的应用区 |
| ST 出厂 Bootloader | 提供芯片预置的下载入口 | 系统存储器 |
以“8KB Bootloader+56KB APP”为例,用户 Flash 可以这样划分。这里是单 APP 的布局示意,没有包含 A/B 双镜像:
| 用户 Flash 分区 | 起始地址 | 结束地址 | 最大长度 |
|---|---|---|---|
| Bootloader | 0x08000000 | 0x08001FFF | 0x2000,8KB |
| APP | 0x08002000 | 0x0800FFFF | 0xE000,56KB |
这只是对同一片 Flash 的软件划分,没有物理隔板。程序越界写入、错误的下载地址或整片擦除,都可能破坏另一块区域;划分后还需要代码和工具配置共同遵守边界。
以前普通程序也有向量表
向量表是一张入口地址表,包含初始栈顶、复位入口,以及异常和外设中断处理入口。以前只有一个程序时,这张表同样存在,只是硬件和启动代码已经完成了前面的步骤。ARM 启动文件说明
现有 MDK 启动文件中,可以看到这样的定义:
__Vectors DCD __initial_sp ; 初始栈顶地址 DCD Reset_Handler ; 复位处理入口地址我检查的中容量 F103 启动文件里,向量表总共有 59 项,每项 4 字节,总长 236 字节,也就是0xEC。这个大小取决于具体芯片和启动文件,并不是所有 MCU 都相同。
假设 APP 向量表放在0x08002000,前两项的含义如下:
| 表项所在位置 | 表项保存的内容 | 保存的值指向哪里 |
|---|---|---|
0x08002000开始的 4 字节 | APP 初始栈顶地址 | RAM 中的栈顶 |
0x08002004开始的 4 字节 | APP 的Reset_Handler入口地址 | APP 的启动代码 |
| 后续表项 | 异常和中断入口地址 | 各个处理函数 |
这里要区分“地址”和“该地址里存放的值”。0x08002000是向量表的位置;真正的 APP 启动入口,是从0x08002004读出的地址值。Bootloader 不是直接执行向量表中的数据,也不是直接跳进 APP 的main()。
| 启动场景 | 谁完成最初的栈和入口加载 | 后续过程 |
|---|---|---|
| 只有一个普通 APP | 复位硬件读取该程序的启动向量 | 执行启动代码,初始化运行环境,再进入main() |
| Bootloader 加 APP | 复位硬件先读取 Bootloader 的启动向量 | Bootloader 检查 APP、完成栈和中断等运行状态的交接,再进入 APP 启动代码 |
以使用 ARMCC 5 和 ST 标准库启动模板的 STM32F103 工程为例,Reset_Handler先调用SystemInit(),再进入 C 运行库的__main,完成变量初始化等准备后调用用户编写的main()。因此,__main是运行库入口,main()才是应用入口;不同工具链的启动代码写法可能有所区别。
假设 Flash 起始地址为0x08000000,前 8KB 留给 Bootloader,那么 APP 的链接起始地址就是0x08002000。在本例的布局中,链接器将 APP 的向量表放在应用区开头,Bootloader 也从这个基址读取初始栈顶和复位入口。与此同时,还需要设置向量表寄存器VTOR,告诉 CPU 后续处理中断和异常时应查哪张表。
上述 ST 标准库模板在system_stm32f10x.c中提供了VECT_TAB_OFFSET,可以把偏移量设为:
#defineVECT_TAB_OFFSET0x2000/* 示例:APP向量表相对Flash起点偏移8KB */SystemInit()会据此将 VTOR 设置为0x08002000。这里有两个不同的动作:链接配置决定向量表实际存放在哪里,VTOR 决定 CPU 去哪里查表。修改这个宏不会搬移固件,也不会改变链接结果;Bootloader 和 APP 的初始化代码需要保持一致,避免跳转后 VTOR 又被改回 Bootloader 的表。
烧录、复位和 A/B 是另外几件事
使用 J-Link 的 SWD 接口下载时,通常保持 BOOT0 为 0。进入 ST 出厂串口 Bootloader 时,需要按 F103 的启动配置切换 BOOT 引脚并复位;如果是从用户 Flash 启动自定义 Bootloader,则保持正常 Flash 启动配置。
两份正确链接的固件分别下载到各自区域后,芯片就保存了两套程序。复位时先进入自定义 Bootloader,再由它选择 APP;它们不是两项同时运行的后台任务。APP 调用NVIC_SystemReset()后,系统重新走启动流程,但升级标志必须先可靠写入,不能只放在普通全局变量里。
| 升级布局 | 新版本怎样运行 | 需要重点解决的问题 |
|---|---|---|
| 内部 A/B 直接启动 | Bootloader 选择 A 或 B 槽启动 | 每个槽的镜像链接地址与向量表要匹配,不能默认同一个 BIN 随便换地址就能运行 |
| 固定 APP 区加暂存、备份区 | Bootloader 把新镜像安装到固定 APP 地址 | 安装中掉电的恢复,以及旧镜像的保留 |
A/B 中的 A 不永远是旧版,B 也不永远是新版。新固件需要经过试运行确认后才成为稳定版本;如果卡死,要由看门狗等机制触发复位,再由 Bootloader 根据状态执行回退。
3. Keil 中几个地址到底控制什么
一开始看 Keil,容易把所有 Start 都当成下载地址。实际上,Target、Linker 和 Flash Download 管的是不同阶段。
| 位置或字段 | 控制的事情 | 使用时要注意 |
|---|---|---|
| Target:IROM1 Start / Size | 本工程默认代码和只读数据区域的起点、长度 | Size 是字节数,不是结束地址 |
| Target:IRAM1 Start / Size | 本工程可用 RAM 的起点、长度 | 它定义整片可用范围,不等于初始栈顶 |
| Linker:自动布局选项 | 根据 Target 配置生成.sct布局 | 当前工程使用这种方式 |
| Linker:R/O Base、R/W Base、Scatter File | 手工控制代码、变量和各段位置 | 使用自定义布局时,要核对实际链接结果 |
| Flash Download:算法的 Flash 范围 | 指定烧录算法处理的存储区域 | 不能代替 APP 的链接地址配置 |
| Flash Download:RAM for Algorithm | 下载期间供烧录算法执行和暂存数据的 MCU RAM | 不是 APP 的栈,也不是电脑内存 |
勾选Use Memory Layout from Target Dialog后,Keil 会根据 Target 页生成布局;关闭后可以使用手工.sct,把指定模块或数据段安排到不同区域。最终地址以实际参与链接的配置和.map报告为准。Keil 链接器说明
界面里的I表示 Internal,即片内。IROM1/IROM2和IRAM1/IRAM2是内部区域配置栏;没有 I 的ROM1/RAM1等在界面中归为片外区域。编号 2 不意味着芯片自动多出一片存储器,也不等于 A/B 升级功能。外部存储器还要具备实际硬件连接和适合的访问方式,普通 SPI Flash 不能只填地址就变成可直接取指的 ROM。
| 其他选项 | 含义 | 与启动或升级的关系 |
|---|---|---|
| default | 让该区域参与默认分配 | 多区域时决定默认内容可以放到哪里 |
| Startup | 指定启动代码所在的只读区域 | 不负责改变芯片 BOOT 引脚 |
| NoInit | 对指定 RAM 区域不做常规启动初始化 | 不等于断电保存,也不保证调试下载时内容不被覆盖 |
| Erase Full Chip | 整片擦除用户 Flash | 会清除其中的自定义 Bootloader、APP 和持久化数据,不会擦掉 ST 出厂系统 Bootloader |
| Erase Sectors | 擦除本次镜像涉及的 Flash 扇区 | 两份程序共存时,需要核对擦除范围 |
下载地址还与文件格式有关:Keil 的 AXF 和 HEX 带有地址信息;裸 BIN 不带目标地址,需要下载工具另行指定。无论哪一种,都要与固件的链接布局匹配。把按0x08000000链接的普通 BIN 改烧到0x08002000,不等于完成了程序重定位。J-Link 文件下载说明
首次分别烧入 Bootloader 和 APP 时,要避免第二次下载执行整片擦除。完成后应按复位键或重新上电观察完整启动路径,不能只依赖调试器直接进入 APP 的现象判断。
4. 从 Program Size 和 map 看程序实际放到了哪里
学习时看到这样一组编译输出:
Program Size: Code=12796 RO-data=512 RW-data=52 ZI-data=1380这些数字单位都是字节。下面按常见的“代码在 Flash、变量运行在 RAM”布局解释;这组数字用于演算,不与工程目录里已有的旧.map强行对应。
| 项目 | 数值 | 通常包含什么 | 占用方式 |
|---|---|---|---|
| Code | 12796 | 函数机器指令、启动代码、用到的库函数等 | 通常在 Flash |
| RO-data | 512 | 常量表、字符串常量、向量表等只读数据 | 通常在 Flash |
| RW-data | 52 | 需要保存初始内容的可修改全局或静态变量,通常是非零初值 | 初始内容在 Flash,运行变量在 RAM |
| ZI-data | 1380 | 零初始化全局或静态变量,以及可能计入其中的栈、堆预留空间 | 通常在 RAM,不逐字节保存一份 Flash 零数据 |
这里也不是“有初值就是 RW,没有初值就是 ZI”。全局或静态变量显式写= 0,通常仍然进入 ZI;普通自动局部变量则是另一种情况。
| 代码示例 | 通常归类 | 初始化和使用方式 |
|---|---|---|
全局int count;或int count = 0; | ZI-data | 程序启动时清零 |
全局int gain = 3; | RW-data | 启动时将初始内容从 Flash 复制到 RAM,之后可以修改 |
全局const int table[3] = {1, 2, 3}; | RO-data | 通常直接读取 Flash 中的常量 |
全局char msg[] = "OK"; | RW-data | 可修改数组,初始内容在 Flash,运行数组在 RAM |
全局const char msg[] = "OK"; | RO-data | 通常是 Flash 中的只读数组 |
函数内static int times; | ZI-data | 启动时为 0,函数返回后仍保留当前值 |
函数内非静态int temp; | 通常使用栈或寄存器 | 没有默认的有效初值,赋值前不能直接读取 |
这些例子假设对象确实被保留,没有被编译器优化掉,也未指定特殊段。局部变量通常不会逐个作为 RW/ZI 的固定对象出现在报告中;它们使用运行时栈或寄存器,相关初始化指令属于 Code。
按常规统计,这组数字对应:
| 资源 | 计算方式 | 结果 |
|---|---|---|
| Flash | Code + RO-data + RW-data | 12796 + 512 + 52 = 13360字节,约 13.05KiB |
| RAM | RW-data + ZI-data | 52 + 1380 = 1432字节,约 1.40KiB |
RW 会同时占两边,是因为设备重启后仍需恢复初值;ZI 可以在启动时清零,所以不需要在固件里保存完整的零数组。这也是常见 Keil 镜像大小统计的理解方式。Keil MDK 官方入门示例
实际 BIN 长度还可能受对齐、地址空隙和初始化数据压缩影响。RAM 汇总也不能直接当作最坏运行用量:栈和堆的预留可能已计入 ZI,仍需检查运行期间是否超出预留,不能重复加一次,也不能因为芯片还有空闲 RAM 就认定没有溢出风险。
map 是链接后的分布报告
.map可以看作程序的内存分布清单,它不是待烧录固件,而是用来检查最终布局。生成和查阅时,要保证它与正在讨论的固件属于同一次构建。
| 想查的问题 | 搜索项或区域 | 能看到什么 |
|---|---|---|
| 程序从哪个地址加载和执行 | Load Region、Execution Region | 区域起点、大小、加载与执行位置 |
| 某个函数或变量在哪里 | 对应符号名 | 地址、大小、所属目标文件 |
| 哪个模块占空间最多 | Image component sizes | 每个.o的 Code、RO、RW、ZI 占用 |
| 向量表在哪、有多大 | __Vectors、__Vectors_Size | 向量表起点和长度 |
| 栈在哪、预留多少 | STACK、Stack_Mem、__initial_sp | 栈区边界与初始栈顶 |
Load Address是内容随镜像存放的位置,Execution Address是运行时使用的位置。代码直接从 Flash 执行时,两者通常相同;RW 的初始内容在 Flash,而运行中的变量在 RAM,因此两者通常不同。
栈大小只是预留,不是自动保护
当前启动文件中有Stack_Size EQU 0x00000400,表示预留 1024 字节栈。
栈通常向低地址增长。初始栈顶位于预留区的上边界之外,压栈后向下使用空间;超过低边界,就可能覆盖其他 RAM 数据。Stack_Size并不会自动给这片空间加一道运行时保护,后续仍需要结合调用深度、中断嵌套、局部数组以及栈水位测量判断是否够用。
我现在看地址时,会先区分它是“内容保存在这里”,还是“运行时使用这里”。例如向量表保存在 Flash,第一项的值却指向 RAM;RW 的初始内容在 Flash,变量本身运行在 RAM。把“存储位置”和“里面保存的地址值”分开,再看 Bootloader 跳转、Keil 配置和 map 报告,关系就清楚了很多。