4、堆区优化策略:多堆分区设计、堆碎片整理与泄漏检测

堆区,说白了就是动态内存的“自由市场”。malloc 和 free 在这里频繁交易,时间一长,市场就乱了。碎片、泄漏、性能抖动,全来了。我在车载项目里吃过不少亏,今天把这几个硬骨头啃一啃。

4.1 多堆分区设计:把“大杂烩”变成“分餐制”

为什么要有多个堆?你想想看,一个系统里既有内部 SRAM(几兆字节),又有外部 SDRAM(几百兆字节)。如果所有 malloc 都走同一个堆,那内部 SRAM 很快就被大块数据占满,实时性要求高的任务反而没地方去了。

我的做法是:按用途分区。

  • 内部 SRAM 堆:放实时性高、生命周期短的数据。比如 CAN 报文缓冲区、中断服务程序的临时变量。
  • 外部 SDRAM 堆:放大块数据、生命周期长的数据。比如导航地图缓存、多媒体解码帧缓冲区。
  • 专用堆:给特定模块单独分配。比如摄像头驱动自己维护一个堆,避免和其他模块互相干扰。

代码实现上,我习惯用 FreeRTOS 的 pvPortMalloc 配合自定义堆管理。或者直接用 heap_4.c 的机制,但把内存区域拆开。

// 伪代码示例:多堆初始化
void MultiHeap_Init(void)
{
    // 内部SRAM堆:起始地址0x20000000,大小64KB
    HeapRegion_t xHeapRegions[] = 
    {
        { (uint8_t *)0x20000000, 64 * 1024 },   // 内部SRAM
        { (uint8_t *)0xC0000000, 128 * 1024 * 1024 } // 外部SDRAM
    };
    
    vPortDefineHeapRegions(xHeapRegions);
}
我的经验:分区时别太细。我曾经在一个项目里分了8个堆,结果管理开销比省下的内存还多。一般来说,3到4个堆就够用了。

4.2 堆碎片整理技术:给内存“搬家”

碎片是堆区的顽疾。你明明还有 10KB 空闲,但 malloc(5KB) 就是失败。为什么?因为空闲块被分割成了很多小碎片,没有一个连续块能满足请求。

碎片整理的思路:把已分配的内存块“挤”到一起,腾出连续的大块空间。但嵌入式系统里,指针满天飞,直接搬动内存会导致野指针。所以,我一般用两种方式:

  1. 内存池 + 固定块大小:这是最粗暴也最有效的方法。把堆分成固定大小的块(比如 16、32、64、128 字节),每个块只分配固定大小。碎片率直接降到接近零。
  2. 垃圾回收式整理:只在系统空闲时执行。先暂停所有任务,然后扫描堆,把已分配块复制到新位置,更新所有指针。嗯,这个操作很重,我一般只在系统启动或休眠时做一次。

避坑指南:我曾经在一个安全气囊控制器里尝试做实时碎片整理,结果在整理过程中触发了中断,指针还没来得及更新,直接导致系统崩溃。从那以后,我定了个规矩:碎片整理期间,必须关中断,或者只在 idle 任务里做。

4.3 动态内存泄漏检测工具:Valgrind 嵌入式版

内存泄漏,说白了就是“借了不还”。在 PC 上,泄漏几次可能没事。但在嵌入式系统里,泄漏 1KB 就可能让系统在 24 小时后挂掉。我见过一个项目,出厂前测试都正常,跑了一周后死机,最后发现是某个日志模块每次调用都泄漏 4 个字节。

Valgrind 嵌入式版,其实就是把 Valgrind 的 memcheck 工具移植到 ARM Cortex-A 或 RISC-V 平台上。它通过模拟 CPU 执行,跟踪每次 malloc/free 的调用,检测:

  • 未释放的内存
  • 越界访问
  • 使用已释放的内存

但说实话,Valgrind 在嵌入式上跑起来很慢,资源消耗也大。我一般只在开发阶段用,或者用轻量级的替代方案:

工具 适用平台 特点
Valgrind 嵌入式版 Cortex-A/RISC-V 功能全,但慢,需要 2 倍内存
dmalloc ARM/单片机 轻量,可统计泄漏位置
自定义 hook 所有平台 自己写 malloc/free 包装函数,记录调用栈

我个人更推荐第三种方式。写一个简单的包装函数,每次 malloc 时记录文件名和行号,free 时擦除记录。最后打印出所有未释放的记录,一目了然。

// 自定义内存泄漏检测示例
#define malloc(size)  my_malloc(size, __FILE__, __LINE__)
#define free(ptr)     my_free(ptr)

void *my_malloc(size_t size, const char *file, int line)
{
    void *ptr = pvPortMalloc(size);
    // 记录到链表:ptr, size, file, line
    LogAlloc(ptr, size, file, line);
    return ptr;
}

void my_free(void *ptr)
{
    // 从链表中删除记录
    RemoveAlloc(ptr);
    vPortFree(ptr);
}

// 在系统空闲时打印泄漏
void DumpLeaks(void)
{
    // 遍历链表,打印所有未释放的记录
    PrintAllocList();
}
注意:自定义 hook 会增加代码体积和运行时间。我一般只在调试版本里启用,发布版本直接去掉。另外,记得在系统退出前调用 DumpLeaks,否则泄漏记录就丢了。

好了,堆区优化就聊这么多。多堆分区解决的是“资源争抢”问题,碎片整理解决的是“空间利用率”问题,泄漏检测解决的是“可靠性”问题。这三板斧用好了,你的嵌入式系统内存管理基本就稳了。下一章我们聊聊栈区优化,那个更刺激——栈溢出可是会直接导致系统跑飞的。