30、指针综合实战:实现一个内存池、实现一个动态数组库、实现一个简单的垃圾回收

指针学到这个份上,咱们得动真格的了。

前面二十几章,我把指针的语法、陷阱、底层原理翻来覆去讲了个遍。但说实话,光看不练,你永远不知道指针到底有多「锋利」。这一章,我带你做三个实战项目:内存池、动态数组库、简单垃圾回收。这三个东西,我在嵌入式项目里反复写过,每次都能帮我省下不少调试时间。

核心目标:用指针操作裸内存,实现三个工业级的基础组件。不依赖任何第三方库,纯 C 语言手写。

30.1 内存池:告别碎片化 malloc

先说说内存池。我在一个实时音频处理项目里遇到过这种情况:系统跑着跑着,malloc 返回 NULL 了。查了半天,不是内存不够,是碎片太多——小块内存反复申请释放,堆被切得跟蜂窝煤似的。

内存池的思路很简单:提前申请一大块连续内存,然后自己管理。分配和释放都是 O(1) 复杂度,而且没有碎片。

我习惯用「固定块大小」的内存池。结构体长这样:

typedef struct {
    void *pool_start;      // 池子起始地址
    size_t block_size;     // 每个块的大小
    size_t block_count;    // 块的总数
    void *free_list;       // 空闲链表头指针
} MemoryPool;

初始化时,我把所有空闲块串成一个链表。每个空闲块的前 4 或 8 个字节(取决于指针大小)存放下一个空闲块的地址。这就是所谓的「隐式空闲链表」——不额外占用内存,指针直接写在块内部。

void pool_init(MemoryPool *pool, void *buffer, size_t block_size, size_t block_count) {
    pool->pool_start = buffer;
    pool->block_size = block_size;
    pool->block_count = block_count;
    pool->free_list = buffer;

    // 把每个块串起来
    char *current = (char *)buffer;
    for (size_t i = 0; i < block_count - 1; i++) {
        void **next = (void **)current;
        *next = current + block_size;
        current += block_size;
    }
    // 最后一个块指向 NULL
    void **last = (void **)current;
    *last = NULL;
}

分配时,直接从 free_list 头部取一个块:

void *pool_alloc(MemoryPool *pool) {
    if (pool->free_list == NULL) return NULL;
    void *block = pool->free_list;
    pool->free_list = *(void **)block;
    return block;
}

释放时,把块放回 free_list 头部:

void pool_free(MemoryPool *pool, void *block) {
    *(void **)block = pool->free_list;
    pool->free_list = block;
}

我的习惯:内存池的 block_size 最好对齐到 4 或 8 字节。ARM Cortex-M 系列上,未对齐访问会触发 hard fault。我吃过这个亏,焊了三天板子才找到原因。

30.2 动态数组库:自动扩容的「智能」数组

C 语言的原生数组是静态的,长度固定。动态数组说白了就是「用指针管理堆上的连续内存,不够了就 realloc」。

我设计的动态数组结构体:

typedef struct {
    void *data;          // 数据指针
    size_t elem_size;    // 每个元素的大小
    size_t capacity;     // 当前容量(元素个数)
    size_t length;       // 实际元素个数
} DynArray;

初始化时,我习惯先分配 4 个元素的容量。太小了频繁 realloc,太大了浪费内存。

void da_init(DynArray *da, size_t elem_size) {
    da->elem_size = elem_size;
    da->capacity = 4;
    da->length = 0;
    da->data = malloc(elem_size * da->capacity);
}

添加元素时,检查容量是否够用。不够就翻倍扩容:

void da_push(DynArray *da, void *elem) {
    if (da->length >= da->capacity) {
        da->capacity *= 2;
        da->data = realloc(da->data, da->elem_size * da->capacity);
    }
    // 把元素拷贝到末尾
    char *target = (char *)da->data + da->length * da->elem_size;
    memcpy(target, elem, da->elem_size);
    da->length++;
}

访问元素时,我用一个宏来简化:

#define DA_AT(da, index, type)  ((type *)((char *)(da).data + (index) * (da).elem_size))

用起来就像这样:

DynArray arr;
da_init(&arr, sizeof(int));
int val = 42;
da_push(&arr, &val);
printf("%d\n", *DA_AT(arr, 0, int));  // 输出 42

注意:realloc 可能会移动内存块,导致之前保存的 data 指针失效。我曾经在项目中把 data 指针存到了另一个结构体里,realloc 之后没更新,结果野指针满天飞。记住:每次 realloc 后,重新获取 data 地址。

30.3 简单垃圾回收:标记-清除法

垃圾回收?C 语言也能做?

能。虽然不像 Java 那样自动,但我们可以写一个简单的「标记-清除」回收器。核心思路:维护一个全局的「根集合」,从根出发遍历所有指针,能访问到的就是「活的」,否则就是「垃圾」。

我实现了一个极简版本,只支持单类型(void *指针)的回收。结构体如下:

typedef struct GCObject {
    struct GCObject *next;   // 链表指针
    int marked;              // 标记位
    void *data;              // 实际数据
} GCObject;

typedef struct {
    GCObject *head;          // 对象链表头
    GCObject **roots;        // 根指针数组
    int root_count;          // 根的数量
} GC;

分配对象时,插入到链表:

GCObject *gc_alloc(GC *gc, size_t size) {
    GCObject *obj = malloc(sizeof(GCObject) + size);
    obj->next = gc->head;
    obj->marked = 0;
    obj->data = (char *)obj + sizeof(GCObject);
    gc->head = obj;
    return obj;
}

标记阶段:从根出发,递归标记所有可达对象。

void gc_mark(GC *gc) {
    for (int i = 0; i < gc->root_count; i++) {
        GCObject *obj = (GCObject *)(*(void **)gc->roots[i]);
        // 这里简化了:假设根直接指向 GCObject 头部
        // 实际项目中需要更复杂的指针追踪
        if (obj) mark_recursive(obj);
    }
}

void mark_recursive(GCObject *obj) {
    if (obj == NULL || obj->marked) return;
    obj->marked = 1;
    // 如果 data 里还包含指针,需要递归标记
    // 这里省略了类型信息,实际需要用户提供回调
}

清除阶段:遍历链表,回收未标记的对象。

void gc_sweep(GC *gc) {
    GCObject **curr = &gc->head;
    while (*curr) {
        if (!(*curr)->marked) {
            GCObject *unused = *curr;
            *curr = unused->next;
            free(unused);
        } else {
            (*curr)->marked = 0;  // 清除标记,准备下一轮
            curr = &(*curr)->next;
        }
    }
}

说实话,这个回收器很简陋。真正的 GC 需要知道每个对象里哪些字段是指针,这需要编译器支持。但在嵌入式场景下,如果你能手动管理根集合,这套机制足够帮你回收大部分循环引用的垃圾。

30.4 三个组件的协作关系

这三个东西不是孤立的。我画了一张图,帮你理清它们之间的关系:

指针综合实战:三大组件协作关系 内存池 固定块分配/释放 O(1) 无碎片 动态数组库 自动扩容 泛型元素存储 简单垃圾回收 标记-清除 根集合管理 协作说明: 1. 动态数组的底层存储可以来自内存池(避免 malloc 碎片) 2. 垃圾回收器可以管理动态数组分配的对象(自动释放) 3. 三者都依赖指针操作裸内存,是 C 指针的终极应用 指针综合实战知识体系

30.5 避坑指南与个人经验

  • 内存池的块大小选择:我一般取项目中最大结构体的大小,再对齐到 8 字节。太小了浪费链表指针空间,太大了浪费内存。
  • 动态数组的缩容策略:只扩容不缩容?那内存会越用越多。我习惯在元素个数降到容量的 1/4 时,缩容到一半。避免频繁缩容导致抖动。
  • 垃圾回收的暂停时间:标记-清除法在标记阶段会暂停程序。我在一个传感器数据采集项目里用过,标记 1000 个对象大约耗时 2ms,对实时性要求不高的场景完全够用。

我曾经踩过一个坑:内存池的 free_list 指针被意外覆盖了。原因是分配出去的块,用户越界写入了前几个字节。从那以后,我每次分配都返回块起始地址偏移 4 字节的位置,把链表指针藏在块头部,用户不可见。代价是多占 4 字节,但安全多了。

30.6 总结

这三个实战项目,说白了就是指针的三种典型用法:

  • 内存池:用指针管理空闲链表
  • 动态数组:用指针操作连续内存,配合 realloc 动态调整
  • 垃圾回收:用指针追踪对象间的引用关系

你想想看,如果把这些代码吃透了,以后遇到内存管理相关的问题,心里是不是就有底了?

嗯,指针专题到这里就结束了。三十章,从基础语法到高级实战,我尽量把每个知识点都掰开揉碎了讲。希望你能把这些代码敲一遍,跑一遍,再改一改——只有亲手写过,指针才能真正变成你的工具。


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