2. 嵌入式系统内存基础

在嵌入式C++开发中,内存不是“无限大”的抽象资源,而是由若干物理介质、地址区间和分配策略共同构成的有限系统。理解内存层次结构,是写出稳定、可预测、可优化代码的前提。本节从物理介质出发,逐步过渡到分配方式、对齐规则与碎片问题,为后续容器选型和内存池设计打基础。

一、内存层次结构:速度、容量与成本的三角关系

嵌入式系统的内存通常呈现明显的层次结构:越靠近CPU的存储,速度越快、容量越小、单位成本越高;越远离CPU的存储,速度越慢、容量越大、单位成本越低。典型层次从内到外依次为:CPU寄存器 → Cache(部分MCU有)→ 片上SRAM → 片外SRAM/PSRAM → 片上Flash → 片外Flash/EEPROM → 外部存储卡。C++程序运行时,代码通常驻留在Flash中,而变量、栈、堆则位于RAM中。

这种层次结构决定了优化方向:频繁访问的热数据应尽量放在高速小容量区域,冷数据和大块常量可放在Flash或外部存储。例如,中断服务程序中的状态变量应放在片上SRAM,而字库、音频采样等只读大数组可声明为const并留在Flash中,避免占用宝贵的RAM。

嵌入式内存层次结构与典型介质 CPU寄存器 Cache / TCM 片上 SRAM 片外 SRAM / PSRAM 片上 Flash / ROM 片外 Flash / EEPROM / SD 速度 / 容量 / 成本趋势 速度:寄存器 > Cache > SRAM > Flash 容量:寄存器 < Cache < SRAM < Flash 成本:寄存器 > Cache > SRAM > Flash C++ 对象典型落点 代码 / const 常量 → Flash 全局 / 静态变量 → .data / .bss(RAM) 局部变量 / 函数调用 → 栈(RAM) new / malloc 对象 → 堆(RAM)

二、RAM、ROM、Flash、栈与堆的角色分工

RAM是可读写的易失性存储,断电后数据丢失,用于存放运行时变量、栈和堆。ROM通常指掩膜只读存储器,出厂固化、不可修改,适合存放启动代码和固定常量。Flash是可电擦写的非易失存储,兼具ROM的持久性和可更新性,是嵌入式程序代码和只读数据的主要载体。

栈由编译器自动管理,用于保存函数参数、返回地址和局部变量,遵循后进先出。栈空间通常很小(几KB到几十KB),溢出会导致程序跑飞。堆用于动态内存分配,由malloc/free或new/delete管理,空间较大但分配和释放时机由程序员控制,容易产生碎片和泄漏。

区域 介质 生命周期 管理方式 典型风险
栈 StackRAM函数调用期间编译器自动溢出、递归过深
堆 HeapRAM手动申请到释放malloc/new碎片、泄漏、越界
.data/.bssRAM程序运行全程编译器静态分配RAM占用过大
.text/.rodataFlash程序运行全程链接器固化误写Flash、寿命
EEPROM非易失掉电保持驱动读写擦写次数有限

三、静态分配与动态分配:确定性与灵活性的取舍

静态分配在编译期或链接期确定内存位置和大小,包括全局变量、静态变量、固定数组和栈上局部变量。它的优点是确定性高、无碎片、执行速度快,适合实时性要求强的嵌入式场景。动态分配在运行期从堆中申请内存,灵活性高,但分配时间不确定、可能失败、易产生碎片,在安全关键系统中通常被限制甚至禁用。

// 静态分配:编译期确定,无碎片
static uint8_t rx_buffer[512];

// 动态分配:运行期申请,需检查失败
uint8_t* p = new (std::nothrow) uint8_t[512];
if (p == nullptr) {
    // 处理内存不足
}

// 嵌入式常见替代:内存池 + placement new
alignas(4) static uint8_t pool[512];
MyObj* obj = new (pool) MyObj();
在多数MCU工程中,推荐优先使用静态数组、内存池或std::array,把动态分配限制在启动阶段或明确可控的路径中。若必须使用堆,应配合std::nothrow并检查返回值。

四、内存对齐与碎片:看不见的性能与稳定性代价

内存对齐要求变量的地址是其对齐值的整数倍。例如,uint32_t通常要求4字节对齐,double可能要求8字节对齐。对齐能保证CPU单次访问完成读写,未对齐访问在某些架构上会触发异常,在另一些架构上则被拆成多次访问,降低性能。结构体成员之间可能插入填充字节,导致sizeof大于成员之和。

struct Bad {
    uint8_t  a;   // 1字节
    uint32_t b;   // 需4字节对齐,a后填充3字节
    uint8_t  c;   // 1字节
};                // sizeof 通常为12

struct Good {
    uint32_t b;   // 4字节
    uint8_t  a;   // 1字节
    uint8_t  c;   // 1字节
};                // sizeof 通常为8

碎片分为内部碎片和外部碎片。内部碎片来自对齐填充或分配器按固定块大小分配,导致块内未被充分利用;外部碎片来自频繁申请和释放不同大小内存,使堆中出现大量不连续的小空闲块,最终无法满足较大请求。嵌入式系统中,碎片往往在长时间运行后突然导致分配失败,排查困难。

常见误区:认为“堆还有剩余空间就一定能分配成功”。实际上,剩余空间可能被切成许多小碎片,无法拼出连续的大块。解决思路包括:固定大小内存池、启动阶段一次性分配、避免频繁new/delete、使用std::pmr或自定义分配器。

五、案例与复盘:从一次通信缓冲失败看内存布局

某设备在连续运行数小时后,串口接收大包时偶发失败。排查发现,接收缓冲区使用new动态申请,且每次收包后释放,长时间运行后堆中形成外部碎片,虽然总空闲量足够,但无法分配连续的大块。复盘结论:将接收缓冲区改为静态双缓冲,配合DMA和内存池,问题消失。该案例说明,嵌入式内存优化不仅是“省空间”,更是“保确定性”。