在这段插曲中,我们将讨论UNIX系统中的内存分配接口。所提供的接口非常简单,因此这一章简短而切中要点。我们要解决的主要问题是:

关键的问题:如何分配和管理内存 在UNIX/C程序中,理解如何分配和管理内存对于构建健壮可靠的软件至关重要。常用的接口是什么?应该避免哪些错误?

14.1 内存的类型 Types of Memory

在运行C程序时,会分配两种类型的内存。第一个称为栈(stack)内存,它的分配和释放由编译器为您(程序员)隐式管理;由于这个原因,它有时被称为自动内存(automatic memory)。
在C语言中在栈上声明内存很容易。例如,假设你需要在函数func()中为一个名为x的整数留出一些空间。要声明这样一块内存,你可以这样做:
image.png
编译器完成剩下的工作,确保在调用进入func()时在栈上留有空间。当你从函数返回时,编译器会为你释放内存;因此,如果您希望一些信息在调用之后仍然存在,最好不要将这些信息留在栈上。
正是这种对长期内存的需求使我们获得了第二种内存,称为堆(heap)内存,其中所有的分配和释放都由程序员显式处理。责任重大,毋庸置疑!当然是许多错误的原因。但是如果你细心和注意,你会正确地使用这些接口,不会太麻烦。这是一个如何在堆上分配整数的示例:
image.png
关于这个小代码段,有几点需要注意。首先,你可能会注意到栈和堆分配都发生在这一行:首先,编译器看到你声明的指针(int x)时,知道要为指向整数的指针腾出空间;随后,*当程序调用malloc()时,它为堆上的一个整数请求空间;例程返回这样一个整数的地址(失败时返回NULL),然后存储在栈上供程序使用。
由于它的显式性质和更多样化的使用,堆内存给用户和系统带来了更多的挑战。因此,它是我们接下来讨论的重点。

14.2 malloc()调用 The malloc() Call

malloc()调用非常简单:向它传递一个请求堆上一些空间的大小,它要么成功并返回一个指向新分配空间的指针,要么失败并返回NULL。
手册页面显示了使用malloc需要做什么;在命令行中输入man malloc,你会看到:
image.png
从这些信息中,您可以看到,您所需要做的就是包含头文件stdlib.h来使用malloc。实际上,您甚至不需要这样做,因为C库(所有C程序默认都链接到它)中有malloc()的代码;添加头文件只是让编译器检查你是否正确地调用了malloc()(例如,传递正确数量的参数给它,类型正确)。
malloc()接受的单个参数是size_t类型的,它简单地描述了需要多少字节。然而,大多数程序员不会在这里直接输入数字(比如10);事实上,这样做会被认为是不恰当的。相反,使用了各种例程和宏。例如,要为双精度浮点值分配空间,只需这样做:
double *d = (double *) malloc(sizeof(double));
哇,好多double! malloc()的调用使用sizeof()操作符来请求适当数量的空间;在C语言中,这通常被认为是一个编译时操作符,这意味着实际的大小在编译时就知道了,因此一个数字(在本例中是8,对于double来说)被替换为malloc()的参数。由于这个原因,sizeof()被正确地认为是一个操作符而不是函数调用(函数调用将在运行时发生)。
您也可以将变量的名称(而不仅仅是类型)传递给sizeof(),但在某些情况下,您可能不会得到所需的结果,所以要小心。例如,让我们看看下面的代码片段:
int *x = malloc(10 * sizeof(int));
printf("%d\n", sizeof(x));
在第一行中,我们为一个10个整数的数组声明了空间,这很好。但是,当在下一行中使用sizeof()时,它返回一个小值,例如4(32位机器上)或8(64位机器上)。原因是,在这种情况下,sizeof()认为我们只是询问一个指向整型数的指针有多大,而不是我们动态分配了多少内存。然而,有时sizeof()会像你期望的那样工作:
int x[10];
printf("%d\n", sizeof(x));
在本例中,有足够的静态信息让编译器知道已经分配了40个字节。
另一个需要注意的地方是字符串。在为字符串声明空间时,使用下面的习惯用法:malloc(strlen(s) + 1),它使用函数 strlen() 获取字符串的长度,并为其加 1,以便为字符串结尾字符腾出空间。在这里使用 sizeof() 可能会导致麻烦。
您可能还注意到malloc()返回一个指向void类型的指针。这样做只是在C中传递回一个地址并让程序员决定如何处理它的方法。程序员通过使用所谓的强制转换进一步提供帮助;在上面的例子中,程序员将malloc()的返回类型强制转换为指向double类型的指针。强制转换实际上并没有完成任何事情,只是告诉编译器和其他可能读取您代码的程序员:“是的,我知道我在做什么。”通过强制转换malloc()的结果,程序员只是提供了一些保证;对于正确性来说不需要强制类型转换。

14.3 free()调用 The free() Call

事实证明,分配内存是最简单的部分;知道何时、如何、甚至是否要释放内存是困难的部分。要释放不再使用的堆内存,程序员只需调用free():
image.png
例程接受一个参数,一个由malloc()返回的指针。因此,您可能会注意到,已分配区域的大小不是由用户传入的,必须由内存分配库本身跟踪

14.4 常见错误 Common Errors

在使用malloc()和free()时会出现许多常见错误。以下是我们在教授本科操作系统课程时反复看到的一些例子。所有这些示例都编译并运行,编译器几乎看不到任何东西;虽然编译 C 程序对于构建正确的 C 程序是必要的,但这还远远不够,正如您将学到的(通常很难)。
正确的内存管理一直是个问题,事实上,许多较新的语言都支持自动内存管理(automatic memory management)。在这类语言中,当您调用类似于malloc()的函数来分配内存(通常是new或类似于分配新对象的函数)时,您永远不需要调用函数来释放空间;相反,垃圾收集器(garbage collector)会运行并找出您不再引用的内存并为您释放它。

忘记分配内存 Forgetting To Allocate Memory

许多例程希望在调用它们之前分配内存。例如,例程strcpy(dst, src)将字符串从源指针复制到目标指针。然而,如果您不小心,您可能会这样做:
image.png

Tip:成功编译并允许≠正确 仅仅因为一个程序成功编译(!),甚至正确地运行了一次或多次,并不意味着这个程序是正确的。很多事情可能会让你觉得它是可行的,但后来有些事情发生了变化,它停止了。一个常见的学生反应是说(或大喊)“但它以前是工作的!”然后责怪编译器、操作系统、硬件,甚至(我们敢这么说)教授。但问题通常就在你认为的地方,在你的代码中。在责怪其他组件之前,开始工作并调试它。

当您运行此代码时,它可能会导致分段错误(segmentation fault),这是一个花哨的术语,表示您对内存的操作有问题,您这个愚蠢的程序员,我很生气。在这种情况下,正确的代码可能看起来像这样:

虽然这听起来很神秘,但您很快就会了解为什么这种非法的内存访问被称为分段错误;如果这都不是激励你继续读下去的动力,那还有什么?

image.png
或者,您可以使用strdup(),使您的工作更加轻松。更多信息请阅读strdup手册页。

没有分配足够的内存 Not Allocating Enough Memory

一个相关的错误是没有分配足够的内存,有时称为缓冲区溢出(buffer overflow)。在上面的示例中,一个常见的错误是为目标缓冲区留出几乎足够的空间。
image.png
奇怪的是,根据malloc的实现方式和许多其他细节,这个程序通常会正常运行。在某些情况下,当字符串复制执行时,它写入的字节超出了分配的空间的末端,但在某些情况下,这是无害的,可能会覆盖不再使用的变量。在某些情况下,这些溢出可能非常有害,实际上是系统中许多安全漏洞的根源[W06]。在其他情况下,malloc库无论如何都会分配一点额外的空间,因此您的程序实际上不会乱写其他变量的值,并且工作得很好。在还有的其他情况下,程序确实会出错并崩溃。因此,我们得到了另一个宝贵的教训:即使它正确运行了一次,并不意味着它是正确的

忘记释放内存 Forgetting To Free Memory

另一个常见的错误称为内存泄漏(memory leak),它发生在忘记释放内存时。在长时间运行的应用程序或系统(例如操作系统本身)中,这是一个巨大的问题,因为缓慢的内存泄漏最终会导致内存耗尽,此时就需要重新启动。因此,一般来说,当您使用一块内存时,应该确保释放它。注意,使用垃圾收集语言(garbage-collected language)在这里并没有帮助:如果您仍然有对某些内存块的引用,那么没有垃圾收集器会释放它,因此即使在更现代的语言中,内存泄漏仍然是一个问题
在某些情况下,似乎不调用free()是合理的。例如,你的程序是短命的,很快就会退出;在这种情况下,当进程死亡时,操作系统将清理它分配的所有页面,因此本身不会发生内存泄漏。虽然这确实有效,但这可能是一个不好的习惯,所以在选择这种策略时要谨慎。从长远来看,作为一名程序员,你的目标之一就是养成良好的习惯;其中一个习惯是理解如何管理内存,以及(在C语言中)释放已分配的块。即使您可以不这样做,养成释放显式分配的每个字节的习惯可能也是好的。

Aside:为什么进程退出后不会泄漏内存 当您编写一个短命的程序时,您可以使用malloc()分配一些空间。程序运行并立马完成:是否需要在退出之前多次调用free() ?虽然不这样做似乎是不对的,但在任何真正意义上,内存都不会“丢失”。原因很简单:系统中实际上有两个级别的内存管理第一级内存管理是由操作系统执行的,它在进程运行时将内存分发给进程,在进程退出(或死亡)时收回内存。第二级管理在每个进程中,例如在堆中调用malloc()和free()时。即使您没有调用free()(从而泄漏了堆中的内存),当程序运行完成时,操作系统也将回收进程的所有内存(包括代码页、栈页和这里相关的堆页)。无论您的地址空间中的堆处于什么状态,当进程死亡时,操作系统将收回所有这些页面,从而确保尽管您没有释放内存,但不会丢失内存。 因此,对于寿命较短的程序,内存泄漏通常不会导致任何操作问题(尽管它可能被认为是糟糕的形式)。当您编写一个长时间运行的服务器(例如web服务器或数据库管理系统,它永远不会退出)时,内存泄漏是一个更大的问题,并且最终会在应用程序耗尽内存时导致崩溃。当然,内存泄漏在一个特定的程序中是一个更大的问题:操作系统本身。再一次向我们展示:那些编写内核代码的人有最艰难的工作……

在你用完之前释放内存 Freeing Memory Before You Are Done With It

有时,程序在使用完之前会释放内存;这样的错误称为悬空指针(dangling pointer),正如您所猜测的那样,它也是一件坏事。随后的使用可能会导致程序崩溃,或者覆盖有效内存(例如,您调用了free(),但随后又调用malloc()来分配其他东西,然后这些东西会回收错误释放的内存)。

重复释放内存 Freeing Memory Repeatedly

程序有时还会多次释放内存;这就是所谓的双重释放(double free)。这样做的结果是未定义的。可以想象,内存分配库可能会感到困惑,并做各种奇怪的事情;崩溃是一个常见的结果。

错误调用free() Calling free() Incorrectly

我们讨论的最后一个问题是错误地调用free()。毕竟,free()只希望您将之前从malloc()接收到的指针之一传递给它。当传入其他值时,可能(也确实)会发生不好的事情。因此,这种无效的free(invalid frees)是危险的,当然也应该避免。

总结 Summary

正如你所看到的,有很多方法可以滥用内存。由于内存经常出错,因此开发了一整套工具来帮助您在代码中发现这类问题。检查purify [HJ92]和valgrind [SN05];这两种方法都能很好地帮助你定位与内存有关的问题的根源。一旦你习惯了使用这些强大的工具,你会想知道没有它们你是如何生存的。

14.5 底层操作系统支持 Underlying OS Support

您可能已经注意到,在讨论malloc()和free()时,我们并没有讨论系统调用。原因很简单:它们不是系统调用,而是库调用。因此,malloc库管理虚拟地址空间中的空间,但它本身是建立在一些系统调用之上的,这些系统调用会调用操作系统来请求更多的内存或释放一些内存回给系统
一个这样的系统调用叫做brk,它用于改变程序的break的位置:堆的末端的位置。它接受一个参数(新中断的地址),因此根据新中断比当前中断大还是小来增加或减少堆的大小。附加的调用sbrk被传递一个增量,但在其他方面服务于类似的目的。
请注意,永远不要直接调用brk或sbrk。它们由内存分配库使用;如果你试图使用它们,你可能会让一些事情变得(可怕的)错误。坚持使用malloc()和free()。
最后,您还可以通过mmap()调用从操作系统获取内存。通过传入正确的参数,mmap()可以在程序中创建一个匿名(anonymous)内存区域,该区域不与任何特定的文件相关联,而是与交换空间(swap space)相关联,稍后我们将在虚拟内存中详细讨论这一点。这些内存也可以像堆一样处理和管理。详细信息请阅读mmap()的手册页。

14.6 其他调用 Other Calls

内存分配库还支持其他一些调用。例如,calloc()分配内存,并在返回前将其置零;这可以防止一些错误,即您认为内存已清零而忘记自己初始化它(请参阅上面有关未初始化读取的段落)。例程realloc()也可能很有用,当您为某个对象(比如数组)分配了空间,然后需要向其添加一些内容时:realloc()创建一个新的更大的内存区域,将旧区域复制到其中,并返回指向新区域的指针。

14.7 总结 Summary

我们已经介绍了一些处理内存分配的APIs。和往常一样,我们只讲了基本知识;更多细节可从别处获得。阅读C书[KR88]和史蒂文斯SR05获得更多信息。有关如何自动检测和纠正这些问题的现代论文,请参阅Novark等人[N+07];本文还对常见问题进行了总结,并就如何发现和解决这些问题提出了一些思路。

References

[HJ92] “Purify: Fast Detection of Memory Leaks and Access Errors” by R. Hastings, B. Joyce.
USENIX Winter ’92. The paper behind the cool Purify tool, now a commercial product.
[KR88] “The C Programming Language” by Brian Kernighan, Dennis Ritchie. Prentice-Hall
1988. The C book, by the developers of C. Read it once, do some programming, then read it again, and
then keep it near your desk or wherever you program.
[N+07] “Exterminator: Automatically Correcting Memory Errors with High Probability” by
G. Novark, E. D. Berger, B. G. Zorn. PLDI 2007, San Diego, California. A cool paper on finding
and correcting memory errors automatically, and a great overview of many common errors in C and
C++ programs. An extended version of this paper is available CACM (Volume 51, Issue 12, December
2008).
[SN05] “Using Valgrind to Detect Undefined Value Errors with Bit-precision” by J. Seward, N.
Nethercote. USENIX ’05. How to use valgrind to find certain types of errors.
[SR05] “Advanced Programming in the UNIX Environment” by W. Richard Stevens, Stephen
A. Rago. Addison-Wesley, 2005. We’ve said it before, we’ll say it again: read this book many times
and use it as a reference whenever you are in doubt. The authors are always surprised at how each time
they read something in this book, they learn something new, even after many years of C programming.
[W06] “Survey on Buffer Overflow Attacks and Countermeasures” by T. Werthman. Avail-
able: www.nds.rub.de/lehre/seminar/SS06/Werthmann BufferOverflow.pdf. A nice survey of
buffer overflows and some of the security problems they cause. Refers to many of the famous exploits.

Homework (Code)

在这个家庭作业中,你会对内存分配有一些熟悉。首先,您将编写一些有bug的程序(很有趣!)然后,您将使用一些工具来帮助您找到所插入的错误。然后,你会意识到这些工具有多棒,并在未来使用它们,从而使自己更快乐和更有效率。这些工具是调试器(例如,gdb)和一个名为valgrind的内存bug检测器[SN05]。

Questions

  1. 首先,编写一个名为NULL.c的简单程序,创建一个指向整型数的指针,将它设置为NULL,然后尝试解除对它的引用。将其编译为一个名为null的可执行文件。当你运行这个程序时会发生什么?
  2. 接下来,编译这个包含符号信息的程序(使用-g标志)。这样,我们就可以将更多的信息放入可执行文件中,使调试器能够访问更多关于变量名之类的有用信息。在调试器下运行程序,输入gdb null,然后,一旦gdb运行,输入run。gdb向您展示了什么?
  3. 最后,在这个程序上使用valgrind工具。我们将使用作为valgrind一部分的memcheck工具来分析所发生的情况。输入以下命令运行此命令:valgrind --leak-check=yes null。运行这个会发生什么?你能解释工具的输出吗?
  4. 编写一个简单的程序,使用malloc()分配内存,但在退出之前忘记释放它。当这个程序运行时会发生什么?您可以使用gdb来查找它的任何问题吗?valgrind怎么样(同样带有—leak-check=yes标志)?
  5. 编写一个程序,使用malloc创建一个大小为100的整数数组,称为data;然后将数据[100]设为零。当你运行这个程序时会发生什么?当你使用valgrind运行这个程序时会发生什么?程序正确吗?
  6. 创建一个程序,分配一个整数数组(如上所示),释放它们,然后尝试打印数组中一个元素的值。程序能运行吗?当你在上面使用valgrind时会发生什么?
  7. 现在将一个有趣的值传递给free(例如,在上面分配的数组中间的一个指针)。会发生什么呢?您是否需要工具来查找这类问题?
  8. 尝试一些内存分配的其他接口。例如,创建一个简单的vector-like的数据结构和使用realloc()来管理vector的相关例程。使用数组来存储vector元素;当用户向vector添加元素时,使用realloc()为其分配更多空间。这种vector的表现如何?它与链表相比如何?使用valgrind来帮助你找到漏洞。
  9. 花更多的时间阅读使用gdb和valgrind。了解你的工具是至关重要的;花时间学习如何成为UNIX和C环境中的专家调试器。