第二十四章:智能指针模拟: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->dataptr->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)++;
    }
}

自赋值检查很多人会忘。你想想看,如果 destsrc 指向同一个 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 知识体系图

下面这张图总结了本章的核心逻辑:

C语言智能指针模拟:引用计数 + RAII SharedPtr 结构体 void *data int *ref_count void (*free_fn)() shared_ptr_create() shared_ptr_destroy() shared_ptr_copy() AUTO_SHARED_PTR 宏(__attribute__((cleanup))) 自动内存管理 · 无泄漏 · 线程安全(需加锁)

24.7 避坑指南与最佳实践

做了这么多年嵌入式,我总结了几条关于引用计数的经验:

  • 循环引用是杀手。 如果A引用B,B又引用A,两个计数永远不为0。我一般用弱引用来打破循环——弱引用不增加计数。
  • 多线程要加锁。 引用计数的加减不是原子操作。在RTOS或Linux多线程环境下,要用互斥锁或原子操作保护。我习惯用 __sync_fetch_and_add 这类GCC内置原子函数。
  • 不要直接操作 ref_count 所有计数操作都通过 shared_ptr_copyshared_ptr_destroy 完成。我曾经见过有人直接 (*ptr->ref_count)++,结果漏了释放函数调用,内存泄漏查了一整天。
  • 自定义释放函数很重要。 不是所有资源都用 free。文件句柄、网络连接、硬件缓冲区,每种资源的释放方式不同。把释放逻辑封装在 free_fn 里,代码更干净。

警告: 引用计数不是银弹。对于小型嵌入式系统(比如只有几KB RAM的MCU),引用计数的开销可能太大。每个 SharedPtr 要多占一个指针和一个整数,还有函数调用开销。这种情况下,我建议用作用域嵌套或静态分析工具来管理内存,而不是引用计数。

好了,这就是C语言中模拟智能指针的完整方案。从引用计数的结构设计,到RAII的宏实现,再到实际应用中的注意事项。希望这些经验能帮你写出更安全、更优雅的C代码。


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