第二十八章:内存分配器实现:实现一个简单的malloc/free、伙伴系统、slab分配器

内存分配器,说白了就是自己管内存。很多嵌入式开发的朋友觉得直接用标准库的 malloc/free 就完事了,但我在实际项目中吃过不少亏——碎片化、分配延迟、确定性不够,这些问题在实时系统里都是致命的。这一章,咱们手撸三个经典分配器:简单 malloc/free、伙伴系统、slab 分配器。每个都有它的适用场景,你想想看,搞懂了它们,你才算真正摸到了内存管理的门道。

28.1 简单malloc/free:最朴素的分配器

先来个最基础的。我刚开始做嵌入式时,第一个分配器就是照着这个思路写的。它用空闲链表管理内存块,每个块前面加个头信息。

核心思想:把一大块内存切成小块,用链表串起来。分配时遍历链表找合适的块,释放时把块挂回链表。

// 内存块头结构
typedef struct mem_block {
    size_t size;           // 块大小(含头)
    int free;              // 1=空闲,0=已分配
    struct mem_block *next; // 下一个块
} mem_block_t;

#define BLOCK_SIZE sizeof(mem_block_t)
#define MEM_POOL_SIZE 1024 * 10  // 10KB 堆区

static char mem_pool[MEM_POOL_SIZE];
static mem_block_t *free_list = NULL;

// 初始化堆区
void mem_init() {
    free_list = (mem_block_t *)mem_pool;
    free_list->size = MEM_POOL_SIZE - BLOCK_SIZE;
    free_list->free = 1;
    free_list->next = NULL;
}

// 简单malloc
void *my_malloc(size_t size) {
    mem_block_t *curr = free_list;
    mem_block_t *prev = NULL;

    // 对齐到4字节
    size = (size + 3) & ~3;

    while (curr) {
        if (curr->free && curr->size >= size) {
            // 如果剩余空间够大,就分裂
            if (curr->size > size + BLOCK_SIZE + 4) {
                mem_block_t *new_block = (mem_block_t *)((char *)curr + BLOCK_SIZE + size);
                new_block->size = curr->size - size - BLOCK_SIZE;
                new_block->free = 1;
                new_block->next = curr->next;
                curr->size = size;
                curr->next = new_block;
            }
            curr->free = 0;
            return (void *)((char *)curr + BLOCK_SIZE);
        }
        prev = curr;
        curr = curr->next;
    }
    return NULL; // 没找到合适的块
}

// 简单free
void my_free(void *ptr) {
    if (!ptr) return;
    mem_block_t *block = (mem_block_t *)((char *)ptr - BLOCK_SIZE);
    block->free = 1;

    // 合并相邻空闲块(向前合并)
    mem_block_t *curr = free_list;
    while (curr) {
        if (curr->free && curr->next && curr->next->free) {
            curr->size += BLOCK_SIZE + curr->next->size;
            curr->next = curr->next->next;
        }
        curr = curr->next;
    }
}

我的经验:这个分配器在小型 MCU 上跑得挺欢,但碎片化严重。我曾经在一个跑了几天的设备上发现,明明还有 2KB 空闲,却分配不出一个 100 字节的块——全是碎片。所以,它只适合临时用用,或者你知道分配释放模式很规律的情况。

28.2 伙伴系统:解决碎片化的利器

伙伴系统是我个人比较喜欢的一种分配器。它把内存按 2 的幂次分成块,分配和释放都很快,而且碎片化比简单链表好得多。

为什么会这样?因为伙伴系统只合并「伙伴」——也就是大小相同、地址相邻的两个块。这样合并操作简单,而且不会产生小碎片。

#define MIN_BLOCK_SIZE 32   // 最小块 32 字节
#define MAX_ORDER 10        // 最大阶数 2^10 = 1024
#define MEM_SIZE (1 << (MAX_ORDER + 4)) // 16KB

typedef struct buddy_block {
    struct buddy_block *next;
} buddy_block_t;

static buddy_block_t *free_lists[MAX_ORDER + 1];
static char mem_area[MEM_SIZE];

// 初始化伙伴系统
void buddy_init() {
    for (int i = 0; i <= MAX_ORDER; i++) {
        free_lists[i] = NULL;
    }
    // 把整块内存挂到最大阶
    free_lists[MAX_ORDER] = (buddy_block_t *)mem_area;
    free_lists[MAX_ORDER]->next = NULL;
}

// 获取块大小对应的阶数
int get_order(size_t size) {
    int order = 0;
    size_t block_size = MIN_BLOCK_SIZE;
    while (block_size < size) {
        block_size <<= 1;
        order++;
    }
    return order;
}

// 伙伴分配
void *buddy_alloc(size_t size) {
    int order = get_order(size);
    if (order > MAX_ORDER) return NULL;

    // 查找可用块
    int current_order = order;
    while (current_order <= MAX_ORDER && free_lists[current_order] == NULL) {
        current_order++;
    }
    if (current_order > MAX_ORDER) return NULL;

    // 取出块
    buddy_block_t *block = free_lists[current_order];
    free_lists[current_order] = block->next;

    // 分裂直到目标阶数
    while (current_order > order) {
        current_order--;
        size_t block_size = MIN_BLOCK_SIZE << current_order;
        buddy_block_t *buddy = (buddy_block_t *)((char *)block + block_size);
        buddy->next = free_lists[current_order];
        free_lists[current_order] = buddy;
    }

    return (void *)block;
}

// 伙伴释放
void buddy_free(void *ptr, size_t size) {
    int order = get_order(size);
    if (order > MAX_ORDER) return;

    buddy_block_t *block = (buddy_block_t *)ptr;
    size_t block_size = MIN_BLOCK_SIZE << order;

    // 尝试合并伙伴
    while (order < MAX_ORDER) {
        // 计算伙伴地址
        buddy_block_t *buddy = (buddy_block_t *)((uintptr_t)ptr ^ block_size);
        // 检查伙伴是否在空闲链表中(简化版,实际需要更复杂的标记)
        // 这里假设伙伴一定在链表中——实际实现需要位图或标记
        block->next = free_lists[order];
        free_lists[order] = block;
        order++;
        block_size <<= 1;
        // 实际合并需要找到伙伴并移除,这里简化
        break;
    }

    // 挂回链表
    block->next = free_lists[order];
    free_lists[order] = block;
}

注意:上面的伙伴释放代码做了简化。实际项目中,你需要用位图或标记来记录每个块的状态,才能正确判断伙伴是否空闲。我曾经在一个项目中偷懒没做位图,结果合并时把已分配的块也合并了,系统直接崩溃。嗯,从那以后我再也不敢省这一步了。

28.3 Slab分配器:为固定大小对象而生

Slab 分配器是 Linux 内核里用的那一套。它专门对付频繁分配释放固定大小对象的情况——比如 task_struct、inode 这些内核对象。说白了,就是预先准备好一批相同大小的对象,用的时候直接拿,用完放回去。

#define SLAB_OBJ_SIZE 64    // 每个对象 64 字节
#define SLAB_OBJ_NUM  16    // 每个 slab 放 16 个对象
#define SLAB_SIZE (SLAB_OBJ_SIZE * SLAB_OBJ_NUM)

typedef struct slab {
    void *free_list;        // 空闲对象链表
    int free_count;         // 空闲对象数
    struct slab *next;      // 链表指针
    char data[SLAB_SIZE];   // 对象存储区
} slab_t;

static slab_t *slab_head = NULL;

// 初始化 slab
void slab_init() {
    slab_head = (slab_t *)malloc(sizeof(slab_t));
    slab_head->free_count = SLAB_OBJ_NUM;
    slab_head->next = NULL;

    // 构建空闲链表
    char *ptr = slab_head->data;
    void **prev = &slab_head->free_list;
    for (int i = 0; i < SLAB_OBJ_NUM; i++) {
        *prev = (void *)ptr;
        prev = (void **)ptr;
        ptr += SLAB_OBJ_SIZE;
    }
    *prev = NULL;
}

// slab 分配
void *slab_alloc() {
    slab_t *slab = slab_head;
    while (slab) {
        if (slab->free_count > 0) {
            void *obj = slab->free_list;
            slab->free_list = *(void **)obj;
            slab->free_count--;
            return obj;
        }
        slab = slab->next;
    }

    // 没有空闲 slab,创建新的
    slab_t *new_slab = (slab_t *)malloc(sizeof(slab_t));
    new_slab->free_count = SLAB_OBJ_NUM;
    new_slab->next = slab_head;
    slab_head = new_slab;

    // 初始化空闲链表
    char *ptr = new_slab->data;
    void **prev = &new_slab->free_list;
    for (int i = 0; i < SLAB_OBJ_NUM; i++) {
        *prev = (void *)ptr;
        prev = (void **)ptr;
        ptr += SLAB_OBJ_SIZE;
    }
    *prev = NULL;

    // 分配第一个对象
    void *obj = new_slab->free_list;
    new_slab->free_list = *(void **)obj;
    new_slab->free_count--;
    return obj;
}

// slab 释放
void slab_free(void *ptr) {
    // 找到对象所属的 slab(简化版:遍历所有 slab)
    slab_t *slab = slab_head;
    while (slab) {
        if (ptr >= (void *)slab->data && 
            ptr < (void *)(slab->data + SLAB_SIZE)) {
            // 把对象放回空闲链表
            *(void **)ptr = slab->free_list;
            slab->free_list = ptr;
            slab->free_count++;
            return;
        }
        slab = slab->next;
    }
    // 没找到——说明 ptr 不是这个 slab 分配的
    // 实际项目中应该 panic 或记录错误
}

我的习惯:在 RTOS 的任务控制块、信号量、消息队列这些内核对象上,我几乎都用 slab。分配一个任务块只需要几条指令,比通用 malloc 快一个数量级。而且没有碎片——因为所有对象大小一样,释放后直接复用。

28.4 三种分配器对比

特性 简单malloc/free 伙伴系统 Slab分配器
分配速度 O(n),遍历链表 O(log n),分裂 O(1),直接取
释放速度 O(n),合并 O(log n),合并 O(1),放回链表
碎片化 严重 内部碎片(2的幂次对齐) 无碎片(固定大小)
适用场景 小系统、临时使用 通用分配、实时系统 频繁分配固定大小对象
内存利用率 高(但碎片多) 中(内部碎片) 高(无碎片)

28.5 实战建议

我个人在实际项目中,通常这样搭配使用:

  • 启动阶段:用简单 malloc/free 做临时分配,反正这时候系统还没跑起来,碎片不是问题。
  • 运行时:用伙伴系统做通用内存分配,特别是需要分配不同大小内存块的场景。
  • 内核对象:用 slab 分配器管理固定大小的结构体,比如任务块、信号量、缓冲区描述符。

你想想看,这三种分配器各有各的脾气。简单 malloc/free 就像菜市场——什么都有,但乱。伙伴系统像超市——整齐,但包装有点浪费。slab 分配器像工厂流水线——专一,但高效。选哪个,得看你的系统到底要什么。

避坑指南:我曾经在一个项目中只用伙伴系统,结果发现分配 33 字节时,实际占了 64 字节——内部碎片接近 50%。后来我加了个 slab 分配器专门处理小对象,内存利用率从 60% 提升到了 85%。所以,别指望一个分配器解决所有问题。

三种内存分配器对比 简单malloc/free ✅ 实现简单 ✅ 内存利用率高 ❌ 分配O(n) ❌ 碎片严重 ❌ 不适合实时系统 适用:小系统、临时分配 伙伴系统 ✅ 分配O(log n) ✅ 释放O(log n) ✅ 碎片较少 ❌ 内部碎片 ❌ 实现稍复杂 适用:通用分配、实时系统 Slab分配器 ✅ 分配O(1) ✅ 释放O(1) ✅ 无碎片 ✅ 缓存友好 ❌ 只适合固定大小 适用:内核对象、频繁分配 选择建议:小系统用简单malloc,通用场景用伙伴系统,固定对象用slab

好了,这一章的内容就到这。三种分配器,从简单到复杂,从通用到专用,你都可以在项目中找到它们的位置。记住,没有银弹——选对工具,比会用工具更重要。