《WINDOWS 环境下32位汇编语言程序设计》第14章 异常处理
14.1 异常处理的用途
对程序执行中的异常(Exception)大家都不陌生,先来回顾一下DOS操作系统对异常的处理方法。在DOS操作系统中,当操作系统在运行中发生“异常”时,会去调用INT 24h中断,系统默认的INT 24h中断处理程序会将出错代码翻译成文本信息显示在屏幕上,然后让用户选择“Ignore”,“Retry”,“Fail”或者“Abort”,并根据用户的输入结果来选择不同的操作——忽略异常、重试产生异常的操作或者强行终止程序的执行。
系统默认的异常处理方法有时候不是很合适,比如,对于在图形方式下运行的DOS程序来说,系统显示的出错信息会破坏屏幕的美观;另外,程序可能希望不要向用户提供“Abort”选项来保证程序不会因为一些微不足道的错误而被终止。为了处理这些情况,程序可以用自定义的异常处理程序来替换系统默认的处理程序,而这是很容易实现的——由于DOS系统在检测到异常的时候仅仅去调用24h号中断并根据中断的返回值决定下一步的处理方式,只要应用程序截获INT 24h中断,就可以在自己提供的中断服务程序中按照自己的意图决定系统处理异常的走向。
Windows操作系统对异常的处理流程相对比较复杂,与DOS操作系统相比,最大的区别在于DOS的异常处理是被动的,一般仅用来处理操作系统内部的异常,对于其他层次的异常是无法处理的,比如,使用INT 21h去读盘的时候发生错误会激发INT 24h中断,但在BIOS服务程序级别用INT 13h去读盘时发生错误就不会激发INT 24h中断,对应用程序胡作非为引发的异常更是束手无策;而Windows的异常处理机制是依靠80x86处理器的保护机制来主动捕获异常,所以Win32下异常处理程序的用途不仅仅局限于防止程序被Windows野蛮地终止,合理利用它们可以让有些功能的实现方式变得更加简单,一般来说,可以在下面这些情况下使用异常处理程序。
● 用来处理非致命的错误
程序执行中发生某些异常时只需要终止发生异常的模块(或子程序),并没有必要终止整个程序的运行,这时可以在异常处理程序中指定让程序转移到一个“安全”的地方去执行,并在这里完成资源释放、删除临时文件、显示错误提示等扫尾工作后从出错模块返回。
● 处理“计划内”的异常
程序中的有些功能本来就是设计在异常处理模块中实现的。Windows系统中虚拟内存的实现就是一个绝好的例子(如图1.5所示),第10章中介绍的内存映射文件也是以同样的方法实现的。在这些情况下,“异常”是作为一个触发条件使用的。
另外,在Windows API中使用异常处理程序进行参数的合法性检测也是很常见的。一般来说,大部分子程序都需要对输入的参数进行合法性检测,特别是对于指针类型的参数,但是当参数涉及的数据结构太复杂的时候,合法性检测会大大降低程序的效率,这时可以假定参数全部合法并尝试直接使用这些参数,如果异常处理程序没有捕获到错误,那么表示参数是合法的,这样要比在每个步骤中检测参数(或操作结果)的合法性要简洁得多。
● 处理致命错误
虽然捕获到致命错误的时候终止程序是最好的选择,但是程序在退出之前,可以在异常处理程序中进行释放资源、删除临时文件等操作,甚至可以详细记录产生异常的指令位置和环境,以便用来分析产生异常的原因。
显然,Windows中的用户自定义的异常处理函数不会再以INT 24h的方式被调用,读者也可以猜到它必定会以“回调函数”的方式来实现,但是如何写回调函数,回调函数的参数是什么,在什么地方定义回调函数呢?接下来将介绍这些内容。
14.2 使用筛选器处理异常
Windows下的异常处理可以有两种方式:筛选器异常处理和SEH异常处理。
筛选器异常处理的方式是由程序指定一个异常处理回调函数(在下面将统一简称为“回调函数”),当发生异常的时候,系统将调用这个回调函数,并根据回调函数的返回值决定如何进行下一步操作,这种方法和DOS系统中使用INT 24h中断来处理异常的方法是很像的。
在进程范围内,筛选器异常处理回调函数是唯一的,设置了一个新的回调函数后,原来的就失效了。
14.2.1 注册回调函数
可以使用SetUnhandledExceptionFilter函数来设置一个筛选器异常处理回调函数,准确地讲,这个回调函数不是替换了系统默认的异常处理程序,而是在它前面进行了一些预处理,操作的结果还是会被送到系统默认的异常处理程序中去,这个过程就相当于对异常进行一次“筛选”,这正是函数名中“Filter”一词的含义。
SetUnhandledExceptionFilter函数的使用方法是:
invoke SetUnhandledExceptionFilter,offset Handler
mov lpPrevHandler,eax
函数的唯一参数是回调函数的地址,如果地址参数被指定为NULL的话,那么系统将去掉这个“筛子”而直接将异常送往系统默认的异常处理程序。函数的返回值是上一次设置的回调函数的入口地址,如果原来没有安装“筛子”,那么返回值将为NULL。
如果原来已经设置了一个回调函数的话,那么新的回调函数将换掉原来的回调函数,注意:不是在原先的回调函数前面再挂上一个新的回调函数。也就是说,当异常发生的时候,系统调用新的回调函数,在这个函数返回的时候并不会再去调用上一次设置的回调函数。一个形象的比喻就是Windows系统不会用两层筛子去筛东西。
本书所附光盘的Chapter14\TopHandler目录中有一个简单的筛选器异常处理的例子,其中的汇编源代码如下:
; TopHandler.asm
; 最高层异常处理
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
; 使用 nmake 或下列命令进行编译和链接:
; ml /c /coff TopHandler.asm
; Link /subsystem:windows TopHandler.obj
.386
.model flat,stdcall
option casemap:none ; include 文件定义
include c:/masm32/include/windows.inc
include c:/masm32/include/user32.inc
include c:/masm32/include/kernel32.inc
includelib c:/masm32/lib/user32.lib
includelib c:/masm32/lib/kernel32.lib ; 数据段
.data?
lpOldHandler dword ?.const
szMsg byte '异常发生位置:%08X,异常代码:%08X,标志:%08X',0
szSafe byte '回到了安全的地方!',0
szCaption byte '筛选器异常处理的例子',0.code
; Exception Handler 异常处理程序
_Handler proc _lpExceptionPoint local @szBuffer[256]:byte pushad mov esi, _lpExceptionPoint assume esi:ptr EXCEPTION_POINTERS mov edi, [esi].ContextRecord mov esi, [esi].pExceptionRecord assume esi:ptr EXCEPTION_RECORD, edi:ptr CONTEXT invoke wsprintf, addr @szBuffer, addr szMsg, \[edi].regEip, [esi].ExceptionCode, [esi].ExceptionFlags invoke MessageBox, NULL, addr @szBuffer, NULL, MB_OK mov [edi].regEip, offset _SafePlace assume esi:nothing, edi:nothing popad mov eax, EXCEPTION_CONTINUE_EXECUTION ret
_Handler endp start:invoke SetUnhandledExceptionFilter, addr _Handler mov lpOldHandler, eax ;会引发异常的指令xor eax, eax mov dword ptr[eax], 0 ;产生异常,然后_Handler被调用;如果这中间有指令,这些指令将不会被执行!;....
_SafePlace:invoke MessageBox, NULL, addr szSafe, addr szCaption, MB_OK invoke SetUnhandledExceptionFilter, lpOldHandler invoke ExitProcess, 0end start
运行结果:
点击【确定】按钮
程序的入口处使用SetUnhandledExceptionFilter函数将_Handler子程序指定为异常处理回调函数,函数返回的原回调函数地址被保存到lpOldHandler变量中(当然,在这个例子中,这个值肯定为0,程序中进行这一步操作是为了演示保存和恢复的方法),在程序退出之前会再次使用SetUnhandledExceptionFilter函数将这个地址设置回去。
在设置好回调函数后,程序人为地产生一个读异常:在xor eax,eax指令将eax清零以后,mov dword ptr [eax],0指令将导致读写0地址处的内存,而这是不允许的,所以这条指令执行时会产生一个异常,系统将捕获到它并调用程序设置的_Handler子程序来进行预处理。在_Handler子程序中,将跳过产生异常的指令并将程序转移到由_SafePlace标号指定的“安全”位置去执行,这就是接下来要介绍的内容。
14.2.2 异常处理回调函数
筛选器异常处理回调函数的格式如下所示:
_Handler proc lpExceptionInfo
回调函数带一个参数,这个参数是个指针,指向一个包含所发生异常详细信息的EXCEPTION_POINTERS结构,结构定义如下:
EXCEPTION_POINTERS STRUCTpExceptionRecord DWORD ?ContextRecord DWORD ?
EXCEPTION_POINTERS ENDS
要处理一个异常,必须详细了解这个异常的各种信息,EXCEPTION_POINTERS结构中包含的正是这些内容,其中的pExceptionRecord字段指向一个EXCEPTION_RECORD结构,这个结构中包含了异常产生的原因、产生的位置等情况,而ContextRecord字段指向一个CONTEXT结构,结构中记录了异常产生时刻的运行环境。这两个结构的定义在13.3.3节中介绍调试API的时候介绍过。
1.获取产生异常的原因
重新来看一下EXCEPTION_RECORD结构的定义:
EXCEPTION_RECORD STRUCTExceptionCode DWORD ? ;异常事件码ExceptionFlags DWORD ? ;标志pExceptionRecord DWORD ? ;下一个EXCEPTION_RECORD结构地址ExceptionAddress DWORD ?NumberParameters DWORD ?ExceptionInformation DWORD EXCEPTION_MAXIMUM_PARAMETERS dup(?)EXCEPTION_RECORD ENDS
结构中的ExceptionCode字段定义了产生异常的原因,这些原因已经被预定义为一系列以EXCEPTION_开头或者以STATUS_开头的常量。表14.1中列出了一些最常用的异常原因。除了表中列出的原因之外,系统中还定义了许多其他异常原因代码,由于MASM32 SDK软件包中所带的Windows.inc文件中的定义值也不是很详尽,为此笔者整理了一份详细的异常原因代码文档来供读者参考,文档放在本书所附光盘的Chapter14\Exception.inc文件中,文档中定义的代码都是以STATUS_开头的,但它们在数值上与以EXCEPTION_开头的代码是一样的。
由于例子中的mov dword ptr [eax],0指令去写一个没有写权限的地址,所以会引发一个EXCEPTION_ACCESS_VIOLATION异常,从例子程序运行后显示的消息中可以验证这点,读者也可以尝试将这条指令修改为各式各样的错误指令,看看它们引发的异常对应哪个异常原因代码。
表14.1 异常原因代码的列表
异常原因代码的含义是按照数据位划分的,其规则如表14.2所示。
表14.2 异常原因代码各数据位的含义
其中的位27~16定义了设备代码,用来表示异常代码发生在哪个特定的设备中,当前已经定义的设备代码如表14.3所示。
表14.3 异常原因代码中的设备代码定义
举例来讲,由于断点异常和单步中断并不属于程序错误,所以这两种异常代码中的严重性系数10,表示属于警告信息而非错误,但是遇到内存越权访问、除零错误等时候,这两位的值就是11了,表示这个错误让线程无法继续执行。
EXCEPTION_RECORD结构中的ExceptionCode字段定义了异常标志,它由一系列的数据位构成,定义如下:
● 位0——代表发生的异常是否允许被恢复执行。当位0被复位的时候,表示回调函数在对异常进行处理后可以指定让程序继续运行,置位的时候表示这个异常是不可恢复的,这时程序最好进行退出前的扫尾工作并选择终止程序,如果这时非要指定让程序继续执行的话,Windows会再次以EXCEPTION_NONCONTINUABLE_EXCEPTION异常代码调用回调函数。为了程序的可读性,可以通过两个预定义值EXCEPTION_CONTINUABLE(定义为0)和EXCEPTION_NONCONTINUABLE(定义为1)来测试这个标志位。
● 位1——EXCEPTION_UNWINDING标志。表示回调函数被调用的原因是进行展开操作(详见14.3.4节)。
● 位2——EXCEPTION_UNWINDING_FOR_EXIT标志。表示回调函数被调用的原因是进行最终退出前的展开操作。
当处理异常的代码设计得不完善而在运行中引发新的异常时,回调函数会被嵌套调用。在这种情况下,EXCEPTION_RECORD结构中的pExceptionRecord字段会指向下一个EXCEPTION_RECORD结构,这条EXCEPTION_RECORD结构链定义了嵌套发生的多个异常的情况;如果没有嵌套的异常,pExceptionRecord的值为NULL。
结构中的ExceptionAddress字段定义了引发异常的指令的地址。
有了这些信息后,回调函数就可以根据类型来对不同的异常进行不同的处理,比如,对那些“计划内”的异常执行功能性的代码,而发生其他非致命异常的时候转到“安全”位置去执行。
2.修正错误
在筛选器异常处理回调函数被系统调用的时候,参数中指定的EXCEPTION_POINTERS结构中的ContextRecord字段指向一个CONTEXT结构,这个结构中保存了异常发生时刻的运行环境,也就是所有寄存器的值。程序也可以通过这个结构中的regEip字段来得知异常发生的位置。在例子中,程序用一个对话框显示出异常代码、异常标志和CONTEXT结构中的EIP值,对比一下就可以发现,显示的EIP值正是那句产生异常的mov [eax],0指令的地址。
在第12章介绍多线程时,已经介绍过操作系统为每个线程保存单独的寄存器环境和单独的堆栈,那么当异常发生的时候,CONTEXT结构指出的环境会对应哪个线程的环境呢?其实答案很简单:Windows将会在产生异常的线程中运行回调函数,CONTEXT结构对应的是出错线程的环境,回调函数使用的堆栈也是这个线程的堆栈,这很容易理解,因为只有在这样的安排下,回调函数才可能去修复出错线程中的错误。
修正错误的操作反映在对这个CONTEXT结构的修改上,当回调函数修改了结构中的值并返回后,系统会将线程的运行环境设置为新的值,所以要修正某个寄存器中的错误取值,只要修改这个CONTEXT结构就可以了。在例子中,异常处理程序将regEip字段的值修改为_SafePlace标号的地址,这样线程恢复运行时是从_SafePlace标号的地方开始执行的。当然,这样处理后,在错误指令和_SafePlace标号之间的其他指令就不会被执行了。
3.回调函数的返回值
回调函数返回后,Windows执行默认的异常处理程序,这个程序会根据回调函数的返回值决定如何进行下一步动作。
回调函数的返回值可以有3种取值:EXCEPTION_EXECUTE_HANDLER(定义为1)、EXCEPTION_CONTINUE_SEARCH(定义为0)和EXCEPTION_CONTINUE_EXECUTION(定义为-1)。
当返回值是EXCEPTION_EXECUTE_HANDLER时,进程将被终止,但是在终止之前系统不会显示出错提示对话框;当返回值是EXCEPTION_CONTINUE_SEARCH时,系统同样将终止程序的执行,但是在终止前会首先显示出错提示对话框。使用这两种返回值的时候,异常处理程序完成的工作一般是退出前的扫尾工作。
而返回值是EXCEPTION_CONTINUE_EXECUTION时,系统将CONTEXT设置回去并继续执行程序,例子程序中就是这样使用的。
当异常标志中包含EXCEPTION_NONCONTINUABLE标志位时,不应该使用EXCEPTION_ CONTINUE_EXECUTION作为返回值,这样只会引发一个新的异常。(例子程序中为了简化代码,没有判断并处理异常标志为EXCEPTION_NONCONTINUABLE的情况)
14.3 使用SEH处理异常
使用筛选器异常处理程序是最简单的处理异常的方法,但在使用中也存在一些不便之处,最明显的就是不便于模块的封装:由于筛选器异常处理程序是全局性的,无法为一个线程或一个子程序单独设置一个异常处理回调函数,这样就无法将私有的异常处理代码封装进某个模块中。
Windows系统中还提供了另一种在每个线程之间独立的异常处理方法——结构化异常处理(Structured Exception Handling,SEH),SEH是Win32系统中为数不多的应用广泛却又未被公开的特征之一。
SEH和筛选器异常处理之间有一些共同点:首先是两者的异常处理程序都是以回调函数的方式提供的;另外,系统都会根据回调函数的返回值选择不同的操作。
但是它们之间也存在很多的不同点:
● SEH是基于线程的,使用SEH可以为每个线程设置不同的异常处理程序,而且可以为每个线程设置多个异常处理程序。
● 两者的回调函数的调用类型、参数定义和返回值的定义都是不同的。
● 由于SEH使用了与硬件平台相关的数据指针,所以在不同硬件平台中使用SEH的方法会有所不同(也许这正是SEH未被Microsoft公开的原因)。
接下来首先看一个使用SEH处理异常的例子,这个例子与前面的例子很相似,都是在回调函数中显示异常代码和发生异常的位置,并将程序修正到_SafePlace标号去执行。例子的源代码可以在本书所附光盘的Chapter14\SEH01目录中找到,其中的SEH.asm的内容如下:
; SEH.asm ------------- 使用 SEH 进行错误截获
;--------------------------------------------------------------------
; 使用 nmake 或下列命令进行编译和链接:
; ml /c /coff SEH.asm
; Link /subsystem:windows SEH.obj
.386
.model flat,stdcall
option casemap:none ; include 文件定义
include c:/masm32/include/windows.inc
include c:/masm32/include/user32.inc
include c:/masm32/include/kernel32.inc
includelib c:/masm32/lib/user32.lib
includelib c:/masm32/lib/kernel32.lib ; 数据段
.const
szMsg byte '异常发生位置:%08X,异常代码:%08X,标志:%08X',0
szSafe byte '回到了安全的地方!',0
szCaption byte 'SEH例子',0.code
; SEH Handler 异常处理程序
_Handler proc C _lpExceptionRecord, _lpSEH, _lpContext, _lpDispatcherContext local @szBuffer[256]:byte pushad mov esi, _lpExceptionRecord mov edi, _lpContext assume esi:ptr EXCEPTION_RECORD, edi:ptr CONTEXT invoke wsprintf, addr @szBuffer, addr szMsg, \[edi].regEip, [esi].ExceptionCode, [esi].ExceptionFlags invoke MessageBox, NULL, addr @szBuffer, NULL, MB_OK mov [edi].regEip, offset _SafePlace assume esi:nothing, edi:nothing popad mov eax, ExceptionContinueExecution ret
_Handler endp start:;在堆栈中构造一个 EXCEPTION_REGISTRATION 结构assume fs:nothing push offset _Handler push fs:[0]mov fs:[0], esp ;会引发异常的指令xor eax, eax mov dword ptr [eax], 0 ;产生异常,然后_Handler被调用;...;如果这中间有指令,这些指令将不会被执行!;...
_SafePlace:invoke MessageBox, NULL, addr szSafe, addr szCaption, MB_OK ;恢复原来的 SEH 链pop fs:[0]pop eax invoke ExitProcess, 0
end start
运行结果:
点击【确定】按钮
14.3.1 注册回调函数
在例子程序中,SEH异常处理回调函数的设置由下面3条指令完成:
push offset _Handler
push fs:[0]
mov fs:[0],esp
为什么这3句简单的指令就可以完成设置工作,为什么又要使用fs段选择器呢?这要从线程信息块(Thread Information Block/TIB)说起。
Win32为每个线程定义了一个线程信息块,其中保存了线程的一些属性数据,线程信息块的格式被定义为NT_TIB结构:
NT_TIB STRUCTExceptionList dd ? ;SEH链入口StackBase dd ? ;堆栈基址StackLimit dd ? ;堆栈大小SubSystemTib dd ?FiberData dd ?ArbitraryUserPointer dd ?Self dd ? ;本NT_TIB结构自身的线性地址
NT_TIB ENDS
NT_TIB结构的第一个字段ExceptionList指向一个EXCEPTION_REGISTRATION结构,SEH异常处理回调函数的入口地址就是由EXCEPTION_REGISTRATION结构指定的,这个结构的定义如下:
EXCEPTION_REGISTRATION STRUCTprev dd ? ;前一个EXCEPTION_REGISTRATION结构的地址handler dd ? ;异常处理回调函数地址
EXCEPTION_REGISTRATION ENDS
当异常发生时,系统从TIB中取出ExceptionList字段,然后从ExceptionList字段指定的EXCEPTION_REGISTRATION结构中取出handler字段,并根据其中的地址去调用回调函数,整个过程如图14.1所示,所以用户程序想自定义一个异常处理程序的话,只要构建一个新的含有回调函数地址的EXCEPTION_REGISTRATION结构,然后修改TIB中的ExceptionList字段,将其指向这个结构就可以注册一个SEH异常处理回调函数了。
图14.1 SEH异常处理程序入口地址的定义
现在还剩下一个关键的问题:到哪里找TIB呢?答案是:TIB永远放在fs段选择器指定的数据段的0偏移处,所以,fs:[0]的地方就是TIB结构的ExceptionList字段,这个答案对于Windows 9x系统和Windows NT系统都是有效的。由于一个进程中的不同线程可以有不同的环境,所以,在不同线程中fs段选择器可以使用不同的值,这种特征使每个线程都可以设置不同的回调函数。
也正是因为使用了fs段选择器,所以使SEH变得与硬件平台相关,试想一下,Power PC或者Alpha平台上有fs段选择器吗?
好了,现在回过头来看看用于设置回调函数的3条指令,第一条push offset _Handler指令将回调函数的地址推入堆栈;第二条push fs:[0]指令则将原先使用的EXCEPTION_REGISTRATION结构地址推入堆栈,现在堆栈指针esp指向的地方刚好是一个新的EXCEPTION_REGISTRATION结构——[esp]等于原结构地址,也就是prev字段,而[esp+4]等于回调函数地址,也就是handler字段;当第三条指令mov fs:[0],esp将esp的值放入fs:[0]后,设置工作就完成了。
当不再需要这个回调函数的时候,只要将fs:[0]的值恢复为原来的EXCEPTION_REGISTRATION结构地址就可以了,这个地址已经被保存在prev字段中,例子程序中使用下面的恢复代码:
pop fs:[0]
pop eax
第一条指令从堆栈中的prev字段中弹出原来的fs:[0]值;第二条指令pop eax仅仅是为了让堆栈平衡,弹出到eax中的值没有实际用途。执行了这两条指令后,堆栈中废弃的EXCEPTION_REGISTRATION结构也被释放掉了。
例子程序在堆栈中构造EXCEPTION_REGISTRATION结构,而不是将结构放在全局的数据段中,这实际上就是局部变量的使用方法,这使构建异常处理程序使用的数据结构存放在一个子程序的私有空间中,更有利于程序结构的模块化。实际上,所有的高级语言在使用SEH时都将数据结构建立在堆栈中。
由于MASM编译器默认将fs段寄存器定义为error,所以程序在使用fs之前要用assume fs:nothing伪指令来启用fs寄存器,否则编译的时候会产生下面的错误:
error A2108: use of register assumed to ERROR
14.3.2 异常处理回调函数
1.回调函数的参数
SEH异常处理回调函数的参数定义与筛选器回调函数的参数定义有所不同,其定义如下,注意:回调函数的调用类型不是std call的,而是C格式的:
_Handler proc C _lpExceptionRecord,_lpSEH,_lpContext,_lpDispatcherContext
在这个回调函数中,前面的3个参数是要用到的。其中的_lpExceptionRecord参数指向一个EXCEPTION_RECORD结构;_lpContext参数指向一个CONTEXT结构,这两个结构提供的数据就相当于14.2.2节中筛选器回调函数从参数中得到的数据,可以用同样的方法来使用它们;_lpSEH参数指向注册回调函数时使用的EXCEPTION_REGISTRATION结构的地址,在例子程序中,它的值就是我们在堆栈中构造的这个结构的地址,这个参数看上去似乎没有什么用处,例子程序中也确实没有用到它,但是如果希望异常处理程序能够被封装在子程序里面的话,这个参数就是不可缺少的,因为使用它可以避免使用全局变量在模块和回调函数之间传递数据,在接下来的内容中读者会了解到如何做到这一点。
本章的前两个例子为了简便起见,演示的都是在主程序中执行异常指令的情况,现在来考虑产生异常的指令发生在子程序中的情况,演示的指令序列如下所示:
_Test procpushadxor ebp,ebpxor eax,eaxmov dword ptr [eax],0 ;异常指令...
_SafePlace:popadret
_Test endp
这段代码首先将所有的寄存器入栈,然后使用了ebp寄存器和eax寄存器,最后结束的时候使用popad从堆栈中恢复所有的寄存器。如果不产生异常的话,那么指令执行完毕以后,ebp的值是正常的,堆栈也是平衡的;但是如果中间的某条指令产生异常的话(就像上面代码中mov dword ptr [eax],0这个位置的指令),回调函数必须在将程序修正到“安全”位置去执行的同时恢复esp和ebp的值,否则,由于ebp和esp值被破坏,程序还是会崩溃。
要将回调函数写得比较“强壮”的话,就必须考虑到这种可能性。最安全的办法就是预先保存一些关键寄存器的值,如果发生异常,回调函数就可以根据保存的数据恢复这些关键寄存器。这些关键寄存器值可以预先保存在全局变量中,但为了程序的模块化,一般推荐使用堆栈来动态传递数据。
如何使用_lpSEH参数实现用堆栈传输数据呢?大家可以注意到,_lpSEH参数指向的数据就是我们自己定义的EXCEPTION_REGISTRATION结构,这个结构存放在由程序自己分配的内存中,所以可以在结构的后面附加一些自定义的数据,这样通过_lpSEH参数就可以在堆栈中寻址到这些数据。下面是根据这个思路修改后的SEH异常处理注册代码:
; ------------------------------------------------------------
; 将 _Handler子程序注册为异常处理程序
; ------------------------------------------------------------push ebp ;附加数据push offset _SafePlace ;附加数据push offset _Handlerpush fs:[0]mov fs:[0],esp......
_SafePlace:...
; ----------------------------------------------------------
; 恢复原来的SEH链
; ----------------------------------------------------------pop fs:[0]add esp,0ch
在这里,程序在标准的EXCEPTION_REGISTRATION结构后面增加了两个自定义数据,一个是“安全地址”,一个是原先的ebp值,同时,回调函数也进行了相应的修改:
_Handler proc C _lpExceptionRecord,_lpSEH,\_lpContext,_lpDispatcherContextpushadmov esi,_lpExceptionRecordmov edi,_lpContextassume esi:ptr EXCEPTION_RECORD,edi:ptr CONTEXT
; -----------------------------------------------------------------
; 将EIP指向安全的位置并恢复堆栈
; ----------------------------------------------------------------- mov eax,_lpSEHpush [eax + 8]pop [edi].regEippush [eax + 0ch]pop [edi].regEbppush eaxpop [edi].regEspassume esi:nothing,edi:nothingpopadmov eax,ExceptionContinueExecutionret
_Handler endp
将_lpSEH参数放入eax后,[eax]相当于EXCEPTION_REGISTRATION结构的prev字段,[eax+4]相当于handler字段,从[eax+8]开始就是程序自定义的数据了,按照主程序中的入栈顺序,[eax+8]是“安全地址”,[eax+0ch]是程序保存的ebp值,程序中并没有单独设置一个自定义字段来保存esp,因为在这个例子中,_lpSEH本身就相当于正确的esp。经过这样的处理后,整个异常处理过程将不使用任何全局变量。修改后的完整代码可以在本书所附光盘的Chapter14\SEH02目录中找到。
【完整代码笔记】
; SEH.asm 使用 SEH 进行错误截获
; -------------------------------------------------------------------
; 使用 nmake 或下列命令进行编译和链接:
; ml /c /coff SEH.asm
; Link /subsystem:windows SEH.obj
.386
.model flat,stdcall
option casemap:none ; include 文件定义
include c:/masm32/include/windows.inc
include c:/masm32/include/user32.inc
include c:/masm32/include/kernel32.inc
includelib c:/masm32/lib/user32.lib
includelib c:/masm32/lib/kernel32.lib ; 数据段
.const
szMsg byte '异常发生位置:%08X,异常代码:%08X,标志:%08X',0
szSafe byte '回到了安全的地方!',0
szCaption byte 'SEH例子',0.code
; 错误 Handler
_Handler proc C _lpExceptionRecord, _lpSEH, _lpContext, _lpDispatcherContent local @szBuffer[256]:byte pushad mov esi, _lpExceptionRecordmov edi, _lpContext assume esi:ptr EXCEPTION_RECORD, edi:ptr CONTEXT invoke wsprintf, addr @szBuffer, addr szMsg, \[edi].regEip, [esi].ExceptionCode, [esi].ExceptionFlags invoke MessageBox, NULL, addr @szBuffer, NULL, MB_OK ;将 EIP 指向安全的位置并恢复堆栈mov eax, _lpSEH push [eax+8]pop [edi].regEip push [eax+0ch]pop [edi].regEbppush eax pop [edi].regEsp assume esi:nothing, edi:nothing popad mov eax, ExceptionContinueExecution ret
_Handler endp _Test proc ;在堆栈中构造一个 EXCEPTION_REGISTRATION 结构assume fs:nothing push ebp push offset _SafePlace push offset _Handler push fs:[0]mov fs:[0], esp ;会引发异常的指令pushad xor ebp, ebp xor eax, eax mov dword ptr [eax], 0popad ;这一句将无法被执行,因为上面一句会发生异常
_SafePlace:invoke MessageBox, NULL, addr szSafe, addr szCaption, MB_OK ;恢复原来的 SEH 链pop fs:[0]add esp, 0ch ret
_Test endp start:invoke _Test invoke ExitProcess, 0
end start
运行结果:
点击【确定】按钮
Windows下的许多高级语言都在EXCEPTION_REGISTRATION结构的后面添加自定义的数据,比如,Microsoft SDK的except.inc中是这样定义的:
__EXCEPTIONREGISTRATIONRECORD structprev_structure dd ?ExceptionHandler dd ?ExceptionFilter dd ? ;附加数据FilterFrame dd ? ;附加数据PExceptionInfoPtrs dd ? ;附加数据__EXCEPTIONREGISTRATIONRECORD ends
而在VC++中是这样定义的:
struct _EXCEPTION_REGISTRATION{struct _EXCEPTION_REGISTRATION *prev;void (*handler)(PEXCEPTION_RECORD,PEXCEPTION_REGISTRATION,PCONTEXT,PEXCEPTION_RECORD);struct scopetable_entry *scopetable; //附加数据int trylevel; //附加数据int _ebp; //附加数据PEXCEPTION_POINTERS xpointers;};
除了上面两个以不同方式定义的结构,笔者在很多汇编源代码中也见过更多的各不相同的EXCEPTION_REGISTRATION结构定义,正是因为这些结构的定义各不相同,Microsoft又没有提供一份标准的文档,这使很多初次接触SEH的人根本搞不清楚SEH究竟是如何定义的。读者现在应该明白这种现象的由来了,回过头去看一看这些结构,就可以发现它们的前面两个字段就是基本的EXCEPTION_REGISTRATION结构!
2.回调函数的返回值
SEH异常处理回调函数的返回值定义不同于筛选器异常处理回调函数,它可以使用下面列出的4种取值。
● ExceptionContinueExecution(等于0):回调函数返回后,系统将线程环境设置为_lpContext参数指定的CONTEXT结构并继续执行。
● ExceptionContinueSearch(等于1):回调函数拒绝处理这个异常,系统将通过EXCEPTION_REGISTRATION结构的prev字段得到前一个回调函数的地址并调用它。
● ExceptionNestedException(等于2):回调函数在执行中又发生了新的异常,即发生了嵌套的异常。
● ExceptionCollidedUnwind(等于3):发生了嵌套的展开操作(展开操作的介绍参见14.3.4节)。
14.3.3 SEH链和异常的传递
每次定义了一个新的SEH异常处理回调函数时,EXCEPTION_REGISTRATION结构的prev字段都被要求填写为原来的EXCEPTION_REGISTRATION结构地址,随着应用程序对执行模块的调用一层层深入下去,如果有多个模块设置了回调函数,那么到最后全部的回调函数会形成一个SEH链,如图14.2所示。
图14.2 SEH链
当程序中有多个线程在运行的时候,每个线程中都会存在各自的SEH链,这些SEH链中指定了多个回调函数,除它们以外,系统中可能还会存在一个全局性的筛选器异常处理回调函数,再者,如果进程被调试的话,调试器进程也相当于一个异常处理程序存在。既然会同时存在这么多的回调函数,而每个函数都可能对发生的异常提出不同的处理意见,那么当一个异常发生的时候,系统究竟该听谁的意见呢?
在这种情况下,系统按照一定的步骤选择一个回调函数并执行它,如果这个被执行的回调函数可以处理这个异常,那么程序被修正后继续执行并且其他的回调函数不会再被执行,否则系统继续执行下一个回调函数,查找的步骤如下:
(1)系统查看产生异常的进程是否正在被调试,如果正在被调试的话,那么向调试器发送EXCEPTION_DEBUG_EVENT事件。
(2)如果进程没有被调试或者调试器不去处理这个异常,那么系统检查异常所处的线程,并在这个线程的环境中查看fs:[0]来确定是否安装有SEH异常处理回调函数,如果有的话则调用它。
(3)回调函数尝试处理这个异常,如果可以正确处理的话,则修正错误并将返回值设置为ExceptionContinueExecution,这时系统将结束整个查找过程。
(4)如果回调函数返回ExceptionContinueSearch,告知系统它无法处理这个异常,那么系统将根据SEH链中的prev字段得到上一个回调函数地址并重复步骤(3),直到链中的某个回调函数返回ExceptionContinueExecution为止,查找结束。
(5)如果到了SEH链的尾部却没有一个回调函数愿意处理这个异常,那么系统将再次检测进程是否正在被调试,如果被调试的话,则再一次通知调试器。
(6)如果调试器还是不去处理这个异常或者进程没有被调试,那么系统检查有没有安装筛选器回调函数,如果有,则去调用它,筛选器回调函数返回时,系统默认的异常处理程序根据这个返回值将做相应的动作。
(7)如果没有安装筛选器回调函数,系统直接调用默认的异常处理程序终止进程。
这个过程归纳起来就是:系统按照调试器、SEH链上从新到旧的各个回调函数、筛选器回调函数的步骤一个个去调用它们,一直到某个回调函数愿意处理异常为止。如果大家都无法处理异常的话,那么最后由系统默认的异常处理程序来终止发生异常的进程。
Windows拿着一份处理异常的活挨个问每个回调函数,“你干不干?”,“不干”,“你呢?”,“我也不干”……当问到某一个的时候,他说:“那我来干好了!”,那么Windows就不会再问其他人了,于是相安无事。
有时,问完了一圈以后谁都不愿干活,Windows大怒:“谁都不干,看我炒了你们!”,于是就把整个进程终止掉了,所有的回调函数随之完蛋。
14.3.4 展开操作(Unwinding)
执行上面演示的SEH例子文件,程序会在显示了如图14.3中A所示的消息框后,再显示一个“转移到安全地址”的消息框后正常退出,这一切都在我们的意料之中。
图14.3 展开操作时的异常代码和标志
现在来看看回调函数不处理异常时会怎样,将SEH.asm修改一下,去掉回调函数中修正eip寄存器的指令并将函数的返回值改为ExceptionContinueSearch,编译执行后再执行一下。首先看到的是图14.3中A所示的消息框,单击“确定”按钮后,程序不会再显示“转移到安全地址”的消息框,而是出现系统的错误报告对话框,到此为止也在我们的意料之中,现在,单击“确定”按钮,奇怪的事情出现了,回调函数再一次被调用并显示了如图14.3中B所示的消息框!
进一步试验可以发现,如果程序在SEH链上挂了多个回调函数,并且每个回调函数都不处理异常的话,在系统默认的显示错误的对话框出现以后,每个回调函数都会被再调用一遍,这时参数中指定的异常代码是EXCEPTION_UNWIND,异常标志的取值是2,也就是EXCEPTION_UNWINDING标志。这种调用并不是要求回调函数去处理什么异常,而是告知回调函数:“你将要被卸掉了,自己处理一些后事吧”,在这时回调函数应该进行一些卸载前的扫尾工作并且返回ExceptionContinueSearch。
对回调函数的这种调用是由展开操作(Unwinding)引起的。当SEH链上的某个回调函数进行展开操作时,它所做的事情是从SEH链上的第一个回调函数开始(也就是fs:[0]指定的回调函数),以EXCEPTION_UNWIND代码和EXCEPTION_UNWINDING标志去调用每个回调函数,一直到调用到自身所处的位置为止,然后将自身之前的所有回调函数卸载,也就是将fs:[0]直接指向描述自身位置的那个EXCEPTION_REGISTRATION结构。当进行展开操作后,发起展开操作的那个回调函数将成为SEH链上的第一个回调函数。
1.为什么要进行展开操作
展开操作在某些情况下是必要的。原因之一是让被卸载的回调函数有机会进行扫尾操作;原因之二是为了防止某些异常情况的发生,这个原因分析起来要复杂一些。
为了程序的模块化设计,一般在堆栈中构造EXCEPTION_REGISTRATION结构来注册SEH异常处理回调函数,这种方法已经成为各种语言注册SEH异常处理程序的首选,然而,与将结构定义成全局变量相比,这种方法又带来了一个新的问题。
现在来看一个典型的应用,如图14.4所示,假设在主程序中调用_Proc1子程序来实现某种功能,由于这个子程序将涉及内存操作,所以设置了一个回调函数来处理内存访问异常,在_Proc1中又会调用_Proc2子程序来对所分配的内存中的数据进行一些运算,为了检测计算中的溢出错误,_Proc2设置了一个回调函数来处理溢出或除零异常,对其他的异常将不予处理并让它在SEH链中继续传递。
图14.4 堆栈中的SEH链存在情况
当程序执行到_Proc2中间的时候,堆栈中的数据如图14.4的右边所示,最下方是_Proc2注册异常处理回调函数使用的EXCEPTION_REGISTRATION结构,现在fs:[0]指向这个结构,上面一点是_Proc2使用的局部变量、返回地址等,再上面就是注册_Proc1中异常处理回调函数使用的EXCEPTION_REGISTRATION结构了。
当_Proc2中发生溢出异常时,_Proc2的回调函数将程序修正到Safe2执行,在这里堆栈被修正到如图14.4中的B所示的位置。到_Proc2返回的时候,回调函数被卸掉,堆栈中的后一个EXCEPTION_REGISTRATION结构被丢弃且fs:[0]被恢复指向_Proc1设置的结构中,一切都很正常。
但是在_Proc2中发生内存存取异常的时候,问题就出现了,这时系统根据fs:[0]的值首先找到并调用_Proc2设置的回调函数,但这个回调函数不处理这种异常,它会要求Windows继续搜索,接下来_Proc1设置的回调函数被调用,在这里堆栈被修正到如图14.4的A所示的位置,这是执行到Safe1位置时正确的堆栈位置。
问题就在这里,这时候esp指向A,在A位置以下的堆栈空间都是自由的,包括A和B之间的堆栈空间,如果_Proc1接下来进行了一些入栈出栈的操作,原先由_Proc2设置的EXCEPTION_REGISTRATION结构就会被冲掉,不要忘了这时fs:[0]还指向这个失效的结构,如果这时再发生一个异常的话,Windows就会调用一个无效的回调函数地址。
这就是要进行展开操作的第二个原因,为了防止这个意外,_Proc2的异常处理回调函数在被执行的时候应该将fs:[0]中的值重新置为本身使用的EXCEPTION_REGISTRATION结构的地址,这样即使再次发生异常,也不会有前面这种危险的情况发生,这个操作相当于将后面所设的所有异常处理回调函数都卸掉了。
2.完整的异常处理回调函
数综上所述,一个完整的异常处理回调函数应该包括异常处理、展开操作和响应展开调用等部分,其结构示意如下所示:
_Handler proc C _lpExceptionRecord,_lpSEH, \_lpContext,_lpDispatcherContext.if (异常代码 == 0c0000027h) || \(异常标志 & EXCEPTION_UNWINDING) || \(异常标志 & EXCEPTION_UNWINDING_FOR_EXIT)进行释放资源等扫尾工作 ;(1)mov eax,ExceptionContinueSearch.elseif异常代码 == 可以处理的异常代码处理异常,对CONTEXT进行修正 ;(2)进行展开操作 ;(3)mov eax,ExceptionContinueExecution.else ;其他无法处理的异常代码mov eax,ExceptionContinueSearch;(4).endifret
_Handler endp
但是在实际的应用中,并不一定要存在上面所示的全部代码,如果某个异常处理回调函数对所有的异常代码都进行处理的话,那么就不会有(4)所示的代码,这样在它以后的回调函数就不可能再被调用,这样一来,这个回调函数也不可能被其他回调函数以展开操作的异常代码调用,结果是(1)所示的代码也就不需要了。
另外,当回调函数能够确定自己是SEH链上的最后一个回调函数的话,由于不存在展开操作的对象,也就不需要(3)所示的代码。
在本章的SEH例子中,程序设置的回调函数既是最后一个异常处理回调函数,又对所有的异常代码进行处理并将程序转移到“安全地址”去执行,所以仅仅需要(2)所示的代码。
3.如何进行展开操作
自己书写展开操作的代码并不复杂,第一步是在一个循环中以EXCEPTION_UNWINDING标志调用从fs:[0]开始到当前回调函数为止的所有回调函数,第二步是将fs:[0]重新设置一下,指向注册当前回调函数使用的EXCEPTION_REGISTRATION结构就可以了。
但是,更方便的办法是使用Win32中未公开的函数RtlUnwind,这个函数可以完成上述的功能,函数的使用方法如下所示:
invoke RtlUnwind,lpLastStackFrame,lpCodeLabel,lpExceptionRecord,dwRet
使用参数lpLastStackFrame可以有两种方法。
第一,将它指定为当前回调函数使用的EXCEPTION_REGISTRATION结构地址的话,表示对当前回调函数之后的所有其他回调函数进行展开操作,RtlUnwind函数调用每个被展开的回调函数时,异常标志中会含有EXCEPTION_UNWINDING标志位。
第二,如果这个参数指定为NULL的话,表示对SEH链上所有的回调函数进行展开操作,这时所有回调函数参数中的异常标志在带有EXCEPTION_UNWINDING标志位的同时也带有EXCEPTION_UNWINDING_FOR_EXIT标志位,这种方式的展开称为退出展开(Exit Unwind)。
lpCodeLabel指定函数返回的位置。如果这个参数指定为NULL,函数使用正常的返回方式,也就是返回到调用RtlUnwind函数的后面一条指令,否则,函数直接返回到lpCodeLabel指定的地址。
lpExceptionRecord指定一个EXCEPTION_RECORD结构。这个结构将在展开操作的时候被传给每一个被调用的回调函数,一般建议使用NULL来让系统自动生成代表展开操作的EXCEPTION_RECORD结构。dwRet参数一般不被使用,可以将它指定为NULL。
本书所附光盘的Chapter14\Unwind目录中包含了一个SEH展开操作的例子,读者可以自行分析一下,由于篇幅所限,在此就不再列出了。
【完整代码笔记】
; Unwind.asm 演示 SEH 链的回卷操作
; ------------------------------------------------------------------
; 使用 nmake 或下列命令进行编译和链接:
; ml /c /coff Unwind.asm
; Link /subsystem:windows Unwind.obj
.386
.model flat,stdcall
option casemap:none ; include 文件定义
include c:/masm32/include/windows.inc
include c:/masm32/include/user32.inc
include c:/masm32/include/kernel32.inc
includelib c:/masm32/lib/user32.lib
includelib c:/masm32/lib/kernel32.lib ; 数据段
.data
szMsg1 byte '这是外层异常处理程序(将处理异常)',0dh,0ahbyte '异常发生位置:%08X,异常代码:%08X,标志:%08X',0
szMsg2 byte '这是内层异常处理程序(对异常不进行处理)',0dh,0ahbyte '异常发生位置:%08X,异常代码:%08X,标志:%08X',0
szCaption byte '提示信息',0
szBeforeUnwind byte '现在将开始 Unwind,当前的 FS:[0] = %08X',0
szAfterUnwind byte 'Unwind 返回,当前的 FS:[0] = %08X',0
szSafe1 byte '回到了外层子程序的安全位置!',0
szSafe2 byte '回到了内层子程序的安全位置!',0; 代码段
.code
; 外层错误 Handler,将处理异常
_Handler1 proc C _lpExceptionRecord, _lpSEH, _lpContext, _lpDispatcherContext local @szBuffer[256]:byte pushad mov esi, _lpExceptionRecord mov edi, _lpContext assume esi:ptr EXCEPTION_RECORD, edi:ptr CONTEXT, fs:nothing invoke wsprintf, addr @szBuffer, addr szMsg1, \[edi].regEip, [esi].ExceptionCode, [esi].ExceptionFlags invoke MessageBox, NULL, addr @szBuffer, NULL, MB_OK ;将 EIP 指向安全的位置并恢复堆栈mov eax, _lpSEH push [eax+8]pop [edi].regEip push _lpSEH pop [edi].regEsp ;对前面的 Handler 进行 Unwind 操作invoke wsprintf, addr @szBuffer, addr szBeforeUnwind, dword ptr fs:[0]invoke MessageBox, NULL, addr @szBuffer, addr szCaption, MB_OK invoke RtlUnwind, _lpSEH, NULL, NULL, NULL invoke wsprintf, addr @szBuffer, addr szAfterUnwind, dword ptr fs:[0]invoke MessageBox, NULL, addr @szBuffer, addr szCaption, MB_OK assume esi:nothing, edi:nothing popad mov eax, ExceptionContinueExecution ret
_Handler1 endp ; 内层错误 Handler,不处理异常
_Handler2 proc C _lpExceptionRecord, _lpSEH, _lpContext, _lpDispatcherContext local @szBuffer[256]:byte pushad mov esi, _lpExceptionRecord mov edi, _lpContext assume esi:ptr EXCEPTION_RECORD, edi:ptr CONTEXT invoke wsprintf, addr @szBuffer, addr szMsg2, \[edi].regEip, [esi].ExceptionCode, [esi].ExceptionFlags invoke MessageBox, NULL, addr @szBuffer, NULL, MB_OK assume esi:nothing, edi:nothing popad mov eax, ExceptionContinueSearch ret
_Handler2 endp _Test2 proc assume fs:nothing push offset _SafePlace push offset _Handler2 push fs:[0]mov fs:[0], esp ;会引发异常的指令pushad xor eax, eax mov dword ptr [eax], 0popad ;这一句将无法被执行,因为上一名发生异常
_SafePlace:invoke MessageBox, NULL, addr szSafe2, addr szCaption, MB_OK pop fs:[0]add esp, 8ret
_Test2 endp _Test1 proc assume fs:nothingpush offset _SafePlace push offset _Handler1 push fs:[0]mov fs:[0], esp invoke _Test2
_SafePlace:invoke MessageBox, NULL, addr szSafe1, addr szCaption, MB_OK pop fs:[0]add esp, 8ret
_Test1 endp start:invoke _Test1 invoke ExitProcess, 0
end start
运行效果:
点【确定】按钮
点【确定】按钮
点【确定】按钮
点【确定】按钮
点【确定】按钮
使用RtlUnwind函数时要注意的是:这个函数并不像其他API函数一样保存esi,edi和ebx寄存器,在函数返回的时候这些寄存器的值可能会被改变,所以,如果程序用到了这些寄存器的话,必须自己去保存和恢复它们。
最后需要说明的是,SEH异常处理属于Win32中未公开的特征,本章中的大部分内容无法从Microsoft的正式文档中查到,它们来自于各种零星的资料(包括笔者对一些例子的分析以及编程测试的结果),所以可能与其他资料有所出入。如果读者发现存在错误或者有什么疑问,请告知笔者。
* 非常感谢温玉杰(Hume)提供本章中SEH相关内容的帮助!