如果你运行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块之类的清理工作。(来2次异常的原因,标志被设置为EH_UNWINDING (2))
在 异常已经被处理完毕,并且所有前面的异常帧都已经被展开之后,流程从处理异常的那个回调函数决定的地方开始继续执行。一定要记住,仅仅把指令指针设置到所 需的代码处就开始执行是不行的。流程恢复执行处的代码的堆栈指针和栈帧指针(在Intel CPU上是ESP和EBP)也必须被恢复成它们在处理这个异常的函数的栈帧上的值。因此,这个处理异常的回调函数必须负责把堆栈指针和栈帧指针恢复成它们 在包含处理这个异常的SEH代码的函数的堆栈上的值。(就是在那个异常处理函数处理了 ESP 和 EBP 也必须回复到该函数上, 处理完后 FS:[0] 也必须指到该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 {
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中,这段代码调
InformationThread( GetCurrentThread(),
用 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”,也可以先调用一个把 ? 计算到 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;