调试器中的断点是如何设置的?

调试器应该每一个程序员都会用,但肯定不是每一个人都知道是怎么回事。

我对调试器非常着迷。它们太可爱了,我最近开发了一个小巧、非常基础的调试器,作为我的一个小项目。在这篇博文中,我将记录学到的一些如何设置断点的知识。本文可以被分为如下几个小节。

  • 什么是断点?
  • 什么是调试器?
  • 设置断点,调试器需要做什么?
  • 调试器如何暂停被调试进程?

 

什么是断点?

断点是程序中的某一点,一旦程序运行到这一点就会停止。

什么是调试器?

你可以认为调试器是这样一个程序,它使用 forks() 创建一个子进程,然后调用 execl() 加载准备调试的进程。我的代码里使用 execl(),但是任何 exec 家族的系统调用函数均可使用。

下面是 run_child() 函数,在其中调用了 execl() 函数,并传入待调试进程的可执行文件名称及路径作为参数。

我们看到在调用 execl() 之前调用 ptrace()。我们暂时不要深究 ptrace() 的细节,虽然理解它对理解调试器工作原理非常重要。稍后我们再讨论它。

现在我们有两个活跃进程:

  1. 作为父进程的调试器。
  2. 作为子进程的被调试进程。

现在让我们以一种简化的方式,抽象理解一下调试器通过哪些工作,才能在子进程中设置断点。调试器需要子进程在断点处停下来,那么该怎么做呢?

调试器需要做些什么才能设置一个断点?

我们首先仔细研究一番“设置一个断点”这句话。我们知道,当一个进程处于运行状态时,它的指令会被处理器依次执行。那么指令加载在哪里呢?在进程的虚拟内存的 text/code 段中!

我们设置一个断点,希望被调试进程可以在指定点暂停。也就是说,我们希望被调试程序在某条指令之前停下来。什么指令可以实现呢?如果在函数开始处设置一个断点,这条指令就是函数的第一条指令;如果在源码中某一行设置断点,这条指令,就是在该行对应的若干指令之前的那条。

那么,调试器需要让被调试进程就在执行这条指令时,停下来。

在我的项目里,我使用截图中下划线标注的指令。

调试器如何使被调试进程在执行指定指令前暂停?

调试器在被调试进程启动时,就将被调试进程的某条指令(或者某条指令的一部分),替换为可产生一个软中断的指令。因此,当这条被修改的指令被处理器执行时,就会产生 SIGTRAP 信号,这就足以使得进程停止了。这里,我略过了很多细节,不过随着我们的进展,都会逐渐明朗的。

好了,我们首先探讨一下,调试器如何修改一条指令?

一系列指令保存在进程的 text 段,并在载入时由虚拟内存进行映射。所以,要想修改指令,调试器需要知道那条指令的地址。

调试器如何找到一条指令的地址?

如果你编译 C/C++ 程序,你可以使用 “-g” 参数,令编译器生成一些额外信息。这些额外信息中,包含了上述映射信息,并被保存在一种称之为“DWARF” 格式的对象文件中。在 Linux 系统上,DWARF 格式被用于在 ELF 文件中保存调试信息。是不是很 Cool!ELF 是 Executable Linkable Format (可执行连接格式)的意思。这是一种表示对象文件、可执行文件、共享库的格式。

调试器用什么指令替代了原有指令?

调试器将原有指令所在位置的第一个字节,重写为 “int 3”。“int 3” 是一个单字节操作码,也就是说,调试器只需要修改指定内存地址的第一个字节即可。

“int 3”是什么指令? “int n”指令会调用一个异常回调,这个回调由操作数 n 指定。“int 3”会产生一个到调试异常回调的调用。该回调函数是内核代码的一部分,会向目标进程发出 SIGTRAP 信号,在我们的例子中,这个进程就是被调试进程。

调试器是什么时候、如何改变被调试进程的指令的?

终于到了见证奇迹的时刻!我们将会使用一个非常强大的系统调用—— ptrace()。

我们先了解下 ptrace。

ptrace() 能做些什么?我们的调试器将通过 ptrace,控制我们调试的程序的执行情况。调试器通过 ptrace() 查看、修改被调试进程的内存和寄存器。

如果你查看一下源码,在用 execl() 启动被调试进程之前,我们调用 ptrace() 函数,并传入 PTRACE_TRACEME 参数(表示当前进程被其父进程追踪,也就是这里的调试器)。

ptrace 的 man 文档中提到:

如果 PTRACE_O_TRACEEXEC 选项未生效,只要被跟踪进程成功调用 execl(2) ,被跟踪进程就会收到一个 SIGTRAP 信号,在该进程开始执行之前,使父进程得到一个获得控制权的机会。

简而言之:在被调试进程启动之前,调试器通过 wait()/waitpid() 系统调用,调试器会得到一个通知。此时,调试器就得到一个黄金时机,修改被调试进程的 text/code 段。

另外,每当被调试进程收到一个信号时,跟踪进程(调试器)都会在下一次调用 waitpid() 时得到通知。所以,当被调试进程收到 SIGTRAP 信号时,它会暂停执行,然后调试器会得到通知,而这就是我们期望的。我们希望被调试进程暂停,当被调试进程执行 “int 3”指令事,调试器得到通知。调试器通过 waitpid() 的返回值,确定被调试进程暂停相关信息。

SIGTRAP 信号的默认行为是进程镜像转储,之后退出进程,但是我们无法调试一个被杀死的进程,不是么?因此,调试器会忽略 SIGTRAP 信号,然后让被调试进程继续执行。

下面是将“int 3”指令设置到原始指令第一个字节的代码。首先,还是用 ptrace() 函数获取指定地址的原始指令,我们将其保存下来以便稍后恢复之用;然后,继续用  ptrace() 函数,但是传入不同的参数 PTRACE_POKETEXT,设置一个新的指令,该指令的第一个字节是 “int 3”。

当 “命中断点” 时,调试器需要做什么?

  • 首先,调试器需要将原始指令,恢复到设置断点的地址。
  • 然后,恢复完成后,原始指令应当被执行一次,调试器将继续被调试进程的执行。

调试器如何恢复原始指令?与通过设置“int 3”设置断点的方法一样。下面是代码。当设置断点的时候,我们将原始指令保存下来了,现在我们需要做的是,将它设置回给定内存地址。

被调试进程的原始指令又是如何被执行的?

现在,被调试进程的程序计数器已经指向下一条指令,当前地址已经被执行过“int 3”了。

为了保证处理器能执行被调试进程的原始指令,我们需要重设其程序计数器 (对 x86 机器 %eip,对 x86 64 机器 %rip)为原始指令的地址。
我们如何才能设置被调试进程的指令指针?

用 ptrace() 啊!ptrace()  拥有这种碉堡的能力,可以让我们“修改被跟踪进程内存和寄存器”。PTRACE_GETREGS 参数可以令 ptrace 将被调试进程的通用寄存器状态复制到一个结构体中。PTRACE_SETREGS 则可以修改被调试进程的通用寄存器状态。下面的代码实现了这些功能。

一旦调试器重置了被调试进程的程序计数器,被调试进程就可以继续执行。可以参照如下做法——

以上就是调试器设置断点的方法。

调试器的完整代码在这里

我在周四晚 RC 上的演讲中介绍过这个问题。你可以在这里下载PPT。

参考:
Eli Bendersky’s articles on debuggers
Call to interrupt procedures
Interrupts and interrupt handlers

2 收藏 评论

关于作者:alvendarthy

一个热爱生活的家伙! 个人主页 · 我的文章 · 14

相关文章

可能感兴趣的话题



直接登录
跳到底部
返回顶部