• [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


    作者:Zplutor
    出处:http://www.cnblogs.com/zplutor/
    本文版权归作者和博客园共有,欢迎转载。但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接,否则保留追究法律责任的权利。

  • 相关阅读:
    [原创]敏捷管理实践看板思维导图
    [原创]敏捷管理实践Scrum思维导图
    [原创]App弱网测试方法介绍
    [原创]SpotLight性能监控工具使用介绍
    [原创]换一份工作要考虑什么?
    [原创]互联网公司App测试流程
    [原创]浅谈大型互联网架构发展
    [原创]Jmeter工具学习思维导图
    [联想] 联想管理三要素:1 建班子 2 定战略 3带队伍
    [原创]上海好买基金招测试开发/测试工程师/配置管理组长/配置管理工程师
  • 原文地址:https://www.cnblogs.com/zplutor/p/1977702.html
Copyright © 2020-2023  润新知