第16章线程的堆栈
有时系统会在你自己进程的地址空间中保留一些区域。第3章讲过,对于进程和线程环境块来说,就会出现这种情况。另外,系统也可以在你自己进程的地址空间中为线程的堆栈保留一些区域。
每当创建一个线程时,系统就会为线程的堆栈(每个线程有它自己的堆栈)保留一个堆栈空间区域,并将一些物理存储器提交给这个已保留的区域。按照默认设置,系统保留1MB的地址空间并提交两个页面的内存。但是,这些默认值是可以修改的,方法是在你链接应用程序时设定Microsoft的链接程序的/STACK选项:/STACK:reserve[,commit]
当创建一个线程的堆栈时,系统将会保留一个链接程序的/STACK开关指明的地址空间区域。但是,当调用CreateThread或_beginthreadex函数时,可以重载原先提交的内存数量。这两个函数都有一个参数,可以用来重载原先提交给堆栈的地址空间的内存数量。如果设定这个参数为0,那么系统将使用/STACK开关指明的已提交的堆栈大小值。后面将假定我们使用默认的堆栈大小值,即1MB的保留区域,每次提交一个页面的内存。
图16-1显示了在页面大小为4KB的计算机上的一个堆栈区域的样子(保留的起始地址是0x08000000)。该堆栈区域和提交给它的所有物理存储器均拥有页面保护属性PAGE_READWRITE。
图16-1线程的堆栈区域刚刚创建时的样子
当保留了这个区域后,系统将物理存储器提交给区域的顶部的两个页面。在允许线程启动运行之前,系统将线程的堆栈指针寄存器设置为指向堆栈区域的最高页面的结尾处(一个非常接近0x08100000的地址)。这个页面就是线程开始使用它的堆栈的位置。从顶部向下的第二个页面称为保护页面。当线程调用更多的函数来扩展它的调用树状结构时,线程将需要更多的堆栈空间。
每当线程试图访问保护页面中的存储器时,系统就会得到关于这个情况的通知。作为响应,系统将提交紧靠保护页面下面的另一个存储器页面。然后,系统从当前保护页面中删除保护页面的保护标志,并将它赋予新提交的存储器页面。这种方法使得堆栈存储器只有在线程需要时才会增加。最终,如果线程的调用树继续扩展,堆栈区域就会变成图16-2所示的样子。
最底下的页面总是被保留的,从来不会被提交。下面将要说明它的原因。
当系统将物理存储器提交给0x08001000地址上的页面时,它必须再执行一个操作,即它要引发一个EXCEPTION_STACK_OVERFLOW异常处理(在WinNT.h文件中定义为0xC00000FD)。通过使用结构化异常处理(SEH),你的程序将能得到关于这个异常处理条件的通知,并且能够实现适度恢复。关于SEH的详细说明,请参见第23、24和25章的内容。本章结尾处的Summation示例应用程序将展示如何对堆栈溢出进行适度恢复。
如果在出现堆栈溢出异常条件之后,线程继续使用该堆栈,那么在0x08001000地址上的页面中的全部内存均将被使用,同时,该线程将试图访问从0x08000000开始的页面中的内存。当该线程试图访问这个保留的(未提交的)内存时,系统就会引发一个访问违规异常条件。如果在线程试图访问该堆栈时引发了这个访问违规异常条件,线程就会陷入很大的麻烦之中。这时,系统就会接管控制权,并终止进程的运行—不仅终止线程的运行,而切终止整个进程的运行。
系统甚至不向用户显示一个消息框,整个进程都消失了!
下面要说明为什么堆栈区域的最后一个页面始终被保留着。这样做的目的是为了防止不小心改写进程使用的其他数据。可以看到,在0x07FF000这个地址上(0x08000000下面的一个页面),另一个地址空间区域已经提交了物理存储器。如果0x08000000地址上的页面包含物理存储器,系统将无法抓住线程访问已保留堆栈区域的尝试。如果堆栈深入到已保留堆栈区域的下面,那么线程中的代码就会改写进程的地址空间中的其他数据,这是个非常难以抓住的错误。
图16-2完整的线程堆栈区域
16.1Windows98下的线程堆栈
在Windows98下,堆栈的行为特性与Windows2000下的堆栈非常相似。但是它们之间存在某些重大的差别。
图16-3显示了Windows98下1MB的堆栈的各个区域的样子(从0x00530000地址上开始保留)。
首先请注意,尽管我们想要创建的堆栈大小最大只有1MB,但是堆栈区域的大小实际上是1MB加128KB。在Windows98中,每当为一个堆栈保留一个区域时,系统保留的区域实际上比要求的尺寸要大128KB。该堆栈位于该区域的中间,堆栈的前面有一个64KB的块,堆栈的后面是另一个64KB的块。
图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所示的样子。
图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函数将它撤消:
BOOL HeapDestroy(HANDLE hHeap);
调用HeapDestroy函数可以释放堆栈中包含的所有内存块,也可以将堆栈占用的物理存储器和保留的地址空间区域重新返回给系统。如果该函数运行成功,HeapDestroy返回TRUE。如果在进程终止运行之前没有显式撤消堆栈,那么系统将为你将它撤消。但是,只有当进程终止运行时,堆栈才能被撤消。如果线程创建了一个堆栈,当线程终止运行时,该堆栈将不会被撤消。在进程完全终止运行之前,系统不允许进程的默认堆栈被撤消。如果将进程的默认堆栈的句柄传递给HeapDestroy函数,系统将忽略对该函数的调用。
18.3.6用C++程序来使用堆栈
使用堆栈的最好方法之一是将堆栈纳入现有的C++程序。在C++中,调用new操作符,而不是调用通常的C运行期例程malloc,就可以执行类对象的分配操作。然后,当我们不再需要这个类对象时,调用delete操作符,而不是调用通常的C运行期例程free将它释放。
18.4其他堆栈函数
除了上面介绍的堆栈函数外,Windows还提供了若干个别的函数。下面对它们作一个简单的介绍。
ToolHelp的各个函数(第4章后面部分讲过)可以用来枚举进程的各个堆栈和这些堆栈中分配的内存块。关于这些函数的详细说明,请参见PlatformSDK文档中的下列函数:Heap32First、Heap32Next、Heap32ListFirst和Heap32ListNext。ToolHelp函数的优点在于,在Windows98和Windows2000中都能够使用它们。
本节中介绍的其他堆栈函数只存在于Windows2000中。
由于进程的地址空间中可以存在多个堆栈,因此可以使用GetProcessHeaps函数来获取现有堆栈的句柄:
DWORD GetProcessHeaps(
DWORD dwNumHeaps,
PHANDLE pHeaps);
HeapValidate函数用于验证堆栈的完整性:
BOOL HeapValidate (
HANDLE hHeaps,
DWORD fdwFlags,
LPCVOID pvMem);
调用该函数时,通常要传递一个堆栈句柄,一个值为0的标志(唯一的另一个合法标志是HEAP_NO_SERIALIZE),并且为pvMem传递NULL。然后,该函数将遍历堆栈中的内存块以确保所有内存块都完好无损。为了使该函数运行得更快,可以为参数pvMem传递一个特定的内存块的地址。这样做可使该函数只检查单个内存块的有效性。
若要合并地址中的空闲内存块并收回不包含已经分配的地址内存块的存储器页面,可以调用下面的函数:
UINT HeapCompact(
HANDLE hHeaps,
DWORD fdwFlags);
通常情况下,可以为参数fdwFlags传递0,但是也可以传递HEAP_NO_SERIALIZE。
下面两个函数HeapLock和HeapUnlock是结合在一起使用的:
BOOL HeapLock(HANDLE hHeap);
BOOL HeapUnlock(HANDLE hHeap);
这些函数是用于线程同步的。当调用HeapLock函数时,调用线程将成为特定堆栈的所有者。如果其他任何线程调用堆栈函数(设定相同的堆栈句柄),系统将暂停调用线程的运行,并且在堆栈被HeapUnlock函数解锁之前不允许它醒来。
HeapAlloc、HeapSize和HeapFree等函数在内部调用HeapLock和HeapUnlock函数来确保对堆栈的访问能够顺序进行。自己调用HeapLock或HeapUnlock这种情况是不常见的。
最后一个堆栈函数是HeapWalk:
BOOL HeapWalk(
HANDLE hHeap,
PPROCESS_HEAP_ENTRY pHeapEntry);
该函数只用于调试目的。它使你能够遍历堆栈的内容。可以多次调用该函数。
在循环调用HeapWalk的时候,必须使用HeapLock和HeapUnlock函数,这样,当遍历堆栈时,其他线程将无法分配和释放堆栈中的内存块.
第19章DLL基础
自从Microsoft公司推出第一个版本的Windows操作系统以来,动态链接库(DLL)一直是这个操作系统的基础。WindowsAPI中的所有函数都包含在DLL中。3个最重要的DLL是Kernel32.dll,它包含用于管理内存、进程和线程的各个函数;User32.dll,它包含用于执行用户界面任务(如窗口的创建和消息的传送)的各个函数;GDI32.dll,它包含用于画图和显示文本的各个函数。
Windows还配有若干别的DLL,它们提供了用于执行一些特殊任务的函数。例如,AdvAPI32.dll包含用于实现对象安全性、注册表操作和事件记录的函数;ComDlg32.dll包含常用对话框(如FileOpen和FileSave);ComCtl32.DLL则支持所有的常用窗口控件。
本章将要介绍如何为应用程序创建DLL。下面是为什么要使用DLL的一些原因:
•它们扩展了应用程序的特性。
•它们可以用许多种编程语言来编写。
•它们简化了软件项目的管理。
•它们有助于节省内存。
•它们有助于资源的共享。
•它们有助于应用程序的本地化。
•它们有助于解决平台差异。
•它们可以用于一些特殊的目的。
19.1DLL与进程的地址空间
创建DLL常常比创建应用程序更容易,因为DLL往往包含一组应用程序可以使用的自主函数。在DLL中通常没有用来处理消息循环或创建窗口的支持代码。DLL只是一组源代码模块,每个模块包含了应用程序(可执行文件)或另一个DLL将要调用的一组函数。当所有源代码文件编译后,它们就像应用程序的可执行文件那样被链接程序所链接。但是,对于一个DLL来说,你必须设定该连链程序的/DLL开关。这个开关使得链接程序能够向产生的DLL文件映像发出稍有不同的信息,这样,操作系统加载程序就能将该文件映像视为一个DLL而不是应用程序。
在应用程序(或另一个DLL)能够调用DLL中的函数之前,DLL文件映像必须被映射到调用进程的地址空间中。若要进行这项操作,可以使用两种方法中的一种,即加载时的隐含链接或运行期的显式链接。隐含链接将在本章的后面部分介绍,显式链接将在第20章中介绍。
一旦DLL的文件映像被映射到调用进程的地址空间中,DLL的函数就可以供进程中运行的所有线程使用。实际上,DLL几乎将失去它作为DLL的全部特征。对于进程中的线程来说,DLL的代码和数据看上去就像恰巧是在进程的地址空间中的额外代码和数据一样。当一个线程调用DLL函数时,该DLL函数要查看线程的堆栈,以便检索它传递的参数,并将线程的堆栈用于它需要的任何局部变量。此外,DLL中函数的代码创建的任何对象均由调用线程所拥有,而DLL本身从来不拥有任何东西。
例如,如果VirtualAlloc函数被DLL中的一个函数调用,那么将从调用线程的进程地址空间中保留一个地址空间的区域,该地址空间区域将始终处于保留状态,因为系统并不跟踪DLL中的函数保留该区域的情况。保留区域由进程所拥有,只有在线程调用VirtualFree函数或者进程终止运行时才被释放。
如你所知,可执行文件的全局变量和静态变量不能被同一个可执行文件的多个运行实例共享。Windows98能够确保这一点,方法是在可执行文件被映射到进程的地址空间时为可执行文件的全局变量和静态变量分配相应的存储器。Windows2000确保这一点的方法是使用第13章介绍的写入时拷贝(copy-on-write)机制。DLL中的全局变量和静态变量的处理方法是完全相同的。当一个进程将DLL的映像文件映射到它的地址空间中去时,系统将同时创建全局数据变量和静态数据变量的实例。
注意必须注意的是,单个地址空间是由一个可执行模块和若干个DLL模块组成的。这些模块中,有些可以链接到静态版本的C/C++运行期库,有些可以链接到一个DLL版本的C/C++运行期库,而有些模块(如果不是用C/C++编写的话)则根本不需要C/C++运行期库。许多开发人员经常会犯一个常见的错误,因为他们忘记了若干个C/C++运行期库可以存在于单个地址空间中。
19.2DLL的总体运行情况
为了全面理解DLL是如何运行的以及你和系统如何使用DLL,让我们首先观察一下DLL的整个运行情况。图19-1综合说明了它的所有组件一道配合运行的情况。
下面各节将更加详细地介绍这个进程的运行情况。
19.3创建DLL模块
当创建DLL时,要创建一组可执行模块(或其他DLL)可以调用的函数。DLL可以将变量、函数或C/C++类输出到其他模块。在实际工作环境中,应该避免输出变量,因为这会删除你的代码中的一个抽象层,使它更加难以维护你的DLL代码。此外,只有当使用同一个供应商提供的编译器对输入C++类的模块进行编译时,才能输出C++类。由于这个原因,也应该避免输出C++类,除非知道可执行模块的开发人员使用的工具与DLL模块开发人员使用的工具相同。
当创建DLL模块时,首先应该建立一个头文件,该文件包含了你想要输出的变量(类型和名字)和函数(原型和名字)。头文件还必须定义用于输出函数和变量的任何符号和数据结构。
你的DLL的所有源代码模块都应该包含这个头文件。另外,必须分配该头文件,以便它能够包含在可能输入这些函数或变量的任何源代码中。拥有单个头文件,供DLL创建程序和可执行模块的创建程序使用,就可以大大简化维护工作。
19.3.1输出的真正含义是什么
下面介绍一下__declspec(dllexport)修改符。当Microsoft的C/C++编译器看到变量、函数原型或C++类之前的这个修改符的时候,它就将某些附加信息嵌入产生的.obj文件中。当链接DLL的所有.obj文件时,链接程序将对这些信息进行分析。
当DLL被链接时,链接程序要查找关于输出变量、函数或C++类的信息,并自动生成一个.lib文件。该.lib文件包含一个DLL输出的符号列表。当然,如果要链接引用该DLL的输出符号的任何可执行模块,该.lib文件是必不可少的。除了创建.lib文件外,链接程序还要将一个输出符号表嵌入产生的DLL文件。这个输出节包含一个输出变量、函数和类符号的列表(按字母顺序排列)。该链接程序还将能够指明在何处找到每个符号的相对虚拟地址(RVA)放入DLL模块。
使用Microsoft的VisualStudio的DumpBin.exe实用程序(带有-exports开关),你能够看到DLL的输出节是个什么样子。
注意许多开发人员常常通过为函数赋予一个序号值来输出DLL函数。对于那些来自16位Windows环境的函数来说,情况尤其是如此。但是,Microsoft并没有公布系统DLL的序号值。当你的可执行模块或DLL模块链接到任何一个Windows函数时,Microsoft要求你使用符号的名字进行链接。如果你按照序号进行链接,那么你的应用程序有可能无法在其他Windows平台或将来的Windows平台上运行。
实际上,我就遇到过这样的情况。我曾经发布了一个示例应用程序,它使用MicrosoftSystemJournal中的序号。我的应用程序在WindowsNT3.1上运行得很好,但是当WindowsNT3.5推出时,我的应用程序就无法正确地运行。为了解决这个问题,我不得不用函数名代替序号。现在该应用程序既能够在WindowsNT3.1上运行,而且能够在所有更新的版本上运行。
19.4创建可执行模块
当创建可执行源代码文件时,必须加上DLL的头文件。如果没有头文件,输入的符号将不会被定义,而且编译器将会发出许多警告和错误消息。
可执行源代码文件不应该定义DLL的头文件前面的MYLIBAPI。当上面显示的这个可执行源代码文件被编译时,MYLIBAPI由MyLib.h头文件使用__declspec(dllimport)进行定义。当编译器看到修改变量、函数或C++类的__declspec(dllimport)时,它知道这个符号是从某个DLL模块输入的。它不知道是从哪个DLL模块输入的,并且它也不关心这个问题。编译器只想确保你用正确的方法访问这些输入的符号。现在你在源代码中可以引用输入的符号,一切都将能够正常工作。
接着,链接程序必须将所有.obj模块组合起来,创建产生的可执行模块。该链接程序必须确定哪些DLL包含代码引用的所有输入符号的DLL。因此你必须将DLL的.lib文件传递给链接程序。如前所述,.lib文件只包含DLL模块输出的符号列表。链接程序只想知道是否存在引用的符号和哪个DLL模块包含该符号。如果连接程序转换了所有外部符号的引用,那么可执行模块就因此而产生了。
19.5运行可执行模块
当一个可执行文件被启动时,操作系统加载程序将为该进程创建虚拟地址空间。然后,加载程序将可执行模块映射到进程的地址空间中。加载程序查看可执行模块的输入节,并设法找出任何需要的DLL,并将它们映射到进程的地址空间中。
由于该输入节只包含一个DLL名而没有它的路径名。因此加载程序必须搜索用户的磁盘驱动器,找出DLL。下面是加载程序的搜索顺序:
1)包含可执行映像文件的目录。
2)进程的当前目录。
3)Windows系统目录。
4)Windows目录。
5)PATH环境变量中列出的各个目录。
应该知道其他的东西也会影响加载程序对一个DLL的搜索(详细说明参见第20章)。当DLL模块映射到进程的地址空间中时,加载程序要检查每个DLL的输入节。如果存在输入节(通常它确实是存在的),那么加载程序便继续将其他必要的DLL模块映射到进程的地址空间中。加载程序将保持对DLL模块的跟踪,使模块的加载和映射只进行一次(尽管多个模块需要该模块)。
当所有的DLL模块都找到并且映射到进程的地址空间中之后,加载程序就会确定对输入的符号的全部引用。为此,它要再次查看每个模块的输入节。对于列出的每个符号,加载程序都要查看指定的DLL的输出节,以确定该符号是否存在。
如果Windows2000版本的消息框指明漏掉的是哪个函数,而不是显示用户难以识别的错误代码0xC000007B,那么这将是非常好的。也许下一个Windows版本能够做到这一点。
如果这个符号不存在,那么加载程序将要检索该符号的RVA,并添加DLL模块被加载到的虚拟地址空间(符号在进程的地址空间中的位置)。然后它将该虚拟地址保存在可执行模块的输入节中。这时,当代码引用一个输入符号时,它将查看调用模块的输入节,并且捕获输入符号的地址,这样它就能够成功地访问输入变量、函数或C++类的成员函数。好了,动态链接完成,进程的主线程开始执行,应用程序终于也开始运行了!
当然,这需要加载程序花费相当多的时间来加载这些DLL模块,并用所有使用输入符号的正确地址来调整每个模块的输入节。由于所有这些工作都是在进程初始化的时候进行的,因此应用程序运行期的性能不会降低。不过,对于许多应用程序来说,初始化的速度太慢是不行的。
为了缩短应用程序的加载时间,应该调整你的可执行模块和DLL模块的位置并且将它们连接起来。真可惜很少有开发人员知道如何进行这项操作,因为这些技术是非常重要的。如果每个公司都能够使用这些技术,系统将能运行的更好。实际上,我认为操作系统销售时应该配有一个能够自动执行这些操作的实用程序。下一章将要介绍对模块调整位置和进行连接的方法。
第20章DLL的高级操作技术
上一章介绍了DLL链接的基本方法,并且重点说明了隐含链接的技术,这是DLL链接的最常用的形式。虽然对于大多数应用程序来说,只要了解上一章介绍的知识就足够了,但是还可以使用DLL进行更多的工作。本章将要介绍与DLL相关的各种操作方法。大多数应用程序不一定需要这些方法,但是它们是非常有用的,所以应该对它们有所了解。
20.1DLL模块的显式加载和符号链接
如果线程需要调用DLL模块中的函数,那么DLL的文件映像必须映射到调用线程的进程地址空间中。可以用两种方法进行这项操作。第一种方法是让应用程序的源代码只引用DLL中包含的符号。这样,当应用程序启动运行时,加载程序就能够隐含加载(和链接)需要的DLL。第二种方法是在应用程序运行时让应用程序显式加载需要的DLL并且显式链接到需要的输出符号。换句话说,当应用程序运行时,它里面的线程能够决定它是否要调用DLL中的函数。该线程可以将DLL显式加载到进程的地址空间,获得DLL中包含的函数的虚拟内存地址,然后使用该内存地址调用该函数。这种方法的优点是一切操作都是在应用程序运行时进行的。
下面显示了一个应用程序是如何显式地加载DLL并且链接到它里面的符号的。
创造DLL:
1)建立带有输出原型/结构/符号的头文件。
2)建立实现输出函数/变量的C/C++源文件。
3)编译器为每个C/C++源文件生成.obj模块。
4)链接程序将生成DLL的.obj模块链接起来。
5)如果至少输出一个函数/变量,那么链接程序也生成.lib文件。
创造EXE:
6)建立带有输入原型/结构/符号的头文件(视情况而定)。
7)建立不引用输入函数/变量的C/C++源文件。
8)编译器为每个C/C++源文件生成.obj源文件。
9)链接程序将各个.obj模块链接起来,生成.exe文件。
注:DLL的lib文件是不需要的,因为并不直接引用输出符号。.exe文件不包含输入表。
运行应用程序:
10)加载程序为.exe创建模块地址空进程的主线程开始执行;应用程序启动运行。
显式加载DLL:
11)一个线程调用LoadLibrary(Ex)函数,将DLL加载到进程的地址空间这时线程可以调用GetProcAddress以便间接引用DLL的输出符号。
20.1.1显式加载DLL模块
无论何时,进程中的线程都可以决定将一个DLL映射到进程的地址空间,方法是调用下面两个函数中的一个:
HINSTANCE LoadLibrary(PCTSTR pszDllPathName);
HINSTANCE LoadLibraryEx(
PCTSTR pszDllPathName,
HANDLE hFile,
DWORD dwFlags);
这两个函数均用于找出用户系统上的文件映像(使用上一章中介绍的搜索算法),并设法将DLL的文件映像映射到调用进程的地址空间中。两个函数返回的HINSTANCE值用于标识文件映像映射到的虚拟内存地址。如果DLL不能被映射到进程的地址空间,则返回NULL。若要了解关于错误的详细信息,可以调用GetLastError.
你会注意到,LoadLibraryEx函数配有两个辅助参数,即hFile和dwFlags。参数hFile保留供将来使用,现在必须是NULL。对于参数dwFlags,必须将它设置为0,或者设置为DONT_RESOLVE_DLL_REFERENCES、LOAD_LIBRARY_AS_DATAFILE和LOAD_WITH_ALTERED_SEARCH_PATH等标志的一个组合。
1.DONT_RESOLVE_DLL_REFERENCES
DONT_RESOLVE_DLL_REFERENCES标志用于告诉系统将DLL映射到调用进程的地址空间中。通常情况下,当DLL被映射到进程的地址空间中时,系统要调用DLL中的一个特殊函数,即DllMain(本章后面介绍)。该函数用于对DLL进行初始化。DONT_RESOLVE_DLL_REFERENCES标志使系统不必调用DllMain函数就能映射文件映像。
此外,DLL能够输入另一个DLL中包含的函数。当系统将一个DLL映射到进程的地址空间中时,它也要查看该DLL是否需要其他的DLL,并且自动加载这些DLL。当DONT_RESOLVE_DLL_REFERENCES标志被设定时,系统并不自动将其他的DLL加载到进程的地址空间中。
2.LOAD_LIBRARY_AS_DATAFILE
LOAD_LIBRARY_AS_DATAFILE标志与DONT_RESOLVE_DLL_REFERENCES标志相类似,因为系统只是将DLL映射到进程的地址空间中,就像它是数据文件一样。系统并不花费额外的时间来准备执行文件中的任何代码。例如,当一个DLL被映射到进程的地址空间中时,系统要查看DLL中的某些信息,以确定应该将哪些页面保护属性赋予文件的不同的节。如果设定了LOAD_LIBRARY_AS_DATAFILE标志,系统将以它要执行文件中的代码时的同样方式来设置页面保护属性。
由于下面几个原因,该标志是非常有用的。首先,如果有一个DLL(它只包含资源,但不包含函数),那么可以设定这个标志,使DLL的文件映像能够映射到进程的地址空间中。然后可以在调用加载资源的函数时,使用LoadLibraryEx函数返回的HINSTANCE值。通常情况下,加载一个.exe文件,就能够启动一个新进程,但是也可以使用LoadLibraryEx函数将.exe文件的映像映射到进程的地址空间中。借助映射的.exe文件的HINSTANCE值,就能够访问文件中的资源。由于.exe文件没有DllMain函数,因此,当调用LoadLibraryEx来加载一个.exe文件时,必须设定LOAD_LIBRARY_AS_DATAFILE标志。
3.LOAD_WITH_ALTERED_SEARCH_PATH
LOAD_WITH_ALTERED_SEARCH_PATH标志用于改变LoadLibraryEx用来查找特定的DLL文件时使用的搜索算法。通常情况下,LoadLibraryEx按照第19章讲述的顺序进行文件的搜索。但是,如果设定了LOAD_WITH_ALTERED_SEARCH_PATH标志,那么LoadLibraryEx函数就按照下面的顺序来搜索文件:
1)pszDLLPathName参数中设定的目录。
2)进程的当前目录。
3)Windows的系统目录。
4)Windows目录。
5)PATH环境变量中列出的目录。
20.1.2显式卸载DLL模块
当进程中的线程不再需要DLL中的引用符号时,可以从进程的地址空间中显式卸载DLL,方法是调用下面的函数:BOOL FreeLibrary(HINSTANCE hinstDll);
必须传递HINSTANCE值,以便标识要卸载的DLL。该值是较早的时候调用LoadLibrary(Ex)而返回的值。
也可以通过调用下面的函数从进程的地址空间中卸载DLL:
VOID FreeLibraryAndExitThread(
HINSTANCE hinstDll,
DWORD dwExitCode);
该函数是在Kernel32.dll中实现的,如下所示:
VOID FreeLibraryAndExitThread(HINSTANCE hinstDll,DWORD dwExitCode){
FreeLibrary(hinstDll);
ExitThread(dwExitCode);
}
初看起来,这并不是个非常高明的代码,你可能不明白,为什么Microsoft要创建FreeLibraryAndExitThread这个函数。其原因与下面的情况有关:假定你要编写一个DLL,当它被初次映射到进程的地址空间中时,该DLL就创建一个线程。当该线程完成它的操作时,它通过调用FreeLibrary函数,从进程的地址空间中卸载该DLL,并且终止运行,然后立即调用ExitThread。
但是,如果线程分开调用FreeLibrary和ExitThread,就会出现一个严重的问题。这个问题是调用FreeLibrary会立即从进程的地址空间中卸载DLL。当调用的FreeLibrary返回时,包含对ExitThread调用的代码就不再可以使用,因此线程将无法执行任何代码。这将导致访问违规,同时整个进程终止运行。
但是,如果线程调用FreeLibraryAndExitThread,该函数调用FreeLibrary,使DLL立即被卸载。下一个执行的指令是在Kernel32.dll中,而不是在刚刚被卸载的DLL中。这意味着该线程能够继续执行,并且可以调用ExitThread。ExitThread使该线程终止运行并且不返回。
20.1.3显式链接到一个输出符号
一旦DLL模块被显式加载,线程就必须获取它要引用的符号的地址,方法是调用下面的函数:
FARPROC GetProcAddress(
HINSTANCE hinstDll,
PCSTR pszSymbolName);
参数hinstDll是调用LoadLibrary(Ex)或GetModuleHandle函数而返回的,它用于设定包含符号的DLL的句柄。参数pszSymbolName可以采用两种形式。第一种形式是以0结尾的字符串的地址,它包含了你想要其地址的符号的名字:
FARPROC pfn = GetProcAddress( hinstDll, “SomeFuncInDll”);
注意,参数pszSymbolName的原型是PCSTR,而不是PCTSTR。这意味着GetProcAddress函数只接受ANSI字符串,决不能将Unicode字符串传递给该函数,因为编译器/链接程序总是将符号名作为ANSI字符串存储在DLL的输出节中。
参数pszSymbolName的第二种形式用于指明你想要其地址的符号的序号:
FARPROC pfn = GetProcAddress( hinstDll,MAKEINTRESOURCE(2));
这种用法假设你知道你需要的符号名被DLL创建程序赋予了序号值2。同样,我要再次强调,Microsoft非常反对使用序号,因此你不会经常看到GetProcAddress的这个用法。
这两种方法都能够提供包含在DLL中的必要符号的地址。如果DLL模块的输出节中不存在你需要的符号,GetProcAddress就返回NULL,表示运行失败。
应该知道,调用GetProcAddress的第一种方法比第二种方法要慢,因为系统必须进行字符串的比较,并且要搜索传递的符号名字符串。对于第二种方法来说,如果传递的序号尚未被分配给任何输出的函数,那么GetProcAddress就会返回一个非NULL值。这个返回值将会使你的应用程序错误地认为你已经拥有一个有效的地址,而实际上你并不拥有这样的地址。如果试图调用该地址,肯定会导致线程引发一个访问违规。我在早期从事Windows编程时,并不完全理解这个行为特性,因此多次出现这样的错误。所以一定要小心(这个行为特性是应该避免使用序号而使用符号名的另一个原因)。
20.2DLL的进入点函数
一个DLL可以拥有单个进入点函数。系统在不同的时间调用这个进入点函数,这个问题将在下面加以介绍。这些调用可以用来提供一些信息,通常用于供DLL进行每个进程或线程的初始化和清除操作。如果你的DLL不需要这些通知信息,就不必在DLL源代码中实现这个函数。
注意函数名DllMain是区分大小写的。许多编程人员有时调用的函数是DLLMain。这是一个非常容易犯的错误,因为DLL这个词常常使用大写来表示。如果调用的进入点函数不是DllMain,而是别的函数,你的代码将能够编译和链接,但是你的进入点函数永远不会被调用,你的DLL永远不会被初始化。
参数hinstDll包含了DLL的实例句柄。与(w)WinMain函数的hinstExe参数一样,这个值用于标识DLL的文件映像被映射到进程的地址空间中的虚拟内存地址。通常应将这个参数保存在一个全局变量中,这样就可以在调用加载资源的函数(如DialogBox和LoadString)时使用它。最后一个参数是fImpLoad,如果DLL是隐含加载的,那么该参数将是个非0值,如果DLL是显式加载的,那么它的值是0。
参数fdwReason用于指明系统为什么调用该函数。该参数可以使用4个值中的一个。这4个值是:DLL_PROCESS_ATTACH、DLL_PROCESS_DETACH、DLL_THREAD_ATTACH或DLL_THREAD_DETACH。这些值将在下面介绍。
注意必须记住,DLL使用DllMain函数来对它们进行初始化。当你的DllMain函数执行时,同一个地址空间中的其他DLL可能尚未执行它们的DllMain函数。这意味着它们尚未初始化,因此你应该避免调用从其他DLL中输入的函数。此外,你应该避免从DllMain内部调用LoadLibrary(Ex)和FreeLibrary函数,因为这些函数会形式一个依赖性循环。
PlatformSDK文档说,你的DllMain函数只应该进行一些简单的初始化,比如设置本地存储器(第21章介绍),创建内核对象和打开文件等。你还必须避免调用User、Shell、ODBC、COM、RPC和套接字函数(即调用这些函数的函数),因为它们的DLL也许尚未初始化,或者这些函数可能在内部调用LoadLibrary(Ex)函数,这同样会形成一个依赖性循环。
另外,如果创建全局性的或静态的C++对象,那么应该注意可能存在同样的问题,因为在你调用DllMain函数的同时,这些对象的构造函数和析构函数也会被调用。
20.2.1DLL_PROCESS_ATTACH通知
当DLL被初次映射到进程的地址空间中时,系统将调用该DLL的DllMain函数,给它传递参数fdwReason的值DLL_PROCESS_ATTACH。只有当DLL的文件映像初次被映射时,才会出现这种情况。如果线程在后来为已经映射到进程的地址空间中的DLL调用LoadLibrary(Ex)函数,那么操作系统只是递增DLL的使用计数,它并不再次用DLL_PROCESS_ATTACH的值来调用DLL的DllMain函数。
当处理DLL_PROCESS_ATTACH时,DLL应该执行DLL中的函数要求的任何与进程相关的初始化。例如,DLL可能包含需要使用它们自己的堆栈(在进程的地址空间中创建)的函数。
通过在处理DLL_PROCESS_ATTACH通知时调用HeapCreate函数,该DLL的DllMain函数就能够创建这个堆栈。已经创建的堆栈的句柄可以保存在DLL函数有权访问的一个全局变量中。
当DllMain处理一个DLL_PROCESS_ATTACH通知时,DllMain的返回值能够指明DLL的初始化是否已经取得成功。如果对HeapCreate的调用取得了成功,DllMain应该返回TRUE。如果堆栈不能创建,它应该返回FALSE。如果fdwReason使用的是其他的值,即DLL_PROCESS_DETACH、DLL_THREAD_ATTACH和DLL_THREAD_DETACH,那么系统将忽略DllMain返回的值。
20.2.2DLL_PROCESS_DETACH通知
DLL从进程的地址空间中被卸载时,系统将调用DLL的DllMain函数,给它传递fdwReason的值DLL_PROCESS_DETACH。当DLL处理这个值时,它应该执行任何与进程相关的清除操作。例如,DLL可以调用HeapDestroy函数来撤消它在DLL_PROCESS_DETACH通知期间创建的堆栈。注意,如果DllMain函数接收到DLL_PROCESS_DETACH通知时返回FALSE,那么DllMain就不是用DLL_PROCESS_DETACH通知调用的。如果因为进程终止运行而使DLL被卸载,那么调用ExitProcess函数的线程将负责执行DllMain函数的代码。在正常情况下,这是应用程序的主线程。当你的进入点函数返回到C/C++运行期库的启动代码时,该启动代码将显式调用ExitProcess函数,终止进程的运行。
如果因为进程中的线程调用FreeLibrary或FreeLibraryAndExitThread函数而将DLL卸载,那么调用函数的线程将负责执行DllMain函数的代码。如果使用FreeLibrary,那么要等到DllMain函数完成对DLL_PROCESS_DETACH通知的执行后,该线程才从对FreeLibrary函数的调用中返回。
注意,DLL能够阻止进程终止运行。例如,当DllMain接收到DLL_PROCESS_DETACH通知时,它就会进入一个无限循环。只有当每个DLL都已完成对DLL_PROCESS_DETACH通知的处理时,操作系统才会终止该进程的运行。
注意如果因为系统中的某个线程调用了TerminateProcess而使进程终止运行,那么系统将不调用带有DLL_PROCESS_DETACH值的DLL的DllMain函数。这意味着映射到进程的地址空间中的任何DLL都没有机会在进程终止运行之前执行任何清除操作。这可能导致数据的丢失。只有在迫不得已的情况下,才能使用TerminateProcess函数。
20.2.3DLL_THREAD_ATTACH通知
当在一个进程中创建线程时,系统要查看当前映射到该进程的地址空间中的所有DLL文件映像,并调用每个文件映像的带有DLL_THREAD_ATTACH值的DllMain函数。这可以告诉所有的DLL执行每个线程的初始化操作。新创建的线程负责执行DLL的所有DllMain函数中的代码。只有当所有的DLL都有机会处理该通知时,系统才允许新线程开始执行它的线程函数。
当一个新DLL被映射到进程的地址空间中时,如果该进程内已经有若干个线程正在运行,那么系统将不为现有的线程调用带有DLL_THREAD_ATTACH值的DDL的DllMain函数。只有当新线程创建时DLL被映射到进程的地址空间中,它才调用带有DLL_THREAD_ATTACH值的DLL的DllMain函数。
另外要注意,系统并不为进程的主线程调用带有DLL_THREAD_ATTACH值的任何DllMain函数。进程初次启动时映射到进程的地址空间中的任何DLL均接收DLL_PROCESS_ATTACH通知,而不是DLL_THREAD_ATTACH通知。
20.2.4DLL_THREAD_DETACH通知
让线程终止运行的首选方法是使它的线程函数返回。这使得系统可以调用ExitThread来撤消该线程。ExitThread函数告诉系统,该线程想要终止运行,但是系统并不立即将它撤消。相反,它要取出这个即将被撤消的线程,并让它调用已经映射的DLL的所有带有DLL_THREAD_DETACH值的DllMain函数。这个通知告诉所有的DLL执行每个线程的清除操作。例如,DLL版本的C/C++运行期库能够释放它用于管理多线程应用程序的数据块。
注意,DLL能够防止线程终止运行。例如,当DllMain函数接收到DLL_THREAD_DETACH通知时,它就能够进入一个无限循环。只有当每个DLL已经完成对DLL_THREAD_DETACH通知的处理时,操作系统才会终止线程的运行。
注意如果因为系统中的线程调用TerminateThread函数而使该线程终止运行,那么系统将不调用带有DLL_THREAD_DETACH值的DLL的所有DllMain函数。这意味着映射到进程的地址空间中的任何一个DLL都没有机会在线程终止运行之前执行任何清除操作。这可能导致数据的丢失。与TerminateProcess一样,只有在迫不得已的时候,才可以使用TerminateThread函数。
如果当DLL被撤消时仍然有线程在运行,那么就不为任何线程调用带有DLL_THREAD_DETACH值的DllMain。可以在进行DLL_THREAD_DETACH的处理时查看这个情况,这样就能够执行必要的清除操作。
上述规则可能导致发生下面这种情况。当进程中的一个线程调用LoadLibrary来加载DLL时,系统就会调用带有DLL_PROCESS_ATTACH值的DLL的DllMain函数(注意,没有为该线程发送DLL_THREAD_ATTACH通知)。接着,负责加载DLL的线程退出,从而导致DLL的DllMain函数被再次调用,这次调用时带有DLL_THREAD_DETACH值。注意,DLL得到通知说,该线程将被撤消,尽管它从未收到DLL_THREAD_ATTACH的这个通知,这个通知告诉该库说线程已经附加。由于这个原因,当执行任何特定的线程清除操作时,必须非常小心。不过大多数程序在编写时就规定调用LoadLibrary的线程与调用FreeLibrary的线程是同一个线程。
20.2.5顺序调用DllMain
系统是顺序调用DLL的DllMain函数的。为了理解这样做的意义,可以考虑下面这样一个环境。假设一个进程有两个线程,线程A和线程B。该进程还有一个DLL,称为SomeDLL.dll,它被映射到了它的地址空间中。两个线程都准备调用CreateThread函数,以便再创建两个线程,即线程C和线程D。
当线程A调用CreateThread来创建线程C时,系统调用带有DLL_THREAD_ATTACH值的SomeDLL.dll的DllMain函数。当线程C执行DllMain函数中的代码时,线程B调用CreateThread函数来创建线程D。这时系统必须再次调用带有DLL_THREAD_ATTACH值的DllMain函数,这次是让线程D执行代码。但是,系统是顺序调用DllMain函数的,因此系统会暂停线程D的运行,直到线程C完成对DllMain函数中的代码的处理并且返回为止。
当线程C完成DllMain的处理后,它就开始执行它的线程函数。这时系统唤醒线程D,让它处理DllMain中的代码。当它返回时,线程D开始处理它的线程函数。
当CreateThread函数被调用时,系统首先创建线程的内核对象和线程的堆栈。然后它在内部调用WaitForSingleObject函数,传递进程的互斥对象的句柄。一旦新线程拥有该互斥对象,系统就让新线程用DLL_THREAD_ATTACH的值调用每个DLL的DllMain函数。只有在这个时候,系统才调用ReleaseMutex,释放对进程的互斥对象的所有权。由于系统采用这种方式来运行,因此添加对DisableThreadLibraryCalls的调用,并不会防止线程被暂停运行。防止线程被暂停运行的唯一办法是重新设计这部分源代码,使得WaitForSingleObject不会在任何DLL的DllMain函数中被调用。
20.3延迟加载DLL
MicrosoftVisualC++6.0提供了一个出色的新特性,它能够使DLL的操作变得更加容易。这个特性称为延迟加载DLL。延迟加载的DLL是个隐含链接的DLL,它实际上要等到你的代码试图引用DLL中包含的一个符号时才进行加载。延迟加载的DLL在下列情况下是非常有用的:
•如果你的应用程序使用若干个DLL,那么它的初始化时间就比较长,因为加载程序要将所有需要的DLL映射到进程的地址空间中。解决这个问题的方法之一是在进程运行的时候分开加载各个DLL。延迟加载的DLL能够更容易地完成这样的加载。
•如果调用代码中的一个新函数,然后试图在老版本的系统上运行你的应用程序,而该系统中没有该函数,那么加载程序就会报告一个错误,并且不允许该应用程序运行。你需要一种方法让你的应用程序运行,然后,如果(在运行时)发现该应用程序在老的系统上运行,那么你将不调用遗漏的函数。
下面让我们从比较容易的操作开始介绍,也就是使延迟加载DLL能够运行。首先,你象平常那样创建一个DLL。也要象平常那样创建一个可执行模块,但是必须修改两个链接程序开关,并且重新链接可执行模块。下面是需要添加的两个链接程序开关:
/Lib:DelayImp.lib
/DelayLoad:MyDll.dll
Lib开关告诉链接程序将一个特殊的函数--delayLoadHelper嵌入你的可执行模块。第二个开关将下列事情告诉链接程序:
•从可执行模块的输入节中删除MyDll.dll,这样,当进程被初始化时,操作系统的加载程序就不会显式加载DLL。
•将新的DelayImport(延迟输入)节(称为.didata)嵌入可执行模块,以指明哪些函数正在从MyDll.dll输入。
•通过转移到对--delayLoadHelper函数的调用,转换到对延迟加载函数的调用。
当应用程序运行时,对延迟加载函数的调用实际上是对--delayLoadHelper函数的调用。该函数引用特殊的DelayImport节,并且知道调用LoadLibrary之后再调用GetProcAddress。一旦获得延迟加载函数的地址,--delayLoadHelper就要安排好对该函数的调用,这样,将来的调用就会直接转向对延迟加载函数的调用。注意,当第一次调用同一个DLL中的其他函数时,必须对它们做好安排。另外,可以多次设定/delayLoad链接程序的开关,为想要延迟加载的每个DLL设定一次开关。
20.4函数转发器
函数转发器是DLL的输出节中的一个项目,用于将对一个函数的调用转至另一个DLL中的另一个函数。
如果调用下面的函数,GetProcAddress就会查看Kernel32的输出节,发现HeapAlloc是个转发函数,然后按递归方式调用GetProcAddress函数,查找NTDLL.dll的输出节中的RtlAl-locateHeap。
GetProcAddress(GetModuleHandle(“Kernel32”),”HeapAlloc”);
也可以利用DLL模块中的函数转发器。最容易的方法是像下面这样使用一个pragma指令:
#pragma comment(linker,”/export:SomeFunc=Dllwork.SomeOtherFunc”)
这个pragma告诉链接程序,被编译的DLL应该输出一个名叫SomeFunc的函数。但是SomeFunc函数的实现实际上位于另一个名叫SomeOtherFunc的函数中,该函数包含在称为DllWork.dll的模块中。必须为你想要转发的每个函数创建一个单独的pragma代码行。
20.5已知的DLL
操作系统提供的某些DLL得到了特殊的处理。这些DLL称为已知的DLL。它们与其他DLL基本相同,但是操作系统总是在同一个目录中查找它们,以便对它们进行加载操作。在注册表中有下面的关键字:
HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Session Manager\KnowDlls
当LoadLibrary或LoadLibraryEx被调用时,这些函数首先查看是否传递了包含.dll扩展名的DLL名字。如果没有传递,那么它们将使用通常的搜索规则来搜索DLL。
如果确实设定了.dll扩展名,那么这些函数将删除扩展名,然后搜索注册表关键字KnownDLL,以便确定它是否包含匹配的值名字。如果没有找到匹配的名字,便使用通常的搜索规则。但是,如果找到了匹配的值名字,系统将查找相关的值数据,并设法使用值数据来加载DLL。系统也开始在注册表中的DllDirectory值数据指明的目录中搜索DLL。按照默认设置,
Windows2000上的DllDirectory值的数据是%SystemRoot%\System32。
20.6DLL转移
Windows98 Windows98不支持DLL转移。
Microsoft给Windows2000增加了一个DLL转移特性。这个特性能够强制操作系统的加载程序首先从你的应用程序目录中加载文件模块。只有当加载程序无法在应用程序目录中找到该文件时,它才搜索其他目录。
为了强制加载程序总是首先查找应用程序的目录,要做的工作就是在应用程序的目录中放入一个文件。该文件的内容可以忽略,但是该文件必须称为AppName.local。
例如,如果有一个可执行文件的名字是SuperApp.exe,那么转移文件必须称为SuperApp.exe.local。
在系统内部,LoadLibrary(Ex)已经被修改,以便查看是否存在该文件。如果应用程序的目录中存在该文件,该目录中的模块就已经被加载。如果应用程序的目录中不存在这个模块,LoadLibrary(Ex)将正常运行。
对于已经注册的COM对象来说,这个特性是非常有用的。它使应用程序能够将它的COM对象DLL放入自己的目录,这样,注册了相同COM对象的其他应用程序就无法干扰你的操作。
20.7改变模块的位置
每个可执行模块和DLL模块都有一个首选的基地址,用于标识模块应该映射到的进程地址空间中的理想内存地址。当创建一个可执行模块时,链接程序将该模块的首选基地址设置为0x00400000。如果是DLL模块,链接程序设置的首选基地址是0x10000000。使用VisualStudio的DumpBin实用程序(带有/Headers开关),可以看到一个映像的首选基地址。
现在假设你设计的应用程序需要两个DLL。按照默认设置,链接程序将.exe模块的首选基地址设置为0x00400000,同时,链接程序将两个DLL模块的首选基地址均设置为0x10000000。如果想要运行.exe模块,那么加载程序便创建该虚拟地址空间,并将.exe模块映射到内存地址0x00400000中。然后加载程序将第一个DLL映射到内存地址0x10000000中。但是,当加载程序试图将第二个DLL映射到进程的地址空间中去时,它将无法把它映射到该模块的首选基地址中,必须改变该DLL模块的位置,将它放到别的什么地方。
改变可执行(或DLL)模块的位置是个非常可怕的过程,应该采取措施避免这样的操作。
如果你有多个模块需要加载到单个地址空间中,必须为每个模块设置不同的首选基地址。MicrosoftVisualStudio的ProjectSettings(项目设置)对话框使得这项操作变得非常容易。你只需要选定Link(链接)选项卡,再选定Output(输出)类别。在BaseAddress(基地址)域中(该域默认为空),可以输入一个数字。
另外,始终都应该从高位内存地址开始加载DLL,然后逐步向下加载到低位内存地址,以减少地址空间中出现的碎片。
注意首选基地址必须始终从分配粒度边界开始。在迄今为止的所有平台上,系统的分配粒度是64KB。将来这个分配粒度可能发生变化。第13章已经对分配粒度进行了详细的介绍。
好了,现在已经对所有的内容做了介绍。但是,如果将许多模块加载到单个地址空间中,情况将会如何呢?如果有一种非常容易的方法,可以为所有的模块设置很好的首选基地址,那就好了。幸运的是,这种方法确实有。
VisualStudio配有一个实用程序,称为Rebase.exe。
当你执行Rebase程序,为它传递一组映象文件名时,它将执行下列操作:
1)它能够仿真创建一个进程的地址空间。
2)它打开通常被加载到该地址空间中的所有模块。
3)它仿真改变各个模块在仿真地址空间中的位置,这样,各个模块就不会重叠。
4)对于已经移位的模块,它会分析该模块的移位节,并修改磁盘上的模块文件中的代码。
5)它会更新每个移位模块的头文件,以反映新的首选基地址。
Rebase是个非常出色的工具,建议尽可能使用这个工具。应该在接近你的应用程序模块创建周期结束时运行这个实用程序,直到所有模块创建完成。另外,如果使用Rebase实用程序,可以忽略ProjectSettings对话框中的基地址的设置。链接程序将为DLL提供一个基地址0x10000000,但是Rebase会修改这个地址。
顺便要指出的是,决不应该改变操作系统配备的任何模块的地址。Microsoft在销售Windows操作系统之前,在操作系统提供的所有文件上运行了Rebase程序,因此,如果将它们映射到单个地址空间中,所有的操作系统模块都不会重叠。
20.8绑定模块
模块的移位是非常重要的,它能够大大提高整个系统的运行性能。但是,还可以使用许多别的办法来提高它的性能。比如说,可以改变应用程序的所有模块的位置。让我们回忆一下第19章中关于加载程序如何查看所有输入符号的地址的情况。加载程序将符号的虚拟地址写入可执行模块的输入节中。这样就可以参考输入的符号,以便到达正确的内存位置。
让我们进一步考虑一下这个问题。如果加载程序将输入符号的虚拟地址写入.exe模块的输入节,那么拷贝输入节的这些页面将被写入虚拟地址。由于这些页面是写入时拷贝的页面,因此这些页面将被页文件拷贝。这样我们就遇到了一个与改变模块的位置相类似的问题,即映像文件的各个部分将与系统的页文件之间来回倒腾,而不是在必要时将它们删除或者从文件的磁盘映像中重新读取。另外,加载程序必须对(所有模块的)所有输入符号的地址进行转换,这是很费时间的。
可以将模块绑定起来,使应用程序能够更快的进行初始化,并且使用较少的存储器。绑定一个模块时,可以为该模块的输入节配备所有输入符号的虚拟地址。为了缩短初始化的时间和使用较少的存储器,当然必须在加载模块之前进行这项操作。
VisualStudio配有另一个实用程序,名字是Bind.exe,当执行Bind程序,传递给它一个映像文件名时,它将执行下列操作:
1)打开指定映像文件的输入节。
2)对于输入节中列出的每个DLL,它打开该DLL文件,查看它的头文件以确定它的首选基地址。
3)查看DLL的输出节中的每个输入符号。
4)取出符号的RVA,并将模块的首选基地址与它相加。将可能产生的输入符号的虚拟地址写入映像文件的输入节中。
5)将某些辅助信息添加到映像文件的输入节中。这些信息包括映像文件绑定到的所有DLL模块的名字和这些模块的时戳。
在执行整个进程期间,Bind程序做了两个重要的假设:
•当进程初始化时,需要的DLL实际上加载到了它们的首选基地址中。可以使用前面介绍的Rebase实用程序来确保这一点。
•自从绑定操作执行以来,DLL的输出节中引用的符号的位置一直没有改变。加载程序通过将每个DLL的时戳与上面第5个步骤中保存的时戳进行核对来核实这个情况。
当然,如果加载程序确定上面的两个假设中有一个是假的,那么Bind就没有执行上面所说的有用的操作,加载程序必须通过人工来修改可执行模块的输入节,就像它通常所做的那样。但是,如果加载程序发现模块已经连接,需要的DLL已经加载到它们的首选基地址中,而且时戳也匹配,那么它实际上已经无事可做。它不必改变任何模块的位置,也不必查看任何输入函数的虚拟地址。该应用程序只管启动运行就是了。
此外,它不需要来自系统的页文件的任何存储器。
好了,现在你已经知道应该将应用程序配有的所有模块连接起来。但是应该在什么时候进行模块的连接呢?如果你在你的公司连接这些模块,可以将它们与你已经安装的系统DLL绑定起来,而这些系统DLL并不一定是用户已经安装的。由于不知道用户运行的是Windows98还是WindowsNT,或者是Windows2000,也不知道这些操作系统是否已经安装了服务软件包,因此应该将绑定操作作为应用程序的安装操作的一部分来进行。
当然,如果用户能够对Windows98和Windows2000进行双重引导,那么绑定的模块可能对这两个操作系统之一来说是不正确的。另外,如果用户在Windows2000下安装你的应用程序,然后又升级到你的服务软件包,那么模块的绑定也是不正确的。在这些情况下,你和用户都可能无能为力。Microsoft应该在销售操作系统时配备一个实用程序,使得操作系统升级后能够自动重新绑定每个模块。不过,现在还不存在这样的实用程序。
第21章线程本地存储器
有时,将数据与对象的实例联系起来是很有帮助的。例如,窗口的附加字节可以使用SetWindowsWord和SetWindowLong函数将数据与特定的窗口联系起来。可以使用线程本地存储器将数据与执行的特定线程联系起来。例如,可以将线程的某个时间与线程联系起来。然后,当线程终止运行时,就能够确定线程的寿命。
C/C++运行期库要使用线程本地存储器(TLS)。由于运行期库是在多线程应用程序出现前的许多年设计的,因此运行期库中的大多数函数是用于单线程应用程序的。函数strtok就是个很好的例子。应用程序初次调用strtok时,该函数传递一个字符串的地址,并将字符串的地址保存在它自己的静态变量中。当你将来调用strtok函数并传递NULL时,该函数就引用保存的字符串地址。
在多线程环境中,一个线程可以调用strtok,然后,在它能够再次调用该函数之前,另一个线程也可以调用Strtok。在这种情况下,第二个线程会在第一个线程不知道的情况下,让strtok用一个新地址来改写它的静态变量。第一个线程将来调用strtok时将使用第二个线程的字符串,这就会导致各种各样难以发现和排除的错误。
为了解决这个问题,C/C++运行期库使用了TLS。每个线程均被赋予它自己的字符串指针,供strtok函数使用。需要予以同样对待的其他C/C++运行期库函数还有asctime和gmtime。如果你的应用程序需要严重依赖全局变量或静态变量,那么TLS能够帮助解决它遇到的问题。但是编程人员往往尽可能减少对这些变量的使用,而更多地依赖自动(基于堆栈的)变量和通过函数的参数传递的数据。这样做是很好的,因为基于堆栈的变量总是与特定的线程相联系的。
标准的C运行期库一直是由许多不同的编译器供应商来实现和重新实现的。如果C编译器不包含标准的C运行期库,那么就不值得去购买它。编程员多年来一直使用标准的C运行期库,并且将会继续使用它,这意味着strtok之类的函数的原型和行为特性必须与上面所说的标准C运行期库完全一样。如果今天重新来设计C运行期库,那么它就必须支持多线程应用程序的环境,并且必须采取相应的措施来避免使用全局变量和静态变量。
在编写应用程序和DLL时,可以使用本章中介绍的两种TLS方法,即动态TLS和静态TLS。
但是,当创建DLL时,这些TLS往往更加有用,因为DLL常常不知道它们链接到的应用程序的结构。不过,当编写应用程序时,你通常知道将要创建多少线程以及如何使用这些线程。然后就可以创造一些临时性的方法,或者最好是使用基于堆栈的方法(局部变量),将数据与创建的每个线程联系起来。不管怎样,应用程序开发人员也能从本章讲述的内容中得到一些启发。
21.1动态TLS
若要使用动态TLS,应用程序可以调用一组4个函数。这些函数实际上是DLL用得最多的函数。
若要使用动态TLS,首先必须调用TlsAlloc函数:DWORD TlsAlloc();
这个函数命令系统对进程中的位标志进行扫描,并找出一个FREE标志。然后系统将该标志从FREE改为INUSE,并且TlsAlloc返回位数组中的标志的索引。DLL(或应用程序)通常将该索引保存在一个全局变量中。这是全局变量作为一个较好选择的情况之一,因为它的值是每个进程而不是每个线程使用的值。
如果TlsAlloc在该列表中找不到FREE标志,它就返回TLS_OUT_OF_INDEXES(在WinBase.h中定义为0xFFFFFFFF)。当TlsAlloc第一次被调用时,系统发现第一个标志是FREE,并将该标志改为INUSE,同时TlsAlloc返回0。TlsAlloc这样运行的概率是99%。下面介绍在另外的1%的概率下TlsAlloc是如何运行的。
当创建一个线程时,便分配一个TLS_MINIMUM_AVAILABLEPVOID值的数组,并将它初始化为0,然后由系统将它与线程联系起来。如图21-1所示,每个线程均得到它自己的数组,数组中的每个PVOID可以存储任何值。
在能够将信息存储在线程的PVOID数组中之前,必须知道数组中的哪个索引可供使用,这就是前面调用TlsAlloc所要达到的目的。按照设计概念,TlsAlloc为你保留了一个索引。如果TlsAlloc返回索引3,那么就说明目前在进程中运行的每个线程中均为你保留了索引3,而且在将来创建的线程中也保留了索引3。
若要将一个值放入线程的数组中,可以调用TlsSetValue函数:
BOOL TlsSetValue(
DWORD dwTlsIndex,
PVOID pvTlsValue);
该函数将一个PVOID值(用pvTlsValue参数标识)放入线程的数组中由dwTlsIndex参数标识的索引处。PvTlsValue的值与调用TlsSetValue的线程相联系。如果调用成功,便返回TRUE。
线程在调用TlsSetValue时,可以改变它自己的数组。但是它不能为另一个线程设置TLS值。我希望有另一个Tls函数能够用于使一个线程将数据存储到另一个线程的数组中,但是不存在这样一个函数。目前,将数据从一个线程传递到另一个线程的唯一方法是,将单个值传递给CreateThread或_beginthreadex,然后该函数将该值作为唯一的参数传递给线程的函数。
当调用TlsSetValue时,始终都应该传递较早的时候调用的TlsAlloc函数返回的索引。Microsoft设计的这些函数能够尽快地运行,在运行时,将放弃错误检查。如果传递的索引是调用TlsAlloc时从未分配的,那么系统将设法把该值存储在线程的数组中,而不进行任何错误检查。
若要从线程的数组中检索一个值,可以调用TlsGetValue:
PVOID TlsGetValue(DWORD dwTlsIndex);
该函数返回的值将与索引dwTlsIndex处的TLS时隙联系起来。与TlsSetValue一样,TlsGetValue只查看属于调用线程的数组。还有,TlsGetValue并不执行任何测试,以确定传递的索引的有效性。
当在所有线程中不再需要保留TLS时隙的位置的时候,应该调用TlsFree:
BOOL TlsFree(DWORD dwTlsIndex);
该函数简单地告诉系统该时隙不再需要加以保留。由进程的位标志数组管理的INUSE标志再次被设置为FREE。如果线程在后来调用TlsAlloc函数,那么将来就分配该INUSE标志。如果TlsFree函数运行成功,该函数将返回TRUE。如果试图释放一个没有分配的时隙,将产生一个错误。
使用动态TLS
通常情况下,如果DLL使用TLS,那么当它用DLL_PROCESS_ATTACH标志调用它的
DllMain函数时,它也调用TlsAlloc。当它用DLL_PROCESS_DETACH调用DllMain函数时,它就调用TlsFree。对TlsSetValue和TlsGetValue的调用很可能是在调用DLL中包含的函数时进行的。将TLS添加给应用程序的方法之一是在需要它时进行添加。例如,你的DLL中可能有一个运行方式类似strtok的函数。第一次调用这个函数时,线程传递一个指向40字节的结构的指针。必须保存这个结构,这样,将来调用函数时就可以引用它。
21.2静态TLS
与动态TLS一样,静态TLS也能够将数据与线程联系起来。但是,静态TLS在代码中使用起来要容易得多,因为不必调用任何函数就能够使用它。
比如说,你想要将起始时间与应用程序创建的每个线程联系起来。只需要将起始时间变量声明为下面的形式:__declspec(thread) DWORD gt_dwstartTime =0;
__declspec(thread)的前缀是Microsoft添加给VisualC++编译器的一个修改符。它告诉编译器,对应的变量应该放入可执行文件或DLL文件中它的自己的节中。__declspec(thread)后面的变量必须声明为函数中(或函数外)的一个全局变量或静态变量。不能声明一个类型为__declspec(thread)的局部变量。这不应该是个问题,因为局部变量总是与特定的线程相联系的。
我将前缀gt_用于全局TLS变量,而将st_用于静态TLS变量。
当编译器对程序进行编译时,它将所有的TLS变量放入它们自己的节,这个节的名字是.tls。链接程序将来自所有对象模块的所有.tls节组合起来,形成结果的可执行文件或DLL文件中的一个大的.tls节。
为了使静态TLS能够运行,操作系统必须参与其操作。当你的应用程序加载到内存中时,系统要寻找你的可执行文件中的.tls节,并且动态地分配一个足够大的内存块,以便存放所有的静态TLS变量。你的应用程序中的代码每次引用其中的一个变量时,就要转换为已分配内存块中包含的一个内存位置。因此,编译器必须生成一些辅助代码来引用该静态TLS变量,这将使你的应用程序变得比较大而且运行的速度比较慢。在x86CPU上,将为每次引用的静态TLS变量生成3个辅助机器指令。
如果在进程中创建了另一个线程,那么系统就要将它捕获并且自动分配另一个内存块,以便存放新线程的静态TLS变量。新线程只拥有对它自己的静态TLS变量的访问权,不能访问属于其他线程的TLS变量。
这就是静态TLS变量如何运行的基本情况。现在让我们来看一看DLL的情况。你的应用程序很可能要使用静态TLS变量,并且链接到也想使用静态TLS变量的一个DLL。当系统加载你的应用程序时,它首先要确定应用程序的.tls节的大小,并将这个值与你的应用程序链接的DLL中的任何.tls节的大小相加。当在你的进程中创建线程时,系统自动分配足够大的内存块来存放应用程序需要的所有TLS变量和所有隐含链接的DLL。
下面让我们来看一下当应用程序调用LoadLibrary,以便链接到也包含静态TLS变量的一个DLL时,将会发生什么情况。系统必须查看进程中已经存在的所有线程,并扩大它们的TLS内存块,以便适应新DLL对内存的需求。另外,如果调用FreeLibrary来释放包含静态TLS变量的DLL,那么与进程中的每个线程相关的的内存块应该被压缩。
对于操作系统来说,这样的管理任务太重了。虽然系统允许包含静态TLS变量的库在运行期进行显式加载,但是TLS数据没有进行相应的初始化。如果试图访问这些数据,就可能导致访问违规。这是使用静态TLS的唯一不足之处。当使用动态TLS时,不会出现这个问题。使用动态TLS的库可以在运行期进行加载,并且可以在运行期释放,根本不会产生任何问题。