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 的嵌入式设备,撑不过一天。
还有一种更隐蔽的情况——提前 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,重则写坏堆链表。
正确的做法是: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); // 第一个节点丢失了!
// 整个链表只剩一个节点,之前的节点全部泄漏
下面这张图展示了指针引用丢失的过程:
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 换成 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 打破循环 |