RtlpExecuteHandlerForException 的 代码与RtlpExecuteHandlerForUnwind的代码极其相似。你可能会回忆起来在前面讨论展开时我提到过它。这两个“函数”都只是简单 地给EDX寄存器加载一个不同的值然后就调用ExecuteHandler函数。也就是 说,
RtlpExecuteHandlerForException和RtlpExecuteHandlerForUnwind都是 ExecuteHanlder这个公共函数的前端。
ExecuteHandler 查 找EXCEPTION_REGISTRATION结构的handler域的值并调用它。令人奇怪的是,对异常处理回调函数的调用本身也被一个结构化异常处 理程序封装着。在SEH自身中使用SEH看起来有点奇怪,但你思索一会儿就会理解其中的含义。如果在异常回调过程中引发了另外一个异常,操作系统需要知道 这个情况。根据异常发生在最初的回调阶段还是展开回调阶段,ExecuteHandler或者返回 DISPOSITION_NESTED_EXCEPTION ,或者返
回 DISPOSITION_COLLIDED_UNWIND 。这两者都是“红色警报!现在把一切都关掉!”类型的代码。
如果你像我一样,那不仅理解所有与SEH有关的函数非常困难,而且记住它们之间的调用关系也非常困难。为了帮助我自己记忆,我画了一个调用关系图(图15)。
现 在要问:在调用ExecuteHandler之前设置EDX寄存器的值有什么用呢?这非常简单。如果ExecuteHandler在调用用户安装的异常处 理程序的过程中出现了什么错误,它就把EDX指向的代码作为原始的异常处理程序。它把EDX寄存器的值压入堆栈作为原始的
EXCEPTION_REGISTRATION结构的handler域。这基本上与我在MYSEH和MYSEH2中对原始的结构化异常处理的使用情况一 样。
图15 在SEH中是谁调用了谁
结论
结构化异常处理是Win32一个非常好的特性。多亏有了像Visual C++之类的编译器的支持层对它的封装,一般的程序员才能付出比较小的学习代价就能利用SEH所提供的便利。但是在操作系统层面上,事情远比Win32文档说的复杂。
不幸的是,由于人人都认为系统层面的SEH是一个非常困难的问题,因此至今这方面的资料都不多。在本文中,我已经向你指出了系统层面的SEH就是围绕着简单的回调在打转。如果你理解了回调的本质,在此基础上分层理解,系统层面的结构化异常处理也不是那么难掌握。
附录:关于prolog和epilog
美 国英语中的“prolog”实际上就是“prologue”。从这个词的意思“序幕、序言”就能大致猜出它的作用。一个函数的prolog代码主要是为这 个函数的执行做一些准备工作,例如设置堆栈帧、设置局部变量所使用的堆栈空间以及保存相关的寄存器等。标准的prolog代码开头一般为以下三条指令: PUSH EBP MOV EBP, ESP SUB ESP, XXX
上 面的三条指令为使用EBP寄存器来访问函数的参数(正偏移)和局部变量(负偏移)做好了准备。例如按照__stdcall调用约定,调用者 (caller)将被调函数(callee)的参数从右向左压入堆栈,然后用CALL指令调用这个函数。CALL指令将返回地址压入堆栈,然后流程就转到 了被调函数的prolog代码。此时[ESP]中是返回地址,[ESP+4]中是函数的第一个参数。本来可以就这样使用ESP寄存器来访问参数,但由于 PUSH和POP指令会隐含修改ESP寄存器的值,这样同一个参数在不同时刻可能需要通过不同的指令形式来访问(例如,如果现在向堆栈中压入一个值的话, 那访问第一个参数就需要使用[ESP+8]了)。为了解决这个问题,所以使用EBP寄存器。EBP寄存器被称为栈帧(frame)指针,它正是用于此目 的。当上述prolog指令中的前两条指令执行后,就可以使用EBP来访问参数了,并且在整个函数中都不会改变此寄存器的值。在前面的例子中, [EBP+8]处就是第一个参数的值,[EBP+0Ch]处是第二个参数的值,依次类推。
大多数C/C++编译器都有“栈帧指针省略( Frame-Pointer Omission )” 这 个选项(在Microsoft C/C++编译器中为/Oy),它导致函数使用ESP来访问参数,从而可以空闲出一个寄存器(EBP)用于其它目的,并且由于不需要设置堆栈帧,从而会稍 微提高运行速度。但是在某些情况下必须使用堆栈帧。作者在前面也提到过,Microsoft已经在其MSDN文档中指明:结构化异常处理是 基于帧 的异常处理。也就是说,它必须使用堆栈帧。当你查看编译器为使用SEH的函数生成的汇编代码时就会清楚这一点。无论你是否使用/Oy选项,它都设置堆栈帧。
可 能有的读者在调试应用程序时偶然进入到了系统DLL(例如NTDLL.DLL)中,但是却意外地发现许多函数的prolog代码的第一条指令并不是上面所 说的“PUSH EBP”,而是一条“垃圾”指令——“MOV EDI, EDI”(这条指令占两个字节)。Microsoft C/C++编译器被称为优化编译器,它怎么可能生成这么一条除了占用空间之外别无它用的指令呢?实 际上,如果你比较细心的话,会发现以这条指令开头的函数的前面有5条NOP指令(它们一共占5个字节),如下图所示。
考 虑一下使用JMP指令进行近跳转和远跳转分别需要几个字节?他们正好分别是2个字节和5个字节!这难道是巧合?熟悉API拦截的读者可能已经猜到了,它们 是供拦截API时使用的。实际上,这是Microsoft对系统打“热补丁”(Hot Patching)时拦截API用的。在打“热补丁“时,修补程序在5条NOP指令处写入一个远跳转指令,以跳转到被修补过的代码处。而“MOV EDI, EDI”处用一个近跳转指令覆盖,它跳转到5个NOP指令所在的位置。使用“MOV EDI, EDI”而不是直接使用两个NOP指令是出于性能考虑。
第三条指令用于为局部变量保留空间,其中的XXX 就 是需要保留的字节数。不使用局部变量的函数没有这条指令。另外,如果局部变量比较少的话——例如2个,为了性能考虑,编译器往往会使用类似于两条 “PUSH ECX”这样的指令来为局部变量保留空间。这三条指令后面一般还有几条PUSH指令用于保存函数使用的寄存器(一般是EBX、ESI和EDI)。
与prolog代码相对的就是epilog代码。与prolog类似,从它的意思“尾声、结尾”也能猜出它的作用。它主要做一些清理工作。标准的epilog代码如下:
MOV ESP, EBP POP EBP RET XXX
这三条指令前面可能还有几条POP指令用于恢复在prolog代码中保存的寄存器(如果存在的话)。有了前面的分析,epilog代码不言自明。需要说明的一点是,最后的RET指令用于返回调用者,并从堆栈中弹出无用信息,XXX 指定了弹出的字节数。它一般用于将参数弹出堆栈。因此从这个值就可以知道函数的参数个数(每个参数均为4字节)。
为 了简化这种操作,Intel引入了ENTER和LEAVE指令。其中ENTER相当于前面所说的prolog代码的前两条指令,而LEAVE相当于上面的 epilog代码的前两条指令。但由于实现上ENTER指令比前面所说的两条指令执行速度慢,因此编译器都不使用这条指令。这样,你实际看到的情况就 是:prolog代码就是前面所说的那样,但epilog代码使用了LEAVE指令。