MCU 内存架构与布局

MCU 的存储系统不只有 RAM。CPU 寄存器、Cache、SRAM、Flash 和外部存储共同组成了从高速小容量到低速大容量的存储层次。

速度快、容量小
┌──────────────────────────────┐
│ CPU 寄存器                   │
├──────────────────────────────┤
│ Cache / TCM(部分高性能 MCU)│
├──────────────────────────────┤
│ 片上 SRAM                    │
├──────────────────────────────┤
│ 片上 Flash                   │
├──────────────────────────────┤
│ 外部 RAM / SPI Flash / SD 卡 │
└──────────────────────────────┘
速度慢、容量大
info
Note

具体容量、速度和存储层级取决于芯片型号。低成本 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):由多个页或扇区组成的更大区域
warning
Warning

“页”和“扇区”并没有跨厂商统一的固定大小。不要直接把 SPI NOR Flash 常见的 256 字节页、4 KiB 扇区套用到片内 Flash。

擦写寿命与数据保存

Flash 单元只能承受有限次数的擦除和编程。频繁更新参数时应考虑:

  • 磨损均衡
  • 多槽轮换保存
  • CRC 或校验和
  • 掉电保护
  • 使用 EEPROM、FRAM 或外部存储

不应在主循环中无条件反复写同一个 Flash 地址。

STM32F1 HAL 示例

下面展示“解锁 → 擦除 → 编程 → 上锁 → 回读校验”的基本流程。

report
Danger

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 局部变量、返回地址、保存的寄存器和函数调用现场

复位启动时,启动代码通常会:

  1. 把 .data 的初始值从 Flash 复制到 SRAM
  2. 将 .bss 清零
  3. 初始化运行环境
  4. 进入 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;
}
info
Note

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 或多任务并发访问

栈占用与溢出检测

读取当前栈指针只能得到当前调用深度,不能代表程序运行以来的最大栈占用。可靠分析通常结合:

  1. 链接脚本中的栈边界
  2. 启动时用固定字节填充栈区域
  3. 运行压力测试后扫描被覆盖范围,得到高水位
  4. MPU/PMP 保护区或 RTOS 的栈检查功能
  5. 编译器生成的栈使用报告
warning
Warning

栈顶、栈底符号名由链接脚本决定;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 内存架构时,建议按以下顺序:

  1. 区分易失性和非易失性存储
  2. 理解 Flash 的读、编程、擦除差异
  3. 理解 .data、.bss、栈和堆
  4. 阅读目标芯片的内存映射
  5. 阅读工程链接脚本和 .map 文件
  6. 再学习 Cache、TCM、DMA 一致性和外部存储

相关笔记

理论参考资料

MCU 内存练习

这组练习用于巩固 Flash、SRAM、程序段、栈、堆和掉电安全存储。建议先阅读 Notion/单片机/内存与存储/内存架构和布局。

练习一:Flash 与 SRAM

题目

解释 Flash 和 SRAM 的主要区别,并分别列出适合存放的内容。

回答要点

对比项 Flash SRAM
断电后数据 保留 丢失
常见用途 程序、只读常量、持久化参数 运行时变量、栈、堆、缓冲区
读取 可随机读取,速度受等待周期和 Cache 影响 通常延迟较低
写入 需要按编程单位写入 可直接读写
擦除 通常按页、扇区或块擦除 不需要擦除
使用限制 擦写寿命有限 容量有限,断电丢失
help
Question

为什么不能在主循环中每次迭代都把参数写入同一个 Flash 地址?

练习二:保存配置结构体

目标

实现以下流程:

  1. 在 Flash 中保存一个配置结构体
  2. 上电后读取并验证配置
  3. 修改配置后安全地写回
  4. 数据损坏或版本不兼容时恢复默认配置

建议的数据结构

#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
priority_high
Important

“指针变量的位置”和“指针指向对象的位置”是两个问题。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
└── ...

写入策略

  1. 扫描所有有效记录,找到目标键的最新版本
  2. 在当前活动页尾部追加新记录
  3. 先写记录头、数据和 CRC
  4. 回读验证后,最后写入 commit 标记
  5. 只有带有效 commit 且 CRC 正确的记录才参与恢复

逻辑上的“字节写入”仍要服从物理 Flash 的最小编程单位,不能假设硬件真的支持任意单字节覆盖。

页切换与磨损均衡

当活动页空间不足时:

  1. 擦除备用页
  2. 写入新的页头,状态设为“接收中”
  3. 把每个键的最新有效记录复制到备用页
  4. 校验复制结果
  5. 将备用页提交为“有效页”
  6. 最后淘汰并擦除旧页

通过递增的页序号和明确的状态转换,可以在任一步掉电后判断哪个页包含最后一份完整数据。

掉电测试

在以下步骤分别模拟复位:

  • 擦除备用页之前和之后
  • 写页头期间
  • 写记录头、数据、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 使用指南。