调试器如何工作-1-基础
译者序
本文是博客https://eli.thegreenplace.net/2011/01/23/how-debuggers-work-part-1的翻译版本
这是一系列介绍调试器如何工作的文章. 我并不确定这一系列会有多少文章或覆盖多少话题, 因此我打算从最基本的东西开始介绍.
本部分:
我将展示Linux下调试器主要构件的实现–ptrace
系统调用. 这篇文章中所有的代码都是在一台32位Ubuntu机器上进行编写的, 因此这些代码具有很强的平台特异性, 尽管将它们移植到别的平台不会很困难.
写作契机
为了理解这篇文章所干的事情, 请想象一下调试器的工作过程. 调试器可以启动一些进程并调试他们, 或者连接到其他的进程. 它可以步进的运行代码, 设置断点并在断点处中断程序, 输出变量值和进行堆栈跟踪. 许多调试器有更加高级的功能, 如及计算表达式的值, 调用正在调试的进程地址空间内的函数, 甚至进行热重载并观测效果.
尽管现代的调试器都是极其复杂的1, 但是令人震惊的是其依赖的基本原理是如此的简单. 调试器仅仅始于几个操作系统和编译器/链接器提供的基本功能, 其他的东西都只是编程上的小事
Linux调试 – ptrace
Linux调试器的核心是ptrace
(意为”跟踪”)系统调用2. 它是多功能且复杂的工具, 其允许一个进程控制另一进程的执行, 并窥探其内部结构3.
如果要完整的向你解释ptrace
, 可能需要一本中等大小的书籍, 这就是为什么我将聚焦实际案例中的应用. 现在让我们分析一些案例.
步进执行一个进程的代码
我现在将展示一个在”跟踪模式”下运行的进程示例, 我们将单步执行它的代码 – CPU实际执行的机器代码(汇编指令). 我将分部分的展示示例代码, 在文章的末尾你可以找到一个可以下载完整C文件的链接, 你可以下载, 编译, 调试它.
一个更高级的计划是编写一段代码, 它将分出一个子进程, 执行用户提供的命令, 而父进程跟踪子进程. 以下是main
函数:
1 | int main(int argc, char** argv) |
这非常简单: 我们使用fork
函数4启动了一个子进程, 后续条件的if
分支执行子进程(称为target
), else if
分支执行父进程(称为debugger
).
这是target
进程代码:
1 | void run_target(const char* programname) |
其中最有趣的就是ptrace
调用, ptrace
在sys/ptrace.h
中被声明:
1 | long ptrace(enum __ptrace_request request, pid_t pid, |
第一个参数是一个请求(request), 它可以是许多预定义的PTRACE*
常量中的一个或多个. 第二个参数是指定某些请求的一个进程的ID, 第三和第四个参数是
地址和指向数据的指针, 他们用于内存操作. 上述代码片段中的ptrace
调用发起一个PTRACE_TRACEME
请求, 这意味着这个子进程请求操作系统内核让它的父进程
跟踪它, 这个请求的描述在man手册上写的十分清晰:
(
PTRACE_TRACEME
请求)表示进程会被父进程跟踪, 任何除了SIGKILL
的信号发送给该进程都会触发它的中断, 父进程会被通过wait()
函数通知.另外, 之后所有此进程对exec()
的调用都会引起SIGTRAP
信号被发送给它, 给父进程机会在子进程开始执行前去得到控制权如果子进程没有请求父进程的跟踪, 那么这个信号就不会发出.(进程ID, 地址, 数据会被忽略)
我标注出了这个例子中值得我们注意的地方. 注意run_target
在ptrace
后做的第一件事就是以execl
为参数调用这个程序. 就像标注出的片段所示,这使得操作系统内核在程序以execl
为参数执行前停下来, 并给父进程发送信号.
现在终于可以看看父进程在做什么了:
1 | void run_debugger(pid_t child_pid) |
回想一下上面的部分, 一旦子进程被exec
调用执行, 它就会停下来并发送SIGTRAP
信号, 父进程会等待第一个wait
调用发生这种情况. 当一些有趣的事情发生时, wait
调用会返回, 父进程会进行检查, 因为子进程中断了(如果子进程被一个信号中断, WIFSTOPPED
就会返回真)
本文最有趣的地方便是父进程接下来要做的事情. 它发起PTRACE_SINGLESTEP
参数的ptrace
调用, 并给出子进程ID. 这告诉操作系统重启子进程, 但是在执行完下一条指令后停下. 父进程再次等待子进程停下, 这样的循环周而复始. 当wait
调用不再发出有关子进程停止的信号时, 这个循环就会终止. 在跟踪器正常运行的时间里, 就会有一个信号告诉父进程子进程退出了(WIFEXITED将在其上返回true).
注意, icounter
(计数器) 会记录下子进程执行的所有指令数. 所以这个简单的例子实际上做了一些有用的事情–在命令行上给出程序的名字, 执行这个程序并报告它从开始到终止总共执行的CPU指令数量. 让我们看看它的实际作用.
测试运行
我编译了下面的简单程序, 并在跟踪器下运行它.
1 |
|
令我惊讶的是, 跟踪器运行了很长一段时间, 并报告不少于10万条CPU指令被执行. 仅仅只是为了一个简单的printf
调用?发生了什么?这个问题的答案是很有趣的5. 默认情况下, gcc
在Linux上将C运行库与可执行文件进行动态链接, 这就意味着任何程序运行前第一件事就是让动态链接库加载器寻找并加载需要的动态库, 这需要执行许多代码–我们的跟踪器会跟踪每一条CPU指令, 并不只是main函数, 而是一整个进程.
因此, 我使用-static
选项链接了程序(并确定了这个可执行文件增长了大约500KB的大小, 这对于C运行库的静态链接合乎逻辑), 现在跟踪器只报告了约7000条指令. 这仍然很多, 如果你记得libc
初始化需要在main
函数之前执行, 内存清理工作必须在main
函数之后执行, 那么这完全是有意义的. 另外, printf
是一个复杂的函数.
我并不会就此满足, 希望看看一些更具有测试性的东西–即在一次完整的运行中, 我可以解释每一条指令. 于是我反汇编上述的”Hello world”程序:
1 | section .text |
上面这些这些足够了. 现在跟踪器报告7条指令被执行, 我能够很清晰的辨认出他们.
深入研究指令流
汇编编写的程序允许我向你介绍ptrace
的另一个相当有用的用法–密切监测被跟踪进程的状态. 下面是run_debugger
函数的另一个版本
1 | void run_debugger(pid_t child_pid) |
唯一的区别就是while
循环的前几行. 共有两个新增的ptrace
调用. 第一条将进程的寄存器信息读入一个结构体. user_regs_struct
在sys/user.h
中定义的. 现在有趣的来了–看看这个头文件开头的注释:
1 | /* The whole purpose of this file is for GDB and GDB only. |
现在我不知道你是怎么想的, 但是我觉得我们的方向是这样的:-) 不管怎样, 一旦我们拥有了所有的寄存器信息, 就可以通过带有PTRACE_PEEKTEXT
参数的ptrace
调用进程的当前指令, 并将regs.eip
(x86上的扩展指令寄存器)作为参数传入, 我们会得到指令信息6. 让我们看看这个新跟踪器在汇编代码上运行的结果:
1 | simple_tracer traced_helloworld |
好, 现在除了icounter
我们还可以看到指令寄存器和每一次它所指向的指令. 如何确认他们是否正确? 我们可以使用objdump -d
来看看:
1 | objdump -d traced_helloworld |
可见这些输出和我们的跟踪器输出是一一对应的.
调试一个正在运行的进程(attach模式)
正如你所知, 调试器也可以附着到一个正在运行的进程. 现在你应该不会惊讶于发现这也是通过使用ptrace
来实现的, 只需要给出PTRACE_ATTACH
请求就可以了. 我将不展示这部分的代码, 因为这通过改写我们已有的代码片段可以轻易的实现. 出于教学目的, 这里的方法会更加简单(因为我们恰好可以使子进程在开始时停下来.)
代码
本文中简单跟踪器的完整C代码(经过改进, 可以打印指令信息的版本)可以在此处获取. 可以在使用gcc
4.4以上版本带有-Wall -pedantic --std=c99
选项时无报错的编译.
总结和接下来的
无可否认的是, 这一部分并没有设计多少东西–我们距离手搓一个真实的调试器还有很远的距离. 然而, 我希望这篇文章可以让你对调试器运行的过程感到少了些神秘感. ptrace
是一个用途相当丰富, 而且能力强大的系统调用, 我们仅仅只是试验了其中很少的几个(用法).
单步运行调试代码很有用, 但仅限于一定程度下. 我用上文的C语言的Hello world!
为例展示, 在开始执行main
函数之前, 也许需要上千条指令初始化C运行库. 这不是很方便. 我们希望能够在main
函数的入口点处放一个断点, 从断点处开始单步执行.在这个系列的下面几篇文章中, 我打算展示断点是如何实现的.
参考文献
我在撰写这篇文章时在以下文章中寻找了有用的资源
1 我没有检查, 但是我确信GDB的代码(LOC, Line Of Code, 代码行数)至少有100万行
2 请通过man 2 ptrace
来查新更详细的说明
3 原文是Peak and poke
, 这是一个广为人知的编程术语, 代表直接的读写内存.
4 这篇文章需要你拥有基础的Unix/Linux编程基础, 我假设你至少听说过fork()
函数, 以exec
为例的一系列函数以及Unix进程信号(如SIGTRAP
, SIGKILL
等)
5 至少如果你和我一样痴迷于底层细节的话
6 在此警告一下: 正如我之前说过, 这些代码是高度平台特异性的. 我做出了一些简单的断定–例如x86指令并不一定是4个字节长(我的32位Ubuntu上unsigned的大小). 实际上, 很多数据都不是如此的. 想要有效的查看指令需要我们手边有完整的反汇编器. 我们现在并没有, 但是真正的调试器有.
Notice
第一次做烤肉工作qwq, 翻的一股机翻味…
氮素! 如果有什么改进建议的话请提出来 :)