调试器工作原理(2):实现断点
2017-02-13 11:02
267 查看
调试器工作原理(2):实现断点
http://blog.jobbole.com/23632/ 2012/07/17 | 分类: IT技术, 开发 | 2条评论 | 标签: LINUX, 断点, 调试
分享到:3
Android中的WebView实战详解
通过自动回复机器人学Mybatis---基础版
Linux网络管理
Android电商活动中的刮刮卡
本文由 伯乐在线 - bigship 翻译。未经许可,禁止转载!
英文出处:Eli
Bendersky。欢迎加入翻译小组。
本文是关于调试器工作原理探究系列的第二篇。在开始阅读本文前,请先确保你已经读过本系列的第一篇(基础篇)。
本文的主要内容
这里我将说明调试器中的断点机制是如何实现的。断点机制是调试器的两大主要支柱之一 ——另一个是在被调试进程的内存空间中查看变量的值。我们已经在第一篇文章中稍微涉及到了一些监视被调试进程的知识,但断点机制仍然还是个迷。阅读完本文之后,这将不再是什么秘密了。
软中断
要在x86体系结构上实现断点我们要用到软中断(也称为“陷阱”trap)。在我们深入细节之前,我想先大致解释一下中断和陷阱的概念。
CPU有一个单独的执行序列,会一条指令一条指令的顺序执行。要处理类似IO或者硬件时钟这样的异步事件时CPU就要用到中断。硬件中断通常是一个专门的电信号,连接到一个特殊的“响应电路”上。这个电路会感知中断的到来,然后会使CPU停止当前的执行流,保存当前的状态,然后跳转到一个预定义的地址处去执行,这个地址上会有一个中断处理例程。当中断处理例程完成它的工作后,CPU就从之前停止的地方恢复执行。
软中断的原理类似,但实际上有一点不同。CPU支持特殊的指令允许通过软件来模拟一个中断。当执行到这个指令时,CPU将其当做一个中断——停止当前正常的执行流,保存状态然后跳转到一个处理例程中执行。这种“陷阱”让许多现代的操作系统得以有效完成很多复杂任务(任务调度、虚拟内存、内存保护、调试等)。
一些编程错误(比如除0操作)也被CPU当做一个“陷阱”,通常被认为是“异常”。这里软中断同硬件中断之间的界限就变得模糊了,因为这里很难说这种异常到底是硬件中断还是软中断引起的。我有些偏离主题了,让我们回到关于断点的讨论上来。
关于int 3指令
看过前一节后,现在我可以简单地说断点就是通过CPU的特殊指令——int 3来实现的。int就是x86体系结构中的“陷阱指令”——对预定义的中断处理例程的调用。x86支持int指令带有一个8位的操作数,用来指定所发生的中断号。因此,理论上可以支持256种“陷阱”。前32个由CPU自己保留,这里第3号就是我们感兴趣的——称为“trap to debugger”。
不多说了,我这里就引用“圣经”中的原话吧(这里的圣经就是Intel’s Architecture software developer’s manual, volume2A):
“INT 3指令产生一个特殊的单字节操作码(CC),这是用来调用调试异常处理例程的。(这个单字节形式非常有价值,因为这样可以通过一个断点来替换掉任何指令的第一个字节,包括其它的单字节指令也是一样,而不会覆盖到其它的操作码)。”
上面这段话非常重要,但现在解释它还是太早,我们稍后再来看。
使用int 3指令
是的,懂得事物背后的原理是很棒的,但是这到底意味着什么?我们该如何使用int 3来实现断点机制?套用常见的编程问答中出现的对话——请用代码说话!
实际上这真的非常简单。一旦你的进程执行到int 3指令时,操作系统就将它暂停。在Linux上(本文关注的是Linux平台),这会给该进程发送一个SIGTRAP信号。
这就是全部——真的!现在回顾一下本系列文章的第一篇,跟踪(调试器)进程可以获得所有其子进程(或者被关联到的进程)所得到信号的通知,现在你知道我们该做什么了吧?
就是这样,再没有什么计算机体系结构方面的东东了,该写代码了。
手动设定断点
现在我要展示如何在程序中设定断点。用于这个示例的目标程序如下:
我希望设定的断点位置应该在第一条打印之后,但恰好在第二条打印之前。我们就让断点打在第一个int 0×80指令之后吧,也就是mov edx, len2。首先,我需要知道这条指令对应的地址是什么。运行objdump –d:
看到这里我真的很想再扯一点题外话,所以你有两个选择。如果你真的对于为什么地址是0×8048096,以及这代表什么意思非常感兴趣的话,接着看下一节。如果你对此毫无兴趣,只是想看看怎么设定断点,可以略过这一部分。
题外话——进程地址空间以及入口点
坦白的说,0×8048096本身并没有太大意义,这只不过是相对可执行镜像的代码段(text section)开始处的一个偏移量。如果你仔细看看前面objdump出来的结果,你会发现代码段的起始位置是0×08048080。这告诉了操作系统要将代码段映射到进程虚拟地址空间的这个位置上。在Linux上,这些地址可以是绝对地址(比如,有的可执行镜像加载到内存中时是不可重定位的),因为在虚拟内存系统中,每个进程都有自己独立的内存空间,并把整个32位的地址空间都看做是属于自己的(称为线性地址)。
如果我们通过readelf工具来检查可执行文件的ELF头,我们将得到如下输出:
1. 将代码段映射到地址0×8048080处
2. 从入口点处开始执行——地址0×8048080
但是,为什么是0×8048080呢?它的出现是由于历史原因引起的。每个进程的地址空间的前128MB被保留给栈空间了(注:这一部分原因可参考Linkers and Loaders)。128MB刚好是0×80000000,可执行镜像中的其他段可以从这里开始。0×8048080是Linux下的链接器ld所使用的默认入口点。这个入口点可以通过传递参数-Ttext给ld来进行修改。
因此,得到的结论是这个地址并没有什么特别的,我们可以自由地修改它。只要ELF可执行文件的结构正确且在ELF头中的入口点地址同程序代码段(text section)的实际起始地址相吻合就OK了。
通过int 3指令在调试器中设定断点
要在被调试进程中的某个目标地址上设定一个断点,调试器需要做下面两件事情:
1. 保存目标地址上的数据
2. 将目标地址上的第一个字节替换为int 3指令
然后,当调试器向操作系统请求开始运行进程时(通过前一篇文章中提到的PTRACE_CONT),进程最终一定会碰到int 3指令。此时进程停止,操作系统将发送一个信号。这时就是调试器再次出马的时候了,接收到一个其子进程(或被跟踪进程)停止的信号,然后调试器要做下面几件事:
1. 在目标地址上用原来的指令替换掉int 3
2. 将被跟踪进程中的指令指针向后递减1。这么做是必须的,因为现在指令指针指向的是已经执行过的int 3之后的下一条指令。
3. 由于进程此时仍然是停止的,用户可以同被调试进程进行某种形式的交互。这里调试器可以让你查看变量的值,检查调用栈等等。
4. 当用户希望进程继续运行时,调试器负责将断点再次加到目标地址上(由于在第一步中断点已经被移除了),除非用户希望取消断点。
让我们看看这些步骤如何转化为实际的代码。我们将沿用第一篇文章中展示过的调试器“模版”(fork一个子进程,然后对其跟踪)。无论如何,本文结尾处会给出完整源码的链接。
注意,我们这里并没有重新加载断点。这可以在单步模式下执行,然后将陷阱指令加回去,再做PTRACE_CONT就可以了。本文稍后介绍的debug库实现了这个功能。
更多关于int 3指令
现在是回过头来说说int 3指令的好机会,以及解释一下Intel手册中对这条指令的奇怪说明。
“这个单字节形式非常有价值,因为这样可以通过一个断点来替换掉任何指令的第一个字节,包括其它的单字节指令也是一样,而不会覆盖到其它的操作码。”
x86架构上的int指令占用2个字节——0xcd加上中断号。int 3的二进制形式可以被编码为cd 03,但这里有一个特殊的单字节指令0xcc以同样的作用而被保留。为什么要这样做呢?因为这允许我们在插入一个断点时覆盖到的指令不会多于一条。这很重要,考虑下面的示例代码:
通过对int 3指令采用一个特殊的单字节编码就能解决这个问题。因为x86架构上指令最短的长度就是1字节,这样我们可以保证只有我们希望停止的那条指令被修改。
封装细节
前面几节中的示例代码展示了许多底层的细节,这些可以很容易地通过API进行封装。我已经做了一些封装,使其成为一个小型的调试库——debuglib。代码在本文末尾处可以下载。这里我只想介绍下它的用法,我们要开始调试C程序了。
跟踪C程序
目前为止为了简单起见我把重点放在对汇编程序的跟踪上了。现在升一级来看看我们该如何跟踪一个C程序。
其实事情并没有很大的不同——只是现在有点难以找到放置断点的位置。考虑如下这个简单的C程序:
代码
这里是完整的源码。在文件夹中你会发现:
debuglib.h以及debuglib.c——封装了调试器的一些内部工作。
bp_manual.c —— 本文一开始介绍的“手动”式设定断点。用到了debuglib库中的一些样板代码。
bp_use_lib.c—— 大部分代码用到了debuglib,这就是本文中用于说明跟踪一个C程序中的循环的示例代码。
结论及下一步要做的
我们已经涵盖了如何在调试器中实现断点机制。尽管实现细节根据操作系统的不同而有所区别,但只要你使用的是x86架构的处理器,那么一切变化都基于相同的主题——在我们希望停止的指令上将其替换为int 3。
我敢肯定,有些读者就像我一样,对于通过指定原始地址来设定断点的做法不会感到很激动。我们更希望说“在do_stuff上停住”,甚至是“在do_stuff的这一行上停住”,然后调试器就能照办。在下一篇文章中,我将向您展示这是如何做到的。
关于作者: bigship
陈舸:程序员,关注网络协议、Linux、C/C++、Python。目前从事网络通信设备的开发。译有《算法精解:C语言描述》和《Linux编程接口》(新浪微博:@bigsh1p)
查看bigship的更多文章
»
相关文章推荐
- 调试器工作原理之二——实现断点
- 调试器工作原理(2):实现断点
- 调试器工作原理之二——实现断点(ptrace)
- 调试器工作原理之二——实现断点
- 调试器工作原理之二——实现断点
- (转)调试器工作原理(2):实现断点
- 调试器工作原理之二——实现断点
- 调试器工作原理之二――实现断点
- 调试器工作原理(2):实现断点
- 调试器工作原理(2):实现断点
- 调试器工作原理(2):实现断点
- Linux调试器工作原理之二——实现断点
- 调试器工作原理(2):实现断点
- 调试器工作原理之二——实现断点
- 【WM】工作窗口类原理和实现
- 简述Asp.net中断点续传的原理与实现
- Struts2框架及实现原理和工作流程
- 调试器工作原理
- [Win32]一个调试器的实现(七)断点
- asp.net装载进度条及工作页面框架原理和实现