2. 常见泄漏场景:从malloc到智能指针的陷阱

内存泄漏这玩意儿,说白了就是「申请了没归还」。我做了十几年嵌入式,见过最离谱的一次——某设备跑三天必死,最后发现是一个循环里每次漏了 128 字节。你想想看,三天累积下来,堆空间全被吃光了。

这一章咱们就把最常见的四种泄漏场景掰开揉碎。每个场景我都配了真实踩坑案例,你对照自己的代码看看,八成能命中一两个。

2.1 场景一:malloc/calloc 忘记 free

这是最原始、也最容易被忽视的泄漏。我见过不少工程师,写驱动时临时 malloc 一块缓冲区,功能调通了,free 却忘了写。

典型错误代码:
void process_data(void) {
    uint8_t *buf = (uint8_t *)malloc(1024);
    // 使用 buf 处理数据...
    // 忘记 free(buf);
}

每次调用 process_data,堆上就多一块 1KB 的孤儿内存。如果这个函数每秒被调用一次,一小时就是 3.6MB——对于只有 64MB RAM 的嵌入式设备,撑不过一天。

我的习惯:每次写 malloc 的同时,立刻写出对应的 free。这叫「配对编码法」。你可以在函数末尾写上 free(buf),哪怕中间逻辑还没写完,先把释放占位符放好。

还有一种更隐蔽的情况——提前 return 导致释放被跳过:

void read_sensor(void) {
    int *data = (int *)malloc(100 * sizeof(int));
    if (!data) return;

    if (sensor_init() != 0) {
        // 这里直接 return 了,没有 free(data)
        return;
    }
    // 正常使用 data...
    free(data);
}

我曾经在一个电机控制项目里排查过这种问题。函数里有三个错误处理分支,只有主路径写了 free,其他两个分支全漏了。修复方法很简单——用 goto cleanup 模式统一出口:

void read_sensor_fixed(void) {
    int *data = (int *)malloc(100 * sizeof(int));
    if (!data) return;

    if (sensor_init() != 0) {
        goto cleanup;
    }
    // 正常使用 data...
cleanup:
    free(data);
}

2.2 场景二:重复 free 导致 double free

double free 比忘记 free 更危险。它不光是泄漏,还可能直接让堆管理结构崩溃,导致程序随机崩溃。

为什么会这样?因为 free 之后,那块内存被归还给堆管理器。如果你再次 free 同一地址,堆管理器会尝试操作一个已经被回收的块——轻则触发 assert,重则写坏堆链表。

我曾经踩过的坑:在一个多任务系统中,两个任务共享同一个指针。任务 A 释放了内存,任务 B 不知道,又释放了一次。结果系统跑着跑着就 hard fault,查了两天才定位到。

正确的做法是:free 之后立即将指针置 NULL。

void safe_free(void **ptr) {
    if (ptr && *ptr) {
        free(*ptr);
        *ptr = NULL;
    }
}

// 使用方式
int *p = (int *)malloc(100);
safe_free((void **)&p);
safe_free((void **)&p);  // 第二次调用安全,因为 p 已经是 NULL

注意,C 标准规定 free(NULL) 是安全的,但 free(已释放的非NULL指针) 是未定义行为。所以置 NULL 这个习惯,一定要养成。

2.3 场景三:丢失指针引用

这是最隐蔽的泄漏之一。你申请了内存,也打算释放,但指向那块内存的指针被覆盖了——再也找不回来了。

void leak_example(void) {
    int *p = (int *)malloc(100);
    p = (int *)malloc(200);  // 第一次 malloc 的地址丢失了!
    free(p);                 // 只释放了第二次的 200 字节
}

第一次 malloc 的 100 字节,从此成为「孤儿内存」。没人知道它的地址,没人能释放它。

还有一种常见情况——在链表操作中丢失头指针:

Node *head = create_node(1);
head = create_node(2);  // 第一个节点丢失了!
// 整个链表只剩一个节点,之前的节点全部泄漏
我的建议:操作指针时,脑子里要时刻有一张「谁指向谁」的地图。尤其是在链表、树、队列这些动态结构中,增删节点时一定要用临时变量保存旧地址。

下面这张图展示了指针引用丢失的过程:

指针引用丢失示意图 指针 p malloc 内存块 A 指针 p(被覆盖) malloc 内存块 A malloc 内存块 B 指针被覆盖

2.4 场景四:循环引用(C++ 智能指针)

到了 C++ 领域,很多人觉得用了 shared_ptr 就万事大吉了。其实不然——循环引用会让引用计数永远降不到零,内存照样泄漏。

看这个经典例子:

class Parent;
class Child;

class Parent {
public:
    std::shared_ptr<Child> child;
    ~Parent() { std::cout << "Parent destroyed\n"; }
};

class Child {
public:
    std::shared_ptr<Parent> parent;
    ~Child() { std::cout << "Child destroyed\n"; }
};

void test() {
    auto p = std::make_shared<Parent>();
    auto c = std::make_shared<Child>();
    p->child = c;
    c->parent = p;
    // 函数结束,p 和 c 离开作用域
    // 但两个对象的引用计数都是 1,永远不会析构!
}

运行这段代码,你会发现两个析构函数都没被调用。Parent 持有 Child 的 shared_ptr,Child 又持有 Parent 的 shared_ptr——互相牵着,谁也跑不掉。

我曾经在项目中遇到过:一个消息订阅系统,订阅者和发布者互相持有 shared_ptr。系统跑了一周后,内存占用从 20MB 涨到 200MB。最后用 weak_ptr 打破循环,问题立刻解决。

解决方案很简单——把其中一个方向的 shared_ptr 换成 weak_ptr:

class Child {
public:
    std::weak_ptr<Parent> parent;  // 不增加引用计数
    ~Child() { std::cout << "Child destroyed\n"; }
};

使用 weak_ptr 时,记得通过 lock() 获取临时 shared_ptr:

if (auto sp = child->parent.lock()) {
    sp->do_something();
} else {
    // parent 已经被销毁
}

2.5 四种场景对比速查

为了方便你快速对照,我把四种场景整理成了一张表:

场景 根本原因 典型后果 修复手段
忘记 free malloc/calloc 后未配对释放 内存持续增长,最终耗尽 配对编码法、goto cleanup
double free 同一地址释放两次 堆结构损坏,随机崩溃 free 后立即置 NULL
指针引用丢失 指针被覆盖或重新赋值 孤儿内存无法回收 使用临时变量保存旧地址
循环引用 shared_ptr 互相持有 引用计数永不归零 改用 weak_ptr 打破循环
核心原则:谁申请,谁释放;谁持有,谁负责。不管是 C 的裸指针还是 C++ 的智能指针,所有权关系必须清晰。