windows程序员进阶系列:《软件调试》之四:断点和单步调试

来源:互联网 发布:js点击div上传图片 编辑:程序博客网 时间:2024/05/06 15:00

         windows程序员进阶系列:《软件调试》之四:断点和单步调试

 

     断点和单步调试是两个经常使用的调试功能,也是调试器的核心功能。在第一篇文章中曾简单介绍了下,本文我们将会对它们进行更详细的介绍。

 

软件断点

 

     INT3断点指令是专门用来支持调试的指令。它的目的就是是cpu中断到调试器,以供调试者对执行现场进行各种分析。当我们在调试软件时,可以在某出插入INT3指令,当cpu执行到此断点处时会暂停下来。INT3又被成为断点指令。

 

INT3指令

 

     接下来我们通过一个实验来体验下INT 3断点的效果。

 

     当单击调试运行后,产生如下对话框:

     打开反汇编窗口,如下图:

    发现中断的位置00921af9出有一条 int 3指令。

    打开寄存器窗口,发现EIP的值也为00921af9,如下图:

     前面我们介绍说过,INT 3指令属于陷阱指令。当cpu产生此异常时,EIP是指向导致异常的下一条指令的。而此处竟然还指向发生异常的位置。各位看官莫要着急,请继续往下看 。

 

调试器中设置断点

 

     当我们在调试器中设置断点时,如在WinDbg中可以使用bp 0xxxxxxxxx来对某一地址设置断点。但此时调试器是如何解释bp命令的呢?原来,当我们设置断点时调试器会将将我们设置断点处的指令的第一个字节保存起来,然后写入INT 3指令。为什么只保存一个字节?这是因为INT 3指令只有一个字节大小。在清除断点时,在将原来保存的一个字节保存回去即可。

 

     vc把断点的信息存放在.opt文件。但是该文件中并没有保存每个断点处应该被INT 3指令替换掉的那个字节。这是因为替换是动态进行的。在非调试状态下我们甚至可以在注释行设置断点。此时vc仅仅是记录断点的位置。当开始调试时vc会一个个取出OPT文件保存的断点记录,并将断点处对应指令的第一个字节保存到内存中,然后再替换为INT 3指令。这被称为落实断点。在落实断点的过程中如果发现断点的位置对应不到目标映像的代码段,便会发出警告。

 

断点命中

 

      当cpu执行到INT 3指令时,会产生断点异常。我们称此时断点被命中。当断点被命中后,cpu转而去执行异常处理程序。在跳转到异常处理程序之前,cpu会保存当前执行上下文,包括段寄存器、EIP等。

 

      在保护模式下,在保存当前执行上下文之后,cpu会从IDTR寄存器中获得IDT的地址,在IDT表中查询异常处理函数。

 

      在Windows系统中,INT 3异常处理函数是操作系统内核函数KiTrap03。因此遇到INT 3会导致执行nt!KiTrap03函数。

 

      由于我们现在讨论的是应用程序调试,断点指令位于用户模式下的应用程序代码中,因此cpu会从用户模式转入内核模式,并经过几个内核函数的分发。此后内核函数会把这个异常通过调试子系统以异常事件的形式分发给用户模式的调试器。收到调试器的回复后,调试子系统的函数会层层返回,最后返回到异常处理程序,然后通知调试目标继续执行。

 

     在调试器收到调试事件后,会根据调试事件数据结构中的程序指针,得到断点发生的位置,然后在自己的断点列表中寻找与其匹配的项。如果能找到说明是自己设置的断点。如果找不到,则说明导致这个异常的INT 3指令不是自己放进去的。会告诉用户:一个用户插入的断点被触发了。

 

     在调试器下,我们看不到动态替换到程序的INT 3指令。大多数调试器的做法是在调试目标被中断到调试器之前,会先将所有断点位置被替换为INT 3的指令恢复成原来的指令,然后再把控制权交给用户。

 

      在Windows系统中,操作系统的断点异常处理函数KiTrap03,对于x86cpu在此函数中会有一个特殊的处理:在进入到此函数后,它会将程序指针寄存器的值减一。这也是我们为什么会在前面看到EIP的值仍然指向的是导致断点异常的指令位置。

 

      这样做当然是有它的考虑的:因为有断点存在,而且被INT 3指令覆盖的指令还没有被执行。按照EIP总是存储下一条要执行的指令的位置的原则,下一条指令就是被INT 3覆盖的指令,EIP也当然指向的是INT 3位置。虽然还指向原来的位置,但是这伴随了EIP++EIP--两步操作。

 

特别用途

 

     因为INT 3的特殊性,所以它会有一些特殊用途。当我们在使用vc进行调试时,常常会观察到一块从栈中刚分配的内存或字符串数组里面被填充满了“CC”。显示中文为“烫烫烫烫”这是因为CCCC是汉字烫的简码。而0xCC又是INT 3的机器码。这当然不是偶然的,为了辅助调试,编译器会在调试版本的程序中用0xCC来填充刚刚分配的栈缓冲区。如果因为缓冲区或堆栈溢出时,程序指针意外指向了这些区域,那么便会遇到INT 3指令而马上中断到调试器。

 

      除了这种做法外, 编译器还会使用INT 3指令填充函数或代码末尾的空闲区域,也就是使用它来做内存对齐。

 

     而我们经常见到的“屯屯”是debug下为未初始化的堆变量。

 

断点API

 

      Windows提供了API用以让应用程序向自己的代码中插入断点。在用户模式下可以使用DebugBreak()。在内核模式下可以使用DbgBreakPoint()或DbgBreakPointWithStatus()函数。

      在前面的实例程序中插入DebugBreak()函数,与使用_asm INT 3的效果是一样的。但是这需要使用windbg的本机内核调试功能跟进DbgBreak函数,才能发现。

 

INT 3INT n

 

      INT 3指令与 n=3INT n指令不同。INT n表示软件中断。INT n指令对应的机器码是0xCD后跟1字节的n值。如INT 23H会被编译成0xCD23。因此当编译器见到INT 3时会把它编译为0xCC而不是0xCD03

 

使用WinDbg观察调试器写入的INT 3指令

 

      可以通过下面的方法观察调试器插入的断点指令。

      步骤一:在vc中启动dbgtest(自定义程序,全部代码如下)程序,并在第10行设置一个断点。

      如:

      在第10行设置一个断点。

      步骤二:启动WinDbg并选择attach to a process。选中刚才的进程。并选中下面的noninvasive复选框(否则便会显示该程序正在被调试,打开失败)。

      步骤三:反汇编WinMain函数。

      得到结果为:

      关闭vc,重新打开WinDbgopen a excutable file。并反汇编WinMain函数。结果如下:

 

      注意观察上面两图第十行,它们分别对应的是:编译器插入INT 3指令和未插入INT 3指令的两种情况。另外可以发现,在第一张图中,由于INT 3指令的插入,后面的指令都发生了变化,这是由于指令组合错位造成的。

 

       因为使用INT 3指令产生的断点是依靠插入指令和软件中断机制工作的。因此人们习惯把这类断点成为软件断点。之所以又引入硬件断点是因为软件断点有以下局限性:

 

      一:只能设置代码类断点。只能让cpu在执行到代码段的某个位置停下来。不适于数据段和IO空间。

      二:对于运行在只读空间的程序如ROM中,无法动态增加软件断点。因为目标内存是只读的,无法动态写入断点指令。这时动态断点就有了用武之地。

      三:在中断向量表IVT或中断描述表IDT被破坏的情况下这个断点会无法工作。这时只能使用硬件级的调试工具。

      虽然软件断点有自己的局限性,但是由于它使用方面且不限制数量,因此被广泛使用。

 

硬件断点

 

      IA-32cpu定义了8个调试寄存器。分别为:DR0-DR7。在32位系统下,它们都是32位的。DR4DR5是保留的。

      432位的调试地址寄存器(DR0-DR3)

      132位的调试控制寄存器(DR7)

      132位的调试状态寄存器(DR6)

     可以看到通过调试寄存器可以设置4个硬件断点(DR0-DR3)

DR0-DR3用来指定断点内存或IO地址。DR7用来进一步定义断点的中断条件。DR6用于在中断发生时,向调试器报告详细的信息,以供调试器判断发生的是何种事件。

 

调试地址寄存器

 

     调试地址寄存器用来指定断点地址。对于设置在内存空间的断点,这个地址应该是线性地址而不是物理地址。因为cpu是在线性地址被翻译成物理地址之前来做断点匹配工作的。这也意味着,在保护模式下,不能使用调试寄存器来对物理地址设置断点。

 

调试控制寄存器

 

     在DR7中有24位被分为四组分别与四个调试地址寄存器相对应。通过设置调试寄存器各个位域来定义各种断点条件。

 

各个位域的含义为:

 

 

 

读写域R/Wn

 

     通过对读写域设置,我们可以设置指定断点的访问类型。也就是以何种方式访问断点寄存器指定的地址时中断。如:执行时此地址代码时中断、读写此地址时中断还是对此地址IO时中断。

 

      读写域有2位,可以指定四种方式。可以分为以下三种情况:

 

读写内存数据时中断

 

      这种断点又被称为数据访问中断。利用此类断点可以实现监控对全局变量或局部变量的读写操作。在WinDbg可以使用ba设置这样的断点。如:ba w4 00401200.它的意思是对地址00401200开始的4字节内存区域进行写操作时产生中断。当把w4换成r4就会在读时中断。

      现在许多非常复杂的条件断点都可以使用数据访问断点来实现。

      读写内存数据时异常为陷阱类异常。

 

执行内存代码时中断

 

      这种断点又被成为代码访问中断或指令中断。这种断点与前面介绍的INT 3软件断点很类似。但是也有自己的优点:不需要向目标代码插入断点指令。执行内存代码异常为错误类异常。要与陷阱类异常相区分。这在后面我们会有介绍。

 

读写IO端口时中断

 

     这种断点又被称为IO访问断点。对于调试设备驱动程序来说很重要。

 

长度域LENn

 

     地址寄存器指定了要监视区域的起始地址。读写域定义了访问类型,下面介绍到的长度域LENn则定义了要监视区域的长度。

     LENn域可以指定1248字节。

     对于代码访问断点长度应该为00,代表1字节长度。cpu只会用指令的起始字节来匹配断点。如在WinDbg中设置代码访问硬件断点:ba e1 0x00383883

     对于数据和IO访问断点,只要断点区域的任意字节被访问都会触发该断点。另外还要注意字节对齐要求。2字节区域必须按2字节边界对齐(低1位会被屏蔽)。4字节区域必须按4字节边界对齐(低2位会被屏蔽)。8字节区域必须按8字节边界对齐(低4位会被屏蔽)。 

 

     如果地址没有按要求对齐,就会出现无法预期的效果。cpu在检查断点匹配时会自动去除相应数量的低位(区域字节/2。例如如果将DR0设为0xA003,LEN0设为114字节2字长)用以实现任何对0xA003-0xA006内存区域内的任何写操作都会触发断点。但是由于断点对齐的要求,起始地址0xA0032会被屏蔽掉,变成了0xA000。此时只有0xA003处于0xA000-0xA003地址区间,因此只有0xA003被访问时才会触发断点。0xA004-0xA006屏蔽2后都是0xA0004,因此它们不会触发断点异常。

 

指令断点注意

 

     对于指令断点,当cpu在执行move ss ,xx时会禁用所有的中断和异常,直到下一条指令被执行完毕。这是为了保证栈段寄存器和栈顶指针的一致性。位于move ss,xx下一行的断点都不会被触发。

类似的,紧邻POP SS指令的下一条指令处的指令也不会被触发。

 

调试异常

 

     IA-32构架专门分配了两个中断向量号来支持软件调试:向量1和向量3。向量3用于INT 3指令产生的断点异常。向量1用于其他情况的调试异常,简称为调试异常。硬件断点产生的是调试异常,所以当硬件断点发生时CPU会执行1号向量所对应的处理例程。

 

     对于错误类调试异常,因为恢复执行之后断点条件仍然存在,为了避免反复发生异常,调试器必须在使用IRETD指令返回前将标志寄存器的EFlagsRF位设为1,告诉cpu不要在执行返回后的第一条指令时产生异常。

调试状态寄存器

     调试状态寄存器DR6的作用是当cpu检测到匹配断点条件的断点或其他调试事件到来时,用来向调试器的断点异常处理程序传递断点异常的详细信息,以便使调试器可以很容易的识别出发生的是什么调试事件。

     比如当B01时,那么就说明DR0所定义的断点被触发了。B0-B3对应DR0-DR3

     因为单步调试和硬件断点触发的异常使用的都是1号向量号,因此调试器需要使用调试状态寄存器来判断触发异常的原因。

 

WinDbg硬件断点设置

 

     使用WinDbg ba命令设置硬件断点时,必须在先执行到入口函数后才可以设置。这是因为在入口函数之前,进程尚未初始化完成,系统还会初始化线程上下文,调试寄存器的值也会被设置。我们可以在WinMain处设置断点,让程序在WinMani处中断到调试器。

 

     下面我们介绍一个例子。使用WinDbg打开我们一直使用的dbgtest.exe程序,此时程序中断到了入口函数入口。然后在WinMain设置一个软件断点:bp WinMain

     然后按F5让调试目标暂停到WinMain

 

 

     接着设置两个硬件断点。

 

     输入bl命令列出所有断点:

     结果显示包括软件断点在内共有三个断点。

     然后立即查看调试寄存器。

     我们发现此时虽然已经设置了两个硬件断点但是调试寄存器的值还没有被改变。

     F5,让调试目标继续执行。暂停到我们设置的第一个硬件断点位置.

 

      注意eip的值:01341aea,这个地址是触发断点异常的下一条指令。这是因为数据访问类断点是陷阱类断点,所以当断点命中时触发断点的指令已经执行结束,eip当然指向的是下一条指令的地址。

     我们前面设置的第一个硬件断点的位置为0136703c,

 

 

      这一句中访问了硬件断点。但是程序却停在了下面的位置:

     此时查看调试器寄存器的值:

     可以看到此时调试寄存器的值已经改变为我们设置的值。

     使用.format命令分析DR6,可以得到:

     位11表示1号断点被命中。这正是我们前面设置的存储在DR1013675c断点被命中。

 

    DR7的第1617位为RW0,值为01表明中断类型为写。

    2021位为RW1值为01,表明中断类型也为写。

     1819LEN0。值为01,表明长度为2字节。

     2223LEN1。值为01,表明长度域也为2字节。

     接着我们在设置一个内存代码执行硬件断点。如

 

 

执行后,调试目标中断到此位置。各寄存器值为:

     我们发现eip的值仍然指向发生内存执行断点时的位置。而不是下一条要执行指令的位置。这是为什么?这是因为内存执行断点导致的异常是错误行异常。错误性异常与陷阱类异常不同。当错误性异常发生时,cpu会将机器状态恢复成执行异常指令前的状态。这也是之所以eip指针没有改变的原因。

 

硬件断点设置方法

 

     只有在实模式下或优先级为0时才能设置调试寄存器,否则便会导致保护性异常。因此对于vc这类用户态调试器,它们是通过设置线程的上下文CONTEXT结构来间接访问调试器。

 

     CONTEXT结构用来保存线程的执行状态。当一个线程被挂起时,寄存器信息会被保存在CONTEXT结构中。当线程恢复执行时,又会被恢复到寄存器中。可以使用SetThreadContext API来设置调试寄存器的函数调用过程。

     因此我们可以通过SetThreadContext函数来间接的存取寄存器。CONTEXT结构包含通用寄存器和调试寄存器。我们可以通过调用SetThreadContext来手动实现硬件断点。

     硬件断点虽然有很多优点但是因为只有四个断点地址寄存器,有时候就会不够用。

 

陷阱标志

 

     除了断点还有一种常用的使cpu中断到调试器中。这就是陷阱标志。当陷阱标志被设置时,cpu一旦检测到符合陷阱条件的事件发生时就会通知调试器。

     IA-32处理器支持的陷阱标志有以下三种:

     一:单步执行标志(EFLAGST位)。

     二:任务状态陷阱标志(任务状态段的TSST标志)。

     三:分支到分支单步执行标志。

 

单步执行标志

 

     当EFLAGST位为1时,cpu每执行完一条指令便会产生一个调试异常,中断到调试异常处理程序。很多调试器的单步执行功能都是依靠这一机制实现的。

     由于调试异常的向量号为1,因此在设置TF标志后,cpu每执行一条指令后都会去执行1号异常处理例程。当硬件断点发生时,可以利用DR6来识别到底发生了何种事件。

     单步异常也属于陷阱类异常。软件断点异常与硬件断点的数据及IO断点异常也属于陷阱异常。但是硬件断点的指令访问异常为错误异常。这一定要明白。

     因为cpu在进入异常处理程序前都会清除TF标志,所以当cpu中断到调试器时在观察TF标志它的值总是0

 

高级语言的单步执行

 

     高级语言的一条语句一般对应多条汇编指令。因此对于高级语言的单步执行一般对应多条汇编指令。这是通过一下三种方式来实现的:
     第一种:使用TF标志一步一步走过每一条汇编指令。产生多条调试异常,但是仅仅最后一次才中断给用户。

     第二种:在C++语句对应的最后一条汇编语句动态的插入一条INT 3指令。先让cpu一下子跑到最后一条汇编指令处插入指令,然后再回来一次执行完。

     第三种:在C++语句的下一条语句的第一条汇编指令处替换一条INT 3指令。

     对于第二种和第三种方法,由于无法预测高级语言的所对应汇编的最后一条语句和第一条语句。因此大多数调试器都是使用第一种方法来实现的。

 

任务段陷阱标志

 

     TSS是用来记录一个任务(线程)的状态,包括各种寄存器和其他重要信息。当任务切换时,当前任务的状态会被保存在这个内存段中,当要恢复任务运行时,系统会根据TSS所保存的信息恢复处理器现场。

      TSS中的T标志为1时,当cpu切换到这个任务时便会产生调试异常。这是在新任务开始执行之前发生的。调试中断处理程序可以根据调试状态寄存器DR6BT标志来识别出发生的是否是任务切换异常。

 

分支到分支单步执行标志(BTF)

 

     顾名思义,分支单步执行就是分支为单位单步执行。要启动分支到分支单步执行必须要同时设置TFBTF标志。此处不再介绍。

     介绍了这么多本篇博文也要结束了。提到的新知识点不少,对于刚接触的程序员来说很多都是第一次听过。在学习中需要需要不断回来查漏补缺。

 

                                     本博文参考自《软件调试》张银奎著。如有纰漏,请指正!!
                                                    2013.2.3于山西大同
原创粉丝点击