6. 地址空间与内存操作 API

在操作系统刚刚诞生时,操作系统仅仅只是内存中的一个库,存放了许多常用函数。另一个程序运行在其余的物理内存中。后来的分时系统时代,采用与 CPU 类似的时分共享技术让运行中的进程独占物理内存一段时间,进程停止运行时将所有状态信息保存在磁盘上,并加载其它进程的状态信息。这种做法的问题在于 太慢了。因此现在采用的方法是进行 上下文切换 时,仍然将进程信息保存在内存中。在之后,又提出了 保护 的要求。本节就将简要概括操作系统采用什么样的方式虚拟化内存,又给进程提供了哪些关于进程的抽象。

地址空间

操作系统为进程提供的物理内存抽象被称作 地址空间(Address Space),这是进程所能看到的系统中的内存。

一个进程的地址空间包含进程的所有内存状态,包括:

一般而言,一个进程的地址空间按照下图所示进行简要划分:

Pasted image 20250604152641.png

堆栈都可以增长和收缩,在上图所示的分配方式中,堆向下增长,而栈向上增长。

然而,地址空间本身只是操作系统提供给进程的抽象,其在物理内存中的实际地址并非与其在内存空间中的地址等同。因此我们称操作系统 虚拟化了内存。进程会认为其被加载到了内存中一个特定的地址,并且具有非常大的地址空间。然而当进程想要实际访问地址空间中某个地址(这被称作 虚拟地址(Virtual Address))时,操作系统在硬件的支持下进行 地址转换,访问物理内存中实际对应的地址的数据。这也引出了我们内存虚拟化部分的关键问题:

关键问题:操作系统如何在单一的物理内存上为多个运行的进程(这些进程共同存在于相同的物理内存中)构建一个私有的、可能很大的地址空间的假象?

在我们介绍解决该关键问题的机制与策略前,先简单介绍一下虚拟内存系统的主要目标:

内存操作 API

本小节讲解在 Unix 系统中操作系统提供的内存分配接口。

关键问题:在 Unix/C 程序中使用哪些接口来管理进程的内存?如何使用?使用时要避免哪些错误?

前文提到,在运行一个 C 程序时会分配栈内存与堆内存两种类型的内存。栈内存的申请与释放由 编译器隐式管理,因此也被称作 自动内存

当我们声明局部变量时,编译器就会自行在栈上申请空间存储该变量。当我们从当前函数退出时,编译器自动将局部变量占用的内存释放。

而堆内存的申请与释放操作全部由程序员手动完成。在这个过程过程中可能会引发许多严重的错误,见与内存相关的常见错误

malloc()

malloc() 函数用于从堆中申请内存。其接受一个 size_t 类型参数,表示需要申请的字节数。申请成功时该函数返回指向新空间的指针,失败则返回 NULL。返回指针的类型为 void *,即无类型指针,其可以指向任意类型的数据,程序员可以通过强制类型转换将其转换为期望的数据类型的指针,但这并不是必须的。

在使用 malloc() 时,直接硬编码需要的字节数是一个不好的行为。更好的做法是使用各种函数或宏,例如 sizeof() 操作符。例如给一个双精度浮点变量申请空间可以这么写:

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

需要注意的是执行这条语句发生了两种内存分配,一种是声明 *x 时编译器隐式在栈上分配了一个双精度型浮点指针的空间;另一种是调用 malloc() 时显式地在堆上分配了一个双精度型浮点数的空间。

当要给字符串分配空间时,习惯的写法为:

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() 的实现和其它各种机制的行为,这样做之后程序仍然可能正常运行,也有可能崩溃。具体而言,可能的结果为:

忘记初始化分配的内存

申请的可用内存可能残留有先前使用此区域的代码残留的各种垃圾数据。尝试读取这些值会导致 未初始化的读取问题,得到一些未知或随机的数据。大部分情况下这些数据是无害的,但偶尔也会引发严重的故障。

calloc() 调用同样能够分配内存,同时其会将分配的内存空间置零,因此避免了未初始化的读取问题。

忘记释放内存

这被称作 内存泄漏。在运行时间很短的程序中不主动调用 free() 释放分配的内存从结果来看没有危害,这是因为操作系统会在进程退出后主动收回进程地址空间内的所有内存。但这仍然是一个不好的习惯。在编写长时间运行的应用程序或系统(例如 Web 服务器、数据库管理系统、以及操作系统本身)时就必须提起重视。长时间的缓慢内存泄漏最终会导致内存不足,应用程序崩溃或被迫重新启动。对于有垃圾回收机制的语言同理:如果忘记解除对某块内存的引用,那么垃圾收集器就不会释放这段内存。

不慎释放了仍然需要使用的内存

这会产生 悬空指针(Dangling Pointer),即指向已经被释放(或不再有效)内存的指针。这意味着指针仍然持有旧地址,但该地址所指向的内存已被回收或重新分配。由于其指向的内存已经被释放,其内容可能会被修改。这导致后续继续使用该悬空指针可能引发故障或崩溃。

重复释放内存

这属于 未定义行为,其最终结果同样是不确定的,可能引发程序崩溃。

无效的内存释放

free() 要求传入一个 malloc() 返回的指针。但指针本身只是一个 8 byte 的无符号整数而已,如果向 free() 中传入一些其他的值,就会引发无效的内存释放,最终导致某些未知但一般不好的结果。

可以看出,在 C 这种允许程序员自行管理内存的语言中,十分容易产生与内存相关的错误。因此,目前也有一些用于检查内存管理问题的工具,如 purifyvalgrind

代码过编译或能运行不代表功能正确

上述所有与内存有关的错误都不会导致编译失败,但当真正运行这样的程序时,种种问题就会产生。而且,这些有问题的程序在某些时候基于某些条件可以正常工作,但过一段时间这些条件不再成立后,问题就随之而来。因此,无论是通过编译,还是能够正常运行一次或多次,都不代表程序的功能正确可以持续运行。

此外,当这种问题发生时,请一定牢记 机器永远是对的,耐心去调试代码来找到隐藏的错误。

malloc()free() 不是系统调用

如标题所述,malloc()free() 不是系统调用,而是普通的库调用。因此它们管理的也是虚拟的地址空间中的内存。它们实际建立在例如 brksbrk 这些系统调用之上。

除此之外,系统调用 mmap() 可以向操作系统申请一块 匿名 内存,并像堆一样管理。其不与任何特定文件相关联,而是与 交换空间 相关联。