25、宏实现内存池:内存块宏定义,分配/释放宏,内存池的宏封装
内存池,说白了就是提前挖好一个“鱼塘”,鱼来了直接捞,不用现挖。在嵌入式系统里,动态内存分配是个老大难问题——malloc/free用起来爽,但碎片化、不确定性、执行时间不可控,分分钟让你的系统在关键时刻“卡壳”。我个人习惯,但凡对实时性有要求的项目,内存池几乎是标配。
这一章,我们就用宏来封装一个轻量级的内存池。你可能会问:“为什么非要用宏?函数不行吗?”嗯,宏的好处在于零开销、无类型约束、可以玩出很多“魔法”效果。当然,代价是调试起来稍微麻烦点。不过,对于嵌入式这种资源受限的环境,这点代价完全值得。
25.1 内存块宏定义:池子的基本单元
先定义内存块的结构。每个内存块需要知道:自己是否空闲、数据区在哪、下一个块是谁。我见过很多新手直接定义一个结构体,然后塞进数组里——这当然可以,但用宏来定义,灵活性会高很多。
// 内存块头部信息
#define MEMPOOL_BLOCK_HEADER \
int is_free; /* 1=空闲, 0=已分配 */ \
void* data_ptr; /* 指向数据区的指针 */ \
struct block_info* next /* 链表指针,用于空闲块管理 */
// 内存块完整定义
#define MEMPOOL_BLOCK(name, size) \
struct { \
MEMPOOL_BLOCK_HEADER; \
uint8_t data[size]; \
} name
// 使用示例
MEMPOOL_BLOCK(my_block, 64);
这里有个小技巧:MEMPOOL_BLOCK_HEADER 这个宏可以单独拿出来复用。如果你需要定义多种不同大小的块,只需要改 size 参数就行。我在项目中遇到过一种场景——需要同时管理 32 字节和 128 字节两种块,用这种宏定义方式,改一行代码就能搞定。
25.2 分配/释放宏:捞鱼和放鱼
分配宏要干的事:遍历空闲块链表,找到第一个空闲块,标记为已占用,返回数据区地址。释放宏正好相反:把块标记为空闲,插回空闲链表。
// 分配宏
#define MEMPOOL_ALLOC(pool, size) \
({ \
void* __ptr = NULL; \
for (int __i = 0; __i < pool##_COUNT; __i++) { \
if (pool[__i].is_free && sizeof(pool[__i].data) >= size) { \
pool[__i].is_free = 0; \
__ptr = pool[__i].data; \
break; \
} \
} \
__ptr; \
})
// 释放宏
#define MEMPOOL_FREE(pool, ptr) \
do { \
for (int __i = 0; __i < pool##_COUNT; __i++) { \
if (pool[__i].data == ptr) { \
pool[__i].is_free = 1; \
break; \
} \
} \
} while(0)
释放宏里的 do { ... } while(0) 是个经典写法。为什么要这么写?因为宏展开后,如果直接写花括号,在 if...else 语句里可能会出问题。举个例子:
// 错误的写法
#define BAD_FREE(pool, ptr) { pool[0].is_free = 1; }
if (condition)
BAD_FREE(pool, ptr); // 展开后变成 if (condition) { ... }; 分号导致 else 悬空
else
// 编译错误!
// 正确的写法
#define GOOD_FREE(pool, ptr) do { pool[0].is_free = 1; } while(0)
if (condition)
GOOD_FREE(pool, ptr); // 展开后 if (condition) do { ... } while(0);
else
// 正常工作
这个坑,我曾经在代码评审时帮同事抓出来过。他当时一脸懵:“明明语法没问题啊?”嗯,宏的展开是文本替换,不是函数调用,很多“直觉上正确”的写法其实都有隐患。
25.3 内存池的宏封装:把池子管理起来
有了块和分配/释放宏,接下来就是封装整个内存池。一个完整的内存池需要:池子大小、块数量、空闲链表头、初始化函数。用宏来封装,可以做到“零运行时开销”——所有配置在编译期就确定了。
// 内存池宏封装
#define MEMPOOL_DEFINE(name, block_size, block_count) \
/* 1. 定义内存块数组 */ \
MEMPOOL_BLOCK(name##_blocks[block_count], block_size); \
\
/* 2. 定义池子控制结构 */ \
struct { \
int total_blocks; \
int free_blocks; \
int block_size; \
void* free_list_head; \
} name = { \
.total_blocks = block_count, \
.free_blocks = block_count, \
.block_size = block_size, \
.free_list_head = NULL \
}; \
\
/* 3. 初始化宏 */ \
#define name##_INIT() do { \
for (int __i = 0; __i < block_count; __i++) { \
name##_blocks[__i].is_free = 1; \
name##_blocks[__i].data_ptr = name##_blocks[__i].data; \
name##_blocks[__i].next = (__i < block_count-1) ? \
&name##_blocks[__i+1] : NULL; \
} \
name.free_list_head = &name##_blocks[0]; \
name.free_blocks = block_count; \
} while(0)
// 使用示例
MEMPOOL_DEFINE(my_pool, 64, 10); // 定义10个64字节的块
void init(void) {
my_pool_INIT(); // 初始化池子
}
void* alloc_block(void) {
return MEMPOOL_ALLOC(my_pool_blocks, 64);
}
void free_block(void* ptr) {
MEMPOOL_FREE(my_pool_blocks, ptr);
}
25.4 知识体系:一张图看懂内存池宏封装
下面这张 SVG 图,把整个内存池宏封装的核心逻辑串起来了。从宏定义到初始化,再到分配和释放,一目了然。
25.5 避坑指南:宏实现内存池的常见问题
用宏实现内存池,爽是真爽,坑也是真多。我把自己踩过的坑列出来,你遇到了可以少走弯路。
- 宏参数副作用:比如
MEMPOOL_ALLOC(pool, size++),size++ 会被展开多次。解决办法:在宏内部用临时变量缓存参数值,或者明确告诉使用者“不要传带副作用的参数”。 - 类型安全缺失:宏不检查类型。你传一个
int*进去,它当void*处理,编译器可能不报错。我建议在分配宏里加一个(void*)强制转换,至少让调用方显式转换一下。 - 调试困难:宏展开后的代码很难单步调试。我的做法是:先写一个函数版本,调试通过后,再改成宏版本。这样即使宏出了问题,也知道是展开过程的问题,而不是逻辑问题。
- 可移植性:前面提到的 GCC 语句表达式、## 连接符、# 字符串化,不同编译器支持程度不同。如果你要跨平台,最好查一下目标编译器的文档。
好了,这一章的内容就到这里。内存池的宏封装,说白了就是“用空间换时间,用宏换性能”。你想想看,在那些连 malloc 都没有的 MCU 上,这种手法几乎是唯一的选择。嗯,下一章我们会聊另一个有意思的话题——宏与函数指针的结合使用,敬请期待。