6. 地址空间与内存操作 API
在操作系统刚刚诞生时,操作系统仅仅只是内存中的一个库,存放了许多常用函数。另一个程序运行在其余的物理内存中。后来的分时系统时代,采用与 CPU 类似的时分共享技术让运行中的进程独占物理内存一段时间,进程停止运行时将所有状态信息保存在磁盘上,并加载其它进程的状态信息。这种做法的问题在于 太慢了。因此现在采用的方法是进行 上下文切换 时,仍然将进程信息保存在内存中。在之后,又提出了 保护 的要求。本节就将简要概括操作系统采用什么样的方式虚拟化内存,又给进程提供了哪些关于进程的抽象。
地址空间
操作系统为进程提供的物理内存抽象被称作 地址空间(Address Space),这是进程所能看到的系统中的内存。
一个进程的地址空间包含进程的所有内存状态,包括:
- 代码
- 栈:保存当前的函数调用信息、局部变量,传递参数与返回值
- 堆:管理动态分配的、用户管理的内存
- 其它所需数据,如静态初始化的变量
一般而言,一个进程的地址空间按照下图所示进行简要划分:

堆栈都可以增长和收缩,在上图所示的分配方式中,堆向下增长,而栈向上增长。
然而,地址空间本身只是操作系统提供给进程的抽象,其在物理内存中的实际地址并非与其在内存空间中的地址等同。因此我们称操作系统 虚拟化了内存。进程会认为其被加载到了内存中一个特定的地址,并且具有非常大的地址空间。然而当进程想要实际访问地址空间中某个地址(这被称作 虚拟地址(Virtual Address))时,操作系统在硬件的支持下进行 地址转换,访问物理内存中实际对应的地址的数据。这也引出了我们内存虚拟化部分的关键问题:
在 C 语言中,当我们打印一个指针的值时,看到的就是 虚拟地址。这个地址意义如上面所述经操作系统与硬件翻译为实际的物理地址,以便得到该地址所存储的数据。事实上,作为用户级程序的的程序员看到的任何地址都是虚拟地址。只有操作系统经内存虚拟化技术知道实际的物理地址。
在我们介绍解决该关键问题的机制与策略前,先简单介绍一下虚拟内存系统的主要目标:
- 透明:操作系统实现内存虚拟化的方式不应被进程感知,而是使内存认为其确实拥有私有的物理内存。操作系统与硬件需要在幕后完成诸如地址转换等工作来让不同的程序复用内存。
- 效率:操作系统实现内存虚拟化的方式应当在时间与空间上是高效的,这意味着内存虚拟化不应让程序运行得更慢,也不应占用过多额外内存。
- 保护:操作系统通过提供保护来提供进程之间的 隔离,允许每个进程在自己的独立环境中运行,即确保进程与操作系统本身不会被其它进程影响;一个进程执行加载、存储或指令提取时也不应以任何方式访问或影响其地址空间之外的任何内容。
通过隔离,一个实体的行为不会影响到另一个实体。操作系统力求让进程彼此隔离以防止伤害。例如通过内存隔离,操作系统确保运行程序不会影响其他进程与操作系统的操作。一些现代操作系统也通过将某些部分与操作系统的其它部分分离以实现进一步的隔离。
内存操作 API
本小节讲解在 Unix 系统中操作系统提供的内存分配接口。
前文提到,在运行一个 C 程序时会分配栈内存与堆内存两种类型的内存。栈内存的申请与释放由 编译器隐式管理,因此也被称作 自动内存。
当我们声明局部变量时,编译器就会自行在栈上申请空间存储该变量。当我们从当前函数退出时,编译器自动将局部变量占用的内存释放。
而堆内存的申请与释放操作全部由程序员手动完成。在这个过程过程中可能会引发许多严重的错误,见与内存相关的常见错误。
malloc()
malloc() 函数用于从堆中申请内存。其接受一个 size_t 类型参数,表示需要申请的字节数。申请成功时该函数返回指向新空间的指针,失败则返回 NULL。返回指针的类型为 void *,即无类型指针,其可以指向任意类型的数据,程序员可以通过强制类型转换将其转换为期望的数据类型的指针,但这并不是必须的。
在使用 malloc() 时,直接硬编码需要的字节数是一个不好的行为。更好的做法是使用各种函数或宏,例如 sizeof() 操作符。例如给一个双精度浮点变量申请空间可以这么写:
double *x = (double *) malloc(sizeof(double));
需要注意的是执行这条语句发生了两种内存分配,一种是声明 *x 时编译器隐式在栈上分配了一个双精度型浮点指针的空间;另一种是调用 malloc() 时显式地在堆上分配了一个双精度型浮点数的空间。
sizeof()
sizeof() 是一个编译时操作符而非函数,编译时操作符意味着其值可以在编译器确定,因此可以被编译器替换为具体的数,例如 sizeof(int) 会被替换为 4。
此外,当我们将一个类型为指针的变量传递给 sizeof() 时,其会输出这个指针类型本身占用的字节数(因此在 64 位机上其会输出 8),而不是其指向的数据的对应类型占用的字节数。然而,当我们将一个声明为数组的变量传递给 sizeof() 时,其会正确地输出该数组本身占用的字节数。
当要给字符串分配空间时,习惯的写法为:
malloc(strlen(s) + 1);
加一是为了给字符串末尾的 NUL 结束标志留出位置。
malloc() 也有许多拓展版本,例如 realloc() 可以新创建一个更大的内存区域,并将旧区域的数据复制到新区域中,可以用于字符串或数组的扩容。
free()
为了释放不再使用的堆内存,只需要调用 free(),其接受一个由 malloc() 返回的指针作为参数,而并不需要分配区域的大小,这由内存分配库本身记录并追踪。
与内存分配相关的常见错误
知道何时、如何以及是否释放内存是十分棘手的问题,稍有不慎就会引发许多内存相关的故障,即使对于那些支持 自动内存管理 的语言(如 Java,Python)也是如此。在支持自动内存管理的语言中,垃圾收集器 会持续在后台运行,找出不再使用的内存并自动释放,无需用户显式调用回收内存的函数。
下面是几类十分常见的内存分配问题及后果:
忘记分配内存
野指针(Wild Pointer) 是指没有被初始化或被错误初始化的指针。它们指向一个不确定的内存地址,可能是一个随机值或者是一个无效的地址。许多过程在调用之前要求不能传入野指针,需要指针预先指向分配好的可用的内存,否则会引发臭名昭著的 段错误(Segmentation Fault)。例如在调用 strcpy() 复制字符串前,需要目标字符串指针提前通过调用 malloc() 指向一块大小合适的空闲区域;或者也可以调用 strdup()。
分配的内存不足
这种问题被称作 缓冲区溢出(Buffer Overflow)。仍然是字符串复制的例子,调用 malloc() 时如果忘了加一给末尾的 NUL 留出空间,再调用 strcpy() 时就会出现问题。根据 malloc() 的实现和其它各种机制的行为,这样做之后程序仍然可能正常运行,也有可能崩溃。具体而言,可能的结果为:
- 执行字符串拷贝时,在超出分配空间的末尾写入了一个字节。其可能会覆盖暂未使用的垃圾内存,也可能引发严重的危害。事实上,操作系统的许多安全漏洞都是因缓冲区溢出而触发的。
malloc()额外分配了一些空间,程序正常运行。- 程序发生故障或直接崩溃。
忘记初始化分配的内存
申请的可用内存可能残留有先前使用此区域的代码残留的各种垃圾数据。尝试读取这些值会导致 未初始化的读取问题,得到一些未知或随机的数据。大部分情况下这些数据是无害的,但偶尔也会引发严重的故障。
calloc() 调用同样能够分配内存,同时其会将分配的内存空间置零,因此避免了未初始化的读取问题。
忘记释放内存
这被称作 内存泄漏。在运行时间很短的程序中不主动调用 free() 释放分配的内存从结果来看没有危害,这是因为操作系统会在进程退出后主动收回进程地址空间内的所有内存。但这仍然是一个不好的习惯。在编写长时间运行的应用程序或系统(例如 Web 服务器、数据库管理系统、以及操作系统本身)时就必须提起重视。长时间的缓慢内存泄漏最终会导致内存不足,应用程序崩溃或被迫重新启动。对于有垃圾回收机制的语言同理:如果忘记解除对某块内存的引用,那么垃圾收集器就不会释放这段内存。
运行中的程序存在两级内存管理。一层由操作系统执行,负责在进程运行时将内存分配给进程,在进程退出时将其回收。操作系统会收回结束进程的整个地址空间(包括用于存放代码、堆、栈的内存页)。另一层由应用进程自身管理其堆内存,并处理所有如 malloc() 与 free() 调用等内存操作。因此当进程结束时,无论进程地址空间中堆的状态如何,操作系统都会将其全部回收,从而产生了不主动释放内存也不会产生泄漏的结果。
不慎释放了仍然需要使用的内存
这会产生 悬空指针(Dangling Pointer),即指向已经被释放(或不再有效)内存的指针。这意味着指针仍然持有旧地址,但该地址所指向的内存已被回收或重新分配。由于其指向的内存已经被释放,其内容可能会被修改。这导致后续继续使用该悬空指针可能引发故障或崩溃。
重复释放内存
这属于 未定义行为,其最终结果同样是不确定的,可能引发程序崩溃。
无效的内存释放
free() 要求传入一个 malloc() 返回的指针。但指针本身只是一个 8 byte 的无符号整数而已,如果向 free() 中传入一些其他的值,就会引发无效的内存释放,最终导致某些未知但一般不好的结果。
可以看出,在 C 这种允许程序员自行管理内存的语言中,十分容易产生与内存相关的错误。因此,目前也有一些用于检查内存管理问题的工具,如 purify 与 valgrind。
上述所有与内存有关的错误都不会导致编译失败,但当真正运行这样的程序时,种种问题就会产生。而且,这些有问题的程序在某些时候基于某些条件可以正常工作,但过一段时间这些条件不再成立后,问题就随之而来。因此,无论是通过编译,还是能够正常运行一次或多次,都不代表程序的功能正确可以持续运行。
此外,当这种问题发生时,请一定牢记 机器永远是对的,耐心去调试代码来找到隐藏的错误。
malloc() 与 free() 不是系统调用
如标题所述,malloc() 与 free() 不是系统调用,而是普通的库调用。因此它们管理的也是虚拟的地址空间中的内存。它们实际建立在例如 brk 与 sbrk 这些系统调用之上。
除此之外,系统调用 mmap() 可以向操作系统申请一块 匿名 内存,并像堆一样管理。其不与任何特定文件相关联,而是与 交换空间 相关联。