分类 调试 下的文章

当一个异常发生时,操作系统要向引起异常的线程的栈里压入三个结构,这三个结构是:E X C E P T I O N _ R E C O R D结构、C O N T E X T结构和E X C E P T I O N _ P O I N T E R S结构。

一、E X C E P T I O N _ R E C O R D

这个结构包含有关最近发生的异常的详细信息,这些信息独立于C P U,定义如下:

typedefstruct_EXCEPTION_RECORD

{

DWORD ExceptionCode;

DWORD ExceptionFlags;

struct_EXCEPTION_RECORD *ExceptionRecord;

PVOID ExceptionAddress;

DWORD NumberParameters;

ULONG_PTR ExceptionInformation[EXCEPTION_MAXIMUM_PARAMETERS];

} EXCEPTION_RECORD;

ExceptionCode:包含异常的代码。这同内部函数G e t E x c e p t i o nCo d e()返回的信息是一样的。这是一个硬件异常产生的代码或者由RaiseException函数指定产生的软件异常码。

ExceptionFlags:包含有关异常的标志。当前只有两个值,分别是0(指出一个可以继续的异常)和E X C E P T I O N _ N O N C O N T I N U A B L E(指出一个不可继续的异常)。在一个不可继续的异常之后,若要继续执行,会引发一个E X C E P T I O N _ N O N C O N T I N U A B L E _E X C E P T I O N异常。

ExceptionRecord:指向另一个未处理异常的E X C E P T I O N _ R E C O R D结构。在处理一个异常的时候,有可能引发另外一个异常。例如,异常过滤器中的代码就可能用零来除一个数。当嵌套异常发生时,可将异常记录链接起来,以提供另外的信息。如果在处理一个异常过滤器的过程当中又产生一个异常,就发生了嵌套异常。如果没有未处理异常,这个成员就包含一个N U L L。

ExceptionAddress:指出产生异常的C P U指令的地址

NumberParameters: 规定了与异常相联系的参数数量(0 到1 5 )。这是在E x c e p t i o n I n f o r m a t i o n数组中定义的元素数量。对几乎所有的异常来说,这个值都是零。

ExceptionInformation: 规定一个附加参数的数组,用来描述异常。对大多数异常来说,数组元素是未定义的。RaiseException函数可以指定这个参数数组。下面的表格展示了哪些异常码的数组元素是定义了的。

Exception codeMeaning
EXCEPTION_ACCESS_VIOLATION

The first element of the array contains a read-write flag that indicates the type of operation that caused the access violation. 数组的第一个元素包含了一个读写标志,表示引起访问违规的操作类型。If this value is zero, the thread attempted to read the inaccessible data. 如果这个值为0,表示线程试图读取不可访问的数据。If this value is 1, the thread attempted to write to an inaccessible address. 如果这个值为1,表示线程试图写入不可访问的地址。If this value is 8, the thread causes a user-mode data execution prevention (DEP) violation.如果这个值是8,表示线程线程引发了一个用户模式的DEP违规。

The second array element specifies the virtual address of the inaccessible data.数组的第二个元素指定了不可访问数据的虚拟地址。

EXCEPTION_IN_PAGE_ERROR

The first element of the array contains a read-write flag that indicates the type of operation that caused the access violation. 数组的第一个元素包含了一个读写标志,用于表示引起访问违规的操作类型。If this value is zero, the thread attempted to read the inaccessible data. 如果值为0,表示线程试图读取不可访问的数据。If this value is 1, the thread attempted to write to an inaccessible address. 如果值为1,表示线程试图写入不可访问的地址。If this value is 8, the thread causes a user-mode data execution prevention (DEP) violation.如果值为8,表示线程引起了一个用户模式的DEP违规。

The second array element specifies the virtual address of the inaccessible data.数组的第二个元素指定了不可访问数据的虚拟地址。

The third array element specifies the underlying NTSTATUS code that resulted in the exception.数组的第三个元素表示底层的NTSTATUS码引起的本次异常。

二、C O N T E X T

包含处理器特定的寄存器数据。系统使用上下文结构执行各种内部操作。

typedef struct _WOW64_CONTEXT {
DWORD ContextFlags;
DWORD Dr0;
DWORD Dr1;
DWORD Dr2;
DWORD Dr3;
DWORD Dr6;
DWORD Dr7;
WOW64_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;
BYTE ExtendedRegisters[WOW64_MAXIMUM_SUPPORTED_EXTENSION];
} CONTEXT;

三、E X C E P T I O N _ P O I N T E R S

这个结构只有两个数据成员,二者都是指针,分别指向被压入栈的E X C E P T I O N _ R E C O R D和C O N T E X T结构:

typedefstruct _EXCEPTION_POINTERS

{

PEXCEPTION_RECORDExceptionRecord;

PCONTEXTContextRecord;

} EXCEPTION_POINTERS, *PEXCEPTION_POINTERS;

 

当我们在调试程序异常时可以利用这些数据结构的数据来分析定位问题代码。

调试问题时可能面临的一个常见任务是记录有关对一个或多个函数的调用的信息。如果你想知道你的程序中有一个你有源代码的函数,你可以添加一些调试打印和重建程序,有时这是不实际的。例如,您可能不总是能够重现一个问题,因此可能不可行的是,必须重新启动调试生成,因为您可能会吹走您的重现。或者,更重要的是,您可能需要记录对没有源代码的函数的调用(或者不作为程序的一部分构建,或者不想修改)。
例如,您可能希望记录对各种Windows api的调用,以便获得有关正在排除故障的问题的信息。现在,根据您正在做的工作,您可以在每次调用特定API之前和之后添加调试打印来完成这项工作。但是,这通常不太方便,如果您不是要记录的函数的直接调用方,那么无论如何,您都不能走这条路。
有许多API spy/API日志记录软件包(Windows发行版的调试工具甚至附带了一个名为Logger的软件包,尽管它往往相当脆弱——就我个人而言,我经常遇到它崩溃,而不是实际工作中遇到的问题)。尽管您可以使用其中的一个,但是“收缩包装”日志工具的一个很大的限制是,它们不知道如何正确地记录对自定义函数的调用,或者日志工具不知道的函数。更好的日志工具在一定程度上是用户可扩展的,因为它们通常提供某种脚本语言或编程语言,允许用户(即您)描述函数参数和调用约定,以便对它们进行日志记录。
但是,通常很难(甚至不可能)向这些工具描述许多类型的函数,例如包含指向包含指向其他结构的指针的结构的指针的函数,或其他此类不重要的结构。因此,在许多情况下,我倾向于建议不要在需要记录对函数的调用的情况下使用所谓的“收缩包装”API日志工具。

但是,如果在源代码中实现调试打印不是一个可行的解决方案,那么表面上看,这会使一个没有可用的解决方案来记录调用。事实上并非如此——事实证明,通过谨慎地使用所谓的“条件断点”,您可以经常使用调试器(例如WinDbg/ntsd/cdb/kd,这是本文其余部分将要提到的)来提供这种调用日志记录。使用调试器有许多优点;例如,您可以“动态”执行此类API日志记录,并且在可以在进程启动后附加调试器的情况下,您甚至不需要特别启动程序来记录它。然而,更好的是,调试器对以有意义的形式向用户显示数据有广泛的支持。
如果你仔细想想,向用户显示数据实际上是调试器的主要功能之一。这也是调试器通过扩展具有高度可扩展性的主要原因之一,这样复杂的数据结构就可以以有意义的方式显示和解释。通过使用调试器来执行API日志记录,您可以利用丰富的功能来显示已烘焙到调试器中的数据(及其扩展,甚至是您自己编写的任何自定义扩展),从而兼作调用日志记录功能。
更好的是,因为调试器可以基于符号文件(如果您有私有符号,例如您编译或提供的程序)以有意义的方式读取和显示许多数据类型,而这些数据类型没有用于显示它们的特定调试器扩展名(如!把手!错误(错误代码)!devobj和soforth),通常可以利用调试器基于符号中的类型信息格式化数据的能力。这通常是通过dt命令完成的,并且通常为大多数自定义数据类型提供一个可行的显示,而不必像处理日志程序那样进行任何复杂的“训练”。(某些数据结构(如树和列表)可能需要比dt中提供的更多的智能来显示数据结构的所有部分。对于“container”数据类型,这通常是正确的,尽管即使对于那些类型,您仍然可以经常使用dt以有意义的方式显示容器中的实际成员。)利用符号文件(通过调试器)中包含的信息进行API日志记录也使您不必确保日志记录程序对所有结构和其他类型的定义与您的程序同步正在调试,因为调试器自动接收基于符号的正确定义(如果使用的符号服务器包含自己内部符号的索引版本,调试器甚至可以自己找到符号)。

这种方法的另一个优点是,如果您对调试器相当熟悉,那么您可能不必像使用API日志程序那样学习新的描述语言。这是因为您可能已经熟悉了调试器从每天的调试器使用中为显示数据而提供的许多命令。(即使您对调试器不太熟悉,但默认情况下调试器附带的大量文档说明了如何通过各种调试器命令格式化和显示数据。此外,还有许多示例描述了如何在Internet上使用大多数重要或有用的调试器命令。)
好吧,关于为什么要考虑使用调试器来执行调用日志记录已经足够了。下一次,快速浏览并逐步介绍如何做到这一点(正如前面提到的那样,这非常简单),以及一些可能需要注意的注意事项和问题。

使用调试器执行调用日志记录并没有那么困难。所涉及的基本思想是在您感兴趣的函数的开头设置一个“条件”断点(例如,通过bp命令)。从那里,断点可以有显示输入参数的命令。但是,在某些情况下(例如,显示返回值、输出参数中的值等),您也可以变得更聪明一些,尽管根据正在调试的程序的特性,这可能是问题,也可能不是问题。
举一个简单的例子,我的意思是,有一个经典的“显示通过Win32 CreateFile打开的所有文件”。为此,方法是在kernel32!CreateFileW上设置一个断点。(请记住,大多数“A”win32api都会跳到“W”api,因此您通常可以仅在“W”版本上设置一个断点来同时获取这两个api。当然,这并不总是正确的(有些bizzare api,比如WinInet,实际上是从“W”到“A”的重击),但作为一般的经验法则,情况往往是这样的。)kernel32断点需要具备如何根据所讨论例程的调用约定显示第一个参数的知识。因为CreateFile是stdcall,所以应该是[esp+4](对于x86)和rcx(对于x64)。

在最基本的情况下,breakpoint命令可能如下所示:

0:001> bp kernel32!CreateFileW "du poi(@esp+4) ; gc"

一、SymView

SymView工具用来显示符号文件中包含的符号表和符号数据。目前支持微软的Visual C/C++和C#编译器产生的DBG格式的符号文件和PDB格式的符号文件。

SymView提供了以下多种方式来查看或者搜索符号文件的内容:

  • 符号表
  • PDB或DBG文件中可以包含6种符号表,分别为符号表、源文件表、节贡献表、段表、注入源代码表和帧数据表。
  • 编译素材(Compiland)
  • 用于产生可执行文件的各种素材文件,包括OBJ、DLL、RES等。
  • 数据流
  • 比如描述FPO的FPO数据流。
  • 符号类标(Tag)
  • 根据所描述对象,符号被分为函数、常量、用户定义类型(UDT)、类型、常量等30多个类型。
  • 搜索
  • 可以按照符号逻辑关系或者符号名称等搜索符号。

下图是SymView的一个截图,左侧提供了上面介绍的5种观察视图。右侧上方的网格窗口用于显示左侧选中表格或者符号类的所有数据行或者符号。右侧下方用于显示上方选中行的子符号或者相关信息(属性)。 例如,目前使用的符号类表视图,选中的是UDT类型,右侧上方显示的是目前符号文件(NT内核的公共符号文件)中的所有UDT类型的符号。右侧下方显示的是上方选中的_DBGKD_BREAKPOINTEX类型的所有属性。

最下方的窗口用户显示SymView程序自身的调试信息,可能包含它工作过程中遇到的异常或者错误情况。

下载地址:http://advdbg.org/books/swdbg/t_symview.aspx

二、Pdbripper

这款Pdb文件提取工具可以将符号文件提取出来,用于单独查看调试文件是否正确。非常好用的一款小工具。

下载地址:http://ntinfo.biz/index.html

 

理想情况下,无论是否附加了调试器,应用程序都会执行相同的操作。这源于非常实际的动机:

  1. bug通常首先出现在调试器之外(一些测试失败),然后您只想在调试器下重新运行测试来重新生成问题。如果调试器更改了行为,这将妨碍您重新编程的能力。
  2. 另一方面,开发人员可以在IDE下开发代码,并通过在调试器下检查代码来确保代码正常工作。如果调试器引入了隐藏缺陷的行为更改,那么开发人员将在这里忽略它。
  3. 如果应用程序的行为只发生在调试器下,人们会将其行为归咎于调试器。例如,如果一个应用程序只在调试器下抛出异常,那么人们会认为调试器导致了异常,或者调试器已损坏并错误地报告了异常。(我们发现了很多这样的缺陷)。这种错误的指责会阻碍真正的问题得到解决。
  4. 行为差异会削弱人们对调试器的信心。
  5. 调试时,调试器是否附加到正在运行的进程,或者调试对象是否从调试器下启动都不重要。行为上的改变会在这两种情况之间造成一个楔子。这对于只能附加的调试器场景特别重要(例如,调试ASP.Net或SQL)

那么,为什么应用程序在托管调试器下的执行方式会有所不同呢?

  1. 用户检查:最大的罪魁祸首:程序可以显式调用System.Diagnostics.Debugger.IsAttached以询问是否附加了托管调试器,然后行为不同。(win32 API IsDebuggerPresent()同样检查是否附加了本机调试器。)。这是最容易引起痛苦的方法。例如,如果附加了托管调试器,WinForms将显式使用不同的“可调试”WndProc。这个可调试的wndproc在用户回调(不可调试的wndproc没有)周围有一个额外的try-catch,用于通知用户回调是否抛出异常。另一个最受欢迎的方法似乎是在附加了调试器以通知用户时抛出异常。
  2. CLR/JIT检查:ICorDebug允许调试器更改实时编译器的codegen选项(例如禁用优化)。这会导致从IL生成不同的本机代码,这肯定会改变行为。如果附加了调试器,v1.0和v1.1clrs中的jit实际上会自动生成可调试代码。这是个非常糟糕的主意。在v2.0中,JIT显式地不知道是否附加了调试器,尽管调试器可以显式地请求JIT生成可调试的代码。
  3. 操作系统检查:在极少数情况下,操作系统可能会处理不同的事情。例如,在Windows上,如果附加了本机(或互操作)调试器,则不会针对未处理的异常执行筛选器。(自己试试看)
  4. 利用非确定性:应用程序执行的某些部分是不确定的,例如定时问题和对机器范围内状态的任何引用(例如页面共享)。例如,在多线程应用程序中,调试器肯定会影响计时,这可能会暴露(或隐藏)现有的竞争条件。我个人觉得这比我想的要普遍得多。正因为这个原因,我不得不调试太多只会在调试器之外的优化版本上重新生成的bug。
  5. 调试器检查:以上所有原因都假定调试器行为良好。然而,作为API的产品,我们不能阻止API的使用者做改变debuggee行为的“愚蠢的”事情。例如:
    .在托管进程下启动:应用程序通常在自己的进程中启动。有人可以编写一个调试器,在宿主进程内部启动调试对象。这是调试和非调试的区别。
    .Func eval:属性求值(Func eval)的每个实例都是只在调试器下运行的代码。如果函数求值有任何副作用,则可以更改调试对象的行为。如果调试器在不提示用户的情况下执行函数求值,这一点尤其重要。

我觉得CLR调试器团队对这个问题太天真了。幸运的是,后面版本中采取了很多伟大的措施来解决这个问题:

  1. 已经认识到这一点很重要。VS对手帮助我们明确了这一点。
  2. 已经明确地消除了任何依赖于是否附加调试器的非调试器CLR行为。如前所述,JIT将不再自动生成可调试代码。我们现在还将始终跟踪jit的调试信息(jit生成的IL-->Native映射)。
  3. 已经尝试识别用户呼叫的常见模式调试器.IsAttached然后让ICorDebug提供其他解决方案,这些解决方案不需要调试人员有不同的行为。例如,Winforms具有可调试回调,因为它们希望在异常跨越回调和处理程序之间的边界时通知用户。如果ICorDebug只是(a)意识到这个边界是重要的,并且(b)提供了一个异常将在其上展开的通知,那么Winforms就永远不需要添加try-catch。

工作区对我来说总是有点混乱。我知道如何说服他们做我需要做的工作,但他们仍然有点神秘。最近我决定解决这个问题,只是为了知道他们是如何在幕后工作的。但在我向您展示我的调查之前,让我们讨论不同类型的工作区。Windbg使用几种内置类型,包括Base、User、Kernel、Remote、Processor Architecture、Per Dump和Per Executable。它还使用命名工作空间(或用户定义的工作空间)。当您执行特定类型的调试(例如实时用户模式、事后转储分析等)时,这些工作区将合并到最终环境中。这里有一个图表来说明工作区的可能组合。

  • 绿线是使用WinDbg打开转储文件的情况。在这种情况下,将使用基本工作区+每个转储工作区。注意:每个转储只意味着打开的每个转储文件都有自己的工作区。
  • 蓝线是使用WinDbg使用Base+User模式工作区实时调试正在运行的应用程序的场景。
  • 橙色线是WinDbg的一个例子,它用于在x86机器上执行实时内核调试。在本例中,windbg使用的是Base+Kernel+x86工作区。

从图中可以看到windbg通常使用两个工作区的组合。当实时内核调试时,它使用三个工作区。

那么工作空间里是什么呢?

  • 会话信息
    包括所有断点(bp)和异常和事件处理信息(sx设置);所有打开的源文件;所有用户定义的别名。
  • 配置设置
    包括符号路径;可执行映像路径;源路径;使用l+,l-(设置源选项)设置的当前源选项;日志文件设置;COM或1394内核连接设置(如果连接是使用图形界面启动的);每个打开的对话框中的最新路径(不保存工作区文件和文本文件路径);当前的.enable_unicode、.force_radix_output和.enable_long_status设置。
  • WinDbg图形界面
    WinDbg窗口的标题;自动打开反汇编设置;默认字体;桌面上WinDbg窗口的大小和位置;打开了哪些调试信息窗口;每个打开的窗口的大小和位置,包括窗口的大小、其浮动或停靠状态、是否与其他窗口有选项卡,以及其快捷菜单中的所有相关设置;调试器命令窗口中窗格边界的位置以及该窗口中的换行设置;工具栏和状态栏以及每个调试信息窗口上的各个工具栏是否可见;“寄存器”窗口的自定义;Calls窗口、Locals窗口和Watch窗口中的标志;在监视窗口中查看的项目;每个源窗口中的光标位置。

所有这些设置(蓝色设置除外)都是累积应用的(首先是基本设置,然后是下一个工作区等)。上面的蓝色项仅从链中的最后一个工作区加载。为了在实际操作中显示这一点,我创建了一个简单的演练,以演示调试器工作区的使用。
首先,我没有使用任何命令行选项就打开了windbg。当它在此休眠状态下打开时(没有附加到任何内容,也没有打开任何内容),它将使用基本工作区。如果我没有更改任何内容(例如窗口位置),则在开始调试时不会提示我使用任何工作区对话框。但是,如果我将调试器的主窗口移动到任何位置(我们将调用此位置1),然后执行下面突出显示的任何操作-

我收到这个对话框的提示-

在上面的对话框中选择“是”将我的更改集成到“基本”工作区中,因此窗口位置1现在是基本工作区的一部分。

现在我要选择“Open Executable”并浏览到我们原来忠实的目标notepad.exe。打开二进制文件后,windbg使用Base+Notepad(每个可执行文件)。现在,我将再次移动调试器的主窗口(我们将调用此位置2),并选择“调试>停止调试”选项。由于窗口位置更改,系统将提示我以下内容-