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万次 | 配置参数、校准数据 |
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 等)
4.3 Flash 分区策略
Flash 分区,我一般分成四块:
- Bootloader 区:固定大小,比如 64KB。负责启动校验和固件升级。
- 主应用区:放业务代码。如果支持 A/B 升级,这里要分两个 Bank。
- 参数存储区:放设备配置、校准值。用 EEPROM 模拟或单独扇区。
- 日志/备份区:运行时日志、升级备份镜像。
分区时要注意扇区擦除粒度。NOR Flash 通常 4KB 或 64KB 一个扇区,你分区边界必须对齐扇区边界,否则擦除时会误伤邻居。
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,需要定义一块「核间通信区」,用原子操作或信号量保护。
4.6 NVM 分区与磨损均衡
NVM(比如 EEPROM 或 FRAM)容量小、擦写寿命有限。分区策略要围绕「减少擦写次数」来设计:
- 参数区:每个参数分配固定偏移,用版本号或 CRC 校验。
- 日志区:循环写入,每次写新记录,满了就擦最旧的一页。
- 备份区:升级前把关键参数备份到这里,升级失败后恢复。
磨损均衡怎么做?简单说就是不要把数据总写在同一地址。我常用的方法是「偏移写入」——每次写入时地址加一个步进,写满一圈再从头开始。配合一个「当前写入指针」变量,存在 NVM 里。
4.7 整体内存映射图
下面这张 SVG 图,展示了一个典型嵌入式系统的内存映射全貌。你可以看到 Flash、RAM、DDR、NVM 各自占据的地址范围,以及它们之间的交互关系。
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
}