调试器是如何工作的

您可能使用过调试器来检查您的代码,但您知道它们是如何工作的吗?
577 位读者喜欢这个。
annoying bugs

Opensource.com

调试器是大多数(如果不是每个)开发人员在其软件工程生涯中至少使用过一次的软件,但是你们有多少人知道它们实际上是如何工作的?在我在悉尼举行的 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_PEEKUSERPTRACE_POKEUSER:这些允许从 tracee 的 USER 区域读取,该区域保存寄存器和其他有用信息。这可以用于修改单个寄存器,而无需更重量级的 PTRACE_{GET,SET}REGS

在调试器中,修改寄存器并不总是足够的。调试器有时需要读取内存的某些部分,甚至修改它。GNU 项目调试器 (GDB) 可以使用 print 来获取内存位置或变量的值。ptrace 具有实现此功能的功能

  • PTRACE_PEEKTEXTPTRACE_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 覆盖每个指令。认识一下调试寄存器,一组旨在更有效地实现此目标的寄存器

  • DR0DR3:这些寄存器中的每一个都包含一个地址(内存位置),调试器希望 tracee 因某种原因在此处停止。原因在 DR7 中指定为位掩码。
  • DR4DR5:这些分别是 DR6DR7 的过时别名。
  • DR6:调试状态。包含有关哪个 DR0DR3 导致引发调试异常的信息。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 日在悉尼。

User profile image.
Levente 是一名系统软件工程师,目前在伦敦帝国学院攻读学士学位。此前,他曾在芝加哥从事性能和任务关键型软件工作,曾在 Apple 和 Red Hat 实习,从事操作系统工作。

评论已关闭。

 

每周在您的收件箱中获取精彩内容。

© . All rights reserved.