4、内存分配器原理:malloc/free实现原理、静态分配器、伙伴系统、slab分配器

说到内存分配,很多嵌入式工程师第一反应就是 mallocfree。嗯,这两个函数确实好用,但你真的了解它们背后干了什么吗?

我早年在一个物联网项目里吃过亏。产品跑着跑着就死机了,查了三天,最后发现是频繁调用 malloc/free 导致堆碎片化严重,系统直接崩了。从那以后,我就开始认真研究内存分配器的原理。

说白了,内存分配器就是帮你在堆区管理空闲内存的管家。不同的管家有不同的管理方式,咱们今天就把几个主流的都捋一遍。

4.1 malloc/free 的实现原理

标准库的 mallocfree 是怎么工作的?我简单拆解一下。

malloc(n) 的核心逻辑:

  • 遍历空闲链表,找一块大小 >= n 的内存块
  • 如果找到,切一块出来返回给用户,剩下的放回空闲链表
  • 如果没找到,调用 sbrkmmap 向操作系统申请更多内存

free(p) 的核心逻辑:

  • 根据指针找到对应的内存块头信息
  • 把这块内存标记为空闲
  • 尝试与相邻的空闲块合并(减少碎片)

这里有个关键点:每个 malloc 返回的指针前面,都藏着一个管理头。这个头里记录了块大小、是否空闲等元数据。

// 简化版的内存块结构
typedef struct mem_block {
    size_t       size;      // 块大小(包含头)
    int          free;      // 空闲标志
    struct mem_block *next; // 指向下一个块
    // 用户数据从这里开始
} mem_block_t;
注意:管理头本身也占用空间。你申请 100 字节,实际消耗可能超过 100 字节。在 RAM 紧张的 MCU 上,这个开销不可忽视。

我见过有人在小内存设备上疯狂调用 malloc,结果堆区被管理头撑爆了。嗯,这就是典型的「没算清楚账」。

4.2 静态分配器

静态分配器,说白了就是「提前划好地盘,谁也别想多占」。它在编译时就确定了内存布局,运行时不做任何动态分配。

实现方式很简单:

  • 定义一个全局大数组作为内存池
  • 用一个指针记录当前分配位置
  • 每次分配就移动指针,释放?不存在的
// 静态分配器示例
#define POOL_SIZE 4096
static uint8_t pool[POOL_SIZE];
static uint8_t *current = pool;

void *static_malloc(size_t size) {
    // 对齐到 4 字节
    size = (size + 3) & ~3;
    if (current + size > pool + POOL_SIZE) {
        return NULL; // 内存不足
    }
    void *ptr = current;
    current += size;
    return ptr;
}

// 注意:没有 free!
适用场景:我一般在裸机系统或 RTOS 的初始化阶段用静态分配器。比如任务栈、消息队列的缓冲区,这些在系统启动时就确定好了,后续不会变化。

静态分配器的优点很明显:零碎片、零管理开销、确定性极高。缺点也明显:不能释放,内存利用率可能不高。

4.3 伙伴系统

伙伴系统(Buddy System)是一种折中方案。它把内存按 2 的幂次分成块,分配和释放都围绕「伙伴」这个概念进行。

核心思想:

  • 内存池大小是 2^N 字节
  • 维护一个链表数组,第 i 个链表管理大小为 2^i 的空闲块
  • 分配时,找刚好能装下请求的最小 2 的幂次块
  • 如果该大小的块没有了,从更大的块分裂
  • 释放时,检查伙伴是否空闲,是则合并

我画了一张图帮你理解:

伙伴系统内存分裂与合并示意图 初始:64KB 空闲块 请求 30KB → 分裂 左伙伴:32KB(分配出去) 右伙伴:32KB(空闲) 请求 15KB → 右伙伴继续分裂 16KB(分配) 16KB(空闲) 释放 16KB → 与伙伴合并 → 32KB 合并后:32KB 空闲块

伙伴系统的优点:

  • 分配和释放速度都很快(O(log N))
  • 外部碎片很少
  • 实现相对简单

缺点:

  • 内部碎片严重(你申请 33KB,它给你 64KB 的块)
  • 内存利用率可能只有 50% 左右

我的经验:伙伴系统适合分配大小比较规整的场景。比如网络协议栈的报文缓冲区,大小通常是 64、128、256、512 这种 2 的幂次。我在一个路由器项目里用过,效果不错。

4.4 Slab 分配器

Slab 分配器是 Linux 内核里用的方案。它专门解决一个问题:频繁分配和释放相同大小的对象

你想想看,内核里经常要分配 task_struct、inode 这些结构体,它们大小固定。如果用通用分配器,每次都要找块、切块、释放、合并,效率太低了。

Slab 的思路很简单:

  • 为每种对象大小维护一个缓存(cache)
  • 每个缓存由多个 slab 组成,每个 slab 是一块连续内存
  • slab 内部被划分为等大小的对象槽
  • 分配时直接从 slab 取一个空闲槽,释放时放回去
// Slab 分配器简化模型
typedef struct slab {
    void          *free_list;  // 空闲对象链表
    struct slab   *next;       // 下一个 slab
    int           num_free;    // 空闲对象数
    // 对象数据紧随其后
} slab_t;

typedef struct kmem_cache {
    size_t        obj_size;    // 对象大小
    int           obj_per_slab;// 每个 slab 的对象数
    slab_t        *slabs_full; // 已满的 slab
    slab_t        *slabs_partial; // 部分空闲的 slab
    slab_t        *slabs_free; // 全空闲的 slab
} kmem_cache_t;

Slab 分配器的三个关键设计:

  • 对象缓存:每个 cache 只管理一种大小的对象
  • 着色(coloring):让不同 slab 的对象起始地址错开,提高缓存命中率
  • 三链表管理:full、partial、free 三个链表,分配时优先从 partial 取

什么时候用 Slab?我建议在以下场景考虑:

  • 频繁创建和销毁同一类对象(比如通信协议中的帧结构)
  • 对象大小固定且较小(几十到几百字节)
  • 对分配速度有严格要求

4.5 四种分配器对比

特性 malloc/free 静态分配器 伙伴系统 Slab 分配器
分配速度 慢(遍历链表) 极快(指针移动) 快(O(log N)) 极快(O(1))
释放速度 慢(合并检查) 不支持 快(O(log N)) 极快(O(1))
外部碎片 严重 很少
内部碎片 较少 严重 较少
适用场景 通用 启动初始化 2的幂次大小 固定大小对象
实现复杂度 中等 简单 中等 较高

避坑指南:我曾经在一个传感器节点项目里用了标准库的 malloc,结果运行 72 小时后堆碎片化导致分配失败。后来换成静态分配器 + 对象池的组合,问题彻底解决。

记住:在嵌入式系统里,确定性比灵活性更重要

嗯,四种分配器各有各的脾气。选型的时候,你得先问自己三个问题:

  • 我的对象大小是固定的还是变化的?
  • 分配和释放的频率有多高?
  • 我能容忍多少内存浪费?

想清楚这些,你自然就知道该用哪种了。


公众号:蓝海资料掘金营,微信deep3321