如果你已经走了这么远,不把整个过程讲完对你有点不公平。我已经讲了当异常发生时操作系统是如何调用用户定义的回调函数的。我也讲了这些回调的内部情况,以及编译器是如何使用它们来实现__try和__except的。我甚至还讲了当某个异常没有被处理时所发生的情况以及系统所做的扫尾工作。剩下的就只有异常回调过程最初是从哪里开始的这个问题了。好吧,让我们深入系统内部来看一下结构化异常处理的开始阶段吧。
图十四是我为 KiUserExceptionDispatcher 函数和一些相关函数写的伪代码。这个函数在NTDLL.DLL中,它是异常处理执行的起点。为了绝对准确起见,我必须指出:刚才说的并不是绝对准确。例如在Intel平台上,一个异常导致CPU将控制权转到ring 0(0特权级,即内核模式)的一个处理程序上。这个处理程序由中断描述符表(Interrupt Descriptor Table,IDT)中的一个元素定义,它是专门用来处理相应异常的。我跳过所有的内核模式代码,假设当异常发生时CPU直接将控制权转到了 KiUserExceptionDispatcher 函数。
KiUserExceptionDispatcher( PEXCEPTION_RECORD pExcptRec, CONTEXT * pContext ) { DWORD retValue; // Note: If the exception is handled, RtlDispatchException() never returns if ( RtlDispatchException( pExceptRec, pContext ) ) retValue = NtContinue( pContext, 0 ); else retValue = NtRaiseException( pExceptRec, pContext, 0 ); EXCEPTION_RECORD excptRec2; excptRec2.ExceptionCode = retValue; excptRec2.ExceptionFlags = EXCEPTION_NONCONTINUABLE; excptRec2.ExceptionRecord = pExcptRec; excptRec2.NumberParameters = 0; RtlRaiseException( &excptRec2 ); } int RtlDispatchException( PEXCEPTION_RECORD pExcptRec, CONTEXT * pContext ) { DWORD stackUserBase; DWORD stackUserTop; PEXCEPTION_REGISTRATION pRegistrationFrame; DWORD hLog; // Get stack boundaries from FS:[4] and FS:[8] RtlpGetStackLimits( &stackUserBase, &stackUserTop ); pRegistrationFrame = RtlpGetRegistrationHead(); while ( -1 != pRegistrationFrame ) { PVOID justPastRegistrationFrame = &pRegistrationFrame + 8; if ( stackUserBase > justPastRegistrationFrame ) { pExcptRec->ExceptionFlags |= EH_STACK_INVALID; return DISPOSITION_DISMISS; // 0 } if ( stackUsertop < justPastRegistrationFrame ) { pExcptRec->ExceptionFlags |= EH_STACK_INVALID; return DISPOSITION_DISMISS; // 0 } if ( pRegistrationFrame & 3 ) // Make sure stack is DWORD aligned { pExcptRec->ExceptionFlags |= EH_STACK_INVALID; return DISPOSITION_DISMISS; // 0 } if ( someProcessFlag ) { // Doesn't seem to do a whole heck of a lot. hLog = RtlpLogExceptionHandler( pExcptRec, pContext, 0, pRegistrationFrame, 0x10 ); } DWORD retValue, dispatcherContext; retValue= RtlpExecuteHandlerForException(pExcptRec, pRegistrationFrame, pContext, &dispatcherContext, pRegistrationFrame->handler ); // Doesn't seem to do a whole heck of a lot. if ( someProcessFlag ) RtlpLogLastExceptionDisposition( hLog, retValue ); if ( 0 == pRegistrationFrame ) { pExcptRec->ExceptionFlags &= ~EH_NESTED_CALL; // Turn off flag } EXCEPTION_RECORD excptRec2; DWORD yetAnotherValue = 0; if ( DISPOSITION_DISMISS == retValue ) { if ( pExcptRec->ExceptionFlags & EH_NONCONTINUABLE ) { excptRec2.ExceptionRecord = pExcptRec; excptRec2.ExceptionNumber = STATUS_NONCONTINUABLE_EXCEPTION; excptRec2.ExceptionFlags = EH_NONCONTINUABLE; excptRec2.NumberParameters = 0 RtlRaiseException( &excptRec2 ); } else return DISPOSITION_CONTINUE_SEARCH; } else if ( DISPOSITION_CONTINUE_SEARCH == retValue ) { } else if ( DISPOSITION_NESTED_EXCEPTION == retValue ) { pExcptRec->ExceptionFlags |= EH_EXIT_UNWIND; if ( dispatcherContext > yetAnotherValue ) yetAnotherValue = dispatcherContext; } else // DISPOSITION_COLLIDED_UNWIND { excptRec2.ExceptionRecord = pExcptRec; excptRec2.ExceptionNumber = STATUS_INVALID_DISPOSITION; excptRec2.ExceptionFlags = EH_NONCONTINUABLE; excptRec2.NumberParameters = 0 RtlRaiseException( &excptRec2 ); } pRegistrationFrame = pRegistrationFrame->prev; // Go to previous frame } return DISPOSITION_DISMISS; } _RtlpExecuteHandlerForException: // Handles exception (first time through) MOV EDX,XXXXXXXX JMP ExecuteHandler RtlpExecutehandlerForUnwind: // Handles unwind (second time through) MOV EDX,XXXXXXXX int ExecuteHandler( PEXCEPTION_RECORD pExcptRec PEXCEPTION_REGISTRATION pExcptReg CONTEXT * pContext PVOID pDispatcherContext, FARPROC handler ) // Really a ptr to an _except_handler() // Set up an EXCEPTION_REGISTRATION, where EDX points to the // appropriate handler code shown below PUSH EDX PUSH FS:[0] MOV FS:[0],ESP // Invoke the exception callback function EAX = handler( pExcptRec, pExcptReg, pContext, pDispatcherContext ); // Remove the minimal EXCEPTION_REGISTRATION frame MOV ESP,DWORD PTR FS:[00000000] POP DWORD PTR FS:[00000000] return EAX; } Exception handler used for _RtlpExecuteHandlerForException: { // If unwind flag set, return DISPOSITION_CONTINUE_SEARCH, else // assign pDispatcher context and return DISPOSITION_NESTED_EXCEPTION return pExcptRec->ExceptionFlags & EXCEPTION_UNWIND_CONTEXT ? DISPOSITION_CONTINUE_SEARCH : *pDispatcherContext = pRegistrationFrame->scopetable, DISPOSITION_NESTED_EXCEPTION; } Exception handler used for _RtlpExecuteHandlerForUnwind: { // If unwind flag set, return DISPOSITION_CONTINUE_SEARCH, else // assign pDispatcher context and return DISPOSITION_COLLIDED_UNWIND return pExcptRec->ExceptionFlags & EXCEPTION_UNWIND_CONTEXT ? DISPOSITION_CONTINUE_SEARCH : *pDispatcherContext = pRegistrationFrame->scopetable, DISPOSITION_COLLIDED_UNWIND; }
KiUserExceptionDispatcher 的核心是对 RtlDispatchException 的调用。这拉开了搜索已注册的异常处理程序的序幕。如果某个处理程序处理这个异常并继续执行,那么对 RtlDispatchException 的调用就不会返回。如果它返回了,只有两种可能:或者调用了NtContinue以便让进程继续执行,或者产生了新的异常。如果是这样,那异常就不能再继续处理了,必须终止进程。
现在把目光对准 RtlDispatchException 函数的代码,这就是我通篇提到的遍历异常帧的代码。这个函数获取一个指向EXCEPTION_REGISTRATION 结构链表的指针,然后遍历此链表以寻找一个异常处理程序。由于堆栈可能已经被破坏了,所以这个例程非常谨慎。在调用每个EXCEPTION_REGISTRATION结构中指定的异常处理程序之前,它确保这个结构是按DWORD对齐的,并且是在线程的堆栈之中,同时在堆栈中比前一个EXCEPTION_REGISTRATION结构高。
RtlDispatchException并不直接调用EXCEPTION_REGISTRATION结构中指定的异常处理程序。相反,它调用 RtlpExecuteHandlerForException来完成这个工作。根据RtlpExecuteHandlerForException的执行情况,RtlDispatchException或者继续遍历异常帧,或者引发另一个异常。这第二次的异常表明异常处理程序内部出现了错误,这样就不能继续执行下去了。
RtlpExecuteHandlerForException的代码与RtlpExecuteHandlerForUnwind的代码极其相似。你可能会回忆起来在前面讨论展开时我提到过它。这两个“函数”都只是简单地给EDX寄存器加载一个不同的值然后就调用ExecuteHandler函数。也就是说,RtlpExecuteHandlerForException和RtlpExecuteHandlerForUnwind都是 ExecuteHanlder这个公共函数的前端。
ExecuteHandler查找EXCEPTION_REGISTRATION结构的handler域的值并调用它。令人奇怪的是,对异常处理回调函数的调用本身也被一个结构化异常处理程序封装着。在SEH自身中使用SEH看起来有点奇怪,但你思索一会儿就会理解其中的含义。如果在异常回调过程中引发了另外一个异常,操作系统需要知道这个情况。根据异常发生在最初的回调阶段还是展开回调阶段,ExecuteHandler或者返回DISPOSITION_NESTED_EXCEPTION,或者返回DISPOSITION_COLLIDED_UNWIND。这两者都是“红色警报!现在把一切都关掉!”类型的代码。
如果你像我一样,那不仅理解所有与SEH有关的函数非常困难,而且记住它们之间的调用关系也非常困难。为了帮助我自己记忆,我画了一个调用关系图(图十五)。
图十五 在SEH中是谁调用了谁
Figure 15 Who Calls Who in SEH KiUserExceptionDispatcher() RtlDispatchException() RtlpExecuteHandlerForException() ExecuteHandler() // Normally goes to __except_handler3 --------- __except_handler3() scopetable filter-expression() __global_unwind2() RtlUnwind() RtlpExecuteHandlerForUnwind() scopetable __except block()
现在要问:在调用ExecuteHandler之前设置EDX寄存器的值有什么用呢?这非常简单。如果ExecuteHandler在调用用户安装的异常处理程序的过程中出现了什么错误,它就把EDX指向的代码作为原始的异常处理程序。它把EDX寄存器的值压入堆栈作为原始的 EXCEPTION_REGISTRATION结构的handler域。这基本上与我在MYSEH和MYSEH2中对原始的结构化异常处理的使用情况一样。
结论
结构化异常处理是Win32一个非常好的特性。多亏有了像Visual C++之类的编译器的支持层对它的封装,一般的程序员才能付出比较小的学习代价就能利用SEH所提供的便利。但是在操作系统层面上,事情远比Win32文档说的复杂。
不幸的是,由于人人都认为系统层面的SEH是一个非常困难的问题,因此至今这方面的资料都不多。在本文中,我已经向你指出了系统层面的SEH就是围绕着简单的回调在打转。如果你理解了回调的本质,在此基础上分层理解,系统层面的结构化异常处理也不是那么难掌握。
附录:关于 “prolog 和 epilog ”
在 Visual C++ 文档中,微软对 prolog 和 epilog 的解释是:“保护现场和恢复现场” 此附录摘自微软 MSDN 库,详细信息参见:
http:
//msdn.microsoft.com/en-us/library/tawsa7cb(VS.80).aspx(英文)
http:
//msdn.microsoft.com/zh-cn/library/tawsa7cb(VS.80).aspx(中文)
如果堆栈中的固定分配超过一页(即大于 4096 字节),则该堆栈分配的范围可能超过一个虚拟内存页,因此在实际分配之前必须检查分配情况。为此,提供了一个特殊的例程,该例程可从 Prolog 调用,并且不会损坏任何参数寄存器。
保存非易失寄存器的首选方法是:在进行固定堆栈分配之前将这些寄存器移入堆栈。如果在保存非易失寄存器之前执行了固定堆栈分配,则很可能需要 32 位位移以便对保存的寄存器区域进行寻址(据说寄存器的压栈操作与移动操作一样快,并且在可预见的未来一段时间内都应该是这样,尽管压栈操作之间存在隐含的相关性)。可按任何顺序保存非易失寄存器。但是,在 Prolog 中第一次使用非易失寄存器时必须对其进行保存。
典型的 Prolog 代码可以为:
mov [RSP + 8], RCX push R15 push R14 push R13 sub RSP, fixed-allocation-size lea R13, 128[RSP
此 Prolog 执行以下操作:将参数寄存器 RCX 存储在其标识位置;保存非易失寄存器 R13、R14、R15;分配堆栈帧的固定部分;建立帧指针,该指针将 128 字节地址指向固定分配区域。使用偏移量以后,便可以通过单字节偏移量对多个固定分配区域进行寻址。
如果固定分配大小大于或等于一页内存,则在修改 RSP 之前必须调用 helper 函数。此 __chkstk helper 函数负责探测待分配的堆栈范围,以确保对堆栈进行正确的扩展。在这种情况下,前面的 Prolog 示例应变为:
mov [RSP + 8], RCX push R15 push R14 push R13 mov RAX, fixed-allocation-size call __chkstk sub RSP, RAX lea R13, 128[RSP] .. |
.除了 R10、R11 和条件代码以外,此 __chkstk helper 函数不会修改任何寄存器。特别是,此函数将返回未更改的 RAX,并且不会修改所有非易失寄存器和参数传递寄存器。
Epilog 代码位于函数的每个出口。通常只有一个 Prolog,但可以有多个 Epilog。Epilog 代码执行以下操作:必要时将堆栈修整为其固定分配大小;释放固定堆栈分配;从堆栈中弹出非易失寄存器的保存值以还原这些寄存器;返回。
对于展开代码,Epilog 代码必须遵守一组严格的规则,以便通过异常和中断进行可靠的展开。这样可以减少所需的展开数据量,因为描述每个 Epilog 不需要额外数据。通过向前扫描整个代码流以标识 Epilog,展开代码可以确定 Epilog 正在执行。
如果函数中没有使用任何帧指针,则 Epilog 必须首先释放堆栈的固定部分,弹出非易失寄存器,然后将控制返回调用函数。例如,
1
2
3
4
5
6
7
8
9
|
add RSP, fixed-allocation-size pop R13 pop R14 pop R15 ret |
如果函数中使用了帧指针,则在执行 Epilog 之前必须将堆栈修整为其固定分配。这在技术上不属于 Epilog。例如,下面的 Epilog 可用于撤消前面使用的 Prolog:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
lea RSP, -128[R13] ; epilogue proper starts here add RSP, fixed-allocation-size pop R13 pop R14 pop R15 ret |
在实际应用中,使用帧指针时,没有必要分两个步骤调整 RSP,因此应改用以下 Epilog:
1
2
3
4
5
6
7
8
9
|
lea RSP, fixed-allocation-size – 128[R13] pop R13 pop R14 pop R15 ret |
以上是 Epilog 的唯一合法形式。它必须由 add RSP,constant 或 lea RSP,constant[FPReg] 组成,后跟一系列零或多个 8 字节寄存器 pop、一个 return 或一个 jmp。(Epilog 中只允许 jmp 语句的子集。仅限于具有 ModRM 内存引用的 jmp 类,其中 ModRM mod 字段值为 00。在 ModRM mod 字段值为 01 或 10 的 Epilog 中禁止使用 jmp。有关允许使用的 ModRM 引用的更多信息,请参见“AMD x86-64 Architecture Programmer’s Manual Volume 3: General Purpose and System Instructions”(AMD x86-64 结构程序员手册第 3 卷:通用指令和系统指令)中的表 A-15。)不能出现其他代码。特别是,不能在 Epilog 内进行调度,包括加载返回值。
请注意,未使用帧指针时,Epilog 必须使用 add RSP,constant 释放堆栈的固定部分,而不能使用 lea RSP,constant[RSP]。由于此限制,在搜索 Epilog 时展开代码具有较少的识别模式。
通过遵守这些规则,展开代码便可以确定某个 Epilog 当前正在执行,并可以模拟该 Epilog 其余部分的执行,从而允许重新创建调用函数的上下文