现在的位置: 首页 > 综合 > 正文

WINDOWS核心编程笔记(16-21)

2013年01月06日 ⁄ 综合 ⁄ 共 25017字 ⁄ 字号 评论关闭

第16章线程的堆栈
有时系统会在你自己进程的地址空间中保留一些区域。第3章讲过,对于进程和线程环境块来说,就会出现这种情况。另外,系统也可以在你自己进程的地址空间中为线程的堆栈保留一些区域。
每当创建一个线程时,系统就会为线程的堆栈(每个线程有它自己的堆栈)保留一个堆栈空间区域,并将一些物理存储器提交给这个已保留的区域。按照默认设置,系统保留1MB的地址空间并提交两个页面的内存。但是,这些默认值是可以修改的,方法是在你链接应用程序时设定Microsoft的链接程序的/STACK选项:/STACK:reserve[,commit]
当创建一个线程的堆栈时,系统将会保留一个链接程序的/STACK开关指明的地址空间区域。但是,当调用CreateThread或_beginthreadex函数时,可以重载原先提交的内存数量。这两个函数都有一个参数,可以用来重载原先提交给堆栈的地址空间的内存数量。如果设定这个参数为0,那么系统将使用/STACK开关指明的已提交的堆栈大小值。后面将假定我们使用默认的堆栈大小值,即1MB的保留区域,每次提交一个页面的内存。
图16-1显示了在页面大小为4KB的计算机上的一个堆栈区域的样子(保留的起始地址是0x08000000)。该堆栈区域和提交给它的所有物理存储器均拥有页面保护属性PAGE_READWRITE。
1 16_1.GIF
图16-1线程的堆栈区域刚刚创建时的样子
当保留了这个区域后,系统将物理存储器提交给区域的顶部的两个页面。在允许线程启动运行之前,系统将线程的堆栈指针寄存器设置为指向堆栈区域的最高页面的结尾处(一个非常接近0x08100000的地址)。这个页面就是线程开始使用它的堆栈的位置。从顶部向下的第二个页面称为保护页面。当线程调用更多的函数来扩展它的调用树状结构时,线程将需要更多的堆栈空间。
每当线程试图访问保护页面中的存储器时,系统就会得到关于这个情况的通知。作为响应,系统将提交紧靠保护页面下面的另一个存储器页面。然后,系统从当前保护页面中删除保护页面的保护标志,并将它赋予新提交的存储器页面。这种方法使得堆栈存储器只有在线程需要时才会增加。最终,如果线程的调用树继续扩展,堆栈区域就会变成图16-2所示的样子。
最底下的页面总是被保留的,从来不会被提交。下面将要说明它的原因。
当系统将物理存储器提交给0x08001000地址上的页面时,它必须再执行一个操作,即它要引发一个EXCEPTION_STACK_OVERFLOW异常处理(在WinNT.h文件中定义为0xC00000FD)。通过使用结构化异常处理(SEH),你的程序将能得到关于这个异常处理条件的通知,并且能够实现适度恢复。关于SEH的详细说明,请参见第23、24和25章的内容。本章结尾处的Summation示例应用程序将展示如何对堆栈溢出进行适度恢复。
如果在出现堆栈溢出异常条件之后,线程继续使用该堆栈,那么在0x08001000地址上的页面中的全部内存均将被使用,同时,该线程将试图访问从0x08000000开始的页面中的内存。当该线程试图访问这个保留的(未提交的)内存时,系统就会引发一个访问违规异常条件。如果在线程试图访问该堆栈时引发了这个访问违规异常条件,线程就会陷入很大的麻烦之中。这时,系统就会接管控制权,并终止进程的运行—不仅终止线程的运行,而切终止整个进程的运行。
系统甚至不向用户显示一个消息框,整个进程都消失了!
下面要说明为什么堆栈区域的最后一个页面始终被保留着。这样做的目的是为了防止不小心改写进程使用的其他数据。可以看到,在0x07FF000这个地址上(0x08000000下面的一个页面),另一个地址空间区域已经提交了物理存储器。如果0x08000000地址上的页面包含物理存储器,系统将无法抓住线程访问已保留堆栈区域的尝试。如果堆栈深入到已保留堆栈区域的下面,那么线程中的代码就会改写进程的地址空间中的其他数据,这是个非常难以抓住的错误。
2 16_2.GIF
图16-2完整的线程堆栈区域

16.1Windows98下的线程堆栈
在Windows98下,堆栈的行为特性与Windows2000下的堆栈非常相似。但是它们之间存在某些重大的差别。
图16-3显示了Windows98下1MB的堆栈的各个区域的样子(从0x00530000地址上开始保留)。
首先请注意,尽管我们想要创建的堆栈大小最大只有1MB,但是堆栈区域的大小实际上是1MB加128KB。在Windows98中,每当为一个堆栈保留一个区域时,系统保留的区域实际上比要求的尺寸要大128KB。该堆栈位于该区域的中间,堆栈的前面有一个64KB的块,堆栈的后面是另一个64KB的块。
3 16_3.GIF
图16-3Windows98下线程的堆栈区域刚刚创建时的样子
堆栈开始处的64KB用于抓取堆栈的溢出条件,而堆栈后面的64KB则用于抓取堆栈的下溢条件。若要了解为什么需要检测堆栈下溢条件,请看下面这个代码段:
Int WINAPI WinMain (HINSTANCE hinstExe,HINSTANCE, PSTR pszCmdLine,int nCmdShow){
Char szBuf[100];
szBuf[10000]=0;//stack underflow
return(0);
}
当该函数的赋值语句执行时,便尝试访问线程堆栈结尾处之外的内存。当然,编译器和链接程序不会抓住上面代码中的错误,但是,如果应用程序是在Windows98下运行,那么当该语句执行时,就会引发访问违规。这是Windows98的一个出色特性,而Windows2000是没有的。在Windows2000中,可以在紧跟线程堆栈的后面建立另一个区域。如果出现这种情况,并且你试图访问你的堆栈外面的内存,那么你将会破坏与进程的另一个部分相关的内存,而系统将不会发现这个情况。
需要指出的第二个重要差别是,没有一个页面具有PAGE_GUARD保护属性标志。由于Windows98不支持这个标志,所以它使用一个不同的方法来扩展线程的堆栈。Windows98将紧靠堆栈下面的已提交页面标记为PAGE_NOACCESS保护属性(图16-3中的地址0x0063E000)。
然后,当线程接触读/写页面下面的页面时,将会发生访问违规。系统抓住这个访问违规,将不能访问的页面改为读写页面,并提交前一个保护页面下面的一个新保护页面。
第三个应该注意的差别是图16-3中的0x00637000地址上的单个PAGE_READWRITE内存页面。这个页面是为了实现与16位Windows相兼容而存在的。虽然Microsoft从未将它纳入文档,但是开发人员发现16位应用程序的堆栈段(SS)开始处的16个字节包含了关于16位应用程序的堆栈、本地堆栈和本地原子表的信息。由于在Windows98上运行的Win32应用程序常常调用16位DLL组件,有些16位组件认为这些信息可以在堆栈段的开始处得到,因此Microsoft不得不在Windows98中仿真这些字节的设置。当32位代码转换为16位代码时,Windows98将把一个16位CPU选择器映射到32位堆栈,并且将堆栈段寄存器设置为指向0x00637000地址上的页面。这时该16位代码就可以访问堆栈段的开始处的16个字节,并且可以继续运行而不会出任何问题。
现在,当Windows98扩大它的线程堆栈时,它将继续扩大0x0063F000地址上的内存块。它也会不断地将保护页面下移,直到1MB的堆栈内存被提交为止。然后保护页面消失,就像在Windows2000下运行的情况一样。系统还继续为了16位Windows组件的兼容性而将页面下移,最后该页面将进入堆栈区域开始处的64KB的内存块中。因此,Windows98中一个完全提交的堆栈将类似图16-4所示的样子。
4 16_4.GIF
图16-4Windows98下的一个完整的线程堆栈区域
16.2C/C++运行期库的堆栈检查函数
C/C++运行期库包含一个堆栈检查函数。当编译源代码时,编译器将在必要时自动生成对该函数的调用。堆栈检查函数的作用是确保页面被适当地提交给线程的堆栈。下面让我们来看一个例子。
这是一个小型函数,它需要相当多的内存用于它的局部变量:
Void SomeFunction(){
Int nValues[4000];
//do some processing with the array.
nValues[0]=0;//Some assignment
}
该函数至少需要16000个字节(4000xsizeof(int),每个整数是4个字节)的堆栈空间,以便放置整数数组。通常情况下,编译器生成的用于分配该堆栈空间的代码只是将CPU的堆栈指针递减16000个字节。但是,在程序试图访问内存地址之前,系统并不将物理存储器分配给堆栈区域的这个较低区域。
在使用4KB或8KB页面的系统上,这个局限性可能导致一个问题出现。如果初次访问堆栈是在低于保护页面的一个地址上进行的(如上面这个代码中的赋值行所示),那么线程将访问已经保留的内存并且引发访问违规。为了确保能够成功地编写上面所示的函数,编译器将插入对C运行期库的堆栈检查函数的调用。
当编译程序时,编译器知道你针对的CPU系统的页面大小。x86编译器知道页面大小是4KB,Alpha编译器知道页面大小是8KB。当编译器遇到程序中的每个函数时,它能确定该函数需要的堆栈空间的数量。如果该函数需要的堆栈空间大于目标系统的页面大小,编译器将自动插入对堆栈检查函数的调用。
Microsoft的VisualC++确实提供了一个编译器开关,使你能够控制一个页面大小的阈值,这个阈值可供编译器用来确定何时添加对StackCheck函数的自动调用。只有当确切地知道究竟在进行什么操作并且有着特殊需要时,才能使用这个编译器开关。对于绝大多数应用程序和DLL来说,都不应该使用这个开关。
16.3Summation示例应用程序
本章最后提供了一个示例应用程序,展示了如何使用异常过滤器和异常处理程序以便对堆栈溢出进行适度恢复的方法。

第17章内存映射文件
对文件进行操作几乎是所有应用程序都必须进行的,Microsoft提供了一种两全其美的方法,那就是内存映射文件。
与虚拟内存一样,内存映射文件可以用来保留一个地址空间的区域,并将物理存储器提交给该区域。它们之间的差别是,物理存储器来自一个已经位于磁盘上的文件,而不是系统的页文件。一旦该文件被映射,就可以访问它,就像整个文件已经加载内存一样。
内存映射文件可以用于3个不同的目的:
•系统使用内存映射文件,以便加载和执行.exe和DLL文件。这可以大大节省页文件空间和应用程序启动运行所需的时间。
•可以使用内存映射文件来访问磁盘上的数据文件。这使你可以不必对文件执行I/O操作,并且可以不必对文件内容进行缓存。
•可以使用内存映射文件,使同一台计算机上运行的多个进程能够相互之间共享数据。
Windows确实提供了其他一些方法,以便在进程之间进行数据通信,但是这些方法都是使用内存映射文件来实现的,这使得内存映射文件成为单个计算机上的多个进程互相进行通信的最有效的方法。
本章将要介绍内存映射文件的各种使用方法。
17.1内存映射的可执行文件和DLL文件
当线程调用CreateProcess时,系统将执行下列操作步骤:
1)系统找出在调用CreateProcess时设定的.exe文件。如果找不到这个.exe文件,进程将无法创建,CreateProcess将返回FALSE。
2)系统创建一个新进程内核对象。
3)系统为这个新进程创建一个私有地址空间。
4)系统保留一个足够大的地址空间区域,用于存放该.exe文件。该区域需要的位置在.exe文件本身中设定。按照默认设置,.exe文件的基地址是0x00400000(这个地址可能不同于在64位Windows2000上运行的64位应用程序的地址),但是,可以在创建应用程序的.exe文件时重载这个地址,方法是在链接应用程序时使用链接程序的/BASE选项。
5)系统注意到支持已保留区域的物理存储器是在磁盘上的.exe文件中,而不是在系统的页文件中。
当.exe文件被映射到进程的地址空间中之后,系统将访问.exe文件的一个部分,该部分列出了包含.exe文件中的代码要调用的函数的DLL文件。然后,系统为每个DLL文件调用LoadLibrary函数,如果任何一个DLL需要更多的DLL,那么系统将调用LoadLibrary函数,以便加载这些DLL。每当调用LoadLibrary来加载一个DLL时,系统将执行下列操作步骤,它们均类似上面的第4和第5个步骤:
1)系统保留一个足够大的地址空间区域,用于存放该DLL文件。该区域需要的位置在DLL文件本身中设定。按照默认设置,Microsoft的VisualC++建立的DLL文件基地址是0x10000000(这个地址可能不同于在64位Windows2000上运行的64位DLL的地址)但是,你可以在创建DLL文件时重载这个地址,方法是使用链接程序的/BASE选项。Windows提供的所有标准系统DLL都拥有不同的基地址,这样,如果加载到单个地址空间,它们就不会重叠。
2)如果系统无法在该DLL的首选基地址上保留一个区域,其原因可能是该区域已经被另一个DLL或.exe占用,也可能是因为该区域不够大,此时系统将设法寻找另一个地址空间的区域来保留该DLL。如果一个DLL无法加载到它的首选基地址,这将是非常不利的,原因有二。首先,如果系统没有再定位信息,它就无法加载该DLL(可以在DLL创建时,使用链接程序的/FIXED开关,从DLL中删除再定位信息,这能够使DLL变得比较小,但是这也意味着该DLL必须加载到它的首选地址中,否则它就根本无法加载)。第二,系统必须在DLL中执行某些再定位操作。在Windows98中,系统可以在页面被转入RAM时执行再定位操作。在Windows2000中,这些再定位操作需要由系统的页文件提供更多的存储器,它们也增加了加载DLL所需要的时间量。
3)系统会注意到支持已保留区域的物理存储器位于磁盘上的DLL文件中,而不是在系统的页文件中。如果由DLL无法加载到它的首选基地址,Windows2000必须执行再定位操作,那么系统也将注意到DLL的某些物理存储器已经被映射到页文件中。
如果由于某个原因系统无法映射.exe和所有必要的DLL文件,那么系统就会向用户显示一个消息框,并且释放进程的地址空间和进程对象。CreateProcess函数将向调用者返回FALSE,调用者可以调用GetLastError函数,以便更好地了解为什么无法创建该进程。
当所有的.exe和DLL文件都被映射到进程的地址空间之后,系统就可以开始执行.exe文件的启动代码。当.exe文件被映射后,系统将负责所有的分页、缓冲和高速缓存的处理。例如,如果.exe文件中的代码使它跳到一个尚未加载到内存的指令地址,那么就会出现一个错误。系统能够发现这个错误,并且自动将这页代码从该文件的映像加载到一个RAM页面。然后,系统将这个RAM页面映射到进程的地址空间中的相应位置,并且让线程继续运行,就像这页代码已经加载了一样。当然,这一切是应用程序看不见的。当进程中的线程每次试图访问尚未加载到RAM的代码或数据时,该进程就会重复执行。
17.1.1可执行文件或DLL的多个实例不能共享静态数据
当为正在运行的应用程序创建新进程时,系统将打开用于标识可执行文件映像的文件映射对象的另一个内存映射视图,并创建一个新进程对象和(为主线程创建)一个新线程对象。系统还要将新的进程ID和线程ID赋予这些对象。通过使用内存映射文件,同一个应用程序的多个正在运行的实例就能够共享RAM中的相同代码和数据。
这里有一个小问题需要注意。进程使用的是一个平面地址空间。当编译和链接你的程序时,所有的代码和数据都被合并在一起,组成一个很大的结构。数据与代码被分开,但仅限于跟在.exe文件中的代码后面的数据而已。图17-1简单说明了应用程序的代码和数据究竟是如何加载到虚拟内存中,然后又被映射到应用程序的地址空间中的。
作为一个例子,假设应用程序的第二个实例正在运行。系统只是将包含文件的代码和数据的虚拟内存页面映射到第二个应用程序的地址空间,如图17-2所示。
如果应用程序的一个实例改变了驻留在数据页面中的某些全局变量,那么该应用程序的所有实例的内存内容都将改变。这种类型的改变可能带来灾难性的后果,因此是决不允许的。实际上,文件的内容被分割为不同的节。代码放在一个节中,全局变量放在另一个节中。各个节按照页面边界来对齐。通过调用GetSystemInfo函数,应用程序可以确定正在使用的页面的大小。在.exe或DLL文件中,代码节通常位于数据数据节的前面。
系统运用内存管理系统的copy-on-write(写入时拷贝)特性来防止进行这种改变。每当应用程序尝试将数据写入它的内存映射文件时,系统就会抓住这种尝试,为包含应用程序尝试写入数据的内存页面分配一个新内存块,再拷贝该页面的内容,并允许该应用程序将数据写入这个新分配的内存块。结果,同一个应用程序的所有其他实例的运行都不会受到影响。图17-3显示了当应用程序的第一个实例尝试改变数据页面2时出现的情况。
系统分配一个新的虚拟内存页面,并且将数据页面2的内容拷贝到新页面中。第一个实例的地址空间发生了变更,这样,新数据页面就被映射到与原始地址页面相同位置上的地址空间中。这时系统就可以让进程修改全局变量,而不必担心改变同一个应用程序的另一个实例的数据。当应用程序被调试时,将会发生类似的事件。比如说,你正在运行一个应用程序的多个实例,并且只想调试其中的一个实例。你访问调试程序,在一行源代码中设置一个断点。调试程序修改了你的代码,将你的一个汇编语言指令改为能使调试程序自行激活的指令。因此你再次第二个实例的地址空间虚拟内存第一个实例的地址空间
遇到了同样的问题。当调试程序修改代码时,它将导致应用程序的所有实例在修改后的汇编语言指令运行时激活该调试程序。为了解决这个问题,系统再次使用copy-on-write内存。当系统发现调试程序试图修改代码时,它就分配一个新内存块,将包含该指令的页面拷贝到新的内存页面中,并且允许调试程序修改页面拷贝中的代码。
Windows98当一个进程被加载时,系统要查看文件映像的所有页面。系统立即为通常用copy-on-write属性保护的那些页面提交页文件中的存储器。这些页面只是被提交而已,它们并不被访问。当文件映像中的页面被访问时,系统就加载相应的页面。如果该页面从来没有被修改,它就可以从内存中删除,并在必要时重新加载。但是,如果文件的页面被修改了,系统就将修改过的页面转到页文件中以前被提交的页面之一。

Windows2000与Windows98之间的行为特性的唯一差别,是在你加载一个模块的两个拷贝并且可写入的数据尚未被修改的时候显示出来的。在这种情况下,在Windows2000下运行的进程能够共享数据,而在Windows98下,每个进程都可以得到它自己的数据拷贝。如果只加载模块的一个拷贝,或者可写入的数据已经被修改(这是通常的情况),那么Windows2000与Windows98的行为特性是完全相同的。
17.1.2在可执行文件或DLL的多个实例之间共享静态数据
全局数据和静态数据不能被同一个.exe或DLL文件的多个映像共享,这是个安全的默认设置。但是,在某些情况下,让一个.exe文件的多个映像共享一个变量的实例是非常有用和方便的。例如,Windows没有提供任何简便的方法来确定用户是否在运行应用程序的多个实例。但是,如果能够让所有实例共享单个全局变量,那么这个全局变量就能够反映正在运行的实例的数量。当用户启动应用程序的一个实例时,新实例的线程能够简单地查看全局变量的值(它已经被另一个实例更新);如果这个数量大于1,那么第二个实例就能够通知用户,该应用程序只有一个实例可以运行,而第二个实例将终止运行。
本节将介绍一种方法,它允许你共享.exe或DLL文件的所有实例的变量。不过在介绍这个方法之前,首先让我们介绍一些背景知识。
每个.exe或DLL文件的映像都由许多节组成。按照规定,每个标准节的名字均以圆点开头。例如,当编译你的程序时,编译器会将所有代码放入一个名叫.text的节中。该编译器还将所有未经初始化的数据放入一个.bss节,而已经初始化的所有数据则放入.data节中。每一节都拥有与其相关的一组属性,这些属性如表17-1所示。
表17-1   .exe或DLL文件各节的属性
属性              含义
READ        该节中的字节可以读取
WRITE       该节中的字节可以写入
EXECUTE     该节中的字节可以执行
SHARED      该节中的字节可以被多个实例共享(本属性能够有效地关闭copy-on-write机制)
表17-2显示了比较常见的一些节的名字,并且说明了每一节的作用。
表17-2常见的节名及作用
节名        作用
.bss          未经初始化的数据
.CRTC        运行期只读数据
.data        已经初始化的数据
.debug        调试信息
.didata        延迟输入文件名表
.edata        输出文件名表
.idata        输入文件名表
.rdata        运行期只读数据
.reloc        重定位表信息
.rsrc        资源
.text        .exe或DLL文件的代码
.tls        线程的本地存储器
.xdata        异常处理表
除了编译器和链接程序创建的标准节外,也可以在使用下面的命令进行编译时创建自己的节:
#pragma data_seg(“sectionname”)
需要记住的是,编译器只将已经初始化的变量放入新节中。
Microsoft的VisualC++编译器提供了一个Allocate说明符,使你可以将未经初始化的数据放入你希望的任何节中。
之所以将变量放入它们自己的节中,最常见的原因也许是要在.exe或DLL文件的多个映像之间共享这些变量。按照默认设置,.exe或DLL文件的每个映像都有它自己的一组变量。然而,可以将你想在该模块的所有映像之间共享的任何变量组合到它自己的节中去。当给变量分组时,系统并不为.exe或DLL文件的每个映像创建新实例。
仅仅告诉编译器将某些变量放入它们自己的节中,是不足以实现对这些变量的共享的。还必须告诉链接程序,某个节中的变量是需要加以共享的。若要进行这项操作,可以使用链接程序的命令行上的/SECTION开关:
/SECTION:name,attributes
在冒号的后面,放入你想要改变其属性的节的名字。
在逗号的后面,我们设定了需要的属性。用R代表READ,W代表WEITE,E代表EXECUTE,S代表SHARED。
也可以使用下面的句法将链接程序开关嵌入你的源代码中:
#pragma comment(linker,”/SECTION:shared,RWS”)
虽然可以创建共享节,但是,由于两个原因,Microsoft并不鼓励你使用共享节。第一,用这种方法共享内存有可能破坏系统的安全。第二,共享变量意味着一个应用程序中的错误可能影响另一个应用程序的运行,因为它没有办法防止某个应用程序将数据随机写入一个数据块。
17.2内存映射数据文件
操作系统使得内存能够将一个数据文件映射到进程的地址空间中。因此,对大量的数据进行操作是非常方便的。
为了理解用这种方法来使用内存映射文件的功能,让我们看一看如何用4种方法来实现一个程序,以便将文件中的所有字节的顺序进行倒序。
17.2.1方法1:一个文件,一个缓存
第一种方法也是理论上最简单的方法,它需要分配足够大的内存块来存放整个文件。该文件被打开,它的内容被读入内存块,然后该文件被关闭。
这种方法实现起来非常容易,但是它有两个缺点。首先,必须分配一个与文件大小相同的内存块。第二,如果进程在运行过程的中间被中断,也就是说当倒序后的字节被重新写入该文件时进程被中断,那么文件的内容就会遭到破坏。防止出现这种情况的最简单的方法是在对它的内容进行倒序之前先制作一个原始文件的拷贝。如果整个进程运行成功,那么可以删除该文件的拷贝。这种方法需要更多的磁盘空间。
17.2.2方法2:两个文件,一个缓存
在第二种方法中,你打开现有的文件,并且在磁盘上创建一个长度为0的新文件。然后分配一个比较小的内部缓存,比如说8KB。你找到离原始文件结尾还有8KB的位置,将这最后的8KB读入缓存,将字节倒序,再将缓存中的内容写入新创建的文件。这个寻找、读入、倒序和写入的操作过程要反复进行,直到到达原始文件的开头。如果文件的长度不是8KB的倍数,那么必须进行某些特殊的处理。当原始文件完全处理完毕之后,将原始文件和新文件关闭,并删除原始文件。
这种方法实现起来比第一种方法要复杂一些。它对内存的使用效率要高得多,因为它只需要分配一个8KB的缓存块,但是它存在两个大问题。首先,它的处理速度比第一种方法要慢,原因是在每个循环操作过程中,在执行读入操作之前,必须对原始文件进行寻找操作。第二,这种方法可能要使用大量的硬盘空间。如果原始文件是400MB,那么随着进程的不断运行,新文件就会增大为400MB。在原始文件被删除之前,两个文件总共需要占用800MB的磁盘空间。这比应该需要的空间大400MB。由于存在这个缺点,因此引来了下一个方法。
17.2.3方法3:一个文件,两个缓存
如果使用这个方法,那么我们假设程序初始化时分配了两个独立的8KB缓存。程序将文件的第一个8KB读入一个缓存,再将文件的第二个8KB读入另一个缓存。然后进程将两个缓存的内容进行倒序,并将第一个缓存的内容写回文件的结尾处,将第二个缓存的内容写回同一个文件的开始处。每个迭代操作不断进行(以8KB为单位,从文件的开始和结尾处移动文件块)。如果文件的长度不是16KB的倍数,并且有两个8KB的文件块相重叠,那么就需要进行一些特殊的处理。这种特殊处理比上一种方法中的特殊处理更加复杂,不过这难不倒经验丰富的编程员。
与前面的两种方法相比,这种方法在节省硬盘空间方面有它的优点。由于所有内容都是从同一个文件读取并写入同一个文件,因此不需要增加额外的磁盘空间,至于内存的使用,这种方法也不错,它只需要使用16KB的内存。当然,这种方法也许是最难实现的方法。与第一种方法一样,如果进程被中断,本方法会导致数据文件被破坏。
下面让我们来看一看如何使用内存映射文件来完成这个过程。
17.2.4方法4:一个文件,零缓存
当使用内存映射文件对文件内容进行倒序时,你打开该文件,然后告诉系统将虚拟地址空间的一个区域进行倒序。你告诉系统将文件的第一个字节映射到该保留区域的第一个字节。然后可以访问该虚拟内存的区域,就像它包含了这个文件一样。实际上,如果在文件的结尾处有一个单个0字节,那么只需要调用C运行期函数_strrev,就可以对文件中的数据进行倒序操作。这种方法的最大优点是,系统能够为你管理所有的文件缓存操作。不必分配任何内存,或者将文件数据加载到内存,也不必将数据重新写入该文件,或者释放任何内存块。但是,内存映射文件仍然可能出现因为电源故障之类的进程中断而造成数据被破坏的问题。
17.3使用内存映射文件
若要使用内存映射文件,必须执行下列操作步骤:
1)创建或打开一个文件内核对象,该对象用于标识磁盘上你想用作内存映射文件的文件。
2)创建一个文件映射内核对象,告诉系统该文件的大小和你打算如何访问该文件。
3)让系统将文件映射对象的全部或一部分映射到你的进程地址空间中。
当完成对内存映射文件的使用时,必须执行下面这些步骤将它清除:
1)告诉系统从你的进程的地址空间中撤消文件映射内核对象的映像。
2)关闭文件映射内核对象。
3)关闭文件内核对象。
17.3.1步骤1:创建或打开文件内核对象
若要创建或打开一个文件内核对象,总是要调用CreateFile函数:
17.3.2步骤2:创建一个文件映射内核对象
调用CreateFile函数,就可以将文件映像的物理存储器的位置告诉操作系统。你传递的路径名用于指明支持文件映像的物理存储器在磁盘(或网络或光盘)上的确切位置。这时,必须告诉系统,文件映射对象需要多少物理存储器。若要进行这项操作,可以调用CreateFileMapping函数:
17.3.3步骤3:将文件数据映射到进程的地址空间
当创建了一个文件映射对象后,仍然必须让系统为文件的数据保留一个地址空间区域,并将文件的数据作为映射到该区域的物理存储器进行提交。可以通过调用MapViewOfFile函数来进行这项操作:
17.3.4步骤4:从进程的地址空间中撤消文件数据的映像
当不再需要保留映射到你的进程地址空间区域中的文件数据时,可以通过调用UnmapViewOfFile函数将它释放:
17.3.5步骤5和步骤6:关闭文件映射对象和文件对象
不用说,你总是要关闭你打开了的内核对象。如果忘记关闭,在你的进程继续运行时会出现资源泄漏的问题。当然,当你的进程终止运行时,系统会自动关闭你的进程已经打开但是忘记关闭的任何对象。但是如果你的进程暂时没有终止运行,你将会积累许多资源句柄。因此你始终都应该编写清楚而又“正确的”代码,以便关闭你已经打开的任何对象。若要关闭文件映射对象和文件对象,只需要两次调用CloseHandle函数,每个句柄调用一次:
17.4使用内存映射文件来处理大文件
上一节讲过我要告诉你如何将一个16EB的文件映射到一个较小的地址空间中。当然,你是无法做到这一点的。你必须映射一个只包含一小部分文件数据的文件视图。首先映射一个文件的开头的视图。当完成对文件的第一个视图的访问时,可以取消它的映像,然后映射一个从文件中的一个更深的位移开始的新视图。必须重复这一操作,直到访问了整个文件。
这使得大型内存映射文件的处理不太方便,但是,幸好大多数文件都比较小,因此不会出现这个问题。
17.5内存映射文件与数据视图的相关性
系统允许你映射一个文件的相同数据的多个视图。例如,你可以将文件开头的10KB映射到一个视图,然后将同一个文件的头4KB映射到另一个视图。只要你是映射相同的文件映射对象,系统就会确保映射的视图数据的相关性。例如,如果你的应用程序改变了一个视图中的文件内容,那么所有其他视图均被更新以反映这个变化。这是因为尽管页面多次被映射到进程的虚拟地址空间,但是系统只将数据放在单个RAM页面上。如果多个进程映射单个数据文件的视图,那么数据仍然是相关的,因为在数据文件中,每个RAM页面只有一个实例——正是这个RAM页面被映射到多个进程的地址空间。
注意Windows允许创建若干个由单个数据文件支持的文件映射对象。Windows不能保证这些不同的文件映射对象的视图具有相关性。它只能保证单个文件映射对象的多个视图具有相关性。然而,当对文件进行操作时,没有理由使另一个应用程序无法调用CreateFile函数以打开由另一个进程映射的同一个文件。这个新进程可以使用ReadFile和WriteFile函数来读取该文件的数据和将数据写入该文件。当然,每当一个进程调用这些函数时,它必须从内存缓冲区读取文件数据或者将文件数据写入内存缓冲区。该内存缓冲区必须是进程自己创建的一个缓冲区,而不是映射文件使用的内存缓冲区。当两个应用程序打开同一个文件时,问题就可能产生:一个进程可以调用ReadFile函数来读取文件的一个部分,并修改它的数据,然后使用WriteFile函数将数据重新写入文件,而第二个进程的文件映射对象却不知道第一个进程执行的这些操作。
由于这个原因,当你为将被内存映射的文件调用CreateFile函数时,最好将dwShareMode参数的值设置为0。这样就可以告诉系统,你想要单独访问这个文件,而其他进程都不能打开它。只读文件不存在相关性问题,因此它们可以作为很好的内存映射文件。内存映射文件决不应该用于共享网络上的可写入文件,因为系统无法保证数据视图的相关性。如果某个人的计算机更新了文件的内容,其他内存中含有原始数据的计算机将不知道它的信息已经被修改。
17.6设定内存映射文件的基地址
正如你可以使用VirtualAlloc函数来确定对地址空间进行倒序所用的初始地址一样,你也可以使用MapViewOfFileEx函数而不是使用MapViewOfFile函数来确定一个文件被映射到某个特定的地址。该函数的所有参数和返回值均与MapViewOfFile函数相同,唯一的差别是最后一个参数pvBaseAddress有所不同。在这个参数中,你为要映射的文件设定一个目标地址。与VirtualAlloc一样,你设定的目标地址应该是分配粒度边界(64KB)的倍数,否则MapViewOfFileEx将返回NULL,表示出现了错误。
在Windows2000下,如果设定的地址不是分配粒度的倍数,就会导致函数运行失败,同时GetLastError将返回1132(ERROR_MAPPED_ALIGNMENT)。在Windows98中,该地址将圆整为分配粒度边界值。
如果系统无法将文件映射到该位置上(通常由于文件太大并且与另一个保留的地址空间相重叠),那么该函数的运行就会失败并且返回NULL。MapViewOfFileEx并不设法寻找另一个地址空间来放置该文件。当然,你可以设定NULL作为pvBaseAddress参数的值,这时,MapViewOfFileEx函数的运行特性与MapViewOfFile函数完全相同。
当你使用内存映射文件与其他进程共享数据时,你可以使用MapViewOfFileEx函数。例如,当两个或多个应用程序需要共享包含指向其他数据结构的一组数据结构时,可能需要在某个特定地址上的内存映射文件。链接表是个极好的例子。在链接表中,每个节点或元素均包含列表中的另一个元素的内存地址。若要遍历该列表,必须知道第一个元素的地址,然后参考包含下一个元素地址的元素成员。当使用内存映射文件时,这可能成为一个问题。
如果一个进程建立了内存映射文件中的链接表,然后与另一个进程共享该文件,那么另一个进程就可能将文件映射到它的地址空间中的一个完全不同的位置上。当第二个进程视图遍历该链接表时,它查看链接表的第一个元素,检索下一个元素的内存地址,然后设法引用下一个元素。然而,第一个节点中的下一个元素的地址并不是第二个进程需要查找的地址。
可以用两种办法来解决这个问题。首先,当第二个进程将包含链接表的内存映射文件映射到它自己的地址空间中去时,它只要调用MapViewOfFileEx函数而不是调用MapViewOfFile。
当然,这种方法要求第二个进程必须知道第一个进程原先在建立链接表时将文件映射到了什么地方。当两个应用程序打算互相进行交互操作时(这是非常可能的),这就不会出现任何问题,因为地址可以通过硬编码放入两个应用程序,或者一个进程可以通知另一个进程使用另一种进程间通信的方式,比如将消息发送到窗口。
第二个方法是创建链接表的进程将下一个节点所在的地址中的位移存放在每个节点中。这要求应用程序将该位移添加给内存映射文件的基地址,以便访问每个节点。这种方法并不高明,因为它的运行速度可能比较慢,它会使程序变得更大(因为编译器要生成附加代码来执行所有的计算操作),而且它很容易出错。但是,它仍然是个可行的方法,Microsoft的编译器为使用__based关键字的基本指针提供了辅助程序。
Windows98当调用MapViewOfFileEx时,必须设定0x80000000与0xBFFFFFFF之间的一个地址,否则MapViewOfFileEx将返回ULL。
Windows20000当调用MapViewOfFileEx时,必须设定在你的进程的用户方式分区中的一个地址,否则MapViewOfFileEx将返回NULL。
17.7实现内存映射文件的具体方法
Windows98和Windows2000实现内存映射文件的方法是不同的。必须知道这些差别,因为它们会影响你编写代码的方法,也会影响其他应用程序对你的数据进行不利的操作。
在Windows98下,视图总是映射到0x80000000至0xBFFFFFFF范围内的地址空间分区中。因此,对MapViewOfFile函数的成功调用都会返回这个范围内的一个地址。你也许还记得,所有进程都共享该分区中的数据。这意味着如果进程映射了文件映射对象的视图,那么该文件映射对象的数据实际上就可以被所有进程访问,而不管它们是否已经映射了该文件映射对象的视图。如果另一个进程调用使用同一个文件映射对象的MapViewOfFile函数,Windows98便将返回给第一个进程的同一个内存地址返回给第二个进程。这两个进程访问相同的数据,并且它们的视图具有相关性。
在Windows98中,一个进程可以调用MapViewOfFile函数,并且可以使用某种进程间的通信方式将返回的内存地址传递给另一个进程的线程。一旦该线程收到这个内存地址,该线程就可以成功地访问文件映射对象的同一个视图。但是,不应该这样做,原因有二。
•你的应用程序将无法在Windows2000下运行,其原因将在下面说明。
•如果第一个进程调用UnmapViewOfFile函数,地址空间区域将恢复为空闲状态,这意味着第二个进程的线程如果尝试访问视图曾经位于其中的内存,会引发一次访问违规。
如果第二个进程访问内存映射对象的视图,那么第二个进程中的线程应该调用MapViewOfFile函数。当第二个进程这样做的时候,系统将对内存映射视图的使用计数进行递增。因此,如果第一个进程调用UnmapViewOfFile函数,那么在第二个进程也调用UnmapViewOfFile之前,系统将不会释放视图占用的地址空间区域。
当第二个进程调用MapViewOfFile函数时,返回的地址将与第一个进程返回的地址相同。这样,第一个进程就没有必要使用进程间的通信方式将内存地址传送给第二个进程。
Windows2000实现内存映射文件的方法要比Windows98好,因为Windows2000要求在进程的地址空间中的文件数据可供访问之前,该进程必须调用MapViewOfFile函数。如果一个进程调用MapViewOfFile函数,系统将为调用进程的地址空间中的视图进行地址空间区域的倒序操作,这样,其他进程都将无法看到该视图。如果另一个进程想要访问同一个文件映射对象中的数据,那么第二个进程中的线程就必须调用MapViewOfFile,同时,系统将为第二个进程的地址空间中的视图进行地址空间区域的倒序操作。
值得注意的是,第一个进程调用MapViewOfFile函数后返回的内存地址,很可能不同于第二个进程调用MapViewOfFile函数后返回的内存地址。即使这两个进程映射了相同文件映射对象的视图,它们返回的地址也可能不同。在Windows98下,MapViewOfFile函数返回的内存地址是相同的,但是,如果想让你的应用程序在Windows2000下运行,那么绝对不应该指望它们也返回相同的地址。
17.8使用内存映射文件在进程之间共享数据
Windows总是出色地提供各种机制,使应用程序能够迅速而方便地共享数据和信息。这些机制包括RPC、COM、OLE、DDE、窗口消息(尤其是WM_COPYDATA)、剪贴板、邮箱、管道和套接字等。在Windows中,在单个计算机上共享数据的最低层机制是内存映射文件。不错,如果互相进行通信的所有进程都在同一台计算机上的话,上面提到的所有机制均使用内存映射文件从事它们的烦琐工作。如果要求达到较高的性能和较小的开销,内存映射文件是举手可得的最佳机制。
数据共享方法是通过让两个或多个进程映射同一个文件映射对象的视图来实现的,这意味着它们将共享物理存储器的同一个页面。因此,当一个进程将数据写入一个共享文件映射对象的视图时,其他进程可以立即看到它们视图中的数据变更情况。注意,如果多个进程共享单个文件映射对象,那么所有进程必须使用相同的名字来表示该文件映射对象。
让我们观察一个例子,启动一个应用程序。当一个应用程序启动时,系统调用CreateFile函数,打开磁盘上的.exe文件。然后系统调用CreateFileMapping函数,创建一个文件映射对象。
最后,系统代表新创建的进程调用MapViewOfFileEx函数(它带有SEC_IMAGE标志),这样,.exe文件就可以映射到进程的地址空间。这里调用的是MapViewOfFileEx,而不是MapViewOfFile,这样,文件的映像将被映射到存放在.exe文件映像中的基地址中。系统创建该进程的主线程,将该映射视图的可执行代码的第一个字节的地址放入线程的指令指针,然后CPU启动该代码的运行。
如果用户运行同一个应用程序的第二个实例,系统就认为规定的.exe文件已经存在一个文件映射对象,因此不会创建新的文件对象或者文件映射对象。相反,系统将第二次映射该文件的一个视图,这次是在新创建的进程的地址空间环境中映射的。系统所做的工作是将相同的文件同时映射到两个地址空间。显然,这是对内存的更有效的使用,因为两个进程将共享包含正在执行的这部分代码的物理存储器的同一个页面。
与所有内核对象一样,可以使用3种方法与多个进程共享对象,这3种方法是句柄继承性、句柄命名和句柄复制。关于这3种方法的详细说明,参见第3章的内容。
17.9页文件支持的内存映射文件
到现在为止,已经介绍了映射驻留在磁盘驱动器上的文件视图的方法。许多应用程序在运行时都要创建一些数据,并且需要将数据传送给其他进程,或者与其他进程共享。如果应用程序必须在磁盘驱动器上创建数据文件,并且将数据存储在磁盘上以便对它进行共享,那么这将是非常不方便的。
Microsoft公司认识到了这一点,并且增加了一些功能,以便创建由系统的页文件支持的内存映射文件,而不是由专用硬盘文件支持的内存映射文件。这个方法与创建内存映射磁盘文件所用的方法几乎相同,不同之处是它更加方便。一方面,它不必调用CreateFile函数,因为你不是要创建或打开一个指定的文件,你只需要像通常那样调用CreateFileMapping函数,并且传递INVALID_HANDLE_VALUE作为hFile参数。这将告诉系统,你不是创建其物理存储器驻留在磁盘上的文件中的文件映射对象,相反,你想让系统从它的页文件中提交物理存储器。分配的存储器的数量由CreateFileMapping函数的dwMaximumSizeHigh和dwMaximumSizeLow两个参数来决定。
当创建了文件映射对象并且将它的一个视图映射到进程的地址空间之后,就可以像使用任何内存区域那样使用它。如果你想要与其他进程共享该数据,可调用CreateFileMapping函数,并传递一个以0结尾的字符串作为pszName参数。然后,想要访问该存储器的其他进程就可以调用CreateFileMapping或OpenFileMapping函数,并传递相同的名字。
当进程不再想要访问文件映射对象时,该进程应该调用CloseHandle函数。当所有句柄均被关闭后,系统将从系统的页文件中收回已经提交的存储器。
17.10稀疏提交的内存映射文件
在迄今为止介绍的所有内存映射文件中,我们发现系统要求为内存映射文件提交的所有存储器必须是在磁盘上的数据文件中或者是在页文件中。这意味着我们不能根据我们的喜好来有效地使用存储器。让我们回到第15章中介绍电子表格的内容上来,比如说,你想要与另一个进程共享整个电子表格。如果我们使用内存映射文件,那么必须为整个电子表格提交物理存储器:
CELLDATA celldata[200][256];
如果CELLDATA结构的大小是128字节,那么这个数组需要6553600(200x256x128)字节的物理存储器。第15章讲过,如果用页文件为电子表格分配物理存储器,那么这是个不小的数目了,尤其是考虑到大多数用户只是将信息放入少数的单元格中,而大多数单元格却空闲不用时,这就显得有些浪费。
显然,我们宁愿将电子表格作为一个文件映射对象来共享,而不必预先提交所有的物理存储器。CreateFileMapping函数为这种操作提供了一种方法,即可以在fdwProtect参数中设定SEC_RESERVE或SEC_COMMIT标志。
只有当创建由系统的页文件支持的文件映射对象时,这些标志才有意义。SEC_COMMIT标志能使CreateFileMapping从系统的页文件中提交存储器。如果两个标志都不设定,其结果也一样。
当调用CreateFileMapping函数并传递SEC_RESERVE标志时,系统并不从它的页文件中提交物理存储器,它只是返回文件映射对象的一个句柄。这时可以调用MapViewOfFile或MapViewOfFileEx函数,创建该文件映射对象的视图。MapViewOfFile和MapViewOfFileEx将保留一个地址空间区域,并且不提交支持该区域的任何物理存储器。对保留区域中的内存地址进行访问的任何尝试均将导致线程引发访问违规。
现在我们得到的是一个保留的地址空间区域和用于标识该区域的文件映射对象的句柄。其他进程可以使用相同的文件映射对象来映射同一个地址空间区域的视图。物理存储器仍然没有被提交给该区域。如果其他进程中的线程试图访问它们区域中的视图的内存地址,这些线程将会引发访问违规。
下面是令人感兴趣的一些事情。若要将物理存储器提交给共享区域,线程需要做的操作只是调用VirtualAlloc函数:
第15章已经介绍了这个函数。调用VirtualAlloc函数将物理存储器提交给内存映射视图区域,就像是调用VirtualAlloc函数将存储器提交给开始时通过调用带有MEM_RESERVE标志的VirtualAlloc函数而保留的区域一样。而且,就像你可以提交稀疏地存在于用VirtualAlloc保留的区域中的存储器一样,你也可以提交稀疏地存在于用MapViewOfFile或MapViewOfFileEx保留的区域中的存储器。但是,当你将存储器提交给用MapViewOfFile或MapViewOfFileEx保留的区域时,已经映射了相同文件映射对象视图的所有进程这时就能够成功地访问已经提交的页面。
使用SEC_RESERVE标志和VirtualAlloc函数,就能够成功地与其他进程共享电子表格应用程序的CellData数组,并且能够非常有效地使用物理存储器。
Windows98通常情况下,当给VirtualAlloc函数传递的内存地址位于0x00400000至0x7FFFFFFF以外时,VirtualAlloc的运行就会失败。但是,当将物理存储器提交给使用SEC_RESERVE标志创建的内存映射文件时,必须调用VirtualAlloc函数,传递一个位于0x80000000至0xBFFFFFFF之间的内存地址。Windows98知道你正在把存储器提交给一个保留的内存映射文件,并且让这个函数调用取得成功。
注意在Windows2000下,无法使用VirtualFree函数从使用SEC_RESERVE标志保留的内存映射文件中释放存储器。但是,Windows98允许在这种情况下调用VirtualFree函数来释放存储器。
NT文件系统(NTFS5)提供了对稀疏文件的支持。这是个非常出色的新特性。使用这个新的稀疏文件特性,能够很容易地创建和使用稀疏内存映射文件,在这些稀疏内存映射文件中,存储器包含在通常的磁盘文件中,而不是在系统的页文件中。
下面是如何使用稀疏文件特性的一个例子。比如,你想要创建一个MMF文件,以便存放记录的音频数据。当用户说话时,你想要将数字音频数据写入内存缓冲区,并且让该缓冲区得到磁盘上的一个文件的支持。稀疏MMF当然是在你的代码中实现这个要求的最容易和最有效的方法。问题是你不知道用户在单击Stop(停止)按钮之前讲了多长时间。你可能需要一个足够大的文件来存放5分钟或5小时的数据,这两个时间长度的差别太大了。但是,当使用稀疏MMF时,数据文件的大小确实无关紧要。

第18章堆栈
对内存进行操作的第三个机制是使用堆栈。堆栈可以用来分配许多较小的数据块。例如,若要对链接表和链接树进行管理,最好的方法是使用堆栈,而不是第15章介绍的虚拟内存操作方法或第17章介绍的内存映射文件操作方法。堆栈的优点是,可以不考虑分配粒度和页面边界之类的问题,集中精力处理手头的任务。堆栈的缺点是,分配和释放内存块的速度比其他机制要慢,并且无法直接控制物理存储器的提交和回收。
从内部来讲,堆栈是保留的地址空间的一个区域。开始时,保留区域中的大多数页面没有被提交物理存储器。当从堆栈中进行越来越多的内存分配时,堆栈管理器将把更多的物理存储器提交给堆栈。物理存储器总是从系统的页文件中分配的,当释放堆栈中的内存块时,堆栈管理器将收回这些物理存储器。
Microsoft并没有以文档的形式来规定堆栈释放和收回存储器时应该遵循的具体规则,Windows98与Windows2000的规则是不同的。可以这样说,Windows98更加注重内存的使用,因此只要可能,它就收回堆栈。Windows2000更加注重速度,因此它往往较长时间占用物理存储器,只有在一段时间后页面不再使用时,才将它返回给页文件。Microsoft常常进行适应性测试并运行各种不同的条件,以确定在大部分时间内最适合的规则。随着使用这些规则的应用程序和硬件的变更,这些规则也会有所变化。如果了解这些规则对你的应用程序非常关键,那么请不要使用堆栈。相反,可以使用虚拟内存函数(即VirtualAlloc和VirtualFree),这样,就能够控制这些规则。
18.1进程的默认堆栈
当进程初始化时,系统在进程的地址空间中创建一个堆栈。该堆栈称为进程的默认堆栈。按照默认设置,该堆栈的地址空间区域的大小是1MB。但是,系统可以扩大进程的默认堆栈,使它大于其默认值。当创建应用程序时,可以使用/HEAP链接开关,改变堆栈的1MB默认区域大小。由于DLL没有与其相关的堆栈,所以当链接DLL时,不应该使用/HEAP链接开关。
/HEAP链接开关的句法如下:/HEAP:reserve[.commit]
许多Windows函数要求进程使用其默认堆栈。由于进程的默认堆栈可供许多Windows函数使用,你的应用程序有许多线程同时调用各种Windows函数,因此对默认堆栈的访问是顺序进行的。换句话说,系统必须保证在规定的时间内,每次只有一个线程能够分配和释放默认堆栈中的内存块。这种顺序访问方法对速度有一定的影响。如果你的应用程序只有一个线程,并且你想要以最快的速度访问堆栈,那么应该创建你自己的独立的堆栈,不要使用进程的默认堆栈。不幸的是,你无法告诉Windows函数不要使用默认堆栈,因此,它们对堆栈的访问总是顺序进行的。
单个进程可以同时拥有若干个堆栈。这些堆栈可以在进程的寿命期中创建和撤消。但是,
默认堆栈是在进程开始执行之前创建的,并且在进程终止运行时自动被撤消。不能撤消进程的默认堆栈。每个堆栈均用它自己的堆栈句柄来标识,用于分配和释放堆栈中的内存块的所有堆栈函数都需要这个堆栈句柄作为其参数。
可以通过调用GetProcessHeap函数获取你的进程默认堆栈的句柄:HANDLE GetProcessHeap();
18.2为什么要创建辅助堆栈
除了进程的默认堆栈外,可以在进程的地址空间中创建一些辅助堆栈。由于下列原因,你可能想要在自己的应用程序中创建一些辅助堆栈:
•保护组件。
•更加有效地进行内存管理。
•进行本地访问。
•减少线程同步的开销。
•迅速释放。
18.3如何创建辅助堆栈
你可以在进程中创建辅助堆栈,方法是让线程调用HeapCreate函数:
HANDLE HeapCreate(
DWORD fdwOptions,
SIZE_T dwInitialSize,
SIZE_T dwMaximumSize);
第一个参数fdwOptions用于修改如何在堆栈上执行各种操作。你可以设定0、HEAP_NO_SERIALIZE、HEAP_GENERATE_EXCEPTIONS或者是这两个标志的组合。
按照默认设置,堆栈将顺序访问它自己,这样,多个线程就能够分配和释放堆栈中的内存块而不至于破坏堆栈。当试图从堆栈分配一个内存块时,HeapAlloc函数(下面将要介绍)必须执行下列操作:
1)遍历分配的和释放的内存块的链接表。
2)寻找一个空闲内存块的地址。
3)通过将空闲内存块标记为“已分配”分配新内存块。
4)将新内存块添加给内存块链接表。
下面这个例子说明为什么应该避免使用HEAP_NO_SERIALIZE标志。假定有两个线程试图同时从同一个堆栈中分配内存块。线程1执行上面的第一步和第二步,获得了空闲内存块的地址。但是,在该线程可以执行第三步之前,它的运行被线程2抢占,线程2得到一个机会来执行上面的第一步和第二步。由于线程1尚未执行第三步,因此线程2发现了同一个空闲内存块的地址。
由于这两个线程都发现了堆栈中它们认为是空闲的内存块,因此线程1更新了链接表,给新内存块做上了“已分配”的标记。然后线程2也更新了链接表,给同一个内存块做上了“已分配”标记。到现在为止,两个线程都没有发现问题,但是两个线程得到的是完全相同的内存块的地址。
这种类型的错误是很难跟踪的,因为它不会立即表现出来。相反,这个错误会在后台等待着,直到很不适合的时候才显示出来。可能出现的问题是:
•内存块的链接表已经被破坏。在试图分配或释放内存块之前,这个问题不会被发现。
•两个线程共享同一个内存块。线程1和线程2会将信息写入同一个内存块。当线程1查看该内存块的内容时,它将无法识别线程2提供的数据。
•一个线程可能继续使用该内存块并且将它释放,导致另一个线程改写未分配的内存。这将破坏该堆栈。
解决这个问题的办法是让单个线程独占对堆栈和它的链接表的访问权,直到该线程执行了对堆栈的全部必要的操作。如果不使用HEAP_NO_SERIALIZE标志,就能够达到这个目的。
只有当你的进程具备下面的一个或多个条件时,才能安全地使用HEAP_NO_SERIALIZE标志:
•你的进程只使用一个线程。
•你的进程使用多个线程,但是只有单个线程访问该堆栈。
•你的进程使用多个线程,但是它设法使用其他形式的互斥机制,如关键代码段、互斥对象和信标(第8、9章中介绍),以便设法自己访问堆栈。
如果对是否可以使用HEAP_NO_SERIALIZE标志没有把握,那么请不要使用它。如果不使用该标志,每当调用堆栈函数时,线程的运行速度会受到一定的影响,但是不会破坏你的堆栈及其数据。
另一个标志HEAP_GENERATE_EXCEPTIONS,会在分配或重新分配堆栈中的内存块的尝试失败时,导致系统引发一个异常条件。所谓异常条件,只不过是系统使用的另一种方法,以便将已经出现错误的情况通知你的应用程序。有时在设计应用程序时让它查看异常条件比查看返回值要更加容易些。异常条件将在第23、24和25章中介绍。
HeapCreate的第二个参数dwInitialSize用于指明最初提交给堆栈的字节数。如果必要的话,HeapCreate函数会将这个值圆整为CPU页面大小的倍数。最后一个参数dwMaximumSize用于指明堆栈能够扩展到的最大值(即系统能够为堆栈保留的地址空间的最大数量)。如果dwMaximumSize大于0,那么你创建的堆栈将具有最大值。如果尝试分配的内存块会导致堆栈超过其最大值,那么这种尝试就会失败。
如果dwMaximumSize的值是0,那么可以创建一个能够扩展的堆栈,它没有内在的限制。
从堆栈中分配内存块只需要使堆栈不断扩展,直到物理存储器用完为止。如果堆栈创建成功,HeapCreate函数返回一个句柄以标识新堆栈。该句柄可以被其他堆栈函数使用。
18.3.1从堆栈中分配内存块
若要从堆栈中分配内存块,只需要调用HeapAlloc函数:
PVOID HeapAlloc(
HANDLE hHeap,
DWORD fdwFlags,
SIZE_T dwBytes);
第一个参数hHeap用于标识分配的内存块来自的堆栈的句柄。dwBytes参数用于设定从堆栈中分配的内存块的字节数。参数fdwFlags用于设定影响分配的各个标志。目前支持的标志只有3个,即HEAP_ZERO_MEMORY、HEAP_GENERATE_EXCEPTIONS和HEAP_NO_SERIALIZE。
HEAP_ZERO_MEMORY标志的作用应该是非常清楚的。该标志使得HeapAlloc在返回前用0来填写内存块的内容。第二个标志HEAP_GENERATE_EXCEPTIONS用于在堆栈中没有足够的内存来满足需求时使HeapAlloc函数引发一个软件异常条件。最后一个标志HEAP_NO_SERIALIZE可以用来强制对HeapAlloc函数的调用与访问同一个堆栈的其他线程不按照顺序进行。在使用这个标志时应该格外小心,因为如果其他线程在同一时间使用该堆栈,那么堆栈就会被破坏。当从你的进程的默认堆栈中分配内存块时,决不要使用这个标志,因为数据可能被破坏,你的进程中的其他线程可能在同一时间访问默认堆栈。
Windows98如果调用HeapAlloc函数并且要求分配大于256MB的内存块,Windows98就将它看成是一个错误,函数的调用将失败。注意,在这种情况下,该函数总是返回NULL,并且不会引发异常条件,即使你在创建堆栈或者试图分配内存块时使用HEAP_GENERATE_EXCEPTIONS标志,也不会引发异常条件。
注意当你分配较大的内存块(大约1MB或者更大)时,最好使用VirtualAlloc函数,应该避免使用堆栈函数。
18.3.2改变内存块的大小
如果要改变内存块的大小,可以调用HeapReAlloc函数:
PVOID HeapReAlloc(
HANDLE hHeap,
DWORD fdwFlags,
PVOID pvMem,
SIZE_T dwBytes);
与其他情况一样,hHeap参数用于指明包含你要改变其大小的内存块的堆栈。fdwFlags参数用于设定改变内存块大小时HeapReAlloc函数应该使用的标志。可以使用的标志只有下面4个,即HEAP_GENERATE_EXCEPTIONS、HEAP_NO_SERIALIZE、HEAP_ZERO_MEMORY和HEAP_REALLOC_IN_PLACE_ONLY。
前面两个标志在用于HeapAlloc时,其作用相同。HEAP_ZERO_MEMORY标志只有在你扩大内存块时才使用。在这种情况下,内存块中增加的字节将被置0。如果内存块已经被缩小,那么该标志不起作用。
HEAP_REALLOC_IN_PLACE_ONLY标志告诉HeapReAlloc函数,它不能移动堆栈中的内存块。如果内存块在增大,HeapReAlloc函数可能试图移动内存块。如果HeapReAlloc能够扩大内存块而不移动它,那么它将会这样做并且返回内存块的原始地址。另外,如果HeapReAlloc必须移动内存块的内容,则返回新的较大内存块的地址。如果内存块被缩小,HeapReAlloc将返回内存块的原始地址。如果内存块是链接表或二进制树的组成部分,那么可以设定HEAP_REALLOC_IN_PLACE_ONLY标志。在这种情况下,链接表或二进制树中的其他节点可能拥有该节点的指针,改变堆栈中的节点位置会破坏链接表的完整性。
其余的两个参数pvMem和dwBytes用于设定你要改变其大小的内存块的地址和内存块的新的大小(以字节为计量单位)。HeapReAlloc既可以返回新的改变了大小的内存块的地址,也可以在内存块不能改变大小时返回NULL。
18.3.3了解内存块的大小
当内存块分配后,可以调用HeapSize函数来检索内存块的实际大小:
SIZE_T HeapSize(
HANDLE hHeap,
DWORD fdwFlags,
LPCVOID pvMem);
参数hHeap用于标识堆栈,参数pvMem用于指明内存块的地址。参数fdwFlags既可以是0,也可以是HEAP_NO_SERIALIZE。
18.3.4释放内存块
当不再需要内存块时,可以调用HeapFree函数将它释放:
BOOL HeapFree(
HANDLE hHeap,
DWORD fdwFlags,
LPCVOID pvMem);
HeapFree函数用于释放内存块,如果它运行成功,便返回TRUE。参数fdwFlags既可以是0,也可以是HEAP_NO_SERIALIZE。调用这个函数可使堆栈管理器收回某些物理存储器,但是这没有保证。
18.3.5撤消堆栈
如果应用程序不再需要它创建的堆栈,可以通过调用HeapDestroy函数

抱歉!评论已关闭.