4. 内存映射与分区:Flash、RAM、DDR、NVM的部署策略,地址空间划分

内存映射这件事,说白了就是给芯片里的每一块存储空间发「门牌号」。CPU 访问外设、读写数据,全靠这些地址。我见过不少项目,功能逻辑写得漂亮,结果栽在内存布局上——要么堆栈溢出,要么 DMA 踩了关键变量,要么升级时把自己给擦没了。

这一章,咱们就聊聊怎么给 Flash、RAM、DDR、NVM 这些存储资源做分区,以及地址空间怎么划分才靠谱。

4.1 存储介质特性速览

先搞清楚每种存储的脾气,分区策略才有依据。我整理了一张表,方便你对照:

类型 速度 掉电保持 擦写寿命 典型用途
Flash(NOR) 读快,写慢 是 ~10万次 代码存储、Bootloader
Flash(NAND) 读快,写较快 是 ~1万次 大容量文件系统、日志
SRAM 极快 否 无限 栈、堆、关键变量
DDR SDRAM 快 否 无限 大内存缓冲、OS 运行空间
NVM(如 EEPROM) 慢 是 ~100万次 配置参数、校准数据
我的习惯:NOR Flash 用来放可执行代码,NAND 只放文件系统或日志。因为 NOR 支持 XIP(片上执行),NAND 不行,得先拷到 RAM 里跑。

4.2 地址空间划分原则

地址空间划分,不是随便画几个框就完事。你得考虑几个硬约束:

  • 对齐边界:Flash 和 RAM 的起始地址通常要按 1KB、4KB 或 1MB 对齐,具体看芯片的 Memory Map 手册。
  • 物理连续 vs 虚拟连续:裸机下地址就是物理地址,必须连续;跑 MMU 的 OS 可以虚拟连续、物理离散。
  • 外设映射区:GPIO、UART、DMA 这些寄存器通常占据一段固定地址,别去碰。
  • 保留区:有些地址是芯片内部保留的,写了也没反应,甚至触发 HardFault。

举个例子,一个典型的 Cortex-M4 芯片,地址空间长这样:

0x0000_0000 - 0x1FFF_FFFF : Code (Flash + Boot ROM)
0x2000_0000 - 0x3FFF_FFFF : SRAM
0x4000_0000 - 0x5FFF_FFFF : 外设寄存器
0x6000_0000 - 0x9FFF_FFFF : 外部 RAM / 扩展存储
0xE000_0000 - 0xE00F_FFFF : 系统控制块 (SCB, NVIC 等)
我曾经踩过的坑:把全局变量定义在 0x2000_0000 开始的区域,结果链接脚本里 .bss 段起始地址设错了,跟堆区重叠。程序跑起来正常,但 malloc 几次之后全局变量就被踩了。排查了两天才发现是地址重叠。

4.3 Flash 分区策略

Flash 分区,我一般分成四块:

  1. Bootloader 区:固定大小,比如 64KB。负责启动校验和固件升级。
  2. 主应用区:放业务代码。如果支持 A/B 升级,这里要分两个 Bank。
  3. 参数存储区:放设备配置、校准值。用 EEPROM 模拟或单独扇区。
  4. 日志/备份区:运行时日志、升级备份镜像。

分区时要注意扇区擦除粒度。NOR Flash 通常 4KB 或 64KB 一个扇区,你分区边界必须对齐扇区边界,否则擦除时会误伤邻居。

避坑指南:Bootloader 和主应用之间,留一个「版本标记扇区」。每次升级后写入版本号和 CRC,Bootloader 启动时先读这个扇区,决定跳转还是回滚。我见过有人把版本号写在主应用头部,结果主应用被擦坏了,Bootloader 读不到版本号,直接死循环。

4.4 RAM 分区策略

RAM 分区比 Flash 更灵活,但也更容易乱。我个人习惯这样分:

区域 用途 大小建议
.text / .rodata 拷贝区 如果代码从 NAND 或外部 Flash 加载,需要 RAM 暂存 按实际代码大小
.data 区 已初始化全局变量 按实际大小
.bss 区 未初始化全局变量 按实际大小
堆区 (Heap) 动态内存分配 剩余 RAM 的 30%~50%
栈区 (Stack) 函数调用、局部变量 剩余 RAM 的 20%~40%
DMA / 中断缓冲 外设 DMA 描述符、双缓冲 固定大小,按外设需求
低功耗保留区 休眠时保持数据的变量 通常 1KB~4KB

你想想看,如果堆和栈没有明确边界,堆往上长、栈往下长,一旦相遇就是「堆栈碰撞」。轻则死机,重则数据损坏。我建议在链接脚本里给堆和栈各分配固定大小,并在它们之间放一个「哨兵区」——一段全 0xDEADBEEF 的填充,运行时定期检查是否被改写。

4.5 DDR 与外部存储映射

DDR 通常出现在带 MMU 的高端 MCU 或 MPU 上。地址映射要考虑:

  • DDR 起始地址:一般在 0x8000_0000 或 0xC000_0000 附近,具体看芯片。
  • 非缓存区 vs 缓存区:外设 DMA 用的缓冲要放在非缓存区,否则 Cache 一致性会让你抓狂。
  • 多核共享区:如果双核共享 DDR,需要定义一块「核间通信区」,用原子操作或信号量保护。
我的经验:DDR 初始化代码要放在 Bootloader 里,而且必须在使能 MMU 和 Cache 之前完成。我遇到过一例,DDR 时序参数没配好,系统跑几分钟就随机挂掉,最后用示波器量 DQS 信号才发现是时序余量不足。

4.6 NVM 分区与磨损均衡

NVM(比如 EEPROM 或 FRAM)容量小、擦写寿命有限。分区策略要围绕「减少擦写次数」来设计:

  1. 参数区:每个参数分配固定偏移,用版本号或 CRC 校验。
  2. 日志区:循环写入,每次写新记录,满了就擦最旧的一页。
  3. 备份区:升级前把关键参数备份到这里,升级失败后恢复。

磨损均衡怎么做?简单说就是不要把数据总写在同一地址。我常用的方法是「偏移写入」——每次写入时地址加一个步进,写满一圈再从头开始。配合一个「当前写入指针」变量,存在 NVM 里。

注意:NVM 写入期间掉电,数据可能半残。一定要用「双缓冲写入」或「写入后回读校验」。我曾经在量产阶段发现一批设备参数丢失,最后定位是写入时序被中断打断,只写了前两个字节。

4.7 整体内存映射图

下面这张 SVG 图,展示了一个典型嵌入式系统的内存映射全貌。你可以看到 Flash、RAM、DDR、NVM 各自占据的地址范围,以及它们之间的交互关系。

嵌入式系统内存映射与分区结构图 嵌入式系统内存映射与分区结构图 Flash (NOR) 0x0800_0000 Bootloader (64KB) 主应用 A (512KB) 主应用 B (512KB) 参数存储 (64KB) 日志区 (128KB) 0x080F_FFFF SRAM 0x2000_0000 .data / .bss 堆区 (Heap) 栈区 (Stack) DMA 缓冲 低功耗保留区 0x2001_FFFF DDR SDRAM 0x8000_0000 OS 内核空间 用户应用堆 帧缓冲 (Display) 文件系统缓存 核间共享区 0x8FFF_FFFF NVM 0x080C_0000 设备参数 校准数据 运行日志 备份区 0x080C_FFFF CPU / 总线矩阵 说明: • Flash 和 NVM 共享同一物理总线,但地址不同;NVM 通常映射到 Flash 的高地址段。 • SRAM 和 DDR 通过不同的内存控制器访问,延迟差异明显。 • 分区边界必须对齐存储介质的擦除块/页大小,否则会出现跨区擦写问题。

4.8 链接脚本中的地址映射

说了这么多理论,最后落到代码上。链接脚本(Linker Script)是地址映射的最终实现。下面是一个简化版的 GCC LD 脚本片段,展示了如何定义各区域:

MEMORY
{
    FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 2M
    RAM   (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
    DDR   (rwx) : ORIGIN = 0x80000000, LENGTH = 64M
    NVM   (rw)  : ORIGIN = 0x080C0000, LENGTH = 64K
}

SECTIONS
{
    .text : {
        *(.isr_vector)
        *(.text*)
        *(.rodata*)
    } > FLASH

    .data : {
        _sdata = .;
        *(.data*)
        _edata = .;
    } > RAM AT > FLASH

    .bss : {
        _sbss = .;
        *(.bss*)
        _ebss = .;
    } > RAM

    .heap : {
        . = ALIGN(8);
        _heap_start = .;
        . = . + 32K;
        _heap_end = .;
    } > RAM

    .stack : {
        . = ALIGN(8);
        _stack_start = .;
        . = . + 16K;
        _stack_end = .;
    } > RAM

    .ddr_heap : {
        . = ALIGN(4096);
        *(.ddr_heap*)
    } > DDR

    .nvm_params : {
        *(.nvm_params*)
    } > NVM
}
我的建议:每次修改链接脚本后,编译生成 .map 文件,用文本编辑器打开,检查各段的起始地址和大小是否符合预期。我习惯在 CI 流程里加一个脚本,自动解析 .map 文件,如果堆栈重叠或段越界就报错。