若将 SEH 的细节都放到一起讨论,任务实在艰巨,因此,我会从简单的开始,一层一层往深里讲。如果之前从未使用过结构化异常处理,则正好心无杂念。若是用过,那就要努力将 _try、GetExceptionCode 和 EXCEPTION_EXECUTE_HANDLER 从脑子中扫出,假装这是一个全新的概念。Are you ready?Good。
当 线程发生异常时,操作系统会将这个异常通知给用户使用户能够得知它的发生。更特别的是,当线程发生异常时,操作系统会调用用户定义的回调函数。这个回调函 数想做什么就能做什么。例如,它可以修正引起异常的程序,也可以播放一段 .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_”的宏就能找到一大堆这样的异常代号。例如,大家熟知的 STATUS_ACCESS_VIOLATION 的代号就是 0xC0000005。更为完整的异常代号可以从 Windows NT DDK 中的 NTSTATUS.H 文件里找到。EXCEPTION_RECORD 结构体的第四个元素是异常发生处的地址。其余的 EXCEPTION_RECORD 域目前都可以忽略掉。_except_handler 函数的第二个参数是一个指向 establisher frame 结构体的指针。在 SEH 里这可是个重要的参数,不过现在先不用管它。第三个参数是一个指向 CONTEXT 结构体的指针。CONTEXT 结构体定义在 WINNT.H 文件中,它保存着某一线程的寄存器的值。图 1 即为 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;
|
|
当用于 SEH 时,CONTEXT 结构体保存着发生异常时各寄存器的值。无独有偶,GetThreadContext 和 SetThreadContext 使用的也是相同的 CONTEXT 结构体。第四个也是最后的一个参数叫做 DispatcherContext,现在先不去管它。
简单总结一下,当发生异常时会调用一个回调函数。这个回调函数需要四个参数,其中三个都是结构体指针。在这些结构体中,有些域重要,有些并不重要。关键的问题是 _except_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 结构体定义中,这个结构体被称为 _EXCEPTION_REGISTRATION_RECORD。然而 _EXCEPTION_REGISTRATION_RECORD 的定义是没有的,因此我所能用的只能是 EXSUP.INC 中的汇编语言的 struc 定义。对于我前面提到的 SEH 的未公开,这就是一例。
不管怎样,我们回到目前的问题上来。当异常发生时,OS 是如何知道调用位置的呢?EXCEPTION_REGISTRATION 结构体有两个域,第一个先不用管。第二个域,handler,为一个指向 _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 函数了,见图 2。
图 2:_except_handler 函数 |
|
把目前这一小点儿东西凑到一起,我写了一个小程序来演示所讲到的这个非常简单的 OS 级的结构化异常处理。图 3 所示的就是 MYSEH.CPP,它只有两个函数。main 函数使用了三个内嵌的 ASM 块。第一个块使用两条 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
// 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
{ // 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
}
__asm
{
mov eax,0 // Zero out EAX
mov [eax], 1 // Write to EAX to deliberately cause a fault
}
printf( "After writing!/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
}
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 指令的执行引发了一个 access violation。系统察看 TIB 的 FS:[0] 并找到指向 EXCEPTION_REGISTRATION 结构体的指针。结构体中有一个指向 MYSEH.CPP 文件中的 _except_handler 函数的指针。系统将所需的四个参数入栈并调用 _except_handler 函数。一进入 _except_handler,代码首先用一条 printf 语句打印“Yo! I made it here!”。然后,_except_handler 修复引起异常的问题。问题在于 EAX 指向了不可写内存的地址(地址 0)。所做的修复就是修改 CONTEXT 中 EAX 的值,使其指向一个可写的内存单元。在这个简单的程序里,一个 DWORD 类型变量(scratch)就是用于此目的的。_except_handler 函数的最后的动作就是返回 ExceptionContinueExecution 类型的值,这个结构体定义在标准的 EXCPT.H 文件中。
当操作系统看到所返回的 ExceptionContinueExecution 时,就认为问题已被解决并重新执行引起异常的指令。因为我的 _except_handler 函数修改了 EAX 寄存器使其指向了有效的内存,MOV EAX,1 就再一次执行,main 函数正常继续。并不很复杂,不是吗?