原著:Matt Pietrek

翻译:VCKBASE


译注:本文都翻译了一大半了,才发现网上已经有一篇董岩的译本(http://www.diybl.com/course/3_program/c++/cppsl/200866/122881.html),
看完他的译文,感觉所有涉及关键技术的地方董岩翻译得非常到位,所以自己就没有再花时间往下译,本文后面的部分基本上都是采用了董岩的译文。此外,董岩在译文后面附带有一篇附录,专门解释“prolog 和 epilog”,后来我发现 MSDN 库中对此有专门的解释,内容也更丰富,所以就用微软文档中的内容取而代之了。特此说明。


原文出处:A Crash Course on the Depths of Win32? Structured Exception Handling

下载源代码

本文假设你熟悉 C++,Win32

摘要

  Win32 结构化异常处理其核心是操作系统提供的服务,你能找到的关于 SEH 的所有文档都是描述一个特定的编译器运行时库,这个运行库包装着操作系统实现。在本文中,我将一层一层对 SEH 进行剥离,以便展现其最基本的概念。

  在 Win32 操作系统提供的所有功能中,使用最广泛但最缺乏文档描述的也许就是结构化异常处理了(SEH),当你考虑 Win32 结构化异常处理时,你也许会想到诸如 _try,_finally 以及 _except 这些术语。你能在任何有关 Win32 的书中发现对 SEH 很好的描述(即使是 remedial)。即便是 Win32 SDK 也具备有相当完整的使用 _try,_finally 和 _except 进行结构化异常处理的概述。
有了这些文档,那为何还说 SEH 缺乏文档呢?其实,Win32 结构化异常处理是操作系统提供的一个服务。你能找到的关于 SEH 的所有文档都是描述特定编译器的运行时库,这个运行库对操作系统实现进行包装。_try,_finally 和 _except 这些关键字没有任何神奇的地方。微软的操作系统及其编译器系列定义这些关键字和用法。其他的编译器提供商则只是沿用这些语义。虽然借助编译器层的 SEH 可以挽回一些原始操作系统级 SEH 处理不良口碑,但在大众眼里对原始操作系统 SEH 细节的处理感觉依旧。
我收到人们大量的e-mail,都是想要实现编译器级的 SEH 处理,又无法找到操作系统功能提供的相关文档。通常我都是建议参考 Visual C++ 或者 Borland C++ 运行库源代码。唉,出于一些未知的原因,编译器级的 SEH 似乎是一个大的秘密,微软和 Borland 都不提供其对 SEH 支持的核心层源代码。
在本文中,我将一层一层对 SEH 进行解剖,以便展现其最基本的概念。我打算通过代码产生和运行时库支持将操作系统提供的功能和编译器提供的功能分开。当我深入代码考察关键的操作系统例程时,我将使用 Intel 平台上的 Windows NT4.0 作为基础。但我将要描述的大多数内容同样适用于其它处理器上运行的应用。
我打算避免涉及到真正的 C++ 异常处理,它们使用 catch(),而不是 _except。其实,真正的 C++ 异常处理实现非常类似于本文中描述的内容。但是 C++ 异常处理有一些额外的复杂性会影响我想要涉及的概念。 
通过深入研究晦涩的 .H 和 .INC 文件来归纳 Win32 SEH 构成,我发现有一个信息源之一就是 IBM OS/2 头文件(尤其是 BSEXCPT.H)。为此你不要觉得大惊小怪。。此处描述的 SEH 机制在其源头被定义时,微软仍然开发 OS/2 平台(译注: OS/2 平台起初是IBM 和 微软共同研发的,后来由于种种原因两个公司没有再继续下去)。所以你会发现Win32 下的 SEH 和 OS/2 下的 SEH 极其相似。

SEH 浅析

  从整体来看,SEH 的可谓不可一世,绝对压倒一切,我将从细微之处开始,用我自己的方式一层一层研究。如果你是一张白纸,以前从没接触过结构化异常处理,那就最好不过了。如果你以前使用过 SEH。那就尝试清理你头脑中的 _try,GetExceptionCode 和 EXCEPTION_EXECUTE_HANDLER 等诸如此类的词,权当自己是个新手。做一个深呼吸,准备好了吗?好,我们开始。
想象一下,我告诉你某个线程出错了,操作系统给你一个机会通知了这个线程错误,或者再具体一点,当线程出错后,操作系统调用某个用户定义的回调函数。这个回调函数可以所任何它想做的事情。例如,它可以修复任何原因导致的错误,或者播放一个 .wav 文件。不管回调函数做什么,其最后总是返回一个值,这个值告诉系统下一步做什么。(这里描述的情况不一定完全一样,但足够接近。)
假定当你的代码出现了混乱,你不得不回来,想看看回调函数是什么样子的?换句话说,你想知道什么样的异常信息呢?其实这无关紧要,因为 Win32 已经帮你决定了。一个异常回调函数就象下面这样:

EXCEPTION_DISPOSITION __cdecl _except_handler( struct _EXCEPTION_RECORD *ExceptionRecord, void * EstablisherFrame, struct _CONTEXT *ContextRecord, void * DispatcherContext );

  该原型出自标准的 Win32 头文件 EXCPT.H,初看就有那么一点不同凡响。如果你慢慢研究,其实并没有那么糟。例如,忽略返回类型(EXCEPTION_DISPOSITION)。基本上你看到的就是一个叫做 _except_handler 的函数,这个函数带有四个参数。
第一个参数是指向 EXCEPTION_RECORD 结构指针,该结构在 WINNT.H 中定义如下:

typedef struct _EXCEPTION_RECORD { DWORD ExceptionCode; DWORD ExceptionFlags; struct _EXCEPTION_RECORD *ExceptionRecord; PVOID ExceptionAddress; DWORD NumberParameters; DWORD ExceptionInformation[EXCEPTION_MAXIMUM_PARAMETERS]; } EXCEPTION_RECORD;

  ExceptionCode 参数是由操作系统赋值给异常的一个数。你可以在 WINNT.H 文件中搜一下“STATUS_”开始的 #defines 内容便可以得到一系列不同的异常编码。例如 STATUS_ACCESS_VIOLATION 是大家再熟悉不过的异常编码了,其值是 0xC0000005。更复杂的异常编码可以从 Windows NT DDK 的 NTSTATUS.H 文件中找到。EXCEPTION_RECORD 结构中的第四个元素是异常发生的地址。剩下的 EXCEPTION_RECORD 域现在可以忽略,不用管它。
_except_handler 回调函数的第二个参数是指向建立者框架(establisher frame)结构的指针,在 SEH 中它是一个至关重要的参数,但现在可以不用关心它。
_except_handler 回调函数的第三个参数是 CONTEXT 结构的指针。CONTEXT 结构在 WINNT.H 中定义,它表示特定线程异常发生时寄存器的值:

typedef struct _CONTEXT { DWORD ContextFlags; DWORD Dr0; DWORD Dr1; DWORD Dr2; DWORD Dr3; DWORD Dr6; DWORD Dr7; FLOATING_SAVE_AREA FloatSave; DWORD SegGs; DWORD SegFs; DWORD SegEs; DWORD SegDs; DWORD Edi; DWORD Esi; DWORD Ebx; DWORD Edx; DWORD Ecx; DWORD Eax; DWORD Ebp; DWORD Eip; DWORD SegCs; DWORD EFlags; DWORD Esp; DWORD SegSs; } CONTEXT;

此外,这个 CONTEXT 结构与 GetThreadContext 和 SetThreadContext API 函数使用的结构是相同的。
_except_handler 回调函数的第四个参数是 DispatcherContext。现在也可以忽略它。
为了简化起见,当异常发生时,你有一个回调函数被调用。此回调函数带四个参数,其中三个是结构指针。在这些结构中,某些域是很重要的,其余的不是那么重要。关键是 _except_handler 回调函数接收
很多信息,比如发生了什么类型的异常,在哪里发生的。利用这些信息,异常回调机制需要确定要做什么。
虽然我迫不急但地想抛出例子程序示范 _except_handler 回调的运行,但还有一些事情不能漏掉,需要说明。特别是当错误发生时,操作系统如何知道到哪里调用?答案仍然涉及另外一个结构 EXCEPTION_REGISTRATION。你将自始自终在本文中看到这个结构,所以不要掠过这部分内容。我能找到正式定义 EXCEPTION_REGISTRATION 结构的唯一地方是 EXSUP.INC 文件,该文件来自 Visual C++ 运行库的源:

_EXCEPTION_REGISTRATION struc prev dd ? handler dd ? _EXCEPTION_REGISTRATION ends

  你还将看到该结构在 WINNT.H 文件中定义的 NT_TIB 结构中被引用为 _EXCEPTION_REGISTRATION_RECORD。唉,除此之外,没有什么地方能找到 _EXCEPTION_REGISTRATION_RECORD 的定义,所以我只能使用 EXSUP.INC 文件中定义的汇编语言结构。这也是我为什么在本文前述内容中说过的 SEH 缺乏文档的一个例证。
不管怎样,让我们回到手头的问题,当某个异常发生时,OS 如何知道到哪里调用回调函数?EXCEPTION_REGISTRATION 由两个域构成,第一个你现在可以忽略。第二个域是句柄,它包含 _except_handler 回调函数的指针。这让你更接近一点了,但目前问题来了,OS 在哪里查找并发现 EXCEPTION_REGISTRATION 结构?
为了回答这个问题,回想一下结构化异常处理是以线程为基础,并作用在每个线程上,明白这一点是有助于理解的。也就是说,每个线程具备其自己的异常处理回调函数。在我1996年5月的专栏文章中,我描述了一个关键的 Win32 数据结构——线程信息块(即 TEB 和 TIB)。该数据结构的某些域在 Windows NT、Windows 95、Win32s 和 OS/2 平台上是一样的。TIB 中的第一个 DWORD 是指向线程 EXCEPTION_REGISTRATION 结构的指针。在 Intel Win32 平台上,FS 寄存器总是指向当前的 TIB。因此,在 FS:[0]位置,你能找到 EXCEPTION_REGISTRATION 结构的指针。
现在我们知道了,当异常发生时,系统检查出错线程的 TIB 并获取 EXCEPTION_REGISTRATION 结构的指针。这个结构中就有一个 _except_handler 回调函数的指针。这些信息足以让操作系统知道在哪里以及如何调用 _except_handler 函数,如图二所示:
 

Win32 结构化异常处理(SEH)探秘【上篇】_C/C++


图二 _except_handler 函数 

通过前面的描述,我写了一个小程序来对操作系统层的结构化异常进行示范。程序代码如下:

//================================================== // MYSEH - Matt Pietrek 1997 // Microsoft Systems Journal, January 1997 // FILE: MYSEH.CPP // To compile: CL MYSEH.CPP //================================================== #define WIN32_LEAN_AND_MEAN #include <windows.h> #include <stdio.h> DWORD scratch; EXCEPTION_DISPOSITION __cdecl _except_handler( struct _EXCEPTION_RECORD *ExceptionRecord, void * EstablisherFrame, struct _CONTEXT *ContextRecord, void * DispatcherContext ) { unsigned i; // Indicate that we made it to our exception handler printf( "Hello from an exception handler/n" ); // Change EAX in the context record so that it points to someplace // where we can successfully write ContextRecord->Eax = (DWORD)&scratch; // Tell the OS to restart the faulting instruction return ExceptionContinueExecution; } int main() { DWORD handler = (DWORD)_except_handler; __asm { // 创建 EXCEPTION_REGISTRATION 结构: push handler // handler函数的地址 push FS:[0] // 前一个handler函数的地址 mov FS:[0],ESP // 装入新的EXECEPTION_REGISTRATION结构 } __asm { mov eax,0 // EAX清零 mov [eax], 1 // 写EAX指向的内存从而故意引发一个错误 } printf( "After writing!/n" ); __asm { // 移去我们的 EXECEPTION_REGISTRATION 结构记录 mov eax,[ESP] // 获取前一个结构 mov FS:[0], EAX // 装入前一个结构 add esp, 8 // 将 EXECEPTION_REGISTRATION 弹出堆栈 } return 0; }

  代码中只有两个函数,main 函数使用了三部分内联汇编块 ASM。第一个 ASM 块通过两个 PUSH 指令(即:“PUSH handler”和“PUSH FS:[0]”)在堆栈上建立一个 EXCEPTION_REGISTRATION 结构。PUSH FS:[0] 保存以前 FS:[0] 的值,它是结构的一部分,但目前这个值对我们不重要。重要的是在堆栈上有一个 8-byte 的 EXCEPTION_REGISTRATION 结构。紧接着的指令(MOV FS:[0],ESP)是让线程信息块中的第一个 DWORD 指到新的 EXCEPTION_REGISTRATION 指令。
如果你想知道为什么我要在堆栈上建立这个 EXCEPTION_REGISTRATION 结构,而不是使用全局变量,有一个很好的理由。当你使用编译器的 _try/_except 时,编译器也会在堆栈上建立 EXCEPTION_REGISTRATION 结构。我只是向你简要地揭示你使用 _try/_except 时编译器所做的事情。让我们回到 main 函数,下一个 __asm 块是通过把 EAX 寄存器清零(MOV EAX,0),然后把此寄存器的值作为内存地址让下一条指令(MOV [EAX],1)向此地址写入数据而故意引发一个错误。最后一个 __asm 块是清除这个简单的异常处理例程:首先它恢复以前的 FS:[0] 内容,然后它将 EXCEPTION_REGISTRATION 结构记录从堆栈中弹出(ADD ESP,8)。
现在,假设你正在运行 MYSEH.EXE 并会看到所发生的事情。当 MOV [EAX],1 指令执行时,它导致一个数据访问违例。系统察看 TIB 中的 FS:[0] 并找到 EXCEPTION_REGISTRATION 结构指针。此结构中则有一个指向 MYSEH.CPP 中 _except_handler 函数的指针。系统则将四个必须的参数(我在前面描述过这四个参数)压入堆栈并调用 _except_handler 函数。
一旦进入 _except_handler,代码首先通过 printf 指示“哈!这里是我干的!”。接着,_except_handler 修复导致出错的问题。即 EAX 寄存器指向某个不能写入的内存地址(地址 0)。修复方法是在改变 CONTEXT 结构中的 EAX 的值,以便它指向某个允许进行写入操作的位置。在这个简单的程序中,DWORD 变量(scratch)是故意为此而设计的。_except_handler 函数最后一个动作时返回 ExceptionContinueExecution 值,它在标准的 EXCPT.H 文件中定义。
当操作系统看到返回值为 ExceptionContinueExecution。它就认为你已经修复了问题,并且引起错误的指令应该被重新执行。因为我的 _except_handler 函数强制 EAX 寄存器指向合法内存,MOV EAX,1 指令再次执行,函数 main 一切正常。看,这并不复杂,不是吗?


进一步深入

  有了前面的最简单的例子,让我们再回过头去填补一些空白。虽然这个异常回调机制很棒,但它并不是一个完美的解决方案。对于稍微复杂一些的应用程序来说,仅用一个函数就能处理程序中任何地方都可能发生的异常是相当困难的。一个更实用的方案应该是有多个异常处理例程,每个例程针对程序的特定部分。不知你是否知道,实际上,操作系统提供的正是这个功能。
还记得系统用来查找异常回调函数的 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 结构并询问它的异常回调函数,看它是否愿意处理这个异常。图四显示了这个过程:

 

Win32 结构化异常处理(SEH)探秘【上篇】_职场_02

图四 查找处理异常的 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 // 使用命令行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" ); // 我们不想处理这个异常,让其它函数处理吧 return ExceptionContinueSearch; } void HomeGrownFrame( void ) { DWORD handler = (DWORD)_except_handler; __asm { // 创建EXCEPTION_REGISTRATION结构: push handler // handler函数的地址 push FS:[0] // 前一个handler函数的地址 mov FS:[0],ESP // 安装新的EXECEPTION_REGISTRATION结构 } *(PDWORD)0 = 0; // 写入地址0,从而引发一个错误 printf( "I should never get here!/n" ); __asm { // 移去我们的EXECEPTION_REGISTRATION结构 mov eax,[ESP] // 获取前一个结构 mov FS:[0], EAX // 安装前一个结构 add esp, 8 // 把我们EXECEPTION_REGISTRATION结构弹出堆栈 } } 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 代码的函数的堆栈上的值。