Windows的内存结构

作系统使用的内存结构是理解操作系统如何运行的最重要的关键。当开始对一个新的操作系统进行操作时,你会想到一系列的问题。比如,“如何在两个应用程序之间共享数据呢?”“系统将要查找的信息存放在什么地方呢?”“如何使我的程序能够更加有效地运行呢?”等等。
很好地理解系统如何管理内存,可以帮助你更快和更准确地地回答这些问题。本章将要介绍M i c r o s o f t公司的Wi n d o w s操作系统使用的内存结构。


13.1 进程的虚拟地址空间

每个进程都被赋予它自己的虚拟地址空间。对于3 2位进程来说,这个地址空间是4 G B,因为3 2位指针可以拥有从0 x 0 0 0 0 0 0 0 0至0 x F F F F F F F F之间的任何一个值。这使得一个指针能够拥有4 294 967 296个值中的一个值,它覆盖了一个进程的4 G B虚拟空间的范围。对于6 4位进程来说,这个地址空间是1 6 E B(1 01 8字节),因为6 4位指针可以拥有从0 x 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0至0 x F F F F F F F F F F F F F F F F之间的任何值。这使得一个指针可以拥有18 446 744 073 709 551 616个值中的一个值,它覆盖了一个进程的1 6 E B虚拟空间的范围。这是相当大的一个范围。

由于每个进程可以接收它自己的私有的地址空间,因此当进程中的一个线程正在运行时,该线程可以访问只属于它的进程的内存。属于所有其他进程的内存则隐藏着,并且不能被正在运行的线程访问。

注意在Windows 2000中,属于操作系统本身的内存也是隐藏的,正在运行的线程无法访问。这意味着线程常常不能访问操作系统的数据。Windows 98中,属于操作系统的内存是不隐藏的,正在运行的线程可以访问。因此,正在运行的线程常常可以访问操作系统的数据,也可以破坏操作系统(从而有可能导致操作系统崩溃)。在Windows 98中,一个进程的线程不可能访问属于另一个进程的内存。

前面说过,每个进程有它自己的私有地址空间。进程A可能有一个存放在它的地址空间中的数据结构,地址是0 x 1 2 3 4 5 6 7 8,而进程B则有一个完全不同的数据结构存放在它的地址空间中,地址是0 x 1 2 3 4 5 6 7 8。当进程A中运行的线程访问地址为0 x 1 2 3 4 5 6 7 8的内存时,这些线程访问的是进程A的数据结构。当进程B中运行的线程访问地址为0 x 1 2 3 4 5 6 7 8的内存时,这些线程访问的是进程B的数据结构。进程A中运行的线程不能访问进程B的地址空间中的数据结构。反之亦然。

当你因为拥有如此大的地址空间可以用于应用程序而兴高采烈之前,记住,这是个虚拟地址空间,不是物理地址空间。该地址空间只是内存地址的一个范围。在你能够成功地访问数据而不会出现违规访问之前,必须赋予物理存储器,或者将物理存储器映射到各个部分的地址空间。本章后面将要具体介绍这是如何操作的。


13.2 虚拟地址空间如何分区

每个进程的虚拟地址空间都要划分成各个分区。地址空间的分区是根据操作系统的基本实现方法来进行的。不同的Wi n d o w s内核,其分区也略有不同。表1 3 - 1显示了每种平台是如何对进程的地址空间进行分区的。


如你所见, 3 2位Windows 2000的内核与6 4位Windows 2000的内核拥有大体相同的分区,差别在于分区的大小和位置有所不同。另一方面,可以看到Windows 98下的分区有着很大的不同。下面让我们看一下系统是如何使用每一个分区的。

注意M i c r o s o f t公司正在积极开发6 4位Windows 2000。但是当我撰写本书时,该系统仍在开发之中。应该使用本书中关于6 4位Windows 2000的信息,将它们用于你的当前项目的设计和实现中。不过应该知道,等到6 4位Windows 2000上市时,本章中介绍的一些详细信息很可能已经发生了变化。至于I A - 6 4(6 4位I n t e l结构)的内存管理,分区和系统页面大小的特定虚拟地址范围也有可能变更。

13.2.1 NULL指针分配的分区—适用于Windows 2000和Windows 98

进程地址空间的这个分区的设置是为了帮助程序员掌握N U L L指针的分配情况。如果你的进程中的线程试图读取该分区的地址空间的数据,或者将数据写入该分区的地址空间,那么C P U就会引发一个访问违规。保护这个分区是极其有用的,它可以帮助你发现N U L L指针的分配情况。

C / C + +程序中常常不进行严格的错误检查。例如,下面这个代码就没有进行任何错误检查:


int* pnSomeInteger = (int*) malloc(sizeof(int));
*pnSomeInteger = 5;
如果m a l l o c不能找到足够的内存来满足需要,它就返回N U L L。但是,该代码并不检查这种可能性,它认为地址的分配已经取得成功,并且开始访问0 x 0 0 0 0 0 0 0 0地址的内存。由于这个分区的地址空间是禁止进入的,因此就会发生内存访问违规现象,同时该进程将终止运行。这个特性有助于编程员发现应用程序中的错误。
13.2.2 MS-DOS/16位Windows应用程序兼容分区—仅适用于Windows 98

进程地址空间的这个4 M B分区是Windows 98需要的,目的是维护M S - D O S应用程序与1 6位应用程序之间的兼容性。不应该试图从3 2位应用程序来读取该分区的数据,或者将数据写入该分区。在理想的情况下,如果进程中的线程访问该内存, C P U应该产生一个访问违规,但是由于技术上的原因, M i c r o s o f t无法保护这个4 M B的地址空间。

在Windows 2000中,1 6位M S - D O S与1 6位Wi n d o w s应用程序是在它们自己的地址空间中运行的,3 2位应用程序不会对它们产生任何影响。

13.2.3 用户方式分区—适用于Windows 2000和Windows 98

这个分区是进程的私有(非共享)地址空间所在的地方。一个进程不能读取、写入、或者以任何方式访问驻留在该分区中的另一个进程的数据。对于所有应用程序来说,该分区是维护进程的大部分数据的地方。由于每个进程可以得到它自己的私有的、非共享分区,以便存放它的数据,因此,应用程序不太可能被其他应用程序所破坏,这使得整个系统更加健壮。

Windows 2000 在Windows 2000中,所有的. e x e和D L L模块均加载这个分区。每个进程可以将这些D L L加载到该分区的不同地址中(不过这种可能性很小)。系统还可以在这个分区中映射该进程可以访问的所有内存映射文件。

在Windows 98中,主要的Wi n 3 2系统D L L(K e r n e l 3 2 . d l l,A d v A P I 3 2 . d l l,U s e r 3 2 . d l l和G D I 3 2 . d l l)均加载共享内存映射文件分区中。. e x e和所有其他D L L模块则加载到这个用户方式分区中。所有进程的共享D L L均位于相同的虚拟地址中,但是其他D L L可以将这些D L L加载到用户方式分区的不同地址中(不过这种可能性不大)。另外,在Windows 98中,用户方式分区中决不会出现内存映射文件。

当我最初观察3 2位进程的地址空间的时候,我惊奇地发现可以使用的地址空间还不到我的进程的全部地址空间的一半。难道内核方式分区真的需要上面的一半地址空间吗?实际上回答是肯定的。系统需要这个地址空间,供内核代码、设备驱动程序代码、设备I / O高速缓存、非页面内存池的分配和进程页面表等使用。实际上M i c r o s o f t将内核压缩到这个2 G B空间之中。在6 4位Windows 2000中,内核终于得到了它真正需要的空间。

1. 在x86 的Wi n d o w s 2 0 0 0中获得3 G B用户方式分区

多年来,编程人员一直强烈要求扩大用户方式的地址空间。为了满足这个需要, M i c r o s o f t允许x 8 6的Windows 2000 Advanced Server版本和Windows 2000 Data Center版本将用户方式分区扩大为3 G B。若要使所有进程都能够使用3 G B用户方式分区和1 G B内核方式分区,必须将/ 3 G B开关附加到系统的B O O T. I N I文件的有关项目中。表1 3 - 1中的“3 2位Windows 2000(x 8 6w / 3 G B用户方式)”这一列显示了使用3 G B开关时它的地址空间是个什么样子。

在M i c r o s o f t添加/ 3 G B开关之前,应用程序无法看到设置了高位的内存指针。一些有创意的编程员自己将这个高位用作一个标志,这个标志只对他们的应用程序具有意义。这时,当应用程序访问内存地址时,运行的代码将在内存地址被使用之前清除该指针的高位。可以想象,当应用程序在3 G B的用户方式环境中运行时,该应用程序转眼之间就会运行失败。

M i c r o s o f t不得不提出一个解决方案,以便使该应用程序能够在3 G B环境中运行。当系统准备运行一个应用程序时,它要查看该应用程序是否与/ L A R G E A D D R E S S AWA R E链接程序开关相链接。如果是链接的,那么应用程序就声称它并没有对内存地址执行什么特殊的操作,并且完全准备充分利用3GB 用户方式地址空间。另一方面,如果该应用程序没有与/ L A R G E A D D R E S S AWA R E开关相链接,那么操作系统将保留0 x 8 0 0 0 0 0 0 0至0 x B F F F F F F F之间的1 G B区域。这可以防止在已经设置了高位的内存地址上进行内存分配。

注意,内核已经被紧紧地压缩到了一个2 G B的分区中。当使用3 G B的开关时,内核勉强地被放入一个1 GB的分区中。使用/ 3 G B的开关,可以减少系统能够创建的线程、堆栈和其他资源的数量。此外,系统最多只能使用1 6 G B的R A M,而通常情况下最多可以使用6 4 G B的R A M,因为内核方式中没有足够的虚拟地址空间可以用来管理更多的R A M。

注意当操作系统创建进程的地址空间时,需要检查一个可执行的L A R G E A D D R ES S AWA R E标志。对于D L L,系统则忽略该标志。在编写D L L时,必须使之能够在3G B用户方式分区中正确地运行,否则它们的行为特性是无法确定的。

2. 在6 4位Windows 2000中获得2 GB用户方式分区

M i c r o s o f t发现许多编程人员需要尽可能迅速而方便地将现有的3 2位应用程序移植到6 4位环境中去。但是,在许多源代码中,指针被视为3 2位值。如果简单地重新编写应用程序,就会造成指针被截断的错误和不正确的内存访问。

然而,如果系统能够确保不对0 x 0 0 0 0 0 0 0 0 7 F F F F F F F以上的内存地址进行分配,那么应用程序就能很好地运行。当较高的3 3位是0时,将6 4位地址截断为3 2位地址,不会产生任何问题。通过在地址空间范围内运行应用程序,而这个地址空间范围将进程的可用地址空间限制为最低的G B,那么系统就能够确保这一点。

默认情况下,当启动一个6 4位应用程序时,系统将保留从0 x 0 0 0 0 0 0 0 8 0 0 0 0 0 0 0开始的所有用户地址空间。这可以确保在底部的2GB 64位地址空间中进行所有的内存分配。这就是地址空间的范围。对于大多数应用程序来说,这个地址空间足够了。若要使6 4位应用程序能够访问它的全部4 TB(terabyte)用户方式分区,该应用程序必须使用/ L A R G E A D D R E S S AWARE 链接开关来创建。

注意当操作系统创建进程的6 4位地址空间时,要检查一个可执行文件的L A R G E A D D R E S S AWA R E标志。如果是D L L,那么系统将忽略该标志。编写D L L时,必须使之能够在整个4 TB用户方式分区中正确地运行,否则它们的行为特性将无法确定。

13.2.4 64KB禁止进入的分区—仅适用于Windows 2000

这个位于用户方式分区上面的64 KB分区是禁止进入的,访问该分区中的内存的任何企图均将导致访问违规。M i c r o s o f t之所以保留该分区,是因为这样做将使得M i c r o s o f t能够更加容易地实现操作系统。当将内存块的地址和它的长度传递给Wi n d o w s函数时,该函数将在执行它的操作之前使内存块生效。可以很容易创建类似下面这个代码(在3 2位Windows 2000系统上运行):


BYTE  bBuf[70000];
DWORD dwNumBytesWritten;
WriteProcessMemory(GetCurrentProcess(), (PVOID)0x7FFEEE90, bBuf,
   sizeof(bBuf), &dwNumBytesWritten);
对于Wr i t e P r o c e s s M e m o r y这样的函数来说,写入的内存区是由内核方式代码来使之生效的,该代码能够成功地访问内核方式分区中的内存( 3 2位系统上0 x 8 0 0 0 0 0 0 0以上的地址)。如果在0 x 8 0 0 0 0 0 0 0地址上存在内存,上面的函数调用就能成功地将数据写入只应该由内核方式代码访问的内存。为了防止出现这种情况,并使这个内存区迅速生效, M i c r o s o f t选择的办法是使该分区始终保持禁止进入的状态。只要试图读取或写入该分区中的内存,就一定会导致访问违规。
13.2.5 共享的MMF分区—仅适用于Windows 98

这个1 G B分区是系统用来存放所有3 2位进程共享数据的地方。例如,系统的动态链接库K e r n e l 3 2 . d l l、A d v A P I 3 2 . d l l、U s e r 3 2 . d l l和G D I 3 2 . d l l等,全部存放在这个地址空间分区中,因此,所有3 2位进程都能很容易同时访问它们。系统还为每个进程将D L L加载相同的内存地址。此外,系统将所有内存映射文件映射到这个分区中。内存映射文件将在第1 7章中详细介绍。

13.2.6 内核方式分区—适用于Windows 2000和Windows 98

这个分区是存放操作系统代码的地方。用于线程调度、内存管理、文件系统支持、网络支持和所有设备驱动程序的代码全部在这个分区加载。驻留在这个分区中的一切均可被所有进程共享。在Windows 2000中,这些组件是完全受到保护的。如果你试图访问该分区中的内存地址,你的线程将会产生访问违规,导致系统向用户显示一个消息框,并关闭你的应用程序。关于访问违规和如何处理这些违规的详细说明,请参见第2 3、2 4和2 5章的内容。

Windows 2000 在6 4位Windows 2000中,4 TB用户方式分区看上去与16, 777, 212TB 的内核方式分区非常不成比例。并不是内核方式分区需要使用该虚拟地址空间的全部空间,它只是说明6 4位地址空间是非常大的,而该地址空间的大部分是不用的。系统允许应用程序使用4 TB分区,并且允许内核使用它需要的东西,而内核方式分区的大部分是不用的。幸好系统并不需要任何内部数据结构来维护内核方式分区的不用部分。

Windows 98 不幸的是,在Windows 98中该分区中的数据是不受保护的。任何应用程序都可以从该分区读取数据,也可以写入数据,因此有可能破坏操作系统。


13.3 地址空间中的区域

当进程被创建并被赋予它的地址空间时,该可用地址空间的主体是空闲的,即未分配的。若要使用该地址空间的各个部分,必须通过调用Vi r t u a l A l l o c函数(第1 5章介绍)来分配它里边的各个区域。对一个地址空间的区域进行分配的操作称为保留( r e s e r v i n g )。

每当你保留地址空间的一个区域时,系统要确保该区域从一个分配粒度的边界开始。对于不同的C P U平台来说,分配粒度是各不相同的。但是,截止到撰写本书时,所有的C P U平台(x 8 6、3 2位A l p h a、6 4位A l p h a和I A - 6 4)都使用6 4 K B这个相同的分配粒度。

当你保留地址空间的一个区域时,系统还要确保该区域的大小是系统的页面大小的倍数。页面是系统在管理内存时使用的一个内存单位。与分配粒度一样,不同的C P U,其页面大小也是不同的。x 8 6使用的页面大小是4 KB,而A l p h a(当既能运行3 2位Windows 2000也能运行6 4位Windows 2000时)使用的页面大小则是8 KB。在撰写本书时, M i c r o s o f t预计I A - 6 4也使用8K B的页面。但是,如果测试显示使用更大的页面能够提高系统的总体性能,那么M i c r o s o f t可以切换到更大的页面(1 6 K B或更大)。

注意有时系统能够代表你的进程来保留地址空间的区域。例如,系统可以分配一个地址空间区域,以便存放进程环境块( F E B)。F E B是由系统创建、操作和撤消的一个小型数据结构。当创建一个进程时,系统就为F E B分配一个地址空间区域。

系统也需要创建一个线程环境块( T E B),以便管理进程中当前存在的所有线程。用于这些T E B的区域将根据进程中的线程被创建和撤消等情况而保留和释放。

虽然系统规定,要求保留的地址空间区域均从分配粒度边界(目前所有平台上均为6 4 K B)开始,但是系统本身并不受这个规定的限制。为你的进程的P E B和T E B保留的地址空间区域很可能不是从64 KB这个边界开始的。不过这些保留区域仍然必须是C P U的页面大小的倍数。

如果想保留一个10 KB的地址空间区域,系统将自动对你的请求进行四舍五入,使保留的地址空间区域的大小是页面大小的倍数。这意味着,在x 8 6平台上,系统将保留一个1 2 K B的区域,在A l p h a平台上,系统将保留一个1 6 K B的区域。

当你的程序算法不再需要访问已经保留的地址空间区域时,该区域应该被释放。这个过程称为释放地址空间的区域,它是通过调用Vi r t u a l F r e e函数来完成的。


13.4 提交地址空间区域中的物理存储器

若要使用已保留的地址空间区域,必须分配物理存储器,然后将该物理存储器映射到已保留的地址空间区域。这个过程称为提交物理存储器。物理存储器总是以页面的形式来提交的。若要将物理存储器提交给一个已保留的地址空间区域,也要调用Vi r t u a l A l l o c函数。


图13-1 不同的CPU使用的示例进程地址空间

当将物理存储器提交给地址空间区域时,不必将物理存储器提交给整个区域。例如,可以保留一个6 4 K B的区域,然后将物理存储器提交给该区域中的第二和第四个页面。图1 3 - 1显示了进程的地址空间是个什么样子。注意,根据运行的C P U平台的不同,地址空间是各有差别的。左边的地址空间显示了x 8 6计算机(它的页面大小是4 KB)上的情况,而右边的地址空间则显示了A l p h a计算机(它的页面大小是8K B)上发生的情况。

当你的程序算法不再需要访问保留的地址空间区域中已提交的物理存储器时,该物理存储器应该被释放。这个过程称为回收物理存储器,它是通过Vi r t u a l F r e e函数来完成的。


13.5 物理存储器与页文件

在较老的操作系统中,物理存储器被视为计算机拥有的R A M的容量。换句话说,如果计算机拥有1 6 M B的R A M,那么加载和运行的应用程序最多可以使用1 6 M B的R A M。今天的操作系统能够使得磁盘空间看上去就像内存一样。磁盘上的文件通常称为页文件,它包含了可供所有进程使用的虚拟内存。

当然,若要使虚拟内存能够运行,需要得到C P U本身的大量帮助。当一个线程试图访问一个字节的内存时, C P U必须知道这个字节是在R A M中还是在磁盘上。

从应用程序的角度来看,页文件透明地增加了应用程序能够使用的R A M(即内存)的数量。如果计算机拥有6 4 M B的R A M,同时在硬盘上有一个100 MB的页文件,那么运行的应用程序就认为计算机总共拥有1 6 4 M B的R A M。

当然,实际上并不拥有1 6 4 M B的R A M。相反,操作系统与C P U相协调,共同将R A M的各个部分保存到页文件中,当运行的应用程序需要时,再将页文件的各个部分重新加载到R A M。由于页文件增加了应用程序可以使用的R A M的容量,因此页文件的使用是视情况而定的。如果没有页文件,那么系统就认为只有较少的R A M可供应用程序使用。但是,我们鼓励用户使用页文件,这样他们就能够运行更多的应用程序,并且这些应用程序能够对更大的数据集进行操作。最好将物理存储器视为存储在磁盘驱动器(通常是硬盘驱动器)上的页文件中的数据。这样,当一个应用程序通过调用Vi r t u a l A l l o c函数,将物理存储器提交给地址空间的一个区域时,地址空间实际上是从硬盘上的一个文件中进行分配的。系统的页文件的大小是确定有多少物理存储器可供应用程序使用时应该考虑的最重要的因素, R A M的容量则影响非常小。

现在,当你的进程中的一个线程试图访问进程的地址空间中的一个数据块时,将会发生两种情况之一,参见图1 3 - 2中的流程图。

在第一种情况中,线程试图访问的数据是在R A M中。在这种情况下, C P U将数据的虚拟内存地址映射到内存的物理地址中,然后执行需要的访问。

在第二种情况中,线程试图访问的数据不在R A M中,而是存放在页文件中的某个地方。这时,试图访问就称为页面失效, C P U将把试图进行的访问通知操作系统。这时操作系统就寻找R A M中的一个内存空页。如果找不到空页,系统必须释放一个空页。如果一个页面尚未被修改,系统就可以释放该页面。但是,如果系统需要释放一个已经修改的页面,那么它必须首先将该页面从R A M拷贝到页交换文件中,然后系统进入该页文件,找出需要访问的数据块,并将数据加载到空闲的内存页面。然后,操作系统更新它的用于指明数据的虚拟内存地址现在已经映射到R A M中的相应的物理存储器地址中的表。这时C P U重新运行生成初始页面失效的指令,但是这次C P U能够将虚拟内存地址映射到一个物理R A M地址,并访问该数据块。

系统需要将内存页面拷贝到页文件并反过来将页文件拷贝到内存页面的次数越多,你的硬盘倒腾的次数就越多,系统运行得越慢(倒腾意味着操作系统要花费更多的时间将页面从内存中转出转进,而不是将时间用于程序的运行)。因此,通过给你的计算机增加更多的R A M,就可以减少运行应用程序所需的倒腾次数,这就必然可以大大提高系统的运行速度。所以必须遵循一条基本原则,那就是要让你的计算机运行得更块,增加更多的R A M。实际上,在大多数情况下,若要提高系统的运行性能,增加R A M比提高C P U的速度所产生的效果更好。

不在页文件中维护的物理存储器

当阅读了上一节后,你必定会认为,如果同时运行许多文件的话,页文件就可能变得非常大,而且你会认为,每当你运行一个程序时,系统必须为进程的代码和数据保留地址空间的一些区域,将物理存储器提交给这些区域,然后将代码和数据从硬盘上的程序文件拷贝到页文件中已提交的物理存储器中。

实际上系统并不进行上面所说的这些操作。如果它进行这些操作的话,就要花费很长的时间来加载程序并启动它运行。相反,当启动一个应用程序的时候,系统将打开该应用程序的. e x e文件,确定该应用程序的代码和数据的大小。然后系统要保留一个地址空间的区域,并指明与该区域相关联的物理存储器是在. e x e文件本身中。即系统并不是从页文件中分配地址空间,而是将. e x e文件的实际内容即映像用作程序的保留地址空间区域。当然,这使应用程序的加载非常迅速,并使页文件能够保持得非常小。


图13-2 将虚拟地址转换成物理存储器地址的流程图

当硬盘上的一个程序的文件映像(这是个. e x e文件或D L L文件)用作地址空间的区域的物理存储器时,它称为内存映射文件。当一个. e x e文件或D L L文件被加载时,系统将自动保留一个地址空间的区域,并将该文件映像映射到该区域中。但是,系统也提供了一组函数,使你能够将数据文件映射到一个地址空间的区域中。关于内存映射文件的详细说明,将在第1 7章中介绍。

Windows 2000能够使用多个页文件。如果多个页文件存在于不同的物理硬盘驱动器上,系统的运行将能得快得多,因为它能够将数据同时写入多个驱动器。打开System Properties Control Panel (系统属性控制面板)小程序,再选择A d v a n c e d选项卡,单击Performance Options(性能选项)按钮,就能够添加或删除页文件。图1 3 - 3显示了该对话框的形式。



图13-3 Virtual Memory 对话框

注意当. e x e或D L L文件从软盘加载时,Windows 98和Windows 2000都能将整个文件从软盘拷贝到系统的R A M中。此外,系统将从页文件中分配足够的内存,以便存放该文件的映像。如果系统选择对当前包含该文件的一部分映像的R A M页面进行裁剪,那么该内存属于只能写入的内存。如果系统R A M上的负载比较小,那么文件始终都可以直接从R A M来运行。

M i c r o s o f t不得不通过软盘来运行的映射文件,这样,安装应用程序才能正确运行。安装程序常常从一个软盘开始,然后用户将软盘从驱动器中取出来,再插入另一个软盘。如果系统需要回到第一个软盘,以便加载. e x e或D L L文件的某些代码,当然该代码已经不再在软盘驱动器中了。然而,由于系统将文件拷贝到R A M(并且受页文件的支持),要访问安装程序是不会有任何问题的。

系统并不将R A M映射文件拷贝在其他可换式介质上,如光盘或网络驱动器,除非映射文件是用/ S WA P R U N:C D或/ S WA P R U N:N E T开关链接的。注意, Windows 98不支持/ S WA P R U N映像标志。


13.6 保护属性

已经分配的物理存储器的各个页面可以被赋予不同的保护属性。表1 3 - 2显示了这些保护属性。

x 8 6和Alpha CPU不支持“执行”保护属性,不过操作系统软件却支持这个属性。这些C P U将读访问视为执行访问。这意味着如果将PA G E _ E X E C U T E保护属性赋予内存,那么该内存也将拥有读优先权。当然,不应该依赖这个行为特性,因为在其他C P U上的Wi n d o w s实现代码很可能将“执行”保护视为“仅为执行”保护。


表13-2 页面的保护属性



Windows 98只支持PA G E _ N O A C C E S S、PA G E _ R E A D O N LY和PA G E _R E A D W R I T E等保护属性。

13.6.1 Copy-On-Write访问

表1 3 - 2列出的保护属性都是非常容易理解的,不过最后两个属性需要作一些说明。一个是PA G E _ W R I T E C O P Y,另一个是PA G E _ E X E C U T E _ W R I T E C O P Y。这两个属性的作用是为了节省R A M的使用量和页文件的空间。Wi n d o w s支持一种机制,使得两个或多个进程能够共享单个内存块。因此,如果1 0个N o t e p a d实例正在运行,那么所有实例可以共享应用程序的代码和数据页面。让所有实例共享同样的内存页面将能够大大提高系统的性能,但是这要求所有实例都将该内存视为只读或只执行的内存。如果一个实例中的线程将数据写入内存修改它,那么其他实例看到的这个内存也将被修改,从而造成一片混乱。

为了防止出现这种混乱,操作系统给共享内存块赋予了C o p y - O n - Wr i t e保护属性。当一个. e x e或D L L模块被映射到一个内存地址时,系统将计算有多少页面是可以写入的(通常包含代码的页面标为PA G E _ E X E C U T E _ R E A D,而包含数据的页面则标为PA G E _ R E A D W R I T E)。然后,系统从页文件中分配内存,以适应这些可写入的页面的需要。除非该模块的可写入页面是实际的写入模块,否则这些页文件内存是不使用的。

当一个进程中的线程试图将数据写入一个共享内存块时,系统就会进行干预,并执行下列操作步骤:

1) 系统查找R A M中的一个空闲内存页面。注意,当该模块初次被映射到进程的地址空间时,该空闲页面将被页文件中已分配的页面之一所映射。当该模块初次被映射时,由于系统要分配所有可能需要的页文件,因此这一步不可能运行失败。

2) 系统将试图被修改的页面内容拷贝到第一步中找到的页面。该空闲页面将被赋予PA G E _ R E A D W R I T E或PA G E _ E X E C U T E _ R E A D W R I T E保护属性。原始页面的保护属性和数据不发生任何变化。

3) 然后系统更新进程的页面表,使得被访问的虚拟地址被转换成新的R A M页面。

当系统执行了这3个操作步骤之后,该进程就可以访问它自己的内存页面的私有实例。第1 7章还要详细地介绍共享内存和C o p y - O n - Wr i t e保护属性。

此外,当使用Vi r t u a l A l l o c函数来保留地址空间或者提交物理存储器时,不应该传递PA G E _ W R I T E C O P Y或PA G E _ E X E C U T E _ W R I T E C O P Y。如果传递的话,将会导致Vi r t u a l A l l o c调用的失败。对G e t L a s t E r r o r的调用将返回E R R O R _ I N VA L I D _ PA R A M E T E R。当操作系统映射. e x e或D L L文件映像时,这两个属性将被操作系统使用。

Windows 98不支持C o p y - O n - Wr i t e保护。当Windows 98发现需要C o p y _ O n _ Wr i t e保护时,它就立即进行数据的拷贝,而不是等待试图对内存进行写入操作。

13.6.2 特殊的访问保护属性的标志

除了上面介绍的保护属性外,还有3个保护属性标志,即PA G E _ N O C A C H E,PA G E _W R I T E C O M B I N E和PA G E _ G U A R D。可以用O R逐位将它们连接,以便将这3个标志用于任何一个保护属性(PA G E _ N O C A C H E除外)。

第一个保护属性标志PA G E _ N O C A C H E用于停用已提交页面的高速缓存。一般情况下最好不要使用该标志,因为它主要是供需要处理内存缓冲区的硬件设备驱动程序的开发人员使用的。

第二个保护属性PA G E _ W R I T E C O M B I N E也是供设备驱动程序开发人员使用的。它允许把单个设备的多次写入合并在一起,以便提高运行性能。

最后一个保护属性标志PA G E _ G U A R D可以在页面上写入一个字节时使应用程序收到一个通知(通过一个异常条件)。该标志有一些非常巧妙的用法。Windows 2000在创建线程堆栈时使用该标志。关于该标志的详细说明,参见第1 6章。

Windows 98将忽略PA G E _ N O C A C H E、PA G E _ W R I T E C O M B I N E和PA G E _ G U A R D这3个保护属性标志。


13.7 综合使用所有的元素

本节要将地址空间、分区、区域、内存块和页面等元素综合起来加以使用。为了更好地说明问题,我们首先来看一看虚拟内存表,它可以显示单个进程中的所有地址空间的区域。该进程正好是第1 4章中介绍的V M M a p示例应用程序。为了全面了解进程的地址空间,首先要介绍一下当在3 2位x 8 6计算机上的Windows 2000下运行V M M a p时,地址空间是个什么样子。表1 3 - 3显示了一个示例地址空间表。后面将要介绍Windows 2000与Windows 98的地址空间之间的差别。

表1 3 - 3中的地址空间表显示了进程的地址空间中的各个不同区域。每行显示一个区域,每行包含6列。

第一列,即最左边的一列显示了区域的基地址。你会发现我们是从地址为0 x 0 0 0 0 0 0 0 0的区域开始观察进程的地址空间的,并在可用地址空间的最后一个区域结束,该区域的起始地址是0 x 7 F F E 0 0 0 0。所有区域都是相邻的。你也会注意到,非空闲区域的所有基地址几乎都是从6 4 K B的倍数上开始的。这是由系统采用的保留地址空间的分配粒度所决定的。不是从分配粒度边界开始的区域,表示该区域是由操作系统代码代表你的进程来分配的。


表13-3 显示了3 2位x 8 6计算机上运行的Windows 2000下的地址空间区域的示例地址空间表


二列显示了区域的类型。区域类型共有4个值,即空闲,私有,映像或映射。表1 3 - 4对它们进行了介绍。


我的V M M a p应用程序计算这一列的方法可能产生错误的结果。当地址空间区域不空闲时,V N N a p示例应用程序就要猜测剩余的3个值中哪一个可以使用。没有一个函数可以调用,以便确定该区域的准确用途。这一列的值的方法是,对区域中的所有内存块进行扫描,然后进行合乎逻辑的推测。可以参考第1 4章中的代码,以便更好地了解计算方法。

第三列显示了为该区域保留的字节数量。例如,系统将U s e r. D L L的映像映射到内存地址0 x 7 7 E 2 0 0 0 0。当系统为该映像保留地址空间时,它必须保留401 408个字节。第三列中的数字总是C P U的页面大小的倍数(x 8 6 C P U为4 0 9 6字节)。

第四列显示了保留区域中的块的数量。所谓块是指一组相邻的页面,它们拥有相同的保护属性,并且都是受相同类型的物理存储器支持的。下一节将要详细介绍这个问题。对于空闲区域来说,这个值始终都是0,因为在空闲区域中不能提交任何内存(在第四列中空闲区域不显示任何信息)。对于非空闲区域来说,这个值可以是1到区域大小/页面大小的最大数字之间的任何值。例如,从内存地址0 x 7 7 E 2 0 0 0 0开始的区域,它的区域大小是401 408个字节。由于该进程是在x86 CPU上运行的(x86 CPU的页面大小是4 0 9 6个字节),因此提交的各种不同的块的最大数量是9 8(401 408/4096)。表中显示该区域中的块的数量是4。

第五列显示了区域的保护属性。各个字母所代表的含义是: E =执行,R =读取,W =写入,C =写入时拷贝。如果一个区域没有显示任何保护属性,那么该区域就没有访问保护。空闲区域没有显示任何保护属性,因为未保留区域不拥有与其相关联的保护属性。这里决不会出现G U A R D保护属性标志或N O - C A C H E保护属性标志。只有当这些标志与物理存储器相关联,而不是与保留的地址空间相关联时,它们才具有意义。给一个区域赋予保护属性的目的只是为了提高效率,并且总是会被赋予物理存储器的保护属性所取代。

第六列即最后一列显示了区域中的信息的文字描述。如果是空闲区域,那么这一列总是空的。如果是私有区域,它通常也是空的,因为V M M a p没有办法知道应用程序为什么要保留这个私有地址空间区域。但是, V M M a p能够识别包含线程堆栈的私有区域。V M M a p通常能够发现线程堆栈,因为它们通常拥有一个具有G U A R D保护属性的物理存储器块。不过,当线程堆栈满了的时候,它就不再拥有一个保护属性为G U A R D的物理存储器块,同时V M M a p将无法发现它。

对于映像区域来说, V M M a p将显示映射到该区域中的文件的全路径名。使用To o l H e l p函数, V M M a p就可以获得该信息。在Windows 2000中,通过调用G e t M a p p e d F i l e N a m e函数(Windows 98中没有这个函数),V M M a p就能够显示受数据文件支持的区域。

13.7.1 区域的内部情况

我们还可以将区域划分得比表1 3 - 3显示的情况更细一些。表1 3 - 5显示的地址空间与表1 3 - 3所示的地址空间相同,但是它也显示了每个区域中包含的内存块。


表13-5 显示了3 2位x 8 6计算机上的Windows 2000下的内存区域和块的示例地址空间表








当然,空闲区域根本不会扩展,因为它们里面没有已经提交的内存页面。每个内存块的行显示4列,下面介绍它们的具体情况。

第一列显示一组页面的地址,这些页面具有相同的状态和保护属性。例如,具有只读保护属性的内存的单个页面(4 0 9 6字节)被提交的地址是0 x 7 7 E 2 0 0 0 0。在地址0 x 7 7 E 2 1 0 0 0上,有一个8 5页(348 160字节)的已提交内存块,它具有执行和读保护特性。如果这两个内存块具有相同的保护属性,那么它们就被组合起来,在内存表中显示为一个8 6个页面(352 256字节)的项目。

第二列显示的是何种类型的物理存储器支持保留区域中的内存块。这一列中可以出现5个值中的一个。这5个值是空闲,私有,映射,映像和保留。如果这个值是私有、映射或映像,则表示内存块是分别受页文件、数据文件或加载的. e x e或D L L文件中的物理存储器支持的。如果这个值是空闲或保留,那么该内存块根本没有任何物理存储器的支持。

大多数情况下,相同类型的物理存储器支持单个区域中的所有提交的内存块。但是单个区域中不同的已提交内存块可以受不同类型的物理存储器的支持。例如,内存映射的文件映像可以受. e x e 或D L L 文件的支持。如果要在该区域中写入拥有PA G E _ W R I T E C O P Y 或PA G E _ E X E C U T E _ W R I T E C O P Y保护属性的单个页面,那么系统就会使你的进程成为一个由页文件而不是文件映像支持的私有页面拷贝。这个新页面拥有的属性将与没有c o p y _ o n _ w r i t e保护属性的原始页面相同。

第三列显示了地址空间块的大小。一个区域中的所有地址空间块都是相邻的,块与块之间没有任何空隙。

第四列显示保留区域中的块的数量。

第五列显示块的保护属性和保护属性标志。块的保护属性优先于包含该块的区域的保护属性。块使用的保护属性与区域的保护属性相同,但是,与区域不关联的保护属性标志PA G E _ G U A R D、PA G E _ N O C A C H E和PA G E _ W R I T E C O M B I N E可以与块相关联。

13.7.2 与Windows 98地址空间的差别

表1 3 - 6显示了在Windows 98下运行相同的V M M A P程序时的地址空间表。为了节省篇幅,我们没有显示0 x 8 0 0 1 8 0 0 0至0 x 8 5 6 2 0 0 0 0之间的虚拟地址。


表13-6 显示Windows 98 下地址空间区域内块的示例地址空间表







两个地址空间表的最大不同是在Windows 98下缺少了某些的信息。例如,每个区域和块能反映出地址空间的区域是空闲、保留还是私有的。你决不会看到映射或者映像之类的字样,因为Windows 98没有提供更多的信息来指明支持该区域的物理存储器的是个内存映射文件还是包含在. e x e或D L L中的文件映像。

你会发现大多数地址空间区域的大小是分配粒度( 6 4 K B)的倍数。如果包含在地址空间区域中的块的大小不是分配粒度的倍数,那么在地址空间区域的结尾处常常有一个保留的地址空间块。这个地址空间块的大小必须使得地址空间区域能够符合分配粒度边界( 6 4 K B)倍数的要求。例如,从地址0 x 0 0 5 3 0 0 0 0开始的地址空间区域包含两个地址块,一个是4 KB的已提交内存块,另一个是占用60 KB内存地址范围的已保留的地址块。

最后,保护标志从来不反映执行或c o p y - o n - w r i t e访问权,因为Windows 98不支持这些标志。它也不支持3个保护属性标志,即PA G E _ N O C A C H E 、PA G E _ W R I T E C O M B I N E和PA G E _ G U A R D。由于不支持PA G E _ G U A R D标志,因此V M M a p使用更加复杂的技术来确定是否已经为线程的堆栈保留了地址空间区域。

你将注意到,与Windows 2000不同,在Windows 98中,0 x 8 0 0 0 0 0 0 0至0 x B F F F F F F F之间的地址空间区域是可以查看的。这个分区包含了所有3 2位应用程序共享的地址空间。如你所见,有4个系统D L L被加载了这个地址空间区域,可以供所有进程使用。


13.8 数据对齐的重要性

本节不再讨论进程的虚拟地址空间问题,而是要介绍数据对齐的重要性。数据对齐并不是操作系统的内存结构的一部分,而是C P U结构的一部分。

当C P U访问正确对齐的数据时,它的运行效率最高。当数据大小的数据模数的内存地址是0时,数据是对齐的。例如, W O R D值应该总是从被2除尽的地址开始,而D W O R D值应该总是从被4除尽的地址开始,如此等等。当C P U试图读取的数据值没有正确对齐时, C P U可以执行两种操作之一。即它可以产生一个异常条件,也可以执行多次对齐的内存访问,以便读取完整的未对齐数据值。

下面是访问未对齐数据的某个代码:


VOID SomeFunc(PVOID pvDataBuffer)
{
   //The first byte in the buffer is some byte of information
   char c = *(PBYTE) pvDataBuffer;

   //Increment past the first byte in the buffer
   pvDataBuffer =(PVOID)((PBYTE) pvDataBuffer + 1);

   //Bytes 2-5 contain a double-word value
   DWORD dw = *(DWORD *) pvDataBuffer;

   //The line above raises a data misalignment exception on the Alpha
   ...
}
显然,如果C P U执行多次内存访问,应用程序的运行速度就会放慢。在最好的情况下,系统访问未对齐的数据所需要的时间将是访问对齐数据的时间的两倍,不过在有些情况下,访问时间可能更长。为了使应用程序获得最佳的运行性能,编写的代码必须使数据正确地对齐。
下面让我们更加深入地说明x86 CPU是如何进行数据对齐的。X86 CPU的E F L A G S寄存器中包含一个特殊的位标志,称为A C(对齐检查的英文缩写)标志。按照默认设置,当C P U首次加电时,该标志被设置为0。当该标志是0时,C P U能够自动执行它应该执行的操作,以便成功地访问未对齐的数据值。然而,如果该标志被设置为1,每当系统试图访问未对齐的数据时,C P U就会发出一个INT 17H中断。x 8 6的Windows 2000和Windows 98版本从来不改变这个C P U标志位。因此,当应用程序在x 8 6处理器上运行时,你根本看不到应用程序中出现数据未对齐的异常条件。

现在让我们来看一看Alpha CPU的情况。Alpha CPU不能自动处理对未对齐数据的访问。当未对齐的数据访问发生时,C P U就会将这一情况通知操作系统。这时, Windows 2000将会确定它是否应该引发一个数据未对齐异常条件。它也可以执行一些辅助指令,对问题默默地加以纠正,并让你的代码继续运行。按照默认设置,当在A l p h a计算机上安装Windows 2000时,操作系统会对未对齐数据的访问默默地进行纠正。然而,可以改变这个行为特性。当引导Windows 2000时,系统就会在注册表中查找的这个关键字:


HKEY_LOCAL_MACHINE/CurrentControlSet/Control/Session Manager
在这个关键字中,可能存在一个值,称为E n a b l e A l i g n m e n t F a u l t E x c e p t i o n s。如果这个值不存在(这是通常的情况),Windows 2000会默默地处理对未对齐数据的访问。如果存在这个值,系统就能获取它的相关数据值。如果数据值是0,系统会默默地进行访问的处理。如果数据值是1,系统将不执行默默的处理,而是引发一个未对齐异常条件。几乎从来都不需要修改该注册表值的数据值,因为如果修改有些应用程序能够引发数据未对齐的异常条件并终止运行。
为了更加容易地修改该注册表项。A l p h a处理器上运行的Microsoft Visual C++版本包含了一个小型实用程序A X PA l i g n . e x e。A X PA l i g n的用法如下面所示:


Alpha AXP alignment fault exception control

Usage: axpalign [option]

Options:
   /enable   to enable alignment fault exceptions.
   /disable  to disable alignment fault exceptions.
   /show     to display the current alignment exception setting.

Enable alignment fault exceptions:
   In this mode any aligned access to unaligned data will result in a
   data misalignment exception and no automatic operating system fixups
   will occur. The application may be terminated or a debugger can be
   used to locate the source of alignment faults in your code.

   This setting applies to all running processes and thus care should
   be taken since older applications may get exceptions and terminate.

   Note that SetErrorMode(SEM_NOALIGNMENTFAULTEXCEPT) can be used to
   suppress alignment exceptions even in this mode.

Disable alignment fault exceptions:
   This is the default mode with Windows NT for Alpha AXP, versions 3.1
   and 3.5. In this mode the operating system will fixup any misaligned
   data accesses should they occur and applications or debuggers will
   not see them. This may lead to performance degradation if it occurs
   at a high rate. Perfmon or wperf may be used to monitor the rate.
该实用程序只是修改注册表值的状态,或者显示值的当前状态。当用该实用程序修改数据值后,必须重新引导操作系统,使所做的修改生效。
如果不使用A X PA l i g n实用程序,仍然可以让系统为进程中的所有线程默默地纠正对未对齐数据的访问,方法是让进程的线程调用S e t E r r o r M o d e函数:


UINT SetErrorMode(UINT fuErrorMode);
就我们的讨论来说,需要说明的标志是S E M _ N O A L I G N M E N T FA U LT E X C E P T标志。当该标志设定后,系统会将自动纠正对未对齐数据的访问。当该标志重新设置时,系统将不纠正对未对齐数据的访问,而是引发数据未对齐异常条件。注意,修改该标志将会影响拥有调用该函数的线程的进程中包含的所有线程。换句话说,改变该标志不会影响其他进程中的任何线程。还要注意,进程的错误方式标志是由所有的子进程继承的。因此,在调用C r e a t e P r o c e s s函数之前,必须临时重置该标志(不过通常不必这样做)。
当然,无论在哪个C P U 平台上运行,都可以调用S e t E r r o r M o d e函数,传递S E M _N O A L I G N M E N T FA U LT E X C E P T标志。但是,结果并不总是相同。如果是x 8 6系统,该标志总是打开的,并且不能被关闭。如果是A l p h a系统,那么只有当EnableAlignmentFault Exceptions注册表值被设置为1时,才能关闭该标志。

可以使用Windows 2000的MMC Performance Monitor来查看每秒钟系统执行多少次数据对齐的调整修改。图1 3 - 4显示了在将该计数器添加给图表之前, Add Counter(添加计数器)对话框是个什么样子。



图13-4 Add Counters 对话框

该计数器显示的是每秒钟C P U通知操作系统的未对齐数据访问的次数。如果监视x 8 6计算机上的这个计数器,你会看到它总是报告每秒钟为0次数据对齐的调整。这是因为x86 CPU本身正在进行调整,因此没有通知操作系统。由于是x86 CPU而不是操作系统来进行这种调整,因此访问x 8 6计算机上的未对齐数据对性能产生的影响并不像需要用软件( Windows 2000操作系统代码)来进行数据对齐调整的C P U那样大。

可以看到,只需要调用S e t E r r o r M o d e函数便足以使你的应用程序能够正确运行。但是这个解决方案肯定不是效率最高的方案。实际上, Digital 出版社出版的《Alpha ArchitectureReference Manual》手册上讲到,系统默默纠正未对齐数据访问的仿真代码运行时所花费的时间相当于普通情况下的1 0 0倍。这是个相当大的开销。不过有一种更加有效的解决方案可以解决你的问题。

M i c r o s o f t用于Alpha CPU的C / C + +编译器支持一个特殊的关键字,称为_ _ u n a l i g n e d。可以像使用c o n s t或v o l a t i l e修改符那样使用_ _ u n a l i g n e d修改符,差别在于_ _ u n a l i g n e d修改符只有在用于指针变量时才起作用。当通过未对齐指针来访问数据时,编译器就会生成一个代码,该代码假设数据没有正确对齐,因此添加一些访问数据时必须使用的辅助C P U指令。下面显示的代码是前面已经讲过的代码的修改版。这个新版本利用了关键字_ _ u n a l i g n e d。


VOID SomeFunc(PVOID pvDataBuffer)
{
   //The first byte in the buffer is some byte of information
   char c = *(PBYTE) pvDataBuffer;

   //Increment past the first byte in the buffer
   pvDataBuffer = (PVOID)((PBYTE) pvDataBuffer + 1);

   //Bytes 2-5 contain a double-word value
   DWORD dw = *(__unaligned DWORD *) pvDataBuffer;

   //The line above causes the compiler to generate additional
   //instructions so that several aligned data accesses are performed
   //to read the DWORD.
   //Note that a data misalignment exception is not raised.
   ...
}
当我对A l p h a计算机上运行的下面这行代码进行编译时,生成了7个C P U指令:

DWORD dw = *(__unaligned DWORD *) pvDataBuffer;
然而,如果我从这行代码中删除_ _ u n a l i g n e d关键字并进行编译,那么只生成3个C P U指令。可以看到,在Alpha CPU上使用_ _ u n a l i g n e d关键字,生成的C P U指令要多两倍以上。编译器添加的指令比C P U跟踪未对齐数据的访问并让操作系统来纠正未对齐数据的效率要高得多。实际上,如果监控Alignment Fixup/sec计数器,你将发现通过未对齐指针进行的访问对图表上显示的数据没有什么影响。
最后要说明的是,x86 CPU上运行的Visual C/C++编译器不支持_ _ u n a l i g n e d关键字。我想M i c r o s o f t公司也许认为这没有必要,因为C P U本身进行未对齐数据的纠正速度很快。但是,这也意味着x 8 6编译器在遇到_ _ u n a l i g n e d关键字时就会产生错误。因此,如果打算为应用程序创建单个基本源代码,就必须使用U N A L I G N E D宏,而不是_ _ u n a l i g n e d关键字。在Wi n N T. h文件中,U N A L I G N E D宏定义为下面的形式:


#if defined(_M_MRX000) || defined(_M_ALPHA) || defined(_M_IA64)
#define UNALIGNED __unaligned
#if defined(_WIN64)
#define UNALIGNED64 __unaligned
#else
#define UNALIGNED64
#endif
#else
#define UNALIGNED
#define UNALIGNED64
#endif
 
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值