《Windows核心编程》读书笔记十三 内存体系结构

来源:互联网 发布:淘宝砍价师是真是假 编辑:程序博客网 时间:2024/05/21 10:14

第十三章  内存体系结构


本章内容

13.1 进程的虚拟地址空间

13.2 虚拟地址空间的分区

13.3 地址空间中的区域

13.4 给区域调拨物理存储器

13.5 物理存储器和页交换文件

13.6 页面保护属性

13.7 实例分析

13.8 数据对齐的重要性


操作系统所使用的内存体系结构是理解操作系统如何运作的关键。当我们使用一个新的操作系统时,脑海中会涌现出许多问题。

比如:

1)怎样在两个应用程序之间共享数据?

2)系统把我们需要的数据保存在哪里了?

3)怎样才能让应用程序更高效的运行?


13.1  进程的虚拟地址空间

每个进程都有自己的虚拟地址空间,对32位进程来说是4GB (0x0000`0000 ~0xFFFF`FFFF)

对64位应用程序来说,是 16EB (0x0000,0000,0000,0000 ~ 0xFFFF,FFFF,FFFF,FFFF)

因为每个进程都有自己的专有地址空间,当进程中的各线程运行时,他们只能访问属于该进程的内存。线程既看不到属于其他进程的内存,也无法访问它们。


每个进程对内存地址的访问都是进程自己的数据结构。互不干扰。

但是这些内存地址仅仅是虚拟地址,还需要和物理地址做映射,否则会导致访问违规(access violation)。


13.2 虚拟地址空间的分区

每个进程的虚拟地址空间被划分成许多分区(partition),由于依赖操作系统的实现,随着windows版本不同略有不同。



32位和64位的windows内核分区基本一致,唯一不同在于分区大小和分区的位置。


13.2.1 空指针赋值区

在32位模式下,是0x00000000~0x0000FFFF的闭区间。其目的是为了帮助程序员捕获对空指针的赋值。

如果进程中的线程试图读取或写入这一分区的内存地址,就会引发访问违规。

例如一下代码检查不够彻底,没考虑malloc分配失败的情况。

int *pnSomeInteger = (int*)malloc(sizeof(int));*pnSomeInteger = 5;



试图访问0x00000000 会触发异常。


13.2.2 用户模式分区

这一分区就是进程地址空间的驻地。可用地址区间和用户模式分区的大小取决于cpu的体系结构。


进程无法通过指针来读取,写入或以任何方式,访问驻留在这一分区中其他进程的数据。

对于所有应用程序来说,进程的大部分数据都保存在这一分区。由于每个进程都有自己的数据分区,因此一个应用程序破坏另一个应用程序的可能性就很小。



(第一次看到32位进程地址空间时,会发现进程可用的地址空间的数列居然不到整个进程地址空间的一半。难道内核模式分区真的需要整个地址空间的上半部分么?实际上是的)

系统需要用这一空间来存放内核代码,设备驱动程序代码,设备输入/输出高速缓存,非页缓冲池分配表(non-paged pool allocation), 进程页面表,等等。

事实上Microsoft已经将内核压缩到这2GB空间中。在64位windows中,内核终于得到它真正需要的空间。

1. 在X86 Windows下得到更大的用户模式分区。

有些应用程序(如Microsoft SQL Server)会收益于大于2GB的用户模式地址空间。可寻址更多的数据由助于提升应用的性能。

x86版的windows提供了一种模式来增大用户模式分区,但最多不超过3GB。

为了让所有应程序使用大于2GB的用户模式分区和小于1GB的内核模式分区,要堆Windows启动配置数据(boot confiuration data 简称BCD)做设定。


例如bcdedit /set IncreaseUserVa 3072 将告知Windows为所有进程保留3GB用户模式地址空间和1GB的内核模式地址空间。

IncreaseVa的最小值是2048, 默认值就是2GB

如果要取消该参数的设定,执行 bcdedit /deletevalue IncreaseUserVa


(在早期版本的Windows中,因为不允许访问2GB以上的地址空间,因此一些有创意的开发人员对此加以利用,他们将指针最高位作为一个标志使用,只有他们的应用程序才知道如何解释该标志。当应用程序访问内存地址时,会在访问内存地址之前清除指针的最高位。当在运行使用2GB以上地址空间的用户模式下运行,显然会死的很难看)。


为了让应用程序即使在用户模式大于2GB的环境下能运行。windows会检查链接时是否使用了  /LARGEADDRESSAWARE 如果是则会允许其使用大于2GB的用户模式地址空间。

反之会对其保留2GB的限制。


另外使用大用户模式分区(3GB)时,系统最多只能使用64GB内存

而使用默认2GB用户模式分区,系统最多可以使用欧冠128GB内存。




2. 在64位Windows下得到2GB用户模式分区

为了兼容大量在32位系统下的代码(32位指针)仅仅重新编译程序会导致截断错误(pointer truncation error)

因此microsoft 提供了一个sandbox 保存不会为其分配大于0x00000000,7FFFFFFF以上的地址。就能够保证程序正常运行。


默认情况下在64位系统下运行64位应用程序系统只为其分配  0x00000000,80000000 以下的内存空间。 (地址空间沙箱)

为了让64位应用能访问整个用户模式分区,必须使用/LARGEADDRESSAWARE连接器开关




3. 内核模式分区

这一分区是操作系统代码的驻地。与线程调度,内存管理,文件系统支持,网络支持以及设备驱动程序的相关代码都载入该分区。驻留在这一分区内的任何东西为所有进程共有。虽然该分区就在进程的用户模式分区的上方,但是该分区中的所有数据都被保护起来。任何一个应用程序试图读取或写入该分区的内存地址将导致违规错误。


尝试写入内核地址空间0x8fffffff 直接导致一个异常。




13.3 地址空间中的区域

系统创建一个进程并赋予其地址空间以后,可用地址空间(用户模式分区)中的大部分都是闲置的(free)或者尚未分配的(unallocated)

为了使用这部分地址空间,必须调用VirtualAlloc来为其分配其中的区域,分配区域称为预定(reserving)

当应用程序预定地址空间区域时,系统会确保区域的起始地址正好是分配粒度(allocation granularity)的整数倍。分配粒度会根据cpu不同而不同。

例如本书会请求到64KB的整数倍。


当应用程序预定地址空间中的一块区域时,系统会确保区域大小正好是系统页面大小的整数倍。

页面是一个内存单元,系统通过他来管理内存。与分配粒度相似,页面大小根据cpu不同也会不同。x86和x64使用4KB

IA-64系统使用8KB


如果应用程序试图预定一块10KB的地址空间区域,那么系统会自动将请求取整到页面大小的整数倍。(实际预定12KB, 在IA-64中预定16KB)

当程序不再需要访问预定的地址空间区域时,应该释放该区域。这个过程称为释放地址空间区域,调用VirtualFree


13.4 给区域调拨物理存储器

调拨:为了使用所预定的地址空间区域,还必须分配物理存储器,并将存储器映射到所预定的区域。物理存储器始终以页面为单位来调拨。

通过VirtualAlloc函数将物理存储器调拨给所预定的区域。


当我们调拨物理存储器给区域的时候,并不需要给整个区域都调拨物理存储器。例如可以预定一块大小为64KB的区域,然后把物理存储区调拨给该区域中的第二个页面和第四个页面。如下图:



撤销调拨(decommitting)当程序不再需要访问所预定区域中已调拨的物理存储器时,应该释放物理存储器。通过调用VirtualFree函数来完成


13.5 物理存储器和页交换文件

磁盘上有一种称为页交换文件(paging file) 其中包含虚拟内存,可供任何进程使用。


当线程试图访问存储器的一个字节时,cpu必须知道该字节是在内存还是在磁盘上。


从应用程序的角度讲,页交换文件以一种透明的方式增大了应用程序可用内存的总量。


最好把物理存储器看成是保存在磁盘(通常是硬盘)上的页交换文件中的数据,当应用程序调用VirtualAlloc函数来把物理存储器调拨给地址空间区域时,

该空间实际上是从硬盘上的页交换文件分配得到的。


当一个线程试图访问所属进程的地址空间中的一块数据(17章介绍的内存映射文件之外)时,可能会有两种情况。




1)线程要访问的数据就在内存中。cpu会把数据的虚拟内存地址映射到内存(RAM)的物理地址,接下来就可以访问内存中的数据。

2)线程要访问的数据不在内存,而是位于页交换文件(pagefile)中的某处。这次不成功的访问会成为页错误。

当发生页错误时,cpu会通知操作系统。操作系统会在随机内存(RAM)中找一个闲置的页面,如果找不到就必须先释放一个已分配的页面。如果被释放的页面没有修改过,那么操作系统可以直接释放该页面。如果系统需要释放一个已修改过的页面,那么必须先把页面从内存赋值到页交换文件。

接下来操作系统会在页交换文件中对所需访问的数据块进行重定位,并把数据载入到内存中闲置的页面。

然后操作系统对其内部的表项进行更新,以反映该数据的需内存地址现在已经被映射到了内存中对应的物理内存地址。

接着cpu会再次运行那条引发页面错误的指令,(和前一次不同)这次cpu能够将虚拟内存地址映射到物理内存地址(RAM)并成功访问所需要的数据。


系统在内存和页文件之间交换越频繁,硬盘颠簸(thrash)的越厉害(指cpu把时间都花在页面文件和内存之间的交换数据上,导致没有时间运行程序)


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

理论上一进程的内存调拨过程:系统为进程的代码和数据预定地址空间区域,为这些区域调拨物理内存,然后把程序文件中的代码和数据复制到已调拨的物理存储器中(page file)

可是实际上上面这个过程并不是总是执行。(因为这会带来极差的性能)

实际情况是:系统会计算应用程序的代码和数据大小。然后系统预定一块地址空间,并注明与该区域相关联的物理存储器就是这个exe本身。是的,系统并没有从页交换文件中分配空间,而是将exe文件的实际内容(或文件映像,即file image)用作程序预定的地址空间区域。这样不但载入程序非常快,而且页面交换文件也可以保持一个合理的大小。


当把一个位于硬盘上的文件映像(即dll或exe)用作地址空间区域对应的物理存储器(通常是hdd)我们称为这个文件映像为内存映像文件(memory mapped file)。

当载入一个exe或dll,系统会自动预定地址空间区域并把文件映像映射到该区域。


windows可以使用多个页交换文件。(如果多个页交换文件位于不同的物理硬盘上)系统可以运行更快,因为系统能同时写入多个磁盘。

设定页交换文件:



实际上虽然调拨物理内存给分配了页面文件上的物理地址。只有当内存空间不足,页要被换出到物理存储器(磁盘)上的时候,操作系统才会进行上面的步骤2.


13.6 页面保护属性

可以为每个已分配的物理存储页指定不同的页面保护属性。


windows的DEP只有对真正需要执行的区域才会使用PAGE_EXECUTE_*

其他属性PAGE_READWRITE(最常见)

如果cpu试图执行某个页面中的代码,而该页面没有PAGE_EXECUTE_*保护属性, 那么cpu会抛出访问违规异常。

系统还对windows支持的结构化异常处理机制(structured exception handling mechanism)做了更进一步的保护。

如果应用程序链接时使用了/SAFESEH开关,那么异常处理器会EI注册到映像文件中一个特殊的表中,这样要执行一个异常处理器,操作系统会检查该处理器有没有在表中注册过,然后决定是否允许它运行。


13.6.1 写时复制

PAGE_WRITECOPY

PAGE_EXECUTE_WRITECOPY

这两个属性存在的目的是为了节省内存和页面交换文件的使用。

windows支持一种机制,允许两个或两个以上的进程共享同一块存储区(共享内存空间)例如有10个记事本进程运行,所有进程都会共享应用程序的代码页和数据页。

让应用程序实例共享相同的存储页极大提升了系统的性能。但也表示应用程序只能读取或执行其中的数据和代码,并不能写入(否则破坏了其他进程)

为了避免此类混乱发生,操作系统会给共享的存储页指定写时复制属性。 系统载入exe或dll的时候会计算多少页是可写的(保护代码的被标记为PAGE_EXECUTE_READ,包含数据的页面被标记为PAGE_READWRITE)然后系统会从页交换文件中分配存储空间来容纳这些可写页面。除非应用程序真的写入可写页面,否则不会用到页交换文件中的物理存储器(磁盘)


当线程试图写入一个共享页面时,系统会介入并执行以下操作:

1)系统在内存中找一个闲置页面。(该页面的后备页面来自页交换文件,它是系统最初将模块映射到进程地址空间时分配的。由于系统在第一次进行映射的时候分配了所有可能需要的页交换文件空间,这一步不可能失败。)

2)系统把线程想要修改的页面内容复制在第1步中找到的闲置页面。系统会给该闲置页面指定PAGE_READWRITEPAGE_EXECUTE_READWRITE保护属性。且系统不会对原始页面做任何属性和数据的修改

3)然后系统更新进程页面表,这样一来,原来的虚拟地址闲置就对应到内存中一个新的页面了。

系统在执行这些步骤以后,进程就可以访问它自己的副本了。(17章介绍存储器共享和写时复制)

另外在预定地址空间或调拨物理内存时,不能使用PAGE_WRITECOPY或PAGE_EXECUTE_WRITECOPY保护属性,这样会导致VirtualAlloc失败。GetLastError返回ERROR_INVALID_PARAMETER.

PAGE_WRITECOPY

PAGE_EXECUTE_WRITECOPY 这两个属性都是操作系统在映射exe或DLL映像文件时用的。


13.6.2 一些特殊的访问保护标志

PAGE_NOCACHE : 用来禁止对已调拨的页面进行缓存。(通常是给需要操作内存缓冲区的驱动开发人员使用,不建议将该标志用于除此以外的用途)

PAGE_WRITECOMBINE  :也是给驱动开发人员用的。允许把对单个设备的多次操作组合在一起,提供性能。

PAGE_GUARD : 在页面中的任何一个字节被写入时得到通知。有一些妙用法(数据断点?)

使用这些标志只要和PAGE_NOACCESS以外的其他标志进行按位或即可。


13.7 实例分析

第一列是基地址

第二列是内存区域的类型



第三3列是区域预定的字节数,(第三列的值是cpu页面大小的整数倍)。连接器创建PE文件可能会采用一些压缩,而windows将PE映射到进程的虚拟地址空间时,每一个段(section)必须另起一页,而且起始地址必须是系统页面大小的整数倍。


第四列是所预定区域内块的数量。 一个块(block)是一些连续的页面,这些页面具有相同的保护属性,并且以相同类型的物理存储器为后备存储器。对于闲置页面来说,由于不可能将存储器调拨给他们,因此该值始终是0.对于非闲置页来说,该值可以是从1到区域最大所能容纳页面的数量。

例如内存起始地址0x767F0000 的区域大小为647168字节(由于运行在x86 CPU上,页面大小为4096字节),因此最多可能出现158块类型不同的存储区。映射表示了该区域总共有5块。


第五列显示了区域的保护属性。每个字母代表的意思:E = Execute, Read = Read, W = write C = copy-on-write

如果一个区域没有显示任何保护属性,表示该区域没有任何访问保护。

PAGE_GUARD和PAGE_NOCACHE标志在这里根本没有出现,因此这些标志只有当用于物理存储时才有意义。对地址空间来说没有意义。给区域指定保护属性完全是为了效率。如果同时给区域和物理存储器指定保护属性,那么以后者为准。


第六列是对该区域的描述。对于私有数据来说,通常是空的,因为VMMAP无法知道程序预定这块地址空间的目的。但是VMMap能识别包含了线程栈的私有区域,线程栈的物理存储器普遍设置了PAGE_GUARD。 如果线程栈已满就不会有PAGE_GUARD标志,这种情况VMMap也检测不出来。

对于映像区,VMMap显示了映射到该区域文件的完整路径。


区域内部


13.8 数据对齐的重要性

相较于操作系统的内存体系结构,数据对齐更多是CPU体系结构的一部分。只有当访问已对齐的数据时,cpu的执行效率才最高。

把数据地址模除数据的大小,如果结果为0,那么数据就是对齐的。例如一个WORD的起始地址能被2整除,一个DWORD值的起始地址能被4整除。

如果cpu要访问的数据没有对齐,那么会有两种可能。1是cpu会引发一个异常。2是cpu要通过多次访问已对齐的内存(意思是按已对其的方式来多次访问并经过筛选来取得未对齐的数据),来取得整个数据。

以下代码访问了错位数据:

VOID SomeFunc(PVOID pvDataBuffer) {// The first byte in the buffer is some byte of informationchar c = *(PBYTE)pvDataBuffer;// Increment past the first byte in the bufferpvDataBuffer = (PVOID)((PBYTE)pvDataBuffer + 1);// Bytes 2-5 contain a double-word valueDWORD dw = *(DWORD *)pvDataBuffer;// The line above raise a data misalignment exception on some CPUs.}

与访问已对齐的数据相比,系统在最好的情况下也需要花两倍的时间来访问错位的数据,而且情况可能还会更糟(如高速缓存命中之类的问题)。为了得到最佳的应用程序性能,编写代码时应该尽量让数据对齐。


x86 CPU的EFLAGS寄存器内有一个特殊标志位,他被成为AC(Alignment check, 对齐检查)标志。默认情况在cpu第一次通电以后会清零,如果该标志为0,cpu会自动执行必要的操作来访问错位数据。但是如果该标志为1,那么点程序试图访问错位数据,cpu直接触发INT 17H中断。由于x86版本的windows从来不会修改这个标志,因此当应用程序在x86处理器上运行时,绝对不会发生数据错位的异常。在AMD的x86-64上运行,也会得到相同的结果。

IA-64 CPU。不能自动处理数据错位的错误。但任何代码要访问错位数据,CPU就会通知操作系统。Windows然后决定到底是抛出数据错位异常,还是应该没有任何提示额外执行指令来修正错误并让代码继续执行。默认情况IA-64版本的windows会自动将错位的错误转换成一个EXCEPTION_DATATYPE_MISALIGNMENT。

但是可以改变这种行为,使用SetErrorMode函数。让操作系统为进程中的所有线程自动修正数据错位的错误。

WINBASEAPIUINTWINAPISetErrorMode(    _In_ UINT uMode    );

传入SEM_NOALIGNMENTFAULTEXCEPT

这个标志会影响到进程中所有的线程。但是不会影响其他进程。但是会影响其创建的子进程。

当然可以在CreateProcess之前将SEM_NOALIGNMENTFAULTEXCPT清0.


也可以在调用SetErrorMode时不管当前cpu运行的平台,总是传递SEM_NOALIGNMENTFAULTEXCPT.

可以采用Windows Reliability and Performance Monitor(可靠性和性能监视工具)来查看系统me秒修正错误数据的次数。


添加该计数器。


这个是cpu每秒通知操作系统发生错位数据访问的次数。在x86上观察会发现始终是0.(因为x86 cpu会自行修正而不通知操作系统)


IA-64的VC++支持一个叫__unaligned的特殊关键字。除了__unaligned修饰符只能用于指针标量以外,可以想使用const或volatile修饰符一样来使用他。

当访问一个_unaligned修饰的指针,编译器会认为数据未经过对其,并生产额外的cpu指令以访问数据。

VOID SomeFunc(PVOID pvDataBuffer) {// The first byte in the buffer is some byte of informationchar c = *(PBYTE)pvDataBuffer;// Increment past the first byte in the bufferpvDataBuffer = (PVOID)((PBYTE)pvDataBuffer + 1);// Bytes 2-5 contain a double-word valueDWORD 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.}

与其让cpu捕获错位数据的访问并由操作系统来修正错误相比,让编译器生产额外的代码来修正错误的效率让让要高得多。


x86版本的VC++编译器并不支持__unaligned关键字。猜测可能是CPU本身修正错误的速度很快。所以Microsoft认为没必要支持__unaligned关键字。

这意味x86编译器遇到__unaligned关键字会报错

1>------ Build started: Project: ConsoleApplication3, Configuration: Debug Win32 ------1>  main.cpp1>c:\users\admin\documents\visual studio 2013\projects\consoleapplication3\consoleapplication3\main.cpp(39): error C4235: nonstandard extension used : '__unaligned' keyword not supported on this architecture1>C:\Program Files (x86)\MSBuild\Microsoft.Cpp\v4.0\V120\Microsoft.CppCommon.targets(341,5): error MSB6006: "CL.exe" exited with code 2.========== Build: 0 succeeded, 1 failed, 1 up-to-date, 0 skipped ==========

如果打算在创建应用程序中使用同样的源代码,最好不要使用__unaligned关键字。而是使用UNALIGNED和UNALIGNED64宏。

#if defined(_M_MRX000) || defined(_M_ALPHA) || defined(_M_PPC) || defined(_M_IA64) || defined(_M_AMD64) || defined(_M_ARM)#define ALIGNMENT_MACHINE#define UNALIGNED __unaligned#if defined(_WIN64)#define UNALIGNED64 __unaligned#else#define UNALIGNED64#endif#else#undef ALIGNMENT_MACHINE#define UNALIGNED#define UNALIGNED64#endif