4、内存分配器原理:malloc/free实现原理、静态分配器、伙伴系统、slab分配器
说到内存分配,很多嵌入式工程师第一反应就是 malloc 和 free。嗯,这两个函数确实好用,但你真的了解它们背后干了什么吗?
我早年在一个物联网项目里吃过亏。产品跑着跑着就死机了,查了三天,最后发现是频繁调用 malloc/free 导致堆碎片化严重,系统直接崩了。从那以后,我就开始认真研究内存分配器的原理。
说白了,内存分配器就是帮你在堆区管理空闲内存的管家。不同的管家有不同的管理方式,咱们今天就把几个主流的都捋一遍。
4.1 malloc/free 的实现原理
标准库的 malloc 和 free 是怎么工作的?我简单拆解一下。
malloc(n) 的核心逻辑:
- 遍历空闲链表,找一块大小 >= n 的内存块
- 如果找到,切一块出来返回给用户,剩下的放回空闲链表
- 如果没找到,调用
sbrk或mmap向操作系统申请更多内存
free(p) 的核心逻辑:
- 根据指针找到对应的内存块头信息
- 把这块内存标记为空闲
- 尝试与相邻的空闲块合并(减少碎片)
这里有个关键点:每个 malloc 返回的指针前面,都藏着一个管理头。这个头里记录了块大小、是否空闲等元数据。
// 简化版的内存块结构
typedef struct mem_block {
size_t size; // 块大小(包含头)
int free; // 空闲标志
struct mem_block *next; // 指向下一个块
// 用户数据从这里开始
} mem_block_t;
我见过有人在小内存设备上疯狂调用 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!
静态分配器的优点很明显:零碎片、零管理开销、确定性极高。缺点也明显:不能释放,内存利用率可能不高。
4.3 伙伴系统
伙伴系统(Buddy System)是一种折中方案。它把内存按 2 的幂次分成块,分配和释放都围绕「伙伴」这个概念进行。
核心思想:
- 内存池大小是 2^N 字节
- 维护一个链表数组,第 i 个链表管理大小为 2^i 的空闲块
- 分配时,找刚好能装下请求的最小 2 的幂次块
- 如果该大小的块没有了,从更大的块分裂
- 释放时,检查伙伴是否空闲,是则合并
我画了一张图帮你理解:
伙伴系统的优点:
- 分配和释放速度都很快(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