进程地址模型

在前面的章节中,我们已经了解到可执行文件是如何映射到计算机内存里的,本节将再深化一下对这方面的理解,顺便结合上一章中关于动态链接的内容,看看加上动态链接之后进程的地址空间是如何分布的。

现代的应用程序都运行在一个内存空间里,在32位的系统里,这个内存空间拥有4GB(2的32次方)的寻址能力。相对于16位时代i386的段地址加段内偏移的寻址模式,如今的应用程序可以直接使用32位的地址进行寻址,这被称为平坦(flat)的内存模型。在平坦的内存模型中,整个内存是一个统一的地址空间,用户可以使用一个32位的指针访问任意内存位置。例如:

int p = (int)0x12345678;
++*p;
这段代码展示了如何直接读写指定地址的内存数据。不过,尽管当今的内存空间号称是平坦的,但实际上内存仍然在不同的地址区间上有着不同的地位,例如,大多数操作系统都会将4GB的内存空间中的一部分挪给内核使用,应用程序无法直接访问这一段内存,这一部分内存地址被称为内核空间。Windows在默认情况下会将高地址的2GB空间分配给内核(也可配置为1GB),而Linux默认情况下将高地址的1GB空间分配给内核,这些在前文中都已经介绍过了。

用户使用的剩下2GB或3GB的内存空间称为用户空间。在用户空间里,也有许多地址区间有特殊的地位,一般来讲,应用程序使用的内存空间里有如下“默认”的区域。

栈:栈用于维护函数调用的上下文,离开了栈函数调用就没法实现。在10.2节中将对栈作详细的介绍。栈通常在用户空间的最高地址处分配,通常有数兆字节的大小。

堆:堆是用来容纳应用程序动态分配的内存区域,当程序使用malloc或new分配内存时,得到的内存来自堆里。堆会在10.3节详细介绍。堆通常存在于栈的下方(低地址方向),在某些时候,堆也可能没有固定统一的存储区域。堆一般比栈大很多,可以有几十至数百兆字节的容量。

可执行文件映像:这里存储着可执行文件在内存里的映像,在第6章已经提到过,由装载器在装载时将可执行文件的内存读取或映射到这里。在此不再详细说明。

保留区:保留区并不是一个单一的内存区域,而是对内存中受到保护而禁止访问的内存区域的总称,例如,大多数操作系统里,极小的地址通常都是不允许访问的,如NULL。通常C语言将无效指针赋值为0也是出于这个考虑,因为0地址上正常情况下不可能有有效的可访问数据。下图是linux下进程地址空间模型:
在这里插入图片描述
在图中,有一个没有介绍的区域:“动态链接库映射区”,这个区域用于映射装载的动态链接库。在Linux下,如果可执行文件依赖其他共享库,那么系统就会为它在从0x40000000开始的地址分配相应的空间,并将共享库载入到该空间。

图中的箭头标明了几个大小可变的区的尺寸增长方向,在这里可以清晰地看出栈向低地址增长,堆向高地址增长。当栈或堆现有的大小不够用时,它将按照图中的增长方向扩大自身的尺寸,直到预留的空间被用完为止。

在接下来的两节中,会详细介绍上述几个区域中的栈和堆,让读者对应用程序执行时内存的状况有一个更加深入的理解。

Q&A

Q:我写的程序常常出现“段错误(segment fault)”或者“非法操作,该内存地址不能read/write”的错误信息,这是怎么回事?
A:这是典型的非法指针解引用造成的错误。当指针指向一个不允许读或写的内存地址,而程序却试图利用指针来读或写该地址的时候,就会出现这个错误。在Linux或Windows的内存布局中,有些地址是始终不能读写的,例如0地址。还有些地址是一开始不允许读写,应用程序必须事先请求获取这些地址的读写权,或者某些地址一开始并没有映射到实际的物理内存,应用程序必须事先请求将这些地址映射到实际的物理地址(commit),之后才能够自由地读写这片内存。当一个指针指向这些区域的时候,对它指向的内存进行读写就会引发错误。造成这样的最普遍原因有两种:

  1. 程序员将指针初始化为NULL,之后却没有给它一个合理的值就开始使用指针。

  2. 程序员没有初始化栈上的指针,指针的值一般会是随机数,之后就直接开始使用指针。

因此,如果你的程序出现了这样的错误,请着重检查指针的使用情况。