大家好,欢迎来到立芯嵌入式。
最近在给一个工控项目做优化,又一次遇到了老生常谈的问题:动态内存分配。
先说说动态内存分配这个东西。在PC端写代码的时候,new一个对象,delete掉,这事儿再正常不过了。内存不够?加内存条呗。但在嵌入式系统里,尤其是那些只有几十K RAM的MCU上,情况就完全不同了。
动态内存分配确实有它的好处。比如你在写协议栈的时候,不同的数据包大小不一样,如果都按最大的来分配静态内存,那简直是暴殄天物。再比如你想实现一些高级的数据结构,链表啊、队列啊,没有动态分配还真不好搞。
但问题也很明显。首先是时间不确定性,malloc这货在分配内存时需要遍历空闲链表,在实时系统里这可是大忌。其次是内存碎片问题,用着用着你会发现,明明还有不少空闲内存,但就是分配不出一块连续的空间。最要命的是,很多小型MCU的工具链压根就不支持malloc,或者支持得很差,一用就让代码体积暴涨。

曾经维护过一个老项目,前任工程师在里面大量使用了malloc和free,结果系统运行个把小时就会莫名其妙地死机。后来花了整整一周时间排查,发现是内存碎片导致的分配失败,而错误处理又写得不完善,直接访问了空指针。那个项目最后全部改成静态分配才算稳定下来。
引入Pool模式
既然malloc有这么多问题,那有没有什么好的替代方案呢?这就要说到今天的主角了:内存池模式,也就是Pool Pattern。

内存池的思路其实很简单,就像食堂的餐盘一样。食堂准备了固定数量的餐盘,每个餐盘大小都一样。你来吃饭的时候拿一个,吃完了洗干净还回去,下一个人接着用。这比每次都去买新餐盘或者用完就扔要实际得多。
在嵌入式系统里,我们可以预先分配一块固定大小的内存区域,把它分成若干个相同大小的块。需要内存的时候从池子里拿一块,用完了还回去。这样做的好处显而易见:分配时间是确定的O(1),不会产生内存碎片,内存使用量在编译时就能确定,调试起来也方便。
动手实现一个内存池

说了这么多理论,咱们来点实际的,展示一个通用的C语言内存池实现:
// 内存池结构体定义
typedefstruct {
void* memory_pool; // 内存池首地址
void* free_list; // 空闲块链表头
uint8_t* bitmap; // 位图管理数组
size_t block_size; // 每个块的大小
size_t block_count; // 总块数
size_t free_count; // 剩余空闲块数
} MemPool;
// 内存池接口函数
void mempool_init(MemPool* pool, void* buffer, size_t block_size, size_t block_count);
void* mempool_alloc(MemPool* pool);
void mempool_free(MemPool* pool, void* block);
size_t mempool_available(MemPool* pool);
实际使用的时候,你需要先准备一块静态内存区域:
// 定义一个可以存放32个CAN报文的内存池
#define CAN_MSG_SIZE 64
#define CAN_MSG_COUNT 32
staticuint8_t can_buffer[CAN_MSG_SIZE * CAN_MSG_COUNT];
staticuint8_t can_bitmap[(CAN_MSG_COUNT + 7) / 8];
static MemPool can_msg_pool;
// 初始化
mempool_init(&can_msg_pool, can_buffer, CAN_MSG_SIZE, CAN_MSG_COUNT);
// 使用
void* msg = mempool_alloc(&can_msg_pool);
if (msg != NULL) {
// 使用消息缓冲区
process_can_message(msg);
// 用完归还
mempool_free(&can_msg_pool, msg);
}
如果你不想每次都传递这么多参数,可以用宏来简化定义:
#define DEFINE_POOL(name, type, count) \
static type name##_buffer[count]; \
static uint8_t name##_bitmap[(count + 7) / 8]; \
static MemPool name = { \
.memory_pool = name##_buffer, \
.bitmap = name##_bitmap, \
.block_size = sizeof(type), \
.block_count = count, \
.free_count = count \
}
// 使用宏定义一个内存池
DEFINE_POOL(uart_pool, UartFrame, 16);
// 分配和释放
UartFrame* frame = (UartFrame*)mempool_alloc(&uart_pool);
mempool_free(&uart_pool, frame);
巧用位图管理内存状态

实现内存池最关键的问题是如何高效地跟踪每个内存块的状态,是空闲还是已分配?最直观的方法是用一个bool数组,每个元素对应一个内存块。但在资源受限的MCU上,这种方法太奢侈了。一个bool通常占用一个字节,64个内存块就要64字节的管理开销。

这里有个小技巧:用位图来管理。一个uint8_t有8个位,可以管理8个内存块的状态。64个内存块只需要8个字节就够了。

计算需要多少个字节很简单:
#define BITS_PER_BYTE 8
#define BITMAP_SIZE(n) (((n) + BITS_PER_BYTE - 1) / BITS_PER_BYTE)
// 例如:64个块需要的位图大小
uint8_t bitmap[BITMAP_SIZE(64)]; // 实际分配8个字节
这个公式里的 ((n) + BITS_PER_BYTE - 1) 是个常见的向上取整技巧,确保即使块数不是8的倍数也能分配足够的空间。
操作位图需要一些位运算技巧,这里是具体实现:
// 设置某一位为1(标记为空闲)
static inline void bitmap_set(uint8_t* bitmap, size_t index)
{
size_t byte_idx = index / 8;
size_t bit_idx = index % 8;
bitmap[byte_idx] |= (1u << bit_idx);
}
// 清除某一位为0(标记为已分配)
static inline void bitmap_clear(uint8_t* bitmap, size_t index)
{
size_t byte_idx = index / 8;
size_t bit_idx = index % 8;
bitmap[byte_idx] &= ~(1u << bit_idx);
}
// 测试某一位的状态
static inline int bitmap_test(const uint8_t* bitmap, size_t index)
{
size_t byte_idx = index / 8;
size_t bit_idx = index % 8;
return (bitmap[byte_idx] & (1u << bit_idx)) != 0;
}
// 查找第一个空闲位
static int bitmap_find_first_set(const uint8_t* bitmap, size_t total_bits)
{
size_t bytes = BITMAP_SIZE(total_bits);
for (size_t i = 0; i < bytes; i++) {
if (bitmap[i] != 0) {
// 使用__builtin_ctz查找最低位的1(GCC内建函数)
// 如果编译器不支持,可以用循环代替
uint8_t byte = bitmap[i];
for (int j = 0; j < 8; j++) {
if (byte & (1u << j)) {
size_t index = i * 8 + j;
if (index < total_bits) {
return (int)index;
}
}
}
}
}
return-1; // 没有找到空闲块
}
完整的内存池实现核心代码如下:
void mempool_init(MemPool* pool, void* buffer, size_t block_size, size_t block_count)
{
pool->memory_pool = buffer;
pool->block_size = block_size;
pool->block_count = block_count;
pool->free_count = block_count;
// 初始化位图,全部标记为空闲
size_t bitmap_bytes = BITMAP_SIZE(block_count);
memset(pool->bitmap, 0xFF, bitmap_bytes);
}
void* mempool_alloc(MemPool* pool)
{
if (pool->free_count == 0) {
returnNULL;
}
// 查找第一个空闲块
int index = bitmap_find_first_set(pool->bitmap, pool->block_count);
if (index < 0) {
returnNULL;
}
// 标记为已分配
bitmap_clear(pool->bitmap, index);
pool->free_count--;
// 计算并返回块地址
return (uint8_t*)pool->memory_pool + (index * pool->block_size);
}
void mempool_free(MemPool* pool, void* block)
{
if (block == NULL) {
return;
}
// 计算块的索引
size_t offset = (uint8_t*)block - (uint8_t*)pool->memory_pool;
size_t index = offset / pool->block_size;
// 边界检查
if (index >= pool->block_count) {
return; // 非法地址
}
// 标记为空闲
bitmap_set(pool->bitmap, index);
pool->free_count++;
}
总结
在实际项目中使用内存池,我总结了几点经验:
首先是池子大小的确定。这个需要根据实际应用场景来评估。我通常的做法是先用一个较大的值,然后在测试阶段监控实际使用情况,逐步调整到合适的大小。记得留出20%左右的余量,应对突发情况。
其次是错误处理。palloc可能返回NULL,一定要检查!我见过太多因为忽略这个检查导致的系统崩溃了。可以考虑在调试版本里加入断言,在分配失败时立即暴露问题。
还有一个细节是内存对齐。如果你的T类型有对齐要求,比如在某些ARM平台上double需要8字节对齐,要确保elements数组也满足这个要求。现代编译器通常会自动处理,但在一些特殊场景下可能需要手动指定对齐属性。
最后说说使用场景。内存池特别适合这些情况:对象大小固定且生命周期相对较短的场景,比如通信协议的数据包缓冲区、事件系统的消息对象、状态机的状态节点等。我们在一个Modbus RTU的实现里,用内存池管理请求和响应帧,效果很好。
如果你的系统需要多种大小的动态内存,可以创建多个不同规格的内存池。比如一个32字节的小对象池,一个256字节的中对象池,一个1K的大对象池。根据请求的大小选择合适的池子,这样可以减少内部碎片。
另一个优化方向是支持可变大小的分配。可以把多个连续的块合并分配给一个请求,但这会增加管理复杂度,需要权衡利弊。
对于只在初始化阶段分配、运行期间不释放的场景,可以实现一个更简单的单向内存池,只支持分配不支持释放,进一步降低开销。