4、堆(Heap)管理机制:剖析malloc/free内部实现,学习newlib和FreeRTOS的堆方案,避免堆碎片化

堆管理,说白了就是动态内存分配。很多嵌入式工程师一听到 mallocfree 就皱眉头,觉得这东西在MCU上不靠谱。我刚开始做TWS项目时也是这个想法,但后来发现——不是堆不好,是你没用好它。

TWS耳机里,蓝牙协议栈、音频编解码、ANC算法,哪个不需要动态内存?全用静态数组?那代码写出来你自己都不想看。今天我们就来扒一扒堆的底裤,看看它到底怎么工作的。

4.1 malloc/free 到底干了什么?

很多人以为 malloc(100) 就是向系统要了100个字节。其实不是。你拿到的是100字节,但系统内部多花了至少8-16个字节来管理这块内存。

我画个图帮你理解:

+----------------+----------------+----------------+
| 管理头 (8-16B) |  你的数据区    | 管理头 (8-16B) |
+----------------+----------------+----------------+
^                 ^
返回的指针        下一个空闲块

每次 malloc,系统都会在数据区前面塞一个管理头。里面记录了啥?

  • 这块内存的大小
  • 这块内存是空闲还是已分配
  • 指向前后块的指针(双向链表)

free 的时候,系统根据你传的指针,往前偏移几个字节找到管理头,把状态改成「空闲」。然后检查前后邻居,如果也是空闲的,就合并成一个更大的块。

关键点free 不会把内存还给操作系统!它只是把这块内存标记为「可用」,放回空闲链表里。下次 malloc 时优先从这里分配。

嗯,这里要注意——如果你 malloc 了100字节,但只用了50字节,剩下的50字节就浪费了。这就是内部碎片。而频繁地分配释放不同大小的块,会导致外部碎片——空闲内存总量够,但都是小碎片,凑不出一个大块。

4.2 newlib 的堆实现:标准C库的玩法

newlib 是嵌入式 Linux 和很多 RTOS 上常用的 C 库。它的 malloc 实现基于一个叫 _sbrk() 的系统调用。

简单说,_sbrk() 就是向系统要一块连续的内存。newlib 拿到这块内存后,自己内部用链表管理。

它的分配策略是这样的:

  1. 遍历空闲链表,找第一个大小足够的块(First Fit)
  2. 如果块太大,就切一块出来,剩下的放回链表
  3. 如果找不到,就调用 _sbrk() 扩展堆

我在项目中遇到过一个问题:TWS耳机里同时跑蓝牙协议栈和音频算法,两个模块频繁 malloc/free,结果堆被切得七零八落。有一次分配一个 256 字节的缓冲区,明明总空闲内存还有 2KB,但就是分配失败。

踩坑提醒:newlib 的 First Fit 策略在长时间运行后,碎片化非常严重。我曾经在一个产品上跑了72小时,堆利用率从80%降到了30%,因为碎片太多,大块分配全失败了。

解决方案?要么定期重启(不现实),要么换堆方案。

4.3 FreeRTOS 的 5 种堆方案

FreeRTOS 提供了 5 种堆管理方案,从简单到复杂。我一个个说。

方案 特点 适用场景
heap_1 只分配,不释放。没有碎片问题 任务栈、队列等一次性分配
heap_2 支持释放,但不会合并相邻空闲块 固定大小内存块分配
heap_3 包装了标准 malloc/free,依赖编译器 需要线程安全时
heap_4 支持释放,且会合并相邻空闲块 通用场景,推荐使用
heap_5 支持多个不连续的内存区域 多块物理内存的场景

我个人习惯用 heap_4。为什么?因为它会合并相邻空闲块,碎片化程度比 newlib 好很多。

heap_4 的实现核心是一个叫 BlockLink_t 的结构体:

typedef struct A_BLOCK_LINK {
    struct A_BLOCK_LINK *pxNextFreeBlock;  // 指向下一个空闲块
    size_t xBlockSize;                     // 块大小(含管理头)
} BlockLink_t;

分配时,它用 Best Fit 策略——找大小最接近的空闲块。这样能减少大块被切碎的情况。

释放时,它会检查前后邻居:

// 伪代码,展示合并逻辑
void vPortFree(void *pv) {
    BlockLink_t *pxLink = pv - sizeof(BlockLink_t);
    // 检查后邻居
    if (后邻居是空闲的) {
        合并当前块和后邻居;
    }
    // 检查前邻居
    if (前邻居是空闲的) {
        合并前邻居和当前块;
    }
    将合并后的块插入空闲链表;
}

小技巧:在 TWS 项目中,我建议把音频缓冲区、协议栈缓冲区这些频繁分配释放的内存,用 heap_4 管理。而一些全局配置、常量数据,用 heap_1 一次性分配,永不释放。

4.4 如何避免堆碎片化?实战经验

说了这么多理论,来点干货。我在 TWS 项目里总结了几条铁律:

第一,尽量分配固定大小的块。

你想想看,如果所有 malloc 都只分配 32、64、128 字节,那碎片化问题就小很多。因为相同大小的块可以互相替换。我在一个项目里把所有音频处理缓冲区都统一成 256 字节,碎片率直接降了 60%。

第二,避免频繁分配释放。

能复用就复用。比如音频解码器需要临时缓冲区,我就在初始化时一次性分配好,用完了不释放,下次直接用。这叫「内存池」思想。

第三,使用内存池(Memory Pool)。

FreeRTOS 没有自带内存池,但你可以自己实现。我写过一个小工具:

// 固定大小内存池
#define POOL_SIZE 10
#define BLOCK_SIZE 64

static uint8_t pool[POOL_SIZE][BLOCK_SIZE];
static uint8_t used[POOL_SIZE];

void *pool_alloc(void) {
    for (int i = 0; i < POOL_SIZE; i++) {
        if (!used[i]) {
            used[i] = 1;
            return pool[i];
        }
    }
    return NULL;  // 池满了
}

void pool_free(void *p) {
    // 计算索引,标记为未使用
    int idx = (uint8_t *)p - (uint8_t *)pool[0];
    idx /= BLOCK_SIZE;
    used[idx] = 0;
}

这个池子没有碎片问题,分配释放都是 O(1) 的。我在 TWS 的蓝牙协议栈里用了这个,效果很好。

第四,监控堆使用情况。

别等到出问题了才去查。我习惯在代码里加一个堆监控任务:

void heap_monitor_task(void *pv) {
    while (1) {
        size_t free_heap = xPortGetFreeHeapSize();
        size_t min_free = xPortGetMinimumEverFreeHeapSize();
        printf("当前空闲: %d, 历史最低: %d\n", free_heap, min_free);
        if (min_free < 512) {
            // 报警!堆快不够了
        }
        vTaskDelay(pdMS_TO_TICKS(5000));
    }
}

我曾经在一个量产项目上,因为没加堆监控,导致产品运行 3 天后死机。查了三天才发现是堆碎片化导致分配失败。从那以后,每个产品的堆监控都是标配。

4.5 总结一下

堆管理这事儿,说白了就是「空间换时间,或者时间换空间」。newlib 的通用性好,但碎片化严重。FreeRTOS 的 heap_4 更适合嵌入式场景。而内存池,则是终极武器。

在 TWS 这种资源紧张的系统里,我的建议是:

  • 核心模块用内存池
  • 次要模块用 heap_4
  • 永远加堆监控
  • 定期检查历史最低空闲内存

嗯,做到这几点,你的 TWS 项目基本不会在堆上翻车。下一章我们聊聊栈(Stack)的管理,那又是另一个坑了。