785.html

来自「里面收集的是发表在www.xfocus.org上的文章」· HTML 代码 · 共 365 行 · 第 1/4 页

HTML
365
字号
typedef struct _EXCEPTION_RECORD {<br />
&nbsp;&nbsp;&nbsp;&nbsp;DWORD ExceptionCode;<br />
&nbsp;&nbsp;&nbsp;&nbsp;DWORD ExceptionFlags;<br />
&nbsp;&nbsp;&nbsp;&nbsp;struct _EXCEPTION_RECORD *ExceptionRecord;<br />
&nbsp;&nbsp;&nbsp;&nbsp;PVOID ExceptionAddress;<br />
&nbsp;&nbsp;&nbsp;&nbsp;DWORD NumberParameters;<br />
&nbsp;&nbsp;&nbsp;&nbsp;DWORD ExceptionInformation[EXCEPTION_MAXIMUM_PARAMETERS];<br />
}&nbsp;&nbsp;EXCEPTION_RECORD;<br />
<br />
参数 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 文件中,它保存着某一线程的寄存器的值。Figure 1 即为 CONTEXT 结构体的域。当用于 SEH 时,CONTEXT 结构体保存着发生异常时各寄存器的值。无独有偶,GetThreadContext 和 SetThreadContext 使用的也是相同的 CONTEXT 结构体。第四个也是最后的一个参数叫做 DispatcherContext,现在先不去管它。<br />
<br />
简单总结一下,当发生异常时会调用一个回调函数。这个回调函数需要四个参数,其中三个都是结构体指针。在这些结构体中,有些域重要,有些并不重要。关键的问题是 _except_handler 回调函数收到了大量的信息,比如异常的类型和发生的位置。异常回调函数需要使用这些信息来决定所采取的行动。<br />
<br />
我很想现在就给出一个样例程序来说明 _except_handler,只是仍有一些东西需要解释,即当异常发生时操作系统是如何知道在那里调用回调函数呢?答案在另一个叫 EXCEPTION_REGISTRATION 的结构体中。本文通篇都能见到这个结构体,因此对这部分还是不要囫囵吞枣为好。唯一能找到&nbsp;&nbsp;EXCEPTION_REGISTRATION 正式定义的地方就是 Visual C++ 运行时库源代码中的 EXSUP.INC 文件:<br />
<br />
_EXCEPTION_REGISTRATION struc<br />
&nbsp;&nbsp;&nbsp;&nbsp; prev&nbsp;&nbsp;&nbsp;&nbsp;dd&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;?<br />
&nbsp;&nbsp;&nbsp;&nbsp; handler dd&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;?<br />
_EXCEPTION_REGISTRATION ends<br />
<br />
可以看到,在 WINNT.H 的 NT_TIB 结构体定义中,这个结构体被称为 _EXCEPTION_REGISTRATION_RECORD。然而 _EXCEPTION_REGISTRATION_RECORD 的定义是没有的,因此我所能用的只能是 EXSUP.INC 中的汇编语言的 struc 定义。对于我前面提到的 SEH 的未公开,这就是一例。<br />
<br />
不管怎样,我们回到目前的问题上来。当异常发生时,OS 是如何知道调用位置的呢?EXCEPTION_REGISTRATION结构体有两个域,第一个先不用管。第二个域,handler,为一个指向 _except_ handler 回调函数的指针。有点儿接近答案了,但是还有个问题就是,OS 从哪里能找到这个 EXCEPTION_REGISTRATION 结构体呢?<br />
<br />
为了回答这个问题,需要记住结构化异常处理是以线程为基础的。也就是说,每一个线程都有自己的异常处理回调函数。在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 函数了,见 Figure 2。<br />
<br />
把目前这一小点儿东西凑到一起,我写了一个小程序来演示所讲到的这个非常简单的 OS 级的结构化异常处理。Figure 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 结构体。<br />
<br />
在堆栈上构建 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)。<br />
<br />
现在假设正在运行 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 文件中。<br />
<br />
当操作系统看到所返回的 ExceptionContinueExecution 时,就认为问题已被解决并重新执行引起异常的指令。因为我的 _except_handler 函数修改了 EAX 寄存器使其指向了有效的内存,MOV EAX,1 就再一次执行,main 函数正常继续。并不很复杂,不是吗?<br />
<br />
<br />
Moving In a Little Deeper <br />
<br />
有了这个最简单的情形,我们再回来填补几个空白。尽管异常回调如此伟大,但并不完美。对于任意大小的程序,编写一个函数来处理程序中可能发生的所有异常,那这个函数恐怕会是一团糟。更为可行的情形是能有多个异常处理函数,每一个函数都用于程序的某一特定的部分。操作系统提供了这个功能。<br />
<br />
还记得系统查找异常处理回调函数所用的 EXCEPTION_REGISTRATION 结构体吧?此结构体的第一个参数,就是我前面忽略的那个,它叫做 prev。它确实是指向另一个 EXCEPTION_REGISTRATION 结构体的指针。这个第二个 EXCEPTION_REGISTRATION 结构体可以有一个完全不同的处理函数。而且它的 prev 域还可以指向第三个 EXCEPTION_REGISTRATION 结构体,依次类推。简单讲,就是一个 EXCEPTION_REGISTRATION 结构体数组的链表。此链表的表头总是由线程信息块的第一个 DWORD (Intel 机器上的 FS:[0] )所指向。<br />
<br />
操作系统用这个 EXCEPTION_REGISTRATION 结构体链表做什么?当异常发生时,系统遍历此链表并查找回调函数与异常相符的 EXCEPTION_REGISTRATION。对于 MYSEH.CPP 来说,回调函数返回 ExceptionContinueExecution 型的值,与异常相符合。回调函数也可能不适合所发生的异常,这时系统就移向链表中下一个 EXCEPTION_REGISTRATION 结构体并询问异常回调是否要处理此异常。Figure 4 所示即为此过程。一旦系统找到了处理此异常的回调函数就停止对 EXCEPTION_REGISTRATION 链表的遍历。<br />
我给出了一个异常回调不能处理异常的例子,见 Figure 5 的 MYSEH2.CPP。为简单起见,我用了一点编译器级的异常处理。main 函数只是建立一个 _try/_except 块。_try 块中的是一个对 HomeGrownFrame 函数的调用。函数与前面的 MYSEH 程序中的代码很类似。它在堆栈上创建了一个 EXCEPTION_REGISTRATION 记录并使 FS:[0] 指向此纪录。在建立了新的处理程序后,函数主动引起异常,向 NULL 指针处进行写入:<br />
<br />
*(PDWORD)0 = 0;<br />
<br />
这里的异常回调函数,也就是 _except_ handler,与前面的那个很不一样。代码先打印出函数的 ExceptionRecord 参数的异常代号和标志。后面会说明打印此异常标志的原因。因为这个 _except_handler 函数并不能修复引起异常的代码,它就返回 ExceptionContinueSearch。这就使得操作系统继续查找链表中的下一个 EXCEPTION_REGISTRATION 记录。下一个异常回调是用于 main 函数中的 _try/_except 代码的。_except 块只是打印“Caught the exception in main()”。此处的异常处理就是简单地将其忽略。此处的一个关键的问题就是执行控制流。当处理程序不能处理异常时,就是在拒绝使控制流在此处继续。接受异常的处理程序则在所有异常处理代码完成之后决定控制流在哪里继续。这一点并不那么显而易见。<br />
<br />
当使用结构化异常处理时,如果一场处理程序没能处理异常,则函数可以用一种非正常的方式退出。例如,MYSEH2 的 HomeGrownFrame 函数中的处理程序并没有处理异常。因为异常处理链中后面的某个处理程序(main 函数)处理了此异常,所以引起异常的指令之后的 printf 从未获得执行。从某种意义上说,使用结构化异常处理和使用运行时库函数 setjmp 和 longjmp 差不多。<br />
<br />
若是运行 MYSEH2,其输出可能会令人惊讶。看上去似乎调用了两次 _except_handler 函数。第一次是可以理解的,那第二次又是怎么回事呢?<br />
<br />
Home Grown handler: Exception Code: C0000005 Exception Flags 0<br />
Home Grown handler: Exception Code: C0000027 Exception Flags 2<br />
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;EH_UNWINDING<br />
Caught the Exception in main()<br />
<br />
比较由“Home Grown Handler”开始的两行,其区别是显然的,即第一次的异常标志为0,而第二次则为2。这里就需要提到 unwinding 的概念。进一步讲,当异常回调拒绝处理异常时,就又被调用了一次。这次回调并没有立即发生,二是更为复杂。我还需要再进一步明确异常发生时的情景。<br />
<br />
当异常发生时,系统遍历 EXCEPTION_REGISTRATION 结构体链表直至找到处理此异常的处理程序。一旦找到了处理程序,系统再一次遍历此链表,直到处理异常的节点。在第二次遍历中,系统对所有的异常处理函数进行第二次调用。关键的区别就是在第二次调用中,异常标志被设为值2。这个值对应着 EH_UNWINDING( EH_UNWINDING 的定义在 Visual C++ 运行时库源代码的 EXCEPT.INC 里,但 Win32 SDK 里并没有等价的定义)。<br />
<br />
EH_UNWINDING 是什么意思呢?当异常回调被第二次调用时(带有 EH_UNWINDING 标志),操作系统就给处理函数一次做所需清理的机会。什么样的清理呢?一个很好的例子就是 C++ 类的析构函数。当函数的异常处理函数拒绝处理异常时,控制流一般并不会以正常的方式从函数中退出。现在考虑一个函数,此函数声明了一个局部的 C++ 类。C++ 规范指出析构函数是必须被调用的。第二次标志为 EH_UNWINDING 的异常处理回调就是为做调用析构函数和 _finally 块此类的清理工作提供机会。在异常被处理并且所有之前的 exception frames 都被调用以进行 unwind 之后,程序从回调函数选择的地方继续。但是记住,这并不等于将指令指针设为所要的代码地址并继续执行。继续执行出的代码要求堆栈和帧指针(Intel CPU 的 ESP 和 EBP 寄存器)都设为在处理异常的堆栈帧中相应的值。因此,接受某一异常的处理程序负责将堆栈指针和堆栈帧指针设为包含处理异常的 SEH 代码的堆栈帧中的值。<br />
<br />
<br />
Figure 6 Unwinding from an Exception <br />
<br />
更一般地说,从异常中的 unwinding 使得位于处理帧的堆栈区域之下的所有的东西都被移除,几乎相当于从未调用过那些函数。unwinding 的另一个效果就是链表中位于处理异常的 EXCEPTION_REGISTRATION 之前的所有 EXCEPTION_REGISTRATIONs 都被从链表中移除。这是有意义的,因为这些 EXCEPTION_REGISTRATION 一般都是在堆栈上构建的。在异常被处理后,堆栈指针和堆栈帧指针在内存中的地址要比从链表中移除的那些 EXCEPTION_REGISTRATIONs 高。Figure 6 所示即为所述。<br />
<br />
<br />
Help! Nobody Handled It! <br />
<br />
到目前为止我都是假设操作系统总能在&nbsp;&nbsp;EXCEPTION_REGISTRATION 链表中找到处理程序。要是没有相应的处理程序怎么办?这种情况几乎不会发生。原因是操作系统私地下为每个线程都准备了一个默认的异常处理程序。这个默认的异常处理程序总是链表的最后一个节点并总被选来处理异常。它的行为与一般的异常回调函数有些不同,我之后会说明。<br />
<br />
我们来看一下系统在那里安插这个默认的、最终的异常处理程序。这显然要在线程执行的前期进行,要在任何用户代码执行之前。Figure 7 为我为 BaseProcessStart 写的伪码,BaseProcessStart 是 Windows NT 的 KERNEL32.DLL 的一个内部函数。BaseProcessStart 需要一个参数,即线程入口的地址。BaseProcessStart 运行在新进程的上下文中并调用入口点来启动进程第一个线程。<br />
<br />
注意在伪码中,对 lpfnEntryPoint 的调用被封装在了一对 _try 和 _except 中。这个 _try 块就是用来在异常处理链表中安装那个默认的最终异常处理程序的。所有之后注册的异常处理程序都会插在链表中这个处理程序的前面。若 lpfnEntryPoint 函数返回,线程就运行至完成而不引起异常。若是这样,BaseProcessStart 调用 ExitThread 来结束线程。<br />
<br />
要是另一种情况,即线程发生了异常却再也没有异常处理程序了怎么办?在这种情况下,控制流流进 _except 关键字后的大括号里。在 BaseProcessStart 里,这段代码叫 UnhandledExceptionFilter API,我在后面还会回来介绍它。现在的关键是 UnhandledExceptionFilter API 包含着默认的异常处理函数。<br />
<br />
若 UnhandledExceptionFilter 返回的是 EXCEPTION_EXECUTE_HANDLER,BaseProcessStart 的 _except 块就执行。_except 块代码所作的就是调用 ExitProcess 来结束当前进程。仔细考虑一下,这样做还是有意义的;一个常识就是,如果程序引起了异常又没有处理程序能处理此异常,系统就结束该进程。伪码中所展示的正是这种情况。<br />
<br />
还要最后补充一点。如果引发异常的线程是作为服务运行的且是用于一个基于线程的服务,则 _except 块并不会调用 ExitProcess 而是调用 ExitThread。没有人会因为一个服务出错而结束整个服务进程。<br />
<br />
UnhandledExceptionFilter 中的默认异常处理程序又作了些什么呢?当我在讨论班上提出这个问题时,没几个人能猜出未处理的异常发生时操作系统的默认行为。通过对默认处理程序行为的演示,答案一点即明,人们就都明白了。我只是运行了一个主动引起异常的程序,并指出其结果(见 Figure 8)。<br />
<br />
<br />
&nbsp;&nbsp;<br />
Figure 8 Unhandled Exception Dialog <br />
<br />
UnhandledExceptionFilter 显示了一个对话框,告诉你发生了一个异常。此时,要么可以结束进程,要么就调试引发异常的进程。在这幕后还有相当多的操作,我在本文结束前再来讲这些东西。正如我所提到的,当异常发生时,用户编写的代码可以得到执行(通常是这样的)。类似地,在 unwind 操作过程中,用户编写的代码也可以得到执行。用户的代码可能仍有问题并引起另一个异常。因此,异常回调函数还可以返回另外两个值:ExceptionNestedException 和 ExceptionCollidedUnwind。显然这些内容就很深了,我并不想在这里介绍。其对于理解基本事实来说太难了。<br />
<br />
<br />

⌨️ 快捷键说明

复制代码Ctrl + C
搜索代码Ctrl + F
全屏模式F11
增大字号Ctrl + =
减小字号Ctrl + -
显示快捷键?