windows底层内存管理技术

1.1. 物理地址

在物理存储器上的内存地址,一般由内核管理,应用程序无法直接得到。

1.2. 虚拟地址

在进程私有空间中的地址,即应用程序指针所指向的地址值。

1.3. 寻址空间

进程所能够范围的地址空间范围,跟指针的位数有关,指针的位数取决于cpu字长,32位指针的地址空间范围为4GB,64位指针的地址空间范围为1 6 E B。

2. windows内存结构

2.1. 虚拟地址空间的管理

对于32位多任务的windows操作系统来说,每个进程都在自己的私有地址空间(虚拟地址空间)运行,因此当进程中的一个线程正在运行时,该线程可以访问只属于它的进程的内存。属于所有其他进程的内存则隐藏着,并且不能被正在运行的线程访问。

在win2k中,属于内核的内存也是隐藏的,正在运行的线程无法访问。这意味着线程不能直接访问内核的数据。如果要想访问内核数据,则必须通过系统调用(系统win32 api)来操作,否则会引发一个内存错误异常。

Win98中,属于操作系统的内存是不隐藏的,正在运行的线程可以访问。因此,正在运行的线程常常可以访问操作系统的数据,也可以破坏操作系统(从而有可能导致操作系统崩溃)。

在Win98中,一个进程的线程不可能访问属于另一个进程的内存,与win2k相同。

建议无论在win98还是win2k中,都采用系统调用来访问内核(操作系统内存)。

2.2. 虚拟地址空间的划分

虽然32位的应用程序理论上可以访问4GB的地址空间,但是真正可以使用的地址空间并没有那么多。

每个进程的虚拟地址空间都要划分成各个分区。地址空间的分区是根据操作系统的基本实现方法来进行的。不同的Windows内核,其分区也略有不同。winxp的内存结构与win2k相同。

进程的地址空间分区表

分区

32位Windows 2000(x86和Alpha处理器)

32位Windows 2000(x86w/3GB用户方式)

64位Windows 2000(Alpha和IA-64处理器)

Windows 98

N U L L指针分配的分区

0 x 0 0 0 0 0 0 0 0 0 x 0 0 0 0 F F F F

0 x 0 0 0 0 0 0 0 0 0 x 0 0 0 0 F F F F

0x00000000 00000000 0x00000000 0000FFFF

0 x 0 0 0 0 0 0 0 0 0 x 0 0 0 0 0 F F F

DOS/16位Windows应用程序兼容分区

0 x 0 0 0 0 0 1 0 0 0 0 x 0 0 3 F F F F F

用户方式

0 x 0 0 0 1 0 0 0 0 0 x 7 F F E F F F F

0 x 0 0 0 1 0 0 0 0 0 x B F F E F F F F F

0x00000000 00010000 0x000003FF FFFEFFFF

0 x 0 0 4 0 0 0 0 0 0 x 7 F F F F F F F

64-KB

0 x 7 F F F 0 0 0 0

0 x B F F F 0 0 0 0

0 x 0 0 0 0 0 3 F F F F F F 0 0 0 0

禁止进入

0 x 7 F F F F F F F

0 x B F F F F F F F

0 x 0 0 0 0 0 3 F F F F F F F F F F

共享的MMF分区

0 x 8 0 0 0 0 0 0 0

文件(MMF)内核方式

0 x 8 0 0 0 0 0 0 0 0 0 x F F F F F F F F

0 x C 0 0 0 0 0 0 0 0 x F F F F F F F F

0x00000400 00000000 0xFFFFFFFFF FFFFFFF

0 x B F F F F F F F 0 x C 0 0 0 0 0 0 0 0 x F F F F F F F F

3 2位Windows 2000的内核与6 4位Windows 2000的内核拥有大体相同的分区,差别在于分区的大小和位置有所不同。另一方面,可以看到Windows 98下的分区有着很大的不同。

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

MS-DOS/16Windows应用程序兼容分区(仅适用Win98):进程地址空间的这个4MB分区是Windows 98需要的,目的是维护MS - DOS应用程序与16位应用程序之间的兼容性。不应该试图从32位应用程序来读取该分区的数据,或者将数据写入该分区。在理想的情况下,如果进程中的线程访问该内存, CPU应该产生一个访问违规,但是由于技术上的原因, Microsoft无法保护这个4MB的地址空间。

在Windows 2000中,16位MS-DOS与16位Windows应用程序是在它们自己的地址空间(其实是在虚拟机中)中运行的,32位应用程序不会对它们产生任何影响。

16位DOS程序的虚拟机就是cmd,16位windows程序使用的是系统虚拟机。

用户方式分区:这个分区是进程的私有(非共享)地址空间所在的地方。

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

在Windows 98中,主要的Win32系统DLL(Kernel32.dll,AdvAPI32.dll,User32.dll和GDI32.dll)均加载共享内存映射文件分区中。. e x e和所有其他D L L模块则加载到这个用户方式分区中。多个进程的共享D L L均位于相同的虚拟地址中,但是其他DLL可以将这些D L L加载到用户方式分区的不同地址中(不过这种可能性不大)。另外,在Windows 98中,用户方式分区中决不会出现内存映射文件。

在32位windows中,用户分区的最大寻址空间大约为2G,内核寻址空间为3G。M i crosof t允许x 8 6的Windows 2000 Advanced Server版本和Windows 2000 Data Center版本将用户方式分区扩大为3 G B,内核分区压缩为1G。若要使所有进程都能够使用3 G B用户方式分区和1 G B内核方式分区,必须将/ 3 G B开关附加到系统的BOOT. INI文件的有关项目中。

在x86w/3GB和64位的windows中,若要使用2GB以上的用户空间,该应用程序必须使用/ LARGEADDRESSAWARE 链接开关来创建。

64KB禁止进入的分区(适用于win2k):这个位于用户方式分区上面的64 KB分区是禁止进入的,访问该分区中的内存的任何企图均将导致访问违规。

共享的MMF分区(适用于win98):存放系统DLL、进程共享数据和内存映射文件。

内核方式分区:存放内核代码。用于线程调度、内存管理、文件系统支持、网络支持和所有设备驱动程序的代码全部在这个分区加载。驻留在这个分区中的一切均可被所有进程共享。

在Windows 2000中,这些组件是完全受到保护的。如果你试图访问该分区中的内存地址,你的线程将会产生访问违规,导致系统向用户显示一个消息框,并关闭你的应用程序。

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

2.3. 地址空间的区域

当进程被创建并被赋予它的地址空间时,该可用地址空间的主体是空闲的,即未分配的。若要使用该地址空间的各个部分,必须通过调用VirtualAlloc函数来分配它里边的各个区域。对一个地址空间的区域进行分配的操作称为保留( reserving )。

每当你保留地址空间的一个区域时,系统要确保该区域从一个分配粒度的边界开始。对于不同的CPU平台来说,分配粒度是各不相同的。几乎所有的CPU平台(x86、32位Alpha、64位Alpha和IA-64)都使用64 KB这个相同的分配粒度。

当你保留地址空间的一个区域时,系统还要确保该区域的大小是系统的页面大小的倍数。页面是系统在管理内存时使用的一个内存单位。与分配粒度一样,不同的C P U,其页面大小也是不同的。x86使用的页面大小是4 KB,而A l p h a使用的页面大小则是8 KB。IA-64也使用8KB的页面。但是,如果测试显示使用更大的页面能够提高系统的总体性能,那么Microsoft可以切换到更大的页面(16KB或更大)。

系统有时会直接代表进程保留一些区域,比如用来存放进程环境块PEB和线程环境块TEB。

由于内核会做区域和页面管理,所以它给应用程序保留的区域边界可能不是64k边界。

如果保留区域大小不是页面大小的整数倍,则会圆整到比它大的最近的页面倍数。比如,在x86平台上页面大小为4K,申请保留10k内存时,系统会保留12K内存给你。

不再使用保留区域时,应该调用VirtualFree来释放。

保留区域并不真正分配物理内存,只是占用进程的地址空间而已。

如果要分配物理页面,必须通过调用VirtualAlloc函数来提交保留区域。

2.4. 物理内存与页文件

Windows虚拟内存是映射到磁盘上的页文件。页文件对应用程序透明。页面调度算法在内核中实现。

虚拟内存的管理需要cpu和内核配合,cpu会判断内存页面是否在RAM中,否则会引发一个缺页中断通知操作系统内核,内核再进行页面调度,根据某种算法淘汰、调入和调出页面。

windows底层内存管理技术_第1张图片

操作系统启动一个.exe文件时,把.exe文件本身作为一个页文件处理(内存映射文件),这样就大大减少了系统页文件的大小。

把系统页文件分散到不同的磁盘分区中,这样可以提高读写效率。

注意软盘上的应用程序是一次性映射到物理内存的,因为安装程序时经常需要更换软盘。

2.5. 数据对齐

数据对齐主要和cpu和编译器有关,跟操作系统关系不大。

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

数据对齐更深入的说明,请查看另一篇文档《深入研究字节对齐问题》。

2.6. 内存管理的几种方法

windows提供了3种进行内存管理的方法,它们是:

• 虚拟内存,以页面为单位进行内存,最适合用来管理大型对象或结构数组。

• 内存映射文件,最适合用来管理大型数据流(通常来自文件)以及在单个计算机上运行的多个进程之间共享数据。

• 内存堆栈,最适合用来管理大量的小对象。

malloc、new、allocator等内存管理是在应用程序的标准库中处理的,不属于操作系统内存管理的范围,故本文不做探讨,在其他文档中再做论述。

3. 进程堆栈

3.1. 简介

堆栈可以用来分配许多较小的数据块。

堆栈的优点是,可以不考虑分配粒度和页面边界之类的问题,集中精力处理手头的任务。堆栈的缺点是,分配和释放内存块的速度比其他机制要慢,并且无法直接控制物理存储器的提交和回收。

从内部来讲,堆栈是保留的地址空间的一个区域。开始时,保留区域中的大多数页面没有被提交物理存储器。当从堆栈中进行越来越多的内存分配时,堆栈管理器将把更多的物理存储器提交给堆栈。物理存储器总是从系统的页文件中分配的,当释放堆栈中的内存块时,堆栈管理器将收回这些物理存储器。

Microsoft并没有以文档的形式来规定堆栈释放和收回存储器时应该遵循的具体规则,Windows 98 与Windows 2000的规则是不同的。可以这样说,Windows 98 更加注重内存的使用,因此只要可能,它就收回堆栈。Windows 2000更加注重速度,因此它往往较长时间占用物理存储器,只有在一段时间后页面不再使用时,才将它返回给页文件。Microsoft常常进行适应性测试并运行各种不同的条件,以确定在大部分时间内最适合的规则。随着使用这些规则的应用程序和硬件的变更,这些规则也会有所变化。如果了解这些规则对你的应用程序非常关键,那么请不要使用堆栈。相反,可以使用虚拟内存函数(即VirtualAlloc和VirtualFree),这样,就能够控制这些规则。

3.2. 默认堆栈

当进程初始化时,系统在进程的地址空间中创建一个堆栈。该堆栈称为进程的默认堆栈。按照默认设置,该堆栈的地址空间区域的大小是1 MB。但是,系统可以扩大进程的默认堆栈,使它大于其默认值。当创建应用程序时,可以使用/ H E A P链接开关,改变堆栈的1 M B默认区域大小。/ H E A P链接开关的句法如下:/HEAP:reserve[,commit]

单个进程可以同时拥有若干个堆栈。这些堆栈可以在进程的寿命期中创建和撤消。但是,默认堆栈是在进程开始执行之前创建的,并且在进程终止运行时自动被撤消。不能撤消进程的默认堆栈。

可以通过调用GetProcessHeap函数获取你的进程默认堆栈的句柄。

3.3. 辅助堆栈

由于某种原因需要创建辅助堆栈:

保护组件。

更加有效地进行内存管理。

更快的访问效率。

减少线程同步的开销。

迅速释放。

3.3.1. 保护组件

把不同组件放到不同的堆栈中,可以防止当一个组件的堆栈出错时影响另外一个组件。假设有两个组件,一个处理链表数据,一个处理二叉树数据,把它们放到不同的辅助堆栈中,当链表内的指针错误操作导致堆栈出错不会影响到二叉树的正确处理。

3.3.2. 更有效的内存管理

通过在堆栈中分配同样大小的对象,就可以更加有效地管理堆栈,这样可以避免内存碎片。

如果每个堆栈只包含大小相同的对象,那么释放一个对象后,另一个对象就可以恰好放入被释放的对象空间中。

3.3.3. 更快的访问效率

如果把相同类型的数据连续放在同一个堆中,这样就可以大大减少cpu访问不同页面的次数,也可能大大减少访问虚拟内存页面的次数,因此会获得更佳的内存访问效率。

3.3.4. 减少线程的开销

多个线程访问进程的默认堆栈是串行操作的,要经常不停的同步互斥操作。如果某个线程的数据不需要与其他线程进行共享,则没有必要和其他线程竞争默认堆栈的访问权。此时创建线程自己的堆栈,可以减少不必要的加锁、解锁开销。

3.3.5. 迅速释放

将专用堆栈用于某些数据结构后,就可以释放整个堆栈,而不必显式释放堆栈中的每个内存块。比如把某个树的数据结构放到一个独立的堆栈中,释放这个树的数据结果就不用一个个节点的慢慢释放,直接撤销堆即可。如果这个树的数据比较大的话,效果会比较明显。

3.4. 堆栈函数

创建堆栈使用HeapCreate,从堆栈中分配内存HeapAlloc,改变堆栈内存大小HeapReAlloc,查询堆栈内存块大小HeapSize,释放堆栈内存块HeapFree,撤销堆栈HeapDestroy。

HeapAlloc函数执行的操作:

1) 遍历分配的和释放的内存块的链接表。

2) 寻找一个空闲内存块的地址。

3) 通过将空闲内存块标记为“已分配”并分配内存块。

4) 将新内存块添加给内存块链接表。

注意当你分配较大的内存块(大约1 MB或者更大)时,最好使用VirtualAlloc函数,应该避免使用堆栈函数。

以上堆栈函数适用于win98和win2k。

C++中的new/delete要调用malloc/free,而malloc/free最终要调用上面的堆栈函数。

ToolHelp的各个函数可以用来枚举进程的各个堆栈和这些堆栈中分配的内存块。函数如下:Heap32First、Heap32Next、Heap32ListFirst和Heap32ListNext,适用于win98和win2k。

以下堆栈函数只适用于win2k:GetProcessHeaps(获取进程多个堆栈的句柄)、HeapValidate(验证堆栈完整性)、HeapCompact(合并空闲地址块)、HeapLock/HeapUnlock(线程对堆栈加锁/解锁,如果在创建堆栈时未设置HEAP_NO_SERIALIZE,则在HeapAlloc和HeapFree时内部加锁)、HeapWalk(遍历堆栈,此时最好加锁,防止有其他线程分配或释放内存)。

4. 线程堆栈

4.1. windows 2000线程堆栈

每当创建一个线程时,系统就会为线程的堆栈(每个线程有它自己的堆栈)保留一个堆栈空间区域,并将一些物理存储器提交给这个已保留的区域。

按照默认设置,系统保留1 MB的地址空间并提交两个页面的内存。但是,这些默认值是可以修改的,方法是在你链接应用程序时设定Microsoft的链接程序的/STACK选项:/STACK:reserve[,commit]。

当创建一个线程的堆栈时,系统将会保留一个链接程序的/ STACK开关指明的地址空间区域。但是,当调用CreateThread或_beginthreadex函数时,可以重设原先提交的内存数量。这两个函数都有一个参数,可以用来重载原先提交给堆栈的地址空间的内存数量。如果设定这个参数为0,那么系统将使用/ S TACK开关指明的已提交的堆栈大小值,即1 MB的保留区域,每次提交一个页面的内存。

下图显示了在页面大小为4KB的计算机上的一个堆栈区域的样子(保留的起始地址是0x08000000) 。该堆栈区域和提交给它的所有物理存储器均拥有页面保护属性PAGE_READWRITE。

windows底层内存管理技术_第2张图片

当保留了这个区域后,系统将物理存储器提交给区域的顶部的两个页面。在允许线程启动运行之前,系统将线程的堆栈指针寄存器设置为指向堆栈区域的最高页面的结尾处(一个非常接近0x08100000的地址)。这个页面就是线程开始使用它的堆栈的位置。从顶部向下的第二个页面称为保护页面。当线程调用更多的函数来扩展它的调用树状结构时,线程将需要更多的堆栈空间。

可以看出栈是向下增长的。

每当线程试图访问保护页面中的存储器时,系统就会得到关于这个情况的通知。作为响应,系统将提交紧靠保护页面下面的另一个存储器页面。然后,系统从当前保护页面中删除保护页面的保护标志,并将它赋予新提交的存储器页面。这种方法使得堆栈存储器只有在线程需要时才会增加。最终,如果线程的调用树继续扩展,堆栈区域就会变成下图所示的样子。

windows底层内存管理技术_第3张图片

假定线程的调用树非常深,堆栈指针C P U寄存器指向堆栈内存地址0 x 0 8 0 0 3 0 0 4。这时,当线程调用另一个函数时,系统必须提交更多的物理存储器。但是,当系统将物理存储器提交给0 x 0 8 0 0 1 0 0 0地址上的页面时,系统执行的操作与它给堆栈的其他内存区域提交物理存储器时的操作并不完全一样。

最底下的页面总是被保留的,从来不会被提交。

完整的线程堆栈区域

windows底层内存管理技术_第4张图片

当系统将物理存储器提交给0x08001000地址上的页面时,它必须再执行一个操作,即它要引发一个EXCEPTION_STACK_OVERFLOW 异常处理(在Wi nNT.h 文件中定义为0 x C00000FD)。通过使用结构化异常处理(SEH),你的程序将能得到关于这个异常处理条件的通知,并且能够实现适度恢复。

如果在出现堆栈溢出异常条件之后,线程继续使用该堆栈,那么在0 x080010 0 0地址上的页面中的全部内存均将被使用,同时,该线程将试图访问从0 x 0 8 0 0 0 0 0 0开始的页面中的内存。当该线程试图访问这个保留的(未提交的)内存时,系统就会引发一个访问违规异常条件。如果在线程试图访问该堆栈时引发了这个访问违规异常条件,线程就会陷入很大的麻烦之中。这时,系统就会接管控制权,并终止进程的运行—不仅终止线程的运行,而切终止整个进程的运行。

最后一个页面始终被保留着。这样做的目的是为了防止不小心改写进程使用的其他数据。

4.2. windows 98线程堆栈

在win98上,线程的堆栈前后都有一个64K的保护区块,可以防止线程堆栈的上溢和下溢,这是win98的一个不错的特色。

堆栈下溢的示例:

int WINAPI WinMain(HINSTANCE hinstExe, HINSTANCE,

PSTR pszCmdLine, int nCmdShow)

{

char szBuf[100];

szBuf[10000] = 0; // Stack underflow,注意栈是向下增长的(与地址相反)

return(0);

}

当该函数的赋值语句执行时,便尝试访问线程堆栈结尾处之外的内存。当然,编译器和链接程序不会抓住上面代码中的错误,但是,如果应用程序是在Windows 98下运行,那么当该语句执行时,就会引发访问违规。这是Windows 98的一个出色特性,而Windows 2000是没有的。在Wi ndows2000中,可以在紧跟线程堆栈的后面建立另一个区域。如果出现这种情况,并且你试图访问你的堆栈外面的内存,那么你将会破坏与进程的另一个部分相关的内存,而系统将不会发现这个情况。

4.3. c/c++运行库线程堆栈检查

C / C + +运行期库包含一个堆栈检查函数。当编译源代码时,编译器将在必要时自动生成对该函数的调用。堆栈检查函数的作用是确保页面被适当地提交给线程的堆栈。

示例代码:

void SomeFunction()

{

int nValues[4000];

// Do some processing with the array.

nValues[0] = 0; // Some assignment

}

该函数至少需要16 000个字节(4000 x sizeof(int),每个整数是4个字节)的堆栈空间,以便放置整数数组。通常情况下,编译器生成的用于分配该堆栈空间的代码只是将C P U的堆栈指针递减16 000个字节。但是,在程序试图访问内存地址之前,系统并不将物理存储器分配给堆栈区域的这个较低区域。

在使用4 KB或8 KB页面的系统上,这个局限性可能导致一个问题出现。如果初次访问堆栈是在低于保护页面的一个地址上进行的(如上面这个代码中的赋值行所示),那么线程将访问已经保留的内存并且引发访问违规。为了确保能够成功地编写上面所示的函数,编译器将插入对C运行期库的堆栈检查函数的调用。

当编译程序时,编译器知道你针对的C P U系统的页面大小。x 8 6编译器知道页面大小是4K B,A l p h a编译器知道页面大小是8 KB。当编译器遇到程序中的每个函数时,它能确定该函数需要的堆栈空间的数量。如果该函数需要的堆栈空间大于目标系统的页面大小,编译器将自动插入对堆栈检查函数的调用。

下面这个伪代码显示了堆栈检查函数执行什么操作。之所以称它是伪代码,是因为这个函数通常是由编译器供应商用汇编语言来实现的:

// The C run-time library knows the page size for the target system.

#ifdef _M_ALPHA

#define PAGESIZE (8 * 1024) //8-KB page

#else

#define PAGESIZE (4 * 1024) //4-KB page

#endif

void StackCheck(int nBytesNeededFromStack)

{

//Get the stack pointer position.

//At this point, the stack pointer has NOT been decremented

//to account for the function's local variables.

PBYTE pbStackPtr = (CPU's stack pointer);

while(nBytesNeededFromStack >= PAGESIZE)

{

// Move down a page on the stack--should be a guard page.

pbStackPtr -= PAGESIZE;

// Access a byte on the guard page--forces new page to be

// committed and guard page to move down a page.

pbStackPtr[0] = 0;

// Reduce the number of bytes needed from the stack.

nBytesNeededFromStack -= PAGESIZE;

}

//Before returning, the StackCheck function sets the CPU's

//stack pointer to the address below the function's

//local variables.

}

5. 虚拟内存管理

注意:我们这里说的虚拟内存指的是进程私有地址空间,而不是页文件(也有把页文件称为操作系统的虚拟内存)。

用于管理虚拟内存的函数可以用来直接保留一个地址空间区域,将物理存储器(来自页文件)提交给该区域,并且可以设置你自己的保护属性。

5.1. 获取系统内存信息

系统内存信息,比如页面的大小,分配粒度大小、最小内存地址、最大内存地址等,都可以通过GetSystemInfo来获取。

函数原型:VOID GetSystemInfo(LPSYSTEM_INFO psinf);

5.2. 获取全局内存状态

可以通过GlobalMemoryStatus来获取全局内存状态,比如整体物理内存大小、整体页文件大小、进程虚拟内存大小、进程可用虚拟内存大小等。

函数原型:VOID GlobalMemoryStatus(LPMEMORYSTATUS pmst);

5.3. 查询内存块的有关信息

可以通过VirtualQuery/ VirtualQueryEx查询内存块的有关信息,如基地址、块大小,存储器类型和保护属性等。

5.4. 保留和提交虚拟内存

通过VirtualAlloc可以保留或提交一块虚拟内存空间。

保留的基地址被圆整为64K的整数倍,保留的大小为cpu页面大小的整数倍。如果内存长期被保留不释放,建议从最高地址往下分配,这样可以把内存碎片放在用户空间的末尾,此时需要在分配类型上设置或参数MEM_TOP_DOWN。

当保留一个区域后,必须将物理存储器提交给该区域,然后才能访问该区域中包含的内存地址。

系统从它的页文件中将已提交的物理存储器分配给一个区域。物理存储器总是按页面边界和页面大小的块来提交的。

若要提交物理存储器,必须再次调用VirtualAlloc函数。

提交物理存储器时可以只提交部分区域,每次提交的页面保护属性页可以不同。提交的大小(单位为字节)会被操作系统圆整为页面大小的整数倍。

把分配类型设置为MEM_RESERVE | MEM_COMMIT就可以保留并提交一块虚拟内存空间。

5.5. 何时提交和回收虚拟内存

对于大块不确定内存操作,可以先保留一个足够大的内存区域,在需要时再提交物理内存,这样可以节省大量的物理内存。

提交方式有以下4种:

1、总是提交。每次都调用VirtualAlloc提交物理内存,让操作系统来判断是否已经提交,这样可能会导致大量的无效调用,因为该页面很可能已经提交过。

2、提交前查询。先调用VirtualQuery查询一下该内存块是否被提交,然后决定是否调用VirtualAlloc。此方法只是减少了VirtualAlloc调用次数,效率可能比第一种还低。

3、跟踪提交页面。把已经提交的页面都记录起来,每次需要新内存时先看已经提交的页面是否有足够内存可用,否则调用VirtualAlloc提交物理内存。此方法效率较高,但是代码可能比较复杂。

4、使用结构化异常处理(SEH)。但进程试图写一个未提交的保留页面时,系统会触发一个内存违规异常,在内存违规异常处理函数中提交物理内存,然后系统返回到异常触发点处继续执行指令,就好像什么都没有发生。此方法代码清晰,效率很高,推荐使用。

使用VirtualFree可以回收全部的保留页面(包括提交和未提交的),也可以只回收部分物理页面。

物理页面回收的3种方法:

1、 对象大小为页面的整数倍。删除对象时直接回收相应的页面。

2、 把每个页面放置固定数目的对象。当页面中所有的对象都删除时,回收该页面。

3、 低优先级定时回收。定时检查每个页面中的所有对象是否都释放,如果是则回收该页面,这种做法的好处是比较通用,而且可以在进程比较空闲时执行,缺点是代码相对复杂。

5.6. 改变页面保护属性和复位内存页面

可以通过VirtualProtect来改变内存保护属性。例如,你编写了一个用于管理链接表的代码,将它的节点存放在一个保留区域中。可以设计一些函数,以便处理该链接表,这样,它们就可以在每个函数开始运行时将已提交内存的保护属性改为PAGE_READWRITE ,然后在每个函数终止运行时将保护属性重新改为PAGE_NOACCESS。

通过这样的设置,就能够使链接表数据不受隐藏在程序中的其他错误的影响。如果进程中的任何其他代码存在一个迷失指针,试图访问你的链接表数据,那么就会引发访问违规。当试图寻找应用程序中难以发现的错误时,利用保护属性是极其有用的。

可以通过在VirtualAlloc中设置MEM_RESET可以复位内存页面,这些页面会被操作系统设置为未修改页面,这样在下次缺页中断时就可以直接把页面文件的页面加载到这些未修改页面上,而不需要保存它们到页文件中去。

5.7. 如何使用4G以上内存

1)在64位的cpu上安装64位windows可以直接支持4G以上内存的访问。

2)32位操作系统4G内存以上支持情况:

    WindowsNT4.0 Server与Enterprise版都属于32位服务器操作系统,支持最大内存都只有4G。

Windows2000系列服务器版操作系统可支持容量最高的是数据中心版,可支持32G;高级服务器版只支持最高8G的内存容量;2000普通服务器版只支持最高4G的内存容量。

Windows2003 Enterprise支持最高32G的内存。

在32位cpu上访问4G以上内存,这是通过X86的PAE(Intel Physical Address Extension)实现的。而windows实现起来的话相当与把内存分页,页表12位,物理地址24 位,组合在一起就是2的36次方,也就是64GB。

PAE需要处理器为Intel Pentium Pro以上。

在cpu和操作系统支持的情况下,应用程序可以通过AWE来使用4G以上内存。

6. 内存映射文件

6.1. 基本概念

与虚拟内存一样,内存映射文件可以用来保留一个地址空间的区域,并将物理存储器提交给该区域。它们之间的差别是,物理存储器来自一个已经位于磁盘上的文件,而不是系统的页文件。一旦该文件被映射,就可以访问它,就像整个文件已经加载内存一样。

6.2. 用途

内存映射文件可以用于3个不同的目的:

• 系统使用内存映射文件,以便加载和执行.exe和DLL文件。这可以大大节省页文件空间和应用程序启动运行所需的时间。

• 可以使用内存映射文件来访问磁盘上的数据文件。这使你可以不必对文件执行I/O操作,并且可以不必对文件内容进行缓存。

• 可以使用内存映射文件,使同一台计算机上运行的多个进程能够相互之间共享数据。Windows确实提供了其他一些方法,以便在进程之间进行数据通信,但是这些方法都是使用内存映射文件来实现的,这使得内存映射文件成为单个计算机上的多个进程互相进行通信的最有效的方法。

6.3. 可执行程序和DLL的内存映射

可执行文件内存映射过程:

1) 系统找出在调用CreateProcess时设定的.exe文件。如果找不到这个.exe文件,进程将无法创建,CreateProcesss将返回FALSE。

2) 系统创建一个新进程内核对象。

3) 系统为这个新进程创建一个私有地址空间。

4) 系统保留一个足够大的地址空间区域,用于存放该.exe文件。该区域需要的位置在. e x e文件本身中设定。按照默认设置, .exe文件的基地址是0x00400000(这个地址可能不同于在6 4位Windows 2000上运行的6 4位应用程序的地址),但是,可以在创建应用程序的. exe文件时重载这个地址,方法是在链接应用程序时使用链接程序的/BASE选项。

5) 系统注意到支持已保留区域的物理存储器是在磁盘上的.exe文件中,而不是在系统的页文件中。

当.exe文件被映射到进程的地址空间中之后,系统将访问.exe文件的一个部分,该部分列出了包含.exe文件中的代码要调用的函数的DLL文件。然后,系统为每个DLL文件调用LoadLibrary函数,如果任何一个DLL需要更多的DLL,那么系统将调用LoadLibrary函数,以便加载这些DLL。

DLL内存映射过程:

1) 系统保留一个足够大的地址空间区域,用于存放该D L L文件。该区域需要的位置在D L L文件本身中设定。按照默认设置, Microsoft的Visual C++ 建立的DLL文件基地址是0 x 10000000(这个地址可能不同于在64位Windows 2000上运行的64位DLL的地址)但是,你可以在创建DLL文件时重载这个地址,方法是使用链接程序的/BASE选项。Windows提供的所有标准系统DLL都拥有不同的基地址,这样,如果加载到单个地址空间,它们就不会重叠。

2) 如果系统无法在该DLL的首选基地址上保留一个区域,其原因可能是该区域已经被另一个DLL或.exe占用,也可能是因为该区域不够大,此时系统将设法寻找另一个地址空间的区域来保留该DLL。如果一个DLL无法加载到它的首选基地址,这将是非常不利的,原因有二。首先,如果系统没有再定位信息,它就无法加载该DLL(可以在DLL创建时,使用链接程序的/FIXED开关,从DLL中删除再定位信息,这能够使DLL变得比较小,但是这也意味着该DLL必须加载到它的首选地址中,否则它就根本无法加载)。第二,系统必须在DLL中执行某些再定位操作。在Windows 98中,系统可以在页面被转入RAM时执行再定位操作。在Windows 2000中,这些再定位操作需要由系统的页文件提供更多的存储器,它们也增加了加载DLL所需要的时间量。

3) 系统会记录当前DLL是映射到磁盘文件还是系统的页文件中。

当所有的.exe和DLL文件都被映射到进程的地址空间之后,系统就可以开始执行.exe文件的启动代码。当.exe文件被映射后,系统将负责所有的分页、缓冲和高速缓存的处理。例如,如果.exe文件中的代码使它跳到一个尚未加载到内存的指令地址,那么就会出现一个异常(缺页中断)。系统能够捕捉这个异常,并且自动将这页代码从该文件的映像加载到一个RAM页面。然后,系统将这个RAM页面映射到进程的地址空间中的相应位置,并且让线程继续运行,就像这页代码已经加载了一样。当然,这一切对应用程序透明。

所有的.exe和DLL映射文件的内容被分割为不同的节。代码放在一个节中,全局变量放在另一个节中。各个节按照页面边界来对齐。通过调用Get SystemInfo函数,应用程序可以确定正在使用的页面的大小。在. e x e或D L L文件中,代码节通常位于数据数据节的前面。

多个进程的.exe或DLL映射文件采用写时拷贝的方法共享RAM和页文件,这样可以避免修改全局变量时对不同进程的影响。

6.4. 可执行程序或DLL的不同示例共享静态数据

每个.exe或DLL文件的映像都由许多节组成。按照规定,每个标准节的名字均以圆点开头。例如,当编译你的程序时,编译器会将所有代码放入一个名叫.text的节中。该编译器还将所有未经初始化的数据放入一个.bss节,而已经初始化的所有数据则放入.data节中。

.exe或D L L文件分节的属性

属性

含义

READ

该节中的字节可以读取

WRITE

该节中的字节可以写入

EXECUTE

该节中的字节可以执行

SHARED

该节中的字节可以被多个实例共享(本属性能够有效地关闭copy -on-write机制)

编译器产生的标准节

节名

作用

.bss

未经初始化的数据

.CRT

C运行期只读数据

.data

已经初始化的数据

.debug

调试信息

.didata

延迟输入文件名表

.edata

输出文件名表

.idata

输入文件名表

.rdata

运行期只读数据

.reloc

重定位表信息

.rsrc

资源

.text

.exe或DLL文件的代码

.tls

线程的本地存储器

.xdata

异常处理表

要想在.exe或dll不同的实例间共享变量,必须满足以下3个条件:

1、 创建分节。如:

#pragma data_seg("Shared")

LONG g_lInstanceCount = 0;

#pragma data_seg()

2、 变量必须初始化,否则该变量就被放到其他分节中,达不到共享的目的。

如:#pragma data_seg("Shared")

LONG g_lInstanceCount;

#pragma data_seg()

3、 必须把该分节设置为共享属性RWS。

可以在连接开关中设置CTION:Shared,RWS。

还可以在代码中设置:#pragma comment(linker, "/SECTION:Shared,RWS")。

如:

#pragma data_seg("Shared")

volatile LONG g_lApplicationInstances = 0;

#pragma data_seg()

#pragma comment(linker, "/Section:Shared,RWS")

虽然可以创建共享节,但是,由于两个原因, Microsoft并不鼓励你使用共享节。第一,用这种方法共享内存有可能破坏系统的安全。第二,共享变量意味着一个应用程序中的错误可能影响另一个应用程序的运行,因为它没有办法防止某个应用程序将数据随机写入一个数据块。

6.5. 内存映射数据文件

操作系统可以将一个数据文件映射到进程的地址空间中。这样,对大量的数据进行操作是非常方便的。

这种方法的最大优点是,系统能够为你管理所有的文件缓存操作。不必分配任何内存,或者将文件数据加载到内存,也不必将数据重新写入该文件,或者释放任何内存块。但是,内存映射文件仍然可能出现因为电源故障之类的进程中断而造成数据被破坏的问题。

若要使用内存映射文件,必须执行下列操作步骤:

1)使用CreateFile创建或打开一个文件内核对象,该对象用于标识磁盘上你想用作内存映射文件的文件。

2) 使用CreateFileMapping创建一个文件映射内核对象,告诉系统该文件的大小和你打算如何访问该文件。

3) 通过MapViewOfFile让系统将文件映射对象的全部或一部分映射到你的进程地址空间中。

当完成对内存映射文件的使用时,必须执行下面这些步骤将它清除:

1) 使用UnmapViewOfFile告诉系统从你的进程的地址空间中撤消文件映射内核对象的映像。

2) 使用CloseHandle关闭文件映射内核对象。

3) 使用CloseHandle关闭文件内核对象。

注意读写权限分配规则:CreateFile≥CreateFileMapping≥MapViewOfFile。这个可以理解,后者都是基于前者进行操作的,不能超越基础权限,另外给程序员带来了一定的灵活性。

另外,如果CreateFileMapping或MapViewOfFile设置了写时拷贝属性,则往映射的页面中写数据时,内核会在系统的页文件中创建新页面并把原始页面数据拷贝过来,然后把新创建的页面地址映射到进程的虚拟空间,并把新页面的属性被设置为读写属性,之后对数据的任何修改都是在私有页面上进行,对映射的数据文件没有任何影响。

注意:设置了写时拷贝属性的页面,在撤销内存文件映射时系统会回收物理页面,所有的修改都会丢失。

可以使用FlushViewOfFile强制系统把修改过的部分或全部页面数据写入数据文件,因为系统有自己的页面管理策略,可能不会马上把缓存数据写入数据文件。

Windows 98不支持写时拷贝属性。

6.6. 内存映射处理大文件

首先映射一个文件的开头的视图。当完成对文件的第一个视图的访问时,可以取消它的映像,然后映射一个从文件中的一个更深的位移开始的新视图。必须重复这一操作,直到访问了整个文件。这使得大型内存映射文件的处理不太方便,但是,幸好大多数文件都比较小,因此不会出现这个问题。

6.7. 内存映射与数据视图的相关性

相同内存映射对象的不同视图,如果它们有部分重叠,则重叠部分在进程的虚拟地址空间上有多个拷贝,但都对应于相同的物理页面,不浪费物理内存。这种情况对于同一进程或不同进程之间都是如此。

但是如果是相同文件的不同内存映射对象,则重叠部分的物理页面可能会重复加载,可能造成物理内存浪费。为什么说是可能,因为你虽然有重叠,但是如果你不访问重叠部分的文件页面,就不会加载到RAM。

如果CreateFile时没有阻止其他进程对这个文件的写访问,则有可能导致内存映射中RAM页面内容和原始文件不一致。

可以使用MapViewOfFileEx把文件内容映射到特定地址,只要地址是64k的整数倍。

关闭视图只是释放虚拟内存地址,并不释放物理页面,只有内存映射文件对象的引用计数为0时才释放物理页面。

6.8. 使用内存映射文件在多个进程间共享数据

尽管windows有多种进程间通信机制,如:RPC、COM、OLE、DDE、窗口消息(尤其是WM_COPYDATA)、剪贴板、邮箱、管道和套接字等。但是,在同一个主机上还是内存映射文件的效率最高。

内存映射可以使用普通磁盘文件,也可以使用系统页文件。如果只是共享和交换数据,使用磁盘文件很不方便,此时推荐使用系统页文件。

使用系统页文件就不用创建文件了,只需要像通常那样调用CreateFileMapping函数,并且传递INVALID_HANDLE_VALUE作为hFile参数即可,其他用法与磁盘文件相同。

与所有内核对象一样,可以使用3种方法与多个进程共享内存映射文件对象,这3种方法是句柄继承性、句柄命名和句柄复制。

采用句柄对象命名的方法可读性较好,推荐使用。在一个进程中调用CreateFileMapping创建内存映射文件对象,在另一个进程中使用OpenFileMapping打开内存映射文件对象,然后建立视图,分别映射文件的相同区块到自己的进程空间中就可以实现数据共享。

你可能感兴趣的:(windows开发,windows,dll,microsoft,编译器,存储,磁盘)