MCU 内存架构与布局
MCU 的存储系统不只有 RAM。CPU 寄存器、Cache、SRAM、Flash 和外部存储共同组成了从高速小容量到低速大容量的存储层次。
速度快、容量小
┌──────────────────────────────┐
│ CPU 寄存器 │
├──────────────────────────────┤
│ Cache / TCM(部分高性能 MCU)│
├──────────────────────────────┤
│ 片上 SRAM │
├──────────────────────────────┤
│ 片上 Flash │
├──────────────────────────────┤
│ 外部 RAM / SPI Flash / SD 卡 │
└──────────────────────────────┘
速度慢、容量大
具体容量、速度和存储层级取决于芯片型号。低成本 MCU 可能没有 Cache,高性能 MCU 可能同时具有多级 SRAM、Cache、TCM 和外部存储接口。
存储器类型对比
| 类型 | 是否易失 | 主要用途 | 特点 |
|---|---|---|---|
| CPU 寄存器 | 是 | 当前运算数据、地址、状态 | 速度最快,数量有限 |
| Cache | 是 | 缓存近期使用的指令和数据 | 对软件通常较透明,但会影响实时性和一致性 |
| TCM | 是 | 确定性要求高的代码或数据 | 延迟低、时序可预测 |
| SRAM | 是 | 栈、堆、全局变量、运行时缓冲区 | 可直接读写,断电丢失 |
| Flash | 否 | 程序代码、只读数据、少量持久化数据 | 读快于擦写,擦写寿命有限 |
| 外部存储 | 视器件而定 | 大容量数据、资源文件、日志 | 容量大,访问延迟通常更高 |
典型程序内存布局
下面是常见的概念图,不是所有 MCU 的固定地址顺序:
高地址
┌─────────────────────┐
│ Stack │ ← SP,常见情况是向低地址增长
├─────────────────────┤
│ 未使用空间 │
├─────────────────────┤
│ Heap │ ← 常见情况是向高地址增长
├─────────────────────┤
│ .bss │ 未显式初始化或零初始化的静态存储期对象
├─────────────────────┤
│ .data │ 有非零初值的静态存储期对象
├─────────────────────┤
│ .rodata │ 只读常量
├─────────────────────┤
│ .text │ 程序代码
├─────────────────────┤
│ 中断/异常向量表 │
└─────────────────────┘
低地址
需要区分两个概念:
- 固件映像布局:各段在 Flash 中的装载位置
- 运行时布局:各段执行或读写时所在的地址
例如,.data 的初始值保存在 Flash 中,启动时会复制到 SRAM;.bss 只需在启动时清零,不需要在固件中保存一整块零数据。最终布局由芯片内存映射和链接脚本共同决定。
Flash
主要用途
Flash 是非易失性存储器,断电后仍能保存内容。在 MCU 中通常存放:
- 程序代码
- 中断向量表
- 只读常量
- 初始化数据的初始值
- 参数、校准信息或少量配置
部分 MCU 支持直接从 Flash 取指执行,这种方式称为 XIP(Execute in Place)。
读、写、擦除
Flash 的三个基本操作并不对称:
- 读取:通常可以随机读取,但高频运行时可能需要等待周期、预取或 Cache
- 编程:只能按芯片规定的最小编程单位写入
- 擦除:通常按页、扇区或块执行,不能像 SRAM 一样任意覆盖单个字节
写入前是否必须显式擦除、最小写入单位和最小擦除单位,都应以具体芯片参考手册为准。
组织结构
下面只是用于理解层级的示意,不代表所有芯片都采用相同尺寸:
Flash
├── 擦除单元 0
│ ├── 编程单元 0
│ ├── 编程单元 1
│ └── ...
├── 擦除单元 1
└── ...
常见术语:
- 编程单元:一次编程操作允许写入的最小单位
- 页(Page):在不同芯片中可能表示编程单位或擦除单位
- 扇区(Sector):常用于表示擦除单位
- 块(Block):由多个页或扇区组成的更大区域
“页”和“扇区”并没有跨厂商统一的固定大小。不要直接把 SPI NOR Flash 常见的 256 字节页、4 KiB 扇区套用到片内 Flash。
擦写寿命与数据保存
Flash 单元只能承受有限次数的擦除和编程。频繁更新参数时应考虑:
- 磨损均衡
- 多槽轮换保存
- CRC 或校验和
- 掉电保护
- 使用 EEPROM、FRAM 或外部存储
不应在主循环中无条件反复写同一个 Flash 地址。
STM32F1 HAL 示例
下面展示“解锁 → 擦除 → 编程 → 上锁 → 回读校验”的基本流程。
FLASH_USER_START 只是示例地址。实际项目必须依据具体芯片容量和页大小,在链接脚本中明确预留该区域,并确认程序、向量表和常量不会占用它。STM32F1 不同容量等级的页大小可能是 1 KiB 或 2 KiB,不能统一写死。
#include "stm32f1xx_hal.h"
#include <stdbool.h>
#include <stdint.h>
/* 示例:应改为链接脚本导出的符号或经过确认的保留页地址。 */
#define FLASH_USER_START 0x0800F000UL
static uint16_t flash_read_u16(uint32_t address)
{
return *(volatile const uint16_t *)address;
}
static HAL_StatusTypeDef flash_erase_page(uint32_t page_address)
{
FLASH_EraseInitTypeDef erase = {0};
uint32_t page_error = 0xFFFFFFFFUL;
erase.TypeErase = FLASH_TYPEERASE_PAGES;
erase.PageAddress = page_address;
erase.NbPages = 1U;
HAL_FLASH_Unlock();
HAL_StatusTypeDef status = HAL_FLASHEx_Erase(&erase, &page_error);
HAL_FLASH_Lock();
return status;
}
static HAL_StatusTypeDef flash_program_u16(uint32_t address, uint16_t data)
{
HAL_FLASH_Unlock();
HAL_StatusTypeDef status =
HAL_FLASH_Program(FLASH_TYPEPROGRAM_HALFWORD, address, data);
HAL_FLASH_Lock();
return status;
}
bool flash_example(void)
{
const uint16_t expected = 0x1234U;
if (flash_erase_page(FLASH_USER_START) != HAL_OK) {
return false;
}
if (flash_program_u16(FLASH_USER_START, expected) != HAL_OK) {
return false;
}
return flash_read_u16(FLASH_USER_START) == expected;
}
工程化使用时还应处理:
- 擦写期间的中断和实时性影响
- 电源中断造成的半写入状态
- 数据版本、长度和 CRC
- 多页轮换与磨损均衡
- HAL 返回值以及
HAL_FLASH_GetError()的错误信息
SRAM
SRAM 是 MCU 运行时最常用的可读写存储器。典型内容包括:
| 区域 | 内容 |
|---|---|
.data |
有初始值的全局变量和静态变量 |
.bss |
初始值为 0 的全局变量和静态变量 |
| Heap | 动态分配的内存 |
| Stack | 局部变量、返回地址、保存的寄存器和函数调用现场 |
复位启动时,启动代码通常会:
- 把
.data的初始值从 Flash 复制到 SRAM - 将
.bss清零 - 初始化运行环境
- 进入
main()
变量通常放在哪里
#include <stdint.h>
uint32_t g_initialized = 0x12345678U; /* .data:Flash 保存初值,运行时位于 SRAM */
uint32_t g_zero_initialized; /* .bss:启动时清零,运行时位于 SRAM */
static uint32_t s_counter = 100U; /* .data:文件内可见的静态变量 */
static const uint32_t k_config = 0x1234U; /* 通常位于只读段 */
void memory_example(void)
{
uint32_t local_value = 1U; /* 通常位于寄存器或栈,取决于编译优化 */
static uint8_t buffer[1024]; /* 静态存储期,通常位于 .bss */
buffer[0] = (uint8_t)local_value;
++s_counter;
}
C 语言对象最终放在寄存器、栈还是内存中,还会受到编译优化、链接脚本和使用方式影响。源代码里的局部变量不一定真的占用栈空间。
栈与堆
栈和堆通常共用有限的 SRAM:
SRAM 低地址
┌──────────────────┐
│ .data / .bss │
├──────────────────┤
│ Heap → │
│ │
│ ← Stack│
└──────────────────┘
SRAM 高地址
图中的增长方向只是常见布局,最终由处理器约定和链接脚本决定。
常见风险:
- 递归或大型局部数组导致栈溢出
- 动态分配造成碎片或内存泄漏
- 栈与堆相向增长后发生碰撞
- DMA 缓冲区没有满足地址、对齐或 Cache 一致性要求
SRAM 使用原则
避免大型栈对象
大型局部数组会占用当前调用栈。更合适的放置方式取决于生命周期和实时性要求:
/* 长期存在、大小固定:使用静态缓冲区。 */
static uint8_t frame_buffer[10000];
动态分配并不天然比栈更安全。在资源受限或强实时 MCU 中,malloc() 可能带来失败、碎片和不可预测耗时。若必须使用:
#include <stdlib.h>
uint8_t *buffer = malloc(10000U);
if (buffer != NULL) {
/* 使用 buffer。 */
free(buffer);
buffer = NULL;
}
应明确所有权、检查返回值并保证所有退出路径都释放资源。许多裸机项目会在初始化阶段一次性分配,或完全使用静态内存池。
合理使用全局变量和常量
- 只读查找表和配置应尽量使用
const - 大型缓冲区应结合
.map文件确认实际占用 - 不要只为“节省栈”就无条件改成全局变量
- 共享全局状态需要考虑中断、DMA 或多任务并发访问
栈占用与溢出检测
读取当前栈指针只能得到当前调用深度,不能代表程序运行以来的最大栈占用。可靠分析通常结合:
- 链接脚本中的栈边界
- 启动时用固定字节填充栈区域
- 运行压力测试后扫描被覆盖范围,得到高水位
- MPU/PMP 保护区或 RTOS 的栈检查功能
- 编译器生成的栈使用报告
栈顶、栈底符号名由链接脚本决定;ARM 与 RISC-V 读取栈指针的汇编写法也不同。不能直接复制一段 mov ..., sp 代码同时用于两种架构。
Cache 与 TCM
Cache
Cache 保存近期使用的指令或数据,用于降低访问 Flash 或外部存储的平均延迟。
需要注意:
- Cache 命中与未命中的耗时不同
- DMA 与数据 Cache 并行访问内存时可能产生一致性问题
- 修改可执行代码或中断向量后,可能需要维护指令 Cache
- 对强实时任务,应评估最坏情况而不是只看平均速度
TCM
TCM(Tightly Coupled Memory,紧耦合内存)直接连接处理器核心,通常不经过普通 Cache 路径,适合:
- 中断服务程序
- 控制环算法
- 需要固定访问延迟的数据
- 对执行时间抖动敏感的代码
TCM 是否存在以及代码如何放入 TCM,需要查看芯片手册和链接脚本。
内存映射
MCU 通常把不同资源映射到统一地址空间:
地址空间
├── Flash
├── SRAM
├── 外设寄存器
├── 系统控制区域
└── 外部存储或保留区域
CPU 使用地址区分访问目标。访问 GPIO、定时器或串口寄存器,本质上也是对特定地址进行读写,这称为 MMIO(Memory-Mapped I/O,内存映射 I/O)。
#define REG32(address) (*(volatile unsigned int *)(address))
REG32(0x40000000U) |= (1U << 3);
这里的 volatile 告诉编译器:该地址的值可能由硬件改变,每次访问都应真正执行读写。实际项目应优先使用芯片头文件提供的寄存器定义。
链接脚本与程序映像
链接脚本决定各段放入哪个存储区域。例如:
| 段 | 典型存放位置 | 运行时位置 |
|---|---|---|
.text |
Flash | Flash 或复制到 RAM/TCM |
.rodata |
Flash | Flash |
.data 初始值 |
Flash | SRAM |
.bss |
不占用固件数据区 | SRAM,启动时清零 |
| Stack / Heap | 不保存初始内容 | SRAM |
排查内存问题时,可结合以下文件:
- 链接脚本(
.ld) - 链接映射文件(
.map) - ELF 段信息
常用检查命令:
riscv-none-elf-size firmware.elf
riscv-none-elf-objdump -h firmware.elf
如果使用的是 riscv64-unknown-elf-* 工具链,应把命令前缀对应替换。
学习重点
理解 MCU 内存架构时,建议按以下顺序:
- 区分易失性和非易失性存储
- 理解 Flash 的读、编程、擦除差异
- 理解
.data、.bss、栈和堆 - 阅读目标芯片的内存映射
- 阅读工程链接脚本和
.map文件 - 再学习 Cache、TCM、DMA 一致性和外部存储
相关笔记
理论参考资料
MCU 内存练习
这组练习用于巩固 Flash、SRAM、程序段、栈、堆和掉电安全存储。建议先阅读 Notion/单片机/内存与存储/内存架构和布局。
练习一:Flash 与 SRAM
题目
解释 Flash 和 SRAM 的主要区别,并分别列出适合存放的内容。
回答要点
| 对比项 | Flash | SRAM |
|---|---|---|
| 断电后数据 | 保留 | 丢失 |
| 常见用途 | 程序、只读常量、持久化参数 | 运行时变量、栈、堆、缓冲区 |
| 读取 | 可随机读取,速度受等待周期和 Cache 影响 | 通常延迟较低 |
| 写入 | 需要按编程单位写入 | 可直接读写 |
| 擦除 | 通常按页、扇区或块擦除 | 不需要擦除 |
| 使用限制 | 擦写寿命有限 | 容量有限,断电丢失 |
为什么不能在主循环中每次迭代都把参数写入同一个 Flash 地址?
练习二:保存配置结构体
目标
实现以下流程:
- 在 Flash 中保存一个配置结构体
- 上电后读取并验证配置
- 修改配置后安全地写回
- 数据损坏或版本不兼容时恢复默认配置
建议的数据结构
#include <stdint.h>
#define CONFIG_MAGIC 0x43464731UL
#define CONFIG_VERSION 1U
typedef struct {
uint32_t magic;
uint16_t version;
uint16_t length;
uint32_t sequence;
uint32_t baud_rate;
int16_t offset;
uint16_t reserved;
uint32_t crc32;
} device_config_t;
字段作用:
| 字段 | 作用 |
|---|---|
magic |
判断该区域是否包含本项目的配置 |
version |
支持结构升级和兼容性判断 |
length |
防止不同结构尺寸被误读 |
sequence |
多槽存储时判断哪个记录更新 |
| 业务字段 | 实际需要持久化的参数 |
crc32 |
检测内容是否损坏或写入不完整 |
推荐接口
void config_set_defaults(device_config_t *config);
bool config_load(device_config_t *config);
bool config_save(const device_config_t *config);
bool config_validate(const device_config_t *config);
保存流程
读取当前有效记录
↓
生成 sequence + 1 的新记录
↓
计算 CRC
↓
写入备用槽
↓
回读并校验
↓
写入最后的有效标记
↓
旧记录等待后续垃圾回收
不要先擦除唯一的有效配置再写新数据,否则擦除或写入期间掉电会同时失去旧数据和新数据。
验收标准
- 空白 Flash 能加载默认配置
- 正常记录能够通过 CRC 校验
- 修改一个字节后校验失败
- 旧版本结构不会被当作当前版本使用
- 任意写入步骤掉电后,至少能恢复一份完整配置
- Flash 地址和页大小来自目标芯片手册与链接脚本
练习三:分析变量的存储区域
题目
分析下面每个对象通常位于哪个内存区域:
#include <stdint.h>
#include <stdlib.h>
uint32_t g_var1 = 100U;
uint32_t g_var2;
const uint32_t g_const = 200U;
void function(void)
{
uint32_t local_var = 300U;
static uint32_t static_var = 400U;
uint32_t *heap_var = malloc(sizeof(*heap_var));
if (heap_var != NULL) {
*heap_var = 500U;
free(heap_var);
heap_var = NULL;
}
}
分析
| 对象 | 典型位置 | 说明 |
|---|---|---|
g_var1 |
.data,运行时位于 SRAM |
非零初值保存在 Flash,启动时复制到 SRAM |
g_var2 |
.bss,运行时位于 SRAM |
启动时清零 |
g_const |
.rodata,通常位于 Flash |
具体位置由编译器和链接脚本决定 |
local_var |
寄存器或栈 | 优化后可能不实际占用栈 |
static_var |
.data,运行时位于 SRAM |
具有静态存储期,不在普通调用栈中 |
heap_var |
寄存器或栈 | 它本身只是指针变量 |
malloc() 返回的对象 |
Heap | 分配失败时返回 NULL |
“指针变量的位置”和“指针指向对象的位置”是两个问题。heap_var 可能在栈或寄存器中,而它指向的 uint32_t 对象位于堆中。
进一步验证
不要只凭源代码猜测。可以通过以下手段验证:
arm-none-eabi-size firmware.elf
arm-none-eabi-objdump -h firmware.elf
arm-none-eabi-nm -S --size-sort firmware.elf
使用 RISC-V 工具链时,将前缀替换为工程实际使用的 riscv-none-elf-* 或 riscv64-unknown-elf-*。
练习四:使用 Flash 模拟 EEPROM
需求
- 对上层提供字节或键值级读写
- 支持磨损均衡
- 写入期间掉电后能够恢复
- 能检测损坏记录
- Flash 满后可以回收空间
推荐结构
使用两个或多个 Flash 页,并在页内追加记录:
页头
├── magic
├── format_version
├── page_sequence
└── page_state
记录 0
├── key / address
├── length
├── sequence
├── data
├── CRC
└── commit 标记
记录 1
└── ...
写入策略
- 扫描所有有效记录,找到目标键的最新版本
- 在当前活动页尾部追加新记录
- 先写记录头、数据和 CRC
- 回读验证后,最后写入
commit标记 - 只有带有效
commit且 CRC 正确的记录才参与恢复
逻辑上的“字节写入”仍要服从物理 Flash 的最小编程单位,不能假设硬件真的支持任意单字节覆盖。
页切换与磨损均衡
当活动页空间不足时:
- 擦除备用页
- 写入新的页头,状态设为“接收中”
- 把每个键的最新有效记录复制到备用页
- 校验复制结果
- 将备用页提交为“有效页”
- 最后淘汰并擦除旧页
通过递增的页序号和明确的状态转换,可以在任一步掉电后判断哪个页包含最后一份完整数据。
掉电测试
在以下步骤分别模拟复位:
- 擦除备用页之前和之后
- 写页头期间
- 写记录头、数据、CRC 期间
- 写
commit标记之前和之后 - 页切换提交之前和之后
每次重启后都应能扫描出一致状态,不应把半条记录当作有效数据。
完成检查
- 能解释 Flash 与 SRAM 的关键差异
- 能通过
.map或 ELF 工具确认变量位置 - 能区分指针变量和动态对象
- 配置记录包含版本、序号和 CRC
- 更新配置时至少保留一份旧的有效记录
- EEPROM 模拟方案考虑编程粒度、页回收和掉电恢复
相关笔记
练习参考资料
延伸:FPU 与浮点数据
FPU(Floating-Point Unit)用于硬件执行浮点运算,但是否存在、支持单精度还是双精度,以及上下文保存代价,都取决于具体 Cortex-M 内核和编译选项。启用 FPU 前至少确认:
| 检查项 | 需要确认的内容 |
|---|---|
| 内核 | 例如 Cortex-M4F/M7 通常带单精度 FPU,M0/M3 不一定具备 |
| 编译选项 | -mfloat-abi、-mfpu 与启动文件、库保持一致 |
| 实时性 | 浮点指令延迟、异常处理和 RTOS 上下文保存是否可接受 |
| 内存 | 浮点库、任务栈和保存的 S 寄存器会增加 Flash/SRAM 占用 |
| 验证 | 用 map/ELF 和基准测试确认代码确实使用硬件 FPU,而不是软件库模拟 |
# 示例:查看 ELF 中是否出现浮点寄存器指令;前缀按工具链调整
arm-none-eabi-objdump -d firmware.elf | grep -E '\<(vmov|vadd|vsub|vmul|vdiv)'
启用 FPU 不是“只改一个编译开关”就完成。要把编译器、链接库、启动代码和 RTOS 上下文策略一起验证,再比较精度、延迟、功耗和内存占用。
延伸阅读:浮点运算单元 FPU 使用指南。