14、内存池技术:为什么需要内存池、固定大小内存池实现、性能对比
说到内存管理,有个话题绕不开——内存池。
我刚开始做嵌入式那会儿,觉得malloc/free挺好用的。直到有一次,一个数据采集项目跑着跑着就卡死了。查了三天,发现是频繁分配释放导致的内存碎片。嗯,从那以后,我开始认真研究内存池。
为什么需要内存池?
说白了,标准库的malloc不是为实时系统设计的。它有几个硬伤:
- 分配时间不确定:malloc内部要查找空闲链表,最坏情况下的耗时你根本没法预估
- 内存碎片:频繁分配释放,堆空间会变得像蜂窝煤一样,明明总空闲内存够,却分配不出一个大块
- 额外开销:每个malloc分配块都有管理头,小对象分配时,头开销占比很高
我在项目中遇到过这样一个场景:一个网络协议栈,每秒要处理上千个小包。每个包都要分配几十字节的缓冲区。用malloc跑了半小时,内存碎片率就飙到30%以上。后来换成内存池,问题直接解决。
核心思想:内存池就是预先申请一大块内存,然后自己管理。分配和释放都在池子里完成,不经过操作系统。
固定大小内存池实现
固定大小内存池,也叫slab分配器。它只分配一种大小的内存块。你想想看,如果所有对象大小都一样,管理起来就简单多了。
我习惯用空闲链表的方式来实现。每个空闲块里存一个指向下一个空闲块的指针。分配时取头节点,释放时插回头部。时间复杂度O(1)。
来看代码:
// 固定大小内存池结构
typedef struct {
void *pool_start; // 池起始地址
void *pool_end; // 池结束地址
void *free_list; // 空闲链表头
size_t block_size; // 每个块的大小
size_t total_blocks; // 总块数
size_t free_blocks; // 空闲块数
} fixed_pool_t;
// 初始化内存池
int fixed_pool_init(fixed_pool_t *pool, void *buf,
size_t buf_size, size_t block_size) {
// 块大小至少能存一个指针
if (block_size < sizeof(void*)) {
block_size = sizeof(void*);
}
// 对齐到4字节
block_size = (block_size + 3) & ~3;
pool->block_size = block_size;
pool->total_blocks = buf_size / block_size;
pool->free_blocks = pool->total_blocks;
pool->pool_start = buf;
pool->pool_end = (char*)buf + buf_size;
// 构建空闲链表
pool->free_list = buf;
char *current = (char*)buf;
for (size_t i = 0; i < pool->total_blocks - 1; i++) {
void **next = (void**)current;
*next = current + block_size;
current += block_size;
}
// 最后一个块指向NULL
void **last = (void**)current;
*last = NULL;
return 0;
}
// 从内存池分配
void *fixed_pool_alloc(fixed_pool_t *pool) {
if (pool->free_list == NULL) {
return NULL; // 池已满
}
void *block = pool->free_list;
pool->free_list = *(void**)block;
pool->free_blocks--;
return block;
}
// 释放回内存池
void fixed_pool_free(fixed_pool_t *pool, void *block) {
if (block == NULL) return;
// 检查是否属于本池
if (block < pool->pool_start || block >= pool->pool_end) {
return; // 非法地址
}
*(void**)block = pool->free_list;
pool->free_list = block;
pool->free_blocks++;
}
个人经验:初始化时把空闲链表建好,运行时就只有指针操作。没有malloc调用,没有系统调用,速度极快。
性能对比
光说快不行,咱们拿数据说话。我在STM32F407上做过一个对比测试:分配和释放10000次,每次32字节。
| 分配方式 | 总耗时(us) | 平均每次(us) | 最大耗时(us) | 碎片率 |
|---|---|---|---|---|
| malloc/free | 2850 | 0.285 | 12.4 | 18% |
| 固定大小内存池 | 320 | 0.032 | 0.04 | 0% |
看到了吗?内存池快了将近9倍。而且最关键的——最大耗时。malloc最慢的一次花了12.4微秒,内存池只有0.04微秒。这个差距在实时系统中就是生与死的区别。
为什么会这样?因为内存池的分配和释放都是常数时间。没有链表遍历,没有内存合并,没有系统调用。就是简单的指针操作。
注意:内存池不是银弹。它适合固定大小、频繁分配释放的场景。如果对象大小差异很大,或者分配模式很随机,固定大小池反而浪费内存。
内存池的核心逻辑
我画了一张图,帮你理解内存池的工作流程:
避坑指南
我曾经踩过一个坑,分享给你:
- 块大小要对齐:ARM Cortex-M系列要求4字节对齐,否则访问会异常。我习惯用 (size + 3) & ~3 做对齐
- 检查释放地址:释放时一定要验证地址是否属于本池。我曾经因为野指针释放,把池子搞乱了,排查了两天
- 考虑多线程:如果多个任务共享一个池,要加锁保护。我一般用关中断的方式,开销最小
- 池大小要算好:总内存 = 块大小 × 块数 + 管理结构。别算少了,运行中池满是很麻烦的事
我的习惯:在调试阶段,我会在池结构里加一个magic number。每次分配释放时校验一下,能快速发现内存越界问题。
内存池技术,说白了就是用空间换时间。你预先多花点内存,换来的是确定性的分配时间和零碎片。在实时系统、网络协议栈、音视频处理这些场景下,它几乎是标配。
嗯,这一章就到这里。记住一句话:频繁分配小对象,就用内存池。