在所有的Win32操作系统提供的功能里,最常用但是描述最不全的(underdocument)恐怕就是结构化异常处理了(structured exception handling (SEH))。当你想到Win32的结构化异常处理,你会想到 _try, _finally, 和 _except这些东西,你可以从任何一本Win32的书中找到SEH的很好的描述,即使是Win32SDK也有一个非常完备的关于_try, _finally, 和 _except等的结构化异常处理的概述。既然有这么多关于关于SEH的书,为什么还说它描述不全呢,那是因为本质上讲Win32的结构化异常处理是操作系统提供的服务。所有你能找到的关于SEH的书都是描述一种包装了操作系统内部实现的特定编译器的运行时库。微软的操作系统或者编译器厂商定义_try, _finally, 和 _except等关键字用以表意相关的操作,其他的编译器厂商完全可以定义其他的关键字进行相同的表意。也就是说编译器级的SEH封装了操作系统原生的SEH,使得我们无法接触到原生SEH的细节。也不知道为什么,编译器级别的SEH就像是一个大秘密,Microsoft的Visual C++和Borland的Borland C++都没有提供它们SEH的最低层的代码。这篇文章中,我们从编译器提供的SEH(通过代码生成和运行时库提供)中剥离操作系统提供的SEH深入探究SEH最基本的概念。我会避免使用真正的C++异常处理(真正的C++异常处理使用catch()代替_except),实际上真正的异常处理的实现方式和本文讨论的非常相似(当然了真正的C++异常处理会有一些额外的复杂的东西,讨论这些东西会掩盖SEH的本质,故略去不讲)。
当一个线程故障发生时,操作系统会提供一个机会告知错误信息。具体点说就是,当一个线程错误发生,操作系统会调用一个用户定义的回调函数,这个回调函数定义一些用户想要的操作,比如让蜂鸣器发声,或者播放一段.wav格式的提示音。不管这个回调函数干什么,它最后的操作是返回一个值告诉操作系统下一步要干什么。Win32的异常回调函数格式(来源于标准的Win32头文件EXCPT.h)如下:
EXCEPTION_DISPOSITION __cdecl _except_handler( struct _EXCEPTION_RECORD *ExceptionRecord, void * EstablisherFrame, struct _CONTEXT *ContextRecord, void * DispatcherContext );
这个异常回调函数的第一个参数是一个指向结构体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里用#define定义了一系列的由STATUS_为前缀的异常代码,比如STATUS_ACCESS_VIOLATION 的异常代码是 0xC0000005,我们可以从Windows NT DDK的头文件NTSTATUS.H中找到更加完备的异常代码。
第四个参数ExceptionAddress异常发生的地址。
其他的参数可以暂时忽略。
异常回调函数_except_handler的第二个参数是一个指向establisher frame结构体的指针,这是SEH中一个很重要的参数,但是现在暂时忽略。
第三个参数是一个指向结构体CONTEXT的指针,CONTEXT结构体定义在WINNT.H里,它代表了特定线程的注册值。当用在SEH时,CONTEXT就表示异常发生时的注册值。顺带说一句,这个CONTEXT结构体与GetThreadContext和SetThreadContext所使用的结构体是同一个。
第四个参数DispatcherContext也可以暂时忽略。
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回调函数接收丰富的异常信息(比如什么类型的异常发生了,在哪发生的),通过这些信息,异常回调函数决定要做什么。
这里留有一个疑问,当异常发生时,操作系统怎么知道从哪调用这个回调函数呢。答案是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的数据类型,但是我们找不到任何关于这个数据类型的定义信息,这也就是为什么说SEH是underdocumented未被文档化的。
让我们回到刚才的问题,操作系统怎么知道当异常发生时要从哪调用回调函数呢,EXCEPTION_REGISTRATION有两部分,第一部分暂时先忽略,第二部分handler是一个指向_except_ handler回调函数的指针。但是问题是操作系统怎么找到这个EXCEPTION_REGISTRATION呢?
为了回答这个问题,让我们重申一下:结构化异常处理工作在每个独立的线程里的,也就是说,每个线程都有自己的异常处理回调函数。thread information block(也叫TEB,或者TIB)是一个重要的Win32数据结构它存储了当前运行的线程的信息。TIB里的第一个DWORD就是一个指向该线程EXCEPTION_REGISTRATION结构体。在Intel的Win32平台上,FS注册表总是指向当前的TIB,也就是说在FS:[0]处你可以找到指向EXCEPTION_REGISTRATION的指针。
总结一下:当异常发生时,操作系统查找异常线程的TIB,从中取得指向EXCEPTION_REGISTRATION的指针,在EXCEPTION_REGISTRATION中可以找到指向异常回调函数 _except_handler的指针。
通过上述信息我写了一小程序简要描述一下系统级的结构化异常处理。
Figure 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#include 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; }
Main函数里有三段inline的ASM代码块,第一段通过在("PUSH handler" 和"PUSH FS:[0]")在栈上生成了一个EXCEPTION_REGISTRATION结构体。PUSH FS:[0]保存了FS:[0]先前的值使之成为结构体的一部分,这样栈上就有一个8字节的EXCEPTION_REGISTRATION。下一条指令MOV FS:[0],ESP将当前线程信息块TIB的第一个DWORD放入新的EXCEPTION_REGISTRATION。
你有可能会奇怪为什么我在栈上建立EXCEPTION_REGISTRATION而不是采用全局变量,理由是当你使用编译器的_try/_except的语法,编译器也是在栈上建立EXCEPTION_REGISTRATION的,我只是简单展示一下当你使用_try/_except时编译器会如何做的一个简化的版本。
第二个_asm代码段主要用于产生一个异常,MOV EAX,0清零EAX寄存器,然后MOV [EAX],1把寄存器的值当成一个内存地址,把1赋值给内存地址为零的内存这样就会产生一个异常。
最后一个_asm代码段移除异常处理:将先前FS:[0]的值还原,然后将EXCEPTION_REGISTRATION弹出堆栈(ADD ESP,8)。
编译完成后执行,你会发现,当MOV [EAX],1执行,它会引起一个access violation违规访问异常,操作系统查看TIB的FS:[0],找到EXCEPTION_REGISTRATION的指针,在这个结构体里是一个指向_except_handler的指针,操作系统会将前边所述四个参数入栈,然后调用异常处理函数。
在异常函数里首先执行printf,然后异常处理函数会解决异常,也就是EAX寄存器指向了一个不能写入的地址0,解决方式是更改CONTEXT的EAX的值使他指向一块可以写入的内存地址(scratch的地址,scrath是为了简化程序说明问题故意引入的),最后的操作将ExceptionContinueExecution返回。
当操作系统看到ExceptionContinueExecution返回,这就意味着你已经解决了问题,故障指令会重新运行。也就是说异常处理函数改变EAX的值使之指向可写入内存,MOV EAX,1会再次执行,main函数正常执行完毕。
Reference:
http://www.microsoft.com/msj/0197/exception/exception.aspx
http://en.wikipedia.org/wiki/Win32_Thread_Information_Block
由于运行在CLR,C#的异常堆栈信息,异常处理显得没有那么神秘,毕竟CLR做为一个平台包办了这一切,但C++的异常处理怎么实现呢,一直对这个问题很感兴趣,以前去codeproject上看过一些帖子对结构化异常处理稍微了解过一点,但是理解的很肤浅。这几天关注C#5碰到一篇采访C#架构师Anders Hejlsberg的帖子无意间发现了关于SEH的这个链接,一读觉着深入浅出,挺有意思,随性翻译一下备忘,没有严格按照原文翻译,而且也没有翻译完,以后抽时间做完它。难免有疏漏,欢迎指正,如果有高手指教提供更完备的资料在下感激不尽了。