在UNIX/C程序中,理解如何分配和管理内存是构建健壮和可靠软件的重要基础。通常使用哪些接口?哪些错误需要避免?
在运行一个C程序的时候,会分配两种类型的内存。第一种称为栈内存,它的申请和释放操作是编译器来隐式管理的,所以有时也称为自动(automatic)内存。
C中申请栈内存很容易。比如,假设需要在func()函数中为一个整形变量x
申请空间。为了声明这样的一块内存,只需要这样做:
void func() {
int x; // declares an integer on the stack
...
}
编译器完成剩下的事情,确保在你进入 func() 函数的时候,在栈上开辟空间。当你从该函数退出时,编译器释放内存。因此,如果你希望某些信息存在于函数调用之外,建议不要将它们放在栈上。
就是这种对长期内存的需求,所以我们才需要第二种类型的内存,即所谓的堆(heap)内存,其中所有的申请和释放操作都由程序员显式地完成。毫无疑问,这是一项非常艰巨的任务!这确实导致了很多缺陷。但如果小心并加以注意,就会正确地使用这些接口,没有太多的麻烦。下面的例子展示了如何在堆上分配一个整数,得到指向它的指针:
void func() {
int *x = (int *) malloc(sizeof(int));
...
}
关于这一小段代码有两点说明。首先,你可能会注意到栈和堆的分配都发生在这一行:首先编译器看到指针的声明(int * x)时,知道为一个整型指针分配空间,随后,当程序调用malloc()时,它会在堆上请求整数的空间,函数返回这样一个整数的地址(成功时,失败时则返回NULL),然后将其存储在栈中以供程序使用。
因为它的显式特性,以及它更富于变化的用法,堆内存对用户和系统提出了更大的挑战。所以这也是我们接下来讨论的重点。
malloc 函数非常简单:传入要申请的堆空间的大小,它成功就返回一个指向新申请空间的指针,失败就返回NULL[2]
。
man手册展示了使用malloc需要怎么做,在命令行输入man malloc,你会看到:
#include
...
void *malloc(size_t size);
从这段信息可以看到,只需要包含头文件 stdlib.h 就可以使用malloc 了。但实际上,甚至都不需这样做,因为C库是C程序默认链接的,其中就有mallock()的代码,加上这个头文件只是让编译器检查你是否正确调用了malloc()(即传入参数的数目正确且类型正确)。
malloc 只需要一个size_t类型参数,该参数表示你需要多少个字节。然而,大多数程序员并不会直接传入数字(比如10)。实际上,这样做会被认为是不太好的形式。替代方案是使用各种函数和宏。例如,为了给双精度浮点数分配空间,只要这样:
double *d = (double *) malloc(sizeof(double));
提示:如果困惑,动手试试
如果你不确定要用的一些函数或者操作符的行为,唯一的办法就是试一下,确保它的行为符合你的期望。虽然读手册或其他文档是有用的,但在实际中如何使用更为重要。实际上,我们正是通过这样做,来确保关于sizeof()我们所说的都是真的!
啊,好多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中传回地址的方式,让程序员决定如何处理它。程序员将进一步使用所谓的强制类型转换(cast),在我们上面的示例中,程序员将返回类型的malloc()强制转换为指向double的指针。强制类型转换实际上没干什么事,只是告诉编译器和其他可能正在读你的代码的程序员:“是的,我知道我在做什么。”通过强制转换malloc()的结果,程序员只是在给人一些信心,强制转换不是程序正确所必须的。
事实证明,分配内存是等式的简单部分。知道何时、如何以及是否释放内存是困难的部分。要释放不再使用的堆内存,程序员只需调用free():
int *x = malloc(10 * sizeof(int));
...
free(x);
该函数接受一个参数,即一个由malloc()返回的指针。
因此,你可能会注意到,分配区域的大小不会被用户传入,必须由内存分配库本身记录追踪。
在使用malloc()和free()时会出现一些常见的错误。以下是我们在教授本科操作系统课程时反复看到的情形。所有这些例子都可以通过编译器的编译并运行。对于构建一个正确的C程序来说,通过编译是必要的,但这远远不够,你会懂的(通常在吃了很多苦头之后)。
实际上,正确的内存管理就是这样一个问题,许多新语言都支持自动内存管理(automatic memory management)。在这样的语言中,当你调用类似malloc()的机制来分配内存时(通常用new或类似的东西来分配一个新对象),你永远不需要调用某些东西来释放空间。实际上,垃圾收集器(garbage collector)会运行,找出你不再引用的内存,替你释放它。
许多例程在调用之前,都希望你为它们分配内存。例如,例程strcpy(dst, src)将源字符串中的字符串复制到目标指针。但是,如果不小心,你可能会这样做:
char *src = "hello";
char *dst; // oops! unallocated
strcpy(dst, src); // segfault and die
运行这段代码时,可能会导致段错误(segmentation fault)[3]
,这是一个很奇怪的术语,表示“你对内存犯了一个错误。你这个愚蠢的程序员。我很生气。”
提示:它编译过了或它运行了!=它对了
仅仅因为程序编译过了甚至正确运行了一次或多次,并不意味着程序是正确的。许多事件可能会让你相信它能工作,但是之后有些事情会发生变化,它停止了。学生常见的反应是说(或者叫喊)“但它以前是好的!”,然后责怪编译器、操作系统、硬件,甚至是(我们敢说)教授。但是,问题通常就像你认为的那样,在你的代码中。在指责别人之前,先撸起袖子调试一下。
在这个例子中,正确的代码可能像这样:
char *src = "hello";
char *dst = (char *) malloc(strlen(src) + 1);
strcpy(dst, src); // work properly
或者你可以用strdup(),让生活更加轻松。阅读strdup的man手册页,了解更多信息。
另一个相关的错误是没有分配足够的内存,有时称为缓冲区溢出(buffer overflow)。在上面的例子中,一个常见的错误是为目标缓冲区留出“几乎”足够的空间。
char *src = "hello";
char *dst = (char *) malloc(strlen(src)); // too small!
strcpy(dst, src); // work properly
奇怪的是,这个程序通常看起来会正确运行,这取决于如何实现malloc和许多其他细节。在某些情况下,当字符串拷贝执行时,它会在超过分配空间的末尾处写入一个字节,但在某些情况下,这是无害的,可能会覆盖不再使用的变量。在某些情况下,这些溢出可能具有令人难以置信的危害,实际上是系统中许多安全漏洞的来源[W06]。在其他情况下,malloc库总是分配一些额外的空间,因此你的程序实际上不会在其他某个变量的值上涂写,并且工作得很好。还有一些情况下,该程序确实会发生故障和崩溃。因此,我们学到了另一个宝贵的教训:即使它正确运行过一次,也不意味着它是正确的。
在这个错误中,你正确地调用malloc(),但忘记在新分配的数据类型中填写一些值。不要这样做!如果你忘记了,你的程序最终会遇到未初始化的读取(uninitialized read),它从堆中读取了一些未知值的数据。谁知道那里可能会有什么?如果走运,读到的值使程序仍然有效(例如,零)。如果不走运,会读到一些随机和有害的东西。
另一个常见错误称为内存泄露(memory leak),如果忘记释放内存,就会发生。在长时间运行的应用程序或系统(如操作系统本身)中,这是一个巨大的问题,因为缓慢泄露的内存会导致内存不足,此时需要重新启动。因此,一般来说,当你用完一段内存时,应该确保释放它。请注意,使用垃圾收集语言在这里没有什么帮助:如果你仍然拥有对某块内存的引用,那么垃圾收集器就不会释放它,因此即使在较现代的语言中,内存泄露仍然是一个问题。
在某些情况下,不调用free()似乎是合理的。例如,你的程序运行时间很短,很快就会退出。在这种情况下,当进程死亡时,操作系统将清理其分配的所有页面,因此不会发生内存泄露。虽然这肯定“有效”(请参阅后面的补充),但这可能是一个坏习惯,所以请谨慎选择这样的策略。长远来看,作为程序员的目标之一是养成良好的习惯。其中一个习惯是理解如何管理内存,并在C这样的语言中,释放分配的内存块。即使你不这样做也可以逃脱惩罚,建议还是养成习惯,释放显式分配的每个字节。
有时候程序会在用完之前释放内存,这种错误称为悬挂指针(dangling pointer),正如你猜测的那样,这也是一件坏事。随后的使用可能会导致程序崩溃或覆盖有效的内存(例如,你调用了free(),但随后再次调用malloc()来分配其他内容,这重新利用了错误释放的内存)。
程序有时还会不止一次地释放内存,这被称为重复释放(double free)。这样做的结果是未定义的。正如你所能想象的那样,内存分配库可能会感到困惑,并且会做各种奇怪的事情,崩溃是常见的结果。
我们讨论的最后一个问题是free()的调用错误。毕竟,free()期望你只传入之前从malloc()得到的一个指针。如果传入一些其他的值,坏事就可能发生(并且会发生)。因此,这种无效的释放(invalid free)是危险的,当然也应该避免。
本文摘自刚刚上架不久的《操作系统导论》
作者:[美] 雷姆兹·H.阿帕希杜塞尔( Remzi H. Arpaci-Dusseau), [美]安德莉亚·C.阿帕希杜塞尔(Andrea C. Arpaci-Dusseau)
译者:王海鹏
本书围绕虚拟化、并发和持久性这三个主要概念展开,介绍了所有现代系统的主要组件(包括调度、虚拟内存管理、磁盘和I/O子系统、文件系统)。全书共50章,分为3个部分,分别讲述虚拟化、并发和持久性的相关内容。作者以对话形式引入所介绍的主题概念,行文诙谐幽默却又鞭辟入里,力求帮助读者理解操作系统中虚拟化、并发和持久性的原理。
本书内容全面,并给出了真实可运行的代码(而非伪代码),还提供了相应的练习,很适合高等院校相关专业的教师开展教学和高校学生进行自学。
本书具有以下特色:
● 主题突出,紧紧围绕操作系统的三大主题元素——虚拟化、并发和持久性。
● 以对话的方式引入背景,提出问题,进而阐释原理,启发动手实践。
● 包含众多“补充”和“提示”,拓展读者知识面,增加趣味性。
● 使用真实代码而不是伪代码,让读者更加深入透彻地了解操作系统。
● 提供作业、模拟和项目等众多学习方式,鼓励读者动手实践。
● 为教师提供教学辅助资源。