[Win32]一个调试器的实现(三)异常

这回接着处理上一篇文章留下的问题:如何处理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_VIOLATIONEXCEPTION_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

被调试程序的代码:

 1 #include <stdio.h>
 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函数代码:

 1 void OnException(const EXCEPTION_DEBUG_INFO* pInfo) {
 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++的异常处理,又会出现什么现象呢?下面几个问题留给大家去解决:

 

③被调试程序的代码如下:

 1 #include <stdio.h>
 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_CONTINUEDBG_EXCEPTION_NOT_HANDLED调用ContinueDebugEvent,仔细观察调试器的输出,解释一下为什么会这样。

 

④将上例的代码改成这样:

 1 #include <stdio.h>
 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_CONTINUEDBG_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_EVENTOUTPUT_DEBUG_STRING_EVENT之外的调试事件,DBG_CONTINUEDBG_EXCEPTION_NOT_HANDLED的作用是一样的,都是继续被调试进程的执行,两者没有什么不同。

 

示例代码

这次的示例代码添加了一个全局变量g_continueStatus,在调用ContinueDebugEvent时以它作为第三个参数。OnExceptionOnOutputDebugString函数都会修改这个值。对于异常,第一次接收时以DBG_EXCEPTION_NOT_HANDLED继续被调试进程执行,第二次接收时以DBG_CONTINUE继续其执行。

https://files.cnblogs.com/zplutor/MiniDebugger3.rar

posted on 2011-03-08 22:39  Zplutor  阅读(18035)  评论(8编辑  收藏  举报