《Windows核心编程系列》十二谈谈Windows内存体系结构
Windows内存体系结构
理解Windows内存体系结构是每一个励志成为优秀的Windows程序员所必须的。
进程虚拟地址空间
每个进程都有自己的虚拟地址空间。对于32位操作系统来说,它的地址空间是4GB。这是因为32位指针可以表示从0x00000000到0xFFFFFFFF之间的任一值。对于64位的操作系统来说有0--2的64次方之间的任一值。
由于每个就进程都有自己的地址空间,因此每个进程都只能访问属于自己的地址空间而不能访问其他进程的空间。这保护了进程,也是之所以我们说进程是资源分配和保护的基本单位的原因。
每个进程都有自己的私有地址空间,进程A可以访问它的地址空间0x11009333处的地址。进程B当然也有这个地址,但进程B的此地址处存储的与进程A的数据完全不同。它们没有任何关系。进程A不能访问进程B的地址空间。反之亦然。
每个进程的地址空间都被分成多个分区。由于地址空间分区依赖于操作系统底层实现,因此各个版本的Windows的分区各不相同。此处我们主要介绍32位和64位的Windows内核下的分区情况。
一:空指针赋值区
这一分区是进程地址空间从0x00000000到0x0000FFFF的区域。共64KB。该分区的作用是帮助程序员捕获对空指针的赋值。如果进程中的线程试图访问此区域将会导致访问违规。(原因:此区域未调拨空间,访问未调拨空间的地址将会导致访问违规)。这种情况经常由调用API申请空间失败引起,如malloc。但未对指针进行检查。
没有任何方法能够访问到此区域的虚拟内存。
二:用户模式模式分区。
这一部分空间是进程地址空间的驻地。它的大小也取决于cpu体系结构。
X86cpu体系下的用户模式分区从0x00010000----0x7FFEFFFF总共2GB。进程的大部分数据都保存在这一分区。这其中包括程序加载的dll。
有些用户程序需要大于2G的用户模式地址空间。为此Windows提供了一种大用户分区模式来增大用户模式分区,但最多为3GB。要启动大用户分区模式可以查询其他资料,此处不再介绍。
当32位的应用程序在64位下的环境下运行时,Windows会让此程序在地址空间沙箱中运行。系统将64位地址的高33位都设为0,这样就截断成了32位地址,也就将可用的地址空间限制在最底部的2GB中。对于大多数的程序来说,2GB的地址空间已经足够,为了让64位应用程序能够访问整个用户模式分区,必须用/LARGEADDRESSAWARE连接器开关来链接应用程序。
三:内核模式分区
这一分区是操作系统代码的驻地。系统需要这一部分空间来存放内核代码、设备驱动程序代码、设备输入输出高速缓存、非分页缓冲池分配表、进程页面表等。驻留在这一分区任何东西为所有进程共有,被映射到所有进程地址空间中。如果一个应用程序试图访问这一区域将会导致访问违规。
当系统创建一个进程同时为其创建它地址空间时,此地址空间中大部分都是闲置的。为了使用这部分地址空间,我们必须调用VirtualAlloc来分配其中的区域。分配区域的操作被称为预定。
当应用程序预定地址空间区域时,系统会确保预定的区域的起始地址正好是分配粒度的整数倍。分配粒度根据不同的平台而有所不同。现在所有的平台都是用相同的分配粒度。大小为64KB。
而对于预定的地址空间的大小,系统会确保区域的大小正好是系统页面大小的整数倍。X86和x64系统使用的页面大小为4KB。
如果应用程序预定一块大小为10KB的地址空间区域。那么系统会将该请求取整到页面大小的整数倍。在x86和x64系统中系统会预定一块大小为12KB的区域。
当程序不再需要访问所预定的地址空间区域时,应该释放该区域可以调用VirtualFree函数来完成。
调拨物理存储区
前面预定的地址空间仅仅是标记此块空间已有人使用,其他程序不能再次预定此块区域。为了使用所预定的地址空间区域,我们还必须分配物理存储器,并将存储器映射到所预定的区域。这个过程叫做调拨物理存储器。
可以通过调用VirtualAlloc来调拨物理存储器。物理存储器的调拨是以页面为单位来调拨的。并不需要为所有预定的区域都调拨物理空间。可以仅调拨需要使用的区域。调拨后程序就可以访问内存空间了。
当程序不需要访问所预定的区域中已调拨的物理存储器,应该释放物理存储器。这个过程被称为撤销调拨物理存储器。这是通过调用VirtualFreee函数来完成。
物理存储器和页交换文件
如今的操作系统可以对磁盘进行虚化,来扩展内存,这部分区域被称为分页文件或页交换文件,其中包含虚拟内存,可用程序使用。
内存和磁分页文件共同构成了总内存。
页交换文件增大了应用程序可用内存的总量。实际上,这时操作系统与cpu分工协作,把内存中的一部分保存到页交换文件,并在应用程序的时候再将页交换文件中的对应部分载入内存。
当应用程序调用VirtualAlloc函数来把物理存储器调拨给地址空间区域时,该空间实际上是从硬盘的页交换文件中得到的。
为了能够使用虚拟内存,当线程试图访问存储器中的一个字节中,cpu必须知道该字节是在内存中还是在磁盘上。
当线程试图访问所属进程地址空间中的一块区域时,有可能出现两种情况:
一:要访问的区域就在内存中。此时cpu会把数据的虚拟地址映射到内存的物理地址,然后访问内存。
二:不在内存中,而是位于页交换文件中。这次不成功的访问将会触发缺页中断。发生缺页中断时中断处理程序会在内存中找到一个闲置的页面,然后将数据从页交换文件复制到内存中。
当用户执行一个程序时,系统会打开应用程序对应的exe文件。计算出应用程序的代码和数据的大小。然后系统会预定一块地址空间,并注明与该区域相关联的物理存储器就是exe文件本身。系统并没有从页交换文件中分配空间,而是将exe文件作为程序预定空间的后备存储器。这样一来,程序载入很快,页交换文件也可以保持在一个合理的大小。
当一个程序位于硬盘上的文件映像作为地址空间区域对应的物理存储器时,我们称这个文件映像为内存映射文件。当载入一个dll或exe时,系统会自动预定地址空间并把文件映像映射到该区域。除此之外系统还允许我们手动将数据文件映射到地址空间。
Windows可以使用多个页交换文件。如果多个页交换文件位于不同的物理硬盘上,那么系统就可以运行得更快。我们可以在设置页交换文件大小。
页面保护属性
我们可以给每个已分配的物理存储页指定不同的页面保护属性。
页面属性有以下几种:
PAGE_NOACCESS: 不可访问。试图访问或执行页面中的代码 将导致访问违规。
PAGE_READONLY: 只读。试图写入页面或执行页面代码将引发访问违规。
PAGE_READWRITE: 读写。试图的执行页面中的代码将引发访问违规。
PAGE_EXECUTE: 可执行。试图读写页面将导致访问违规。
PAGE_EXECUTE_READ: 执行只读。试图写入页面将导致访问违规。
PAGE_EXECUTE_READWRITE:允许对页面进行任何操作。
PAGE_WRITECOPY: 试图执行页面代码将导致访问违规。试图写入页将使系统在页交换文件中为此页面创建一份副本。
PAGE_EXECUTE_WRITECOPY: 对页面执行任何操作都不会导致访问违规。试图写入页面将导致系统从页交换文件为此页面创建一份副本。
Windows通过页面保护属性对物理存储页面进行保护。如果cpu试图执行某个页面中的代码,而该页又没有PAGE_EXECUTE_*保护属性,那么cpu会抛出访问违规异常。
写时复制
PAGE_WRITECOPY与PAGE_EXECUTE_WRITECOPY的作用是节省内存和页交换文件的使用。
Windows允许多个进程共享同一块存储器。所有的进程进程会共享程序的代码页和数据页,但只能读取或执行而不能写入。如果每个进程实例修改并写入一个存储页,这等于是修改了其他程序实例正在使用的存储页,最终将导致混乱。让所有的应用程序实例共享相同的存储页能够极大的提升了系统的性能。
为了避免此种情况,os会给共享的可写存储页指定写时复制属性。当系统把一个exe或dll映射到进程地址空间中时,会计算出多少页面是可写的。计算操作是根据页面的保护属性来进行的。包含代码的页面具有PAGE_EXECUTE_READ属性,包含数据的页面具有PAGE_READWRITE属性。计算出可写页面所占的空间大小后,系统会从页交换文件预定存储空间来容纳这些可写页面。
当线程试图写入一个页面时,系统会执行以下操作:
1:在内存中找到一个闲置页面,并将其后备存储器标明为页交换文件,这次是真正的从页交换文件中分配。
2:系统把线程想要修改的页面复制到闲置页面中。并将闲置页面标记为PAGE_READWRITE或PAGE_EXECUTE_READWRITE属性。
3:系统不会对原始页面做任何更改。然后更新页面表,原来的虚拟地址现在就映射到这个新的页面了。
4:线程将对新页面进行写入。
每个进程在修改共享页面时都会执行操作,而不会对共享页面有任何影响。
内存区域的类型
闲置区域 区域的虚拟地址没有任何后备存储器。该地址尚未预定,应用程序既可以从它的基地址开始预订区域,也可以从闲置区域的任何位置开始。
私有区域:区域的虚拟地址以系统页交换文件为后备存储器。
映像区域:区域的虚拟地址刚开始时是以映像文件如exe或dll文件作为后备存储器。但以后就不一定是。由于写入的页面具有写时复制属性,写时复制机制会改用页交换文件作为后备存储器。
已映射区域:区域的虚拟地址刚开始以内存映射文件为后备存储器。以后可能就不一定以页交换文件为后备存储器。
内存对齐
Cpu只有访问对齐后的数据执行效率才是最高的。
数据的地址模数据的大小如果结果是0,那么数据就是对齐的。
如果cpu要访问的数据没有对齐将会出现两种结果:
一:cpu会引发一个异常。
二:通过多次访问来取得未对齐的数据。
显然cpu多次访问内存会影响到程序的性能。为了得到最佳的应用程序性能,我们在编写代码时尽量让数据对齐。
X86环境下的vc++支持使用UNALIGNED和UNALIGNED64宏。可以将未对齐的数据对齐。
- #pragma pack(show)
该命令来查看当前的对齐值,但是要注意的是,结果是以warning的形式输出的,所以要在VS的警告窗口中才看得见,如下
warning C4810: value of pragma pack(show) == 8
数据对齐规则:
一般来说,结构体的对齐规则是先按数据类型自身进行对齐,然后再按整个结构体进行对齐,对齐值必须是2的幂,比如1,2, 4, 8, 16。如果一个类型按n字节对齐,那么该类型的变量起始地址必须是n的倍数。比如int按四字节对齐,那么int类型的变量起始地址一定是4的倍数,比如0x0012ff60,0x0012ff48等
数据自身的对齐:
数据自身的对齐值通常就是数据类型所占的空间大小,比如int类型占四个字节,那么它的对齐值就是4
整个结构体的对齐:
整个结构体的对齐值一般是结构体中最大数据类型所占的空间,比如下面这个结构体的对齐值就是8,因为double类型占8个字节。
有了上面的基础,再看看一开始的两个例子
先看结构体Test
- struct Test
- {
- char c ;
- int i ;
- };
1 c是char类型,按1个字节对齐
2 i是int类型,按四个字节对齐,所以在c和i之间实际上空了三个字节。
整个结构体一共是1 + 3(补齐)+ 4 = 8字节。
再看Test1
- struct Test1
- {
- int i ;
- double d ;
- char c ;
- };
i是int类型,按4字节对齐
d是double类型,按8字节对齐,所以i和d之间空了4字节
c是char类型,按1字节对齐。
所以整个结构体是 4(i) + 4(补齐)+ 8(d) + 1(c) = 17字节,注意!还没完,整个结构体还没有对齐,因为结构体中空间最大的类型是double,所以整个结构体按8字节对齐,那么最终结果就是17 + 7(补齐) = 24字节。
书写结构体的建议
我们对Test1做一点改动
struct Test1
{
char c ;
int i ;
double d ;
};
这时Test1的大小就变成了16,而不是24了,节省了8个字节!可见结构体中成员的书写顺序对结构体大小的影响还是很大的,一个好的建议是,按照数据类型由小到大的顺序进行书写。
综上我们知道,数据对齐的结果是结构体中最大成员的整数倍。各个成员都按最大成员所占字节数对齐。判断一个结构是否是对齐的可以让数据的地址模数据的大小结果为0则该结构是数据对齐的。