第二十四章:智能指针模拟:C语言中模拟引用计数、RAII思想在C中的实现
说实话,很多从C++转回C的工程师,最怀念的就是智能指针。我自己也经历过这种“戒断反应”——在C里手动管理内存,稍不留神就漏了或者重复释放。但C语言真的做不到吗?其实可以。我们用结构体、函数指针和一点巧思,就能模拟出引用计数和RAII的核心思想。
24.1 引用计数:给内存加个“计数器”
引用计数的原理很简单:每块动态内存都配一个计数器。有人引用它,计数加1;引用结束,计数减1。当计数归零时,自动释放。我在项目中用这个模式管理共享缓冲区,效果不错。
核心结构体设计:
typedef struct {
void *data; // 实际数据指针
int *ref_count; // 引用计数器(堆上分配)
void (*free_fn)(void *); // 自定义释放函数
} SharedPtr;
这里有个关键点:ref_count 必须分配在堆上。为什么?因为多个 SharedPtr 实例要共享同一个计数器。如果放在栈上,函数返回就没了。
24.2 创建与销毁:谁分配,谁负责
创建智能指针时,我习惯把数据和计数器一起分配。这样能减少内存碎片,也方便管理。
SharedPtr shared_ptr_create(void *data, void (*free_fn)(void *)) {
SharedPtr ptr;
ptr.data = data;
ptr.ref_count = (int *)malloc(sizeof(int));
if (ptr.ref_count) {
*ptr.ref_count = 1;
}
ptr.free_fn = free_fn;
return ptr;
}
嗯,这里要注意:malloc 可能失败。虽然概率低,但嵌入式环境里不能赌。我一般会加个断言或者返回一个空结构体让调用方检查。
释放时,先减计数。只有计数归零才真正释放内存:
void shared_ptr_destroy(SharedPtr *ptr) {
if (!ptr || !ptr->ref_count) return;
(*ptr->ref_count)--;
if (*ptr->ref_count == 0) {
if (ptr->free_fn) {
ptr->free_fn(ptr->data);
} else {
free(ptr->data);
}
free(ptr->ref_count);
ptr->data = NULL;
ptr->ref_count = NULL;
}
}
我曾经踩过的坑: 忘记把 ptr->data 和 ptr->ref_count 置空。结果后续代码误判指针有效,导致野指针访问。记住:释放后一定要清空,这是C语言的好习惯。
24.3 拷贝与赋值:引用计数怎么变?
拷贝时,新指针和老指针共享同一块内存。计数加1就行:
SharedPtr shared_ptr_copy(SharedPtr *src) {
SharedPtr new_ptr = *src;
if (new_ptr.ref_count) {
(*new_ptr.ref_count)++;
}
return new_ptr;
}
赋值操作稍微复杂点。得先释放旧资源,再拷贝新资源:
void shared_ptr_assign(SharedPtr *dest, SharedPtr *src) {
if (dest == src) return; // 自赋值检查
shared_ptr_destroy(dest); // 释放旧资源
*dest = *src;
if (dest->ref_count) {
(*dest->ref_count)++;
}
}
自赋值检查很多人会忘。你想想看,如果 dest 和 src 指向同一个 SharedPtr,先释放再拷贝,数据就丢了。我在代码审查时见过好几次这种bug。
24.4 RAII思想:用作用域管理资源
RAII(Resource Acquisition Is Initialization)说白了就是:资源在构造函数中获取,在析构函数中释放。C语言没有构造函数和析构函数,但我们可以用宏来模拟。
我个人喜欢用 __attribute__((cleanup)) 这个GCC扩展。它能在变量离开作用域时自动调用清理函数:
#define AUTO_SHARED_PTR __attribute__((cleanup(shared_ptr_auto_cleanup))) SharedPtr
static inline void shared_ptr_auto_cleanup(SharedPtr *ptr) {
shared_ptr_destroy(ptr);
}
用法很简洁:
void example(void) {
AUTO_SHARED_PTR p1 = shared_ptr_create(malloc(100), free);
// 使用 p1 ...
// 函数结束时,p1 自动释放
}
提示: __attribute__((cleanup)) 是GCC和Clang的特性。如果你用其他编译器,可以用 goto 集中释放,或者用 do { ... } while(0) 宏包裹作用域。我在一个IAR项目里就用过后者,效果也不错。
24.5 完整示例:共享缓冲区管理
下面是一个实际可用的例子。多个模块共享同一个数据缓冲区,谁最后用完谁释放:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
// 前面定义的 SharedPtr 结构体和函数...
void process_data(SharedPtr buf) {
// 模拟处理数据
printf("Processing: %s\n", (char *)buf.data);
// 函数结束自动释放(如果用 AUTO_SHARED_PTR)
}
int main(void) {
AUTO_SHARED_PTR buf = shared_ptr_create(malloc(256), free);
strcpy((char *)buf.data, "Hello, RAII in C!");
AUTO_SHARED_PTR buf2 = shared_ptr_copy(&buf);
process_data(buf2);
// buf 和 buf2 共享同一块内存
// main 结束时,引用计数归零,自动释放
return 0;
}
运行这段代码,你会发现内存管理完全自动化了。没有手动 free,没有内存泄漏。这就是RAII的魅力。
24.6 知识体系图
下面这张图总结了本章的核心逻辑:
24.7 避坑指南与最佳实践
做了这么多年嵌入式,我总结了几条关于引用计数的经验:
- 循环引用是杀手。 如果A引用B,B又引用A,两个计数永远不为0。我一般用弱引用来打破循环——弱引用不增加计数。
- 多线程要加锁。 引用计数的加减不是原子操作。在RTOS或Linux多线程环境下,要用互斥锁或原子操作保护。我习惯用
__sync_fetch_and_add这类GCC内置原子函数。 - 不要直接操作
ref_count。 所有计数操作都通过shared_ptr_copy和shared_ptr_destroy完成。我曾经见过有人直接(*ptr->ref_count)++,结果漏了释放函数调用,内存泄漏查了一整天。 - 自定义释放函数很重要。 不是所有资源都用
free。文件句柄、网络连接、硬件缓冲区,每种资源的释放方式不同。把释放逻辑封装在free_fn里,代码更干净。
警告: 引用计数不是银弹。对于小型嵌入式系统(比如只有几KB RAM的MCU),引用计数的开销可能太大。每个 SharedPtr 要多占一个指针和一个整数,还有函数调用开销。这种情况下,我建议用作用域嵌套或静态分析工具来管理内存,而不是引用计数。
好了,这就是C语言中模拟智能指针的完整方案。从引用计数的结构设计,到RAII的宏实现,再到实际应用中的注意事项。希望这些经验能帮你写出更安全、更优雅的C代码。
公众号:蓝海资料掘金营,微信 deep3321