7、free函数深度解析:free的工作原理、double free问题、悬空指针、free(NULL)的安全性

大家好,我是你们的嵌入式老司机。今天我们来聊聊 free 这个函数。

说实话,free 看起来简单,就一个参数,一个返回值都没有。但在我十几年的C语言生涯里,内存释放 出的问题,比内存分配多十倍不止。你想想看,分配内存的时候我们小心翼翼,一到了释放,很多人就开始放飞自我了。

嗯,今天我们就把它彻底讲透。

7.1 free 到底干了什么?

很多人以为 free(p) 就是把 p 指向的内存“删除”了。其实不是。

free 做的事情,说白了就两步:

  1. 标记归还:告诉堆管理器,这块内存我不用了,你可以回收。
  2. 合并空闲块:如果相邻的内存也是空闲的,把它们合并成一块更大的空闲块,减少碎片。

但它 不会 把内存里的数据清零,也不会把指针 p 置为 NULL。这就是很多 bug 的根源。

核心理解: free 之后,那块内存还在,只是它的“所有权”从你的程序交还给了堆管理器。里面的数据可能还在,但随时可能被下一次 malloc 覆盖。

我个人习惯,在 free 之后立刻把指针置为 NULL。这不是 C 标准要求的,但这是一个非常好的习惯。为什么?我们后面讲悬空指针的时候你就明白了。

7.2 free 的内部机制(简版)

为了让你理解得更透彻,我画了一张图。这张图展示了 free 在堆管理器内部的大致流程。

free(p) 内部执行流程 步骤1:检查指针 p == NULL ? 直接返回 步骤2:获取元数据 从指针偏移读取块大小 步骤3:标记空闲 修改控制位 步骤4:合并相邻空闲块 检查前后块,合并以减少碎片 步骤5:更新空闲链表 将块加入空闲列表 返回 注意 free 不会清空数据 不会置 NULL 指针

你看,流程并不复杂。但正是这些看似简单的步骤里,藏着无数的坑。

7.3 double free:同一个错误,犯两次

double free 指的是对同一块内存调用两次 free。这是 C 语言里最经典的错误之一。

危险: double free 会导致堆管理器内部数据结构损坏。轻则程序崩溃,重则产生安全漏洞(攻击者可以利用它实现任意地址写)。

为什么会这样?

第一次 free 之后,那块内存被标记为空闲,并且可能被合并进了空闲链表。第二次 free 时,堆管理器会再次尝试操作这块内存,但此时它的状态已经不是合法的已分配块了。这就像你把同一把钥匙还给了管理员两次——第二次的时候,管理员会懵掉。

我曾经在一个产品代码里见过这样的 bug:

void process_data(void) {
    char *buf = (char*)malloc(1024);
    if (!buf) return;
    
    // ... 使用 buf ...
    
    free(buf);
    
    // 某些条件下,另一个函数也会 free(buf)
    // 但 buf 已经被释放了!
    cleanup_resources();  // 这里面又 free(buf) 了一次
}

这种 bug 很难查,因为两次 free 可能相隔很远,甚至在不同的函数里。我的建议是:谁分配,谁释放。尽量不要把指针传来传去然后各自释放。

7.4 悬空指针:最阴险的敌人

悬空指针(dangling pointer)是指向已释放内存的指针。它比 NULL 指针危险得多,因为 它看起来是有效的

你想想看:

  • NULL 指针:你访问它,程序立即崩溃,你能马上定位问题。
  • 悬空指针:你访问它,可能正常,可能出错,可能覆盖别人的数据。这种不确定性是最可怕的。

我记得有一次调试一个网络协议栈,数据包偶尔会校验失败。查了三天,最后发现是一个悬空指针导致的——释放了一个缓冲区后,另一个线程还在用那个指针读数据。因为那块内存被重新分配给了别的模块,数据被覆盖了。

最佳实践: free 之后立即将指针置为 NULL。这样即使误用了,也会立即崩溃,而不是产生诡异的随机 bug。

char *p = (char*)malloc(100);
// ... 使用 p ...
free(p);
p = NULL;  // 这一步救了我无数次

另外,如果多个指针指向同一块内存,你 free 掉其中一个后,其他指针全部变成悬空指针。这种情况在复杂的数据结构里很常见,比如链表、树。你需要有清晰的“所有权”设计。

7.5 free(NULL) 是安全的吗?

答案是:绝对安全

C 标准明确规定,free(NULL) 什么都不做,直接返回。这是标准库的设计,不是某个编译器的扩展。

所以你可以放心地写:

char *p = NULL;
// ... 可能分配,也可能不分配 ...
free(p);  // 安全,即使 p 是 NULL

这个特性在实际项目中非常有用。比如在错误处理路径上,你不需要每次都检查指针是否为 NULL:

void cleanup(char *a, char *b, int *c) {
    free(a);  // 即使 a 是 NULL 也没问题
    free(b);
    free(c);
}

我个人习惯在函数开头把所有资源指针初始化为 NULL,然后在 cleanup 标签处统一 free。这样代码干净,也不容易漏掉。

7.6 避坑指南:我的血泪史

最后,分享几个我踩过的坑,希望能帮你省下几天调试时间。

  • 不要 free 栈上的地址free 只能释放 malloccallocrealloc 返回的堆内存。如果你 free 一个局部变量的地址,程序会直接崩溃。
  • 不要 free 同一个指针两次:前面讲过了,double free 是重灾区。如果你不确定是否已经 free 过,可以在 free 之后把指针置 NULL,然后加个判断:if (p) { free(p); p = NULL; }
  • 注意内存对齐:有些平台要求 free 的指针必须与 malloc 返回的指针完全一致。如果你对指针做了偏移(比如 p++),然后 free 偏移后的地址,行为是未定义的。
  • 多线程环境要加锁:堆管理器通常不是线程安全的。如果你在多线程里同时 malloc/free,必须加锁保护,或者使用线程安全的内存分配器。

嗯,关于 free 的内容就讲到这里。记住一句话:free 不是结束,而是责任的转移。把内存还给系统后,你就不再拥有它的使用权了。尊重这个规则,你的程序会稳定很多。


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