[Win32]一个调试器的实现(七)断点

[Win32]一个调试器的实现(七)断点

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

断点是最基本和最重要的调试技术之一,本文讲解了如何在调试器中实现断点功能。

什么是断点

在进行调试的时候,只有被调试进程暂停执行时调试器才可以对它执行操作,例如观察内存内容等。如果被调试进程不停下来的话,调试器是什么也做不了的。要使被调试进程停下来,除了几个在特定时刻才发生的调试事件外,唯一的途径就是引发异常。

断点正是用来达到上述目的的异常,在第三篇文章的异常代码表中,有一种EXCEPTION_BREAKPOINT异常,它就是断点异常。虽然断点是一种异常,但并不意味着被调试进程发生了问题,它只是用来调试的一种手段,所以调试器应该将它和别的异常明显区分开来。实际上Windows对断点异常的处理也有一些微妙的不同,下文将会讲到。

断点有软件断点和硬件断点之分。硬件断点是通过CPU的寄存器来设置的,它的功能很强大,既可以在代码中设置断点,也可以在数据中设置断点,但是可以设置的数量有限。软件断点即通过int 3指令引发的断点,机器码是0xCC,它只能设置在代码中,但没有数量的限制。本文只关注软件断点。

如果你使用过前几篇文章中的MiniDebugger来调试程序,肯定会注意到在被调试程序刚开始运行的时候总会有一个发生在高地址处的断点异常(通过异常代码是0x80000003来判别),这个断点就是初始断点。如果Windows检测到一个程序正在被调试,那么在这个程序初始化完成之后,就会引发一个断点异常,告诉调试器一切就绪。调试器可以在接收到这个断点时进行准备工作,例如加载调试符号。初始断点是不可避免的,只要在Windows下调试程序都会引发这个断点。

断点异常的分发

断点实际上是异常,所以它同样也会经历第三篇文章所说的异常分发的过程。那么,它是属于错误异常还是陷阱异常呢?不妨通过实验来证实。这里使用上一篇文章的MiniDebugger作为调试器,以下面代码生成的程序作为被调试程序:

1 int wmain(int argc, wchar_t** argv) {
2    __asm { int 3 };
3    return 0;
4 }

首先启动被调试程序,跳过初始断点,使它执行__asm {int 3};语句,引发断点异常:

执行l和r命令查看源代码和寄存器:

可以看到,执行完int 3指令后,EIP指向了下一条指令,如果以g c命令恢复执行,就会执行return语句,被调试进程就会结束。得出结论:断点异常属于陷阱异常。

上文说过Windows对于断点异常的处理有微妙的不同,现在让我们看一下有什么不同。执行g命令,不处理异常,在第二次接收到异常时执行l和r命令:

这时EIP回退了一个字节,指向了引发断点异常的那条指令。Windows在分发其它异常时并不会修改EIP的值,这就是它们的区别。

另外,调试器只会接收到一次初始断点,无论以DBG_CONTINUE还是DBG_EXCEPTION_NOT_HANDLED调用ContinueDebugEvent,都不会再接收到初始断点。

陷阱标志

除了断点之外,CPU本身提供了一个单步执行的功能,也可以使程序在某处中断。在CPU的标志寄存器中,有一个TF(Trap Flag)位,当该位为1时,CPU每执行一条指令就会引发一次中断,Windows以单步执行异常来通知调试器,异常代码为EXCEPTION_SINGLE_STEP。每引发一次中断,CPU都会自动将TF位设为0,所以如果想连续单步执行多条指令,需要在每次处理单步执行异常时都重新设置TF位。

单步执行异常属于错误异常,引发异常的地址与EIP指向的地址相同。

断点功能原理

上面的例子在被调试程序中插入了一条int 3指令,那是为了实验的需要,但是在正常的程序中不可能会有这样的指令。为了可以在任何指令处设置断点,调试器要将指令的第一个字节替换成0xCC(int 3的机器码),接收到断点异常之后,再替换回原来的那个字节,从该指令开始继续执行。这样就实现了在任意指令处中断,并对原程序毫无影响。

例如,下面的赋值语句对应一条汇编指令:

int b = 2;
C7 45 F8 02 00 00 00    mov    dword ptr [b],2

这条指令有7个字节。假如调试器想要在这条语句设置断点,它首先将指令的第一个字节0xC7保存起来,然后替换成0xCC:

CC 45 F8 02 00 00 00

此时原来的mov指令变成了一条int 3指令和6个字节的垃圾数据。被调试程序不知道这个变化,它逐条指令地执行,到了int 3指令之后引发断点异常,暂停执行。此时被调试程序不能再往下执行了,因为接下来的6个字节是垃圾数据,尝试执行的话肯定会失败。

调试器可以选择在第一次或第二次接收断点异常时进行处理。如果在第一次接收时处理,它就要主动将被调试进程的EIP减。如果在第二次接收时处理,就不需要修改被调试进程的EIP了,因为正如上文所说,第二次接收断点异常时Windows已经将EIP减1了。无论何时处理异常,调试器都要将0xCC替换回原来的0xC7,然后以DBG_CONTINUE继续被调试进程执行。

我建议在第一次接收断点异常时进行处理,因为如果第一次接收时不处理,Windows会执行额外的代码,这会给单步执行功能带来一些麻烦。

最后还有一个问题需要留意,如果断点设置在循环的内部,或者设置在一个被多次调用的函数中,那么该断点只会中断一次,因为它在第一次中断之后就被取消了。为了让它持续有效,我们需要一种机制,让断点所在的指令执行完之后重新设置该断点。这可以借助TF位的帮助:处理断点异常的时候,在取消断点之后立即设置TF位,然后继续执行;在捕捉到单步执行异常时重新设置断点。

完整的断点功能流程图如下:

实现断点功能

了解了断点功能的原理,下面就来逐步实现这个功能。这里只描述大概的思路,具体如何实现可以参考示例代码。

首先是要确定断点的地址,这可以通过MiniDebugger的l命令来获取每一行的地址。注意,断点只能设置在指令的第一个字节,否则会破坏指令的结构,导致被调试进程无法执行。

确定地址之后就要替换指令第一个字节。读取这个字节可以使用ReadProcessMemory函数,写入字节可以使用WriteProcessMemory函数。前者已经在第四篇文章中介绍过,而后者的使用方法与之非常相似,这里不再详述了。恢复指令也是使用WriteProcessMemory函数。

调试器必须保存一份断点列表,最好用一个结构体来表示断点,例如:

1 typedef struct {
2     DWORD address;    //断点地址
3     BYTE content;     //原指令第一个字节
4 } BREAK_POINT;

接下来是处理断点异常的方式。应该将断点分成三种类型:初始断点,被调试进程中的断点,以及调试器设置的断点。对于初始断点,不需要进行任何处理,因为它是由Windows管理的。如果对初始断点应用了以上的处理过程,被调试进程会无法运行。被调试进程中的断点即代码中显式加入的断点,例如上面例子中的__asm{ int3 }语句。对于这类断点,只要在第一次接收断点异常时报告给用户即可,不需要进行其它处理。而调试器设置的断点就要按照上文所说的方法来处理了。

如果选择在第一次接收断点异常时进行处理,那么需要使用SetThreadContext函数设置被调试进程的EIP,该函数的参数与GetThreadContext完全一致。为了避免修改EIP而影响到其它的寄存器,应该先调用GetThreadContext填充CONTEXT结构,再调用SetThreadContext。例如:

1 CONTEXT context;
2 context.ContextFlags = CONTEXT_CONTROL;
3 GetThreadContext(g_hThread, &context);
4 context.Eip -= 1;
5 SetThreadContext(g_hThread, &context);

设置TF位的方法与设置EIP的方法一致,同样是先调用GetThreadContext,然后修改Eflags字段的值,再调用SetThreadContext。TF位是EFLAGS寄存器中的第8位(从0开始算),通过下面的语句可以设置TF位:

1 context.EFlags |= 0x100;

在处理单步执行异常时,不能简单认为EIP减1就是原断点的地址,因为断点所在指令的长度是不确定的。为了重新设置断点,需要保存该断点的地址,或者干脆将所有断点都重新设置一次。具体使用什么方法则因人而异了。

最后提醒一下,设置断点之后使用d命令观察断点处的内存时会“露馅”,看到替换之后的0xCC。通常应该对用户隐藏这个事实,所以在处理d命令时应该将断点处原来的内容显示出来。

Main函数设置断点

如果按照上面的处理方法将初始断点忽略之后,带来了一个新的问题:被调试进程此时不会在初始断点发生时暂停,而是一直运行到结束,我们根本没机会对它进行任何操作。解决这个问题的方法就是在Main函数的入口处设置断点。这里所说的Main函数是一个统称,指代下面四个入口函数:

main

wmain

WinMain

wWinMain

一个C/C++应用程序的入口函数必定是上面四个的其中之一。

为了在Main函数处设置断点,首先要知道它的地址,这就需要调试符号的帮助了。一个函数是一个符号,可以通过SymFromName函数根据符号名称获取符号的信息。该函数的声明如下:

1 BOOL WINAPI SymFromName(
2     HANDLE hProcess,
3     PCTSTR Name,
4     PSYMBOL_INFO Symbol
5 );

第一个参数是符号处理器的标识符;第二个参数是符号的名称;第三个参数是指向SYMBOL_INFO结构体的指针,函数调用成功后符号的信息就保存在这个结构体中。该结构体的定义如下:

1 typedef struct _SYMBOL_INFO {
 2     ULONG SizeOfStruct;
 3     ULONG TypeIndex;
 4     ULONG64 Reserved[2];
 5     ULONG Index;
 6     ULONG Size;
 7     ULONG64 ModBase;
 8     ULONG Flags;
 9     ULONG64 Value;
10     ULONG64 Address;
11     ULONG Register;
12     ULONG Scope;
13     ULONG Tag;
14     ULONG NameLen;
15     ULONG MaxNameLen;
16     TCHAR Name[1];
17 } SYMBOL_INFO, *PSYMBOL_INFO;

这个结构体有很多字段,但目前我们只关注Address,它就是符号的起始地址。关于SYMBOL_INFO这个结构体,在后面的文章中还会提到。

获取Main函数地址的函数大概像下面那样:

1 DWORD GetEntryPointAddress() {
 2 
 3    static LPCTSTR entryPointNames[] = {
 4       TEXT("main"),
 5       TEXT("wmain"),
 6       TEXT("WinMain"),
 7       TEXT("wWinMain"),
 8    };
 9 
10    SYMBOL_INFO symbolInfo = { 0 };
11    symbolInfo.SizeOfStruct = sizeof(SYMBOL_INFO);
12 
13    for (int index = 0; index != sizeof(entryPointNames) / sizeof(LPCTSTR); ++index) {
14 
15        if (SymFromName(g_hProcess, entryPointNames[index], &symbolInfo) == TRUE) {
16 
17          return (DWORD)symbolInfo.Address;
18       }
19    }
20 
21    return 0;
22 }

示例代码

这次为MiniDebugger添加了b命令,其功能是设置断点,命令格式如下:

b [address [d]]

address为断点的地址,以十六进制表示。如果带d参数,表示删除断点,否则设置断点。如果不带任何参数,则显示所有已设置的断点。

这个版本的MiniDebugger示范了如何在第二次接收断点异常时进行处理,正如上文所说,这会给单步执行功能带来麻烦,所以在添加了单步执行功能之后会改回第一次接收时处理,请大家留意。另外,该版本的MiniDebugger没有对d命令进行额外处理以隐藏断点的0xCC机器码。

http://files.cnblogs.com/zplutor/MiniDebugger7.rar

时间: 2024-10-05 02:57:17

[Win32]一个调试器的实现(七)断点的相关文章

[Win32]一个调试器的实现(二)调试事件的处理

[Win32]一个调试器的实现(二)调试事件的处理 作者:Zplutor 出处:http://www.cnblogs.com/zplutor/ 本文版权归作者和博客园共有,欢迎转载.但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接,否则保留追究法律责任的权利. 上一篇文章说到了调试循环的写法,这回讲一下调试器应该如何处理各种调试事件. RIP_EVENT 关于这种调试事件的文档资料非常少,即使提到也只是用“系统错误”或者“内部错误”一笔带过.既然如此,我们也不需要对其进行什么处理

[Win32]一个调试器的实现(五)调试符号

[Win32]一个调试器的实现(五)调试符号 作者:Zplutor 出处:http://www.cnblogs.com/zplutor/ 本文版权归作者和博客园共有,欢迎转载.但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接,否则保留追究法律责任的权利. 一个调试器应该可以跟踪被调试程序执行到了什么地方,显示下一条将要执行的语句,显示各个变量的值,设置断点,进行单步执行等等,这些功能都需要一个基础设施的支持,那就是调试符号. 什么是调试符号 我们知道,在exe.dll等可执行文件

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

[Win32]一个调试器的实现(三)异常 作者:Zplutor 出处:http://www.cnblogs.com/zplutor/ 本文版权归作者和博客园共有,欢迎转载.但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接,否则保留追究法律责任的权利. 这回接着处理上一篇文章留下的问题:如何处理EXCEPTION_DEBUG_EVENT这类调试事件.这类调试事件是调试器与被调试进程进行交互的最主要手段,在后面的文章中你会看到调试器如何使用它完成断点.单步执行等操作.所以,关于这类调

[Win32]一个调试器的实现(八)单步执行

[Win32]一个调试器的实现(八)单步执行 作者:Zplutor 出处:http://www.cnblogs.com/zplutor/ 本文版权归作者和博客园共有,欢迎转载.但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接,否则保留追究法律责任的权利. 上回讲解了如何实现断点功能,这回讲解如何实现与断点紧密相关的单步执行功能.单步执行有三种类型:StepIn,StepOver和StepOut,它们的实现方式比较多样化,单独实现它们的话并不困难,但是将它们整合到一起就比较困难了,

[Win32]一个调试器的实现(十)显示变量

[Win32]一个调试器的实现(十)显示变量 作者:Zplutor 出处:http://www.cnblogs.com/zplutor/ 本文版权归作者和博客园共有,欢迎转载.但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接,否则保留追究法律责任的权利. 上回介绍了微软的符号模型,有了这个基础知识,这回我们向MiniDebugger中添加两个新功能,分别是显示变量列表和以指定类型显示内存内容.显示变量列表用于列出当前函数内的局部变量或者全局变量:以指定类型显示内存内容用于读取指定

[Win32]一个调试器的实现(十一)显示函数调用栈

[Win32]一个调试器的实现(十一)显示函数调用栈 作者:Zplutor 出处:http://www.cnblogs.com/zplutor/ 本文版权归作者和博客园共有,欢迎转载.但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接,否则保留追究法律责任的权利. 本文讲解如何在调试器中显示函数调用栈,如下图所示: 原理 首先我们来看一下显示调用栈所依据的原理.每个线程都有一个栈结构,用来记录函数的调用过程,这个栈是由高地址向低地址增长的,即栈底的地址比栈顶的地址大.ESP寄存器的

[Win32]一个调试器的实现(四)读取寄存器和内存

[Win32]一个调试器的实现(四)读取寄存器和内存 作者:Zplutor 出处:http://www.cnblogs.com/zplutor/ 本文版权归作者和博客园共有,欢迎转载.但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接,否则保留追究法律责任的权利. 在前几篇文章中,我实现的那个调试器只能被动接收调试事件并输出这些事件的信息.现在,我要将它修改成可以接收命令,并根据命令对被调试进程进行各种操作.首先从最基本的操作开始. 获取寄存器的值 每个线程都有一个上下文环境,它包

[Win32]一个调试器的实现(一)调试事件与调试循环

[Win32]一个调试器的实现(一)调试事件与调试循环 作者:Zplutor 出处:http://www.cnblogs.com/zplutor/ 本文版权归作者和博客园共有,欢迎转载.但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接,否则保留追究法律责任的权利. 前言 程序员离不开调试器,它可以动态显示程序的执行过程,对于解决程序问题有极大的帮助.如果你和我一样对调试器的工作原理很感兴趣,那么这一系列文章很适合你,这些文章记录了我开发一个调试器雏形的过程,希望对你有帮助.或许我

[Win32]一个调试器的实现(六)显示源代码

[Win32]一个调试器的实现(六)显示源代码 作者:Zplutor 出处:http://www.cnblogs.com/zplutor/ 本文版权归作者和博客园共有,欢迎转载.但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接,否则保留追究法律责任的权利. 上一篇文章介绍了调试符号以及DbgHelp的加载和清理,这回我们使用它来实现一个显示源代码的功能.该功能的实际使用效果如下图所示: 该功能不仅仅是显示源代码,还要显示每一行代码对应的地址.实现该功能大概需要进行以下的步骤: ①