Matt Pietrek 著
董岩 译
原文:http://www.microsoft.com/msj/0197/exception/exception.aspx
在Win32操作系统提供的所有功能中,使用最广泛而又没有公开的恐怕要数结构化异常处理(Structured Exception Handling,SEH)了。当你考虑Win32结构化异常处理时,也许会想到__try、__finally和__except等术语。可能你在任何一本讲解Win32的好书上都能找到关于SEH较为详细的描述,甚至Win32 SDK文档也对使用__try、__finally和__except进行结构化异常处理进行了相当完整的描述。
既然已经有了这些文档,那为什么我还说SEH并未公开呢?本质上来说,Win32结构化异常处理是操作系统提供的服务。你可能找到的所有关于SEH方面的文档都只是描述了某个特别的编译器的运行时库对操作系统实现的封装。关键字__try、__finally或者__except并没有什么神奇的。 Microsoft的操作系统和编译器开发小组定义了这些关键字和它们的作用。其它C++编译器厂商完全按照它们的语义来就可以了。当编译器的SEH支持层把原始的操作系统SEH的复杂性封装起来的时候,它同时也把原始的操作系统SEH的细节隐藏了起来。
我曾经接到大量来自想自己实现编译器层面SEH的人发来的电子邮件,他们苦于找不到关于操作系统SEH实现方面的任何文档。按说,我应该能够告诉他们 Visual C++或Borland C++的运行时库源代码就是他们想要的。但是不知出于什么原因,编译器层面的SEH看起来好像是个大秘密。无论是Microsoft还是Borland都没有提供他们的SEH支持层最底层的源代码。(现在Microsoft仍然没有提供这些源代码,它提供的是编译过的目标文件,而Borland则提供了相应的源代码。)
在本文中,我会剥掉结构化异常处理外面的包装直至其最基本的概念。在此过程中,我会把操作系统提供的支持与编译器通过代码生成和运行时库提供的支持分开来说。当我挖掘到关键的操作系统例程时,我使用的是运行于Intel处理器上的Windows NT 4.0。但是我这里讲的大部分内容同样也适用于其它处理器。
我会避免涉及到真实的C++异常处理,它使用的是catch()而不是__except。从内部来讲,真实的C++异常处理的实现与我这里要讲的非常相似。但是真实的C++异常处理有一些其它的复杂问题,它会混淆我这里要讲的一些概念。
在挖掘组成Win32 SEH的晦涩的.H和.INC文件的过程中,我发现最好的信息来源之一是IBM OS/2头文件(特别是BSEXCPT.H)。如果你涉足这方面已经有一段时间了,就不会感到太奇怪。这里描述的SEH机制是早在Microsoft还工作在OS/2上时就已经定义好的。由于这个原因,你会发现Win32下的SEH和OS/2下的SEH极其相似。(现在我们可能已经没有机会体验这一点了,OS/2已经永远成为历史了。)
浅析SEH
如果我把SEH的所有细节一股脑儿全倒给你,你可能无法接受。因此我先从一小部分开始,然后层层深入。如果你以前从未接触过结构化异常处理,那正好,因为你头脑中没有一些自己设想的概念。如果你以前接触过SEH,最好把头脑中有关__try、GetExceptionCode和 EXCEPTION_EXECUTE_HANDLER之类的词统统忘掉,假设它对你来说是全新的。深呼吸。准备好了吗?让我们开始吧!
设想我告诉过你,当一个线程出现错误时,操作系统给你一个机会被告知这个错误。说得更明白一些就是,当一个线程出现错误时,操作系统调用用户定义的一个回调函数。这个回调函数可以做它想做的一切。例如它可以修复错误,或者它也可以播放一段音乐。无论回调函数做什么,它最后都要返回一个值来告诉系统下一步做什么。(这不是十分准确,但就此刻来说非常接近。)
当你的某一部分代码出错时,系统再回调你的其它代码,那么这个回调函数看起来是什么样子呢?换句话说,你想知道关于异常什么类型的信息呢?实际上这并不重要,因为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;
这个结构中的 ExcepitonCode成员是赋予异常的代码。通过在WINNT.H中搜索以“STATUS_”开头的#define定义,你可以得到一个异常代码列表。例如所有人都非常熟悉的STATUS_ACCESS_VIOLATION的代码是0xC0000005。一个更全面的异常代码列表可以在 Windows NT DDK的NTSTATUS.H中找到。此结构的第四个成员是异常发生的地址。其它成员暂时可以忽略。
_except_handler函数的第二个参数是一个指向establisher帧结构的指针。它是SEH中一个至关重要的参数,但是现在你可以忽略它。
_except_handler回调函数的第三个参数是一个指向CONTEXT结构的指针。此结构在WINNT.H中定义,它代表某个特定线程的寄存器值。图1显示了CONTEXT结构的成员。当用于SEH时,CONTEXT结构表示异常发生时寄存器的值。顺便说一下,这个CONTEXT结构就是GetThreadContext和SetThreadContext这两个API中使用的那个CONTEXT结构。
图1 CONTEXT结构
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;
_except_handler回调函数的第四个参数被称为DispatcherContext。它暂时也可以被忽略。
到现在为止,你头脑中已经有了一个当异常发生时会被操作系统调用的回调函数的模型了。这个回调函数带四个参数,其中三个指向其它结构。在这些结构中,一些域比较重要,其它的就不那么重要。这里的关键是_exept_handler回调函数接收到操作系统传递过来的许多有价值的信息,例如异常的类型和发生的地址。使用这些信息,异常回调函数就能决定下一步做什么。
对我来说,现在就写一个能够显示_except_handler作用的样例程序是再诱人不过的了。但是我们还缺少一些关键信息。特别是,当错误发生时操作系统是怎么知道到哪里去调用这个回调函数的呢?答案是还有一个称为EXCEPTION_REGISTRATION的结构。通篇你都会看到这个结构,所以不要跳过这一部分。我唯一能找到的EXCEPTION_REGISTRATION结构的正式定义是在Visual C++运行时库源代码中的EXSUP.INC文件中:
_EXCEPTION_REGISTRATION struc
prev dd ?
handler dd ?
_EXCEPTION_REGISTRATION ends
这个结构在WINNT.H的NT_TIB结构的定义中被称为_EXCEPITON_REGISTARTION_RECORD。唉,没有一个地方能够找到 _EXCEPTION_REGISTRATION_RECORD的定义,所以我不得不使用EXSUP.INC中这个汇编语言的结构定义。这是我前面所说 SEH未公开的一个证据。(读者可以使用内核调试器,如KD或SoftICE并加载调试符号来查看这个结构的定义。
下图是在KD中的结果:
下图是在SoftICE中的结果:
译者注)
无论正在干什么,现在让我们回到手头的问题上来。当异常发生时,操作系统是如何知道到哪里去调用回调函数的呢?实际上,EXCEPTION_REGISTARTION结构由两个域组成,第一个你现在可以忽略。第二个域handler,包含一个指向 _except_handler回调函数的指针。这让你离答案更近一点,但现在的问题是,操作系统到哪里去找 EXCEPTION_REGISTATRION结构呢?
要回答这个问题,记住结构化异常处理是基于线程的这一点是非常有用的。也就是说,每个线程有它自己的异常处理回调函数。在1996年五月的Under The Hood专栏中,我介绍了一个关键的Win32数据结构——线程信息块(Thread Information/Environment Block,TIB或TEB)。这个结构的某些域在Windows NT、Windows 95、Win32s和OS/2上是相同的。TIB的第一个DWORD是一个指向线程的EXCEPTION_REGISTARTION结构的指针。在基于Intel处理器的Win32平台上,FS寄存器总是指向当前的TIB。因此在FS:[0]处你可以找到一个指向EXCEPTION_REGISTARTION结构的指针。
到现在为止,我们已经有了足够的认识。当异常发生时,系统查找出错线程的TIB,获取一个指向EXCEPTION_REGISTRATION结构的指针。在这个结构中有一个指向_except_handler回调函数的指针。现在操作系统已经知道了足够的信息去调用_except_handler函数,如图 2所示。
图2 _except_handler函数
把这些小块知识拼凑起来,我写了一个小程序来演示上面这个对操作系统层面的结构化异常处理的简化描述,如图3的MYSEH.CPP所示。它只有两个函数。 main函数使用了三个内联汇编块。第一个内联汇编块通过两个PUSH指令(“PUSH handler”和“PUSH FS:[0]”)在堆栈上创建了一个EXCEPTION_REGISTRATION结构。PUSH FS:[0]这条指令保存了先前的FS:[0]中的值作为这个结构的一部分,但这在此刻并不重要。重要的是现在堆栈上有一个8字节的 EXCEPTION_REGISTRATION结构。紧接着的下一条指令(MOV FS:[0],ESP)使线程信息块中的第一个DWORD指向了新的EXCEPTION_REGISTRATION结构。(注意堆栈操作)
图3 MYSEH.CPP
//==================================================
// MYSEH - Matt Pietrek 1997
// Microsoft Systems Journal, January 1997
// FILE: MYSEH.CPP
// 用命令行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;
// 指明是我们让流程转到我们的异常处理程序的
printf( "Hello from an exception handler/n" );
// 改变CONTEXT结构中EAX的值,以便它指向可以成功进写操作的位置
ContextRecord->Eax = (DWORD)&scratch;
// 告诉操作系统重新执行出错的指令
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;
}
如果你想知道我为什么把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这条指令执行时,它引发一个访问违规。系统在FS:[0]处的TIB中查找,然后发现了一个指向 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结构并询问它的异常回调函数,看它是否同意处理这个异常。图 4显示了这个过程。一旦系统找到一个处理这个异常的回调函数,它就停止遍历链表。
图4 查找一个处理异常的EXCEPTION_REGISTRATION结构
图 5的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()”。此时我们只是简单地忽略这个异常来表明我们已经处理了它。
图5 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。这把我们带入到了展开(Unwinding)的世界中。实际上,当一个异常处理回调函数拒绝处理某个异常时,它会被再一次调用。但是这次回调并不是立即发生的。这有点复杂。我需要把异常发生时的情形好好梳理一下。
当异常发生时,系统遍历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结构高。图6显示了我说的情况。
图6 从异常展开
迄今为止,我实际上一直在假设操作系统总是能在EXCEPTION_REGISTRATION结构链表中找到一个异常处理程序。如果找不到怎么办呢?实际上,这几乎不可能发生。因为操作系统暗中已经为每个线程都提供了一个默认的异常处理程序。这个默认的异常处理程序总是链表的最后一个结点,并且它总是选择处理异常。它进行的操作与其它正常的异常处理回调函数有些不同,下面我会说明。
让我们来看一下系统是在什么时候插入了这个默认的、最后一个异常处理程序。很明显它需要在线程执行的早期,在任何用户代码开始执行之前。图7是我为BaseProcessStart函数写的伪代码,它是Windows NT KERNEL32.DLL的一个内部例程。这个函数带一个参数——线程入口点函数的地址。BaseProcessStart运行在新进程的环境中,并且它调用这个进程的第一个线程的入口点函数。
图7 BaseProcessStart伪代码
BaseProcessStart( PVOID lpfnEntryPoint )
{
DWORD retValue;
DWORD currentESP;
DWORD exceptionCode;
currentESP = ESP;
__try
{
NtSetInformationThread( GetCurrentThread(),
ThreadQuerySetWin32StartAddress,
&lpfnEntryPoint,
sizeof(lpfnEntryPoint) );
retValue = lpfnEntryPoint();
ExitThread( retValue );
}
__except( //过滤器表达式代码
exceptionCode = GetExceptionInformation(),
UnhandledExceptionFilter( GetExceptionInformation() ) )
{
ESP = currentESP;
if ( !_BaseRunningInServerProcess ) // 普通进程
ExitProcess( exceptionCode );
else // 服务
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中的默认异常处理程序都做了什么呢?当我在一个技术讲座上问起这个问题时,响应者寥寥无几。几乎没有人知道当未处理异常发生时,到底操作系统的默认行为是什么。简单地演示一下这个默认的行为也许会让很多人豁然开朗。我运行一个故意引发错误的程序,其结果如下(见图8)。
图8 未处理异常对话框
表面上看,UnhandledExceptionFilter显示了一个对话框告诉你发生了一个错误。这时,你被给予了一个机会或者终止出错进程,或者调试它。但是幕后发生了许多事情,我会在文章最后详细讲述它。
正如我让你看到的那样,当异常发生时,用户写的代码可以(并且通常是这样)获得机会执行。同样,在展开操作期间,用户写的代码也可以执行。这个用户写的代码可能也有错误,并且可能引发另一个异常。由于这个原因,异常处理回调函数也可以返回另外两个值:ExceptionNestedException和ExceptionCollidedUnwind。很明显,它们很重要。但这是非常复杂的问题,我并不打算在这里涉及它们。要想理解其中的一些基本问题太困难了。
编译器层面的SEH
虽然我在前面偶尔也使用了__try和__except,但迄今为止几乎我写的所有内容都是关于操作系统方面对SEH的实现。然而看一下我那两个使用操作系统的原始SEH的小程序别扭的样子,编译器对这个功能进行封装实在是非常有必要的。现在让我们来看一下Visual C++是如何在操作系统对SEH功能实现的基础上来创建它自己的结构化异常处理支持的。
在我们继续下去之前,记住其它编译器可以使用原始的系统SEH来做一些完全不同的事情这一点是非常重要的。并没有什么规定编译器必须实现Win32 SDK文档中描述的__try/__except模型。例如Visual Basic 5.0在它的运行时代码中使用了结构化异常处理,但是那里的数据结构和算法与我这里要讲的完全不同。
如果你把Win32 SDK文档中关于结构化异常处理方面的内容从头到尾读一遍,一定会遇到下面所谓的“基于帧”的异常处理程序模型:
__try {
// 这里是被保护的代码
}
__except (过滤器表达式) {
// 这里是异常处理程序代码
}
简单地说,在一个函数中,一个__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] ; 执行完毕,EAX指向EXCEPTION_POINTERS结构
MOV EAX,DWORD PTR [EAX] ; 执行完毕,EAX指向EXCEPTION_RECORD结构
MOV EAX,DWORD PTR [EAX] ; 执行完毕,EAX中是ExceptionCode的值
现在回到扩展的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数组指针
EBP-0C handler函数地址
EBP-10指向前一个EXCEPTION_REGISTRATION结构
EBP-14 GetExceptionInformation
EBP-18 栈帧中的标准ESP
在操作系统看来,只存在组成原始EXCEPTION_REGISTRATION结构的两个域:即[EBP-10h]处的prev指针和[EBP-0Ch]处的handler函数指针。栈帧中的其它所有内容是针对于Visual C++的。把这个Visual C++生成的标准异常堆栈帧记到脑子里之后,让我们来看一下真正实现编译器层面SEH的这个Visual C++运行时库例程——__except_handler3。
__except_handler3和scopetable
我真的很希望让你看一看Visual C++运行时库源代码,让你自己好好研究一下__except_handler3函数,但是我办不到。因为Microsoft并没有提供。在这里你就将就着看一下我为__except_handler3函数写的伪代码吧(如图9所示)。
图9 __except_handler3函数的伪代码
int __except_handler3(
struct _EXCEPTION_RECORD * pExceptionRecord,
struct EXCEPTION_REGISTRATION * pRegistrationFrame,
struct _CONTEXT *pContextRecord,
void * pDispatcherContext )
{
LONG filterFuncRet;
LONG trylevel;
EXCEPTION_POINTERS exceptPtrs;
PSCOPETABLE pScopeTable;
CLD // 将方向标志复位(不测试任何条件!)
// 如果没有设置EXCEPTION_UNWINDING标志或EXCEPTION_EXIT_UNWIND标志
// 表明这是第一次调用这个处理程序(也就是说,并非处于异常展开阶段)
if ( ! (pExceptionRecord->ExceptionFlags
& (EXCEPTION_UNWINDING | EXCEPTION_EXIT_UNWIND)) )
{
// 在堆栈上创建一个EXCEPTION_POINTERS结构
exceptPtrs.ExceptionRecord = pExceptionRecord;
exceptPtrs.ContextRecord = pContextRecord;
// 把前面定义的EXCEPTION_POINTERS结构的地址放在比
// establisher栈帧低4个字节的位置上。参考前面我讲
// 的编译器为GetExceptionInformation生成的汇编代码*(PDWORD)((PBYTE)pRegistrationFrame - 4) = &exceptPtrs;
// 获取初始的“trylevel”值
trylevel = pRegistrationFrame->trylevel;
// 获取指向scopetable数组的指针
scopeTable = pRegistrationFrame->scopetable;
search_for_handler:
if ( pRegistrationFrame->trylevel != TRYLEVEL_NONE )
{
if ( pRegistrationFrame->scopetable[trylevel].lpfnFilter )
{
PUSH EBP // 保存这个栈帧指针
// !!!非常重要!!!切换回原来的EBP。正是这个操作才使得
// 栈帧上的所有局部变量能够在异常发生后仍然保持它的值不变。
EBP = &pRegistrationFrame->_ebp;
// 调用过滤器函数
filterFuncRet = scopetable[trylevel].lpfnFilter();
POP EBP // 恢复异常处理程序的栈帧指针
if ( filterFuncRet != EXCEPTION_CONTINUE_SEARCH )
{
if ( filterFuncRet < 0 ) // EXCEPTION_CONTINUE_EXECUTION
return ExceptionContinueExecution;
// 如果能够执行到这里,说明返回值为EXCEPTION_EXECUTE_HANDLER
scopetable = pRegistrationFrame->scopetable;
// 让操作系统清理已经注册的栈帧,这会使本函数被递归调用
__global_unwind2( pRegistrationFrame );
// 一旦执行到这里,除最后一个栈帧外,所有的栈帧已经
// 被清理完毕,流程要从最后一个栈帧继续执行
EBP = &pRegistrationFrame->_ebp;
__local_unwind2( pRegistrationFrame, trylevel );
// NLG = "non-local-goto" (setjmp/longjmp stuff)
__NLG_Notify( 1 ); // EAX = scopetable->lpfnHandler
// 把当前的trylevel设置成当找到一个异常处理程序时
// SCOPETABLE中当前正在被使用的那一个元素的内容
pRegistrationFrame->trylevel = scopetable->previousTryLevel;
// 调用__except {}块,这个调用并不会返回
pRegistrationFrame->scopetable[trylevel].lpfnHandler();
}
}
scopeTable = pRegistrationFrame->scopetable;
trylevel = scopeTable->previousTryLevel;
goto search_for_handler;
}
else // trylevel == TRYLEVEL_NONE
{
return ExceptionContinueSearch;
}
}
else // 设置了EXCEPTION_UNWINDING标志或EXCEPTION_EXIT_UNWIND标志
{
PUSH EBP // 保存EBP
EBP = &pRegistrationFrame->_ebp; // 为调用__local_unwind2设置EBP
__local_unwind2( pRegistrationFrame, TRYLEVEL_NONE )
POP EBP // 恢复EBP
return ExceptionContinueSearch;
}
}