30、综合实战:实现一个简易内存分配器

说实话,动态内存管理是C语言里最让人又爱又恨的部分。爱它,是因为它给了我们极大的灵活性;恨它,是因为一旦用不好,段错误、内存泄漏就找上门了。今天,咱们就亲手写一个简易的内存分配器。别怕,我会带着你一步步来。

我刚开始做嵌入式项目时,就遇到过标准库的malloc在资源紧张的环境下表现不佳的情况。嗯,从那以后,我就开始琢磨:能不能自己控制内存的分配和释放?于是就有了今天这个实战。

1. 核心思路:我们到底要做什么?

说白了,我们要实现一个类似malloc和free的东西。但这次,内存池是我们自己管理的。

基本想法是这样的:

  • 预先申请一大块静态内存(比如一个全局数组)。
  • 自己维护一个“空闲链表”,记录哪些内存块是空闲的。
  • 用户请求分配时,从空闲链表中找一块合适的给他。
  • 用户释放时,把内存块重新挂回空闲链表。

你想想看,这不就是一个小型的内存管家吗?

核心要点: 我们只实现最简单的“首次适应”算法。也就是从链表头开始找,找到第一个足够大的空闲块就分配出去。

2. 数据结构设计:怎么管理这些内存块?

每个内存块都需要一个“头部”来记录元信息。我习惯用一个结构体来表示:

// 内存块头部
typedef struct mem_block {
    size_t   size;       // 数据区大小(不含头部)
    int      free;       // 1=空闲,0=已分配
    struct mem_block *next; // 指向下一个块
} mem_block_t;

注意,这个头部是紧挨着数据区的。也就是说,用户拿到的指针,其实是 头部 + 1 的位置。这样做的好处是,释放时我们只需要从用户指针回退一个头部大小,就能拿到完整的元信息。

我的小习惯: 头部里只放必要的信息。size、free标志、next指针,这三个就够了。别放太多,否则每个块的开销太大。

3. 内存池初始化:先圈一块地

我们需要一个全局的内存池。我建议用静态数组,这样地址是固定的,调试起来也方便。

#define POOL_SIZE 1024 * 10  // 10KB
static char memory_pool[POOL_SIZE];

初始化时,整个池子就是一个大空闲块。我们把第一个头部放在池子的起始位置:

void mem_init() {
    mem_block_t *first = (mem_block_t*)memory_pool;
    first->size = POOL_SIZE - sizeof(mem_block_t);
    first->free = 1;
    first->next = NULL;
}

这里要注意:size 记录的是数据区的大小,不是整个块的大小。整个块的大小是 sizeof(mem_block_t) + size

我曾经踩过的坑: 初始化时忘记减去头部大小,导致后续分配时总感觉内存少了几个字节。后来排查了半天才发现是这里算错了。所以,一定要把头部大小算清楚。

4. 分配内存:my_malloc 的实现

分配的逻辑其实不复杂:遍历空闲链表,找到第一个 free == 1size >= 请求大小 的块。

但这里有个关键点:如果找到的块比请求的大很多,我们要不要分割?我的答案是:要分割。否则会造成内部碎片。

void *my_malloc(size_t size) {
    mem_block_t *curr = (mem_block_t*)memory_pool;
    mem_block_t *best = NULL;

    // 遍历链表,首次适应
    while (curr != NULL) {
        if (curr->free && curr->size >= size) {
            best = curr;
            break;
        }
        curr = curr->next;
    }

    if (best == NULL) {
        return NULL; // 没有合适的块
    }

    // 如果剩余空间足够大,就分割
    size_t remaining = best->size - size;
    if (remaining > sizeof(mem_block_t) + 4) { // 至少能再放一个头部+4字节
        mem_block_t *new_block = (mem_block_t*)((char*)(best + 1) + size);
        new_block->size = remaining - sizeof(mem_block_t);
        new_block->free = 1;
        new_block->next = best->next;

        best->size = size;
        best->next = new_block;
    }

    best->free = 0;
    return (void*)(best + 1);
}

为什么剩余空间要大于 sizeof(mem_block_t) + 4 才分割?因为新块至少需要一个头部,再加上一点点数据区,否则分割没有意义。

注意: 返回给用户的是 best + 1,也就是跳过头部,直接指向数据区。用户完全不知道头部的存在,这才是封装。

5. 释放内存:my_free 的实现

释放相对简单。用户把指针还给我们,我们回退一个头部大小,拿到 mem_block_t,然后把 free 标志置为1。

但这里有个进阶操作:合并相邻的空闲块。如果不合并,内存会越来越碎片化。

void my_free(void *ptr) {
    if (ptr == NULL) return;

    mem_block_t *block = (mem_block_t*)ptr - 1;
    block->free = 1;

    // 尝试向后合并
    mem_block_t *next = block->next;
    if (next != NULL && next->free) {
        block->size += sizeof(mem_block_t) + next->size;
        block->next = next->next;
    }

    // 尝试向前合并(需要遍历链表找到前一个块)
    mem_block_t *curr = (mem_block_t*)memory_pool;
    while (curr != NULL && curr->next != block) {
        curr = curr->next;
    }
    if (curr != NULL && curr->free) {
        curr->size += sizeof(mem_block_t) + block->size;
        curr->next = block->next;
    }
}

向前合并需要遍历链表,效率不高。但考虑到我们的池子只有10KB,这点开销可以接受。

我的建议: 在实际项目中,如果内存块数量很多,可以考虑用双向链表,这样向前合并就不用遍历了。但今天这个简易版本,单向链表就够了。

6. 整体流程图:看看数据怎么流动

下面这张图展示了从初始化到分配、释放的完整流程。我特意画得比较直观,方便你理解。

简易内存分配器工作流程 1. mem_init() 整个池子=一个大空闲块 2. my_malloc(size) 遍历链表,首次适应 3. 分割(可选) 剩余空间足够则分割 返回 指针 4. my_free(ptr) 回退头部,置free=1 5. 合并相邻空闲块 向后合并 + 向前合并 6. 链表更新完成 等待下一次分配 关键点:每次分配/释放后,链表中的块要么被分割,要么被合并 保证内存利用率最大化,减少碎片 💡 首次适应算法简单高效,适合小规模内存池

7. 测试一下:写个简单的demo

光说不练假把式。我们来写几行代码测试一下:

#include <stdio.h>
#include <string.h>

int main() {
    mem_init();

    char *p1 = (char*)my_malloc(100);
    char *p2 = (char*)my_malloc(200);
    char *p3 = (char*)my_malloc(50);

    strcpy(p1, "Hello, memory allocator!");
    printf("p1: %s\n", p1);

    my_free(p2);
    my_free(p1);

    char *p4 = (char*)my_malloc(150);
    printf("p4 allocated, size=150\n");

    my_free(p3);
    my_free(p4);

    printf("All tests passed!\n");
    return 0;
}

如果一切正常,你应该能看到输出。注意,这里我们没有处理内存耗尽的情况,实际项目中一定要加错误处理。

注意: 这个分配器没有线程安全保护。如果在多线程环境下使用,需要加锁。不过作为教学示例,单线程足够了。

8. 总结与思考

写到这里,一个简易的内存分配器就完成了。它虽然简单,但麻雀虽小五脏俱全:有初始化、分配、释放、分割、合并。你想想看,标准库的malloc底层也不过是这些思路的延伸。

我个人觉得,动手实现一次内存分配器,对理解C语言的内存模型非常有帮助。以后再遇到段错误、内存泄漏,你至少能猜到问题可能出在哪个环节。

最后,留个思考题给你:如果我想支持“对齐分配”(比如要求返回的地址是8字节对齐),该怎么改?嗯,这个问题留给你自己琢磨。


公众号:蓝海资料掘金营,微信deep3321