辛辛苦苦翻译了那么长,突然间发现网上早有这篇文章的翻译,贴过来吧。
进一步深入
有了前面的最简单的例子,让我们再回过头去填补一些空白。虽然这个异常回调机制很棒,但它并不是一个完美的解决方案。对于稍微复杂一些的应用程序来说,仅用一个函数就能处理程序中任何地方都可能发生的异常是相当困难的。一个更实用的方案应该是有多个异常处理例程,每个例程针对程序的特定部分。不知你是否知道,实际上,操作系统提供的正是这个功能。
还记得系统用来查找异常回调函数的 EXCEPTION_REGISTRATION 结构吗?这个结构的第一个成员,称为 prev,前面我们曾把它忽略掉了。它实际上是一个指向另外一个 EXCEPTION_REGISTRATION 结构的指针。这第二个 EXCEPTION_REGISTRATION 结构可以有一个完全不同的处理函数。然后呢,它的 prev 域可以指向第三个 EXCEPTION_REGISTRATION 结构,依次类推。简单地说,就是有一个 EXCEPTION_REGISTRATION 结构链表。线程信息块的第一个 DWORD(在基于 Intel CPU 的机器上是 FS:[0])总是指向这个链表的头部。
操作系统要这个 EXCEPTION_REGISTRATION 结构链表做什么呢?原来,当异常发生时,系统遍历这个链表以便查找其中的一个EXCEPTION_REGISTRATION 结构,其例程回调(异常处理程序)同意处理该异常。在 MYSEH.CPP 的例子中,异常处理程序通过返回ExceptionContinueExecution 表示它同意处理这个异常。异常回调函数也可以拒绝处理这个异常。在这种情况下,系统移向链表的下一个EXCEPTION_REGISTRATION 结构并询问它的异常回调函数,看它是否愿意处理这个异常。图四显示了这个过程:
图四 查找处理异常的 EXCEPTION_REGISTRATION 结构
一旦系统找到一个处理该异常的某个回调函数,它就停止遍历结构链表。
下面的代码 MYSEH2.CPP 就是一个异常处理函数不处理某个异常的例子。为了使代码尽量简单,我使用了编译器层面的异常处理。main 函数只设置了一个 __try/__except块。在__try 块内部调用了 HomeGrownFrame 函数。这个函数与前面的 MYSEH 程序非常相似。它也是在堆栈上创建一个 EXCEPTION_REGISTRATION 结构,并且让 FS:[0] 指向此结构。在建立了新的异常处理程序之后,这个函数通过向一个 NULL 指针所指向的内存处写入数据而故意引发一个错误:
*(PDWORD)0 = 0;
这个异常处理回调函数,同样被称为_except_handler,却与前面的那个截然不同。它首先打印出 ExceptionRecord 结构中的异常代码和标志,这个结构的地址是作为一个指针参数被这个函数接收的。打印出异常标志的原因稍后就会明白。因为_except_handler 函数并没有打算修复出错的代码,因此它返回 ExceptionContinueSearch。这导致操作系统继续在 EXCEPTION_REGISTRATION 结构链表中搜索下一个 EXCEPTION_REGISTRATION结构。接下来安装的异常回调函数是针对 main 函数中的__try/__except块的。__except 块简单地打印出“Caught the exception in main()”。此时我们只是简单地忽略这个异常来表明我们已经处理了它。 以下是 MYSEH2.CPP:
//================================================== // MYSEH2 - Matt Pietrek 1997 // Microsoft Systems Journal, January 1997 // FILE: MYSEH2.CPP // To compile: CL MYSEH2.CPP //================================================== #define WIN32_LEAN_AND_MEAN #include <windows.h> #include <stdio.h> EXCEPTION_DISPOSITION __cdecl _except_handler( struct _EXCEPTION_RECORD *ExceptionRecord, void * EstablisherFrame, struct _CONTEXT *ContextRecord, void * DispatcherContext ) { printf( "Home Grown handler: Exception Code: %08X Exception Flags %X", ExceptionRecord->ExceptionCode, ExceptionRecord->ExceptionFlags ); if ( ExceptionRecord->ExceptionFlags & 1 ) printf( " EH_NONCONTINUABLE" ); if ( ExceptionRecord->ExceptionFlags & 2 ) printf( " EH_UNWINDING" ); if ( ExceptionRecord->ExceptionFlags & 4 ) printf( " EH_EXIT_UNWIND" ); if ( ExceptionRecord->ExceptionFlags & 8 ) printf( " EH_STACK_INVALID" ); if ( ExceptionRecord->ExceptionFlags & 0x10 ) printf( " EH_NESTED_CALL" ); printf( "\n" ); // Punt... We don't want to handle this... Let somebody else handle it return ExceptionContinueSearch; } void HomeGrownFrame( void ) { DWORD handler = (DWORD)_except_handler; __asm { // Build EXCEPTION_REGISTRATION record: push handler // Address of handler function push FS:[0] // Address of previous handler mov FS:[0],ESP // Install new EXECEPTION_REGISTRATION } *(PDWORD)0 = 0; // Write to address 0 to cause a fault printf( "I should never get here!\n" ); __asm { // Remove our EXECEPTION_REGISTRATION record mov eax,[ESP] // Get pointer to previous record mov FS:[0], EAX // Install previous record add esp, 8 // Clean our EXECEPTION_REGISTRATION off stack } } int main() { _try { HomeGrownFrame(); } _except( EXCEPTION_EXECUTE_HANDLER ) { printf( "Caught the exception in main()\n" ); } return 0; }
这里的关键是执行流程。当一个异常处理程序拒绝处理某个异常时,它实际上也就拒绝决定流程最终将从何处恢复。只有接受某个异常的异常处理程序才能决定待所有异常处理代码执行完毕之后流程将从何处继续执行。这个规则暗含的意义非常重大,虽然现在还不是显而易见。
当使用结构化异常处理时,如果一个函数有一个异常处理程序但它却不处理某个异常,这个函数就有可能非正常退出。例如在 MYSEH2中 HomeGrownFrame 函数就不处理异常。由于在链表中后面的某个异常处理程序(这里是 main 函数中的)处理了这个异常,因此出错指令后面的 printf 就永远不会执行。从某种程度上说,使用结构化异常处理与使用 setjmp 和 longjmp 运行时库函数有些类似。
如果你运行 MYSEH2,会发现其输出有些奇怪。看起来好像调用了两次 _except_handler 函数。根据你现有的知识,第一次调用当然可以完全理解。但是为什么会有第二次呢?
Home Grown handler: Exception Code: C0000005 Exception Flags 0 Home Grown handler: Exception Code: C0000027 Exception Flags 2 EH_UNWINDING Caught the Exception in main()
比较一下以“Home Grown Handler”开头的两行,就会看出它们之间有明显的区别。第一次异常标志是0,而第二次是2。这个问题说来话就长了。实际上,当一个异常处理回调函数拒绝处理某个异常时,它会被再一次调用。但是这次回调并不是立即发生的。这有点复杂。我需要把异常发生时的情形好好梳理一下。
当异常发生时,系统遍历 EXCEPTION_REGISTRATION 结构链表,直到它找到一个处理这个异常的处理程序。一旦找到,系统就再次遍历这个链表,直到处理这个异常的结点为止。在这第二次遍历中,系统将再次调用每个异常处理函数。关键的区别是,在第二次调用中,异常标志被设置为2。这个值被定义为 EH_UNWINDING。(EH_UNWINDING 的定义在 Visual C++ 运行时库源代码文件 EXCEPT.INC 中,但 Win32 SDK 中并没有与之等价的定义。)
EH_UNWINDING 表示什么意思呢?原来,当一个异常处理回调函数被第二次调用时(带 EH_UNWINDING 标志),操作系统给这个函数一个最后清理的机会。什么样的清理呢?一个绝好的例子是 C++ 类的析构函数。当一个函数的异常处理程序拒绝处理某个异常时,通常执行流程并不会正常地从那个函数退出。现在,想像一下定义了一个C++类的实例作为局部变量的函数。C++规范规定析构函数必须被调用。这带 EH_UNWINDING 标志的第二次回调就给这个函数一个机会去做一些类似于调用析构函数和__finally 块之类的清理工作。
在异常已经被处理完毕,并且所有前面的异常帧都已经被展开之后,流程从处理异常的那个回调函数决定的地方开始继续执行。一定要记住,仅仅把指令指针设置到所需的代码处就开始执行是不行的。流程恢复执行处的代码的堆栈指针和栈帧指针(在Intel CPU上是 ESP 和EBP)也必须被恢复成它们在处理这个异常的函数的栈帧上的值。因此,这个处理异常的回调函数必须负责把堆栈指针和栈帧指针恢复成它们在包含处理这个异常的 SEH 代码的函数的堆栈上的值。
通常,展开操作导致堆栈上处理异常的帧以下的堆栈区域上的所有内容都被移除了,就好像我们从来没有调用过这些函数一样。展开的另外一个效果就是 EXCEPTION_REGISTRATION 结构链表上处理异常的那个结构之前的所有 EXCEPTION_REGISTRATION 结构都被移除了。这很好理解,因为这些 EXCEPTION_REGISTRATION 结构通常都被创建在堆栈上。在异常被处理后,堆栈指针和栈帧指针在内存中比那些从 EXCEPTION_REGISTRATION 结构链表上移除的 EXCEPTION_REGISTRATION 结构高。图六显示了我说的情况。
迄今为止,我实际上一直在假设操作系统总是能在 EXCEPTION_REGISTRATION 结构链表中 的某个地方找到一个异常处理程序。如果找不到怎么办呢?实际上,这几乎不可能发生。因为操作系统暗中已经为每个线程都提供了一个默认的异常处理程序。这个默认的异常处理程序总是链表的最后一个结点,并且它总是选择处理异常。它进行的操作与其它正常的异常处理回调函数有些不同,下面我会说明。
让我们来看一下系统是在什么时候插入了这个默认的、最后一个异常处理程序。很明显它需要在线程执行的早期,在任何用户代码开始执行之前。
下面是我为 BaseProcessStart 函数写的伪代码。它是 Windows NT KERNEL32.DLL 的一个内部例程。这个函数带一个参数——线程入口点函数的地址。BaseProcessStart 运行在新进程的上下文环境中,并且从该进程的第一个线程的入口点函数开始执行。
BaseProcessStart Pseudocode
BaseProcessStart( PVOID lpfnEntryPoint ) { DWORD retValue DWORD currentESP; DWORD exceptionCode; currentESP = ESP; _try { NtSetInformationThread( GetCurrentThread(), ThreadQuerySetWin32StartAddress, &lpfnEntryPoint, sizeof(lpfnEntryPoint) ); retValue = lpfnEntryPoint(); ExitThread( retValue ); } _except(// filter-expression code exceptionCode = GetExceptionInformation(), UnhandledExceptionFilter( GetExceptionInformation() ) ) { ESP = currentESP; if ( !_BaseRunningInServerProcess ) // Regular process ExitProcess( exceptionCode ); else // Service ExitThread( exceptionCode ); } }
在这段伪码中,注意对 lpfnEntryPoint 的调用被封装在一个__try 和 __except 块中。正是此__try 块安装了默认的、异常处理程序链表上的最后一个异常处理程序。所有后来注册的异常处理程序都被安装在此链表中这个结点的前面。如果 lpfnEntryPoint 函数返回,那么表明线程一直运行到完成并且没有引发异常。这时 BaseProcessStart 调用 ExitThread 使线程退出。
另一方面,如果线程引发了一个异常但是没有异常处理程序来处理它时,该怎么办呢?这时,执行流程转到 __except 关键字后面的括号中。在 BaseProcessStart 中,这段代码调用 UnhandledExceptionFilter 这个 API,稍后我会讲到它。现在对于我们来说,重要的是 UnhandledExceptionFilter 这个API包含了默认的异常处理程序。
如果 UnhandledExceptionFilter 返回 EXCEPTION_EXECUTE_HANDLER,这时 BaseProcessStart 中的__except 块开始执行。而__except块所做的只是调用 ExitProcess 函数去终止当前进程。稍微想一下你就会理解了。常识告诉我们,如果一个进程引发了一个错误而没有异常处理程序去处理它,这个进程就会被系统终止。你在伪代码中看到的正是这些。
对于上述内容我还有一点要补充。如果引发错误的线程是作为服务来运行的,并且是基于线程的服务,那么__except 块并不调用 ExitProcess,而是调用 ExitThread。不能仅仅因为一个服务出错就终止整个服务进程。
UnhandledExceptionFilter 中的默认异常处理程序都做了什么呢?当我在一个技术讲座上问起这个问题时,响应者寥寥无几。几乎没有人知道当未处理异常发生时,到底操作系统的默认行为是什么。简单地演示一下这个默认的行为也许会让很多人豁然开朗。我运行一个故意引发错误的程序,其结果如下(如图八)。
图八 未处理异常对话框
表面上看,UnhandledExceptionFilter 显示了一个对话框告诉你发生了一个错误。这时,你被给予了一个机会要么终止出错进程,要么调试它。但是幕后发生了许多事情,我会在文章最后详细讲述它。
正如我让你看到的那样,当异常发生时,用户写的代码可以(并且通常是这样)获得机会执行。同样,在操作过程中,用户写的代码可以执行。此用户编写的代码也可能有缺陷并可能引发另一个异常。由于这个原因,异常处理回调函数也可以返回另外两个值: ExceptionNestedException 和 ExceptionCollidedUnwind。很明显,它们很重要。但这是非常复杂的问题,我并不打算在这里详细讲述它们。要想理解其基本概念真的太困难了。
虽然我在前面偶尔也使用了__try 和__except,但迄今为止几乎我写的所有内容都是关于操作系统方面对 SEH 的实现。然而看一下我那两个使用操作系统的原始 SEH 的小程序别扭的样子,编译器对这个功能进行封装实在是非常有必要的。现在让我们来看一下 Visual C++ 是如何在操作系统对 SEH 功能实现的基础上来创建它自己的结构化异常处理支持的。
在继续往下讨论之前,记住其它编译器可以使用原始的系统 SEH 来做一些完全不同的事情这一点是非常重要的。没有谁规定编译器必须实现 Win32 SDK 文档中描述的__try/__except 模型。例如 Visual Basic 5.0 在它的运行时代码中使用了结构化异常处理,但是那里的数据结构和算法与我这里要讲的完全不同。
如果你把 Win32 SDK 文档中关于结构化异常处理方面的内容从头到尾读一遍,一定会遇到下面所谓的“基于帧”的异常处理程序模型:
try { // guarded body of code } except (filter-expression) { // exception-handler block }
简单地说,某个函数__try 块中的所有代码是由 EXCEPTION_REGISTRATION 结构来保护的,该结构建立在此函数的堆栈帧上。在函数的入口处,这个新的 EXCEPTION_REGISTRATION 结构被放在异常处理程序链表的头部。在__try 块结束后,相应的 EXCEPTION_REGISTRATION 结构从这个链表的头部被移除。正如我前面所说,异常处理程序链表的头部被保存在 FS:[0] 处。因此,如果你在调试器中单步跟踪时能看到类似下面的指令
MOV DWORD PTR FS:[00000000],ESP 或者 MOV DWORD PTR FS:[00000000],ECX
就能非常确定这段代码正在进入或退出一个__try/__except块。
既然一个__try 块对应着堆栈上的一个 EXCEPTION_REGISTRATION 结构,那么 EXCEPTION_REGISTRATION 结构中的回调函数又如何呢?使用 Win32 的术语来说,异常处理回调函数对应的是过滤器表达式(filter-expression)代码。事实上,过滤器表达式就是__except 关键字后面的小括号中的代码。就是这个过滤器表达式代码决定了后面的大括号中的代码是否执行。
由于过滤器表达式代码是你自己写的,你当然可以决定在你的代码中的某个地方是否处理某个特定的异常。它可以简单的只是一句 “EXCEPTION_EXECUTE_HANDLER”,也可以先调用一个把p计算到20,000,000位的函数,然后再返回一个值来告诉操作系统下一步做什么。随你的便。关键是你的过滤器表达式代码必须是我前面讲的有效的异常处理回调函数。
我刚才讲的虽然相当简单,但那只不过是隔着有色玻璃看世界罢了。现实是非常复杂的。首先,你的过滤器表达式代码并不是被操作系统直接调用的。事实上,各个 EXCEPTION_REGISTRATION 结构的 handler 域都指向了同一个函数。这个函数在 Visual C++ 的运行时库中,它被称为__except_handler3。正是这个__except_handler3 调用了你的过滤器表达式代码,我一会儿再接着说它。
对我前面的简单描述需要修正的另一个地方是,并不是每次进入或退出一个__try 块时就创建或撤销一个 EXCEPTION_REGISTRATION 结构。相反,在使用 SEH 的任何函数中只创建一个 EXCEPTION_REGISTRATION 结构。换句话说,你可以在一个函数中使用多个 __try/__except 块,但是在堆栈上只创建一个 EXCEPTION_REGISTRATION 结构。同样,你可以在一个函数中嵌套使用 __try 块,但 Visual C++ 仍旧只是创建一个 EXCEPTION_REGISTRATION 结构。
如果整个 EXE 或 DLL 只需要单个的异常处理程序(__except_handler3),同时,如果单个的 EXCEPTION_REGISTRATION 结构就能处理多个__try 块的话,很明显,这里面还有很多东西我们不知道。这个技巧是通过一个通常情况下看不到的表中的数据来完成的。由于本文的目的就是要深入探索结构化异常处理,那就让我们来看一看这些数据结构吧。
扩展的异常处理帧
Visual C++ 的 SEH 实现并没有使用原始的 EXCEPTION_REGISTRATION 结构。它在这个结构的末尾添加了一些附加数据。这些附加数据正是允许单个函数(__except_handler3)处理所有异常并将执行流程传递到相应的过滤器表达式和__except 块的关键。我在 Visual C++ 运行时库源代码中的 EXSUP.INC 文件中找到了有关 Visual C++ 扩展的 EXCEPTION_REGISTRATION 结构格式的线索。在这个文件中,你会看到以下定义(已经被注释掉了):
;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; ;};
在前面你已经见过前两个域:prev 和 handler。它们组成了基本的 EXCEPTION_REGISTRATION 结构。后面三个域:scopetable(作用域表)、trylevel 和_ebp 是新增加的。scopetable 域指向一个 scopetable_entry 结构数组,而 trylevel 域实际上是这个数组的索引。最后一个域_ebp,是 EXCEPTION_REGISTRATION 结构创建之前栈帧指针(EBP)的值。
_ebp 域成为扩展的 EXCEPTION_REGISTRATION 结构的一部分并非偶然。它是通过 PUSH EBP 这条指令被包含进这个结构中的,而大多数函数开头都是这条指令(通常编译器并不为使用FPO优化的函数生成标准的堆栈帧,这样其第一条指令可能不是 PUSH EBP。但是如果使用了SEH的话,那么无论你是否使用了FPO优化,编译器一定生成标准的堆栈帧)。这条指令可以使 EXCEPTION_REGISTRATION 结构中所有其它的域都可以用一个相对于栈帧指针(EBP)的负偏移来访问。例如 trylevel 域在 [EBP-04]处,scopetable 指针在[EBP-08]处,等等。(也就是说,这个结构是从[EBP-10H]处开始的。)
紧跟着扩展的 EXCEPTION_REGISTRATION 结构下面,Visual C++ 压入了另外两个值。紧跟着(即[EBP-14H]处)的一个DWORD,是为一个指向 EXCEPTION_POINTERS 结构(一个标准的Win32 结构)的指针所保留的空间。这个指针就是你调用 GetExceptionInformation 这个API时返回的指针。尽管SDK文档暗示 GetExceptionInformation 是一个标准的 Win32 API,但事实上它是一个编译器内联函数。当你调用这个函数时,Visual C++ 生成以下代码:
MOV EAX,DWORD PTR [EBP-14]
GetExceptionInformation 是一个编译器内联函数,与它相关的 GetExceptionCode 函数也是如此。此函数实际上只是返回 GetExceptionInformation 返回的数据结构(EXCEPTION_POINTERS)中的一个结构(EXCEPTION_RECORD)中的一个域(ExceptionCode)的值。当 Visual C++ 为 GetExceptionCode 函数生成下面的指令时,它到底是想干什么?我把这个问题留给读者。(现在就能理解为什么SDK文档提醒我们要注意这两个函数的使用范围了。)
MOV EAX,DWORD PTR [EBP-14] MOV EAX,DWORD PTR [EAX] MOV EAX,DWORD PTR [EAX]
现在回到扩展的 EXCEPTION_REGISTRATION 结构上来。在这个结构开始前的8个字节处(即[EBP-18H]处),Visual C++ 保留了一个DWORD来保存所有prolog代码执行完毕之后的堆栈指针(ESP)的值(实际生成的指令为MOV DWORD PTR [EBP-18H],ESP)。这个DWORD中保存的值是函数执行时ESP寄存器的正常值(除了在准备调用其它函数时把参数压入堆栈这个过程会改变 ESP寄存器的值并在函数返回时恢复它的值外,函数在执行过程中一般不改变ESP寄存器的值)。
看起来好像我一下子给你灌输了太多的信息,我承认。在继续下去之前,让我们先暂停,来回顾一下 Visual C++ 为使用结构化异常处理的函数生成的标准异常堆栈帧,它看起来像下面这个样子:
EBP-00 _ebp EBP-04 trylevel EBP-08 scopetable pointer EBP-0C handler function address EBP-10 previous EXCEPTION_REGISTRATION EBP-14 GetExceptionPointers EBP-18 Standard ESP in frame
在操作系统看来,只存在组成原始 EXCEPTION_REGISTRATION 结构的两个域:即[EBP-10h]处的prev指针和[EBP-0Ch]处的handler函数指针。栈帧中的其它所有内容是针对于Visual C++的。把这个Visual C++生成的标准异常堆栈帧记到脑子里之后,让我们来看一下真正实现编译器层面SEH的这个Visual C++运行时库例程——__except_handler3。