Tuesday, March 29, 2005

 

Notes on "Programming Applications for Microsoft Windows"

By 长河落日
longriver@126.com
12/27/2003

第1章 对程序错误的处理

在开始介绍Microsoft windows的特性之前。先了解windows的各个函数是如何进行错误处理的;
当调用一个W1ndows函数时,它首先要检验传递给它的各个参数的有效性。再设法执行任务。如果传递了一个无效参数,或者由于某种原因无法执行这项操作,那么操作系统就会返回一个值.指明该函数在某种程度上运行失败了。表1—1列出了大多数windows的函数使用的返回值的数据类型。

数据类型 表示失败的值
VOID 该函数的运行不可能失败。windows函数的返回值类型很少是VOID
BOOL 如果函数运行失败,那么返回值是0,否则返回的是非0值。
HANDLE 如果函数运行失败,则返回值是NULL,否则返回值为HANDLE,用于标识你可以操作的一个对象。注意,有些函数会返回一个句柄值INVALID_HANDLE_VALUE,它被定义为-1.
PVOID 如果函数运行失败,则返回值是NULL,否则返回值为PVOID,以标识数据块的内存地址。
LONG/DWORD 这是个难以处理的值。返回数量的函数通常返回LONG或DWORD,如果由于某种原因,函数无法对想要进行计数的对象进行计数,那么该函数通常返回0或-1,如果调用的函数返回了LONG/DWORD,那么请认真阅读SDK文档,以确保能正确检查潜在的错误。

一个WIndows函数返回的错误代码对了解该函数为什么会运行失败常常很有用。微软公司编译了一个所有可能的错误代码的列表.并且为每个错误代码分配了一个32位的号码。
从系统内部来讲,当一个windows函数检测到一个错误时.它会使用一个称为线程本地存储器的机制,
将相应的错误代码号码与调用的线程关联起来。这将使线程能够互相独立地运行.而不会影响各自的错误
代码。当函数返回时.它的返回值就能指明—个错误已经发生。若要确定这是个什么错误。请调用GetLastError函数;
GetLastError能返回线程产生的最后一个错误。如果该线程调用的windows函数运行成功,那么最后一个错误代码就不会被改写,并且不指明运行成功。有少数windows函数并不遵循这一规则,它会更改最后的错误代码;但是platform SDK文档通常指明,当函数运行成功时,该函数会更改最后的错误代码。
Windows 98 许多Windows 98的函数实际上是用Microsoft公司的16位Windows 3.1产品产生的1 6位代码来实现的。这种比较老的代码并不通过GetLastError之类的函数来报告错误,而且Microsoft公司并没有在Windows 98中修改1 6位代码,以支持这种错误处理方式。对于我们来说,这意味着Windows 98中的许多Wi n 3 2函数在运行失败时不能设置最后的错误代码。该函数将返回一个值,指明运行失败,这样你就能够发现该函数确实已经运行失败,但是你无法确定运行失败的原因。
在进行调试的时候,监控线程的最后错误代码是非常有用的。在VC6.0中微软的调试程序支持一个非常有用的特性,即可以配置Watch窗口,以便始终都能显示线程的最后错误代码的号码和该错误的英文描述。通过选定watch窗口中的一行,并键入"@err,hr",就能够做到这一点。另外,Visual studio还配有一个小的实用程序,称为Error Lookup.可以用它来将错误代码的号码转换成相应文本描述。
最后要说的是你也可以自己定义自己的错误代码。若要指明函数运行失败,只需要设定线程的最后错误代码,然后让你的函数返回FALSE、INVALID_HANDLE_VALUE、NULL或者返回任何合适的信息。若要设定线程的最后错误代码,只需要调用WinError.h中已经存在的代码。


第2章 Unicode

Unicode是开发任何应用程序时要采用的基本步骤。所以放在前面来讲这个问题。
有些文字和书写规则(比如日文中的汉字就是个典型的例子)的字符集中的符号太多了,因此单字节(它提供的符号最多不能超过2 5 6个)是根本不敷使用的。为此出现了双字节字符集(D B C S),以支持这些文字和书写规则。但是对双字节字符集的操作必须通过windows提供的三个函数CharNext 和Char Prev 、IsDBCSLeadByte来完成。还是有点复杂。为了更使操作更容易,从而产生了Unicode(宽字节字符集)。
U n i c o d e是A p p l e和X e r o x公司于1 9 8 8年建立的一个技术标准。1 9 9 1年,成立了一个集团机构负责U n i c o d e的开发和推广应用。
U n i c o d e提供了一种简单而又一致的表示字符串的方法。U n i c o d e字符串中的所有字符都是1 6位的(两个字节)。它没有专门的字节来指明下一个字节是属于同一个字符的组成部分,还是一个新字符。这意味着你只需要对指针进行递增或递减,就可以遍历字符串中的各个字符,不再需要调用C h a r N e x t、C h a r P r e v和I s D B C S L e a d B y t e之类的函数。
由于U n i c o d e用一个1 6位的值来表示每个字符,因此总共可以得到65 000个字符,这样,它就能够对世界各国的书面文字中的所有字符进行编码,远远超过了单字节字符集的2 5 6个字符的数目。
U n i c o d e具备下列功能:
• 可以很容易地在不同语言之间进行数据交换。
• 使你能够分配支持所有语言的单个二进制. e x e文件或D L L文件。
• 提高应用程序的运行效率
Windows 2000是使用U n i c o d e从头进行开发的,用于创建窗口、显示文本、进行字符串操作等的所有核心函数都需要U n i c o d e字符串。如果调用任何一个Wi n d o w s函数并给它传递一个A N S I字符串,那么系统首先要将字符串转换成U n i c o d e,然后将U n i c o d e字符串传递给操作系统。如果希望函数返回A N S I字符串,系统就会首先将U n i c o d e字符串转换成A N S I字符串,然后将结果返回给你的应用程序。所有这些转换操作都是在你看不见的情况下发生的。当然,进行这些字符串的转换需要占用系统的时间和内存。所以通过从头开始用U n i c o d e来开发应用程序,就能够使你的应用程序更加有效地运行。
另外Windows 98只支持A N S I,只能为A N S I开发应用程序。Windows CE只支持U n i c o d e,只能为U n i c o d e开发应用程序。
这里强调一下COM。 当M i c r o s o f t公司将C O M从1 6位Wi n d o w s转换成Wi n 3 2时,公司作出了一个决定,即需要字符串的所有C O M接口方法都只能接受U n i c o d e字符串。这是个了不起的决定,因为C O M通常用于使不同的组件能够互相进行通信,而U n i c o d e则是传递字符串的最佳手段。
请注意,所有的U n i c o d e函数均以w c s开头,w c s是宽字符串的英文缩写。若要调用U n i c o d e函数,只需用前缀w c s来取代A N S I字符串函数的前缀s t r即可。
注意 大多数软件开发人员可能已经不记得这样一个非常重要的问题了,那就是M i c r o s o f t公司提供的C运行期库与A N S I的标准C运行期库是一致的。ANSI C规定,C运行期库支持U n i c o d e字符和字符串。这意味着始终都可以调用C运行期函数,以便对U n i c o d e字符和字符串进行操作,即使是在Windows 98上运行,也可以调用这些函数。换句话说, w c s c a t、w c s l e n和w c s t o k等函数都能够在Windows 98上很好地运行,这些都是必须关心的操作系统函数。
对于包含了对s t r函数或w c s函数进行显式调用的代码来说,无法非常容易地同时为A N S I和
U n i c o d e对这些代码进行编译。本章前面说过,可以创建同时为A N S I和U n i c o d e进行编译的单个源代码文件。若要建立双重功能,必须包含T C h a r. h文件,而不是包含S t r i n g . h文件。
T C h a r. h文件的唯一作用是帮助创建A N S I / U n i c o d e通用源代码文件。它包含你应该用在源代码中的一组宏,而不应该直接调用s t r函数或者w c s函数。如果在编译源代码文件时定义了_ U N I C O D E,这些宏就会引用w c s这组函数。如果没有定义_ U N I C O D E,那么这些宏将引用s t r这组宏。
若要生成一个U n i c o d e字符串而不是A N S I字符串,必须使用如下代码:
例:TCHAR *szERROR L”ERROR”.
字符串前面的大写字母L,用于告诉编译器该字符串应该作为U n i c o d e字符串来编译
Wi n d o w s定义的U n i c o d e数据类型
数据类型 说明
W C H A R U n i c o d e字符
P W S T R 指向U n i c o d e字符串的指针
P C W S T R 指向一个恒定的U n i c o d e字符串的指针

将你的应用程序转换成符合U n i c o d e的应用程序。下面是应该遵循的一些基本原则:
• 将文本串视为字符数组,而不是c h a r s数组或字节数组。
• 将通用数据类型(如T C H A R和P T S T R)用于文本字符和字符串。
• 将显式数据类型(如B Y T E和P B Y T E)用于字节、字节指针和数据缓存。
• 将T E X T宏用于原义字符和字符串。
• 执行全局性替换(例如用P T S T R替换P S T R)。
• 修改字符串运算问题。例如函数通常希望你在字符中传递一个缓存的大小,而不是字节。这意味着你不应该传递s i z e o f ( s z B u ff e r ) ,而应该传递( s i z e o f ( s z B u ff e r ) / s i z e o f ( T C H A R )。另外,如果需要为字符串分配一个内存块,并且拥有该字符串中的字符数目,那么请记住要按字节来分配内存。这就是说,应该调用malloc(nCharacters *sizeof(TCHAR)), 而不是调用m a l l o c( n C h a r a c t e r s )。在上面所说的所有原则中,这是最难记住的一条原则,如果操作错误,编译器将不发出任何警告。

第3章内核对象

准确地理解内核对象对于想要成为一名Wi n d o w s软件开发能手的人来说是至关重要的。本章就来说说内核对象。
什么是内核对象
每个内核对象只是内核分配的一个内存块,并且只能由该内核访问。该内存块是一种数据结构,它的成员负责维护该对象的各种信息。有些数据成员(如安全性描述符、使用计数等)在所有对象类型中是相同的,但大多数数据成员属于特定的对象类型。
由于内核对象的数据结构只能被内核访问,因此应用程序无法在内存中找到这些数据结构并直接改变它们的内容。
对内核对象的操作,Wi n d o w s提供了一组函数来对这些结构进行操作。这些内核对象始终都可以通过这些函数进行访问。当调用一个用于创建内核对象的函数时,该函数就返回一个用于标识该对象的句柄。该句柄可以被视为一个不透明值,你的进程中的任何线程都可以使用这个值。将这个句柄传递给Wi n d o w s的各个函数,这样,系统就能知道你想操作哪个内核对象。
内核对象由内核所拥有,而不是由进程所拥有。换句话说,如果你的进程调用了一个创建内核对象的函数,然后你的进程终止运行,那么内核对象不一定被撤消。在大多数情况下,对象将被撤消,但是如果另一个进程正在使用你的进程创建的内核对象,那么该内核知道,在另一个进程停止使用该对象前不要撤消该对象,必须记住的是,内核对象的存在时间可以比创建该对象的进程长。
安全性问题:内核对象能够得到安全描述符的保护。安全描述符用于描述谁创建了该对象,谁能够访问或使用该对象,谁无权访问该对象。安全描述符通常在编写服务器应用程序时使用,如果你编写客户机端的应用程序,那么可以忽略内核对象的这个特性。
根据原来的设计, Windows 98并不用作服务器端的操作系统。为此,M i c r o s o f t公司没有在Windows 98中配备安全特性。不过,如果你现在为Windows 98设计软件,在实现你的应用程序时仍然应该了解有关的安全问题,并且使用相应的访问信息,以确保它能在Windows 2000上正确地运行
若要确定一个对象是否属于内核对象,最容易的方法是观察创建该对象所用的函数。创建内核对象的所有函数几乎都有一个参数,你可以用来设定安全属性的信息。
当一个进程被初始化时,系统要为它分配一个句柄表。该句柄表只用于内核对象,不用于用户对象或G D I对象。句柄表只是个数据结构的数组。每个结构都包含一个指向内核对象的指针、一个访问屏蔽和一些标志。当进程初次被初始化时,它的句柄表是空的。然后,当进程中的线程调用创建内核对象的函数时,比如C r e a t e F i l e M a p p i n g,内核就为该对象分配一个内存块,并对它初始化。这时,内核对进程的句柄表进行扫描,找出一个空项。由于句柄表是空的,内核便找到索引1位置上的结构并对它进行初始化。该指针成员将被设置为内核对象的数据结构的内存地址,访问屏蔽设置为全部访问权,同时,各个标志也作了设置. 最后,无论怎样创建内核对象,都要向系统指明将通过调用C l o s e H a n d l e来结束对该对象的操作。

跨越进程边界共享内核对象

许多情况下,在不同进程中运行的线程需要共享内核对象。下面是为何需要共享的原因:
• 文件映射对象使你能够在同一台机器上运行的两个进程之间共享数据块。
• 邮箱和指定的管道使得应用程序能够在连网的不同机器上运行的进程之间发送数据块。
• 互斥对象、信标和事件使得不同进程中的线程能够同步它们的连续运行,这与一个应用程序在完成某项任务时需要将情况通知另一个应用程序的情况相同。
第一个方法:对象句柄的继承性
只有当进程具有父子关系时,才能使用对象句柄的继承性。
请记住,虽然内核对象句柄具有继承性,但是内核对象本身不具备继承性。
若要创建能继承的句柄,父进程必须指定一个S E C U R I T Y _ AT T R I B U T E S结构并对它进行初始化,然后将该结构的地址传递给特定的C r e a t e函数。
现在介绍存放在进程句柄表项目中的标志。
每个句柄表项目都有一个标志位,用来指明该句柄是否具有继承性。当创建一个内核对象时,如果传递N U L L作为P S E C U R I T Y _ AT T R I B U T E S的参数,那么返回的句柄是不能继承的,并且该标志位是0。如果将b I n h e r i t H a n d l e成员置为T R U E,那么该标志位将被置为1。
第二个方法:对象命名
共享跨越进程边界的内核对象的第二种方法是给对象命名。许多(虽然不是全部)内核对象都是可以命名的。
第三个方法:复制句柄
共享跨越进程边界的内核对象的最后一个方法是使用D u p l i c a t e H a n d l e函数:

第四章 进程

本章介绍系统如何管理所有正在运行的应用程序。
首先讲述什么是进程,以及系统如何创建进程内核对象,以便管理每个进程。
然后将说明如何使用相关的内核对象来对进程进行操作。
接着,要介绍进程的各种不同的属性,以及查询和修改这些属性所用的若干个函数。
还要讲述创建或生成系统中的辅助进程所用的函数。
最后,说明如何来结束进程的运行。

进程的概念
进程通常被定义为一个正在运行的程序的实例,它由两个部分组成:
• 一个是操作系统用来管理进程的内核对象。内核对象也是系统用来存放关于进程的统计信息的地方。
• 另一个是地址空间,它包含所有可执行模块或D L L模块的代码和数据。它还包含动态内存分配的空间。如线程堆栈和堆分配空间。
进程是不活泼的。若要使进程完成某项操作,它必须拥有一个在它的环境中运行的线程,该线程负责执行包含在进程的地址空间中的代码。实际上,单个进程可能包含若干个线程,所有这些线程都“同时”执行进程地址空间中的代码。为此,每个线程都有它自己的一组C P U寄存器和它自己的堆栈。
若要使所有这些线程都能运行,操作系统就要为每个线程安排一定的C P U时间。它通过以一种循环方式为线程提供时间片(称为量程),造成一种假象,仿佛所有线程都是同时运行的一样。
当创建一个进程时,系统会自动创建它的第一个线程,称为主线程。然后,该线程可以创建其他的线程,而这些线程又能创建更多的线程。
注意:Windows 98只能在单处理器计算机上运行。Windows2000以后版本则可以运行在多处理器上。实现真正的多线程。

应用程序类型进入点嵌入可执行文件的启动函数
需要A N S I字符和字符串的G U I应用程序Wi n M a i n Wi n M a i n C RT S t a r t u p
需要U n i c o d e字符和字符串的G U I应用程序wWinMain wWi n M a i n C RT S t a r t u p
需要A N S I字符和字符串的C U I应用程序m a i n m a i n C RT S t a r t u p
需要U n i c o d e字符和字符串的C U I应用程序w m a i n w m a i n C RT S t a r t u p

启动函数的功能归纳如下:
• 检索指向新进程的完整命令行的指针。
• 检索指向新进程的环境变量的指针。
• 对C / C + +运行期的全局变量进行初始化。如果包含了S t d L i b . h文件,代码就能访问这些变量。
• 对C运行期内存单元分配函数( m a l l o c和c a l l o c)和其他低层输入/输出例程使用的内存栈进行初始化。
• 为所有全局和静态C + +类对象调用构造函数。当所有这些初始化操作完成后, C / C + +启动函数就调用应用程序的进入点函数。

当进入点函数返回时,启动函数便调用C运行期的e x i t函数,将返回值( n M a i n R e t Va l)传递给它。E x i t函数负责下面的操作:
• 调用由_ o n e x i t函数的调用而注册的任何函数。
• 为所有全局的和静态的C + +类对象调用析构函数。
• 调用操作系统的E x i t P r o c e s s函数,将n M a i n R e t Va l传递给它。这使得该操作系统能够撤消进程并设置它的e x i t代码。

进程的实例句柄
加载到进程地址空间的每个可执行文件或D L L文件均被赋予一个独一无二的实例句柄。可执行文件的实例作为( w ) Wi n M a i n的第一个参数h i n s t E x e来传递。对于加载资源的函数调用来说,通常都需要该句柄的值。

二、创建一个进程:
可以用CreateProcess函数创建一个进程:
BOOL CreateProcess(
PCTSTR pszApplicationName,
PCSTR pszCommandLine,
PSECURITY_ATTRIBUTES psaProcess,
PSECURITY_ATTRIBUTES psaThread,
BOOL bInheritHandles,
DWORD fdwCreate,
PVOID pvEnvironment,
PCTSTR pszCurDir,
PSTARTUPINFO psiStartInfo,
PPROCESS_INFORMATION ppiProcInfo);
当一个线程调用C r e a t e P r o c e s s时,系统就会创建一个进程内核对象,其初始使用计数是1。该进程内核对象不是进程本身,而是操作系统管理进程时使用的一个较小的数据结构。可以将进程内核对象视为由进程的统计信息组成的一个较小的数据结构。然后,系统为新进程创建一个虚拟地址空间,并将可执行文件或任何必要的D L L文件的代码和数据加载到该进程的地址空间中。
然后,系统为新进程的主线程创建一个线程内核对象(其使用计数为1)。与进程内核对象一样,线程内核对象也是操作系统用来管理线程的小型数据结构。通过执行C / C + +运行期启动代码,该主线程便开始运行,它最终调用Wi n M a i n、w Wi n M a i n、m a i n或w m a i n函数。如果系统成功地创建了新进程和主线程,C r e a t e P r o c e s s便返回T R U E。
下面分别介绍C r e a t e P r o c e s s的各个参数。
1 pszApplicationName和p s z C o m m a n d L i n e
p s z A p p l i c a t i o n N a m e和p s z C o m m a n d L i n e参数分别用于设定新进程将要使用的可执行文件的名字和传递给新进程的命令行字符串。
可以将地址传递给p s z A p p l i c a t i o n N a m e参数中包含想运行的可执行文件的名字的字符串。请注意,必须设定文件的扩展名,系统将不会自动假设文件名有一个. e x e扩展名。
psaProcess、p s a T h r e a d和b i n h e r i t H a n d l e s
若要创建一个新进程,系统必须创建一个进程内核对象和一个线程内核对象(用于进程的主线程),由于这些都是内核对象,因此父进程可以得到机会将安全属性与这两个对象关联起来。可以使用p s a P r o c e s s和p s a T h r e a d参数分别设定进程对象和线程对象需要的安全性。可以为这些参数传递N U L L,在这种情况下,系统为这些对象赋予默认安全性描述符。也可以指定两个S E C U R I T Y _ AT T R I B U T E S结构,并对它们进行初始化,以便创建自己的安全性权限,并将它们赋予进程对象和线程对象。b I n h e r i t H a n d l e s传递内核对象句柄继承性.
fdwCreate
f d w C r e a t e参数用于标识标志,以便用于规定如何来创建新进程。如果将标志逐位用O R操作符组合起来的话,就可以设定多个标志。
•D E B U G _ P R O C E S S标志用于告诉系统,父进程想要调试子进程和子进程将来生成的任何进程。本标志还告诉系统,当任何子进程(被调试进程)中发生某些事件时,将情况通知父进程(这时是调试程序)。
• D E B U G _ O N LY _ T H I S _ P R O C E S S标志与D E B U G _ P R O C E S S标志相类似,差别在于,调试程序只被告知紧靠父进程的子进程中发生的特定事件。如果子进程生成了别的进程,那么将不通知调试程序在这些别的进程中发生的事件。
• C R E AT E _ S U S P E N D E D标志可导致新进程被创建,但是,它的主线程则被挂起。这使得父进程能够修改子进程的地址空间中的内存,改变子进程的主线程的优先级,或者在进程有机会执行任何代码之前将进程添加给一个作业。一旦父进程修改了子进程,父进程将允许子进程通过调用R e s u m e T h r e a d函数来执行代码(第7章将作详细介绍)。
• D E TA C H E D _ P R O C E S S标志用于阻止基于C U I的进程对它的父进程的控制台窗口的访问,并告诉系统将它的输出发送到新的控制台窗口。如果基于C U I的进程是由另一个基于C U I的进程创建的,那么按照默认设置,新进程将使用父进程的控制台窗口(当通过命令外壳程序来运行C编译器时,新控制台窗口并不创建,它的输出将被附加在现有控制台窗口的底部)。通过设定本标志,新进程将把它的输出发送到一个新控制台窗口。
• C R E AT E _ N E W _ C O N S O L E标志负责告诉系统,为新进程创建一个新控制台窗口。如果同时设定C R E AT E _ N E W _ C O N S O L E和D E TA C H E D _ P R O C E S S标志,就会产生一个错误。
• C R E AT E _ N O _ W I N D O W标志用于告诉系统不要为应用程序创建任何控制台窗口。可以使用本标志运行一个没有用户界面的控制台应用程序。
• C R E AT E _ N E W _ P R O C E S S _ G R O U P标志用于修改用户在按下C t r l + C或C t r l + B r e a k键时得到通知的进程列表。如果在用户按下其中的一个组合键时,你拥有若干个正在运行的C U I进程,那么系统将通知进程组中的所有进程说,用户想要终止当前的操作。当创建一个新的C U I进程时,如果设定本标志,可以创建一个新进程组。如果该进程组中的一个进程处于活动状态时用户按下C t r l + C或C t r l _ B r e a k键,那么系统只通知用户需要这个进程组中的进程。
• C R E AT E _ D E FA U LT _ E R R O R _ M O D E标志用于告诉系统,新进程不应该继承父进程使用的错误模式(参见本章前面部分中介绍的S e t E r r o r M o d e函数)。
• C R E AT E _ S E PA R AT E _ W O W _ V D M标志只能当你在Windows 2000上运行1 6位Wi n d o w s应用程序时使用。它告诉系统创建一个单独的D O S虚拟机(V D M),并且在该V D M中运行1 6位Wi n d o w s应用程序。按照默认设置,所有1 6位Wi n d o w s应用程序都在单个共享的V D M中运行。在单独的VDM 中运行应用程序的优点是,如果应用程序崩溃,它只会使单个V D M停止工作,而在别的V D M中运行的其他程序仍然可以继续正常运行。另外,在单独的V D M中运行的1 6位Wi n d o w s应用程序有它单独的输入队列。这意味着如果一个应用程序临时挂起,在各个V D M中的其他应用程序仍然可以继续接收输入信息。运行多个V D M的缺点是,每个V D M都要消耗大量的物理存储器。Windows 98在单个V D M中运行所有的1 6位Wi n d o w s应用程序,不能改变这种情况。
• C R E AT E _ S H A R E D _ W O W _ V D M标志只能当你在Windows 2000上运行1 6位Wi n d o w s应用程序时使用。按照默认设置,除非设定了C R E AT E _ S E PA R AT E _ W O W _ V D M标志,否则所有1 6位Wi n d o w s应用程序都必须在单个V D M中运行。但是,通过在注册表中将H K E Y _ L O C A L _ M A C H I N E \ s y s t e m \ C u r r e n t C o n t r o l S e t \ C o n t r o l \ W O W下的D e f a u l t S e p a r a t e V D M 设置为“ y e s ”,就可以改变该默认行为特性。这时, C R E AT E _ S H A R E D _W O W _ V D M标志就在系统的共享V D M中运行1 6位Wi n d o w s应用程序。
• C R E AT E _ U N I C O D E _ E N V I R O N M E N T标志用于告诉系统,子进程的环境块应该包含U n i c o d e字符。按照默认设置,进程的环境块包含的是A N S I字符串。
• C R E AT E _ F O R C E D O S标志用于强制系统运行嵌入1 6位O S / 2应用程序的M O S - D O S应用程序。
• C R E AT E _ B R E A K AWAY _ F R O M _ J O B标志用于使作业中的进程生成一个与作业相关联的新进程
pvEnvironment
p v E n v i r o n m e n t参数用于指向包含新进程将要使用的环境字符串的内存块。在大多数情况下,为该参数传递N U L L,使子进程能够继承它的父进程正在使用的一组环境字符串。也可以使用G e t E n v i r o n m e n t S t r i n g s函数:该函数用于获得调用进程正在使用的环境字符串数据块的地址。可以使用该函数返回的地址,作为C r e a t e P r o c e s s的p v E n v i r o n m e n t参数。如果为p v E n v i r o n m e n t参数传递N U L L,那么这正是C r e a t e P r o c e s s函数所做的操作。当不再需要该内存块时,应该调用F r e e E n v i r o n m e n t S t r i n g s函数将内存块释放:
pszCurDir
p s z C u r D i r参数允许父进程设置子进程的当前驱动器和目录。如果本参数是N U L L,则新进程的工作目录将与生成新进程的应用程序的目录相同。如果本参数不是N U L L,那么p s z C u r D i r必须指向包含需要的工作驱动器和工作目录的以0结尾的字符串。注意,必须设定路径中的驱动器名。
psiStartInfo
p s i S t a r t I n f o参数用于指向一个S TA RT U P I N F O结构:
ppiProcInfo
p p i P r o c I n f o参数用于指向你必须指定的P R O C E S S _ I N F O R M AT I O N结构。C r e a t e P r o c e s s在返回之前要对该结构的成员进行初始化。该结构的形式如下面所示:
typedef struct _PROCESS_INFORMATION{
HANDLE hProcess;
HANDLE hThread;
DWORD dwProcessId;
DWORD dwThreadId;
}PROCESS_INFORMATION;

终止进程的运行

若要终止进程的运行,可以使用下面四种方法:
• 主线程的进入点函数返回(最好使用这个方法)。
• 进程中的一个线程调用E x i t P r o c e s s函数(应该避免使用这种方法)。
• 另一个进程中的线程调用Te r m i n a t e P r o c e s s函数(应该避免使用这种方法)。
• 进程中的所有线程自行终止运行(这种情况几乎从未发生)。
主线程的进入点函数返回
始终都应该这样来设计应用程序,即只有当主线程的进入点函数返回时,它的进程才终止
运行。这是保证所有线程资源能够得到正确清除的唯一办法。
让主线程的进入点函数返回,可以确保下列操作的实现:
• 该线程创建的任何C + +对象将能使用它们的析构函数正确地撤消。
• 操作系统将能正确地释放该线程的堆栈使用的内存。
• 系统将进程的退出代码(在进程的内核对象中维护)设置为进入点函数的返回值。
• 系统将进程内核对象的返回值递减1。
一旦进程终止运行(无论采用何种方法),系统将确保该进程不会将它的任何部分遗留下来。绝对没有办法知道该进程是否曾经运行过。进程一旦终止运行,它绝对不会留下任何蛛丝马迹。希望这是很清楚的。

第五章 作业

Microsoft Windoss 2000提供了一个新的作业内核对象,使你能够将进程组合在一起,并且创建一个“沙框”,以便限制进程能够进行的操作。最好将作业对象视为一个进程的容器。但是,创建包含单个进程的作业是有用的,因为这样一来,就可以对该进程加上通常情况下不能加的限制。
注意: Windows 98不支持作业的操作。
5.1 对作业进程的限制
进程创建后,通常需要设置一个沙框(设置一些限制),以便限制作业中的进程能够进行的操作。可以给一个作业加上若干不同类型的限制:
• 基本限制和扩展基本限制,用于防止作业中的进程垄断系统的资源。
• 基本的U I限制,用于防止作业中的进程改变用户界面。
• 安全性限制,用于防止作业中的进程访问保密资源(文件、注册表子关键字等)。
这里对四种限制的成员函数及参数的介绍省略了,可以自己细看书中的介绍。
5.2 将进程放入作业
需要注意的一点是,该函数只允许将尚未被赋予任何作业的进程赋予一个作业。一旦进程成为一个作业的组成部分,它就不能转到另一个作业,并且不能是无作业的进程。另外,当作为作业的一部分的进程生成另一个进程的时候,新进程将自动成为父作业的组成部分。
5.3 终止作业中所有进程的运行
当然,想对作业进行的最经常的操作是撤消作业中的所有进程。D e v e l o p e r S t u d i o没有配备任何便于使用的方法,来停止进程中的某个操作,因为它不知道哪个进程是由第一个进程生成的。
若要撤消作业中的进程,只需要调用下面的代码:
BOOL Terminatejobobject (
Handle Hjob
uInt uexitcode
)
这类似为作业中的每个进程调用Te r m i n a t e P r o c e s s函数,将它们的所有退出代码设置为u E x i t C o d e。
5.4 查询作业统计信息
Q u e r y I n f o r m a t i o n J o b O b j e c t函数用来获取对作业的当前限制信息。也可以使用它来获取关于作业的统计信息。例如,若要获取基本的统计信息,可以调用Q u e r y I n f o r m a t i o n J o b O b j e c t,为第二个参数传递J o b O b j e c t B a s i c A c c o u n t i n g I n f o r m a t i o n ,并传递J O B O B J E C T _ B A S I C _ A C C O U N T I N G _ I N F O R M AT I O N结构的地址:
除了查询这些基本统计信息外,可以进行一次函数调用,以同时查询基本统计信息和I/O统计信息。为此,必须为第二个参数传递J o b O b j e c t B a s i c A n d I o A c c o u n t i n g I n f o r m a t i o n ,并传递J O B O B J E C T _ B A S I C _ A N D _ I O _ A C C O U N T I N G _ I N F O R M AT I O N结构的地址
5.5 作业通知信息
现在,已经知道了关于作业对象的基本知识,剩下要介绍的内容是关于通知的问题。例如,是否想知道作业中的所有进程何时终止运行或者分配的全部C P U时间是否已经到期呢?也许想知道作业中何时生成新进程或者作业中的进程何时终止运行。如果不关心这些通知信息(而且许多应用程序也不关心这些信息),作业的操作非常容易。如果关心这些事件,那么还有一些工作要做。
如果关心的是分配的所有C P U时间是否已经到期,那么可以非常容易地得到这个通知信息。当作业中的进程尚未用完分配的C P U时间时,作业对象就得不到通知。一旦分配的所有C P U时间已经用完, Wi n d o w s就强制撤消作业中的所有进程,并将情况通知作业对象。通过调用Wa i t F o r S i n g l e O b j e c t (或类似的函数),可以很容易跟踪这个事件。有时,可以在晚些时候调用S e t I n f o r m a t i o n J o b O b j e c t函数,使作业对象恢复未通知状态,并为作业赋予更多的C P U时间。
当开始对作业进行操作时,我觉得当作业中没有任何进程运行时,应该将这个事件通知作业对象。毕竟当进程和线程停止运行时,进程和线程对象就会得到通知。因此,当作业停止运行时它也应该得到通知。这样,就能够很容易确定作业何时结束运行。但是, M i c r o s o f t选择在分配的C P U时间到期时才向作业发出通知,因为这显示了一个错误条件。由于许多作业启动时有一个父进程始终处于工作状态,直到它的所有子进程运行结束,因此只需要在父进程的句柄上等待,就可以了解整个作业何时运行结束。S t a r t R e s t r i c t e d P r o c e s s函数用于显示分配给作业的C P U时间何时到期,或者作业中的进程何时终止运行。
前面介绍了如何获得某些简单的通知信息,但是尚未说明如何获得更高级的通知信息,如进程创建/终止运行等。如果想要得到这些通知信息,必须将更多的基础结构放入应用程序。特别是,必须创建一个I/O完成端口内核对象,并将作业对象或多个作业对象与完成端口关联起来。然后,必须让一个或多个线程在完成端口上等待作业通知的到来,这样它们才能得到处理。
一旦创建了I/O完成端口,通过调用S e t I n f o r m a t i o n J o b O b j e c t函数,就可以将作业与该端口关联起来.
最后要说明的一点是,按照默认设置,作业对象是这样配置的:当分配给作业的C P U时间已经到期时,作业的所有进程均自动停止运行,而J O B _ O B J E C T _ M S G _ E N D _ O F _ J O B _ T I M E通知尚未发送。


第6章 线程的基础知识

理解线程是非常关键的,因为每个进程至少需要一个线程。与进程内核对象一样,线程内核对象也拥有属性,本章要介绍许多用于查询和修改这些属性的函数。此外还要介绍可以在进程中创建和生成更多的线程时所用的函数。
第4章介绍了进程是由两个部分构成的,一个是进程内核对象,另一个是地址空间。同样,线程也是由两个部分组成的:
• 一个是线程的内核对象,操作系统用它来对线程实施管理。内核对象也是系统用来存放线程统计信息的地方。
• 另一个是线程堆栈,它用于维护线程在执行代码时需要的所有函数参数和局部变量。

第4章中讲过,进程是不活泼的。进程从来不执行任何东西,它只是线程的容器。线程总是在某个进程环境中创建的,而且它的整个寿命期都在该进程中。这意味着线程在它的进程地址空间中执行代码,并且在进程的地址空间中对数据进行操作。因此,如果在单进程环境中,你有两个或多个线程正在运行,那么这两个线程将共享单个地址空间。这些线程能够执行相同的代码,对相同的数据进行操作。这些线程还能共享内核对象句柄,因为句柄表依赖于每个进程而不是每个线程存在。

如你所见,进程使用的系统资源比线程多得多,原因是它需要更多的地址空间。为进程创建一个虚拟地址空间需要许多系统资源。系统中要保留大量的记录,这要占用大量的内存。另外,由于. e x e和. d l l文件要加载到一个地址空间,因此也需要文件资源。而线程使用的系统资源要少得多。实际上,线程只有一个内核对象和一个堆栈,保留的记录很少,因此需要很少的内存。
由于线程需要的开销比进程少,因此始终都应该设法用增加线程来解决编程问题,而要避免创建新的进程。但是,这个建议并不是一成不变的。许多程序设计用多个进程来实现会更好些。应该懂得权衡利弊,经验会指导你的编程实践。

在详细介绍线程之前,首先花一点时间讲一讲如何正确地在应用程序结构中使用线程。
6.1 何时创建线程
线程用于描述进程中的运行路径。每当进程被初始化时,系统就要创建一个主线程。该线程与C / C + +运行期库的启动代码一道开始运行,启动代码则调用进入点函数( m a i n、w m a i n、Wi n M a i n或w Wi n M a i n),并且继续运行直到进入点函数返回并且C / C + +运行期库的启动代码调用E x i t P r o c e s s为止。对于许多应用程序来说,这个主线程是应用程序需要的唯一线程。不过,进程能够创建更多的线程来帮助执行它们的操作。
一个简单的例子就是,We b浏览器可以在后台与它们的服务器进行通信。因此,在来自当前We b站点的结果输入之前,用户可以缩放浏览器的窗口或者转到另一个We b站点。
设计一个拥有多线程的应用程序,就会扩大该应用程序的功能。我们在下一章中可以看到,每个线程被分配了一个C P U。因此,如果你的计算机拥有两个C P U,你的应用程序中有两个线程,那么两个C P U都将处于繁忙状态。实际上,你是让两个任务在执行一个任务的时间内完成操作。
6.2 何时不能创建线程
线程确实是非常有用的,但是,当使用线程时,在解决原有的问题时可能产生新的问题。例如,你开发了一个文字处理应用程序,并且想要让打印函数作为它自己的线程来运行。这听起来是个很好的主意,因为用户可以在打印文档时立即回头着手编辑文档。但是,这意味着文档中的数据可能在文档打印时变更。也许最好是不要让打印操作在它自己的线程中发生,不过这种“方案”看起来有点儿极端。如果你让用户编辑另一个文档,但是锁定正在打印的文档,使得打印结束前该文档不能修改,那将会怎样呢?这里还有第三种思路,将文档拷贝到一个临时文件,然后打印该临时文件的内容,并让用户修改原始文档。当包含该文档的临时文件结束打印时,删除临时文件。
本节的实质就是提醒大家应该慎重地使用多线程。不要想用就用。仅仅使用赋予进程的主线程,就能够编写出许多非常有用的和功能强大的应用程序。
6.3 编写第一个线程函数
每个线程必须拥有一个进入点函数,线程从这个进入点开始运行。前面已经介绍了主线程的进入点函数:即m a i n、w m a i n、Wi n M a i n或w Wi n M a i n。如果想要在你的进程中创建一个辅助线程,它必定也是个进入点函数,类似下面的样子:
DWORD WINAPI THREADFUNC(PVOID PVParam)
{ DWORD dwresult=0;

Return(dwresult);
}
下面对线程函数的几个问题作一说明:
• 主线程的进入点函数的名字必须是m a i n、w m a i n、Wi n M a i n或w Wi n M a i n,与这些函数不同的是,线程函数可以使用任何名字。
• 由于给你的主线程的进入点函数传递了字符串参数,因此可以使用A N S I / U n i c o d e版本的进入点函数: m a i n / w m a i n和Wi n M a i n / w Wi n M a i n。
• 线程函数必须返回一个值,它将成为该线程的退出代码。
• 线程函数(实际上是你的所有函数)应该尽可能使用函数参数和局部变量。

下面讲述如何让操作系统来创建能够执行线程函数的线程。
6.4 CreateThread函数
如果想要创建一个或多个辅助函数,只需要让一个已经在运行的线程来调用C r e a t e T h r e a d:当C r e a t e T h r e a d被调用时,系统创建一个线程内核对象。该线程内核对象不是线程本身,而是操作系统用来管理线程的较小的数据结构。可以将线程内核对象视为由关于线程的统计信息组成的一个小型数据结构。这与进程和进程内核对象之间的关系是相同的。系统从进程的地址空间中分配内存,供线程的堆栈使用。新线程运行的进程环境与创建线程的环境相同。因此,新线程可以访问进程的内核对象的所有句柄、进程中的所有内存和在这个相同的进程中的所有其他线程的堆栈。这使得单个进程中的多个线程确实能够非常容易地互相通信。注意C r e a t e T h r e a d函数是用来创建线程的Wi n d o w s函数。不过,如果你正在编写C / C + +代码,决不应该调用C r e a t e T h r e a d。相反,应该使用Visual C++运行期库函数_ b e g i n t h r e a d e x。如果不使用M i c r o s o f t的Visual C++编译器,你的编译器供应商有它自己的C r e a t e T h r e d替代函数。不管这个替代函数是什么,你都必须使用。本章后面将要介绍_ b e g i n t h r e a d e x能够做什么,它的重要性何在。
这就是Create Thread函数的概述,下面各节将要具体介绍C r e a t e T h r e a d的每个参数。
6.5 终止线程的运行
若要终止线程的运行,可以使用下面的方法:
• 线程函数返回(最好使用这种方法)。
• 通过调用E x i t T h r e a d函数,线程将自行撤消(最好不要使用这种方法)。
• 同一个进程或另一个进程中的线程调用Te r m i n a t e T h r e a d函数(应该避免使用这种方法)。
• 包含线程的进程终止运行(应该避免使用这种方法)。
始终都应该将线程设计成线程函数返回的形式,即当想要线程终止运行时,它们就能够返回。这是确保所有线程资源被正确地清除的唯一办法。
如果线程能够返回,就可以确保下列事项的实现:
• 在线程函数中创建的所有C + +对象均将通过它们的撤消函数正确地撤消。
• 操作系统将正确地释放线程堆栈使用的内存。
• 系统将线程的退出代码(在线程的内核对象中维护)设置为线程函数的返回值。
• 系统将递减线程内核对象的使用计数。


第7章 线程的调度、优先级和亲缘性


抢先式操作系统必须使用某种算法来确定哪些线程应该在何时调度和运行多长时间。在这一章中,我们将要介绍Microsoft Windows 98和Windows 2000使用的一些算法。
在第6章中,我们介绍了每个线程是如何拥有一个上下文结构的,这个结构维护在线程的内核对象中。这个上下文结构反映了线程上次运行时该线程的CPU寄存器的状态。每隔20毫秒左右,Windows要查看当前存在的所有线程内核对象。在这些对象中,只有某些对象被视为可以调度的对象。Windows选择可调度的线程内核对象中的一个,将它加载到CPU的寄存器中,它的值是上次保存在线程的环境中的值。这项操作称为上下文转换。Windows实际上保存了一个记录,它说明每个线程获得了多少个运行机会。这时,线程正在执行代码,并对它的进程的地址空间中的数据进行操作。再过20毫秒左右,Windows就将CPU的寄存器重新保存到线程的上下文中。线程不再运行。系统再次查看其余的可调度线程内核对象,选定另一个线程的内核对象,将该线程的上下文加载到CPU的寄存器中,然后继续运行。当系统引导时,便开始加载线程的上下文,让线程运行,保存上下文和重复这些操作,直到系统关闭。这就是系统对线程进行调度的过程。请记住,你无法保证你的线程总是能够运行,也不能保证你的线程能够得到整个进程,并且无法保证其他线程不允许运行,等等。

我想强调这样一个概念,即系统只调度可以调度的线程,但是实际情况是,系统中的大多数线程是不可调度的线程。例如,如果你运行Notepad,但是并不键入任何数据。那么Notepad的线程就没有什么事情要做。系统不给无事可做的线程分配CPU时间。

7.1 暂停和恢复线程的运行
在线程内核对象的内部有一个值,用于指明线程的暂停计数。当你调用CreateProcess或CreateThread函数时,就创建了线程的内核对象,并且它的暂停计数被初始化为1。这可以防止线程被调度到CPU中。当然,这是必须的,因为线程的初始化需要时间,你不希望在系统做好充分的准备之前就开始执行线程。
当线程完全初始化好了之后,CreateProcess或CreateThread要查看你是否已经传递CREATE_SUSPENDED标志。如果你已经传递了这个标志,那么这些函数就返回,同时新线程处于暂停状态。如果你尚未传递该标志,那么该函数将线程的暂停计数递减为0。当线程的暂停计数是0的时候,除非线程正在等待其他某种事情的发生,否则该线程就处于可调度状态。
在暂停状态中创建一个线程,你就能够在线程有机会执行任何代码之前改变线程的运行环境(如优先级,本章后面将要介绍)。一旦你改变了线程的环境,你必须使线程成为可调度线程。如果要进行这项操作,你可以调用ResumeThread,再将调用CreateThread函数时返回的线程句柄传递给它.当你创建线程时,除了使用CREATE_SUSPENDED外,你也可以调用SuspendThread函数来暂停线程的运行。

7.2 暂停和恢复进程的运行
对于Windows来说,不存在暂停或恢复进程的概念,因为进程从来不会被安排获得CPU时间。但是, Windows确实允许一个进程暂停另一个进程中的所有线程的运行,但是从事暂停操作的进程必须是个调试程序。特别是,进程必须调用WaitForDebugEvent和ContinueDebugEvent之类的函数。
虽然你无法创建绝对完美的SuspendProcess函数,但是你可以创建一个该函数的实现代码,它能够在许多条件下出色地运行。收中介绍了SuspendProcess函数的实现代码。

7.3 睡眠方式
线程也能告诉系统,它不想在某个时间段内被调度。这是通过调用Sleep函数来实现的:
VOID Sleep(DWORD dwMilliseconds);
该函数可使线程暂停自己的运行,直到dwMilliseconds过去为止。
关于Sleep函数,有下面几个重要问题值得注意:
* 调用Sleep,可使线程自愿放弃它剩余的时间片。
* 系统将在大约的指定毫秒数内使线程不可调度。
* 你可以调用Sleep,并且为dwMilliseconds参数传递INFINITE。这将告诉系统永远不要调度该线程。这不是一件值得去做的事情。最好是让线程退出,并还原它的堆栈和内核对象。
* 你可以将0传递给Sleep。这将告诉系统,调用线程将释放剩余的时间片,并迫使系统调度另一个线程。但是,系统可以对刚刚调用Sleep的线程重新调度。如果不存在多个拥有相同优先级的可调度线程,就会出现这种情况。

7.4 转换到另一个线程
系统提供了一个称为SwitchToThread的函数,它使得另一个可调度线程能够运行。当你调用这个函数的时候,系统要查看是否存在一个迫切需要CPU时间的线程。如果没有线程迫切需要CPU时间,SwitchToThread就会立即返回。如果存在一个迫切需要CPU时间的线程,SwitchToThread就对该线程进行调度(该线程的优先级可能低于调用SwitchToThread的线程)。这个迫切需要CPU时间的线程可以运行一个时间段,然后系统调度程序照常运行。
该函数允许一个需要资源的线程强制另一个优先级较低、而目前却拥有该资源的线程放弃该资源。如果调用SwitchToThread函数时没有其他线程能够运行,那么该函数返回FALSE,否则返回一个非0值。
调用SwitchtoThread函数与调用Sleep是相似的,并且传递给它一个0毫秒的超时。差别是SwitchToThread允许优先级较低的线程运行。即使低优先级线程迫切需要CPU时间,Sleep也能够立即对调用线程重新进行调度。

* Windows 98没有配备该函数的非常有用的实现代码。

7.5 线程的运行时间
有时你想要计算线程执行某个任务需要多长的时间。还好Windows提供了一个称为GetThreadTimes的函数,它能返回这些信息:
BOOL GetThreadTimes(
Handle HPROCESS;
Pfiletime Pftcreationtime;
Pfiletime Pftexittime;
Pfiletime Pftkeneltime;
Pfiletime Pftusertime;
)

GetThreadTimes函数返回4个不同的时间值,这些值如下表所示。

时间值 含义
创建时间 用英国格林威治时间1601年1月1日午夜后100ns的时间间隔表
示的英国绝对值,用于指明线程创建的时间。
退出时间 用英国格林威治时间1601年1月1日午夜后100ns的时间间隔表
示的英国绝对值,用于指明线程退出的时间。如果线程仍然在运行,
退出时间则未定义。
内核时间 一个相对值,用于指明线程执行操作系统代码已经经过了多少个100ns的CPU时间。
用户时间 一个相对值,用于指明线程执行应用程序代码已经经过了多少个100ns的CPU时间。

请注意,GetProcessTimes是个类似GetThreadTimes的函数,它适用于进程中的所有线程:GetProcessTimes返回的时间适用于某个进程中的所有线程(甚至是已经终止运行的线程)。例如,返回的内核时间是所有进程的线程在内核代码中经过的全部时间的总和。

遗憾的是GetThreadTimes和GetProcessTimes这两个函数在Windows 98中不起作用。在Windows 98中,没有一个可靠的机制可供应用程序来确定线程或进程已经使用了多少CPU时间。

对于高分辨率的显示来说,GetThreadTimes不是太好。书中提到了两个高分辨率性能函数。

7.6 综合运用上下文环境
现在你应该懂得上下文结构在线程调度中所起的重要作用了。上下文结构使得系统能够记住线程的状态,这样,当下次线程拥有可以运行的CPU时,它就能够找到它上次中断运行的地方。
Windows允许你查看线程内核对象的内部情况,以便抓取它当前的一组CPU寄存器。若要进行这项操作,你只需要调用GetThreadContext函数。
在调用GetThreadContext函数之前,你应该调用SuspendThread,否则,线程可能被调度,而且线程的上下文可能与你收回的不同。一个线程实际上有两个上下文。一个是用户方式,一个是内核方式。GetThreadContext只能返回线程的用户方式上下文。如果你调用SuspendThread来停止线程的运行,但是该线程目前正在用内核方式运行,那么,即使SuspendThread实际上尚未暂停该线程的运行,它的用户方式仍然处于稳定状态。但是,线程在恢复用户方式之前,它无法执行更多的用户方式代码,因此你可以放心地将线程视为处于暂停状态,并且GetThreadContext函数将能正常运行。

Windows使你能够修改CONTEXT结构中的成员,然后通过调用SetThreadContext将新寄存器值放回线程的内核对象中。同样,你修改其上下文的线程应该首先暂停,否则其结果将无法预测。
GetThreadContext和SetThreadContext函数使你能够对线程进行许多方面的控制,但是你在使用它们时应该小心。实际上,调用这些函数的应用程序根本就不多。增加这些函数是为了增强调试程序和其他工具的功能。不过任何应用程序都可以调用它们。
我将在第24章中更加详细地介绍CONTEXT结构。

7.7 线程的优先级
在本章的开头,我讲述了CPU是如何只使线程运行20毫秒,然后调度程序将另一个可调度的线程分配给CPU的。如果所有线程具有相同的优先级,那么就会发生这种情况,但是,在现实环境中,线程被赋予许多不同的优先级,这会影响到调度程序将哪个线程取出来作为下一个运行的线程。
每个线程都会被赋予一个从0(最低)到31(最高)的优先级号码。当系统要确定将哪个线程分配给CPU时,它首先观察优先级为31的线程,并以循环方式对它们进行调度。如果优先级为31的线程可以调度,那么就将该线程赋予一个CPU。在该线程的时间片结束时,系统要查看是否还有另一个优先级为31的线程可以运行,如果有,它将允许该线程被赋予一个CPU。
只有优先级为31的线程才可以调度,系统将绝对不会将优先级为0到30的线程分配给CPU。这种情况称为渴求调度(starvation)。当高优先级线程使用如此多的CPU时间,从而使得低优先级线程无法运行时,便会出现渴求情况。
现在我想提醒你注意一个问题。高优先级线程将抢在低优先级线程之前运行,不管低优先级线程正在运行什么。
顺便我要指出,当系统引导时,它会创建一个特殊的线程,称为0页线程。该线程被赋予优先级0,它是整个系统中唯一的一个在优先级0上运行的线程。当系统中没有任何线程需要执行操作时,0页线程负责将系统中的所有空闲RAM页面置0。

7.8 对优先级的抽象说明
Windows支持6个优先级类:即空闲,低于正常,正常,高于正常,高和实时。当然,正常优先级是最常用的优先级类,99%的应用程序均使用这个优先级类。下面这个表描述了这些优先级类。

优先级类 描述
实时 进程中的线程必须立即对事件作出响应,以便执行关键时间的任
务。该进程中的线程还会抢先于操作系统组件之前运行。使用本
优先级类时必须极端小心。
高 进程中的线程必须立即对事件作出响应,以便执行关键时间的任
务。Task Manager(任务管理器)在这个类上运行,因此用户可
以撤消脱离控制的进程。
高于正常 进程中的线程在正常优先级与高优先级之间运行(这是Windows
2000中的新优先级类)。
正常 进程中的线程没有特殊的调度需求。
低于正常 进程中的线程在正常优先级与空闲优先级之间运行(这是Windows
2000中的新优先级类)。
空闲 进程中的线程在系统空闲时运行。该进程通常由屏幕保护程序或
后台实用程序和搜集统计数据的软件使用。

当然,大多数进程都属于正常优先级类。低于正常和高于正常的优先级类是Windows 2000中的新增优先级。Microsoft增加这些优先级类的原因是,有若干家公司抱怨现有的优先级类无法提供足够的灵活性。
一旦你选定了应该优先级类之后,你就不必考虑你的应用程序如何与其他应用程序之间的关系,而只需要集中考虑你的应用程序中的各个线程。Windows甚至支持7个相对的线程优先级:即空闲,最低,低于正常,正常,高于正常,最高,和关键时间优先级。这些优先级是相对于进程的优先级类而言的。同样,大多数线程都使用正常线程优先级。下面这个表描述了这些相对的线程优先级。

相对线程优先级 描述
关键时间优先级 对于实时优先级类来说,线程在优先级31上运行,
对于其他优先级类来说,线程在优先级15上运行。
最高优先级 线程在高于正常优先级的上两级上运行。
高于正常优先级 线程在正常优先级的上一级上运行。
正常优先级 线程在进程的优先级类上正常运行。
低于正常优先级 线程在低于正常优先级的下一级上运行。
最低优先级 线程在低于正常优先级的下二级上运行。
空闲 对于实时优先级类来说,线程在优先级16上运行
对于其他优先级类来说,线程在优先级1上运行。

概括起来说,你的进程是优先级类的一个组成部分,你为进程中的线程赋予相对线程优先级。你将会注意到,我还没有讲到关于0到31的优先级的任何情况。应用程序开发人员从来不必具体设置优先级。相反,系统负责将进程的优先级类和线程的相对优先级映射到一个优先级上。正是这种映射方式,Microsoft不想拘泥不变。实际上这种映射方式是随着系统的版本的升级而变化的。
下面这个表显示了这种映射方式是如何用于Windows 2000的。

相对线程 低于 高于
优先级 空闲 正常 正常 正常 高 实时

关键时间 15 15 15 15 15 31
最高 6 8 10 12 15 26
高于正常 5 7 9 11 14 25
正常 4 6 8 10 13 24
低于正常 3 5 7 9 12 23
最低 2 4 6 8 11 22
空闲 1 1 1 1 1 16

请注意,上面这个表并没有显示优先级的等级为0的线程。这是因为0优先级保留供零页线程使用,系统不允许任何其他线程拥有0优先级。另外,下列优先级等级是无法使用的:17,18,19,20,21,27,28,29,和30。如果你编写一个以内核方式运行的设备驱动程序,你可以获得这些优先级等级,而用户方式的应用程序则不能。另外请注意,实时优先级类中的线程不能低于优先级等级16。同样,非实时优先级类中的线程的等级不能高于15。

说明 有些人常常搞不清进程优先级类的概念。他们认为这可能意味着进程是
可以调度的。但是进程是根本不能调度的,只有线程才能被调度。进程优先
级类是个抽象概念,Microsoft提出这个概念的目的,是为了帮助你将它与调
度程序的内部运行情况区分开来。它没有其他目的。

说明 一般来说,大多数时候高优先级的线程不应该处于可调度状态。当线程
要进行某种操作时,它能迅速获得CPU时间。这时线程应该尽可能少地执行
CPU指令,并返回睡眠状态,等待再次变成可调度状态。相反,低优先级的
线程可以保持可调度状态,执行大量的CPU指令来进行它的操作。如果你按
照这些原则来办,整个操作系统就能正确地对用户作出响应。

7.9 程序的优先级
那么进程是如何被赋予优先级类的呢?当你调用CreateProcess时,你可以在fdwCreate参数中传递需要的优先级类。下面这个表显示了优先级类的标识符。

优先级类 标识符
实时 REALTIME_PRIORITY_CLASS
高 HIGH_PRIORITY_CLASS
高于正常 ABOVE_NORMAL_PRIORITY_CLASS
正常 NORMAL_PRIORITY_CLASS
低于正常 BELOW_NOMAL_PRIORITY_CLASS
空闲 IDLE_PRIORITY_CLASS

创建子进程的进程负责选择子进程运行的优先级类,这看起来有点奇怪。让我们以Explorer为例来说明这个问题。当你使用Explorer来运行一个应用程序时,新进程按正常优先级运行。Explorer不知道进程在做什么,也不知道隔多长时间它的线程需要进行调度。但是,一旦子进程运行,它就能够通过调用SetPriorityClass来改变它自己的优先级类。
你可以使用Start命令加一个开关来设定应用程序的起始优先级。例如,在命令外壳输入的下面这个命令: C:\START /LOW CALC.EXE可使系统启动Calculator,并在开始时按空闲优先级来运行它.Start命令还能识别/BELOWNORMAL,/NORMAL,/ABOVENORMAL,/HIGH和/REALTIME等开关,以便按它们各自的优先级启动执行一个应用程序。当然,一旦应用程序启动运行,它就可以调用SetPriorityClass函数,将它自己的优先级改为它选择的任何优先级。
当一个线程刚刚创建时,它的相对线程优先级总是设置为正常优先级。我总感到有些奇怪,CreateThread没有为调用者提供一个设置新线程的相对优先级的方法。若要设置和获得线程的相对优先级,你必须调用SetThreadPriority这个函数。
SetThreadPriority(
Handle hThread;
INT nPriority;


当然,hThread参数用于标识你想要改变优先级的单个线程,nPriority参数是下表列出的7个标识符之一。

线程相对优先级 标识符常量
关键时间优先级 THREAD_PRIORITY_TIME_CRITICAL
最高优先级 THREAD_PRIORITY_HIGHEST
高于正常优先级 THREAD_PRIORITY_ABOVE_NORMAL
正常优先级 THREAD_PRIORITY_NORMAL
低于正常优先级 THREAD_PRIORITY_BELOW_NORMAL
最低优先级 THREAD_PRIORITY_LOWEST
空闲优先级 THREAD_PRIORITY_IDLE

Windows还提供了检索线程的相对优先级的补充函数:GetThreadPriority。

见原书P237的程序(2)

该函数返回上表列出的标识符之一。
若要创建一个带有相对优先级为空闲的线程,你可以执行类似下面的代码:

说明 Windows没有提供返回线程的优先级的函数。这种省略是故意的。请记
住,Microsoft保留了随时修改调度算法的权利。你不应该设计需要调度算法
的专门知识的应用程序。如果你坚持使用进程优先级类和相对线程优先级,
你的应用程序不仅现在能够顺利地运行,而且在系统的将来版本上也能很好
地运行。

7.9.1 动态提高线程的优先级等级
通过将线程的相对优先级与线程的进程优先级类综合起来考虑,系统就可以确定线程的优先级等级。有时这称为线程的基本优先级等级。系统常常要提高线程的优先级等级,以便对窗口消息或读取磁盘等某些I/O事件作出响应。
请注意,线程的当前优先级等级决不会低于线程的基本优先级等级。
系统只能为基本优先级等级在1至15之间的线程提高其优先级等级。实际上这是因为这个范围称为动态优先级范围。此外,系统决不会将线程的优先级等级提高到实时范围(高于15)。由于实时范围中的线程能够执行大多数操作系统的函数,因此给等级的提高规定一个范围,就可以防止应用程序干扰操作系统的运行。另外,系统决不会动态提高实时范围内的线程优先级等级。
有些编程人员抱怨说,系统动态提高线程优先级等级的功能对他们的线程性能会产生一种不良的影响,为此Microsoft增加了下面两个函数,这样你就能够使系统的动态提高线程优先级等级的功能不起作用:SetProcessPriorityBoost负责告诉系统激活或停用进行中的所有线程的优先级提高功能,而SetThreadPriorityBoost则让你激活或停用各个线程的优先级提高功能。

另一种情况也会导致系统动态地提高线程的优先级等级。比如有一个优先级为4的线程准备运行,但是却不能运行,因为一个优先级为8的线程正连续被调度。在这种情况下,优先级为4的线程就非常渴望得到CPU时间。当系统发现一个线程在大约3至4秒钟内一直渴望得到CPU时间,它就将这个渴望得到CPU时间的线程的优先级动态提高到15,并让该线程运行两倍于它的时间量。当到了两倍时间量的时候,该线程的优先级立即返回到它的基本优先级。

7.9.2 为前台进程调整调度程序
当用户对进程的窗口进行操作时,该进程就称为前台进程,所有其他进程则称为后台进程。当然,用户希望他正在使用的进程比后台进程具有更强响应性的行为特性。为了提高前台进程的响应特性,Windows能够为前台进程中的线程调整其调度算法。对于Windows 2000来说,系统可以为前台进程的线程提供比通常多的CPU时间量。这种调整只能在前台进程属于正常优先级类的进程时才能进行。如果它属于其他任何优先级类,就无法进行任何调整。
Windows 2000实际上允许用户对这种调整进行相应的配置。在System Priorities(系统属性)对话框的Advanced选项卡上,用户可以单击Performance Options(性能选项)按钮,打开对话框。如果用户选择优化应用程序的性能,系统就执行配置的调整。如果用户选择优化后台服务程序的性能,系统就不进程调整。
Windows 98没有提供允许用户配置这种调整手段的任何用户界面。
将进程改为前台进程的原因是,使它们能够对用户的输入更快地作出响应。

7.10 亲缘性
按照默认设置,当系统将线程分配给处理器时,Windows 2000使用软亲缘性来进行操作。这意味着如果所有其他因素相同的话,它将设法在它上次运行的那个处理器上运行线程。让线程留在单个处理器上,有助于重复使用仍然在处理器的内存高速缓存中的数据。
有一种新的计算机结构,称为NUMA(非统一内存访问),在该结构中,计算机包含若干块插件板,每个插件板上有4个CPU和它自己的内存区。下面这个插图显示了一台配有3块插件板的计算机,总共有12个CPU,这样,任何一个线程都可以在12个CPU中的任何一个CPU上运行。
当CPU访问的内存是它自己的插件板上的内存时,NUMA系统运行的性能最好。如果CPU需要访问位于另一个插件板上的内存时,就会产生巨大的性能降低。在这样的环境中,就需要来自一个进程中的线程在CPU 0至3上运行,让另一个进程中的线程在CPU 4至7上运行,依次类推。为了适应这种计算机结构的需要,Windows 2000允许你设置进程和线程的亲缘性。换句话说,你可以控制哪个CPU能够运行某些线程。这称为硬亲缘性。
请注意,子进程可以继承进程的亲缘性。
为此windows提供了设置亲缘性的函数。SetProcessAffinityMask。 当然,还有一个函数能够返回进程的亲缘性位屏蔽,它就是GetProcessAffinityMask。
有时你可能想要将进程中的一个线程限制到一组CPU上去运行。可以通过调用SetThreadAffinityMask,你就能为各个线程设置亲缘性屏蔽。
最后作以下几点说明:
(1)Windows98 无论计算机中实际拥有多少个CPU,Windows 98只使用一个CPU。
(2)在大多数环境中,改变线程的亲缘性就会影响调度程序有效地在各个CPU之间移植线程的能力,而这种能力可以最有效地使用CPU时间。
(3)有时强制将一个线程分配给特定的CPU的做法是不妥当的。
(4)当Windows 2000在x86计算机上引导时,你可以限制系统能够使用的CPU的数量。只要在Boot.ini文件的[operating systems]栏改动如下示:
multi(0)disk(0)rdisk(0)partition(1)\WINDOWS="Microsoft Windows XP Home Edition" /fastdetect /noguiboot /NumProcs=1

第8章 用户方式中线程的同步

当所有的线程在互相之间不需要进行通信的情况下就能够顺利地运行时,Microsoft Windows的运行性能最好。但是,线程很少能够在所有的时间都独立地进行操作。通常情况下,要生成一些线程来处理某个任务。当这个任务完成时,另一个线程必须了解这个情况。
线程需要在下面两种情况下互相进行通信:
* 当你有多个线程访问共享资源而不使资源被破坏时
* 当一个线程需要将某个任务已经完成的情况通知另外一个或多个线程时

Windows提供了许多方法,可以非常容易地实现线程的同步。

8.1 原子访问:互锁的函数家族
线程同步问题在很大程度上与原子访问有关,所谓原子访问,是指线程在访问资源时能够确保所有其他线程都不在同一时间内访问相同的资源。书中例举了一个简单例子说明互锁函数的重要性。(祥见书)。那么互锁函数是如何运行的呢?答案要取决于你运行的是何种CPU平台。对于x86家族的CPU来说,互锁函数会对总线发出一个硬件信号,防止另一个CPU访问同一个内存地址。在Alpha平台上,互锁函数能够执行下列操作:
1. 打开CPU中的一个特殊的位标志,并注明被访问的内存地址。
2. 将内存的值读入一个寄存器。
3. 修改该寄存器。
4. 如果CPU中的特殊位标志是关闭的,则转入第二步。否则,特殊位标志仍然是打开的,寄存器的值重新存入内存。

你也许会问,执行第4步时CPU中的特殊位标志是如何关闭的呢?答案是:如果系统中的另一个CPU试图修改同一个内存地址,那么它就能够关闭CPU的特殊位标志,从而导致互锁函数返回第二步。

下面是书中介绍的几个互锁函数:InterlockedExchangeAdd、InterlockedExchange和InterlockedExchangePointer,InterlockedCompareExchange,InterlockedCompareExchangePointer。InterlockedExchange和InterlockedExchangePointer能够以原子操作方式用第二个参数中传递的值来取代第一个参数中传递的当前值。如果是32位应用程序,两个函数都能用另一个32位值取代一个32位值。但是,如果是个64位应用程序,那么InterlockedExchange能够取代一个32位值,而InterlockedExchangePointer则取代64位值。两个函数都返回原始值。当你实现一个循环锁时,InterlockedExchange是非常有用的。
你应该避免在单个CPU计算机上使用循环锁。如果一个线程正在循环运行,它就会浪费前一个CPU时间,这将防止另一个线程修改该值。
循环锁认为,受保护的资源总是被访问较短的时间。这使它能够更加有效地循环运行,然后转为内核方式并进入等待状态。许多编程人员循环运行一定的次数(比如400次),如果对资源的访问仍然被拒绝,那么该线程就转为内核方式,在这种方式下,它要等待(不消耗CPU时间),直到该资源变为可供使用为止。这就是关键部分实现的方法。
循环锁在多处理器计算机上非常有用,因为当一个线程循环运行的时候,另一个线程可以在另一个CPU上运行。但是,即使在这种情况下,你也必须小心。你不应该让线程循环运行太长的时间,也不能浪费更多的CPU时间。

8.2 高速缓存行
如果你想创建一个能够在多处理器计算机上运行的高性能应用程序,你必须懂得CPU的高速缓存行。当一个CPU从内存读取一个字节时,它不只是取出一个字节,它要取出足够的字节来填入高速缓存行。高速缓存行由32或64个字节组成(视CPU而定),并且始终在第32个字节或第64个字节的边界上对齐。高速缓存行的作用是为了提高CPU运行的性能。通常情况下,应用程序只能对一组相邻的字节进行处理。如果这些字节在高速缓存中,那么CPU就不必访问内存总线,而访问内存总线需要多得多的时间。
但是,在多处理器环境中,高速缓存行使得内存的更新更加困难。所以最好是始终都让单个线程来访问数据(函数参数和局部变量是确保做到这一点的最好方法),或者始终让单个CPU访问这些数据(使用线程亲缘性)。如果你采取其中的一种方法,你就能够完全避免高速缓存行的各种问题。

8.3 高级线程同步
CPU时间非常宝贵,决不应该浪费。因此我们需要一种机制,使线程在等待访问共享资源时不浪费CPU时间。方法如下:
当线程想要访问共享资源,或者得到关于某个“特殊事件”的通知时,该线程必须调用一个操作系统函数,给它传递一些参数,以指明该线程正在等待什么。如果操作系统发现资源可供使用,或者该特殊事件已经发生,那么函数就返回,同时该线程保持可调度状态。如果资源不能使用,或者特殊事件还没有发生,那么系统便使该线程处于等待状态,使该线程无法调度。这可以防止线程浪费CPU时间。当线程处于等待状态时,系统作为一个代理,代表你的线程来执行操作。系统能够记住你的线程需要什么,当资源可供使用的时候,便自动使该线程退出等待状态,该线程的运行将与特殊事件实现同步。

8.4 关键代码段
关键代码段是指一个小代码段,在代码能够执行前,它必须独占对某些共享资源的访问权。这是让若干行代码能够“以原子操作方式”来使用资源的一种方法。所谓原子操作方式,我是指该代码知道没有别的线程要访问该资源。当然,系统仍然能够抑制你的线程的运行,而抢先安排其他线程的运行。不过,在你的线程退出关键代码段之前,系统将不给想要访问相同资源的其他任何线程进行调度。作者举了个形象的例子(上厕所)来说明这个问题。

注意 最难记住的一件事情是,你编写的需要使用共享资源的任何代码都必须封装在EnterCriticalSection和LeaveCriticalSection函数中。如果你忘记将代码封装在一个位置,共享资源就可能遭到破坏。

最后要注意的几点:
(1)InitializeCriticalSection函数的运行可能失败。
(2)每个共享资源使用一个CRITICAL_SECTION变量。
(3)同时访问多个资源时防止死锁状态发生。
(4)不要长时间运行关键代码段。

第9章 线程与内核对象的同步

上一章介绍了如何使用允许线程保留在用户方式中的机制来实现线程同步的方法。用户方式同步的优点是它的同步速度非常快。但是它也有其局限性。对于许多应用程序来说,这种机制是不适用的。本章将要介绍如何使用内核对象来实现线程的同步。你将会看到,内核对象机制的适应性远远优于用户方式机制。实际上,内核对象机制的唯一不足之处是它的速度比较慢。
内核对象中的每种对象都可以说是处于已通知或未通知的状态之中。这种状态的切换是由M i c r o s o f t为每个对象建立的一套规则来决定的。内核对象总是在未通知状态中创建的。比如,当进程正在运行的时候,进程内核对象处于未通知状态,当进程终止运行的时候,它就变为已通知状态。内核对象中是个布尔值,当对象创建时,该值被初始化为FA L S E(未通知状态)。当进程终止运行时,操作系统自动将对应的对象布尔值改为T R U E,表示该对象已经得到通知。当进程等待的对象处于未通知状态中时,这些进程不可调度。但是一旦对象变为已通知状态,进程看到该标志变为可调度状态,并且很快恢复运行。

9.1 等待函数
等待函数可使线程自愿进入等待状态,直到一个特定的内核对象变为已通知状态为止。这些等待函数中最常用的是WaitForSingleObject 和WaitForMultipleObjects。两个函数很相似,区别在于后者允许调用线程同时查看若干个内核对象(最多64个)的已通知状态。

9.2 成功等待的副作用
对于有些内核对象来说,成功地调用WaitForSingleObject 和WaitForMultipleObjects,实际上会改变对象的状态。成功地调用是指函数发现对象已经得到通知并且返回一个相对于WAIT_OBJECT_0的值。如果函数返回WA I T _ T I M E O U T或WA I T _ FA I L E D,那么调用就没有成功。如果函数调用没有成功,对象的状态就不可能改变。当一个对象的状态改变时,我称之为成功等待的副作用。例如,有一个线程正在等待自动清除事件对象(本章后面将要介绍)。当事件对象变为已通知状态时,函数就会发现这个情况,并将WA I T _ O B J E C T _ 0返回给调用线程。但是就在函数返回之前,该事件将被置为未通知状态,这就是成功等待的副作用。

9.3 事件内核对象
在所有的内核对象中,事件内核对象是个最基本的对象。它们包含一个使用计数(与所有内核对象一样),一个用于指明该事件是个自动重置的事件还是一个人工重置的事件的布尔值,另一个用于指明该事件处于已通知状态还是未通知状态的布尔值。
事件能够通知一个操作已经完成。有两种不同类型的事件对象。一种是人工重置的事件,另一种是自动重置的事件。当人工重置的事件得到通知时,等待该事件的所有线程均变为可调度线程。当一个自动重置的事件得到通知时,等待该事件的线程中只有一个线程变为可调度线程。
当一个线程执行初始化操作,然后通知另一个线程执行剩余的操作时,事件使用得最多。事件初始化为未通知状态,然后,当该线程完成它的初始化操作后,它就将事件设置为已通知状态。这时,一直在等待该事件的另一个线程发现该事件已经得到通知,因此它就变成可调度线程。这第二个线程知道第一个线程已经完成了它的操作。

9.4 等待定时器内核对象
等待定时器是在某个时间或按规定的间隔时间发出自己的信号通知的内核对象。它们通常用来在某个时间执行某个操作。若要创建等待定时器,只需要调用CreateWaitableTimer函数。当然,进程可以获得它自己的与进程相关的现有等待定时器的句柄,方法是调用OpenWaitableTimer函数。
等待定时器对象总是在未通知状态中创建。必须调用SetWaitableTimer函数来告诉定时器你想在何时让它成为已通知状态。
除了上面介绍的定时器函数外,最后还有一个CancelWaitableTimer函数。这个简单的函数用于取出定时器的句柄并将它撤消,这样,除非接着调用SetWaitableTimer函数以便重新设置定时器,否则定时器决不会进行报时。如果想要改变定时器的报时条件,不必在调用SetWaitableTimer函数之前调用CancelWaitableTimer函数。每次调用SetWaitableTimer函数,都会在设置新的报时条件之前撤消定时器原来的报时条件。

9.4.1 让等待定时器给A P C项排队
Microsoft还允许定时器在定时器得到通知信号时调用SetWaitableTimer函数的线程的异步过程调用(A P C)进行排队。一般来说,当调用SetWaitableTimer函数时,你将同时为pfnCompletionRoutine和pvArgCompletionRoutine参数传递N U L L。当SetWaitableTimer函数看到这些参数的N U L L时,它就知道,当规定的时间到来时,就向定时器发出通知信号。但是,如果到了规定的时间,你愿意让定时器给一个A P C排队,那么你必须传递定时器A P C例程的地址,而这个例程是你必须实现的。
最后要说明的是,线程不应该等待定时器的句柄,也不应该以待命的方式等待定时器。通常没有理由使用带有等待定时器的A P C例程,因为你始终都可以等待定时器变为已通知状态,然后做你想要做的事情。

9.4.2 定时器的松散特性
如果你发现自己创建和管理了若干个定时器对象,那么应该观察一下CreateTimerQueueTimer这个函数,它能够为你处理所有的操作,以减少应用程序的开销。
如果对等待定时器与用户定时器(用SetTimer函数进行设置)进行比较。可以发现它们之间的最大差别是,用户定时器需要在应用程序中设置许多附加的用户界面结构,这使定时器变得资源更加密集。另外,等待定时器属于内核对象,这意味着它们可以供多个线程共享,并且是安全的。
用户定时器能够生成WM_TIMER消息,这些消息将返回给调用SetTimer(用于回调定时器)的线程和创建窗口(用于基于窗口的定时器)的线程。因此,当用户定时器报时的时候,只有一个线程得到通知。另一方面,多个线程可以在等待定时器上进行等待,如果定时器是个人工重置的定时器,则可以调度若干个线程。如果要执行与用户界面相关的事件,以便对定时器作出响应,那么使用用户定时器来组织代码结构可能更加容易些,因为使用等待定时器时,线程必须既要等待各种消息,又要等待内核对象(如果要改变代码的结构,可以使用MsgWaitForMultipleObjects函数)。最后,运用等待定时器,当到了规定时间的时候,更有可能得到通知。WM_TIMER消息始终属于最低优先级的消息,当线程的队列中没有其他消息时,才检索该消息。等待定时器的处理方法与其他内核对象没有什么差别,如果定时器发出报时信息,而你的线程正在等待之中,那么你的线程就会醒来。

9.5 信标内核对象
信标内核对象用于对资源进行计数。它们与所有内核对象一样,包含一个使用数量,但是它们也包含另外两个带符号的3 2位值,一个是最大资源数量,一个是当前资源数量。最大资源数量用于标识信标能够控制的资源的最大数量,而当前资源数量则用于标识当前可以使用的资源的数量。
信标的使用规则如下:
• 如果当前资源的数量大于0,则发出信标信号。
• 如果当前资源数量是0,则不发出信标信号。
• 系统决不允许当前资源的数量为负值。
• 当前资源数量决不能大于最大资源数量。
CreateSemaphore函数用于创建信标内核对象。通过调用OpenSemaphore函数,另一个进程可以获得它自己的进程与现有信标相关的句柄.通过调用ReleaseSemaphore函数,线程就能够对信标的当前资源数量进行递增。

9.6 互斥对象内核对象
互斥对象(m u t e x)内核对象能够确保线程拥有对单个资源的互斥访问权。实际上互斥对象是因此而得名的。互斥对象包含一个使用数量,一个线程I D和一个递归计数器。互斥对象的行为特性与关键代码段相同,但是互斥对象属于内核对象,而关键代码段则属于用户方式对象。这意味着互斥对象的运行速度比关键代码段要慢。但是这也意味着不同进程中的多个线程能够访问单个互斥对象,并且这意味着线程在等待访问资源时可以设定一个超时值。I D用于标识系统中的哪个线程当前拥有互斥对象,递归计数器用于指明该线程拥有互斥对象的次数。互斥对象有许多用途,属于最常用的内核对象之一。通常来说,它们用于保护由多个线程访问的内存块。如果多个线程要同时访问内存块,内存块中的数据就可能遭到破坏。互斥对象能够保证访问内存块的任何线程拥有对该内存块的独占访问权,这样就能够保证数据的完整性。
互斥对象的使用规则如下:
• 如果线程I D是0(这是个无效I D),互斥对象不被任何线程所拥有,并且发出该互斥对象的通知信号。
• 如果I D是个非0数字,那么一个线程就拥有互斥对象,并且不发出该互斥对象的通知信号。
• 与所有其他内核对象不同, 互斥对象在操作系统中拥有特殊的代码,允许它们违反正常的规则。
若要使用互斥对象,必须有一个进程首先调用CreateMutex,以便创建互斥对象。当然,通过调用OpenM utex,另一个进程可以获得它自己进程与现有互斥对象相关的句柄。一旦线程成功地等待到一个互斥对象,该线程就知道它已经拥有对受保护资源的独占访问权。试图访问该资源的任何其他线程(通过等待相同的互斥对象)均被置于等待状态中。当目前拥有对资源的访问权的线程不再需要它的访问权时,它必须调用ReleseMutex函数来释放该互斥对象。

9.6.1 释放问题
互斥对象不同于所有其他内核对象,因为互斥对象有一个“线程所有权”的概念。本章介绍的其他内核对象中,没有一种对象能够记住哪个线程成功地等待到该对象,只有互斥对象能够对此保持跟踪。互斥对象的线程所有权概念是互斥对象为什么会拥有特殊异常规则的原因,这个异常规则使得线程能够获取该互斥对象,尽管它没有发出通知。这个异常规则不仅适用于试图获取互斥对象的线程,而且适用于试图释放互斥对象的线程。当一个线程调用ReleseMutex函数时,该函数要查看调用线程的I D是否与互斥对象中的线程I D相匹配。如果两个I D相匹配,递归计数器就会像前面介绍的那样递减。如果两个线程的I D不匹配,那么ReleseMutex函数将不进行任何操作,而是将FA L S E(表示失败)返回给调用者。

9.7 其他的线程同步函数
除了WaitForSingleObject和WaitForMultipleObjects,Windows还提供了另外几个稍有不同的函数。异步设备I/O,WaitForInputIdle,MsgWaitForMultipleObjects(Ex),WaitForDebugEvent,SingleObjectAndWait。如果理解了WaitForSingleObject和WaitForMultipleObjects函数,那么要理解其他函数如何运行,就不会遇到什么困难。这里就不再细述。

第1 0章线程同步工具包

第11章线程池的使用

到目前为止,已经知道创建多线程应用程序是非常困难的。需要会面临两个大问题。一个是要对线程的创建和撤消进行管理,另一个是要对线程对资源的访问实施同步。为了对资源访问实施同步,Wi n d o w s提供了许多基本要素来帮助进行操作,如事件、信标、互斥对象和关键代码段等。这些基本要素的使用都非常方便。为了使操作变得更加方便,唯一的方法是让系统能够自动保护共享资源。在如何对线程的创建和撤消进行管理的问题上,Microsoft公司的Windows 2000提供了一些新的线程池函数,使得线程的创建、撤消和基本管理变得更加容易。这个新的通用线程池并不完全适合每一种环境,但是它常常可以适合你的需要,并且能够节省大量的程序开发时间。
新的线程池函数使你能够执行下列操作:
• 异步调用函数。
• 按照规定的时间间隔调用函数。
• 当单个内核对象变为已通知状态时调用函数。
• 当异步I/O请求完成时调用函数。
为了完成这些操作,线程池由4个独立的部分组成。定时器 等待 I/O 非I/O.

11.1 方案1:异步调用函数
假设有一个服务器进程,该进程有一个主线程,正在等待客户机的请求。当主线程收到该请求时,它就产生一个专门的线程,以便处理该请求。这使得应用程序的主线程循环运行,并等待另一个客户机的请求。这个方案是客户机/服务器应用程序的典型实现方法。虽然它的实现方法非常明确,但是也可以使用新线程池函数来实现它。
当服务器进程的主线程收到客户机的请求时,它可以调用QueueUserWorkItem这个函数.
BOOL QueueUserWorkItem(
PTHREAD_START_ROUTINE pfnCallback,
PVOID pvContext,
ULONG dwFlags);
该函数将一个“工作项目”排队放入线程池中的一个线程中并且立即返回。所谓工作项目是指一个(用pfnCallback参数标识的)函数,它被调用并传递单个参数pvContext。最后,线程池中的某个线程将处理该工作项目,导致函数被调用。所编的回调函数必须采用下面的原型:
DWORD WINAPI WorkItemFunc(PVOID pvContext);
尽管必须使这个函数的原型返回DWORD,但是它的返回值实际上被忽略了。
注意,你自己从来不调用CreateThread。系统会自动为你的进程创建一个线程池,线程池中的一个线程将调用你的函数。另外,当该线程处理完客户机的请求之后,该线程并不立即被撤消。它要返回线程池,这样它就可以准备处理已经排队的任何其他工作项目。你的应用程序的运行效率可能会变得更高,因为不必为每个客户机请求创建和撤消线程。另外,由于线程与完成端口相关联,因此可以同时运行的线程数量限制为C P U数量的两倍。这就减少了线程的上下文转移的开销。
该函数的内部运行情况是, QueueUserWorkItem检查非I/O组件中的线程数量,然后根据负荷量(已排队的工作项目的数量)将另一个线程添加给该组件。接着QueueUserWorkItem执行对PostQueuedCompletionStatus的等价调用,将工作项目的信息传递给I/O完成端口。最后,在完成端口上等待的线程取出信息(通过调用GetQueuedCompletionStatus),并调用函数。当函数返回时,该线程再次调用GetQueuedCompletionStatus,以便等待另一个工作项目。线程池希望经常处理异步I/O请求,即每当线程将一个I/O请求排队放入设备驱动程序时,便要处理异步I/O请求。当设备驱动程序执行该I/O时,请求排队的线程并没有中断运行,而是继续执行其他指令。异步I/O是创建高性能可伸缩的应用程序的秘诀,因为它允许单个线程处理来自不同客户机的请求。该线程不必顺序处理这些请求,也不必在等待I/O请求运行结束时中断运行。
但是,Wi n d o w s对异步I/O请求规定了一个限制,即如果线程将一个异步I/O请求发送给设备驱动程序,然后终止运行,那么该I/O请求就会丢失,并且在I/O请求运行结束时,没有线程得到这个通知。在设计良好的线程池中,线程的数量可以根据客户机的需要而增减。因此,如果线程发出一个异步I/O请求,然后因为线程池缩小而终止运行,那么该I/O请求也会被撤消。因为这种情况实际上并不是你想要的,所以你需要一个解决方案。如果你想要给发出异步I/O请求的工作项目排队,不能将该工作项目插入线程池的非I/O组件中。必须将该工作项目放入线程池的I/O组件中进行排队。该I/O组件由一组线程组成,如果这组线程还有尚未处理的I/O请求,那么它们决不能终止运行。因此你只能将它们用来运行发出异步I/O请求的代码。
若要为I/O组件的工作项目进行排队,仍然必须调用QueueUserWorkItem函数,但是可以为dwFlags参数传递WT_EXECUTEINIOTHREAD。通常只需传递WT_EXECUTEDEFAULT(定义为0),这使得工作项目可以放入非I/O组件的线程中。Windows提供的函数(如RegNotifyChangeKeyValue)能够异步执行与非I/O相关的任务。这些函数也要求调用线程不能终止运行。如果想使用永久线程池的线程来调用这些函数中的一个,可以使用WT_EXECUTEINPERSISTENTTHREAD标志,它使定时器组件的线程能够执行已排队的工作项目回调函数。由于定时器组件的线程决不会终止运行,因此可以确保最终发生异步操作。应该保证回调函数不会中断,并且保证它能迅速执行,这样,定时器组件的线程就不会受到不利的影响。设计良好的线程池也必须设法保证线程始终都能处理各个请求。如果线程池包含4个线程,并且有1 0 0个工作项目已经排队,每次只能处理4个工作项目。如果一个工作项目只需要几个毫秒来运行,那么这是不成问题的。但是,如果工作项目需要运行长得多的时间,那么将无法及时处理这些请求。
当然,系统无法很好地预料工作项目函数将要进行什么操作,但是,如果知道工作项目需要花费很长的时间来运行, 那么可以调用QueueUserWorkItem 函数,为它传递WT_EXECUTELONGFUNCTION标志。该标志能够帮助线程池决定是否要将新线程添加给线程池。如果线程池中的所有线程都处于繁忙状态,它就会强制线程池创建一个新线程。因此,如果同时对10 000个工作项目进行了排队(使用WT_EXECUTELONGFUNCTION标志),那么这10000 个线程就被添加给该线程池。如果不想创建10000个线程,必须分开调用QueueUserWorkItem函数,这样某些工作项目就有机会完成运行。线程池不能对线程池中的线程数量规定一个上限,否则就会发生渴求或死锁现象。假如有10000个排队的工作项目,当第10001个项目通知一个事件时,这些工作项目将全部中断运行。如果你已经设置的最大数量为10 000个线程,第10 001个工作项目没有被执行,那么所有的10 000个线程将永远被中断运行。当使用线程池函数时,应该查找潜在的死锁条件。当然,如果工作项目函数在关键代码段、信标和互斥对象上中断运行,那么必须十分小心,因为这更有可能产生死锁现象。始终都应该了解哪个组件(I/O、非I/O、等待或定时器等)的线程正在运行你的代码。另外,如果工作项目函数位于可能被动态卸载的D L L中,也要小心。调用已卸载的D L L中的函数的线程将会产生违规访问。若要确保不卸载带有已经排队的工作项目的D L L,必须对已排队工作项目进行引用计数,在调用QueueUserWorkItem函数之前递增计数器的值,当工作项目函数完成运行时则递减该计数器的值。只有当引用计数降为0时,才能安全地卸载D L L。
11.2 方案2:按规定的时间间隔调用函数
有时应用程序需要在某些时间执行操作任务。Wi n d o w s提供了一个等待定时器内核对象,因此可以方便地获得基于时间的通知。许多程序员为应用程序执行的每个基于时间的操作任务创建了一个等待定时器对象,但是这是不必要的,会浪费系统资源。相反,可以创建一个等待定时器,将它设置为下一个预定运行的时间,然后为下一个时间重置定时器,如此类推。然而,要编写这样的代码非常困难,不过可以让新线程池函数对此进行管理。若要调度在某个时间运行的工作项目,首先要调用下面的函数,创建一个定时器队列:HANDLE CreateTimerQueue();
定时器队列对一组定时器进行组织安排。例如,有一个可执行文件控制着若干个服务程序。每个服务程序需要触发定时器,以帮助保持它的状态,比如客户机何时不再作出响应,何时收集和更新某些统计信息等。让每个服务程序占用一个等待定时器和专用线程,这是不经济的。相反,每个服务程序可以拥有它自己的定时器队列(这是个轻便的资源),并且共享定时器组件的线程和等待定时器对象。当一个服务程序终止运行时,它只需要删除它的定时器队列即可,因为这会删除该队列创建的所有定时器。
一旦拥有一个定时器队列,就可以在该队列中创建下面的定时器:
BOOL CreateTimerQueueTimer();
工作回调函数必须采用下面的原型:
VOID WINAPI WaitOrTimeCallback();
当不再想要触发定时器时,必须通过调用下面的函数将它删除:
BOOL DeleteTimeQueueTimer();
一旦创建了一个定时器,可以调用下面这个函数来改变它的到期时间和到期周期:
BOOL ChangeTimerQueueTimer();
这里传递了定时器队列的句柄和想要修改的现有定时器的句柄。可以修改定时器的dwDueTime和dwPeriod。注意,试图修改已经触发的单步定时器是不起作用的。另外,你可以随意调用该函数,而不必担心死锁。
当不再需要一组定时器时,可以调用下面这个函数,删除定时器队列:
BOOL DeleteTimerQueueEX();
该函数取出一个现有的定时器队列的句柄,并删除它里面的所有定时器,这样就不必为删除每个定时器而显式调用DeleteTimerQueueTimer。hCompletionEvent参数在这里的语义与它在DeleteTimerQueueTimer函数中的语义是相同的。这意味着它存在同样的死锁可能性,因此必须小心。
在开始介绍另一个方案之前,让我们说明两个其他的项目。首先,线程池的定时器组件创建等待定时器,这样,它就可以给APC项目排队,而不是给对象发送通知。这意味着操作系统能够连续给APC项目排队,并且定时器事件从来不会丢失。因此,设置一个定期定时器能够保证每个间隔时间都能为你的工作项目排队。如果创建一个定期定时器,每隔10s触发一次,那么每隔10s就调用你的回调函数。必须注意这在使用多线程时也会发生必须对工作项目函数的各个部分实施同步。
如果不喜欢这种行为特性,而希望你的工作项目在每个项目执行之后的10s进行排队,那么应该在工作项目函数的结尾处创建单步定时器。或者可以创建一个带有高超时值的单个定时器,并在工作项目函数的结尾处调用ChangeTimerQueueTimer.

11.3方案3:当单个内核对象变为已通知状态时调用函数
Microsoft发现,许多应用程序产生的线程只是为了等待内核对象变为已通知状态。一旦对象得到通知,该线程就将某种通知移植到另一个线程,然后返回,等待该对象再次被通知。有些编程人员甚至编写了代码,在这种代码中,若干个线程各自等待一个对象。这对系统资源是个很大的浪费。当然,与创建进程相比,创建线程需要的的开销要小得多,但是线程是需要资源的。每个线程有一个堆栈,并且需要大量的CPU指令来创建和撤消线程。始终都应该尽量减少它使用的资源。
如果想在内核对象得到通知时注册一个要执行的工作项目,可以使用另一个新的线程池函数:
BOOL RegisterWaitForSingleObject();
该函数负责将参数传送给线程池的等待组件。
当工作项目准备执行时,它被默认排队放入非I/O组件的线程中。这些线程之一最终将会醒来,并且调用你的函数,该函数的原型必须是下面的形式:
VOID WINAPI WaitOrTimeCallbackFunc();
现在,如果正在等待一个自动重置的事件内核对象。一旦该对象变为已通知状态,该对象就重置为它的未通知状态,并且它的工作项目将被放入队列。这时,该对象仍然处于注册状态,同时,等待组件再次等待该对象被通知,或者等待超时(它已经重置)结束。当不再想让该等待组件等待你的注册对象时,必须取消它的注册状态。即使是使用WT_EXECUTEONLYONCE标志注册的并且已经拥有队列的工作项目的等待组件,情况也是如此。调用下面这个函数,可以取消等待组件的注册状态:
BOOL UnregisterWaitEx();

11.4方案4:当异步I/O请求完成运行时调用函数
最后一个方案是个常用的方案,即服务器应用程序发出某些异步I/O请求,当这些请求完成时,需要让一个线程池准备好来处理已完成的I/O请求。这个结构是I/O完成端口原先设计时所针对的一种结构。如果要管理自己的线程池,就要创建一个I/O完成端口,并创建一个等待该端口的线程池。还需要打开多个I/O设备,将它们的句柄与完成端口关联起来。当异步I/O请求完成时,设备驱动程序就将“工作项目”排队列入该完成端口。
这是一种非常出色的结构,它使少数线程能够有效地处理若干个工作项目,同时它又是一种很特殊的结构,因为线程池函数内置了这个结构,使你可以节省大量的设计和精力。若要利用这个结构,只需要打开设备,将它与线程池的非I/O组件关联起来。记住,I/O组件的线程全部在一个I/O组件端口上等待。若要将一个设备与该组件关联起来,可以调用下面的函数:
BOOL BindIoCompletionCallback();
该函数在内部调用CreateIoCompletionPort,传递hDevice和内部完成端口的句柄。调用BindIoCompletionCallback也可以保证至少有一个线程始终在非I/O组件中。与该设备相关联的完成关键字是重叠完成例程的地址。这样,当该设备的I/O运行完成时,非I/O组件就知道要调用哪个函数,以便它能够处理已完成的I/O请求。该完成例程必须采用下面的原型:
BOOL OverlappedCompletionRoutine();

第12章纤程
Microsoft公司给Windows添加了一种纤程,以便能够非常容易地将现有的UNIX服务器应用程序移植到Windows中。UNIX服务器应用程序属于单线程应用程序(由Windows定义),但是它能够为多个客户程序提供服务。换句话说,UNIX应用程序的开发人员已经创建了他们自己的线程结构库,他们能够使用这种线程结构库来仿真纯线程。该线程包能够创建多个堆栈,保存某些CPU寄存器,并且在它们之间进行切换,以便为客户机请求提供服务。
显然,若要取得最佳的性能,这些UNIX应用程序必须重新设计,仿真的线程库应该用Windows提供的纯线程来替代。然而,这种重新设计需要花费数月甚至更长的时间才能完成,因此许多公司首先将它们现有的UNIX代码移植到Windows中,这样就能够将某些应用软件推向Windows市场。
当你将UNIX代码移植到Windows中时,一些问题就会因此而产生。尤其是Windows管理线程的内存栈的方法要比简单地分配内存复杂得多。Windows内存栈开始时的物理存储器的容量比较小,然后根据需要逐步扩大。这个过程在第16章“线程的堆栈”中详细介绍。由于结构化异常处理机制的原因,代码的移植就更加复杂了。为了能够更快和更正确地将它们的代码移植到Windows中,Microsoft公司在操作系统中添加了纤程。本章将要介绍纤程的概念、负责操作纤程的函数以及如何利用纤程的特性。要记住,如果有设计得更好的使用Windows自身线程的应用程序,那么应该避免使用纤程。
12.1纤程的操作
首先要注意的一个问题是,实现线程的是Windows内核。操作系统清楚地知道线程的情况,并且根据Microsoft定义的算法对线程进行调度。纤程是以用户方式代码来实现的,内核并不知道纤程,并且它们是根据用户定义的算法来调度的。由于你定义了纤程的调度算法,因此,就内核而言,纤程采用非抢占式调度方式。需要了解的下一个问题是,单线程可以包含一个或多个纤程。就内核而言,线程是抢占调度的,是正在执行的代码。然而,线程每次执行一个纤程的代码—--你决定究竟执行哪个纤程。当使用纤程时,你必须执行的第一步操作是将现有的线程转换成一个纤程。可以通过调用ConvertThreadToFiber函数来执行这项操作:该函数为纤程的执行环境分配相应的内存(约为200字节)。该执行环境由下列元素组成:
•一个用户定义的值,它被初始化为传递给ConvertThreadToFiber的pvParam参数的值。
•结构化异常处理链的头。
•纤程内存栈的最高和最低地址(当将线程转换成纤程时,这也是线程的内存栈)。
•CPU寄存器,包括堆栈指针、指令指针和其他。
当对纤程的执行环境进行分配和初始化后,就可以将执行环境的地址与线程关联起来。该线程被转换成一个纤程,而纤程则在该线程上运行。ConvertThreadToFiber函数实际上返回纤程的执行环境的内存地址。虽然必须在晚些时候使用该地址,但是决不应该自己对该执行环境数据进行读写操作,因为必要时纤程函数会为你对该结构的内容进行操作。现在,如果你的纤程(线程)返回或调用ExitThread函数,那么纤程和线程都会终止运行。
除非打算创建更多的纤程以便在同一个线程上运行,否则没有理由将线程转换成纤程。若要创建另一个纤程,该线程(当前正在运行纤程的线程)可以调用CreateFiber函数。
PVOID CreateFiber(
DWORD dwStackSize,
PFIBER_START_ROUTINE pfnstartAddress,
PVOID pvParam);
CreateFiber首先设法创建一个新内存栈,它的大小由dwStackSize参数来指明。通常传递的参数是0,按照默认设置,它创建一个内存栈,其大小可以扩展为1MB,不过开始时有两个存储器页面用于该内存栈。如果设定一个非0值,那么就用设定的大小来保存和使用内存栈。接着,CreateFiber函数分配一个新的纤程执行环境结构,并对它进行初始化。该用户定义的值被设置为传递给CreateFiber的pvParam参数的值,新内存栈的最高和最低地址被保存,同时,纤程函数的内存地址(作为pfnStartAddress参数来传递)也被保存。
PfnStartAddress参数用于设定必须实现的纤程例程的地址,它必须采用下面的原型:
VOID WINAPI FiberFunc(PVOID pvParam);
当纤程被初次调度时,该函数就开始运行,并且将原先传递给CreateFiber的pvParam的值传递给它。可以在这个纤程函数中执行想执行的任何操作。但是该函数的原型规定返回值是VOID,这并不是因为返回值没有任何意义,而是因为该函数根本不应该返回。如果纤程确实返回了,那么线程和该线程创建的所有纤程将立即被撤消。与ConvertThreadToFiber函数一样,CreateFiber函数也返回纤程运行环境的内存地址。但是,与ConvertThreadToFiber不同的是,这个新纤程并不执行,因为当前运行的纤程仍然在执行。在单个线程上,每次只能运行一个纤程。若要使新纤程能够运行,可以调用SwitchToFiber函数:
VOID SwithToFiber(PVOID pvFiberExecutionContext);
SwitchToFiber函数只有一个参数,即pvFiberExecutionContext,它是上次调用ConvertThreadToFiber或CreateFiber函数时返回的纤程的执行环境的内存地址。该内存地址告诉该函数要对哪个纤程进行调度。SwitchToFiber函数在内部执行下列操作步骤:
1)它负责将某些当前的CPU寄存器保存在当前运行的纤程执行环境中,包括指令指针寄存器和堆栈指针寄存器。
2)它将上一次保存在即将运行的纤程的执行环境中的寄存器装入CPU寄存器。这些寄存器包括堆栈指针寄存器。这样,当线程继续执行时,就可以使用该纤程的内存栈。
3)它将纤程的执行环境与线程关联起来,线程运行特定的纤程。
4)它将线程的指令指针设置为已保存的指令指针。线程(纤程)从该纤程上次执行的地方开始继续执行。
SwitchToFiber函数是纤程获得CPU时间的唯一途径。由于你的代码必须在相应的时间显式调用SwitchToFiber函数,因此你对纤程的调度可以实施全面的控制。记住,纤程的调度与线程调度毫不相干。纤程运行所依赖的线程始终都可以由操作系统终止其运行。当线程被调度时,当前选定的纤程开始运行,而其他纤程则不能运行,除非显式调用SwitchToFiber函数。若要撤消纤程,可以调用DeleteFiber函数:该函数用于删除pvFiberExecutionContext参数指明的纤程,当然这是纤程的执行环境的地址。该函数能够释放纤程栈使用的内存,然后撤消纤程的执行环境。但是,如果传递了当前与线程相关联的纤程地址,那么该函数就在内部调用ExitThread函数,该线程及其创建的所有纤程全部被撤消。DeleteFiber函数通常由一个纤程调用,以便删除另一个纤程。已经删除的纤程的内存栈将被撤消,纤程的执行环境被释放。注意,纤程与线程之间的差别在于,线程通常通过调用ExitThread函数将自己撤消。实际上,用一个线程调用TerminateThread函数来终止另一个线程的运行,是一种不好的方法。如果你确实调用了TerminateThread函数,系统并不撤消已经终止运行的线程的内存栈。可以利用纤程的这种能力来删除另一个纤程,后面介绍示例应用程序时将说明这是如何实现的。为了使操作更加方便,还可以使用另外两个纤程函数。一个线程每次可以执行一个纤程,操作系统始终都知道当前哪个纤程与该线程相关联。如果想要获得当前运行的纤程的执行环境的地址,可以调用GetCurrentFiber函数:另一个使用非常方便的函数是GetFiberData:前面讲过,每个纤程的执行环境包含一个用户定义的值。这个值使用作为ConvertThreadToFiber或CreateFiber的pvParam参数而传递的值进行初始化。该值也可以作为纤程函数的参数来传递。GetFiberData只是查看当前执行的纤程的执行环境,并返回保存的值。无论GetCurrentFiber还是GetFiberData,运行速度都很快,并且通常是作为内蕴函数(infrinsicfuncfion)来实现的,这意味着编译器能够为这些函数生成内联代码。

第三部分内存管理

第13章Windows的内存结构

操作系统使用的内存结构是理解操作系统如何运行的最重要的关键。本章将要介绍Microsoft公司的Windows操作系统使用的内存结构。
13.1进程的虚拟地址空间
每个进程都被赋予它自己的虚拟地址空间。对于32位进程来说,这个地址空间是4GB,因为32位指针可以拥有从0x00000000至0xFFFFFFFF之间的任何一个值。这使得一个指针能够拥有4294967296个值中的一个值,它覆盖了一个进程的4GB虚拟空间的范围。对于64位进程来说,这个地址空间是16EB(1018字节),因为64位指针可以拥有从0x0000000000000000至0xFFFFFFFFFFFFFFFF之间的任何值。这使得一个指针可以拥有18446744073709551616个值中的一个值,它覆盖了一个进程的16EB虚拟空间的范围。这是相当大的一个范围。
由于每个进程可以接收它自己的私有的地址空间,因此当进程中的一个线程正在运行时,该线程可以访问只属于它的进程的内存。属于所有其他进程的内存则隐藏着,并且不能被正在运行的线程访问。
注意在Windows2000中,属于操作系统本身的内存也是隐藏的,正在运行的线程无法访问。这意味着线程常常不能访问操作系统的数据。Windows98中,属于操作系统的内存是不隐藏的,正在运行的线程可以访问。因此,正在运行的线程常常可以访问操作系统的数据,也可以破坏操作系统(从而有可能导致操作系统崩溃)。在Windows98中,一个进程的线程不可能访问属于另一个进程的内存。
前面说过,每个进程有它自己的私有地址空间。进程A可能有一个存放在它的地址空间中的数据结构,地址是0x12345678,而进程B则有一个完全不同的数据结构存放在它的地址空间中,地址是0x12345678。当进程A中运行的线程访问地址为0x12345678的内存时,这些线程访问的是进程A的数据结构。当进程B中运行的线程访问地址为0x12345678的内存时,这些线程访问的是进程B的数据结构。进程A中运行的线程不能访问进程B的地址空间中的数据结构。反之亦然。
当你因为拥有如此大的地址空间可以用于应用程序而兴高采烈之前,记住,这是个虚拟地址空间,不是物理地址空间。该地址空间只是内存地址的一个范围。在你能够成功地访问数据而不会出现违规访问之前,必须赋予物理存储器,或者将物理存储器映射到各个部分的地址空间。本章后面将要具体介绍这是如何操作的。
13.2虚拟地址空间如何分区
每个进程的虚拟地址空间都要划分成各个分区。地址空间的分区是根据操作系统的基本实现方法来进行的。不同的Windows内核,其分区也略有不同。表13-1显示了每种平台是如何对进程的地址空间进行分区的。

如你所见,32位Windows2000的内核与64位Windows2000的内核拥有大体相同的分区,差别在于分区的大小和位置有所不同。另一方面,可以看到Windows98下的分区有着很大的不同。下面让我们看一下系统是如何使用每一个分区的。
13.2.1NULL指针分配的分区—适用于Windows2000和Windows98
进程地址空间的这个分区的设置是为了帮助程序员掌握NULL指针的分配情况。如果你的进程中的线程试图读取该分区的地址空间的数据,或者将数据写入该分区的地址空间,那么CPU就会引发一个访问违规。保护这个分区是极其有用的,它可以帮助你发现NULL指针的分配情况。
C/C++程序中常常不进行严格的错误检查。例如,下面这个代码就没有进行任何错误检查:
Int* pnSomeInteger = (int*) malloc(sizeof(int));
*pnSomeInteger =5;
如果malloc不能找到足够的内存来满足需要,它就返回NULL。但是,该代码并不检查这种可能性,它认为地址的分配已经取得成功,并且开始访问0x00000000地址的内存。由于这个分区的地址空间是禁止进入的,因此就会发生内存访问违规现象,同时该进程将终止运行。这个特性有助于编程员发现应用程序中的错误。
13.2.2MS-DOS/16位Windows应用程序兼容分区—仅适用于Windows98
进程地址空间的这个4MB分区是Windows98需要的,目的是维护MS-DOS应用程序与16位应用程序之间的兼容性。不应该试图从32位应用程序来读取该分区的数据,或者将数据写入该分区。在理想的情况下,如果进程中的线程访问该内存,CPU应该产生一个访问违规,但是由于技术上的原因,Microsoft无法保护这个4MB的地址空间。
在Windows2000中,16位MS-DOS与16位Windows应用程序是在它们自己的地址空间中运行的,32位应用程序不会对它们产生任何影响。
13.2.3用户方式分区—适用于Windows2000和Windows98
这个分区是进程的私有(非共享)地址空间所在的地方。一个进程不能读取、写入、或者以任何方式访问驻留在该分区中的另一个进程的数据。对于所有应用程序来说,该分区是维护进程的大部分数据的地方。由于每个进程可以得到它自己的私有的、非共享分区,以便存放它的数据,因此,应用程序不太可能被其他应用程序所破坏,这使得整个系统更加健壮。
Windows2000在Windows2000中,所有的.exe和DLL模块均加载这个分区。每个进程可以将这些DLL加载到该分区的不同地址中(不过这种可能性很小)。系统还可以在这个分区中映射该进程可以访问的所有内存映射文件。
Windows98在Windows98中,主要的Win32系统DLL(Kernel32.dll,AdvAPI32.dll,User32.dll和GDI32.dll)均加载共享内存映射文件分区中。.exe和所有其他DLL模块则加载到这个用户方式分区中。所有进程的共享DLL均位于相同的虚拟地址中,但是其他DLL可以将这些DLL加载到用户方式分区的不同地址中(不过这种可能性不大)。另外,在Windows98中,用户方式分区中决不会出现内存映射文件。
当我最初观察32位进程的地址空间的时候,我惊奇地发现可以使用的地址空间还不到我的进程的全部地址空间的一半。难道内核方式分区真的需要上面的一半地址空间吗?实际上回答是肯定的。系统需要这个地址空间,供内核代码、设备驱动程序代码、设备I/O高速缓存、非页面内存池的分配和进程页面表等使用。实际上Microsoft将内核压缩到这个2GB空间之中。在64位Windows2000中,内核终于得到了它真正需要的空间。
1.在x86的Windows2000中获得3GB用户方式分区
多年来,编程人员一直强烈要求扩大用户方式的地址空间。为了满足这个需要,Microsoft允许x86的Windows2000 Advanced Server版本和Windows2000 DataCenter版本将用户方式分区扩大为3GB。若要使所有进程都能够使用3GB用户方式分区和1GB内核方式分区,必须将/3GB开关附加到系统的BOOT.INI文件的有关项目中。表13-1中的“32位Windows2000(x86 w/3GB用户方式)”这一列显示了使用3GB开关时它的地址空间是个什么样子。
在Microsoft添加/3GB开关之前,应用程序无法看到设置了高位的内存指针。一些有创意的编程员自己将这个高位用作一个标志,这个标志只对他们的应用程序具有意义。这时,当应用程序访问内存地址时,运行的代码将在内存地址被使用之前清除该指针的高位。可以想象,当应用程序在3GB的用户方式环境中运行时,该应用程序转眼之间就会运行失败。
Microsoft不得不提出一个解决方案,以便使该应用程序能够在3GB环境中运行。当系统准备运行一个应用程序时,它要查看该应用程序是否与/LARGEADDRESSAWARE链接程序开关相链接。如果是链接的,那么应用程序就声称它并没有对内存地址执行什么特殊的操作,并且完全准备充分利用3GB用户方式地址空间。另一方面,如果该应用程序没有与/LARGEADDRESSAWARE开关相链接,那么操作系统将保留0x80000000至0xBFFFFFFF之间的1GB区域。这可以防止在已经设置了高位的内存地址上进行内存分配。
注意,内核已经被紧紧地压缩到了一个2GB的分区中。当使用3GB的开关时,内核勉强地被放入一个1GB的分区中。使用/3GB的开关,可以减少系统能够创建的线程、堆栈和其他资源的数量。此外,系统最多只能使用16GB的RAM,而通常情况下最多可以使用64GB的RAM,因为内核方式中没有足够的虚拟地址空间可以用来管理更多的RAM。
注意当操作系统创建进程的地址空间时,需要检查一个可执行的LARGEADDRESSAWARE标志。对于DLL,系统则忽略该标志。在编写DLL时,必须使之能够在3GB用户方式分区中正确地运行,否则它们的行为特性是无法确定的。
2.在64位Windows2000中获得2GB用户方式分区
Microsoft发现许多编程人员需要尽可能迅速而方便地将现有的32位应用程序移植到64位环境中去。但是,在许多源代码中,指针被视为32位值。如果简单地重新编写应用程序,就会造成指针被截断的错误和不正确的内存访问。然而,如果系统能够确保不对0x000000007FFFFFFF以上的内存地址进行分配,那么应用程序就能很好地运行。当较高的33位是0时,将64位地址截断为32位地址,不会产生任何问题。通过在地址空间范围内运行应用程序,而这个地址空间范围将进程的可用地址空间限制为最低的GB,那么系统就能够确保这一点。默认情况下,当启动一个64位应用程序时,系统将保留从0x000000080000000开始的所有用户地址空间。这可以确保在底部的2GB64位地址空间中进行所有的内存分配。这就是地址空间的范围。对于大多数应用程序来说,这个地址空间足够了。若要使64位应用程序能够访问它的全部4TB(terabyte)用户方式分区,该应用程序必须使用/LARGEADDRESSAWARE链接开关来创建。
注意当操作系统创建进程的64位地址空间时,要检查一个可执行文件的LARGEADDRESSAWARE标志。如果是DLL,那么系统将忽略该标志。编写DLL时,必须使之能够在整个4TB用户方式分区中正确地运行,否则它们的行为特性将无法确定。
13.2.464KB禁止进入的分区—仅适用于Windows2000
这个位于用户方式分区上面的64KB分区是禁止进入的,访问该分区中的内存的任何企图均将导致访问违规。Microsoft之所以保留该分区,是因为这样做将使得Microsoft能够更加容易地实现操作系统。当将内存块的地址和它的长度传递给Windows函数时,该函数将在执行它的操作之前使内存块生效。
13.2.5共享的MMF分区—仅适用于Windows98
这个1GB分区是系统用来存放所有32位进程共享数据的地方。例如,系统的动态链接库Kernel32.dll、AdvAPI32.dll、User32.dll和GDI32.dll等,全部存放在这个地址空间分区中,因此,所有32位进程都能很容易同时访问它们。系统还为每个进程将DLL加载相同的内存地址。此外,系统将所有内存映射文件映射到这个分区中。内存映射文件将在第17章中详细介绍。
13.2.6内核方式分区—适用于Windows2000和Windows98
这个分区是存放操作系统代码的地方。用于线程调度、内存管理、文件系统支持、网络支持和所有设备驱动程序的代码全部在这个分区加载。驻留在这个分区中的一切均可被所有进程共享。在Windows2000中,这些组件是完全受到保护的。如果你试图访问该分区中的内存地址,你的线程将会产生访问违规,导致系统向用户显示一个消息框,并关闭你的应用程序。关于访问违规和如何处理这些违规的详细说明,请参见第23、24和25章的内容。
Windows98不幸的是,在Windows98中该分区中的数据是不受保护的。任何应用程序都可以从该分区读取数据,也可以写入数据,因此有可能破坏操作系统。
13.3地址空间中的区域
当进程被创建并被赋予它的地址空间时,该可用地址空间的主体是空闲的,即未分配的。若要使用该地址空间的各个部分,必须通过调用VirtualAlloc函数(第15章介绍)来分配它里边的各个区域。对一个地址空间的区域进行分配的操作称为保留(reserving)。每当你保留地址空间的一个区域时,系统要确保该区域从一个分配粒度的边界开始。对于不同的CPU平台来说,分配粒度是各不相同的。但是,至今所有的CPU平台(x86、32位Alpha、64位Alpha和IA-64)都使用64KB这个相同的分配粒度。
当你保留地址空间的一个区域时,系统还要确保该区域的大小是系统的页面大小的倍数。页面是系统在管理内存时使用的一个内存单位。与分配粒度一样,不同的CPU,其页面大小也是不同的。x86使用的页面大小是4KB,而Alpha(当既能运行32位Windows2000也能运行64位Windows2000时)使用的页面大小则是8KB。
注意有时系统能够代表你的进程来保留地址空间的区域。例如,系统可以分配一个地址空间区域,以便存放进程环境块(FEB)。FEB是由系统创建、操作和撤消的一个小型数据结构。当创建一个进程时,系统就为FEB分配一个地址空间区域。系统也需要创建一个线程环境块(TEB),以便管理进程中当前存在的所有线程。用于这些TEB的区域将根据进程中的线程被创建和撤消等情况而保留和释放。虽然系统规定,要求保留的地址空间区域均从分配粒度边界(目前所有平台上均为64KB)开始,但是系统本身并不受这个规定的限制。为你的进程的PEB和TEB保留的地址空间区域很可能不是从64KB这个边界开始的。不过这些保留区域仍然必须是CPU的页面大小的倍数。
如果想保留一个10KB的地址空间区域,系统将自动对你的请求进行四舍五入,使保留的地址空间区域的大小是页面大小的倍数。这意味着,在x86平台上,系统将保留一个12KB的区域,在Alpha平台上,系统将保留一个16KB的区域。
当你的程序算法不再需要访问已经保留的地址空间区域时,该区域应该被释放。这个过程称为释放地址空间的区域,它是通过调用VirtualFree函数来完成的。
13.4提交地址空间区域中的物理存储器
若要使用已保留的地址空间区域,必须分配物理存储器,然后将该物理存储器映射到已保留的地址空间区域。这个过程称为提交物理存储器。物理存储器总是以页面的形式来提交的。若要将物理存储器提交给一个已保留的地址空间区域,也要调用VirtualAlloc函数。
当将物理存储器提交给地址空间区域时,不必将物理存储器提交给整个区域。
当你的程序算法不再需要访问保留的地址空间区域中已提交的物理存储器时,该物理存储器应该被释放。这个过程称为回收物理存储器,它是通过VirtualFree函数来完成的。
13.5物理存储器与页文件
在较老的操作系统中,物理存储器被视为计算机拥有的RAM的容量。换句话说,如果计算机拥有16MB的RAM,那么加载和运行的应用程序最多可以使用16MB的RAM。今天的操作系统能够使得磁盘空间看上去就像内存一样。磁盘上的文件通常称为页文件,它包含了可供所有进程使用的虚拟内存。
操作系统与CPU相协调,共同将RAM的各个部分保存到页文件中,当运行的应用程序需要时,再将页文件的各个部分重新加载到RAM。由于页文件增加了应用程序可以使用的RAM的容量,因此页文件的使用是视情况而定的。如果没有页文件,那么系统就认为只有较少的RAM可供应用程序使用。但是,我们鼓励用户使用页文件,这样他们就能够运行更多的应用程序,并且这些应用程序能够对更大的数据集进行操作。最好将物理存储器视为存储在磁盘驱动器(通常是硬盘驱动器)上的页文件中的数据。这样,当一个应用程序通过调用VirtualAlloc函数,将物理存储器提交给地址空间的一个区域时,地址空间实际上是从硬盘上的一个文件中进行分配的。系统的页文件的大小是确定有多少物理存储器可供应用程序使用时应该考虑的最重要的因素,RAM的容量则影响非常小。现在,当你的进程中的一个线程试图访问进程的地址空间中的一个数据块时,将会发生两种情况之一,参见图13-2中的流程图。

在第一种情况中,线程试图访问的数据是在RAM中。在这种情况下,CPU将数据的虚拟内存地址映射到内存的物理地址中,然后执行需要的访问。
在第二种情况中,线程试图访问的数据不在RAM中,而是存放在页文件中的某个地方。这时,试图访问就称为页面失效,CPU将把试图进行的访问通知操作系统。这时操作系统就寻找RAM中的一个内存空页。如果找不到空页,系统必须释放一个空页。如果一个页面尚未被修改,系统就可以释放该页面。但是,如果系统需要释放一个已经修改的页面,那么它必须首先将该页面从RAM拷贝到页交换文件中,然后系统进入该页文件,找出需要访问的数据块,并将数据加载到空闲的内存页面。然后,操作系统更新它的用于指明数据的虚拟内存地址现在已经映射到RAM中的相应的物理存储器地址中的表。这时CPU重新运行生成初始页面失效的指令,但是这次CPU能够将虚拟内存地址映射到一个物理RAM地址,并访问该数据块。系统需要将内存页面拷贝到页文件并反过来将页文件拷贝到内存页面的次数越多,你的硬盘倒腾的次数就越多,系统运行得越慢(倒腾意味着操作系统要花费更多的时间将页面从内存中转出转进,而不是将时间用于程序的运行)。因此,通过给你的计算机增加更多的RAM,就可以减少运行应用程序所需的倒腾次数,这就必然可以大大提高系统的运行速度。所以必须遵循一条基本原则,那就是要让你的计算机运行得更块,增加更多的RAM。实际上,在大多数情况下,若要提高系统的运行性能,增加RAM比提高CPU的速度所产生的效果更好。

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


当阅读了上一节后,你必定会认为,如果同时运行许多文件的话,页文件就可能变得非常大,而且你会认为,每当你运行一个程序时,系统必须为进程的代码和数据保留地址空间的一些区域,将物理存储器提交给这些区域,然后将代码和数据从硬盘上的程序文件拷贝到页文件中已提交的物理存储器中。
实际上系统并不进行上面所说的这些操作。如果它进行这些操作的话,就要花费很长的时间来加载程序并启动它运行。相反,当启动一个应用程序的时候,系统将打开该应用程序的.exe文件,确定该应用程序的代码和数据的大小。然后系统要保留一个地址空间的区域,并指明与该区域相关联的物理存储器是在.exe文件本身中。即系统并不是从页文件中分配地址空间,而是将.exe文件的实际内容即映像用作程序的保留地址空间区域。当然,这使应用程序的加载非常迅速,并使页文件能够保持得非常小。
当硬盘上的一个程序的文件映像(这是个.exe文件或DLL文件)用作地址空间的区域的物理存储器时,它称为内存映射文件。当一个.exe文件或DLL文件被加载时,系统将自动保留一个地址空间的区域,并将该文件映像映射到该区域中。但是,系统也提供了一组函数,使你能够将数据文件映射到一个地址空间的区域中。关于内存映射文件的详细说明,将在第17章中介绍。
Windows2000 Windows2000能够使用多个页文件。如果多个页文件存在于不同的物理硬盘驱动器上,系统的运行将能得快得多,因为它能够将数据同时写入多个驱动器。打开SystemPropertiesControlPanel(系统属性控制面板)小程序,再选择Advanced选项卡,单击PerformanceOptions(性能选项)按钮,就能够添加或删除页文件。注意当.exe或DLL文件从软盘加载时,Windows98和Windows2000都能将整个文件从软盘拷贝到系统的RAM中。此外,系统将从页文件中分配足够的内存,以便存放该文件的映像。如果系统选择对当前包含该文件的一部分映像的RAM页面进行裁剪,那么该内存属于只能写入的内存。如果系统RAM上的负载比较小,那么文件始终都可以直接从RAM来运行。
Microsoft不得不通过软盘来运行的映射文件,这样,安装应用程序才能正确运行。安装程序常常从一个软盘开始,然后用户将软盘从驱动器中取出来,再插入另一个软盘。如果系统需要回到第一个软盘,以便加载.exe或DLL文件的某些代码,当然该代码已经不再在软盘驱动器中了。然而,由于系统将文件拷贝到RAM(并且受页文件的支持),要访问安装程序是不会有任何问题的。
系统并不将RAM映射文件拷贝在其他可换式介质上,如光盘或网络驱动器,除非映射文件是用/SWAPRUN:CD或/SWAPRUN:NET开关链接的。注意,Windows98不支持/SWAPRUN映像标志。
13.6保护属性
已经分配的物理存储器的各个页面可以被赋予不同的保护属性。表13-2显示了这些保护属性。
x86和AlphaCPU不支持“执行”保护属性,不过操作系统软件却支持这个属性。这些CPU将读访问视为执行访问。这意味着如果将PAGE_EXECUTE保护属性赋予内存,那么该内存也将拥有读优先权。当然,不应该依赖这个行为特性,因为在其他CPU上的Windows实现代码很可能将“执行”保护视为“仅为执行”保护。

表13-2页面的保护属性
保护属性描述
PAGE_NOACCESS 如果试图在该页面上读取、写入或执行代码,就会引发访问违规
PAGE_READONLY 如果试图在该页面上写入或执行代码,就会引发访问违规
PAGE_READWRITE 如果试图在该页面上执行代码,就会引发访问违规
PAGE_EXECUTE 如果试图在该页面上对内存进行读取或写入操作,就会引发访问违规
PAGE_EXECUTE_READ 如果试图在该页面上对内存进行写入操作,就会引发访问违规
PAGE_EXECUTE_READWRITE 对于该页面不管执行什么操作,都不会引发访问违规
PAGE_WRITECOPY 如果试图在该页面上执行代码,就会引发访问违规。如果试图在该页面上写入内存,就会导致系统将它自己的私有页面(受页文件的支持)拷贝赋予该进程
PAGE_EXECUTE_WRITECOPY 对于该地址空间的区域,不管执行什么操作,都不会引发访问违规。如果试图在该页面上的内存中进行写入操作,就会将它自己的私有页面(受页文件的支持)拷贝赋予该进程
Windows98Windows98只支持PAGE_NOACCESS、PAGE_READONLY和PAGE_READWRITE等保护属性。
13.6.1Copy-On-Write访问
表13-2列出的保护属性都是非常容易理解的,不过最后两个属性需要作一些说明。一个是PAGE_WRITECOPY,另一个是PAGE_EXECUTE_WRITECOPY。这两个属性的作用是为了节省RAM的使用量和页文件的空间。Windows支持一种机制,使得两个或多个进程能够共享单个内存块。因此,如果10个Notepad实例正在运行,那么所有实例可以共享应用程序的代码和数据页面。让所有实例共享同样的内存页面将能够大大提高系统的性能,但是这要求所有实例都将该内存视为只读或只执行的内存。如果一个实例中的线程将数据写入内存修改它,那么其他实例看到的这个内存也将被修改,从而造成一片混乱。
为了防止出现这种混乱,操作系统给共享内存块赋予了Copy-On-Write保护属性。当一个.exe或DLL模块被映射到一个内存地址时,系统将计算有多少页面是可以写入的(通常包含代码的页面标为PAGE_EXECUTE_READ,而包含数据的页面则标为PAGE_READWRITE)。然后,系统从页文件中分配内存,以适应这些可写入的页面的需要。除非该模块的可写入页面是实际的写入模块,否则这些页文件内存是不使用的。
当一个进程中的线程试图将数据写入一个共享内存块时,系统就会进行干预,并执行下列操作步骤:
1)系统查找RAM中的一个空闲内存页面。注意,当该模块初次被映射到进程的地址空间时,该空闲页面将被页文件中已分配的页面之一所映射。当该模块初次被映射时,由于系统要分配所有可能需要的页文件,因此这一步不可能运行失败。
2)系统将试图被修改的页面内容拷贝到第一步中找到的页面。该空闲页面将被赋予PAGE_READWRITE或PAGE_EXECUTE_READWRITE保护属性。原始页面的保护属性和数据不发生任何变化。
3)然后系统更新进程的页面表,使得被访问的虚拟地址被转换成新的RAM页面。当系统执行了这3个操作步骤之后,该进程就可以访问它自己的内存页面的私有实例。第17章还要详细地介绍共享内存和Copy-On-Write保护属性。
此外,当使用VirtualAlloc函数来保留地址空间或者提交物理存储器时,不应该传递PAGE_WRITECOPY或PAGE_EXECUTE_WRITECOPY。如果传递的话,将会导致VirtualAlloc调用的失败。对GetLastError的调用将返回ERROR_INVALID_PARAMETER。当操作系统映射.exe或DLL文件映像时,这两个属性将被操作系统使用。
Windows98Windows98不支持Copy-On-Write保护。当Windows98发现需要Copy_On_Write保护时,它就立即进行数据的拷贝,而不是等待试图对内存进行写入操作。
13.6.2特殊的访问保护属性的标志
除了上面介绍的保护属性外,还有3个保护属性标志,即PAGE_NOCACHE,PAGE_WRITECOMBINE和PAGE_GUARD。可以用OR逐位将它们连接,以便将这3个标志用于任何一个保护属性(PAGE_NOCACHE除外)。
第一个保护属性标志PAGE_NOCACHE用于停用已提交页面的高速缓存。一般情况下最好不要使用该标志,因为它主要是供需要处理内存缓冲区的硬件设备驱动程序的开发人员使用的。
第二个保护属性PAGE_WRITECOMBINE也是供设备驱动程序开发人员使用的。它允许把单个设备的多次写入合并在一起,以便提高运行性能。
最后一个保护属性标志PAGE_GUARD可以在页面上写入一个字节时使应用程序收到一个通知(通过一个异常条件)。该标志有一些非常巧妙的用法。Windows2000在创建线程堆栈时使用该标志。关于该标志的详细说明,参见第16章。
Windows98Windows98将忽略PAGE_NOCACHE、PAGE_WRITECOMBINE和PAGE_GUARD这3个保护属性标志。
13.7综合使用所有的元素
本节要将地址空间、分区、区域、内存块和页面等元素综合起来加以使用。区域类型共有4个值,即空闲,私有,映像或映射。表1 3 - 4对它们进行了介绍。
类型说明
空闲 该区域的虚拟地址不受任何内存的支持。该地址空间没有被保留。应用程序既可以将一个区域保留在显示的基地址上,也可以保留在空闲区域中的任何位置上
私有 该区域的虚拟地址将受系统的页文件的支持。
映像 该区域的虚拟地址原先受内存映射的映像文件(如.exe或DLL文件)的支持,但也许不再受映像文件的支持。例如,当写入模块映像中的全局变量时,“写入时拷贝”的机制将由页文件来支持特定的页面,而不是受原始映像文件的支持
映射 该区域的虚拟地址原先是受内存映射的数据文件的支持,但也许不再受数据文件的支持。例如,数据文件可以使用“写入时拷贝”的保护属性来映射。对文件的任何写入操作都将导致页文件而不是原始数据支持特定的页面

13.7.1与Windows98地址空间的差别
两个地址空间表的最大不同是在Windows98下缺少了某些的信息。例如,每个区域和块能反映出地址空间的区域是空闲、保留还是私有的。你决不会看到映射或者映像之类的字样,因为Windows98没有提供更多的信息来指明支持该区域的物理存储器的是个内存映射文件还是包含在.exe或DLL中的文件映像。
你会发现大多数地址空间区域的大小是分配粒度(64KB)的倍数。如果包含在地址空间区域中的块的大小不是分配粒度的倍数,那么在地址空间区域的结尾处常常有一个保留的地址空间块。这个地址空间块的大小必须使得地址空间区域能够符合分配粒度边界(64KB)倍数的要求。例如,从地址0x00530000开始的地址空间区域包含两个地址块,一个是4KB的已提交内存块,另一个是占用60KB内存地址范围的已保留的地址块。
最后,保护标志从来不反映执行或copy-on-write访问权,因为Windows98不支持这些标志。它也不支持3个保护属性标志,即PAGE_NOCACHE、PAGE_WRITECOMBINE和PAGE_GUARD。由于不支持PAGE_GUARD标志,因此VMMap使用更加复杂的技术来确定是否已经为线程的堆栈保留了地址空间区域。
你将注意到,与Windows2000不同,在Windows98中,0x80000000至0xBFFFFFFF之间的地址空间区域是可以查看的。这个分区包含了所有32位应用程序共享的地址空间。如你所见,有4个系统DLL被加载了这个地址空间区域,可以供所有进程使用。
13.8数据对齐的重要性
本节不再讨论进程的虚拟地址空间问题,而是要介绍数据对齐的重要性。数据对齐并不是操作系统的内存结构的一部分,而是CPU结构的一部分。当CPU访问正确对齐的数据时,它的运行效率最高。当数据大小的数据模数的内存地址是0时,数据是对齐的。例如,WORD值应该总是从被2除尽的地址开始,而DWORD值应该总是从被4除尽的地址开始,如此等等。当CPU试图读取的数据值没有正确对齐时,CPU可以执行两种操作之一。即它可以产生一个异常条件,也可以执行多次对齐的内存访问,以便读取完整的未对齐数据值。
显然,如果CPU执行多次内存访问,应用程序的运行速度就会放慢。在最好的情况下,系统访问未对齐的数据所需要的时间将是访问对齐数据的时间的两倍,不过在有些情况下,访问时间可能更长。为了使应用程序获得最佳的运行性能,编写的代码必须使数据正确地对齐。

下面让我们更加深入地说明x86CPU是如何进行数据对齐的。X86CPU的EFLAGS寄存器中包含一个特殊的位标志,称为AC(对齐检查的英文缩写)标志。按照默认设置,当CPU首次加电时,该标志被设置为0。当该标志是0时,CPU能够自动执行它应该执行的操作,以便成功地访问未对齐的数据值。然而,如果该标志被设置为1,每当系统试图访问未对齐的数据时,CPU就会发出一个INT17H中断。x86的Windows2000和Windows98版本从来不改变这个CPU标志位。因此,当应用程序在x86处理器上运行时,你根本看不到应用程序中出现数据未对齐的异常条件。
现在让我们来看一看AlphaCPU的情况。AlphaCPU不能自动处理对未对齐数据的访问。当未对齐的数据访问发生时,CPU就会将这一情况通知操作系统。这时,Windows2000将会确定它是否应该引发一个数据未对齐异常条件。它也可以执行一些辅助指令,对问题默默地加以纠正,并让你的代码继续运行。按照默认设置,当在Alpha计算机上安装Windows2000时,操作系统会对未对齐数据的访问默默地进行纠正。然而,可以改变这个行为特性。当引导Windows2000时,系统就会在注册表中查找的这个关键字:HKEY_LOCAL_MACHINE\CurrentControlSet\Control\Session Manager
在这个关键字中,可能存在一个值,称为EnableAlignmentFaultExceptions。如果这个值不存在(这是通常的情况),Windows2000会默默地处理对未对齐数据的访问。如果存在这个值,系统就能获取它的相关数据值。如果数据值是0,系统会默默地进行访问的处理。如果数据值是1,系统将不执行默默的处理,而是引发一个未对齐异常条件。几乎从来都不需要修改该注册表值的数据值,因为如果修改有些应用程序能够引发数据未对齐的异常条件并终止运行。为了更加容易地修改该注册表项。Alpha处理器上运行的MicrosoftVisualC++版本包含了一个小型实用程序AXPAlign.exe。该实用程序只是修改注册表值的状态,或者显示值的当前状态。当用该实用程序修改数据值后,必须重新引导操作系统,使所做的修改生效。如果不使用AXPAlign实用程序,仍然可以让系统为进程中的所有线程默默地纠正对未对齐数据的访问,方法是让进程的线程调用SetErrorMode函数:
UINT SetErrorMode(UINT fuErrorMode);
就我们的讨论来说,需要说明的标志是SEM_NOALIGNMENTFAULTEXCEPT标志。当该标志设定后,系统会将自动纠正对未对齐数据的访问。当该标志重新设置时,系统将不纠正对未对齐数据的访问,而是引发数据未对齐异常条件。注意,修改该标志将会影响拥有调用该函数的线程的进程中包含的所有线程。换句话说,改变该标志不会影响其他进程中的任何线程。
还要注意,进程的错误方式标志是由所有的子进程继承的。因此,在调用CreateProcess函数之前,必须临时重置该标志(不过通常不必这样做)。
当然,无论在哪个CPU平台上运行,都可以调用SetErrorMode函数,传递SEM_NOALIGNMENTFAULTEXCEPT标志。但是,结果并不总是相同。如果是x86系统,该标志总是打开的,并且不能被关闭。如果是Alpha系统,那么只有当EnableAlignmentFaultExceptions注册表值被设置为1时,才能关闭该标志。
可以使用Windows2000的MMCPerformanceMonitor来查看每秒钟系统执行多少次数据对齐的调整修改。

第14章虚拟内存
这一章主要具体介绍几个Windows函数,这些函数能够提供关于系统内存管理以及进程中的虚拟地址空间等信息。
14.1系统信息
许多操作系统的值是根据主机而定的,比如页面的大小,分配粒度的大小等。这些值决不应该用硬编码的形式放入你的源代码。相反,你始终都应该在进程初始化的时候检索这些值,并在你的源代码中使用检索到的值。GetSystemInfo函数将用于检索与主机相关的值。
必须传递SYSTEM_INFO结构的地址给这个函数。这个函数将初始化所有的结构成员然后返回。
下面是SYSTEM_INFO数据结构的样子。
Typedef struct _SYSTEM_INFO{
Union{
DWORD dwOemId;
Struct {
WORD wProcessorArchitecture;
WORD wReserved;
};
};
DWORD dwPageSize;
LPVOID lpMinimumApplictionAddress;
LPVOID lpMaximumApplictionAddress;
DWORD_PTR dwActiveProcessorMask;
DWORD dwNumberofProcessors;
DWORD dwProcessorType;
DWORD dwAllocationGranularity;
WORD wProcessorLevel;
WORD wProcessorRevision;
}SYSTEM_INFO,*LPSYSTEM_INFO;
当系统引导时,它要确定这些成员的值是什么。对于任何既定的系统来说,这些值总是相同的,因此决不需要为任何既定的进程多次调用该函数。由于有了GetSystemInfo函数,因此应用程序能够在运行的时候查询这些值。在该结构的所有成员中,只有4个成员与内存有关。
表14-1与内存有关的成员函数
成员名 描述
dwPageSize 用于显示CPU的页面大小。在x86CPU上,这个值是4096字节。在AlphaCPU上,这个值是8192字节。在IA-64上,这个值是8192字节
lpMinimumApplicationAddress 用于给出每个进程的可用地址空间的最小内存地址。在Windows98上,这个值是4194304,或0x00400000,因为每个进程的地址空间中下面的4MB是不能使用的。在Windows2000上,这个值是65536或0x00010000,因为每个进程的地址空间中开头的64KB总是空闲的
lpMaximumApplicationAddress 用于给出每个进程的可用地址空间的最大内存地址。在Windows98上,这个地址是2147483647或0x7FFFFFFF,因为共享内存映射文件区域和共享操作系统代码包含在上面的2GB分区中。在Windows2000上,这个地址是内核方式内存开始的地址,它不足64KB
dwAllocationGranularity 显示保留的地址空间区域的分配粒度。截止到撰写本书时,在所有Windows平台上,这个值都是65536
该结构的其他成员与内存管理毫无关系,为了完整起见,下面也对它们进行了介绍(见表
14-2)。
表14-2与内存无关的成员函数
成员名 描述
dwOemId 已作废,不引用
WRederved 保留供将来使用,不引用
dwNumberOfProcessors 用于指明计算机中的CPU数目
dwActiveProcessorMask 一个位屏蔽,用于指明哪个CPU是活动的(允许运行线程)
dwProcessorType 只用于Windows98,不用于Windows2000,用于指明处理器的类型,如Intel386、486或Pentium
wProcessorArchitecture 只用于Windows2000,不用于Windows98,用于指明处理的结构,如Intel、Alpha、Intel64位或Alpha64位
wProcessorLevel 只用于Windows2000,不用于Windows98,用于进一步细分处理器的结构,如用于设定IntelPentiumPro或PentiumII
wProcessorRevision 只用于Windows2000,不用于Windows98,用于进一步细分处理器的级别
书中给也了一个系统信息示例应用程序。
14.2虚拟内存的状态
Windows函数GlobalMemoryStatus可用于检索关于当前内存状态的动态信息:
当调用GlobalMemoryStatus时,必须传递一个MEMORYSTATUS结构的地址。下面显示了MOMORYSTATUS的数据结构。
Typedef struct _MEMORYSTATUS{
DWORD dwLength;
DWORD dwMemoryLoad;
SIZE_T dwTotalphys;
SIZE_T dwAvailphys;
SIZE_T dwTotalPageFile;
SIZE_T dwAvailPageFile;
SIZE_T dwTotalVirtual;
SIZE_T dwAvailVirtual;
} MEMORYSTATUS,*LPMEMORYSTATUS;
在调用GlobalMemoryStatus之前,必须将dwLength成员初始化为用字节表示的结构的大小,即一个MEMORYSTATUS结构的大小。这个初始化操作使得Microsoft能够将成员添加给将来的Windows版本中的这个结构,而不会破坏现有的应用程序。当调用GlobalMemoryStatus时,它将对该结构的其余成员进行初始化并返回。下面的VMStat示例应用程序将要描述各个成员及其含义。
如果希望应用程序在内存大于4GB的计算机上运行,或者合计交换文件的大小大于4GB,那么可以使用新的GlobalMemoryStatusEx函数。必须给该函数传递新的MEMORYSTATUSEX结构的地址:Typedef struct _MEMORYSTATUSEX{
DWORD dwLength;
DWORD dwMemoryLoad;
DWORDLONG ullTotalphys;
DWORDLONG ullAvailphys;
DWORDLONG ullTotalPageFile;
DWORDLONG ullAvailPageFile;
DWORDLONG ullTotalVirtual;
DWORDLONG ullAvailVirtual;
DWORDLONG ullAvailExtendedVirtual;
} MEMORYSTATUSEX,*LPMEMORYSTATUSEX;

这个结构与原先的MEMORYSTATUS结构基本相同,差别在于新结构的所有成员的大小都是64位宽,因此它的值可以大于4GB。最后一个成员是ullAvailExtendedVirtual,用于指明在调用进程的虚拟地址空间的极大内存(VLM)部分中未保留内存的大小。该VLM部分只适用于某些配置中的某些CPU结构。
书中给出了一个虚拟内存状态示例应用程序(VMStat)
14.3确定地址空间的状态
Windows提供了一个函数,可以用来查询地址空间中内存地址的某些信息(如大小,存储器类型和保护属性等)。实际上本章中带的VMMap示例应用程序就使用这个函数来生成第13章所附的虚拟内存表交换信息。这个函数称为VirtualQuery:
DWORD VirtualQuery(
LPCVOID pvAddress,
PMEMORY_BASIC_INFORMATION pmbi,
DWORD dwLength);
Windows还提供了另一个函数,它使一个进程能够查询另一个进程的内存信息:
DWORD VirtualQueryEX(
HANDLE hProcess,
LPCVOID pvAddress,
PMEMORY_BASIC_INFORMATION pmbi,
DWORD dwLength);
这两个函数基本相同,差别在于使用VirtualQueryEx时,可以传递你想要查询的地址空间信息的进程的句柄。调试程序和其他实用程序使用这个函数最多,几乎所有的应用程序都只需要调用VirtualQuery函数。当调用VirtualQuery(Ex)函数时,pvAddress参数必须包含你想要查询其信息的虚拟内存地址。Pmbi参数是你必须分配的MEMORY_BASIC_INFORMATION结构的地址。最后一个参数是dwLength,用于设定MEMORY_BASIC_INFORMATION结构的大小。
VirtualQuery(Ex)函数返回拷贝到缓存中的字节的数量。
根据在pvAddress参数中传递的地址,VirtualQuery(Ex)函数将关于共享相同状态、保护属性和类型的相邻页面的范围信息填入MEMORY_BASIC_INFORMATION结构中。表14-3描述了该结构的成员。
表14-3MEMORY_BASIC_INFORMATION结构的成员函数
成员名 描述
BaseAddress 与pvAddress参数的值相同,但是四舍五入为页面的边界值
AllocationBase 用于指明包含在pvAddress参数中设定的地址区域的基地址
AllocationProtect 用于指明一个地址空间区域被初次保留时赋予该区域的保护属性
RegionSize 用于指明从基地址开始的所有页面的大小(以字节为计量单位)这些页面与含有用pvSddress参数设定的地址的页面拥有相同的保护属性、状态和类型
State 用于指明所有相邻页面的状态(MEM_FREE、MEM_RESERVE或MEM_COMMIT)。这些页面与含有用pvAddress参数设定的地址的页面拥有相同的保护属性、状态和类型.如果它的状态是空闲,那么AllocationBase、AllocationProtect、Protect和Type等成员均未定义,如果状态是MEM_RESERVE,则Protect成员未定义
Protect 用于指明所有相邻页面的保护属性(PAGE_*)。这些页面与含有用pvAddress 参数设定的地址的页面拥有相同的保属性、状态和类型
Type 用于指明支持所有相邻页面的物理存储器的类型(MEM_IMAGE,MEM_MAPPED或MEM_PRIVATE)。这些相邻页面与含有用pvAddress参数设定的地址的页面拥有相同的保护属性、状态和类型。如果是Windows98,那么这个成员将总是MEM_PRIVATE

第15章在应用程序中使用虚拟内存
Windows提供了3种进行内存管理的方法,它们是:
•虚拟内存,最适合用来管理大型对象或结构数组。
•内存映射文件,最适合用来管理大型数据流(通常来自文件)以及在单个计算机上运行的多个进程之间共享数据。
•内存堆栈,最适合用来管理大量的小对象。
本章将要介绍第一种方法,即虚拟内存。内存映射文件和堆栈分别在第17章和第18章介绍。
用于管理虚拟内存的函数可以用来直接保留一个地址空间区域,将物理存储器(来自页文件)提交给该区域,并且可以设置你自己的保护属性。
15.1在地址空间中保留一个区域
通过调用VirtualAlloc函数,可以在进程的地址空间中保留一个区域:
PVOID VirtualAlloc(
PVOID pvAddress,
SIZE_T dwSize,
DWORD fdwAllocationType,
DWORD fdwProtect);
第一个参数pvAddress包含一个内存地址,用于设定想让系统将地址空间保留在什么地方。在大多数情况下,你为该参数传递MULL。它告诉VirtualAlloc,保存着一个空闲地址区域的记录的系统应该将区域保留在它认为合适的任何地方。系统可以从进程的地址空间的任何位置来保留一个区域,因为不能保证系统可以从地址空间的底部向上或者从上面向底部来分配各个区域。可以使用MEM_TOP_DOWN标志来说明该分配方式。这个标志将在本章的后面加以介绍。对大多数程序员来说,能够选择一个特定的内存地址,并在该地址保留一个区域,这是个非同寻常的想法。当你在过去分配内存时,操作系统只是寻找一个其大小足以满足需要的内存块,并分配该内存块,然后返回它的地址。但是,由于每个进程有它自己的地址空间,因此可以设定一个基本内存地址,在这个地址上让操作系统保留地址空间区域。
例如,你想将一个从50MB开始的区域保留在进程的地址空间中。这时,可以传递52428800(50×1024×1024)作为pvAddress参数。如果该内存地址有一个足够大的空闲区域满足你的要求,那么系统就保留这个区域并返回。如果在特定的地址上不存在空闲区域,或者如果空闲区域不够大,那么系统就不能满足你的要求,VirtualAlloc函数返回NULL。
注意,为pvAddress参数传递的任何地址必须始终位于进程的用户方式分区中,否则对VirtualAlloc函数的调用就会失败,导致它返回NULL。
第13章讲过,地址空间区域总是按照分配粒度的边界来保留的(迄今为止在所有的Windows环境下均是64KB)。因此,如果试图在进程地址空间中保留一个从19668992(300×5536+8192)这个地址开始的区域,系统就会将这个地址圆整为64KB的倍数,然后保留从19660800(300×65536)这个地址开始的区域。
如果VirtualAlloc函数能够满足你的要求,那么它就返回一个值,指明保留区域的基地址。如果传递一个特定的地址作为VirtualAlloc的pvAddress参数,那么该返回值与传递给VirtualAlloc的值相同,并被圆整为(如果需要的话)64KB边界值。
VirtualAlloc函数的第二个参数是dwSize,用于设定想保留的区域的大小(以字节为计量单位)。由于系统保留的区域始终必须是CPU页面大小的倍数,因此,如果试图保留一个跨越62KB的区域,结果就会在使用4KB、8KB或16KB页面的计算机上产生一个跨越64KB的区域。
VirtualAlloc函数的第三个参数是fdwAllocationType,它能够告诉系统你想保留一个区域还是提交物理存储器(这样的区分是必要的,因为VirtualAlloc函数也可以用来提交物理存储器)。
若要保留一个地址空间区域,必须传递MEM_RESERVE标识符作为FdwAllocationType参数的值。
如果保留的区域预计在很长时间内不会被释放,那么可以在尽可能高的内存地址上保留该区域。这样,该区域就不会从进程地址空间的中间位置上进行保留。因为在这个位置上它可能导致区域分成碎片。如果想让系统在最高内存地址上保留一个区域,必须为pvAddress参数和fdwAllocationType参数传递NULL,还必须逐位使用OR将MEM_TOP_DOWN标志和MEM_RESERVE标志连接起来。
注意在Windows98下,MEM_TOP_DOWN标志将被忽略。
最后一个参数是fdwProtect,用于指明应该赋予该地址空间区域的保护属性。与该区域相关联的保护属性对映射到该区域的已提交内存没有影响。无论赋予区域的保护属性是什么,如果没有提交任何物理存储器,那么访问该范围中的内存地址的任何企图都将导致该线程引发一个访问违规。
当保留一个区域时,应该为该区域赋予一个已提交内存最常用的保护属性。例如,如果打算提交的物理存储器的保护属性是PAGE_READWRITE(这是最常用的保护属性),那么应该用PAGE_READWRITE保护属性来保留该区域。当区域的保护属性与已提交内存的保护属性相匹配时,系统保存的内部记录的运行效率最高。
可以使用下列保护属性中的任何一个:PAGE_NOACCESS、PAGE_READWRITE、PAGE_READONLY、PAGE_EXECUTE、PAGE_EXECUTE_READ或PAGE_EXECUTE_READWRITE。但是,既不能设定PAGE_WRITECOPY属性,也不能设定PAGE_EXECUTE_WRITECOPY属性。如果设定了这些属性,VirtualAlloc函数将不保留该区域,并且返回NULL。
另外,当保留地址空间区域时,不能使用保护属性标志PAGE_GUARD,PAGE_NOCACHE或PAGE_WRITECOMBINE,这些标志只能用于已提交的内存。
注意Windows98只支持PAGE_NOACCESS、PAGE_READONLY和PAGE_READWRITE保护属性。如果试图保留使用PAGE_EXECUTE或PAGE_EXECUTE_READ两个保护属性的区域,将会产生一个带有PAGE_READONLY保护属性的区域。同样,如果保留一个使用PAGE_EXECUTE_READWRITE保护属性的区域,就会产生一个带有PAGE_READWRITE保护属性的区域。
15.2在保留区域中的提交存储器
当保留一个区域后,必须将物理存储器提交给该区域,然后才能访问该区域中包含的内存地址。系统从它的页文件中将已提交的物理存储器分配给一个区域。物理存储器总是按页面边界和页面大小的块来提交的。
若要提交物理存储器,必须再次调用VirtualAlloc函数。不过这次为fdwAllocationType参数传递的是MEM_COMMIT标志,而不是MEM_RESERVE标志。传递的页面保护属性通常与调用VirtualAlloc来保留区域时使用的保护属性相同(大多数情况下是PAGE_READWRITE),不过也可以设定一个不同的保护属性。
在已保留的区域中,你必须告诉VirtualAlloc函数,你想将物理存储器提交到何处,以及要提交多少物理存储器。为了做到这一点,可以在pvAddress参数中设定你需要的内存地址,并在dwSize参数中设定物理存储器的数量(以字节为计量单位)。注意,不必立即将物理存储器提交给整个区域。
下面让我们来看一个如何提交物理存储器。比如说,你的应用程序是在x86CPU上运行的,该应用程序保留了一个从地址5242880开始的512KB的区域。你想让应用程序将物理存储器提交给已保留区域的6KB部分,从2KB的地方开始,直到已保留区域的地址空间。为此,可以调用带有MEM_COMMIT标志的VirtualAlloc函数,如下所示:
VirtualAlloc((PVOID)(5242880+(2*1024)),6*1024,
MEM_COMMIT,PAGE_READWRITE);
在这个例子中,系统必须提交8KB的物理存储器,地址范围从5242880到5251071(5242880+8KB-1字节)。这两个提交的页面都拥有PAGE_READWRITE保护属性。保护属性只以整个页面为单位来赋予。同一个内存页面的不同部分不能使用不同的保护属性。然而,区域中的一个页面可以使用一种保护属性(比如PAGE_READWRITE),而同一个区域中的另一个页面可以使用不同的保护属性(比如PAGE_READONLY)。
15.3同时进行区域的保留和内存的提交
有时你可能想要在保留区域的同时,将物理存储器提交给它。只需要一次调用VirtualAlloc
函数就能进行这样的操作,如下所示:
PVOID pvMem=VirtualAlloc(NULL,99*1024,
MEM_RESERVE | MEM_COMMIT,PAGE_READWRITE);
这个函数调用请求保留一个99KB的区域,并且将99KB的物理存储器提交给它。当系统处理这个函数调用时,它首先要搜索你的进程的地址空间,找出未保留的地址空间中一个地址连续的区域,它必须足够大,能够存放100KB(在4KB页面的计算机上)或104KB(在8KB页面的计算机上)。
最后需要说明的是,VirtualAlloc将返回保留区域和提交区域的虚拟地址,然后该虚拟地址被保存在pvMem变量中。如果系统无法找到足够大的地址空间,或者不能提交该物理存储器,VirtualAlloc将返回NULL。
当用这种方式来保留一个区域和提交物理存储器时,将特定的地址作为pvAddress参数传递给VirtualAlloc当然是可能的。否则就必须用OR将MEM_TOP_DOWN标志与fdwAllocationType参数连接起来,并为pvAddress参数传递NULL,让系统在进程的地址空间的顶部选定一个适当的区域。
15.4何时提交物理存储器
假设想实现一个电子表格应用程序,这个电子表格为200行x256列。对于每一个单元格,都需要一个CELLDATA结构来描述单元格的内容。若要处理这种二维单元格矩阵,最容易的方法是在应用程序中声明下面的变量:CELLDATA CellData[200][256];
如果CELLDATA结构的大小是128字节,那么这个二维矩阵将需要6553600(200x256x128)个字节的物理存储器。对于电子表格来说,如果直接用页文件来分配物理存储器,那么这是个不小的数目了,尤其是考虑到大多数用户只是将信息放入少数的单元格中,而大部分单元格却空闲不用,因此显得有些浪费。内存的利用率非常低。
传统上,电子表格一直是用其他数据结构技术来实现的,比如链接表等。使用链接表,只需要为电子表格中实际包含数据的单元格创建CELLDATA结构。由于电子表格中的大多数单元格都是不用的,因此这种方法可以节省大量的内存。但是这种方法使得你很难获得单元格的内容。如果想知道第5行第10列的单元格的内容,必须遍历链接表,才能找到需要的单元格,因此使用链接表方法比明确声明的矩阵方法速度要慢。
虚拟内存为我们提供了一种兼顾预先声明二维矩阵和实现链接表的两全其美的方法。运用虚拟内存,既可以使用已声明的矩阵技术进行快速而方便的访问,又可以利用链接表技术大大节省内存的使用量。
如果想利用虚拟内存技术的优点,你的程序必须按照下列步骤来编写:
1)保留一个足够大的地址空间区域,用来存放CELLDATA结构的整个数组。保留一个根本不使用任何物理存储器的区域。
2)当用户将数据输入一个单元格时,找出CELLDATA结构应该进入的保留区域中的内存地址。当然,这时尚未有任何物理存储器被映射到该地址,因此,访问该地址的内存的任何企图都会引发访问违规。
3)就CELLDATA结构来说,只将足够的物理存储器提交给第二步中找到的内存地址(你可以告诉系统将物理存储器提交给保留区域的特定部分,这个区域既可以包含映射到物理存储器的各个部分,也可以包含没有映射到物理存储器的各个部分)。
4)设置新的CELLDATA结构的成员。
现在物理存储器已经映射到相应的位置,你的程序能够访问内存,而不会引发访问违规。这个虚拟内存技术非常出色,因为只有在用户将数据输入电子表格的单元格时,才会提交物理存储器。由于电子表格中的大多数单元格是空的,因此大部分保留区域没有提交给它的物理存储器。
虚拟内存技术存在的一个问题是,必须确定物理存储器在何时提交。如果用户将数据输入一个单元格,然后只是编辑或修改该数据,那么就没有必要提交物理存储器,因为该单元格的CELLDATA结构的内存在数据初次输入时就已经提交了。
另外,系统总是按页面的分配粒度来提交物理存储器的。因此,当试图为单个CELLDATA结构提交物理存储器时(像上面的第二步那样),系统实际上提交的是内存的一个完整的页面。这并不像它听起来那样十分浪费:为单个CELLDATA结构提交物理存储器的结果是,也要为附近的其他CELLDATA结构提交内存。如果这时用户将数据输入邻近的单元格(这是经常出现的情况),就不需要提交更多的物理存储器。
有4种方法可以用来确定是否要将物理存储器提交给区域的一个部分:
•始终设法进行物理存储器的提交。每次调用VirtualAlloc函数的时候,不要查看物理存储器是否已经映射到地址空间区域的一个部分,而是让你的程序设法进行内存的提交。系统首先查看内存是否已经被提交,如果已经提交,那么就不要提交更多的物理存储器。
这种方法最容易操作,但是它的缺点是每次改变CELLDATA结构时要多进行一次函数的调用,这会使程序运行得比较慢。
•(使用VirtualQuery函数)确定物理存储器是否已经提交给包含CELLDATA结构的地址空间。如果已经提交了,那么就不要进行任何别的操作。如果尚未提交,则可以调用
VirtualAlloc函数以便提交内存。这种方法实际上比第一种方法差,它既会增加代码的长度,又会降低程序运行的速度(因为增加了对VirtualAlloc函数的调用)。
•保留一个关于哪些页面已经提交和哪些页面尚未提交的记录。这样做可以使你的应用程
序运行得更快,因为不必调用VirtualAlloc函数,你的代码能够比系统更快地确定内存是否已经被提交。它的缺点是,必须不断跟踪页面提交的信息,这可能非常简单,也可能非常困难,要根据你的情况而定。
•使用结构化异常处理(SEH)方法,这是最好的方法。SEH是一个操作系统特性,它使系统能够在发生某种情况时将此情况通知你的应用程序。实际上可以创建一个带有异常处理程序的应用程序,然后,每当试图访问未提交的内存时,系统就将这个问题通知应用程序。然后你的应用程序便进行内存的提交,并告诉系统重新运行导致异常条件的指令。这时对内存的访问就能成功地进行了,程序将继续运行,仿佛从未发生过问题一样。
这种方法是优点最多的方法,因为需要做的工作最少(也就是说要你编写的代码比较少),同时,你的程序可以全速运行。关于SEH的全面介绍,请参见第23、24和25章。第25章中的电子表格示例应用程序说明了如何按照上面介绍的方法来使用虚拟内存。
15.5回收虚拟内存和释放地址空间区域
若要回收映射到一个区域的物理存储器,或者释放这个地址空间区域,可调用VirtualFree
函数:BOOL VirtualFree(
LPVOID pvAddress,
SIZE_T dwSize,
DWORD fdwFreeType);
首先让我们观察一下调用VirtualFree函数来释放一个已保留区域的简单例子。当你的进程不再访问区域中的物理存储器时,就可以释放整个保留的区域和所有提交给该区域的物理存储器,方法是一次调用VirtualFree函数。
就这个函数的调用来说,pvAddress参数必须是该区域的基地址。此地址与该区域被保留时VirtualAlloc函数返回的地址相同。系统知道在特定内存地址上的该区域的大小,因此可以为dwSize参数传递0。实际上,必须为dwSize参数传递0,否则对VirtualFree的调用就会失败。对于第三个参数fdwFreeType,必须传递MEM_RELEASE,以告诉系统将所有映射的物理存储器提交给该区域并释放该区域。当释放一个区域时,必须释放该区域保留的所有地址空间。例如不能保留一个128KB的区域,然后决定只释放它的64KB。必须释放所有的128KB。
当想要从一个区域回收某些物理存储器,但是却不释放该区域时,也可以调用VirtualFree函数,若要回收某些物理存储器,必须在VirtualFree函数的pvAddress参数中传递用于标识要回收的第一个页面的内存地址,还必须在dwSize参数中设定要释放的字节数,并在fdwFreeType参数中传递MEM_DECOMMIT标志。
与提交物理存储器的情况一样,回收时也必须按照页面的分配粒度来进行。这就是说,设定页面中间的一个内存地址就可以回收整个页面。当然,如果pvAddress+dwSize的值位于一个页面的中间,那么包含该地址的整个页面将被回收。因此位于pvAddress至pvAddress+dwSize范围内的所有页面均被回收。
如果dwSize是0,pvSddress是已分配区域的基地址,那么VirtualFree将回收全部范围内的已分配页面。当物理存储器的页面已经回收之后,已释放的物理存储器就可以供系统中的所有其他进程使用,如果试图访问未回收的内存,将会造成访问违规。
15.5.1何时回收物理存储器
在实践中,知道何时回收内存是非常困难的。让我们再以电子表格为例。如果你的应用程序是在x86计算机上运行,每个内存页面是4KB,它可以存放32个(4096/128)CELLDATA结构。如果用户删除了单元格CellData[0][1]的内容,那么只要单元格CellData[0][0]至CellData[0][31]也不被使用,就可以回收它的内存页面。那么怎么能够知道这个情况呢?可以用下面3种方法来解决这个问题。
•毫无疑问,最容易的方法是设计一个CELLDATA结构,它的大小只有一个页面。这时,由于始终都是每个页面使用一个结构,因此当不再需要该结构中的数据时,就可以回收该页面的物理存储器。即使你的数据结构是x86CPU上的8KB或12KB页面的倍数(通常这是非常大的数据结构),回收内存仍然是非常容易的。当然,如果要使用这种方法,必须定义你的数据结构,使之符合你针对的CPU的页面大小而不是我们通常编写程序所用的结构。
•更为实用的方法是保留一个正在使用的结构的记录。为了节省内存,可以使用一个位图。这样,如果有一个100个结构的数组,你也可以维护一个100位的数组。开始时,所有的位均设置为0,表示这些结构都没有使用。当使用这些结构时,可以将对应的位设置为1。然后,每当不需要某个结构,并将它的位重新改为0时,你可以检查属于同一个内存页面的相邻结构的位。如果没有相邻的结构正在使用,就可以回收该页面。
•最后一个方法是实现一个无用单元收集函数。这个方案依赖于这样一种情况,即当物理存储器初次提交时,系统将一个页面中的所有字节设置为0。若要使用该方案,首先必须在你的结构中设置一个BOOL(也许称为fInUse)。然后,每次你将一个结构放入已提交的内存中,必须确保该fInUse被置于TRUE。
当你的应用程序运行时,必须定期调用无用单元收集函数。该函数应该遍历所有潜在的数据结构。对于每个数据结构,该函数首先要确定是否已经为该结构提交内存。如果已经提交,该函数将检查fInUse成员,以确定它是否是0。如果该值是0,则表示该结构没有被使用。如果该值是TRUE,则表示该结构正在使用。当无用单元函数检查了属于既定页面的所有结构后,
如果所有结构都没有被使用,它将调用VirtualFree函数,回收该内存。
当一个结构不再被视为“在用”(InUse)后,就可以立即调用无用单元收集函数,不过这项操作需要的时间比你想像的要长,因为该函数要循环通过所有可能的结构。实现该函数的一个出色方法是让它作为低优先级线程的一部分来运行。这样,就不必占用执行主应用程序的线程的时间。每当主应用程序运行空闲时,或者主应用程序的线程执行文件的I/O操作时,系统就可以给无用单元收集函数安排运行时间。
在上面列出的所有方法中,前面的两种方法是我个人喜欢使用的方法。不过,如果你的结构比较小(小于一个页面),那么建议你使用最后一种方法。
15.6改变保护属性
虽然实践中很少这样做,但是可以改变已经提交的物理存储器的一个或多个页面的保护属性。例如,你编写了一个用于管理链接表的代码,将它的节点存放在一个保留区域中。可以设计一些函数,以便处理该链接表,这样,它们就可以在每个函数开始运行时将已提交内存的保护属性改为PAGE_READWRITE,然后在每个函数终止运行时将保护属性重新改为PAGE_NOACCESS。
通过这样的设置,就能够使链接表数据不受隐藏在程序中的其他错误的影响。如果进程中的任何其他代码存在一个迷失指针,试图访问你的链接表数据,那么就会引发访问违规。当试图寻找应用程序中难以发现的错误时,利用保护属性是极其有用的。
若要改变内存页面的保护属性,可以调用VirtualProtect函数:
BOOL VirtualProtect(
PVOID pvAddress,
SIZE_T dwSize,
DWORD flNewProtect,
PDWORD pfloldProtect);
这里的pvAddress参数指向内存的基地址(它必须位于进程的用户方式分区中),dwSize参数用于指明你想要改变保护属性的字节数,而flNewProtect参数则代表PAGE_*保护属性标志中的任何一个标志,但PAGE_WRITECOPY和PAGE_EXECUTE_WRITECOPY这两个标志除外。
最后一个参数pflOldProtect是DWORD的地址,VirtualProtect将用原先与pvAddress位置上的字节相关的保护属性填入该DWORD。尽管许多应用程序并不需要该信息,但是必须为该参数传递一个有效地址,否则该函数的运行将会失败。
当然,保护属性是与内存的整个页面相关联的,而不是赋予内存的各个字节的。Windows98Windows98只支持PAGE_READONLY和PAGE_READWRITE两个保护属性。如果试图将页面的保护属性改为PAGE_EXECUTE或PAGE_EXECUTE_READ,该页面可得到PAGE_READONLY保护属性。同样,如果试图将页面的保护属性改为PAGE_EXECUTE_READWRITE,那么该页面将得到PAGE_READWRITE保护属性。
VirtualProtect函数不能用于改变跨越不同保留区域的页面的保护属性。如果拥有相邻的保留区域并想改变这些区域中的一些页面的保护属性,那么必须多次调用VirtualProtect函数。
15.7清除物理存储器的内容
Windows98Windows98不支持物理存储器内容的清除。
当你修改物理存储器的各个页面的内容时,系统将尽量设法将修改的内容保存在RAM中。但是,当应用程序运行时,从.exe文件、DLL文件和/或页文件加载页面就可能需要占用系统的RAM。由于系统要查找RAM的页面,以满足当前加载页面的需求,因此系统必须将RAM的已修改页面转到系统的页文件中。
Windows2000提供了一个特性,使得应用程序能够提高它的性能,这个特性就是对物理存储器内容进行清除。清除存储器意味着你告诉系统,内存的一个或多个页面上的数据并没有被修改。如果系统正在搜索RAM的一个页面,并且选择一个已修改的页面,系统必须将RAM的这个页面写入页文件。这个操作的速度很慢,而且会影响系统的运行性能。对于大多数应用程序来说,可以让系统将你修改了的页面保留在系统的页文件中。
然而,有些应用程序使用内存的时间很短,然后就不再要求保留该内存的内容。为了提高性能,应用程序可以告诉系统不要将内存的某些页面保存在系统的页文件中。这是应用程序告诉系统数据页面尚未修改的一个基本方法。因此,如果系统选择将RAM的页面用于别的目的,那么该页面的内容就不必保存在页文件中,从而可以提高应用程序的运行性能。若要清除内存的内容,应用程序可以调用VirtualAlloc函数,在第三个参数中传递MEM_RESET标志。
如果在调用VirtualAlloc函数时引用的页面位于页文件中,系统将删除这些页面。下次应用程序访问内存时,便使用最初被初始化为0的RAM页面。如果清除了当前RAM中的页面内容,那么它们将被标上未修改的标记,这样它们将永远不会被写入页文件。注意,虽然RAM页面的内容没有被置0,但是不应该继续从内存的该页面读取数据。如果系统不需要RAM的这个页面,它将包含其原始内容。但是如果系统需要RAM的这个页面,系统就可以提取该页面。然后当你试图访问该页面的内容时,系统将给你一个已经删除内容的新页面。由于你无法控制这个行为特性,因此,当清除页面的内容后,你必须假定该页面的内容是无用信息。
当清除内存的内容时,有两件事情必须记住。首先,当调用VirtualAlloc函数时,基地址通常圆整为一个页面边界的值,而字节数则圆整为一个页面的整数。当清除页面的内容时,用这种办法圆整基地址和字节数是非常危险的,因此,当传递MEM_RESET标志时,VirtualAlloc将按反方向对这些值进行圆整。
要记住的第二件事情是,MEM_RESET标志始终必须自己单独使用,不能用OR将它与任何其他标志连接起来使用。将MEM_RESET标志与任何其他标志连接起来确实没有任何意义。
最后请注意,带有MEM_RESET标志的VirtualAlloc函数要求传递一个有效的页面保护属性,即使该函数不使用这个值,也必须传递该值。
15.8地址窗口扩展—适用于Windows2000
随着时间的推移,应用程序需要的内存越来越多。对于服务器应用程序来说,情况更是如此。由于越来越多的客户机对服务器提出访问请求,服务器的运行性能就会降低。为了提高运
行性能,服务器应用程序必须在RAM中保存更多的数据,并且缩小磁盘的页面。其他类别的应用程序,比如数据库、工程设计和科学应用程序,也需要具备处理大块内存的能力。对于所有这些应用程序来说,32位地址空间是不够使用的。
为了满足这些应用程序的需要,Windows2000提供了一个新特性。称为地址窗口扩展(AWE)。Microsoft创建AWE是出于下面两个目的:
•允许应用程序对从来不在操作系统与磁盘之间交换的RAM进行分配。
•允许应用程序访问的RAM大于进程的地址空间。
AWE基本上为应用程序提供了一种分配一个或多个RAM块的手段。当分配RAM块时,在进程的地址空间中是看不见这些RAM块的。后来,应用程序(使用VirtualAlloc函数)保留一个地址空间区域,这个区域就成为地址窗口。这时应用程序调用一个函数,每次将一个RAM块赋予该地址窗口。将一个RAM块赋予地址窗口的速度是非常快的(通常只需要几个毫秒)。显然,通过单个地址窗口,每次只能访问一个RAM块。这使得你的代码很难实现,因为,当需要时,必须在你的代码中显式调用函数,才能将不同的RAM块赋予地址窗口。
对VirtualAlloc函数的调用保留了一个1MB的地址窗口。通常该地址窗口要大得多。你必须选定一个适合于应用程序需要的RAM块大小的窗口大小。当然,你创建的最大窗口取决于你的地址空间中可用的最大相邻空闲地址块。MEM_RESERVE标志用于指明我正在保留一个地址区域。MEM_PHYSICAL标志用于指明这个区域最终将受RAM物理存储器的支持。AWE的局限性是,映射到地址窗口的所有内存必须是可读的和可写入的,因此PAGE_READWRITE是可以传递VirtualAlloc函数的唯一有效的保护属性。此外,不能使用VirtualProtect函数来修改这个保护属性。
RAM物理存储器的分配是非常简单的,只需要调用AllocateUserPhysicalPages:
BOOL AllocateUserPhysicalPages(
HANDLE hprocess,
PULONG_PTR pulRamPages,
PULONG_PTR aRAMPages);
该函数负责分配pulRAMPages参数指明的值设定的RAM页面的数量,并且将这些页面赋予hProcess参数标识的进程。
每个RAM页面由操作系统赋予一个页框号。当系统选择供分配用的RAM页面时,它就将每个RAM页面的页框号填入aRAMPages参数指向的数组。页框号本身对应用程序没有任何用处,不应该查看该数组的内容,并且肯定不应该修改该数组中的任何一个值。注意,你不知道哪些RAM页面已经被分配给该内存块,也不应该去关注这个情况。当地址窗口显示RAM块中的页面时,它们显示为一个相邻的内存块。这使得RAM非常便于使用,并且使你可以不必了解系统内部的运行情况。
该函数返回时,pulRAMPages参数中的值用于指明该函数成功地分配的页面数量。这个数量通常与传递给函数的值是相同的,但是它也可能是个较小的值。
只有拥有页面的进程才能使用已经分配的RAM页面,AWE不允许RAM页面被映射到另一个进程的地址空间。因此不能在进程之间共享RAM块。
注意当然,物理RAM是一种非常宝贵的资源,并且应用程序只能分配尚未指定用途的RAM。应该非常节省地使用AWE,否则你的进程和其他进程将会过分地在内存与磁盘之间进行页的交换,从而严重影响系统的运行性能。此外,如果可用RAM的数量比较少,也会对系统创建新进程、线程和其他资源的能力产生不利的影响。应用程序可以使用GlobalMemoryStatusEx函数来监控物理存储器的使用情况。
为了保护RAM的分配,AllocateUserPhysicalPages函数要求调用者拥有LockPagesinMemory(锁定内存中的页面)的用户权限,并且已经激活该权限,否则该函数的运行将会失败。按照默认设置,该权限不被赋予任何用户或用户组。该权限被赋予LocalSystem(本地系统)帐户,它通常用于服务程序。如果想要运行一个调用AllocateUserPhysicalPages函数的交互式应用程序,那么管理员必须在你登录和运行应用程序之前为你赋予该权限。
Windows2000在Windows2000中,可以执行下列步骤,打开LockPagesinMemory用户权限:
1)单击Start按钮,选定Run菜单项,打开ComputerManagementMMC控制台。在
Run框中,键入“Compmgmt.msc/a”,再单击OK按钮。
2)如果在左边的窗格中没有显示LocalComputerPolicy(本地计算机政策)项,那么在控制台菜单中选定Add/RemoveSnap-ins(添加/删除咬接项(snap-in))。在Standalone选项卡上,从Snap-insAddedTo(咬接项添加到)组合框中选定ComputerManagement(local)。现在单击Add按钮,显示AddStandaloneSnap-in(添加独立咬接项)对话框。从AvailableStandaloneSnap-ins(可用独立咬接项)中选定GroupPolicy(组政策)。并单击Add按钮。在SelectGroupPolicyObject(选定组政策对象)对话框中,保留默认值,并单击Finish按钮。单击AddStandaloneSnap-in对话框上的Close按钮,再单击Add/RemoveSnap-in对话框上的OK按钮。这时,在ComputerManagement控制台的左窗格中就可以看到LocalComputerPolicy项。
3)在控制台的左窗格中,双击下列项目,将它们展开:LocalComputerPolicy(本地计算机政策)、ComputerConfiguration(计算机配置)、WindowsSettings(窗口设置)、SecuritySettings(安全性设置)和LocalPolicy(本地政策)。然后选定UserRightsAssignment(用户权限赋值)项。
4)在右窗格中,选定LockPagesinMemory属性。
5)从Action(操作)菜单中选定Security,显示LockPagesinMemory对话框,单击Add按钮。使用SelectUsersorGroup对话框,添加你想为其赋予LockPagesinMemory用户权限的用户和/或用户组。单击OK按钮,退出每个对话框。当用户登录时,他将被赋予相应的用户权限。如果你只是将LockPagesinMemory权限赋予你自己,那么必须在该权限生效前退出并重新登录。
现在我们已经创建了地址窗口并且分配了一个RAM块,可以通过调用MapUserPhysicalPages函数将该RAM块赋予该地址窗口:
BOOL MapUserPhysicalPages(
PVOID pvAddressWindow,
PULONG_PTR pulRamPages,
PULONG_PTR aRAMPages);
第一个参数pvAddressWindow用于指明地址窗口的虚拟地址,第二和第三个参数ulRAMPages和aRAMPages用于指明该地址窗口中可以看到多少个RAM页面以及哪些页面可以看到。如果窗口小于你试图映射的页面数量,那么函数运行就会失败。Microsoft设置这个函数的主要目的是使它运行起来非常快。一般来说,MapUserPhysicalPages函数能够在几个微秒内映射该RAM块。
注意也可以调用MapUserPhysicalPages函数来取消对当前RAM块的分配,方法是为aRAMPages参数传递NULL。
一旦RAM块被分配给地址窗口,只需要引用相对于地址窗口的基地址(在我的示例代码
中是pvWindow)的虚拟地址,就可以很容易地访问该RAM内存。
当不再需要RAM块时,应该调用FreeUserPhysicalPages函数将它释放:
BOOL FreeUserPhysicalPages(
HANDLE hprocess,
PULONG_PTR pulRamPages,
PULONG_PTR aRAMPages);
第一个参数hProcess用于指明哪个进程拥有你试图释放的RAM页面。第二和第三个参数用于指明你要释放多少个页面和这些页面的页框号。如果该RAM块目前已经被映射到该地址窗口,那么它将被取消映射并被释放。
最后,为了彻底清除页面,我仅仅调用了VirtualFree函数,传递窗口的虚拟基地址,为区域大小传递0,再传递MEM_RELEASE,将地址窗口释放掉。
我的简单的示例代码创建了单个地址窗口和单个RAM块。这使得我的应用程序能够访问没有与磁盘进行数据交换的RAM。但是,应用程序也能够创建若干个地址窗口,并且可以分配若干个RAM块。虽然这些RAM块可以分配给任何一个地址窗口,但是系统不允许单个RAM块同时出现在两个地址窗口中。
64位Windows2000全面支持AWE。对使用AWE的32位应用程序进行移植是非常容易和简单的。不过对于64位应用程序来说,AWE的用处比较小,因为进程的地址空间太大了。但是AWE仍然是有用的,因为它使得应用程序能够分配不与磁盘进行数据交换的物理RAM。

第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 异常处理表
除了编译器和链接程序创建的标准节外,也可以在使用下面的命令进行编译时创建自己的节:
#pragmadata_seg(“sectionname”)
需要记住的是,编译器只将已经初始化的变量放入新节中。
Microsoft的VisualC++编译器提供了一个Allocate说明符,使你可以将未经初始化的数据放入你希望的任何节中。
之所以将变量放入它们自己的节中,最常见的原因也许是要在.exe或DLL文件的多个映像之间共享这些变量。按照默认设置,.exe或DLL文件的每个映像都有它自己的一组变量。然而,可以将你想在该模块的所有映像之间共享的任何变量组合到它自己的节中去。当给变量分组时,系统并不为.exe或DLL文件的每个映像创建新实例。
仅仅告诉编译器将某些变量放入它们自己的节中,是不足以实现对这些变量的共享的。还必须告诉链接程序,某个节中的变量是需要加以共享的。若要进行这项操作,可以使用链接程序的命令行上的/SECTION开关:
/SECTION:name,attributes
在冒号的后面,放入你想要改变其属性的节的名字。
在逗号的后面,我们设定了需要的属性。用R代表READ,W代表WEITE,E代表EXECUTE,S代表SHARED。
也可以使用下面的句法将链接程序开关嵌入你的源代码中:
#pragmacomment(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章中介绍电子表格的内容上来,比如说,你想要与另一个进程共享整个电子表格。如果我们使用内存映射文件,那么必须为整个电子表格提交物理存储器:
CELLDATAcelldata[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函数获取你的进程默认堆栈的句柄:HANDLEGetProcessHeap();
18.2为什么要创建辅助堆栈
除了进程的默认堆栈外,可以在进程的地址空间中创建一些辅助堆栈。由于下列原因,你可能想要在自己的应用程序中创建一些辅助堆栈:
•保护组件。
•更加有效地进行内存管理。
•进行本地访问。
•减少线程同步的开销。
•迅速释放。
18.3如何创建辅助堆栈
你可以在进程中创建辅助堆栈,方法是让线程调用HeapCreate函数:
HANDLEHeapCreate(
DWORDfdwOptions,
SIZE_TdwInitialSize,
SIZE_TdwMaximumSize);
第一个参数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函数:
PVOIDHeapAlloc(
HANDLEhHeap,
DWORDfdwFlags,
SIZE_TdwBytes);
第一个参数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函数:
PVOIDHeapReAlloc(
HANDLEhHeap,
DWORDfdwFlags,
PVOIDpvMem,
SIZE_TdwBytes);
与其他情况一样,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_THeapSize(
HANDLEhHeap,
DWORDfdwFlags,
LPCVOIDpvMem);
参数hHeap用于标识堆栈,参数pvMem用于指明内存块的地址。参数fdwFlags既可以是0,也可以是HEAP_NO_SERIALIZE。
18.3.4释放内存块
当不再需要内存块时,可以调用HeapFree函数将它释放:
BOOLHeapFree(
HANDLEhHeap,
DWORDfdwFlags,
LPCVOIDpvMem);
HeapFree函数用于释放内存块,如果它运行成功,便返回TRUE。参数fdwFlags既可以是0,也可以是HEAP_NO_SERIALIZE。调用这个函数可使堆栈管理器收回某些物理存储器,但是这没有保证。
18.3.5撤消堆栈
如果应用程序不再需要它创建的堆栈,可以通过调用HeapDestroy函数将它撤消:
BOOLHeapDestroy(HANDLEhHeap);
调用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函数来获取现有堆栈的句柄:
DWORDGetProcessHeaps(
DWORDdwNumHeaps,
PHANDLEpHeaps);
HeapValidate函数用于验证堆栈的完整性:
BOOLHeapValidate(
HANDLEhHeaps,
DWORDfdwFlags,
LPCVOIDpvMem);
调用该函数时,通常要传递一个堆栈句柄,一个值为0的标志(唯一的另一个合法标志是HEAP_NO_SERIALIZE),并且为pvMem传递NULL。然后,该函数将遍历堆栈中的内存块以确保所有内存块都完好无损。为了使该函数运行得更快,可以为参数pvMem传递一个特定的内存块的地址。这样做可使该函数只检查单个内存块的有效性。
若要合并地址中的空闲内存块并收回不包含已经分配的地址内存块的存储器页面,可以调用下面的函数:
UINTHeapCompact(
HANDLEhHeaps,
DWORDfdwFlags);
通常情况下,可以为参数fdwFlags传递0,但是也可以传递HEAP_NO_SERIALIZE。
下面两个函数HeapLock和HeapUnlock是结合在一起使用的:
BOOLHeapLock(HANDLEhHeap);
BOOLHeapUnlock(HANDLEhHeap);
这些函数是用于线程同步的。当调用HeapLock函数时,调用线程将成为特定堆栈的所有者。如果其他任何线程调用堆栈函数(设定相同的堆栈句柄),系统将暂停调用线程的运行,并且在堆栈被HeapUnlock函数解锁之前不允许它醒来。
HeapAlloc、HeapSize和HeapFree等函数在内部调用HeapLock和HeapUnlock函数来确保对堆栈的访问能够顺序进行。自己调用HeapLock或HeapUnlock这种情况是不常见的。
最后一个堆栈函数是HeapWalk:
BOOLHeapWalk(
HANDLEhHeap,
PPROCESS_HEAP_ENTRYpHeapEntry);
该函数只用于调试目的。它使你能够遍历堆栈的内容。可以多次调用该函数。
在循环调用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映射到进程的地址空间,方法是调用下面两个函数中的一个:
HINSTANCELoadLibrary(PCTSTRpszDllPathName);
HINSTANCELoadLibraryEx(
PCTSTRpszDllPathName,
HANDLEhFile,
DWORDdwFlags);
这两个函数均用于找出用户系统上的文件映像(使用上一章中介绍的搜索算法),并设法将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,方法是调用下面的函数:BOOLFreeLibrary(HINSTANCEhinstDll);
必须传递HINSTANCE值,以便标识要卸载的DLL。该值是较早的时候调用LoadLibrary(Ex)而返回的值。
也可以通过调用下面的函数从进程的地址空间中卸载DLL:
VOIDFreeLibraryAndExitThread(
HINSTANCEhinstDll,
DWORDdwExitCode);
该函数是在Kernel32.dll中实现的,如下所示:
VOIDFreeLibraryAndExitThread(HINSTANCEhinstDll,DWORDdwExitCode){
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模块被显式加载,线程就必须获取它要引用的符号的地址,方法是调用下面的函数:
FARPROCGetProcAddress(
HINSTANCEhinstDll,
PCSTRpszSymbolName);
参数hinstDll是调用LoadLibrary(Ex)或GetModuleHandle函数而返回的,它用于设定包含符号的DLL的句柄。参数pszSymbolName可以采用两种形式。第一种形式是以0结尾的字符串的地址,它包含了你想要其地址的符号的名字:
FARPROCpfn=GetProcAddress(hinstDll,“SomeFuncInDll”);

注意,参数pszSymbolName的原型是PCSTR,而不是PCTSTR。这意味着GetProcAddress函数只接受ANSI字符串,决不能将Unicode字符串传递给该函数,因为编译器/链接程序总是将符号名作为ANSI字符串存储在DLL的输出节中。
参数pszSymbolName的第二种形式用于指明你想要其地址的符号的序号:
FARPROCpfn=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指令:
#pragmacomment(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\SessionManager\KnowDlls
当LoadLibrary或LoadLibraryEx被调用时,这些函数首先查看是否传递了包含.dll扩展名的DLL名字。如果没有传递,那么它们将使用通常的搜索规则来搜索DLL。
如果确实设定了.dll扩展名,那么这些函数将删除扩展名,然后搜索注册表关键字KnownDLL,以便确定它是否包含匹配的值名字。如果没有找到匹配的名字,便使用通常的搜索规则。但是,如果找到了匹配的值名字,系统将查找相关的值数据,并设法使用值数据来加载DLL。系统也开始在注册表中的DllDirectory值数据指明的目录中搜索DLL。按照默认设置,
Windows2000上的DllDirectory值的数据是%SystemRoot%\System32。
20.6DLL转移
Windows98Windows98不支持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的库可以在运行期进行加载,并且可以在运行期释放,根本不会产生任何问题。

第22章插入DLL和挂接API

在MicrosoftWindows中,每个进程都有它自己的私有地址空间。当使用指针来引用内存时,指针的值将引用你自己进程的地址空间中的一个内存地址。你的进程不能创建一个其引用属于另一个进程的内存指针。因此,如果你的进程存在一个错误,改写了一个随机地址上的内存,那么这个错误不会影响另一个进程使用的内存。
在Windows98下运行的各个进程共享2GB的地址空间,该地址空间从0x80000000至0xFFFFFFFF。只有内存映像文件和系统组件才能映射到这个区域。
独立的地址空间对于编程人员和用户来说都是非常有利的。然而有些情况下,必须打破进程的界限,访问另一个进程的地址空间,这些情况包括:
•当你想要为另一个进程创建的窗口建立子类时。
•当你需要调试帮助时(例如,当你需要确定另一个进程正在使用哪个DLL时)。
•当你想要挂接其他进程时。
本章将介绍若干种方法,可以用来将DLL插入到另一个进程的地址空间中。一旦你的DLL进入另一个进程的地址空间,就可以对另一个进程为所欲为。
22.1插入DLL
假设你想为由另一个进程创建的窗口建立一个子类。你可能记得,建立子类就能够改变窗口的行为特性。若要建立子类,只需要调用SetWindowLongPtr函数,改变窗口的内存块中的窗口过程地址,指向一个新的(你自己的)WndProc。PlatformSDK文档说,应用程序不能为另一个进程创建的窗口建立子类。这并不完全正确。为另一个进程的窗口建立子类的关键问题与进程地址空间的边界有关。
当调用下面所示的SetWindowsLongPtr函数,建立一个窗口的子类时,你告诉系统,发送到或者显示在hwnd设定的窗口中的所有消息都应该送往MySubclassProc,而不是送往窗口的正常窗口过程:SetWindowLongPtr(hwnd,GWLP_WNDPROC,MySubclassProc);
换句话说,当系统需要将消息发送到指定窗口的WndProc时,要查看它的地址,然后直接调用WndProc。在本例中,系统发现MySubclassProc函数的地址与窗口相关联,因此就直接调用MySubclassProc函数。
为另一个进程创建的窗口建立子类时遇到的问题是,建立子类的过程位于另一个地址空间中。解决办法是:只需要用DLL“插入”进程的地址空间的方法来进行这项操作。有若干种方法可以用来进行这项操作。下面将逐个介绍它们。
22.2使用注册表来插入DLL
Windows98Windows98将忽略注册表的这个关键字。在Windows98下,无法使用该方法插入DLL。
启动注册表编辑器,进入:HKEY_LOCAL_MACHINE\Software\Microsoft\WindowsNT\CurrentVersion\Windows\AppInit_Dlls.该关键字的值包含一个DLL文件名或者一组DLL文件名(用空格或逗号隔开)。由于空格用来将文件名隔开,因此必须避免使用包含空格的文件名。列出的第一个DLL文件名可以包含一个路径,但是包含路径的其他DLL均被忽略。由于这个原因,最好将你的DLL放入Windows的系统目录中,这样就不必设定路径。在窗口中,我将该值设置为单个DLL路径名C:\MyLib.dll。
当重新启动计算机及Windows进行初始化时,系统将保存这个关键字的值。然后,当User32.dll库被映射到进程中时,它将接收到一个DLL_PROCESS_ATTACH通知。当这个通知被处理时,User32.dll便检索保存的这个关键字中的值,并且为字符串中指定的每个DLL调用LoadLibrary函数。当每个库被加载时,便调用与该库相关的DllMain函数,其fdwReason的值是DLL_PROCESS_ATTACH,这样,每个库就能够对自己进行初始化。由于插入的DLL在进程的寿命期中早早地就进行了加载,因此在调用函数时应该格外小心。调用kernel32.dll中的函数时应该不会出现什么问题,不过调用其他DLL中的函数时就可能产生一些问题。User32.dll并不检查每个库是否已经加载成功,或者初始化是否取得成功。
在插入DLL时所用的所有方法中,这是最容易的一种方法。要做的工作只是将一个值添加到一个已经存在的注册表关键字中。不过这种方法也有它的某些不足:
•由于系统在初始化时要读取这个关键字的值,因此在修改这个值后必须重新启动你的计算机—即使退出后再登录,也不行。当然,如果从这个关键字的值中删除DLL,那么在计算机重新启动之前,系统不会停止对库的映射操作。
•你的DLL只会映射到使用User32.dll的进程中。所有基于GUI的应用程序均使用User32.dll,不过大多数基于CUI的应用程序并不使用它。因此,如果需要将DLL插入编译器或链接程序,这种方法将不起作用。
•你的DLL将被映射到每个基于GUI的应用程序中,但是必须将你的库插入一个或几个进程中。你的DLL映射到的进程越多,“容器”进程崩溃的可能性就越大。毕竟在这些进程中运行的线程是在执行你的代码。如果你的代码进入一个无限循环,或者访问的内存不正确,就会影响代码运行时所在进程的行为特性和健壮性。因此,最好将你的库插入尽可能少的进程中。
•你的DLL将被映射到每个基于GUI的应用程序中。这与上面的问题相类似。在理想的情况下,你的DLL只应该映射到需要的进程中,同时,它应该以尽可能少的时间映射到这些进程中。假设在用户调用你的应用程序时你想要建立WordPad的主窗口的子类。在用户调用你的应用程序之前,你的DLL不必映射到WordPad的地址空间中。如果用户后来决定终止你的应用程序的运行,那么你必须撤消WordPad的主窗口。在这种情况下,你的DLL将不再需要被插入WordPad的地址空间。最好是仅在必要时保持DLL的插入状态。
22.3使用Windows挂钩来插入DLL
可以使用挂钩将DLL插入进程的地址空间。为了使挂钩能够像它们在16位Windows中那样工作,Microsoft不得不设计了一种方法,使得DLL能够插入另一个进程的地址空间中。下面让我们来看一个例子。进程A(类似MicrosoftSpy++的一个实用程序)安装了一个挂钩WN_GETMESSAGE,以便查看系统中的各个窗口处理的消息。该挂钩是通过调用下面的SetWindowsHookEx函数来安装的:
HHOOKhHook=SetWindowsHookEx(WH_GETMESSAGE,GetMsgProc,hinstdll,0);
第一个参数WH_GETMESSAGE用于指明要安装的挂钩的类型。第二个参数GetMsgProc用于指明窗口准备处理一个消息时系统应该调用的函数的地址(在你的地址空间中)。第三个参数hinstDll用于指明包含GetMsgProc函数的DLL。在Windows中,DLL的hinstDll的值用于标识DLL被映射到的进程的地址空间中的虚拟内存地址。最后一个参数0用于指明要挂接的线程。
对于一个线程来说,它可以调用SetWindowsHookEx函数,传递系统中的另一个线程的ID。通过为这个参数传递0,就告诉系统说,我们想要挂接系统中的所有GUI线程。
现在让我们来看一看将会发生什么情况:
1)进程B中的一个线程准备将一条消息发送到一个窗口。
2)系统查看该线程上是否已经安装了WH_GETMESSAGE挂钩。
3)系统查看包含GetMsgProc函数的DLL是否被映射到进程B的地址空间中。
4)如果该DLL尚未被映射,系统将强制该DLL映射到进程B的地址空间,并且将进程B中的DLL映像的自动跟踪计数递增1。
5)当DLL的hinstDll用于进程B时,系统查看该函数,并检查该DLL的hinstDll是否与它用于进程A时所处的位置相同。
如果两个hinstDll是在相同的位置上,那么GetMsgProc函数的内存地址在两个进程的地址空间中的位置也是相同的。在这种情况下,系统只需要调用进程A的地址空间中的GetMsgProc函数即可。
如果hinstDll的位置不同,那么系统必须确定进程B的地址空间中GetMsgProc函数的虚拟内存地址。这个地址可以使用下面的公式来确定:
GetMsgProcB=hinstDllB+(GetMsgProcA–hinstDllA)
将GetMsgProcA的地址减去hinstDllA的地址,就可以得到GetMsgProc函数的地址位移(以字节为计量单位)。将这个位移与hinstDllB的地址相加,就得出GetMsgProc函数在用于进程B的地址空间中该DLL的映像时它的位置。
6)系统将进程B中的DLL映像的自动跟踪计数递增1。
7)系统调用进程B的地址空间中的GetMsgProc函数。
8)当GetMsgProc函数返回时,系统将进程B中的DLL映像的自动跟踪计数递减1。
注意,当系统插入或者映射包含挂钩过滤器函数的DLL时,整个DLL均被映射,而不只是挂钩过滤器函数被映射。这意味着DLL中包含的任何一个函数或所有函数现在都存在,并且可以从进程B的环境下运行的线程中调用。
若要为另一个进程中的线程创建的窗口建立子类,首先可以在创建该窗口的挂钩上设置一个WH_GETMESSAGE挂钩,然后,当GetMsgProc函数被调用时,调用SetWindowLongPtr函数来建立窗口的子类。当然,子类的过程必须与GetMsgProc函数位于同一个DLL中。
与插入DLL的注册表方法不同,这个方法允许你在另一个进程的地址空间中不再需要DLL时删除该DLL的映像,方法是调用下面的函数:BOOLUnhookWindowsHookEx(HHOOKhhook);
当一个线程调用UnhookWindowsHookEx函数时,系统将遍历它必须将DLL插入到的各个进程的内部列表,并且对DLL的自动跟踪计数进行递减。当自动跟踪计数递减为0时,DLL就自动从进程的地址空间中被删除。应该记得,就在系统调用GetMsgProc函数之前,它对DLL的自动跟踪计数进行了递增(见上面的第6个步骤)。这可以防止产生内存访问违规。如果该自动跟踪计数没有递增,那么当进程B的线程试图执行GetMsgProc函数中的代码时,系统中运行的另一个线程就可以调用UnlookWindowsHookEx函数。
这一切意味着不能撤消该窗口的子类并且立即撤消该挂钩。该挂钩必须在该子类的寿命期内保持有效状态。

22.4使用远程线程来插入DLL
插入DLL的第三种方法是使用远程线程。这种方法具有更大的灵活性。它要求你懂得若干个Windows特性、如进程、线程、线程同步、虚拟内存管理、DLL和Unicode等(如果对这些特性不清楚,请参阅本书中的有关章节)。Windows的大多数函数允许进程只对自己进行操作。这是很好的一个特性,因为它能够防止一个进程破坏另一个进程的运行。但是,有些函数却允许一个进程对另一个进程进行操作。这些函数大部分最初是为调试程序和其他工具设计的。不过任何函数都可以调用这些函数。
这个DLL插入方法基本上要求目标进程中的线程调用LoadLibrary函数来加载必要的DLL。由于除了自己进程中的线程外,我们无法方便地控制其他进程中的线程,因此这种解决方案要求我们在目标进程中创建一个新线程。由于是自己创建这个线程,因此我们能够控制它执行什么代码。幸好,Windows提供了一个称为CreateRemoteThread的函数,使我们能够非常容易地在另一个进程中创建线程:
HANDLECreateRemoteThread(
HANDLEhProcess,
PSECURITY_ATTRIBUTESpsa,
DWORDdwStackSize,
PTHREAD_START_ROUTINEpfnStartAddr,
PVOIDpvParam,
DWORDfdwCreate,
PDWORDpdwThreadId);
CreateRemoteThread与CreateThread很相似,差别在于它增加了一个参数hProcess。该参数指明拥有新创建线程的进程。参数pfnStartAddr指明线程函数的内存地址。当然,该内存地址与远程进程是相关的。线程函数的代码不能位于你自己进程的地址空间中。
注意在Windows2000中,更常用的函数CreateThread是在内部以下面的形式来实现的:
HANDLECreateThread(PSECURITY_ATTRIBUTESpsa,DWORDdwStackSize,PTHREAD_START_ROUTINEpfnStartAddr,
PVOIDpvParam,DWORDfdwCreate,PDWORDpdwThreadId){
return(CreateRemoteThread(GetCurrentProcess(),psa,dwStackSize,pfnStartAddr,pvParam,fdwCreate,pdwThreadId));
}
Windows98在Windows98中,CreateRemoteThread函数不存在有用的实现代码,它只是返回NULL。调用GetLastError函数将返回ERROR_CALL_NOT_IMPLEMENTED(CreateThread函数包含用于在调用进程中创建线程的完整的实现代码)。由于CreateRemoteThread没有实现,因此,在Windows98下,不能使用本方法来插入DLL。
好了,现在你已经知道如何在另一个进程中创建线程了,但是,如何才能让该线程加载我们的DLL呢?答案很简单,那就是需要该线程调用LoadLibrary函数:
HINSTANCELoadLibrary(PCTSTRpszLibFile);
实际上有两个LoadLibrary函数,即LoadLibraryA和LoadLibraryW。这两个函数之间存在的唯一差别是,传递给函数的参数类型不同。如果将库的文件名作为ANSI字符串来存储,那么必须调用LoadLibraryA(A是指ANSI)。如果将文件名作为Unicode字符串来存储,那么必须调用LoadLibraryW(W是指通配符)。
下面让我们将必须执行的操作步骤做一个归纳:
1)使用VirtualAllocEx函数,分配远程进程的地址空间中的内存。
2)使用WriteProcessMemory函数,将DLL的路径名拷贝到第一个步骤中已经分配的内存中。
3)使用GetProcAddress函数,获取LoadLibraryA或LoadLibratyW函数的实地址(在Kernel32.dll中)。
4)使用CreateRemoteThread函数,在远程进程中创建一个线程,它调用正确的LoadLibrary函数,为它传递第一个步骤中分配的内存的地址。
这时,DLL已经被插入远程进程的地址空间中,同时DLL的DllMain函数接收到一个DLL_PROCESS_ATTACH通知,并且能够执行需要的代码。当DllMain函数返回时,远程线程从它对LoadLibrary的调用返回到BaseThreadStart函数(第6章中已经介绍)。然后BaseThreadStart调用ExitThread,使远程线程终止运行。
现在远程进程拥有第一个步骤中分配的内存块,而DLL则仍然保留在它的地址空间中。若要将它删除,需要在远程线程退出后执行下面的步骤:
5)使用VirtualFreeEx函数,释放第一个步骤中分配的内存。
6)使用GetProcAddress函数,获得FreeLibrary函数的实地址(在Kernel32.dll中)。
7)使用CreateRemoteThread函数,在远程进程中创建一个线程,它调用FreeLibrary函数,传递远程DLL的HINSTANCE。
这就是它的基本操作步骤。这种插入DLL的方法存在的唯一一个不足是,Windows98并不支持这样的函数。只能在Windows2000上使用这种方法。

22.5使用特洛伊DLL来插入DLL
插入DLL的另一种方法是取代你知道进程将要加载的DLL。例如,如果你知道一个进程将要加载Xyz.dll,就可以创建你自己的DLL,为它赋予相同的文件名。当然,你必须将原来的Xyz.dll改为别的什么名字。
在你的Xyz.dll中,输出的全部符号必须与原始的Xyz.dll输出的符号相同。使用函数转发器(第20章做了介绍),很容易做到这一点。虽然函数转发器使你能够非常容易地挂接某些函数,你应该避免使用这种方法,因为它不具备版本升级能力。例如,如果你取代了一个系统DLL,而Microsoft在将来增加了一些新函数,那么你的DLL将不具备它们的函数转发器。引用这些新函数的应用程序将无法加载和执行。
如果你只想在单个应用程序中使用这种方法,那么可以为你的DLL赋予一个独一无二的名字,并改变应用程序的.exe模块的输入节。更为重要的是,输入节只包含模块需要的DLL的名字。你可以仔细搜索文件中的这个输入节,并且将它改变,使加载程序加载你自己的DLL。这种方法相当不错,但是必须要非常熟悉.exe和DLL文件的格式。
22.6将DLL作为调试程序来插入
调试程序能够对被调试的进程执行特殊的操作。当被调试进程加载时,在被调试进程的地址空间已经作好准备,但是被调试进程的主线程尚未执行任何代码之前,系统将自动将这个情况通知调试程序。这时,调试程序可以强制将某些代码插入被调试进程的地址空间中(比如使用WriteProcessMemory函数来插入),然后使被调试进程的主线程执行该代码。这种方法要求你对被调试线程的CONTEXT结构进行操作,意味着必须编写特定CPU的代码。必须修改你的源代码,使之能够在不同的CPU平台上正确地运行。另外,必须对你想让被调试进程执行的机器语言指令进行硬编码。而且调试程序与它的被调试程序之间必须存在固定的关系。如果调试程序终止运行,Windows将自动撤消被调试进程。而你则无法阻止它。
22.7用Windows98上的内存映射文件插入代码
在Windows98上插入你自己的代码是非常简单的。在Windows98上运行的所有32位Windows应用程序均共享同样的最上面的2GB地址空间。如果你分配这里面的某些存储器,那么该存储器在每个进程的地址空间中均可使用。若要分配2GB以上的存储器,只要使用内存映射文件(第17章已经介绍)。可以创建一个内存映射文件,然后调用MapViewOfFile函数,使它显示出来。然后将数据填入你的地址空间区域(这是所有进程地址空间中的相同区域)。
必须使用硬编码的机器语言来进行这项操作,其结果是这种解决方案很难移植到别的CPU平台。不过,如果进行这项操作,那么不必考虑不同的CPU平台,因为Windows98只能在x86CPU上运行。
这种方法的困难之处在于仍然必须让其他进程中的线程来执行内存映射文件中的代码。要做到这一点,需要某种方法来控制远程进程中的线程。CreateRemoteThread函数能够很好地执行这个任务,可惜Windows98不支持该函数的运行,而我也无法提供相应的解决方案。
22.8用CreateProcess插入代码
如果你的进程生成了你想插入代码的新进程,那么事情就会变得稍稍容易一些。原因之一是,你的进程(父进程)能够创建暂停运行的新进程。这就使你能够改变子进程的状态,而不影响它的运行,因为它尚未开始运行。但是父进程也能得到子进程的主线程的句柄。使用该句柄,可以修改线程执行的代码。你可以解决上一节提到的问题,因为可以设置线程的指令指针,以便执行内存映射文件中的代码。
下面介绍一种方法,它使你的进程能够控制子进程的主线程执行什么代码:
1)使你的进程生成暂停运行的子进程。
2)从.exe模块的头文件中检索主线程的起始内存地址。
3)将机器指令保存在该内存地址中。
4)将某些硬编码的机器指令强制放入该地址中。这些指令应该调用LoadLibrary函数来加载DLL。
5)继续运行子进程的主线程,使该代码得以执行。
6)将原始指令重新放入起始地址。
7)让进程继续从起始地址开始执行,就像没有发生任何事情一样。
上面的步骤6和7要正确运行是很困难的,因为你必须修改当前正在执行的代码。不过这是可能的。
这种方法具有许多优点。首先,它在应用程序执行之前就能得到地址空间。第二,它既能在Windows98上使用,也能在Windows2000上使用。第三,由于你不是调试者,因此能够很容易使用插入的DLL来调试应用程序。最后,这种方法可以同时用于控制台和GUI应用程序。当然,这种方法也有某些不足。只有当你的代码是父进程时,才能插入DLL。另外,这种方法当然不能跨越不同的CPU来运行,必须对不同的CPU平台进行相应的修改。
22.9挂接API的一个示例
将DLL插入进程的地址空间是确定进程运行状况的一种很好的方法。但是,仅仅插入DLL无法提供足够的信息,人们常常需要知道某个进程中的线程究竟是如何调用各个函数的,也可能需要修改Windows函数的功能。
例如,我知道一家公司生产的DLL是由一个数据库产品来加载的。该DLL的作用是增强和扩展数据库产品的功能。当数据库产品终止运行时,该DLL就会收到一个DLL_PROCESS_DETACH通知,并且只有在这时,它才执行它的所有清除代码。该DLL将调用其他DLL中的函数,以便关闭套接字连接、文件和其他资源,但是当它收到DLL_PROCESS_DETACH通知时,进程的地址空间中的其他DLL已经收到它们的DLL_PROCESS_DETACH通知。因此,当该公司的DLL试图清除时,它调用的许多函数的运行将会失败,因为其他DLL已经撤消了初始化信息。
一个好的办法就是利用挂接函数ExitProcess。如你所知,调用ExitProcess将导致系统向该DLL发送DLL_PROCESS_DETACH通知。通过挂接ExitPrecess函数,我们就能确保当ExitProcess函数被调用时,该公司的DLL能够得到通知。这个通知将在任何DLL得到DLL_PROCESS_DETACH通知之前进来,因此进程中的所有DLL仍然处于初始化状态中,并且能够正常运行。此时,该公司的DLL知道进程将要终止运行,并且能够成功地执行它的全部清除操作。然后,操作系统的ExitProcess函数被调用,使所有DLL收到它们的DLL_PROCESS_DETACH通知并进行清除操作。当该公司的DLL收到这个通知时,它将不执行专门的清除操作,因为它已经做了它必须做的事情。
在这个例子中,插入DLL是可以随意进行的,因为数据库应用程序的设计已经允许进行这样的插入,并且它加载了公司的DLL。当该公司的DLL被加载时,它必须扫描所有已经加载的可执行模块和DLL模块,以便找出对ExitProcess的调用。当它发现对ExitProcess的调用后,DLL必须修改这些模块,这样,这些模块就能调用公司的DLL中的函数,而不是调用操作系统的ExitProcess函数(这个过程比想象的情况要简单的多)。一旦公司的ExitProcess替换函数(即通常所说的挂钩函数)执行它的清除代码,操作系统的ExitProcess函数(在Kernel32.dll文件中)就被调用。
这个例子显示了挂接API的一种典型用法。它用很少的代码解决了一个非常实际的问题。
22.9.1通过改写代码来挂接API
API挂接并不是一个新技术,多年来编程人员一直在使用API挂接方法。如果要解决上面所说的问题,那么人们首先看到的“解决方案”是通过改写代码来进行挂接。下面是具体的操作方法:
1)找到你想挂接的函数在内存中的地址(比如说Kernel32.dll中的ExitProcess)。
2)将该函数的头几个字节保存在你自己的内存中。
3)用一个JUMPCPU指令改写该函数的头几个字节,该指令会转移到你的替换函数的内存地址。当然,你的替换函数的标记必须与你挂接的函数的标记完全相同,即所有的参数必须一样,返回值必须一样,调用规则必须一样。
4)现在,当一个线程调用已经挂接的函数时,JUMP指令实际上将转移到你的替换函数。这时,你就能够执行任何代码。
5)取消函数的挂接状态,方法是取出(第二步)保存的字节,将它们放回挂接函数的开头。
6)调用挂接的函数(它已不再被挂接),该函数将执行其通常的处理操作。
7)当原始函数返回时,再次执行第二和第三步,这样你的替换函数就可以被调用。
这种方法在16位Windows编程员中使用得非常普遍,并且用得很好。今天,这种方法存在着若干非常严重的不足,因此建议尽量避免使用它。首先,它对CPU的依赖性很大,在x86、Alpha和其他的CPU上的JUMP指令是不同的,必须使用手工编码的机器指令才能使这种方法生效。第二,这种方法在抢占式多线程环境中根本不起作用。线程需要占用一定的时间来改写函数开头的代码。当代码被改写时,另一个线程可能试图调用该同一个函数。结果将是灾难性的。
因此,只有当你知道在规定的时间只有一个线程试图调用某个函数时,才能使用这种方法。Windows98在Windows98上,主要的WindowsDLL(Kernel32、AdvAPI32、User32和GDI32)是这样受到保护的,即应用程序不能改写它们的代码页面。通过编写虚拟设备驱动程序(VxD)才能够获得这种保护。
22.9.2通过操作模块的输入节来挂接API
另一种API挂接方法能够解决我前面讲到的两个问题。这种方法实现起来很容易,并且相当健壮。但是,要理解这种方法,必须懂得动态连接是如何工作的。尤其必须懂得模块的输入节中保护的是什么信息。第19章已经用了较多的篇幅介绍了输入节是如何生成的以及它包含的内容。当阅读下面的内容时,可以回头参考第19章的有关说明。
如你所知,模块的输入节包含一组该模块运行时需要的DLL。另外,它还包含该模块从每个DLL输入的符号的列表。当模块调用一个输入函数时,线程实际上要从模块的输入节中捕获需要的输入函数的地址,然后转移到该地址。
要挂接一个特定的函数,只需要改变模块的输入节中的地址,就这么简单。它不存在依赖CPU的问题。同时,由于修改了函数的代码,因此不需要担心线程的同步问题。

第五部分结构化异常处理

第23章结束处理程序
本章讨论结构化异常处理(SEH)。使用SEH的好处是当你编写程序时,只需要关注程序要完成的任务。如果在运行时发生什么错误,系统会发现并将发生的问题通知你。利用SEH,你可以完全不用考虑代码里是不是有错误,这样就把主要的工作同错误处理分离开来。这样的分离,可以使你集中精力处理眼前的工作,而将可能发生的错误放在后面处理。
微软在Windows中引入SEH的主要动机是为了便于操作系统本身的开发。操作系统的开发人员使用SEH,使得系统更加强壮。我们也可以使用SEH,使我们的自己的程序更加强壮。使用SEH所造成的负担主要由编译程序来承担,而不是由操作系统承担。
由于各编译程序的实现上存在着差别,这样以特定的方式用特定的代码例子讨论SEH的优点就很困难。但大多数编译程序厂商都采用微软建议的文法。本书中的例子使用的文法和关键字可能与其他一些公司编译程序所使用的不同,但主要的SEH概念是一样的。本章使用MicrosoftVisualC++编译程序的文法。
注意不要将结构化异常处理同C++的异常处理相混淆。C++异常处理是一种不同形式的异常处理,其形式是使用C++关键字catch和throw。微软的VisualC++也支持C++的异常处理,并且在内部实现上利用了已经引入到编译程序和Windows操作系统的结构化异常处理的功能。
SEH实际包含两个主要功能:结束处理(terminationhandling)和异常处理(exceptionhandling)。本章讨论结束处理,下一章讨论异常处理。
一个结束处理程序能够确保去调用和执行一个代码块(结束处理程序,terminationhandler),而不管另外一段代码(保护体,guardedbody)是如何退出的。结束处理程序的文法结构(使用微软的VisualC++编译程序)如下:
__try{
//Guardedbody

}
__finally{
//Terminationhandler

}
--try和--finally关键字用来标出结束处理程序两段代码的轮廓。在上面的代码段中,操作系统和编译程序共同来确保结束处理程序中的--finally代码块能够被执行,不管保护体(try块)是如何退出的。不论你在保护体中使用return,还是goto,或者是longjump,结束处理程序(finally块)都将被调用。下面将通过几个例子来说明这一点。
23.1通过例子理解结束处理程序
由于在使用SEH时,编译程序和操作系统直接参与了程序代码的执行,为了解释SEH如何工作,最好的办法就是考察源代码例子,讨论例子中语句执行的次序。
因此,在下面几节给出不同的源代码片段,对每一个片段解释编译程序和操作系统如何改变代码的执行次序。
23.2Funcenstein1
为了甄别使用结束处理程序的各种情况,我们来考察更具体的代码例子。
DWORDFuncenstein1(){
DWORDdwTemp;
//1.Doanyprocessinghere.

__try{
//2.Requestpermissiontoaccessprotecteddata,andthenuseit.
WaitForSingleObject(g_hsem,INFINITE);
G_dwprotectedData=5;
Dwatemp=g_dwprotectedData;
}
__finally{
//3.Allowotherstouseprotecteddata.
ReleaseSemaphore(g_hsem,1,NULL);
}
//4.continueprocessing.
Return(dwTemp);
}
上面程序中加了标号的注释指出了代码执行的次序。在Funcenstein1中,使用try-finally块并没有带来很多好处。代码要等待信标(semaphore),改变保护数据的内容,保存局部变量dwTemp的新值,释放信标,将新值返回给调用程序。
23.3Funcenstein2
现在我们把这个程序稍微改动一下,看会发生什么事情。
DWORDFuncenstein2(){
DWORDdwTemp;
//1.Doanyprocessinghere.

__try{
//2.Requestpermissiontoaccessprotecteddata,andthenuseit.
WaitForSingleObject(g_hsem,INFINITE);
G_dwprotectedData=5;
dwtemp=g_dwprotectedData;
//returnthenewvalue.
Return(dwTemp);
}
__finally{
//3.Allowotherstouseprotecteddata.
ReleaseSemaphore(g_hsem,1,NULL);
}
//4.continueprocessing.—thiscodewillneverexecuteinthisversion.
dwTemp=9;
Return(dwTemp);
}
在Funcenstein2中,try块的末尾增加了一个return语句。这个return语句告诉编译程序在这里要退出这个函数并返回dwTemp变量的内容,现在这个变量的值是5。但是,如果这个return语句被执行,该线程将不会释放信标,其他线程也就不能再获得对信标的控制。可以想象,这样的执行次序会产生很大的问题,那些等待信标的线程可能永远不会恢复执行。
通过使用结束处理程序,可以避免return语句的过早执行。当return语句试图退出try块时,编译程序要确保finally块中的代码首先被执行。要保证finally块中的代码在try块中的return语句退出之前执行。Funcenstein2中,将对ReleaseSemaphore的调用放在结束处理程序块中,保证信标总会被释放。这样就不会造成一个线程一直占有信标,否则将意味着所有其他等待信标的线程永远不会被分配CPU时间。
在finally块中的代码执行之后,函数实际上就返回。任何出现在finally块之下的代码将不再执行,因为函数已在try块中返回。所以这个函数的返回值是5,而不是9。
读者可能要问编译程序是如何保证在try块可以退出之前执行finally块的。当编译程序检查源代码时,它看到在try块中有return语句。这样,编译程序就生成代码将返回值(本例中是5)保存在一个编译程序建立的临时变量中。编译程序然后再生成代码来执行finally块中包含的指令,这称为局部展开。更特殊的情况是,由于try块中存在过早退出的代码,从而产生局部展开,导致系统执行finally块中的内容。在finally块中的指令执行之后,编译程序临时变量的值被取出并从函数中返回。
可以看到,要完成这些事情,编译程序必须生成附加的代码,系统要执行额外的工作。在不同的CPU上,结束处理所需要的步骤也不同。例如,在Alpha处理器上,必须执行几百个甚至几千个CPU指令来捕捉try块中的过早返回并调用finally块。在编写代码时,就应该避免引起结束处理程序的try块中的过早退出,因为程序的性能会受到影响。本章后面,将讨论__leave关键字,它有助于避免编写引起局部展开的代码。
设计异常处理的目的是用来捕捉异常的—不常发生的语法规则的异常情况(在我们的例子中,就是过早返回)。如果情况是正常的,明确地检查这些情况,比起依赖操作系统和编译程序的SEH功能来捕捉常见的事情要更有效。
注意当控制流自然地离开try块并进入finally块(就像在Funcenstein1中)时,进入finally块的系统开销是最小的。在x86CPU上使用微软的编译程序,当执行离开try块进入finally块时,只有一个机器指令被执行,读者可以在自己的程序中注意到这种系统开销。当编译程序要生成额外的代码,系统要执行额外的工作时(如同在Funcenstein2中),系统开销就很值得注意了。
23.4Funcenstein3
现在我们对函数再做修改,看会出现什么情况:
DWORDFuncenstein3(){
DWORDdwTemp;
//1.Doanyprocessinghere.

__try{
//2.Requestpermissiontoaccessprotecteddata,andthenuseit.
WaitForSingleObject(g_hsem,INFINITE);
G_dwprotectedData=5;
dwtemp=g_dwprotectedData;
//trytojumpoverthefinallyblock.
GotoReturnvalue;
}
__finally{
//3.Allowotherstouseprotecteddata.
ReleaseSemaphore(g_hsem,1,NULL);
}
//4.continueprocessing.—thiscodewillneverexecuteinthisversion.
returnValue:
Return(dwTemp);
}
在Funcenstein3中,当编译程序看到try块中的goto语句,它首先生成一个局部展开来执行finally块中的内容。这一次,在finally块中的代码执行之后,在ReturnValue标号之后的代码将执行,因为在try块和finally块中都没有返回发生。这里的代码使函数返回5。而且,由于中断了从try块到finally块的自然流程,可能要蒙受很大的性能损失(取决于运行程序的CPU)。
23.5Funcfurter1
现在我们来考察另外的情况,这里可以真正显示结束处理的价值。看下面的函数:
DWORDFuncfurter1(){
DWORDdwTemp;
//1.Doanyprocessinghere.

__try{
//2.Requestpermissiontoaccessprotecteddata,andthenuseit.
WaitForSingleObject(g_hsem,INFINITE);
dwtemp=funcinator(g_dwprotectedData);
}
__finally{
//3.Allowotherstouseprotecteddata.
ReleaseSemaphore(g_hsem,1,NULL);
}
//4.continueprocessing.
Return(dwTemp);
}
现在假想一下,try块中的Funcinator函数调用包含一个错误,会引起一个无效内存访问。如果没有SEH,在这种情况下,将会给用户显示一个很常见的ApplicationError对话框。当用户忽略这个错误对话框,该进程就结束了。当这个进程结束(由于一个无效内存访问),信标仍将被占用并且永远不会被释放,这时候,任何等待信标的其他进程中的线程将不会被分配CPU时间。但若将对ReleaseSemaphore的调用放在finally块中,就可以保证信标获得释放,即使某些其他函数会引起内存访问错误。
如果结束处理程序足够强,能够捕捉由于无效内存访问而结束的进程,我们就可以相信它也能够捕捉setjump和longjump的结合,还有那些简单语句如break和continue。
23.6Funcenstein4
我们再看一种异常处理的情况。
DWORDFuncenstein4(){
DWORDdwTemp;
//1.Doanyprocessinghere.

__try{
//2.Requestpermissiontoaccessprotecteddata,andthenuseit.
WaitForSingleObject(g_hsem,INFINITE);
G_dwprotectedData=5;
dwtemp=g_dwprotectedData;
//returnthenewvalue.
Return(dwTemp);
}
__finally{
//3.Allowotherstouseprotecteddata.
ReleaseSemaphore(g_hsem,1,NULL);
Return(103);
}
//4.continueprocessing.—thiscodewillneverexecute.
dwTemp=9;
Return(dwTemp);
}
在Funcenstein4中,try块将要执行,并试图将dwTemp的值(5)返回给Funcenstein4的调用者。如同对Funcenstein2的讨论中提到的,试图从try块中过早的返回将导致产生代码,把返回值置于由编译程序建立的临时变量中。然后,finally块中的代码被执行。在这里,与Funcenstein2不同的是在finally块中增加了一个return语句。Funcenstein4会向调用程序返回5还是103?这里的答案是103,因finally块中的return语句引起值103存储在值5所存储的临时变量中,覆盖了值5。当finally块完成执行,现在临时变量中的值(103)从Funcenstein4返回给调用程序。
我们已经看到结束处理程序在补救try块中的过早退出的执行方面很有效,但也看到结束处理程序由于要阻止try块的过早退出而产生了我们不希望有的结果。更好的办法是在结束处理程序的try块中避免任何会引起过早退出的语句。实际上,最好将return、continue、break、goto等语句从结束处理程序的try块和finally块中移出去,放在结束处理程序的外面。这样做会使编译程序产生较小的代码,因为不需要再捕捉try块中的过早退出,也使编译程序产生更快的代码(因为执行局部展开的指令也少)。另外,代码也更容易阅读和维护。
23.8Funcarama1
我们已经谈过了结束处理程序的基本语法和语意。现在看一看如何用结束处理程序来简化一个更复杂的编程问题。先看一个完全没有利用结束处理程序的函数:
BOOLFuncarama1(){
HANDLEhFIle=INVALID_HANDLE_VALUE;
PVOIDpvBuf=NULL;
DWORDdwNumBytesRead;
BOOLfok;
hFile=CreateFile(“SOMEDATA.DAT”,GENERIC_READ,FILE_SHARE_READ,
NULL,OPEN_EXISTING,0,NULL);
If(hFile=INVALID_HANDLE_VALUE){
Return(FALSE);
}
pvBuf=VirtualAlloc(NULL,1024,MEM_COMMIT,PAGE_READWRITE);
if(pvBuf=NULL){
CloseHandle(hFile);
Return(FALSE);
}
Fok=ReadFile(hFile,pvBuf,1024,&dwNumBytesRead,NULL);
If(!fok||(dwNumBytesRead==0)){
VirtualFree(pvBuf,MEM_RELEASE|MEM_DECOMMIT);
CloseHandle(hFile);
Return(FALSE);
}
//Dosomecalculationonthedata.

//cleanupalltheresources.
VirtualFree(pvBuf,MEM_RELEASE|MEM_DECOMMIT);
closeHandle(hFile);
return(TRUE);
}
Funcarama1中的各种错误检查使这个函数非常难以阅读,也使这个函数难以理解、维护和修改。
23.9Funcarama2
当然,可以重新编写Funcarama1,使它更清晰一些,也更容易理解。
BOOLFuncarama2(){
HANDLEhFIle=INVALID_HANDLE_VALUE;
PVOIDpvBuf=NULL;
DWORDdwNumBytesRead;
BOOLfok,fSuccess=FALSE;
hFile=CreateFile(“SOMEDATA.DAT”,GENERIC_READ,FILE_SHARE_READ,
NULL,OPEN_EXISTING,0,NULL);
If(hFile!=INVALID_HANDLE_VALUE){
pvBuf=VirtualAlloc(NULL,1024,MEM_COMMIT,PAGE_READWRITE);
if(pvBuf!=NULL){
Fok=ReadFile(hFile,pvBuf,1024,&dwNumBytesRead,NULL);
If(fok||(dwNumBytesRead!=0)){
//Dosomecalculationonthedata.

fSuccess=TRUE;
}
}
VirtualFree(pvBuf,MEM_RELEASE|MEM_DECOMMIT);
}
CloseHandle(hFile);
Return(fSuccess);
}
Funcarama2尽管比Funcarama1容易理解一些,但还是不好修改和维护。而且,当增加更多的条件语句时,这里的缩排格式就会走向极端,很快就到屏幕的最右边。
23.10Funcarama3
我们使用一个SEH结束处理程序来重新编写Funcarama1。
BOOLFuncarama3(){
//IMPORTANT:initializeallvariablestoassumefailure.
HANDLEhFIle=INVALID_HANDLE_VALUE;
PVOIDpvBuf=NULL;
__try{
DWORDdwNumBytesRead;
BOOLfok;
hFile=CreateFile(“SOMEDATA.DAT”,GENERIC_READ,FILE_SHARE_READ,
NULL,OPEN_EXISTING,0,NULL);
If(hFile=INVALID_HANDLE_VALUE){
Return(FALSE);
}
pvBuf=VirtualAlloc(NULL,1024,MEM_COMMIT,PAGE_READWRITE);
if(pvBuf=NULL){
Return(FALSE);
}
Fok=ReadFile(hFile,pvBuf,1024,&dwNumBytesRead,NULL);
If(!fok||(dwNumBytesRead==!1024)){
Return(FALSE);
}
//Dosomecalculationonthedata.

}
__finally{
//cleanupalltheresources.
If(pvBuf!=NULL)
VirtualFree(pvBuf,MEM_RELEASE|MEM_DECOMMIT);
If(hFile!=INVALID_HANDLE_VALUE)
closeHandle(hFile);
}
//Continueprocessing.
return(TRUE);
}
Funcarama3版的真正好处是函数的所有清理(cleanup)代码都局部化在一个地方且只在一个地方:finally块。如果需要在这个函数中再增加条件代码,只需在finally块中简单地增加一个清理行,不需要回到每个可能失败的地方添加清理代码。
23.11Funcarama4:最终的边界
Funcarama3版本的问题是系统开销。就像在Funcenstein4中讨论的,应该尽可能避免在try块中使用return语句。
为了帮助避免在try块中使用return语句,微软在其C/C++编译程序中增加了另一个关键字--leave。这里是Funcarma4版,它使用了--leave关键字:
BOOLFuncarama1(){
//IMPORTANT:initializeallvariablestoassumefailure.
HANDLEhFIle=INVALID_HANDLE_VALUE;
PVOIDpvBuf=NULL;
//assumethatthefunctionwillnotexecutesuccessfully.
BOOLfFunctionOk=FALSE;
__try{
DWORDdwNumBytesRead;
BOOLfok;
hFile=CreateFile(“SOMEDATA.DAT”,GENERIC_READ,FILE_SHARE_READ,
NULL,OPEN_EXISTING,0,NULL);
If(hFile=INVALID_HANDLE_VALUE){
__leave;
}
pvBuf=VirtualAlloc(NULL,1024,MEM_COMMIT,PAGE_READWRITE);
if(pvBuf=NULL){
__leave;}
Fok=ReadFile(hFile,pvBuf,1024,&dwNumBytesRead,NULL);
If(!fok||(dwNumBytesRead==!1024)){
__leave;}
//Dosomecalculationonthedata.

//indicatethattheentirefunctionexecutedsuccessfully.
fFunctionOk=TRUE;
}
__finally{
//cleanupalltheresources.
If(pvBuf!=NULL)
VirtualFree(pvBuf,MEM_RELEASE|MEM_DECOMMIT);
If(hFile!=INVALID_HANDLE_VALUE)
closeHandle(hFile);
}
//Continueprocessing.
return(fFunctionOk);
}
在try块中使用--leave关键字会引起跳转到try块的结尾。可以认为是跳转到try块的右大括号。由于控制流自然地从try块中退出并进入finally块,所以不产生系统开销。当然,需要引入一个新的Boolean型变量fFunctionOk,用来指示函数是成功或失败。这是比较小的代价。
当按照这种方式利用结束处理程序来设计函数时,要记住在进入try块之前,要将所有资源句柄初始化为无效的值。然后,在finally块中,查看哪些资源被成功的分配,就可以知道哪些要释放。另外一种确定需要释放资源的办法是对成功分配的资源设置一个标志。然后,finally块中的代码可以检查标志的状态,来确定资源是否要释放。
23.12关于finally块的说明
我们已经明确区分了强制执行finally块的两种情况:
•从try块进入finally块的正常控制流。
•局部展开:从try块的过早退出(goto、longjump、continue、break、return等)强制控制转移到finally块。
第三种情况,全局展开(globalunwind),在发生的时候没有明显的标识,我们在本章前面Funcfurter1函数中已经见到。在Funcfurter1的try块中,有一个对Funcinator函数的调用。如果Funcinator函数引起一个内存访问违规(memoryaccessviolation),一个全局展开会使Funcfurter1的finally块执行。下一章将详细讨论全局展开。
由于以上三种情况中某一种的结果而导致finally块中的代码开始执行。为了确定是哪一种情况引起finally块执行,可以调用内部函数(或内蕴函数,intrinsicfunction)AbnormalTermination:BOOLAbnormalTermination();
这个内部函数只在finally块中调用,返回一个Boolean值。指出与finally块相结合的try块是否过早退出。换句话说,如果控制流离开try块并自然进入finally块,AbnormalTermination将返回FALSE。如果控制流非正常退出try块—通常由于goto、return、break或continue语句引起的局部展开,或由于内存访问违规或其他异常引起的全局展开—对AbnormalTermination的调用将返回TRUE。没有办法区别finally块的执行是由于全局展开还是由于局部展开。但这通常不会成为问题,因为可以避免编写执行局部展开的代码。
注意内部函数是编译程序识别的一种特殊函数。编译程序为内部函数产生内联(inline)代码而不是生成调用函数的代码。例如,memcpy是一个内部函数(如果指定/Oi编译程序开关)。当编译程序看到一个对memcpy的调用,它直接将memcpy的代码插入调用memcpy的函数中,而不是生成一个对memcpy函数的调用。其作用是代码的长度增加了,但执行速度加快了。
23.13Funcfurter2
我们已经知道如何编写结束处理程序了,下一章读者会看到异常过滤程序和异常处理程序更有用,更重要。在继续之前,回顾一下使用结束处理程序的理由:
•简化错误处理,因所有的清理工作都在一个位置并且保证被执行。
•提高程序的可读性。
•使代码更容易维护。
•如果使用得当,具有最小的系统开销。

第24章 异常处理程序和软件异常
异常有两种,CPU引发的异常,就是所谓的硬件异常(hardware exception)。我们还会看到操作系统和应用程序也可以引发相应的异常,称为软件异常(software exception)。
当出现一个硬件或软件异常时,操作系统向应用程序提供机会来考察是什么类型的异常被引发,并能够让应用程序自己来处理异常。下面就是异常处理程序的文法:
__try{ … }
__except(exception filter){
…}
注意--except关键字。每当你建立一个try块,它必须跟随一个finally块或一个except块。一个try块之后不能既有finally块又有except块。但可以在try-except块中嵌套try-finally块,反过来也可以。
24.1通过例子理解异常过滤器和异常处理程序
与结束处理程序(前一章讨论过)不同,异常过滤器(exception filter)和异常处理程序是通过操作系统直接执行的,编译程序在计算异常过滤器表达式和执行异常处理程序方面不做什么事。下面几节的内容举例说明try-except块的正常执行,解释操作系统如何以及为什么计算异常过滤器,并给出操作系统执行异常处理程序中代码的环境。
24.1.1Funcmeister1
这里是一个try-exception块的更具体的例子。
DWORD Funcmeister1(){
DWORD dwTemp;
//1.Do any processing here.

__try{
//2.perform some operation.
dwTemp=0;
}
__except(EXCEPTION_EXECUTE_HANDLER){
//Handle an exception;this never executes.
…;
}
//3.continue processing.
Return(dwTemp);
}
在Funcmeister1的try块中,只是把一个0赋给dwTemp变量。这个操作决不会造成异常的引发,所以except块中的代码永远不会执行。注意这与try-finally行为的不同。在dwTemp被设置成0之后,下一个要执行的指令是return语句。
尽管在结束处理程序的try块中使用return、goto、continue和break语句遭到强烈地反对,但在异常处理程序的try块中使用这些语句不会产生速度和代码规模方面的不良影响。这样的语句出现在与except块相结合的try块中不会引起局部展开的系统开销。
24.1.2Funcmeister2
让我们修改这个函数,看会发生什么事情:
DWORD Funcmeister2(){
DWORD dwTemp=0;
//1.Do any processing here.

__try{
//2.perform some operation.
dwTemp=5/dwTemp; //Generates an exception
dwTemp+=10; //Never executes
}
__except(EXCEPTION_EXECUTE_HANDLER){
//3.Handle an exception.
MessageBeep(0);

}
//4.continue processing.
Return(dwTemp);
}
Funcmeister2中,try块中有一个指令试图以0来除5。CPU将捕捉这个事件,并引发一个硬件异常。当引发了这个异常时,系统将定位到except块的开头,并计算异常过滤器表达式的值,过滤器表达式的结果值只能是下面三个标识符之一,这些标识符定义在Windows的Excpt.h文件中(见表24-1)。
表24-1 标识符及其定义
标识符 定义为
EXCEPTION_EXECUTE_HANDLER 1
EXCEPTION_CONTINUE_SEARCH 0
EXCEPTION_CONTINUE_EXECUTION -1
下面几节将讨论这些标识符如何改变线程的执行。在阅读这些内容时可参阅图24-1,该图概括了系统如何处理一个异常的情况。

图24-1系统如何处理一个异常
24.2EXCEPTION_EXECUTE_HANDLER
在Funcmeister2中,异常过滤器表达式的值是EXCEPTION_EXECUTE_HANDLER。在这个时候,系统执行一个全局展开(本章后面将讨论),然后执行向except块中代码(异常处理程序代码)的跳转。在except块中代码执行完之后,系统考虑这个要被处理的异常并允许应用程序继续执行。这种机制使Windows应用程序可以抓住错误并处理错误,再使程序继续运行,不需要用户知道错误的发生。但是,当except块执行后,代码将从何处恢复执行?稍加思索,我们就可以想到几种可能性。
第一种可能性是从产生异常的CPU指令之后恢复执行。在Funcmeister2中执行将从对dwTemp加10的指令开始恢复。这看起来像是合理的做法,但实际上,很多程序的编写方式使得当前面的指令出错时,后续的指令不能够继续成功地执行。
第二种可能性是从产生异常的指令恢复执行。
第三种可能性是从except块之后的第一条指令开始恢复执行。这实际是当异常过滤器表达式的值为EXCEPTION_EXECUTE_HANDLER时所发生的事。在except块中的代码结束执行后,控制从except块之后的第一条指令恢复。
24.2.1全局展开
当一个异常过滤器的值为EXCEPTION_EXECUTE_HANDLER时,系统必须执行一个全局展开(globalunwind)。这个全局展开使所有那些在处理异常的try_except块之后开始执行但未完成的try-finally块恢复执行。图24-2是描述系统如何执行全局展开的流程图,在解释后面的例子时,请参阅这个图。
void Func0stimpy1(){
//1.Do any processing here.

__try{
//2.call another function.
Func0Ren1()
//code here never executes.
}
__except(/*6.Evaluate filter.*/EXCEPTION_EXECUTE_HANDLER){
//8.after the unwind .the exception handler executes.
Messagebox(…);
}
//9.Exception handled--continue execution.

}
Void Func0Ren1(){
DWORD dwTemp=0;
//3.do any processing here.

__try{
//4.request permission to access protected data.
Waitforsingleobject(g_hsem,INFINITE);
//5.Modify the data.
//an exception is generated here.
G_dwProtectedData=5/dwTemp;
}
__finally{
//7.global unwind occurs because filter evaluated to EXCEPTION_EXECUTE_HANDLER.
//allow others to use protected data.
releaseSemaphore(g_hSem,1,NULL);
}
//continue processing –never executes.

}
函数FuncOStimpy1和FuncOren1结合起来可以解释SEH最令人疑惑的方面。程序中注释的标号给出了执行的次序,我们现在开始做一些分析。
FuncOStimpy1开始执行,进入它的try块并调用FuncORen1。FuncORen1开始执行,进入它的try块并等待获得信标。当它得到信标,FuncORen1试图改变全局数据变量g_dwProtectedData。但由于除以0而产生一个异常。系统因此取得控制,开始搜索一个与except块相配的try块。因为FuncORen1中的try与同一个finally块相配,所以系统再上溯寻找另外的try块。这里,系统在FuncOStimpy1中找到一个try块,并且发现这个try块与一个except块相配。
系统现在计算与FuncOStimpy1中except块相联的异常过滤器的值,并等待返回值。当系统看到返回值是EXCEPTION_EXECUTE_HANDLER的,系统就在FuncORen1的finally块中开始一个全局展开。注意这个展开是在系统执行FuncOStimpy1的except块中的代码之前发生的。对于一个全局展开,系统回到所有未完成的try块的结尾,查找与finally块相配的try块。在这里,系统发现的finally块是FuncORen1中所包含的finally块。
当系统执行FuncORen1的finally块中的代码时,就可以清楚地看到SEH的作用了。FuncORen1释放信标,使另一个线程恢复执行。如果这个finally块中不包含ReleaseSemaphore的调用,则信标不会被释放。
在finally块中包含的代码执行完之后,系统继续上溯,查找需要执行的未完成finally块。在这个例子中已经没有这样的finally块了。系统到达要处理异常的try-except块就停止上溯。这时,全局展开结束,系统可以执行except块中所包含的代码。
结构化异常处理就是这样工作的。SEH比较难于理解,是因为在代码的执行当中与系统牵扯太多。程序代码不再是从头到尾执行,系统使代码段按照它的规定次序执行。这种执行次序虽然复杂,但可以预料。为了更好地理解这个执行次序,我们再从不同的角度来看发生的事情。当一个过滤器返回EXCEPTION_EXECUTE_HANDLER时,过滤器是在告诉系统,线程的指令指针应该指向except块中的代码。但这个指令指针在FuncORen1的try块里。回忆一下第23章,每当一个线程要从一个try-finally块离开时,必须保证执行finally块中的代码。在发生异常时,全局展开就是保证这条规则的机制。
24.2.3暂停全局展开
通过在finally块里放入一个return语句,可以阻止系统去完成一个全局展开。请看下面的代码:
void FuncMonkey(){
__try{
FuncFish()
}
__except(EXCEPTION_EXECUTE_HANDLER){
Messagebeep(0);
}
MessageBox(…);
}
Void FuncFish(){
FuncPheasant();
MessageBox(…);
}
Void FuncPheasant(){
__try{
Strcpy(NULL,NULL);
}
__finally{
Return;
}
}
在FuncPheasant的try块中,当调用strcpy函数时,会引发一个内存存取异常。当异常发生时,系统开始查看是否有一个过滤器可以处理这个异常。系统会发现在FuncMonkey中的异常过滤器是处理这个异常的,并且系统开始一个全局展开。
全局展开启动,先执行FuncPheasant的finally块中的代码。这个代码块包含一个return语句。这个return语句使系统停止做展开,FuncPheasant将实际返回到FuncFish。然后FuncFish又返回到函数FuncMonkey。FuncMonkey中的代码继续执行,调用MessageBox。
注意FuncMonkey的异常块中的代码从不会执行对MessageBeep的调用。FuncPheasant的finally块中的return语句使系统完全停止了展开,继续执行,就像什么也没有发生。
微软专门设计SEH按这种方式工作。程序员有可能希望使展开停止,让代码继续执行下去。这种方法为程序员提供了一种手段。原则上,应该小心避免在finally块中安排return语句。
24.3EXCEPTION_CONTINUE_EXECUTION
我们再仔细考察一下异常过滤器,看它是如何计算出定义在Excpt.h中的三个异常标识符之一的。在“Funcmeister2”一节中,为简单起见,在过滤器里直接硬编码了标识符EXCEPTION_EXECUTE_HANDLER,但实际上可以让过滤器调用一个函数确定应该返回哪一个标识符。这里是另外一个例子。
Char g_szBuffer[100];
Void FunclinRooseveltl(){
Int x=0;
Char *pchBuffer=NULL;
__try{
*pchBuffer=’J’;
X=5/x;
}
__except(oilFilter1(&pchBuffer)){
MessageBox(NULL,”an exception occurred”,NULL,MB_OK);
}
MessageBox(NULL,”Function completed”,NULL,MB_OK);
}
LONG OilFilter1(char **ppchBuffer){
If (*ppchBuffer==NULL){
*ppchBuffer=g_szBuffer;
Return(EXCEPTION_CONTINUE_EXECUTION);
}
Return(EXCEPTION_EXECUTE_HANDLER);
}
这里,首先遇到的问题是在我们试图向pchBuffer所指向的缓冲区中放入一个字母‘J’时发生的。因为这里没有初始化pchBuffer,使它指向全局缓冲区g_szBuffur。pchBuffer实际指向NULL。CPU将产生一个异常,并计算与异常发生的try块相关联的except块的异常过滤器。在except块中,对OilFilter函数传递了pchBuffer变量的地址。
当OilFilter获得控制时,它要查看*ppchBuffer是不是NULL,如果是,把它设置成指向全局缓冲区g_szBuffer。然后这个过滤器返回EXCEPTION_CONTINUE_EXECUTION。当系统看到过滤器的值是EXCEPTION_CONTINUE_EXECUTION时,系统跳回到产生异常的指令,试图再执行一次。这一次,指令将执行成功,字母‘J’将放在g_szBuffer的第一个字节。
随着代码继续执行,我们又在try块中碰到除以0的问题。系统又要计算过滤器的值。这一次,OilFilter看到*ppchBuffer不是NULL,就返回EXCEPTION_EXECUTE_HANDLER,这是告诉系统去执行except块中的代码。这会显示一个消息框,用文本串报告发生了异常。
如你所见,在异常过滤器中可以做很多的事情。当然过滤器必须返回三个异常标识符之一,但可以执行任何其他你想执行的任务。
24.4EXCEPTION_CONTINUE_SEARCH
迄今为止我们看到的例子都很平常。通过增加一个函数调用,让我们来看看其他方面的问题:
Char g_szBuffer[100];
Void FunclinRoosevelt2(){
Char *pchBuffer=NULL;
__try{
FuncAtude2(pchBuffer);
}
__except(oilFilter2(&pchBuffer)){
MessageBox(…);
}
}
Void FuncAtude2(char *sz){
*sz=0;
}
LONG OilFilter2(char **ppchBuffer){
If (*ppchBuffer==NULL){
*ppchBuffer=g_szBuffer;
Return(EXCEPTION_CONTINUE_EXECUTION);
}
Return(EXCEPTION_EXECUTE_HANDLER);
}
当FunclinRoosevelt2执行时,它调用FuncAtude2并传递参数NULL。当FuncAtude2执行时,引发了一个异常。同前面的例子一样,系统计算与最近执行的try块相关联的异常过滤器的值。在这个例子中,FunclinRoosevelt2中的try块是最近执行的try块,所以系统调用OilFilter2函数来求异常过滤器的值——尽管这个异常是在FunclinAtude2函数中产生的。
现在我们让问题变得更复杂一点,在程序中再增加一个try_except块。
Char g_szBuffer[100];
Void FunclinRoosevelt3(){
Char *pchBuffer=NULL;
__try{
FuncAtude3(pchBuffer);
}
__except(oilFilter3(&pchBuffer)){
MessageBox(…);
}
}
Void FuncAtude3(char *sz){
__try{
*sz=0;
}
__except(EXCEPTION_CONTINUE_SEARCH){
//This never executes.

}
}
LONG OilFilter3(char **ppchBuffer){
If (*ppchBuffer==NULL){
*ppchBuffer=g_szBuffer;
Return(EXCEPTION_CONTINUE_EXECUTION);
}
Return(EXCEPTION_EXECUTE_HANDLER);
}
现在,当FuncAtude3试图向地址NULL里存放0时,会引发一个异常。但这时将执行FuncAtude3的异常过滤器。FuncAtude3的异常过滤器很简单,只是取值EXCEPTION_CONTINUE_SEARCH。这个标识符是告诉系统去查找前面与一个except块相匹配的try块,并调用这个try块的异常处理器。
因为FuncAtude3的过滤器的值为EXCEPTION_CONTINUE_SEARCH,系统将查找前面的try块(在FunclinRoosevelt3里),并计算其异常过滤器的值,这里异常过滤器是OilFilter3。OilFilter3看到pchBuffer是NULL,将pchBuffer设定为指向全局缓冲区,然后告诉系统恢复执行产生异常的指令。这将使FuncAtude3的try块中的代码执行,但不幸的是,FuncAtude3的局部变量sz没有变化,恢复执行失败的指令只是产生另一个异常。这样,又造成死循环。
前面说过,系统要查找最近执行的与except块相匹配的try块,并计算它的过滤器值。这就是说,系统在查找过程当中,将略过那些与finally块相匹配而不是与except块相匹配的try块。
这样做的理由很明显:finally块没有异常过滤器,系统没有什么要计算的。如果前面例子中FuncAtude3包含一个finally块而不是except块,系统将在一开始就通过FunclinRoosevelt3的OilFilter3计算异常过滤器的值。
第25章提供有关EXCEPTION_CONTINUE_SEARCH的更多信息。
24.5GetExceptionCode
一个异常过滤器在确定要返回什么值之前,必须分析具体情况。例如,异常处理程序可能知道发生了除以0引起的异常时该怎么做,但是不知道该如何处理一个内存存取异常。异常过滤器负责检查实际情况并返回适当的值。
可用内部函数GetExceptionCode,他返回一个值,该值指出所发生异常的种类:
DWORD GetExceptionCode();
下面列出所有预定义的异常和相应的含意,这些内容取自PlatformSDK文档。这些异常标识符可以在WinBase.h文件中找到。我们对这些异常做了分类。
1.与内存有关的异常
•EXCEPTION_ACCESS_VIOLATION。线程试图对一个虚地址进行读或写,但没有做适当的存取。这是最常见的异常。
•EXCEPTION_DATATYPE_MISALIGNMENT。线程试图读或写不支持对齐(alignment)的硬件上的未对齐的数据。例如,16位数值必须对齐在2字节边界上,32位数值要对齐在4字节边界上。
•EXCEPTION_ARRAY_BOUNDS_EXCEEDED。线程试图存取一个越界的数组元素,相应的硬件支持边界检查。
•EXCEPTION_IN_PAGE_ERROR。由于文件系统或一个设备启动程序返回一个读错误,造成不能满足要求的页故障。
•EXCEPTION_GUARD_PAGE。一个线程试图存取一个带有PAGE_GUARD保护属性的内存页。该页是可存取的,并引起一个EXCEPTION_GUARD_PAGE异常。
•EXCEPTION_STACK_OVERFLOW。线程用完了分配给它的所有栈空间。
•EXCEPTION_ILLEGAL_INSTRUCTION。线程执行了一个无效的指令。这个异常由特定的CPU结构来定义;在不同的CPU上,执行一个无效指令可引起一个陷井错误。
•EXCEPTION_PRIV_INSTRUCTION。线程执行一个指令,其操作在当前机器模式中不允许。
2.与异常相关的异常
•EXCEPTION_INVALID_DISPOSITION。一个异常过滤器返回一值,这个值不是EXCEPTION_EXECUTE_HANDLER、EXCEPTION_CONTINUE_SEARCH、EXCEPTION_CONTINUE_EXECUTION三者之一。
•EXCEPTION_NONCONTINUABLE_EXCEPTION。一个异常过滤器对一个不能继续的异常返回EXCEPTION_CONTINUE_EXECUTION。
3.与调试有关的异常
•EXCEPTION_BREAKPOINT。遇到一个断点。
•EXCEPTION_SINGLE_STEP。一个跟踪陷井或其他单步指令机制告知一个指令已执行完毕。
•EXCEPTION_INVALID_HANDLE。向一个函数传递了一个无效句柄。
4.与整数有关的异常
•EXCEPTION_INT_DIVIDE_BY_ZERO。线程试图用整数0来除一个整数
•EXCEPTION_INT_OVERFLOW。一个整数操作的结果超过了整数值规定的范围。
5.与浮点数有关的异常
•EXCEPTION_FLT_DENORMAL_OPERAND。浮点操作中的一个操作数不正常。不正常的值是一个太小的值,不能表示标准的浮点值。
•EXCEPTION_FLT_DIVIDE_BY_ZERO。线程试图用浮点数0来除一个浮点。
•EXCEPTION_FLT_INEXACT_RESULT。浮点操作的结果不能精确表示成十进制小数。
•EXCEPTION_FLT_INVALID_OPERATION。表示任何没有在此列出的其他浮点数异常。
•EXCEPTION_FLT_OVERFLOW。浮点操作的结果超过了允许的值。
•EXCEPTION_FLT_STACK_CHECK。由于浮点操作造成栈溢出或下溢。
•EXCEPTION_FLT_UNDERFLOW。浮点操作的结果小于允许的值。
内部函数GetExceptionCode只能在一个过滤器中调用(--except之后的括号里),或在一个异常处理程序中被调用。
但是,不能在一个异常过滤器函数里面调用GetExceptionCode。编译程序会捕捉这样的错误。
异常代码遵循在WinError.h文件中定义的有关错误代码的规则。每个DWORD被划分如表24-2所示。
表24-2一个错误代码的构成
位 31-30 29 28 27-16 15-0
内容 严重性系数 微软/客户 保留 设备代码 异常代码
意义 0=成功 0=微软定义 必须为0 微软定义 微软/客户定义
1=信息 的代码 (见表24-3)
2=警告 1=客户定义
3=错误 的代码
目前,微软定义了下面一些设备代码(见表24-3)。
表24-3设备代码及其值
设备代码 值
FACILITY_NULL 0
FACILITY_RPC 1
FACILITY_DISPATCH 2
FACILITY_STORAGE 3
FACILITY_ITF 4
FACILITY_WIN32 7
FACILITY_WINDOWS 8
FACILITY_SECURITY 9
FACILITY_NULL0FACILITY_CONTROL 10
FACILITY_RPC1FACILITY_CERT 11
FACILITY_DISPATCH2FACILITY_INTERNET 12
FACILITY_STORAGE3FACILITY_MEDIASERVER 13
FACILITY_ITF4FACILITY_MSMQ 14
FACILITY_WIN327FACILITY_SETUPAPI 15
FACILITY_WINDOWS8FACILITY_SCARD 16
FACILITY_SECURITY9FACILITY_COMPLUS 17
我们将EXCEPTION_ACCESS_VIOLATION异常代码拆开来,看各位(bit)都是什么。在WinBase.h中找到EXCEPTION_ACCESS_VIOLATION,它的值为0xC0000005:
C 0 0 0 0 0 0 5 (十六进制)
1100 0000 0000 0000 0000 0000 0000 0101(二进制)
第30位和第31位都是1,表示存取异常是一个错误(线程不能继续运行)。第29位是0,表示Microsoft已经定义了这个代码。第28位是0,留待后用。第16位至27位是0,代表FACILITY_NULL(存取异常可发生在系统中任何地方,不是使用特定设备才发生的异常)。第0位到第15位包含一个数5,表示微软将存取异常这种异常的代码定义成5。
24.6GetExceptionInformation
当一个异常发生时,操作系统要向引起异常的线程的栈里压入三个结构,这三个结构是:EXCEPTION_RECORD结构、CONTEXT结构和EXCEPTION_POINTERS结构。
EXCEPTION_RECORD结构包含有关已发生异常的独立于CPU的信息,CONTEXT结构包含已发生异常的依赖于CPU的信息。EXCEPTION_POINTERS结构只有两个数据成员,二者都是指针,分别指向被压入栈的EXCEPTION_RECORD和CONTEXT结构:
Typedef struct _EXCEPTION_POINTERS{
Pexception_record exceptionrecord;
Pcontext contextrecord;
}EXCEPTON_POINTERS,*PEXCEPTION_POINTERS;
为了取得这些信息并在你自己的程序中使用这些信息,需要调用GetExceptionInformation函数:PEXCEPTION_POINTERS GetExceptionInformation();
这个内部函数返回一个指向EXCEPTION_POINTERS结构的指针。
关于GetExceptionInformation函数,要记住的最重要事情是它只能在异常过滤器中调用,因为仅仅在处理异常过滤器时,CONTEXT、EXCEPTION_RECORD和EXCEPTION_POINTERS才是有效的。一旦控制被转移到异常处理程序,栈中的数据就被删除。
如果需要在你的异常处理程序块里面存取这些异常信息(虽然很少有必要这样做),必须将EXCEPTION_POINTERS结构所指向的CONTEXT数据结构和/或EXCEPTION_RECORD数据结构保存在你所建立的一个或多个变量里。
24.7软件异常
迄今为止,我们一直在讨论硬件异常,也就是CPU捕获一个事件并引发一个异常。在代码中也可以强制引发一个异常。这也是一个函数向它的调用者报告失败的一种方法。传统上,失败的函数要返回一些特殊的值来指出失败。函数的调用者应该检查这些特殊值并采取一种替代的动作。通常,这个调用者要清除所做的事情并将它自己的失败代码返回给它的调用者。这种错误代码的逐层传递会使源程序的代码变得非常难于编写和维护。
另外一种方法是让函数在失败时引发异常。用这种方法,代码更容易编写和维护,而且也执行得更好,因为通常不需要执行那些错误测试代码。实际上,仅当发生失败时也就是发生异常时才执行错误测试代码。
但令人遗憾的是,许多开发人员不习惯于在错误处理中使用异常。这有两方面的原因。第一个原因是多数开发人员不熟悉SEH。即使有一个程序员熟悉它,但其他程序员可能不熟悉它。如果一个程序员编写了一个引发异常的函数,但其他程序员并不编写SEH框架来捕获这个异常,那么进程就会被操作系统结束。
开发人员不使用SEH的第二个原因是它不能移植到其他操作系统。许多公司的产品要面向多种操作系统,因此希望有单一的源代码作为产品的基础,这是可以理解的。SEH是专门针对Windows的技术。
本段讨论通过异常返回错误有关的内容。首先,让我们看一看WindowsHeap函数,例如HeapCreate、heapAlloc等。回顾第18章的内容,我们知道这些函数向开发人员提供一种选择。通常当某个堆(heap)函数失败,它会返回NULL来指出失败。然而可以对这些堆函数传递HEAP_GENERATE_EXCEPTIONS标志。如果使用这个标志并且函数失败,函数不会返回NULL,而是由函数引发一个STATUS_NO_MEMORY软件异常,程序代码的其他部分可以用SEH框架来捕获这个异常。
如果想利用这个异常,可以编写你的try块,好像内存分配总能成功。如果内存分配失败,可以利用except块来处理这个异常,或通过匹配try块与finally块,清除函数所做的事。这非常方便。
程序捕获软件异常采取的方法与捕获硬件异常完全相同。也就是说,前一章介绍的内容可以同样适用于软件异常。
本节重讨论如何让你自己的函数引发软件异常,作为指出失败的方法。实际上,可以用类似于微软实现堆函数的方法来实现你的函数:让函数的调用者传递一个标志,告诉函数如何指出失败。
引发一个软件异常很容易,只需要调用RaiseException函数:
VOID RaiseException{
DWORD dwExceptionCode,
DWORD dwExceptionFlags,
DWORD nNumberOfarguments,
CONST ULONG_PTR *parguments);
第一个参数dwExceptionCode是标识所引发异常的值。HeapAlloc函数对这个参数设定STATUS_NO_MEMORY。如果程序员要定义自己的异常标识符,应该遵循标准Windows错误代码的格式,像WinError.h文件中定义的那样。参阅表24-1。
如果要建立你自己的异常代码,要填充DWORD的4个部分:
•第31位和第30位包含严重性系数(severity)。
•第29位是1(0表示微软建立的异常,如HeapAlloc的STATUS_NO_MEMORY)。
•第28位是0。
•第27位到16位是某个微软定义的设备代码。
•第15到0位是一个任意值,用来标识引起异常的程序段。
RaiseException的第二个参数dwExceptionFlags,必须是0或EXCEPTION_NONCONTINUABLE。本质上,这个标志是用来规定异常过滤器返回EXCEPTION_CONTINUE_EXECUTION来响应所引发的异常是否合法。如果没有向RaiseException传递EXCEPTION_NONCONTINUABLE参数值,则过滤器可以返回EXCEPTION_CONTINUE_EXECUTION。正常情况下,这将导致线程重新执行引发软件异常的同一CPU指令。但微软已做了一些动作,所以在调用RaiseException函数之后,执行会继续进行。
如果你向RaiseException传递了EXCEPTION_NONCONTINUABLE标志,你就是在告诉系统,你引发异常的类型是不能被继续执行的。这个标志在操作系统内部被用来传达致命(不可恢复)的错误信息。另外,当HeapAlloc引发STATUS_NO_MEMORY软件异常时,它使用EXCEPTION_NONCONTINUABLE标志来告诉系统,这个异常不能被继续。意思就是没有办法强制分配内存并继续运行。
如果一个过滤器忽略EXCEPTION_NONCONTINUABLE并返回EXCEPTION_CONTINUE_EXECUTION,系统会引发新的异常:EXCEPTION_NONCONTINUABLE_EXCEPTION。
当程序在处理一个异常的时候,有可能又引发另一个异常。比如说,一个无效的内存存取有可能发生在一个finally块、一个异常过滤器、或一个异常处理程序里。当发生这种情况时,系统压栈异常。回忆一下GetExceptionInformation函数。这个函数返回EXCEPTION_POINTERS结构的地址。EXCEPTION_POINTERS的ExceptionRecord成员指向一个EXCEPTION_RECORD结构,这个结构包含另一个ExceptionRecord成员。这个成员是一个指向另外的EXCEPTION_RECORD的指针,而这个结构包含有关以前引发异常的信息。
通常系统一次只处理一个异常,并且ExceptionRecord成员为NULL。然而如果处理一个异常的过程中又引发另一个异常,第一个EXCEPTION_RECORD结构包含有关最近引发异常的信息,并且这个EXCEPTION_RECORD结构的ExceptionRecord成员指向以前发生的异常的EXCEPTION_RECORD结构。如果增加的异常没有完全处理,可以继续搜索这个EXCEPTION_RECORD结构的链表,来确定如何处理异常。
RaiseException的第三个参数nNumberOfArguments和第四个参数pArguments,用来传递有关所引发异常的附加信息。通常,不需要附加的参数,只需对pArguments参数传递NULL,这种情况下,RaiseException函数忽略nNumberOfArguments参数。如果需要传递附加参数,nNumberOfArguments参数必须规定由pArguments参数所指向的ULONG_PTR数组中的元素数目。这个数目不能超过EXCEPTION_MAXIMUM_PARAMETERS,EXCEPTION_MAXIMUM_PARAMETERS在WinNT.h中定义成15。
在处理这个异常期间,可使异常过滤器参照EXCEPTION_RECORD结构中的NumberParameters和ExceptionInformation成员来检查nNumberOfArguments和pArguments参数中的信息。
你可能由于某种原因想在自己的程序中产生自己的软件异常。例如,你可能想向系统的事件日志发送通知消息。每当程序中的一个函数发现某种问题,你可以调用RaiseException并让某些异常处理程序上溯调用树查看特定的异常,或者将异常写到日志里或弹出一个消息框。你还可能想建立软件异常来传达程序内部致使错误的信息。

第25章未处理异常和C++异常
前一章讨论了当一个异常过滤器返回EXCEPTION_CONTINUE_SEARCH时会发生什么事情。返回EXCEPTION_CONTINUE_SEARCH是告诉系统继续上溯调用树,去寻找另外的异常过滤器。但是当每个过滤器都返回EXCEPTION_CONTINUE_SEARCH时会出现什么情况呢?
在这种情况下,就出现了所谓的“未处理异常”(Unhandledexception)。
在第6章里我们已经知道,每个线程开始执行,实际上是利用Kernel32.dll中的一个函数来调用BaseProcessStart或BaseThreadStart。这两个函数实际是一样的,区别在于一个函数用于进程的主线程(Primarythread):
VOID BaseProcessStart(PPROCESS_START_ROUTINE pfnStartAddr){
__try{
ExitThread((pfnStartAddr)());
}
__except(UnhandledExceptionFilter(GetExceptionInformation())){
ExitProcess(GetExceptionCode());
}
//NOTE:we never get here
}
另一个函数用于进程的所有辅助线程(Secondarythread):
VOID BaseThreadStart(PPROCESS_START_ROUTINE pfnStartAddr,PVOID pvParam){
__try{
ExitThread((pfnStartAddr)(pvParam));
}
__except(UnhandledExceptionFilter(GetExceptionInformation())){
ExitProcess(GetExceptionCode());
}
//NOTE:we never get here
}
注意这两个函数都包含一个SEH框架。每个函数都有一个try块,并从这个try块里调用主线程或辅助线程的进入点函数。所以,当线程引发一个异常,所有过滤器都返回EXCEPTION_CONTINUE_SEARCH时,将会自动调用一个由系统提供的特殊过滤器函数:UnhandledExceptionFilter。这个函数负责显示一个消息框,指出有一个进程的线程存在未处理的异常,并且能让用户结束或调试这个进程。
在消息框中的异常描述之后,又提供给用户两种选择。第一种选择是点击OK按钮,这将导致UnhandledExceptionFilter返回EXCEPTION_EXECUTE_HANDLER。这又引起全局展开的发生,所有的finally块都要执行,然后BaseProcessStart或BaseThreadStart中的处理程序执行。
这两个处理程序都叫ExitProcess,意思是退出进程,这就是程序结束的原因。注意进程的退出代码就是异常代码。还要注意是进程的线程废(kill)了进程本身,而不是操作系统。这也意味着程序员可以控制这种行为并可以改变它。
第二种选择是点击Cancel按钮。在这里程序员的梦想成真。当点击Cancel按钮时,UnhandledExceptionFilter试图加载一个调试程序,并将这个调试程序挂接在进程上。通过将调试程序附在进程上,可以检查全局、局部和静态变量的状态,设置断点,检查调用树,重新启动进程,以及调试一个进程时可以做的任何事情。
这里真正的好处是,你可以在程序运行当中错误发生时就处理错误。在其他操作系统中,必须在调试程序调用程序时才能对其进行调试。在那些操作系统中,如果一个进程中发生了一个异常,必须结束这个进程,启动调试程序,调用程序,再使用调试程序。这样,你必须重新产生这个错误,才有可能去修正它。但谁能记住问题最初发生时的各种条件和各变量的值?按这种方式解决程序错误问题非常困难。将调试程序动态挂接在运行中的进程上,是Windows最好的特性之一。
Windows2000本书着重讨论用户方式(user-mode)的程序开发。但对于运行在内核方式(kernel-mode)的线程引发的未处理异常会造成什么情况,读者也许会感兴趣。内核方式中的异常同用户方式中的异常是按同样方式处理的。如果一个低级虚拟内存函数产生一个异常,系统查找是否有内核方式异常过滤器准备处理这个异常。如果系统找不到一个异常过滤器来处理这个异常,则异常就是未处理的。对于内核方式的异常,未处理异常是在操作系统中或(更可能)在设备驱动程序中,而不是在应用程序中。这样一个未处理异常表示一个严重的程序错误(bug)!
如果一个未处理异常发生在内核方式,让系统继续运行是不安全的。所以系统在这种情况下不去调用UnhandledExceptionFilter函数,而是显示所谓的蓝屏死机(BlueScreenofDeath)。显示画屏切换到只包含文本的蓝屏视频方式,并且计算机被停机(balt)。显示的文本告诉是哪个设备驱动程序被加载,并且该模块中包含有引发未处理异常的代码。用户应该记下这些信息并送交微软或设备驱动程序的厂商,以便修复这个错误。因为计算机被挂起,要想再做其他事情就必须重新启动计算机,所有未保存的工作都丢失了。
25.1即时调试
随时将调试程序连接到任何进程的能力称为即时调试(Just-in-timeDebugging)。这里我们对它如何工作稍加说明:当程序员点击Cancel按钮,就是告诉UnhandledExceptionFilter函数对进程进行调试。
在内部,UnhandledExceptionFilter调用调试程序,这需要查看下面的注册表子关键字:
HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\AeDebug
在这个子关键字里,有一个名为Debugger的数值,在安装VisualStudio时被设置成下面的值:
"C:\Program Files\Microsoft Visual Studio\Common\MSDev98\Bin\msdev.exe" -p %ld -e %ld
Windows98在Windows98中,这些值不是存放在注册表中,而是存放在Win.ini文件中。
这一行代码是告诉系统要将哪一个程序(这里是MSDev.exe)作为调试程序运行。当然也可以选择其他调试程序。UnhandledExceptionFilter还在这个命令行中向调试程序传递两个参数。第一个参数是被调试进程的ID。第二个参数规定一个可继承的手工复位事件,这个事件是由UnhandledExceptionFilter按无信号状态建立的。厂商必须实现他们的调试程序,这样才能认识指定进程ID和事件句柄的-p和-e选项。
在进程ID和事件句柄都合并到这个串中之后,UnhandledExceptionFilter通过调用CreateProcess来执行调试程序。这时,调试程序进程开始运行并检查它的命令行参数。如果存在-p选项,调试程序取得进程ID,并通过调用DebugActiveProcess将自身挂接在该进程上。
BOOL DebugActiveProcess(DWORD dwProcessID);
一旦调试程序完成自身的挂接,操作系统将被调试者(debuggee)的状态通报给调试程序。
在调试程序完全初始化之后,它要再检查它的命令行,找-e选项。如果该选项存在,调试程序取得相应的事件句柄并调用SetEvent。调试程序可以直接使用事件的句柄值,因为事件句柄具有创建的可继承性,并且被调试进程对UnhandledExceptionFilter函数的调用也使调试程序进程成为一个子进程。
设定这个事件将唤醒被调试进程的线程。被唤醒的线程将有关未处理异常的信息传递给调试程序。调试程序接收这些通知并加载相应的源代码文件,再将自身放在引发异常的指令位置上。
还有,不必在调试进程之前等待异常的出现。可以随时将一个调试程序连接在任何进程上,只需运行“MSDEV-pPID”,其中PID是要调试的进程的ID。实际上,利用Windows2000 TaskManager,做这些事很容易。当观察Process标记栏时,可以选择一个进程,点击鼠标右键,并选择Debug菜单选项。这将引起TaskManager去查看前面讨论过的注册表子关键字,调用CreateProcess,并传递所选定的进程的ID作为参数。在这里,TaskManager为事件句柄传送0值。
25.2关闭异常消息框
有时候,在异常发生时,你可能不想在屏幕上显示异常消息框。可以使用几种不同的方法来防止这种消息框的出现。
25.2.1强制进程终止运行
为防止UnhandledExceptionFilter显示异常消息框,可以调用下面的SetErrorModel函数,并向它传递一个SEM_NOGPFAULTERRORBOX标识符:
UINT SetErrorMode(UINT fuErrorMode);
然后,当调用UnhandledExceptionFilter函数来处理异常时,看到已经设置了这个标志,就会立即返回EXCEPTION_EXECUTE_HANDLER。这将导致全局展开并执行BaseProcessStart或BaseThreadStart中的处理程序。该处理程序结束进程。
在这种方式下,用户完全没有得到警告,程序只是自行消失。
25.2.2包装一个线程函数
使用另外一种办法也可以避免出现这个消息框,就是针对主线程进入点函数(main、wmain、WinMain或wWinMain)的整个内容安排一个try-except块。保证异常过滤器的结果值总是EXCEPTION_EXECUTE_HANDLER,这样就保证异常得到处理,防止了系统再调用UnhandledExceptionFilter函数。
在你的异常处理程序中,你可以显示一个对话框,在上面显示一些有关异常的诊断信息。用户可以记录下这些信息,并通报给你公司的客户服务部门,以便能够找到程序的问题根源。你应该建立这个对话框,这样用户只能结束程序而不能调用调试程序。
这种方法的缺点是它只能捕捉进程的主线程中发生的异常。如果其他线程在运行,并且其中有一个线程发生了一个未处理异常,系统就要调用内部的UnhandledExceptionFilter函数。为了改正这一点,需要在所有的辅助线程进入点函数中包含try-except块。
25.2.3包装所有的线程函数
Windows还提供另外一个函数,SetUnhandledExceptionFilter,利用它可以按SEH格式包装所有的线程函数:
PTOP_LEVEL_EXCEPTION_FILTER SetUnhandledExceptionFilter(
PTOP_LEVEL_EXCEPTION_FILTER pTopLevelExceptionFilter);
在进程调用这些函数之后,进程的任何线程中若发生一个未处理的异常,就会导致调用程序自己的异常过滤器。需要将这个过滤器的地址作为参数传递给SetUnhandledExceptionFilter。
过滤器函数原型必须是下面的样子:
LONG UnhandledExceptionFilter(PEXCEPTION_POINTERS pExceptionInfo);
你可能会注意到这个函数同UnhandledExceptionFilter函数的形式是一样的。程序员可以在自己的异常过滤器中执行任何想做的处理,但要返回三个EXCEPTION_*标识符中的一个。表25-1给出了当返回各标识符时所发生的事。
表25-1返回标识符时的情况
标识符 出现的情况
EXCEPTION_EXECUTE_HANDLER 进程只是结束,因系统在其异常处理程序块中没有执行任何动作
EXCEPTION_CONTINUE_EXECUTION 从引起异常的指令处继续执行。可以参照PEXCEPTION_POINTERS参数修改异常信息
EXCEPTION_CONTINUE_SEARCH 执行正规的WindowsUnhandledExceptionFilter函数

为了使UnhandledExceptionFilter函数再成为默认的过滤器,可以调用SetUnhandledExceptionFilter并传递NULL给它。而且,每当设置一个新的未处理的异常过滤器时,SetUnhandledExceptionFilter就返回以前安装的异常过滤器的地址。如果UnhandledExceptionFilter是当前所安装的过滤器,则这个返回的地址就是NULL。如果你自己的过滤器要返回EXCEPTION_CONTINUE_SEARCH,你就应该调用以前安装的过滤器,其地址通过SetUnhandledExceptionFilter函数返回。
25.2.4自动调用调试程序
现在再介绍关闭UnhandledExceptionFilter消息框的最后一种方法。在前面提到的同一个注册表子关键字里,还有另外一个数据值,名为Auto。这个值用来规定UnhandledExceptionFilter是应该显示消息框,还是仅启动调试程序。如果Auto设置成1,UnhandledExceptionFilter就不显示消息框向用户报告异常,而是立即调用调试程序。如果Auto子关键设置成0,UnhandledExceptionFilter就显示异常消息框,并按前面描述的那样操作。
25.3程序员自己调用UnhandledExceptionFilter
UnhandledExceptionFilter函数是一个公开的、文档完备的Windows函数,程序员可以直接在自己的代码中调用这个函数。
25.4UnhandledExceptionFilter函数的一些细节
下面所列的步骤详细描述了UnhandledExceptionFilter函数的内部执行情况:
1)如果发生一个存取违规并且是由于试图写内存(相对于读内存)引起的,系统要查看你是不是要修改一个.exe模块或DLL模块中的资源。默认时,资源是(而且应该是)只读的。试图修改资源会引起存取异常。然而16位Windows允许修改资源,从兼容性考虑,32位和64位Windows也应该允许修改资源。所以当想要修改资源的时候,UnhandledExceptionFilter调用VirtualProtect,将资源页上的保护改成PAGE_READWRITE,并返回EXCEPTION_CONTINUE_EXECUTION。
2)如果你已经调用SetUnhandledExceptionFilter指定了你自己的过滤器,UnhandledExceptionFilter就调用你自己的过滤器函数。如果你自己的过滤器函数返回EXCEPTION_EXECUTE_HANDLER或EXCEPTION_CONTINUE_EXECUTION,UnhandledExceptionFilter就将这个值返回给系统。如果你没有设置你自己的未处理异常过滤器,或者你的未处理异常过滤器返回EXCEPTION_CONTINUE_SEARCH,转到第3步继续处理。
Windows98Windows98有这样的错误(bug):如果进程不在调试中,系统只调用程序自己的未处理异常过滤器函数。
3)如果你的进程是在调试程序下运行的,就返回EXCEPTION_CONTINUE_SEARCH。你可能会对此感到不解,因系统已经为线程执行了最高层的try或except框架,再往高层已经没有其他的异常过滤器可搜索。当系统看到最高层过滤器返回EXCEPTION_CONTINUE_SERCH时,系统知道要同调试程序联系并告诉调试程序,被调试程序只是有一个未处理异常。作为回答,调试程序显示一个消息框并允许你调试进程(注意,IsDebuggerPresent函数用来确定一个进程是否正在被调试)。
4)如果进程中的一个线程以SEM_NOGPFAULTERRORBOX标志为参数调用SetErrorMode,UnhandledExceptionFilter就返回EXCEPTION_EXECUTE_HANDLER。
5)如果进程在一个作业(job)里并且作业的限制信息设定了JOB_OBJECT_LIMIT_DIE_ON_UNHANDLED_EXCEPTION标志,则UnhandledExceptionFilter返回EXCEPTION_EXECUTE_HANDLER。
Windows98Windows98不支持作业,所以这一步略去。
6)UnhandledExceptionFilter查阅注册表并取出Auto值。如果这个值是1,跳到第7步。如果这个值是0,向用户显示一个消息框。这个消息框指出引发了什么异常。如果注册表子关键字也包含这个Debugger值,消息框有OK按钮和Cancel按钮。如果注册表子关键字没有Debugger值,消息框只包含OK按钮。如果用户点击OK按钮,UnhandledExceptionFilter返回EXCEPTION_EXECUTE_HANDLER。如果Cancel按钮可用并且用户按了这个按钮,转到第7步继续处理。
Windows98 在Windows98中,这些值不是保存在注册表里,而是保存在Win.ini文件里。
7)UnhandledExceptionFilter现在要产生调试程序。它首先调用CreateEvent建立一个无信号的、手工复位的事件。这个事件的句柄是可继承的。然后它从注册表中取出Debugger值,调用sprintf把它粘贴到进程ID(通过调用GetCurrentProcessID函数得到)和事件句柄里。
STARTUPINFO的lpDesktop成员也设置成“Winsta0\\Default”,这样调试程序就出现在交互式桌面上。调用CreateProcess,其中fInheritHandles参数设置成TRUE,这个函数再调用调试程序进程并允许它继承事件对象的句柄。UnhandledExceptionFilter通过以事件句柄为参数调用WaitForSingleObjectEx,等待调试程序初始化。注意这里是用WaitForSingleObjectEx函数而不是WaitForSingleObject函数,所以线程是在可报警状态等待。这样就可以处理线程的任何排队的同步过程调用(APC)。
8)当调试程序完成初始化时,就设置事件句柄,这将唤醒UnhandledExceptionFilter中的线程。现在进程就在调试程序之下运行,UnhandledExceptionFilter返回EXCEPTION_CONTINUE_SEARCH。注意这就是第3步所发生的事。
25.5异常与调试程序
MicrosoftVisualC++调试程序支持调试异常。当一个进程的线程引起一个异常,操作系统立即通知调试程序(如果挂接了一个调试程序)。这个通知被称作最初机会通知(first-chancenotificontion)。正常情况下,调试程序要响应最初机会通知,告诉线程去搜索异常过滤器。如果所有的异常过滤器都返回EXCEPTION_CONTINUE_SEARCH,操作系统再通知调试程序,称为最后机会通知(last-chancenotification)。使用这两种通知是为了使软件开发人员对调试异常有更多的控制能力。
25.6C++异常与结构性异常的对比
笔者常碰到开发人员询问,在开发程序时应该使用结构化异常处理,还是使用C++异常处理?本节对此作出回答。
首先要提醒读者,SEH是可用于任何编程语言的操作系统设施,而异常处理只能用于编写C++代码。如果你在编写C++程序,你应该使用C++异常处理而不是结构化异常处理。理由是C++异常处理是语言的一部分,编译器知道C++类对象是什么。也就是说编译器能够自动生成代码来调用C++对象析构函数,保证对象的清除。
但是也应该知道,MicrosoftVisualC++编译器已经利用操作系统的结构化异常处理实现了C++异常处理。所以当你建立一个C++try块时,编译器就生成一个SEH__try块。一个C++catch测试变成一个SEH异常过滤器,并且catch中的代码变成SEH__except块中的代码。实际上,当你写一条C++throw语句时,编译器就生成一个对Windows的RaiseException函数的调用。用于throw语句的变量传递给RaiseException作为附加的参数。
注意由于C++异常在内部是由结构性异常实现的,所以可以在一个程序中使用两种机制。例如,当引起存取违规时,我乐意使用虚拟内存来提交存储区。而C++语言完全不支持这种类型的再恢复异常处理(resumptiveexceptionhandling)。我可以在我的代码的某些部分使用结构化处异常处理。在这些部分需要利用这种结构的优点,可使我自己的__except过滤器返回EXCEPTION_CONTINUE_EXECUTION。对于代码的其他部分,如果不要求这种再恢复异常处理,我还是使用C++异常处理。



<< Home

This page is powered by Blogger. Isn't yours?