为什么调用两次时Free会崩溃?

25
在C和C++中,当多次调用free(my_pointer)时会崩溃。
为什么呢?因为每个malloc都有一个记录其大小的记账数据。当第一次调用free时,它会识别该内存块的大小,因此我们在调用free时不需要再传递大小信息。
既然free已经知道所有信息,为什么不在第二次调用时检查并不做任何处理呢?
要么我没有理解malloc/free的行为,要么free实现得不够安全。

2
为什么要点踩?不确定是不是重复问题,但这确实是一个非常好的问题。 - Ben Zotto
4
C语言强调速度和程序员对控制的重视,而不是保证安全。人们期望你养成良好习惯以确保安全,而不是随意操作并希望被发现错误。即使你想要进行所需的记账操作,也会导致速度变慢,我不想为了让其他开发者(我们这些大括号类型常常认为需要安全网的人)感到安全而付出速度代价。 - Kate Gregory
1
@el.pescado,对于NULL有一个特殊的例外,因为语言是这样设计的。free(NULL)什么也不做,所以你可以随意使用它。 - Kate Gregory
1
@el.pescado 我当时不在那里(我还没那么老),但我猜想"nothing"是一种非常快速的操作,既然free(NULL)没有意义,那么什么都不做就是一个合理的处理选择。 - Kate Gregory
1
@el.pescado @KateGregory:C99中free()的基本原理指出:将空指针指定为此函数的有效参数,以减少特殊情况编码的需要。请注意,realloc(p, 0)(以及malloc(0)calloc的任一参数为0)可能会返回空指针。 - ninjalj
显示剩余6条评论
5个回答

34
您不能在未分配的内存上调用free,标准已经非常明确地说明了这一点(略有改动,重点是我的强调):

free函数使其参数指向的空间被释放,即可供进一步分配使用。如果参数是空指针,则不会发生任何操作。否则,如果参数与之前由内存管理函数返回的指针不匹配,或者如果该空间已被free或realloc调用释放,则行为是未定义的。

例如,如果您双倍释放的地址已在新块中重新分配,而分配它的代码刚好将某些东西存储在那里,看起来像是真正的malloc块头,会发生什么情况呢?像这样:
 +- New pointer    +- Old pointer
 v                 v
+------------------------------------+
|                  <Dodgy bit>       |
+------------------------------------+

混乱,就是这样。
内存分配函数就像电锯一样是一种工具,只要使用正确,你就不会遇到问题。但如果滥用它们,后果就是你自己的错,可能会损坏内存或更糟糕的情况,甚至可能割掉你的手臂 :-)
关于评论:
“...它可以向最终用户优雅地传达有关在相同位置双倍释放的信息。”
除了记录所有malloc和free调用以确保您不会重复释放块之外,我看不出这是可行的。这将需要巨大的开销,仍然无法解决所有问题。
如果发生以下情况会怎样:
1.线程A在地址42处分配并释放内存。 2.线程B在地址42处分配内存并开始使用它。 3.线程A第二次释放该内存。 4.线程C在地址42处分配内存并开始使用它。
那么,您现在有线程B和C都认为它们拥有该内存(这些不必是执行线程,我在这里使用线程一词只是一个运行的代码片段 - 它可以全部在一个执行线程中被顺序调用)。
不,我认为当前的malloc和free只要正确使用就很好。请考虑实现自己的版本,我认为这没有任何问题,但我怀疑您会遇到一些非常棘手的性能问题。
如果您确实想要在free周围实现自己的包装器,您可以使其更安全(以稍微降低性能为代价),具体请参见下面的myFreeXxx调用:
#include <stdio.h>
#include <stdlib.h>

void myFreeVoid (void **p) { free (*p); *p = NULL; }
void myFreeInt  (int  **p) { free (*p); *p = NULL; }
void myFreeChar (char **p) { free (*p); *p = NULL; }

int main (void) {
    char *x = malloc (1000);
    printf ("Before: %p\n", x);
    myFreeChar (&x);
    printf ("After:  %p\n", x);
    return 0;
}

代码的要点是你可以使用指向指针的指针来调用 myFreeXxx 函数,它会同时:
  • 释放内存;并且
  • 将指针设置为 NULL。
后面那部分的意思是,如果你尝试再次释放该指针,它将不起作用(因为标准明确规定了释放 NULL)。
不能保护你免受所有情况的影响,例如,如果你在其他地方复制了该指针,释放原始指针,然后释放副本:
char *newptr = oldptr;
myFreeChar (&oldptr);     // frees and sets to NULL.
myFreeChar (&newptr);     // double-free because it wasn't set to NULL.

如果您使用的是C11,现在有一种比显式调用不同函数更好的方式,因为C具有编译时函数重载。您可以使用通用选择来调用正确的函数,同时仍然允许类型安全:

#include <stdio.h>
#include <stdlib.h>

void myFreeVoid (void **p) { free (*p); *p = NULL; }
void myFreeInt  (int  **p) { free (*p); *p = NULL; }
void myFreeChar (char **p) { free (*p); *p = NULL; }
#define myFree(x) _Generic((x), \
    int** :  myFreeInt,  \
    char**:  myFreeChar, \
    default: myFreeVoid  )(x)

int main (void) {
    char *x = malloc (1000);
    printf ("Before: %p\n", x);
    myFree (&x);
    printf ("After:  %p\n", x);
    return 0;
}

因此,您只需调用myFree,它将根据类型选择正确的函数。


1
我怀疑大多数实现中(我见过的肯定不是)没有足够的簿记来保证该操作。如果您引入足够的簿记,它几乎肯定会对性能产生影响。请参见更新,但基本概要是当前方法在速度和安全性之间取得了平衡。 - paxdiablo
1
除了可能的性能影响之外,一旦堆数据结构出现可疑情况,C运行时应该在让某些恶意软件干扰代码执行路径之前中止。 - ninjalj
3
如果我们希望free检查双重释放,那么 free的实现将需要跟踪每个已释放的地址,并每次检查所有这些地址。更不用说要合理处理曾经被释放然后重新分配给malloc的地址了。你基本上是在建议标准 C 库像valgrind一样运作。有时间通过 valgrind 运行一个程序,看看它有多快。这就是为什么这种簿记会计不会进行,除非你主动要求。 - Tyler McHenry
@paxdiablo。你说:“例如,如果您正在双重释放的地址已经在新块的中间被重新分配,并且分配它的代码刚好存储了一些看起来像真正的malloc块头的东西,会发生什么?”我想知道为什么malloc调用会从中间选择一个空闲块。malloc总是从头部选择空闲块,不是吗? - zhuang
1
@Tingya:因为C语言是按值传递的,所以你不能改变传入的指针并将其反映给调用者,除非再进行另一层间接引用。你可以自己做这个,但可能会引入速度惩罚,尽管很小。C语言的主要假设之一是:相信程序员。为了展示如何完成,我已经将其添加到答案中。 - paxdiablo
显示剩余2条评论

10

你可能误解了它的行为。如果它立即崩溃,那么它的实现方式是安全的。我可以证明,很久以前 free() 的这种行为并不常见。当时典型的 CRT 实现根本没有进行任何检查。它会快速而猛烈地破坏堆的内部结构,混乱分配链。

程序在堆损坏后很久才会出现问题或崩溃,而没有任何诊断信息,导致它的错误行为难以理解。造成崩溃的代码实际上并不负责崩溃,这是一种难以排查的 Heisenbug。

现代CRT或操作系统堆的实现通常不再存在这种情况。这种未定义行为非常容易被恶意软件利用。同时,它也使你的生活变得更加轻松愉快,你能够快速地找到代码中的 bug。在过去的几年里,它让我远离麻烦,我已经很长时间没有调试无法跟踪的堆破坏了,这真是件好事。


4
很好的问题。正如您所指出的,malloc和free通常会在分配之前几个字节中进行某种形式的记帐。但可以这样想:
  1. 分配一些内存--添加记帐数据。
  2. 释放它--将内存返回到池中。
  3. 您或其他人分配了更多的内存,可能包括或与旧分配对齐。
  4. 您再次释放旧指针。
此时堆(用于管理malloc和free的代码)已经失去了跟踪和/或覆盖记账数据的能力,因为内存已经返回到堆!
因此会出现程序崩溃的情况。提供这一点的唯一方法是在某个地方记住每次分配的所有内容,但这样会导致数据库不断增加。所以他们不这样做。相反,只需记住不要重复释放相同的内存即可 :)。

堆(malloc和free管理代码)此时已经丢失和/或覆盖了簿记数据,因为内存已经返回到堆中!正如您所说的一样,一旦信息丢失,就可以得出结论,它已经被释放(free())或者是错误的地址。应该优雅地向程序员传达这个信息,但不应该尝试从那个位置释放,否则会导致应用程序崩溃,因为它没有破坏堆中的任何内容,只是发送了一个错误的指针,而它已经通过簿记细节进行了验证,并未找到任何参考。 - vikas jain
2
啊!但是如果返回的内存已经被其他人重复使用了,那么应该在记账区域中的内容已经被其他人的数据替换了,然后free读取了那些数据并假设它是堆跟踪的引用...而实际上,它根本不是记账数据。因此,free走进了荒野并崩溃了。 - Ben Zotto
1
"So free goes off into the weeds and crashes". 或者更糟的是,攻击者精心制作了账目信息,以便使free()函数最终覆盖一些重要指针,例如SEH或下一个要调用的动态函数。 - ninjalj

3
为什么第二次free()没有找到任何分配大小时不进行第二次检查? 在所有正确的情况下,free()函数本身中的额外检查会使您的程序变慢。 您不应该执行双重释放。 管理内存是作为程序员的责任; 不这样做会是编程错误。 这是C语言哲学的一部分:它给你所需的所有力量,但作为一个结果,容易自己犯错。
许多C运行时将在其调试版本中进行一些检查,因此,如果您做错了什么,您将收到合理的通知。

1

你说:

不明白为什么。每个malloc()都有记账,包括大小。

不一定。我来简单解释一下dlmalloc(用于glibc、uClibc等)。

dlmalloc跟踪空闲空间块。不能有两个相邻的空闲块,它们会立即合并。分配的块根本没有被跟踪!分配的块有一些备用空间用于记账信息(此块的大小、前一个块的大小和一些标志)。当分配的块被free()时,dlmalloc将其插入双向链表中。

当然,所有这些都可以在this dlmalloc article中更好地解释。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接