内存接口

在本章中,我们将介绍 UNIX 操作系统的内存管理接口。操作系统提供的接口非常简洁,因此本章简明扼要。

内存类型

在运行一个 C 程序的时候,会分配两种类型的内存。第一种称为栈内存,它的申请和释放操作是编译器来隐式管理的,所以有时也称为自动内存。

C 申请栈内存很容易。比如,假设需要在 func 函数中为一个整形变量 x 申请空间。为了声明这样一块内存,只需要这样做:

void func() {
	int x; // declares an integer on the stack
	...
}

编译器将完成剩下的工作,确保你进入 func 函数的时候,在栈上开辟空间。当你从该函数退出时,编译器释放内存。因此,如果你希望某些信息存在于函数调用之外,建议不要将其放在栈上。

就是这种对长期内存的需求,所以我们才需要第二种类型的内存,即堆内存。其中所有的申请和释放操作都有程序员显式完成。毫无疑问,这是一项艰巨的任务。这确实导致了很多缺陷。但如果小心并加以注意,就会正确的使用这些接口,没有太多的麻烦。下面的例子展示了如何在堆上分配一个整数,并得到指向它的指针:

void func() {
	int *x = (int *) malloc(sizeof(int));
	...
}

关于这一段代码有两点说明。首先,你可能会注意到栈和堆的分配都发生在同一行:首先编译器看到指针的声明 (int * x),知道为一个整形指针分配空间,随后,当程序调用 malloc 时,它会在堆上请求整数的空间,函数返回这样一个整数的地址,然后将其存储在栈中一共程序使用。

因为它的显式特性,以及它富于变化的用法,堆内存对用户和系统提出了更大的挑战。所以这也是我们接下来要讨论的重点。

malloc 调用

malloc 函数非常简单:传入要申请的堆空间的大小,它成功就返回一个指向新申请空间的指针,失败就返回 NULL。

man 手册展示了使用 malloc 需要怎么做,在命令行输入 man malloc 即可:

#include <stdlib.h>
...
void *malloc(size_t size);

从这段信息可以看到,只要包含头文件 stdlib.h 就可以使用 malloc 了。但实际上,甚至都不要这样做,因为 C 库是程序默认链接的,其中就有 malloc 的代码,加上头文件只是让编译器检查你是否正确调用了 malloc。

malloc 只需要一个 size_t 类型参数,该参数表示你需要多少个字节。然而,大多数程序员并不会直接传入数字。实际上,这样做会被认为是不好的形式。替代方案是使用各种函数或宏。比如为了给双精度浮点分配空间会这样做:

double *d = (double *) malloc(sizeof(double));

对 malloc 的调用是用 sizeof 操作符来申请正确大小的空间。在 C 中,这通常被认为是编译时操作符,意味着这个大小是在编译时已经知道的,因此被替换为一个数,再作为 malloc 的参数。处于这个原因,sizeof 被正确的认为是一个操作符,而不是一个函数调用,因为函数调用发生在运行时。

你可以可以传入一个变量的名字给 sizeof,但在一些情况下,可能得不到你想要的结果,所以要小心使用。

另一个需要注意的地方是使用字符串。如果为一个字符串分配空间,请使用以下惯用法:malloc(strlen(s) + 1),它使用函数 strlen 获取字符串的长度并加上 1,以便给字符串结束符留出空间。这里使用 sizeof 可能会导致麻烦。

你也许注意到 malloc 返回一个指向 void 类型的指针。这样做只是 C 中传回地址的形式,让程序员决定如何处理它。程序员进一步使用所谓的强制类型转换,在我们上面的例子中,程序员将返回类型的 malloc 强制转换为指向 double 的指针。强制类型转换实际上没干什么,只是告诉编译器和其他可能正在读你代码的程序员:是的,我知道正在做什么。通过强制转换 malloc 的结果,程序员只是在给人一些信息,并非必须。

free 调用

事实证明,分配内存是等式的简单部分。知道何时、如何以及是否释放内存是困难的部分。要释放不再使用的堆内存,程序员只需调用 free。

int *x = malloc(10 * sizeof(int));
...
free(x);

该函数接收一个参数,即一个由 malloc 返回的指针。因此你会注意到,分配区域的大小不会被传入,必须由内存分配库自己来记录追踪。

常见错误

在使用 malloc 和 free 时会出现一些常见的错误。以下是我们在教授本科操作系统课程时反复看到的情形。所有使用的这些例子都可以通过编译器编译并运行。对于构建一个正确的 C 程序来说,通过编译是必要的,但这还远远不够,你会懂的。

实际上,正确的内存管理就是这样一个问题,许多新语言都支持自动内存管理。在这样的语言中,当你调用类似 malloc 的机制来分配内存时,你永远不需要调用某些东西来释放空间。实际上,来及收集器会运行,找出你不再引用的内存并将其释放。

忘记分配内存

许多例程在调用之前,都希望你为它们分配内存,比如 strcpy(dst,src) 将源字符串中的字符串复制到目标指针。但是,如果不小心,你可能会这样做:

char *src = "hello";
char *dst;			// oops! unallocated
strcoy(dst, src);	// segfault and die

运行这段代码将会导致段错误,这是一个很奇怪的术语,表示你对内存犯了一个错误。

正确的代码应该向下面这样:

char *src = "hello";
char *dst = (char *)malloc(strlen(src)+1);
strcpy(dst, src);
``

或者你可以使用 strdup 来让生活更加轻松。

### 没有分配足够的内存

另一个相关的错误是没有分配足够的内存,有时称为缓冲区溢出。在上面的例子中,一个常见的错误是为目标缓冲区仅仅留出几乎足够的空间:

```c
char *src = "hello";
char *dst = (char *) malloc(strlen(src)); // too small!
strcpy(dst, src); // work properly

奇怪的是,这个程序通常看起来会正确运行,这取决于如何实现 malloc 和许多其他细节。在某些情况下,当字符串拷贝执行时,它会在超过分配空间的末尾处写入一个字节,但在某些情况下,这是无害的,可能会覆盖不再使用的变量。在某些情况下,这些溢出可能具有令人难以置信的危害,实际上是系统中许多安全漏洞的来源。在其他情况下,malloc 库总是分配一些额外空间,因此你的程序实际上不会在其他某个变量的值上涂写,并能工作的很好。还有一些情况,该程序确实会发生故障和崩溃。因此,我们学到了另一个宝贵的教训:即使它正确运行过一次,也不意味着它是正确的。

忘记初始化分配的内存

在这个错误中,你正确的调用 malloc,但忘记在新分配的数据类型中填写数值。不要这样做。如果你忘记了,你成程序最终会遇到未初始化的读取,它从堆中读取了一些未知的数值。谁知道都是些什么数值!如果走运,读到的值使程序仍然有效。如果不走运,将会读到一些有害的数值。

忘记释放内存

另一个常见错误是内存泄露,如果忘记释放内存,就会发生。在长时间运行的程序或系统中,这是一个巨大的问题。因为缓慢泄露的内存会导致内存不足,此时需要重新启动。因此一般来说,当你用完一段内存后应该确保将其释放。请注意,使用垃圾回收语言在这里没有什么帮助:如果你仍然持有对某块内存的引用,那么垃圾收集器就不会将其释放,因此即使在较现代的语言中,内存泄露仍然是一个问题。

在某些情况下,不调用 free 似乎是合理的。比如你的程序运行时间很短,很快就会退出。在这种情况下,当进程死亡时,操作系统将清理其分配的所有内存,因此将不会发生内存泄露。虽然这肯定有效,但这可能是一个坏习惯,所以请谨慎使用这种策略。

在用完之前释放内存

有时候程序会提前释放内存,这种错误被称为悬挂指针,正如你猜测的那样,这也是不对的。随后的使用可能会导致程序错误或覆盖有效的内存。

反复释放内存

程序有时还会不止一次的释放内存,这种操作被称为重复释放。这样做的结果是未定义的。正如你所能想到的那样,内存分配库可能会感到困惑,并且会做出各种奇怪的事情,崩溃是常见的结果。

错误的调用 free

我们讨论的最后一个问题是 free 的错误调用。free 期望你只传入之前从 malloc 得到的指针。如果传入一些其他值,就会发生错误。因此这种无效的释放是危险的。

底层操作系统支持

你可能已经注意到,在讨论 malloc 和 free 时,我们没有提到系统调用。原因很简单:它们不是系统调用,而是库调用。因此 malloc 库管理虚拟地址空间,但是它本身是建立在一些系统调用之上的,这些系统调用会进入操作系统,来请求更多内存或释放内存。

一个这样的系统条用叫做 brk,它被用来改变程序分断的位置:堆结束的位置。它需要一个参数,从而根据新分断是大于还是小于当前分断,来增加或减少堆的大小。另一个调用 sbrk 要求传入一个增量,但目的类似。

请注意,你不应该直接调用 brk 或 sbrk。它们被内存分配库使用。如果你尝试使用它们,很可能会犯一些错误。

最后,你还可以通过 mmap 调用从操作系统获取内存。通过传入正确的参数,mmap 可以在程序中创建一个匿名内存区域——该区域不与任何特定文件关联,而是与交换空间关联,稍后将详细讨论。这种内存也可以像堆一样被管理。

其他调用

内存分配库还支持一些其他调用。比如 calloc 分配内存,并在返回之前将其置零。如果你认为内存已归零并忘记自己初始化它,这可以放置出现一些错误。当你为某些东西分配空间,然后还需要添加一些东西时,例程 realloc 也会很有用。realloc 创建一个新的更大的内存区域,将旧区域复制到其中,并返回新区域的指针。