这回接着处理上一篇文章留下的问题:如何处理EXCEPTION_DEBUG_EVENT这类调试事件。这类调试事件是调试器与被调试进程进行交互的最主要手段,在后面的文章中你会看到调试器如何使用它完成断点、单步执行等操作。所以,关于这类调试事件的处理很自由,调试器的作者可以根据需要进行不同的处理。但是,在对其进行处理之前必须要了解一些关于异常的知识,这也是本文的重点。(本文的内容参考了《软件调试》一书)
异常的分类
根据异常发生时是否可以恢复执行,可以将异常分为三种类型,分别是错误异常,陷阱异常以及中止异常。
错误异常和陷阱异常一般都可以修复,并且在修复后程序可以恢复执行。两者的不同之处在于,错误异常恢复执行时,是从引发异常的那条指令开始执行;而陷阱异常是从引发异常那条指令的下一条指令开始执行。例如下面的三条指令:
i1
i2
i3
若i2引发了一个错误异常,恢复执行时是从i2开始执行;若引发的是陷阱异常,恢复执行时是从i3开始执行。
中止异常属于严重的错误,程序不可以再继续执行。
根据异常产生的原因,可以将异常分为硬件异常和软件异常。硬件异常即由CPU引发的异常,Windows定义了以下的硬件异常代码:
异常 |
值 |
描述 |
EXCEPTION_ACCESS_VIOLATION |
0xC0000005 |
程序企图读写一个不可访问的地址时引发的异常。例如企图读取0地址处的内存。 |
EXCEPTION_ARRAY_BOUNDS_EXCEEDED |
0xC000008C |
数组访问越界时引发的异常。 |
EXCEPTION_BREAKPOINT |
0x80000003 |
触发断点时引发的异常。 |
EXCEPTION_DATATYPE_MISALIGNMENT |
0x80000002 |
程序读取一个未经对齐的数据时引发的异常。 |
EXCEPTION_FLT_DENORMAL_OPERAND |
0xC000008D |
如果浮点数操作的操作数是非正常的,则引发该异常。所谓非正常,即它的值太小以至于不能用标准格式表示出来。 |
EXCEPTION_FLT_DIVIDE_BY_ZERO |
0xC000008E |
浮点数除法的除数是0时引发该异常。 |
EXCEPTION_FLT_INEXACT_RESULT |
0xC000008F |
浮点数操作的结果不能精确表示成小数时引发该异常。 |
EXCEPTION_FLT_INVALID_OPERATION |
0xC0000090 |
该异常表示不包括在这个表内的其它浮点数异常。 |
EXCEPTION_FLT_OVERFLOW |
0xC0000091 |
浮点数的指数超过所能表示的最大值时引发该异常。 |
EXCEPTION_FLT_STACK_CHECK |
0xC0000092 |
进行浮点数运算时栈发生溢出或下溢时引发该异常。 |
EXCEPTION_FLT_UNDERFLOW |
0xC0000093 |
浮点数的指数小于所能表示的最小值时引发该异常。 |
EXCEPTION_ILLEGAL_INSTRUCTION |
0xC000001D |
程序企图执行一个无效的指令时引发该异常。 |
EXCEPTION_IN_PAGE_ERROR |
0xC0000006 |
程序要访问的内存页不在物理内存中时引发的异常。 |
EXCEPTION_INT_DIVIDE_BY_ZERO |
0xC0000094 |
整数除法的除数是0时引发该异常。 |
EXCEPTION_INT_OVERFLOW |
0xC0000095 |
整数操作的结果溢出时引发该异常。 |
EXCEPTION_INVALID_DISPOSITION |
0xC0000026 |
异常处理器返回一个无效的处理的时引发该异常。 |
EXCEPTION_NONCONTINUABLE_EXCEPTION |
0xC0000025 |
发生一个不可继续执行的异常时,如果程序继续执行,则会引发该异常。 |
EXCEPTION_PRIV_INSTRUCTION |
0xC0000096 |
程序企图执行一条当前CPU模式不允许的指令时引发该异常。 |
EXCEPTION_SINGLE_STEP |
0x80000004 |
标志寄存器的TF位为1时,每执行一条指令就会引发该异常。主要用于单步调试。 |
EXCEPTION_STACK_OVERFLOW |
0xC00000FD |
栈溢出时引发该异常。 |
虽然异常代码有很多,而且有一些不容易理解,但其中的大部分异常在使用高级语言编程时几乎不会遇到。比较常见的异常有EXCEPTION_ACCESS_VIOLATION,EXCEPTION_INT_DIVIDE_BY_ZERO 和EXCEPTION_STACK_OVERFLOW。
软件异常即程序调用RaiseException函数引发的异常,C++的throw语句最终也是调用该函数来抛出异常的。软件异常的异常代码可以在调用RaiseException时由程序员任意指定。通过throw语句抛出的异常的异常代码是由编译器指定的,对于Visual C++的编译器来说,异常代码总是0xE06D7363,对应“.msc”的ASCII码。
硬件异常和软件异常都可以通过Windows提供的结构化异常处理机制来捕捉和处理,这种处理机制可以让程序在发生异常的地方继续执行,或者转到异常处理块内执行。而C++提供的异常处理机制只能捕捉和处理由throw语句抛出的异常,简单地说,这是通过检查异常代码是否0xE06D7363来决定的。另外,C++的异常处理机制只能转到异常处理块中执行,而不能在异常发生的地方继续执行。实际上C++的异常处理是对Windows的结构化异常处理的包装。
异常的分发
一个异常一旦发生了,就要经历一个复杂的分发过程。一般来说,一个异常有以下几种可能的结果:
1.异常未被处理,程序因“应用程序错误”退出。
2.异常被调试器处理了,程序在发生异常的地方继续执行(具体取决于是错误异常还是陷阱异常)。
3.异常被程序内的异常处理器处理了,程序在发生异常的地方继续执行,或者转到异常处理块内继续执行。
下面来看一下异常的分发过程。为了突出重点,这里省略了很多细节:
1.程序发生了一个异常,Windows捕捉到这个异常,并转入内核态执行。
2.Windows检查发生异常的程序是否正在被调试,如果是,则发送一个EXCEPTION_DEBUG_EVENT调试事件给调试器,这是调试器第一次收到该事件;如果否,则跳到第4步。
3.调试器收到异常调试事件之后,如果在调用ContinueDebugEvent时第三个参数为DBG_CONTINUE,即表示调试器已处理了该异常,程序在发生异常的地方继续执行,异常分发结束;如果第三个参数为DBG_EXCEPTION_NOT_HANDLED,即表示调试器没有处理该异常,跳到第4步。
4.Windows转回到用户态中执行,寻找可以处理该异常的异常处理器。如果找到,则进入异常处理器中执行,然后根据执行的结果继续程序的执行,异常分发结束;如果没找到,则跳到第5步。
5.Windows又转回内核态中执行,再次检查发生异常的程序是否正在被调试,如果是,则再次发送一个EXCEPTION_DEBUG_EVENT调试事件给调试器,这是调试器第二次收到该事件;如果否,跳到第7步。
6.调试器第二次处理该异常,如果调用ContinueDebugEvent时第三个参数为DBG_CONTINUE,程序在发生异常的地方继续执行,异常分发结束;如果第三个参数为DBG_EXCEPTION_NOT_HANDLED,跳到第7步。
7.异常没有被处理,程序以“应用程序错误”结束。
下面的流程图表达了这个过程:
下面使用几个例子来加深对异常分发过程的理解。调试器使用的是上一篇文章的示例代码。如果你已熟悉了异常分发的过程,那么可以略过这部分不看。
①引发硬件异常,在收到异常调试事件的时候以DBG_CONTINUE调用ContinueDebugEvent。
被调试程序的代码:
2 #include <Windows.h>
3
4 int wmain() {
5
6 OutputDebugString(TEXT("Warning! An exception will be thrown!"));
7
8 __try {
9
10 int a = 0;
11 int b = 10 / a;
12
13 }
14 __except(EXCEPTION_EXECUTE_HANDLER) {
15
16 OutputDebugString(TEXT("Entered exception handler."));
17 }
18 }
调试器的OnException函数代码:
2
3 std::wcout << TEXT("An exception was occured.") << std::endl
4 << TEXT("Exception code: ") << std::hex << std::uppercase << std::setw(8)
5 << std::setfill(L'0') << pInfo->ExceptionRecord.ExceptionCode << std::dec << std::endl;
6
7 if (pInfo->dwFirstChance == TRUE) {
8
9 std::wcout << TEXT("First chance.") << std::endl;
10 }
11 else {
12
13 std::wcout << TEXT("Second chance.") << std::endl;
14 }
15 }
运行调试器程序,会看到它进入了一个死循环,不断输出“An exception was occurred…”信息,而且一直都是“First chance.”。结合上面的流程图来看这个过程:我们以DBG_CONTINUE继续被调试进程执行,意味着我们已经处理了该异常,被调试进程从发生异常的地方开始继续执行。由于EXCEPTION_INT_DIVIDE_BY_ZERO是一个错误异常,int b = 10 / a这条语句会再次执行。然而实际上调试器并没有进行任何处理异常的操作,这条语句还是会引发异常。就这样周而复始,陷入了死循环。从这个例子也看出,即使引发异常的语句被一个__try块包围,最先捕获到异常的却是调试器。
②引发硬件异常,在收到异常调试事件的时候以DBG_EXCEPTION_NOT_HANDLED调用ContinueDebugEvent。
仍然使用上面例子的代码,但是将ContinueDebugEvent的第三个参数改成DBG_EXCEPTION_NOT_HANDLED。运行调试器,这次只输出了一次“An exception was occurred…”信息,后面接着被调试进程的输出信息,表明被调试进程的异常处理器被执行了。过程:我们以DBG_EXCEPTION_NOT_HANDLED继续被调试进程的执行,意味着异常未被处理,所以Windows寻找异常处理器。由于存在异常处理器,而且它返回EXCEPTION_EXECUTE_HANDLER,因此被调试进程进入了异常处理器执行。如果将EXCEPTION_EXECUTE_HANDLER改成EXCEPTION_CONTINUE_EXECUTION,那么被调试进程就会再次执行引发异常的语句,结果也是陷入一个死循环。
假如我们将__try和__except块去掉,那么将没有异常处理器处理异常,调试器会第二次收到异常调试信息。如果仍然以DBG_EXCEPTION_NOT_HANDLED调用ContinueDebugEvent,被调试进程就会退出;如果以DBG_CONTINUE进行调用,那么被调试进程继续执行,结果又是陷入死循环。
上面的两个例子使用了硬件异常以及Windows结构化异常处理。如果是使用软件异常以及C++的异常处理,又会出现什么现象呢?下面几个问题留给大家去解决:
③被调试程序的代码如下:
2 #include <Windows.h>
3
4 int wmain() {
5
6 OutputDebugString(TEXT("Warning! An exception will be thrown!"));
7
8 try {
9
10 throw 9;
11 }
12 catch(int ex) {
13
14 OutputDebugString(TEXT("Entered exception handler."));
15 }
16 }
分别以DBG_CONTINUE和DBG_EXCEPTION_NOT_HANDLED调用ContinueDebugEvent,仔细观察调试器的输出,解释一下为什么会这样。
④将上例的代码改成这样:
2 #include <Windows.h>
3
4 int wmain() {
5
6 OutputDebugString(TEXT("Warning! An exception will be thrown!"));
7
8 try {
9
10 throw 9;
11
12 OutputDebugString(TEXT("Will this message be shown?"));
13 }
14 catch(int ex) {
15
16 OutputDebugString(TEXT("Entered exception handler."));
17 }
18 }
分别以DBG_CONTINUE和DBG_EXCEPTION_NOT_HANDLED调用ContinueDebugEvent,仔细观察调试器的输出,解释一下为什么会这样。
⑤根据上面两个例子回答:软件异常属于错误异常还是陷阱异常?
再谈OutputDebugString
在上面的第一、第二个例子中,你可能会注意到一个小问题:第一个例子中,被调试进程用OutputDebugString输出的字符串只显示一次;但在第二个例子中却显示两次。这是因为OutputDebugString在内部调用了RaiseException,它本质上是通过软件异常来工作的,Windows将它引发的异常转换成了OUTPUT_DEBUG_STRING_EVENT调试事件来通知调试器。
所以,当我们以DBG_CONTINUE调用ContinueDebugEvent时,OutputDebugString的异常被处理了,调试器只收到一次OUTPUT_DEBUG_STRING_EVENT事件;以DBG_EXCEPTION_NOT_HANDLED调用时,该异常未被处理,调试器会第二次收到OUTPUT_DEBUG_STRING_EVENT。这就是为什么在第二个例子中这些信息会输出两次了。
那么,为什么在调试器第二次处理OUTPUT_DEBUG_STRING_EVENT之后以DBG_EXCEPTION_NOT_HANDLED调用ContinueDebugEvent时,被调试进程不会结束呢?这只能说是因为OutputDebugString引发的异常属于特殊的异常,Windows对它有特别的处理。OutputDebugString的目的是为了向调试器输出调试信息,而不是为了报告一个错误,如果被调试进程在调用OutputDebugString之后立即结束了,肯定会让人感到莫名奇妙。
对EXCPETION_DEBUG_EVENT的处理
好了,上面进行了那么多铺垫,终于可以回到正题了。EXCEPTION_DEBUG_INFO结构体描述了该类调试事件的详细信息。dwFirstChance指明是第一次还是第二次接收到同一个异常,为1是第一次,为0是第二次。ExceptionRecord则是一个EXCEPTION_RECORD结构体,包含了异常的详细信息:
ExceptionCode 异常代码
ExceptionFlags 异常标志,为0表示这是一个可继续执行的异常,否则为EXCEPTION_NONCONTINUABLE。
ExceptionRecord 指向另一个异常的指针。一个异常可以嵌套另一个异常,形成链式结构。
ExceptionAddress 引发异常的指令地址。
ExceptionInformation 如果异常需要包含更多信息,则用该数组来保存这些信息。
NumberParameters ExceptionInformation数组中元素的个数。
由上文的描述可以看出,ContinueDebugEvent的第三个参数对于调试器的行为有很大的影响,所以我们不能仅仅使用DBG_CONTINUE或者DBG_EXCEPTION_NOT_HANDLED,而应该根据异常代码执行不同的操作,然后使用适当的值调用ContinueDebugEvent。例如,遇到除零异常,我们可以将除数的值改为非零,然后以DBG_CONTINUE继续被调试进程的执行。又如,我们希望只在异常没有被异常处理器处理的情况下才对其处理,那么我们可以在第一次接收到异常调试事件时以DBG_EXCEPTION_NOT_HANDLED继续执行,在第二次接收到异常调试事件时才对其进行处理。
最后说明一下,对于EXCEPTION_DEBUG_EVENT和OUTPUT_DEBUG_STRING_EVENT之外的调试事件,DBG_CONTINUE和DBG_EXCEPTION_NOT_HANDLED的作用是一样的,都是继续被调试进程的执行,两者没有什么不同。
示例代码
这次的示例代码添加了一个全局变量g_continueStatus,在调用ContinueDebugEvent时以它作为第三个参数。OnException和OnOutputDebugString函数都会修改这个值。对于异常,第一次接收时以DBG_EXCEPTION_NOT_HANDLED继续被调试进程执行,第二次接收时以DBG_CONTINUE继续其执行。