调试器是大多数(如果不是每个)开发人员在其软件工程生涯中至少使用过一次的软件,但是你们有多少人知道它们实际上是如何工作的?在我在悉尼举行的 linux.conf.au 2018 上的演讲中,我将谈论从头开始编写调试器...使用 Rust!
在本文中,“调试器/追踪器”这两个术语可以互换使用。“Tracee”指的是被追踪器追踪的进程。
ptrace 系统调用
大多数调试器严重依赖于一个名为 ptrace(2)
的系统调用,其原型如下:
long ptrace(enum __ptrace_request request, pid_t pid, void *addr, void *data);
这是一个可以操作进程几乎所有方面的系统调用;但是,在调试器可以附加到进程之前,“tracee”必须使用请求 PTRACE_TRACEME
调用 ptrace
。这告诉 Linux,父进程通过 ptrace
附加到此进程是合法的。但是...我们如何强制进程调用 ptrace
呢?很简单!fork/execve
提供了一种在 fork
之后但在 tracee 真正开始使用 execve
之前调用 ptrace
的简单方法。方便的是,fork
还会返回 tracee 的 pid
,这对于以后使用 ptrace
是必需的。
现在 tracee 可以被调试器追踪了,重要的变化发生了
- 每次信号传递给 tracee 时,它都会停止,并且等待事件会传递给追踪器,追踪器可以通过
wait
系统调用族捕获该事件。 - 每个
execve
系统调用都会导致SIGTRAP
传递给 tracee。(结合前一项,这意味着 tracee 在execve
完全发生之前停止。)
这意味着,一旦我们发出 PTRACE_TRACEME
请求并调用 execve
系统调用以实际启动 tracee 中的程序,tracee 将立即停止,因为 execve
传递 SIGTRAP
,并且这会被追踪器中的等待事件捕获。我们如何继续?正如人们所期望的那样,ptrace
有许多请求可用于告诉 tracee 可以继续
PTRACE_CONT
:这是最简单的。tracee 运行,直到收到信号,此时等待事件传递给追踪器。这最常用于实现实际调试器的“继续直到断点”和“永远继续”选项。断点将在下面介绍。PTRACE_SYSCALL
:与PTRACE_CONT
非常相似,但在进入系统调用之前以及系统调用返回到用户空间之前停止。它可以与其他请求(我们将在本文后面介绍)结合使用,以监视和修改系统调用的参数或返回值。系统调用追踪器strace
大量使用此请求来确定进程发出的系统调用。PTRACE_SINGLESTEP
:这个是不言自明的。如果您之前使用过调试器,则此请求会执行下一个指令,但立即停止。
我们可以使用各种请求停止进程,但是我们如何获取 tracee 的状态呢?进程的状态主要由其寄存器捕获,因此 ptrace
当然有一个请求来获取(或修改!)寄存器
PTRACE_GETREGS
:此请求将给出 tracee 停止时寄存器的状态。PTRACE_SETREGS
:如果追踪器具有先前调用PTRACE_GETREGS
的寄存器值,它可以修改该结构中的值,并通过此请求将寄存器设置为新值。PTRACE_PEEKUSER
和PTRACE_POKEUSER
:这些允许从 tracee 的USER
区域读取,该区域保存寄存器和其他有用信息。这可以用于修改单个寄存器,而无需更重量级的PTRACE_{GET,SET}REGS
。
在调试器中,修改寄存器并不总是足够的。调试器有时需要读取内存的某些部分,甚至修改它。GNU 项目调试器 (GDB) 可以使用 print
来获取内存位置或变量的值。ptrace
具有实现此功能的功能
PTRACE_PEEKTEXT
和PTRACE_POKETEXT
:这些允许在 tracee 的地址空间中读取和写入一个字。当然,tracee 必须停止才能工作。
实际的调试器还具有断点和监视点等功能。在下一节中,我将深入探讨调试支持的架构细节。为了清晰和简洁起见,本文将仅考虑 x86。
架构支持
ptrace
非常酷,但它是如何工作的呢?在上一节中,我们已经看到 ptrace
与信号有很多关系:SIGTRAP
可以在单步执行、execve
之前以及系统调用之前或之后传递。信号可以通过多种方式生成,但我们将查看两个特定示例,调试器可以使用它们在给定位置停止程序(有效地创建断点!)
-
未定义的指令: 当进程尝试执行未定义的指令时,CPU 会引发异常。此异常通过 CPU 中断处理,并调用内核中与中断对应的处理程序。这将导致向进程发送
SIGILL
。反过来,这会导致进程停止,并且追踪器通过等待事件收到通知。然后它可以决定做什么。在 x86 上,指令ud2
保证始终未定义。 -
调试中断: 前一种方法的问题是
ud2
指令占用两个字节的机器代码。存在一条特殊的指令,它占用一个字节并引发中断。它是int $3
,机器代码是0xCC
。当引发此中断时,内核会向进程发送SIGTRAP
,就像之前一样,追踪器会收到通知。
这很好,但是我们如何强制 tracee 执行这些指令呢?很简单:ptrace
具有 PTRACE_POKETEXT
,它可以覆盖内存位置的字。调试器将使用 PTRACE_PEEKTEXT
读取该位置的原始字,并将其替换为 0xCC
,记住原始字节以及它是一个断点的事实,并记录在其内部状态中。下次 tracee 在该位置执行时,它会自动因 SIGTRAP
而停止。然后,调试器的最终用户可以决定如何继续(例如,检查寄存器)。
好的,我们已经介绍了断点,但是监视点呢?当读取或写入某个内存位置时,调试器如何停止程序?当然,您不会只用可能读取或写入某些内存位置的 int $3
覆盖每个指令。认识一下调试寄存器,一组旨在更有效地实现此目标的寄存器
DR0
到DR3
:这些寄存器中的每一个都包含一个地址(内存位置),调试器希望 tracee 因某种原因在此处停止。原因在DR7
中指定为位掩码。DR4
和DR5
:这些分别是DR6
和DR7
的过时别名。DR6
:调试状态。包含有关哪个DR0
到DR3
导致引发调试异常的信息。Linux 使用它来确定随SIGTRAP
传递给 tracee 的信息。DR7
:调试控制。使用这些寄存器中的位,调试器可以控制如何解释DR0
中指定的地址 到DR3
。位掩码控制监视点的大小(是监视 1、2、4 还是 8 个字节),以及是在执行、读取、写入还是读取和写入中的任何一种情况下引发异常。
由于调试寄存器构成进程 USER
区域的一部分,因此调试器可以使用 PTRACE_POKEUSER
将值写入调试寄存器。调试寄存器仅与特定进程相关,因此在进程重新获得 CPU 控制权之前,它们会恢复为抢占时的值。
冰山一角
我们瞥见了调试器的冰山一角:我们介绍了 ptrace
,回顾了它的一些功能,然后我们了解了 ptrace
是如何实现的。ptrace
的某些部分可以在软件中实现,但其他部分必须在硬件中实现,否则它们将非常昂贵甚至不可能。
当然,还有很多我们没有介绍到的内容。诸如“调试器如何知道变量在内存中的位置?”之类的问题由于篇幅和时间限制仍然悬而未决,但我希望您从本文中学到了一些东西;如果它引起了您的兴趣,那么网上有很多资源可以了解更多信息。
要了解更多信息,请参加 Levente Kurusa 的演讲 让我们编写一个调试器!,在 linux.conf.au 举行,时间为 1 月 22 日至 26 日在悉尼。
评论已关闭。