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 三个组件的协作关系
这三个东西不是孤立的。我画了一张图,帮你理清它们之间的关系:
30.5 避坑指南与个人经验
- 内存池的块大小选择:我一般取项目中最大结构体的大小,再对齐到 8 字节。太小了浪费链表指针空间,太大了浪费内存。
- 动态数组的缩容策略:只扩容不缩容?那内存会越用越多。我习惯在元素个数降到容量的 1/4 时,缩容到一半。避免频繁缩容导致抖动。
- 垃圾回收的暂停时间:标记-清除法在标记阶段会暂停程序。我在一个传感器数据采集项目里用过,标记 1000 个对象大约耗时 2ms,对实时性要求不高的场景完全够用。
我曾经踩过一个坑:内存池的 free_list 指针被意外覆盖了。原因是分配出去的块,用户越界写入了前几个字节。从那以后,我每次分配都返回块起始地址偏移 4 字节的位置,把链表指针藏在块头部,用户不可见。代价是多占 4 字节,但安全多了。
30.6 总结
这三个实战项目,说白了就是指针的三种典型用法:
- 内存池:用指针管理空闲链表
- 动态数组:用指针操作连续内存,配合 realloc 动态调整
- 垃圾回收:用指针追踪对象间的引用关系
你想想看,如果把这些代码吃透了,以后遇到内存管理相关的问题,心里是不是就有底了?
嗯,指针专题到这里就结束了。三十章,从基础语法到高级实战,我尽量把每个知识点都掰开揉碎了讲。希望你能把这些代码敲一遍,跑一遍,再改一改——只有亲手写过,指针才能真正变成你的工具。
公众号:蓝海资料掘金营,微信deep3321