4、内存池设计:固定大小内存池、动态内存池、伙伴系统实现
内存管理,说白了就是给蓝牙协议栈找个靠谱的「管家」。我早年做蓝牙耳机驱动时,就吃过内存碎片的大亏——系统跑着跑着突然分配不出连续内存,音频直接断流。后来我学乖了,针对不同场景设计了三种内存池方案。今天咱们就聊聊固定大小内存池、动态内存池和伙伴系统,看看它们各自适合什么场景。
4.1 固定大小内存池
这是我最喜欢的一种方案,简单、高效、零碎片。你想想看,蓝牙协议栈里很多数据结构大小是固定的——比如HCI命令包、ACL数据包、L2CAP信令包。给它们分配固定大小的内存块,再合适不过了。
核心思想:预先分配一大块连续内存,按固定大小切成若干块。用位图或空闲链表管理。
我在项目中遇到过一个问题:蓝牙芯片RAM只有64KB,但需要同时处理多个连接。每个连接要分配几个固定大小的控制块。如果用标准malloc,碎片化严重。后来我改用固定大小内存池,内存利用率直接提升到95%以上。
来看一个简单的实现:
// 固定大小内存池结构
typedef struct {
void *pool_start; // 内存池起始地址
size_t block_size; // 每个块的大小
uint32_t total_blocks; // 总块数
uint32_t free_blocks; // 空闲块数
uint32_t *bitmap; // 位图,1表示已分配
} fixed_pool_t;
// 初始化
int fixed_pool_init(fixed_pool_t *pool, void *mem,
size_t block_size, uint32_t num_blocks) {
pool->pool_start = mem;
pool->block_size = block_size;
pool->total_blocks = num_blocks;
pool->free_blocks = num_blocks;
// 位图大小 = (num_blocks + 31) / 32
size_t bitmap_size = (num_blocks + 31) / 32 * sizeof(uint32_t);
pool->bitmap = (uint32_t *)((uint8_t *)mem + block_size * num_blocks);
memset(pool->bitmap, 0, bitmap_size);
return 0;
}
// 分配
void *fixed_pool_alloc(fixed_pool_t *pool) {
if (pool->free_blocks == 0) return NULL;
// 找第一个空闲块
for (uint32_t i = 0; i < pool->total_blocks; i++) {
uint32_t word_idx = i / 32;
uint32_t bit_idx = i % 32;
if (!(pool->bitmap[word_idx] & (1 << bit_idx))) {
pool->bitmap[word_idx] |= (1 << bit_idx);
pool->free_blocks--;
return (uint8_t *)pool->pool_start + i * pool->block_size;
}
}
return NULL;
}
// 释放
void fixed_pool_free(fixed_pool_t *pool, void *ptr) {
uint32_t offset = (uint8_t *)ptr - (uint8_t *)pool->pool_start;
uint32_t block_idx = offset / pool->block_size;
uint32_t word_idx = block_idx / 32;
uint32_t bit_idx = block_idx % 32;
pool->bitmap[word_idx] &= ~(1 << bit_idx);
pool->free_blocks++;
}
我的经验:位图查找可以用CLZ指令优化,ARM Cortex-M系列都支持。另外,块大小建议对齐到4字节或8字节,避免总线错误。
4.2 动态内存池
固定大小池子虽好,但总有「不按套路出牌」的时候。比如蓝牙配网阶段,要传输的配置文件大小不确定。这时候就需要动态内存池了——说白了就是自己实现一个malloc/free。
动态内存池的核心是管理空闲块。常见的有两种方式:
- 空闲链表法:所有空闲块用链表串起来,分配时遍历查找合适大小的块
- 边界标记法:每个内存块头部和尾部都标记大小信息,释放时方便合并
我个人习惯用边界标记法,因为合并相邻空闲块特别方便。来看代码:
// 内存块头部
typedef struct mem_header {
uint32_t size; // 包含头部的大小
uint32_t magic; // 魔数,用于校验
struct mem_header *next; // 空闲链表指针
struct mem_header *prev;
} mem_header_t;
// 内存块尾部
typedef struct mem_footer {
uint32_t size; // 必须和头部一致
} mem_footer_t;
#define HEADER_SIZE sizeof(mem_header_t)
#define FOOTER_SIZE sizeof(mem_footer_t)
#define MIN_BLOCK_SIZE (HEADER_SIZE + FOOTER_SIZE + 4)
#define MAGIC_NUM 0xDEADBEEF
// 分配
void *dyn_pool_alloc(dyn_pool_t *pool, size_t size) {
// 对齐到4字节
size = (size + 3) & ~3;
size_t need_size = size + HEADER_SIZE + FOOTER_SIZE;
// 遍历空闲链表
mem_header_t *curr = pool->free_list;
while (curr) {
if (curr->size >= need_size) {
// 如果剩余空间足够拆分成新块
if (curr->size - need_size >= MIN_BLOCK_SIZE) {
// 拆分逻辑...
}
// 从空闲链表移除
remove_from_free_list(pool, curr);
curr->magic = MAGIC_NUM;
return (void *)((uint8_t *)curr + HEADER_SIZE);
}
curr = curr->next;
}
return NULL; // 内存不足
}
// 释放
void dyn_pool_free(dyn_pool_t *pool, void *ptr) {
mem_header_t *header = (mem_header_t *)((uint8_t *)ptr - HEADER_SIZE);
// 检查魔数,防止重复释放
if (header->magic != MAGIC_NUM) {
// 错误处理
return;
}
header->magic = 0;
// 尝试合并相邻空闲块
mem_footer_t *prev_footer = (mem_footer_t *)((uint8_t *)header - FOOTER_SIZE);
mem_header_t *next_header = (mem_header_t *)((uint8_t *)header + header->size);
// 合并前一个块
if (prev_footer->size && /* 前一个块是空闲的 */) {
// 合并逻辑...
}
// 合并后一个块
if (next_header->magic == 0 /* 后一个块空闲 */) {
// 合并逻辑...
}
// 插入空闲链表
insert_to_free_list(pool, header);
}
注意:动态内存池最怕碎片化。我曾经在一个项目中连续分配释放不同大小的内存块,结果空闲链表越来越长,分配效率急剧下降。后来我加了个「内存整理」机制——当碎片率达到阈值时,主动合并所有空闲块。
4.3 伙伴系统
伙伴系统是个折中方案。它既有固定大小池子的分配效率,又有动态池子的灵活性。核心思想是:把内存按2的幂次分成块,分配时找最小能满足需求的块,释放时和「伙伴」合并。
举个例子:假设总内存是1KB,按2的幂分成512B、256B、128B、64B... 分配300B时,找512B的块;释放后,如果它的伙伴也是空闲的,就合并成1KB。
伙伴系统的关键是怎么找到「伙伴」。公式很简单:
buddy_addr = block_addr ^ (1 << order)
其中order是块大小的阶数(2^order字节)。
来看完整实现:
#define MAX_ORDER 10 // 最大2^10 = 1024字节
#define MIN_ORDER 4 // 最小2^4 = 16字节
typedef struct {
void *base_addr; // 内存基址
size_t total_size; // 总大小
list_t free_lists[MAX_ORDER + 1]; // 每个阶的空闲链表
} buddy_system_t;
// 初始化
void buddy_init(buddy_system_t *sys, void *mem, size_t size) {
sys->base_addr = mem;
sys->total_size = size;
// 初始化所有空闲链表
for (int i = 0; i <= MAX_ORDER; i++) {
list_init(&sys->free_lists[i]);
}
// 把整块内存作为最大阶的空闲块
int order = MAX_ORDER;
while ((1 << order) > size) order--;
add_to_free_list(sys, mem, order);
}
// 分配
void *buddy_alloc(buddy_system_t *sys, size_t size) {
// 计算需要的阶数
int order = MIN_ORDER;
while ((1 << order) < size + sizeof(block_header_t)) order++;
// 找可用块
int current_order = order;
while (current_order <= MAX_ORDER) {
if (!list_empty(&sys->free_lists[current_order])) {
break;
}
current_order++;
}
if (current_order > MAX_ORDER) return NULL;
// 从空闲链表取出块
void *block = remove_from_free_list(sys, current_order);
// 如果块太大,不断分裂
while (current_order > order) {
current_order--;
void *buddy = (uint8_t *)block + (1 << current_order);
add_to_free_list(sys, buddy, current_order);
}
return block;
}
// 释放
void buddy_free(buddy_system_t *sys, void *ptr) {
block_header_t *header = (block_header_t *)((uint8_t *)ptr - sizeof(block_header_t));
int order = header->order;
// 尝试合并伙伴
while (order < MAX_ORDER) {
void *buddy = (void *)((uintptr_t)ptr ^ (1 << order));
if (is_buddy_free(sys, buddy, order)) {
// 从空闲链表移除伙伴
remove_buddy_from_list(sys, buddy, order);
// 合并
ptr = (uintptr_t)ptr < (uintptr_t)buddy ? ptr : buddy;
order++;
} else {
break;
}
}
// 插入空闲链表
add_to_free_list(sys, ptr, order);
}
避坑指南:我曾经在伙伴系统中遇到一个隐蔽的bug——释放时伙伴判断错误。原因是内存基址不是2的幂对齐。记住:伙伴系统要求内存基址必须是2^MAX_ORDER对齐的。否则伙伴地址计算会出错。
4.4 三种方案对比
| 特性 | 固定大小内存池 | 动态内存池 | 伙伴系统 |
|---|---|---|---|
| 分配速度 | O(1) 极快 | O(n) 较慢 | O(log n) 快 |
| 内存碎片 | 无碎片 | 容易产生外部碎片 | 内部碎片(最多50%) |
| 灵活性 | 差(只能固定大小) | 好(任意大小) | 中等(2的幂次) |
| 适用场景 | HCI包、ACL数据 | 配置数据、大块缓存 | 通用场景、实时系统 |
| 实现复杂度 | 低 | 中 | 高 |
嗯,这里要注意:没有银弹。我一般会在蓝牙协议栈里同时用两种方案——固定大小池子管高频小对象,伙伴系统管大块数据。动态内存池嘛,只在配置阶段用用,跑起来后就尽量不用了。
最后说一句:不管用哪种方案,一定要加内存保护。我习惯在每个内存块头部加魔数,分配时检查、释放时校验。这招帮我抓到了不少野指针和重复释放的bug。