23. 内存池:从散兵游勇到正规军
说实话,我刚开始写嵌入式程序那会儿,对内存管理这事根本没当回事。需要了就 malloc,用完了就 free,多简单啊。直到有一次,我做一个网络协议栈的项目,设备跑着跑着就卡死了。查了三天,最后发现是频繁的内存分配导致大量碎片,系统再也分配不出一个连续的大块内存了。
嗯,从那以后,我就开始认真研究内存池了。说白了,内存池就是提前申请好一大块内存,然后自己管理它。你想想看,这就像你开公司,与其每次要用人都去人才市场现招,不如直接养一批固定员工,随叫随到。
23.1 内存池到底是个啥?
内存池的核心思想很简单:一次申请,多次使用。我们预先向操作系统要一大块连续内存,然后自己实现分配和释放的逻辑。
为什么要这么做?我总结了几点:
- 消除碎片:固定大小的内存块,不会产生外部碎片
- 分配速度快:O(1) 时间复杂度,比 malloc 快一个数量级
- 确定性高:实时系统里,你不能忍受 malloc 偶尔的耗时抖动
- 内存泄漏可控:池子用完就回收,不会漏到系统里
核心区别:malloc/free 是通用方案,内存池是专用方案。通用意味着妥协,专用意味着高效。
23.2 简单固定大小内存池的实现
我习惯用空闲链表的方式来实现。每个空闲块的前几个字节存放下一个空闲块的地址。这样,分配和释放都只需要操作链表头,非常快。
来看代码:
#include <stdint.h>
#include <stdlib.h>
// 内存池结构
typedef struct {
void *pool_start; // 池起始地址
size_t block_size; // 每个块的大小(字节)
size_t block_count; // 块的总数
void *free_list; // 空闲链表头
} mempool_t;
// 初始化内存池
// 注意:block_size 必须大于等于 sizeof(void*)
int mempool_init(mempool_t *pool, void *buffer,
size_t block_size, size_t block_count) {
if (!pool || !buffer || block_size < sizeof(void*)) {
return -1;
}
pool->pool_start = buffer;
pool->block_size = block_size;
pool->block_count = block_count;
// 构建空闲链表
char *ptr = (char*)buffer;
for (size_t i = 0; i < block_count - 1; i++) {
// 每个块的前4/8字节存放下一个块的地址
void **next = (void**)(ptr + i * block_size);
*next = (void*)(ptr + (i + 1) * block_size);
}
// 最后一个块指向 NULL
void **last = (void**)(ptr + (block_count - 1) * block_size);
*last = NULL;
pool->free_list = buffer;
return 0;
}
// 从池中分配一个块
void *mempool_alloc(mempool_t *pool) {
if (!pool || !pool->free_list) {
return NULL; // 池已耗尽
}
void *block = pool->free_list;
// 空闲链表头指向下一个空闲块
pool->free_list = *(void**)block;
return block;
}
// 释放块回池中
void mempool_free(mempool_t *pool, void *block) {
if (!pool || !block) return;
// 检查 block 是否属于本池(简单检查)
char *start = (char*)pool->pool_start;
char *end = start + pool->block_size * pool->block_count;
if (block < (void*)start || block >= (void*)end) {
return; // 不属于本池,忽略
}
// 将块插入空闲链表头部
*(void**)block = pool->free_list;
pool->free_list = block;
}
我踩过的坑:曾经我设的 block_size 太小,忘了预留 sizeof(void*) 的空间来存链表指针。结果分配出去后,用户数据把链表指针覆盖了,整个池子乱成一锅粥。记住:block_size 必须 >= sizeof(void*),否则链表指针没地方放。
23.3 内存池的工作流程
为了让你更直观地理解,我画了一张图:
你看,整个过程就是链表头插和头删。没有遍历,没有排序,干净利落。
23.4 内存池的优势到底在哪?
我拿一个实际项目来说吧。之前做的一个物联网网关,需要频繁创建和销毁数据包。每个数据包大小固定,大概 256 字节。用 malloc/free 的时候,跑 24 小时就会出现分配失败。换成内存池后,连续跑了三个月没出过问题。
具体优势我列个表:
| 对比项 | malloc/free | 内存池 |
|---|---|---|
| 分配速度 | 几十到几百纳秒,不稳定 | 几个纳秒,恒定 |
| 碎片问题 | 容易产生外部碎片 | 无外部碎片 |
| 确定性 | 差,耗时不可预测 | 好,每次耗时固定 |
| 内存利用率 | 高(按需分配) | 低(固定大小,可能有浪费) |
| 适用场景 | 通用场景 | 固定大小对象、高频分配 |
注意:内存池不是银弹。如果你分配的对象大小差异很大,固定大小池子会造成严重的内存浪费。这时候可以考虑分级内存池——不同大小用不同的池子。我有个项目就是这么干的,效果不错。
23.5 使用内存池的注意事项
嗯,这里我要多说几句。用内存池有几个容易翻车的地方:
- 块大小要算准:别忘了加上链表指针占用的空间。我一般会在 block_size 里预留 8 字节(64位系统)。
- 对齐问题:如果你的数据需要 4 字节或 8 字节对齐,block_size 必须是对齐大小的整数倍。否则,你分配出去的地址可能不对齐,导致硬件异常。
- 线程安全:上面的代码没有加锁。多线程环境下,你需要用互斥锁或原子操作保护 free_list。我习惯用关中断的方式,在裸机系统里很管用。
- 越界检查:释放时最好检查一下地址是否属于本池。不然,有人传了个野指针进来,你的链表就毁了。
我的习惯:在调试阶段,我会在内存池里加一个魔数(magic number)。分配时写入魔数,释放时检查魔数是否被覆盖。这样能快速发现内存越界问题。等产品发布时,再把检查关掉,不影响性能。
说白了,内存池就是拿空间换时间,拿确定性换灵活性。在嵌入式系统里,尤其是实时性要求高的场景,这个交换非常划算。你想想看,一个稳定的系统,比一个偶尔崩溃但内存利用率高的系统,哪个更值钱?
好了,关于内存池的核心内容就这些。记住一句话:高频分配用池,低频分配用堆。这是我在无数个熬夜调 bug 的夜晚总结出来的经验。
公众号:蓝海资料掘金营,微信deep3321