操作系统所使用的内存体系结构是理解操作系统如何运作的关键。当我们开始使用一个新的操作系统时,脑海中会涌现出许多问题。比如“怎样才能在两个应用程序间共享数据?”,“系统把我需要的数据保存在哪里了?”,以及“怎样才能让应用程序更高效地运行?”,等等。
我发现在很多时候,充分理解系统是如何管理内存的,将有助于我们快速而准确地找到上述问题的答案。本章将深入探讨Microsoft Windows所使用的内存体系结构。
每个进程都有自己的虚拟地址空间。对32位进程来说,这个地址空间的大小为4 GB,这是因为32位指针可以表示从0x00000000到0xFFFFFFFF之间的任一值。指针在这个范围内可以有4 294 967 296个值,它们覆盖了进程的4 GB地址空间。对64位进程来说,由于64位指针可以表示从0x00000000'00000000到0xFFFFFFFF'FFFFFFFF之间的任一值,因此这个地址空间的大小为16 EB[1]。指针在这个范围内可以有18 446 744 073 709 551 616个值,它们覆盖了进程的16 EB地址空间。这个地址空间实在是太大了!
因为每个进程有自己私有的地址空间,当进程中的各线程运行时,它们只能访问属于该进程的内存。线程既看不到属于其他进程的内存,也无法访问它们。
注意 在Windows中,线程看不到属于操作系统本身的内存,这意味着该线程不能无意间访问到操作系统的数据。
如前所述,每个进程都有自己私有的地址空间。进程A可以在位于它的地址空间内的0x12345678地址处存储一个数据结构,而进程B也可以在自己的地址空间内存储一个完全不同的数据结构——同样位于0x12345678地址处。当进程A中的线程访问位于地址0x12345678处的内存时,它们访问的是进程A的数据结构。当进程B中的线程访问位于地址0x12345678处的内存时,它们访问的是进程B的数据结构。进程A中的线程无法访问位于进程B的地址空间内的数据结构,反之亦然。
别高兴得太早,虽然应用程序有这么大的地址空间可用,但是要记住这只是虚拟地址空间——不是物理存储器。这个地址空间只不过是一个内存地址区间。为了能够正常读/写数据,我们还需要把物理存储器分配或映射到相应的地址空间,否则将导致访问违规[2]。我们会在本章的后半部分对此进行详细的介绍。
每个进程的虚拟地址空间被划分成许多分区[3]。由于地址空间的分区依赖于操作系统的底层实现,因此会随着Windows内核的不同而略有变化。表13-1列出了各平台上对进程地址空间的分区。
我们可以看到,32位Windows内核和64位Windows内核的分区基本一致,唯一的不同在于分区的大小和分区的位置。下面我们来看一下系统如何使用每一个分区。
表13-1 进程的地址空间是如何划分的
|
分区 |
x86 32位 Windows |
3 GB用户模式下的x86 32位 Windows |
x64 64位 Windows |
IA-64 64位 Windows |
|
空指针赋值分区 |
0x00000000 0x0000FFFF |
0x00000000 0x0000FFFF |
0x00000000’00000000 0x00000000’0000FFFF |
0x00000000’00000000 0x00000000’0000FFFF |
|
用户模式分区 |
0x00010000 0x7FFEFFFF |
0x00010000 0xBFFEFFFF |
0x00000000’00010000 0x000007FF’FFFEFFFF |
0x00000000’00010000 0x000006FB’FFFEFFFF |
|
64 KB禁入分区 |
0x7FFF0000 0x7FFFFFFF |
0xBFFF0000 0xBFFFFFFF |
0x000007FF’FFFF0000 0x000007FF’FFFFFFFF |
0x000006FB’FFFF0000 0x000006FB’FFFFFFFF |
|
内核模式分区 |
0x80000000 0xFFFFFFFF |
0xC0000000 0xFFFFFFFF |
0x00000800’00000000 0xFFFFFFFF’FFFFFFFF |
0x000006FC’00000000 0xFFFFFFFF’FFFFFFFF |
这一分区是进程地址空间中从0x00000000到0x0000FFFF的闭区间,保留该分区的目的是为了帮助程序员捕获对空指针的赋值。如果进程中的线程试图读取或写入位于这一分区内的内存地址,就会引发访问违规。
在C/C++程序中,错误检查经常执行得不够彻底。例如,下面的代码就没有执行错误检查:
int* pnSomeInteger = (int*) malloc(sizeof(int));
*pnSomeInteger =
5;
如果malloc无法分配足够的内存,那么它会返回NULL。但是,前面的代码并没有检查这种可能性——它想当然地认为分配一定会成功并访问位于地址0x00000000处的内存。因为地址空间中的这一分区是禁止访问的,所以会引发内存访问违规并导致进程被终止。这一特性可以帮助开发人员发现应用程序中的缺陷。值得注意的是,没有任何办法可以让我们分配到位于这一地址区间内的虚拟内存,即便是使用Win32应用程序编程接口[4],也不例外。
这一分区是进程地址空间的驻地。可用的地址区间和用户模式分区的大小取决于CPU体系结构,如表13-2所示。
表13-2 CPU体系结构、对应的用户模式可用地址区间以及区间的大小
|
CPU体系结构 |
用户模式分区的可用地址区间 |
用户模式分区的大小 |
|
x86(普通) |
0x00010000 → 0x7FFEFFFF |
~2 GB |
|
x86(3GB) |
0x00010000 → 0xBFFEFFFF |
~3 GB |
|
x64 |
0x00000000’00010000 → 0x000007FF’FFFEFFFF |
~8192 GB |
|
IA-64 |
0x00000000’00010000 → 0x000006FB’FFFEFFFF |
~7152 GB |
进程无法通过指针来读取、写入或以任何方式访问驻留在这一分区中其他进程的数据。对所有应用程序来说,进程的大部分数据都保存在这一分区。由于每个进程都有自己的数据分区,因此一个应用程序破坏另一个应用程序的可能性就非常小,从而使得整个系统更加坚固。
注意 在Windows中,所有.exe和动态链接库[5]都载入到这一区域。每个进程可能将这些DLL载入到这一分区内的不同地址(虽然这种可能性很小)。系统同时会把该进程可以访问的所有内存映射文件映射到这一分区。
当我第一眼看到32位进程的地址空间时,惊讶地发现进程可用地址空间的数量居然还不到进程整个地址空间的一半。难道内核模式分区真的需要整个地址空间的上半部分吗?实际上,回答是肯定的。系统需要用这一空间来存放内核代码、设备驱动程序代码、设备输入/输出高速缓存、非分页缓冲池分配表[6]、进程页面表,等等。事实上,Microsoft已经将内核压缩到这2 GB的空间中。在64位Windows中,内核终于能够得到它真正需要的空间了。
有些应用程序,比如Microsoft SQL Server,会受益于大于2 GB的用户模式地址空间。可寻址更多的数据有助于这些应用程序提高性能和可伸缩性。因此,x86版的Windows提供了一种模式来增大用户模式分区,最多不超过3 GB。为了让所有的应用程序使用大于2 GB的用户模式分区和小于1 GB的内核模式分区,我们需要对Windows中的启动配置数据(boot configuration data,后面简称为BCD)进行设定,并重新启动机器。有关BCD的更多信息,请参阅以下网址提供的白皮书:http://www.microsoft.com/whdc/system/platform/firmware/bcd.mspx。
要配置BCD,只需执行BCDEdit.exe并使用/set开关和IncreaseUserVA参数。例如,bcdedit /set IncreaseUserVa 3072告诉Windows为所有进程保留3 GB的用户模式地址空间和1 GB的内核模式地址空间。表13-2中的“x86 w/3GB”那一列显示了当IncreaseUserVa的值为3072时地址空间的分布。IncreaseUserVa可接受的最小值为2048,它对应于默认的2 GB。如果需要取消对该参数的设定,只需执行下面的命令:bcdedit /deletevalue IncreaseUserVa
提示 如果需要知道BCD各参数的当前设定值,在命令行运行bcdedit /enum即可。(有关BCDEdit各参数的更多信息,请参阅http://msdn2.microsoft.com/en-us/library/aa906211.aspx。)
在早期版本的Windows中,Microsoft不允许应用程序访问2 GB以上的地址空间,因此一些有创意的开发人员决定对此加以利用。他们将指针的最高位作为一个标志位使用,只有他们的应用程序才知道该如何解释该标志位。当应用程序访问内存地址时,会在访问内存地址之前清除指针的最高位。可以想象,当此类应用程序在用户模式分区大于2 GB的环境下运行时,显然会“死得很难看”[7]。
为了让此类应用程序即使在用户模式分区大于2GB的环境下仍能正常运行,Microsoft必须为这一问题提供一种解决方案。当系统即将运行一个应用程序时,它会检查应用程序在链接时是否使用了/LARGEADDRESSAWARE链接器开关。如果是,则相当于应用程序在声明它会充分利用大用户模式地址空间,而不会对内存地址进行任何不当的操作。反之,如果应用程序在链接时没有使用/LARGEADDRESSAWARE开关,那么操作系统会保留用户模式分区中2 GB以上到内核模式开始处的整个部分。这样,所有分配到的内存地址的最高位都是0,因此避免了应用程序对该位做出错误的解释。
需要注意的是,内核所需要的代码和数据原来已经被紧紧地压缩到2 GB的分区内。因此,将内核的地址空间减少到2 GB以下将限制系统所能创建的线程、栈、及其他资源的数量。此外,在使用大用户模式分区时,系统最多只能使用64 GB内存,而如果使用默认的2 GB用户模式分区,系统最多可以使用128 GB内存[8]。
注意 操作系统会在创建进程的地址空间时检查可执行文件的LARGEADDRESSAWARE标志。对DLL来说,系统会忽略该标志。所有的DLL必须经过正确编写,以便能够在用户模式分区大于2GB的情况下正常运行,否则其结果将不可预料。
Microsoft意识到许多开发人员希望尽可能快、尽可能容易地将已有的32位应用程序移植到64位环境下。但是,还存在大量为32位指针开发的源代码。仅仅重新编译应用程序会导致指针截断错误[9]和不正确的内存访问。
但是,如果系统能够保证不在0x00000000'7FFFFFFF以上的地址分配内存,那么应用程序就能够正常运行。把一个高33位都为0的64位地址截断为32位地址,无论如何都不会产生问题。系统可以提供这一保证,其做法是让应用程序在地址空间沙箱[10]中运行,这样进程可用的地址空间就被限制在最底部的2 GB中。
在默认情况下,当运行一个64位应用程序时,系统会保留用户模式地址空间中位于地址0x00000000'80000000之后的所有部分。这就确保了所有的内存都分配自64位地址空间中最底部的2 GB。这就是所谓的地址空间沙箱。对大多数应用程序来说,2 GB的地址空间已经足够了。为了让64位应用程序能够访问整个用户模式分区,必须用/LARGEADDRESSAWARE链接器开关来链接应用程序。
注意 操作系统会在创建进程的64位地址空间时检查可执行文件的LARGEADDRESSAWARE标志。对DLL来说,系统会忽略该标志。所有的DLL必须经过正确编写,以便能够在4 TB用户模式分区的情况下正常运行,否则其结果将不可预料。
这一分区是操作系统代码的驻地。与线程调度、内存管理、文件系统支持、网络支持以及设备驱动程序相关的代码都载入到该分区。驻留在这一分区内的任何东西为所有进程共有。虽然这一分区就在每个进程中用户模式分区的上方,但该分区中所有代码和数据都被完全保护起来。如果一个应用程序试图读取或写入位于这一分区中的内存地址,会引发访问违规。在默认情况下,访问违规会导致系统先向用户显示一个消息框,然后结束该应用程序。有关访问违规的更多信息,以及如何对其进行处理,请参阅第23章、第24章以及第25章。
注意 在64位Windows中,8 TB的用户模式分区和16 777 208 TB的内核模式分区看起来完全不成比例。这并不是因为内核模式分区需要这么大的虚拟地址空间。这只不过是因为64位地址空间实在是太大了,其中大部分尚未使用。系统允许应用程序使用8 TB,也允许内核根据其需要加以使用,实际上内核模式分区中的大部分尚未使用。对内核模式分区中尚未使用的部分,系统不必分配任何内部数据结构来对它们进行维护。
当系统创建一个进程并赋予它地址空间时,可用地址空间中的大部分都是闲置的[11],或尚未分配的[12]。为了使用这部分地址空间,我们必须调用VirtualAlloc(详情参见第15章)来分配其中的区域[13]。分配区域的操作被称为预订[14]。
当应用程序预订地址空间区域时,系统会确保区域的起始地址正好是分配粒度[15]的整数倍。分配粒度会根据不同的CPU平台而有所不同。但是,在写作本书时,所有的CPU平台都使用相同的分配粒度,大小为64 KB——也就是说,系统会把分配请求取整到64 KB的整数倍。
当应用程序预订地址空间中的一块区域时,系统会确保区域的大小正好是系统页面大小的整数倍。页面是一个内存单元,系统通过它来管理内存。与分配粒度相似,页面大小会根据不同的CPU而有所不同。x86和 x64系统使用的页面大小为4 KB,而IA-64系统使用的页面大小为8 KB。
注意 有时系统会以应用程序的名义来预订地址空间区域。例如,系统会分配一块地址空间区域来存放进程环境块(process environment block,后面简称为PEB)。PEB是一个完全由系统创建、操控并销毁的小型数据结构。当系统创建一个进程时,它会为PEB分配一块地址空间区域。
系统同时还需要创建线程环境块(thread environment block,后面简称为TEB)来协助管理进程中所有的线程。系统会在创建线程时为TEB预订区域,并在销毁线程时释放相应的区域。
虽然系统规定应用程序在预订地址空间区域时起始地址必须是分配粒度(在所有平台上都为64 KB)的整数倍,但系统自己却不存在同样的限制。非常有可能出现的情况是,系统为PEB和TEB预订的区域的起始地址并非64 KB的整数倍。但是,这些区域仍然必须是CPU页面大小的整数倍。
如果应用程序试图预订一块大小为10 KB的地址空间区域,那么系统会自动将该请求取整到页面大小的整数倍,然后用取整后的大小预订区域。这意味着在x86和x64系统中,系统会预订一块大小为12 KB的区域,而在IA-64系统中,系统会预订一块大小为16 KB的区域。
当程序不再需要访问所预订的地址空间区域时,应该释放该区域。这个过程被称为释放地址空间区域,通过调用VirtualFree函数来完成。
为了使用所预订的地址空间区域,我们还必须分配物理存储器,并将存储器映射到所预订的区域。这个过程被称为调拨[16]物理存储器。物理存储器始终都以页面为单位来调拨。我们通过调用VirtualAlloc函数来将物理存储器调拨给所预订的区域。
当我们调拨物理存储器给区域时,并不需要给整个区域都调拨物理存储器。例如,我们可以预订一块大小为64 KB的区域,然后把物理存储器拨给该区域中的第2和第4个页面。图13-1显示了在这种情况下进程的地址空间会是什么样子。注意,地址空间会根据CPU平台的不同而有所不同。左面显示的是x86/x64机器上(页面大小为4 KB)的地址空间,而右面显示的是IA-64机器上(页面大小为8 KB)的地址空间。

图13-1 不同CPU上的进程地址空间示例
当程序不再需要访问所预订区域中已调拨的物理存储器时,应该释放物理存储器。这个过程被称为撤销调拨[17]物理存储器,通过调用VirtualFree函数来完成。
[1] 译注:exabytes,百亿亿字节
[2] 译注:access violation
[3] 译注:partition
[4] 译注:application programming interface,通常简称为API
[5] 译注:dynamic-link library,通常简称为DLL
[6] 译注:non-paged pool allocation
[7] 译注:这种情况发生在Windows分配的内存位于2GB以上的时候,把指针最高位的1清除会导致应用程序访问错误的内存地址。
[9] 译注:pointer truncation error
[10] 译注:address space sandbox
[11] 译注:free
[12] 译注:unallocated
[13] 译注:region
[14] 译注:reserving
[15] 译注:allocation granularity
[16] 译注:committing
[17] 译注:decommitting