admin管理员组

文章数量:1543652

第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\Windows NT\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函数来安装的:
HHOOK hHook=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函数的虚拟内存地址。这个地址可以使用下面的公式来确定:
GetMsgProc B=hinstDll B+(GetMsgProc A – hinstDll A)
将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的映像,方法是调用下面的函数:BOOL UnhookWindowsHookEx(HHOOK hhook);
当一个线程调用UnhookWindowsHookEx函数时,系统将遍历它必须将DLL插入到的各个进程的内部列表,并且对DLL的自动跟踪计数进行递减。当自动跟踪计数递减为0时,DLL就自动从进程的地址空间中被删除。应该记得,就在系统调用GetMsgProc函数之前,它对DLL的自动跟踪计数进行了递增(见上面的第6个步骤)。这可以防止产生内存访问违规。如果该自动跟踪计数没有递增,那么当进程B的线程试图执行GetMsgProc函数中的代码时,系统中运行的另一个线程就可以调用UnlookWindowsHookEx函数。
这一切意味着不能撤消该窗口的子类并且立即撤消该挂钩。该挂钩必须在该子类的寿命期内保持有效状态。

22.4使用远程线程来插入DLL
插入DLL的第三种方法是使用远程线程。这种方法具有更大的灵活性。它要求你懂得若干个Windows特性、如进程、线程、线程同步、虚拟内存管理、DLL和Unicode等(如果对这些特性不清楚,请参阅本书中的有关章节)。Windows的大多数函数允许进程只对自己进行操作。这是很好的一个特性,因为它能够防止一个进程破坏另一个进程的运行。但是,有些函数却允许一个进程对另一个进程进行操作。这些函数大部分最初是为调试程序和其他工具设计的。不过任何函数都可以调用这些函数。
这个DLL插入方法基本上要求目标进程中的线程调用LoadLibrary函数来加载必要的DLL。由于除了自己进程中的线程外,我们无法方便地控制其他进程中的线程,因此这种解决方案要求我们在目标进程中创建一个新线程。由于是自己创建这个线程,因此我们能够控制它执行什么代码。幸好,Windows提供了一个称为CreateRemoteThread的函数,使我们能够非常容易地在另一个进程中创建线程:
HANDLE CreateRemoteThread(
HANDLE hProcess,
PSECURITY_ATTRIBUTES psa,
DWORD dwStackSize,
PTHREAD_START_ROUTINE pfnStartAddr,
PVOID pvParam,
DWORD fdwCreate,
PDWORD pdwThreadId);
CreateRemoteThread与CreateThread很相似,差别在于它增加了一个参数hProcess。该参数指明拥有新创建线程的进程。参数pfnStartAddr指明线程函数的内存地址。当然,该内存地址与远程进程是相关的。线程函数的代码不能位于你自己进程的地址空间中。
注意在Windows2000中,更常用的函数CreateThread是在内部以下面的形式来实现的:
HANDLE CreateThread( PSECURITY_ATTRIBUTES psa, DWORD dwStackSize, PTHREAD_START_ROUTINE pfnStartAddr, PVOID pvParam, DWORD fdwCreate, PDWORD pdwThreadId){
return(CreateRemoteThread(GetCurrentProcess(),psa, dwStackSize, pfnStartAddr, pvParam, fdwCreate, pdwThreadId));
}
Windows98在Windows98中,CreateRemoteThread函数不存在有用的实现代码,它只是返回NULL。调用GetLastError函数将返回ERROR_CALL_NOT_IMPLEMENTED(CreateThread函数包含用于在调用进程中创建线程的完整的实现代码)。由于CreateRemoteThread没有实现,因此,在Windows98下,不能使用本方法来插入DLL。
好了,现在你已经知道如何在另一个进程中创建线程了,但是,如何才能让该线程加载我们的DLL呢?答案很简单,那就是需要该线程调用LoadLibrary函数:
HINSTANCE LoadLibrary(PCTSTR pszLibFile);
实际上有两个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{
//Guarded body

}
__finally{
//Termination handler

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

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

__try {
//2.Request permission to access protected data,and then use it.
WaitForSingleObject(g_hsem,INFINITE);
G_dwprotectedData=5;
dwtemp=g_dwprotectedData;
//return the new value.
Return(dwTemp);
}
__finally{
//3.Allow others to use protected data.
ReleaseSemaphore(g_hsem,1,NULL);
}
//4.continue processing.—this code will never execute in this version.
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关键字,它有助于避免编写引起局部展开的代码。<

本文标签: 挂接dllAPI