调试器原理
调试器是大多数(如果不是每种)开发人员在软件工程生涯中至少使用一次的软件之一,但是你们当中有多少人知道它们的实际工作原理? 在悉尼举行的linux.conf.au 2018上的演讲中,我将谈论从头开始编写调试器...在Rust中 !
在本文中,术语调试器/跟踪器是可互换的。 “跟踪”是指跟踪程序正在跟踪的进程。
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
所必需的。
现在,调试器可以跟踪该跟踪,进行了重要的更改:
- 每次将信号传递到跟踪时,它都会停止,并且将等待事件传递到跟踪器,该事件可以由
wait
的系统调用系列捕获。 - 每个
execve
系统调用都将导致SIGTRAP
被传递给Tracee。 (与之前的项目相结合,这意味着之前的tracee停止execve
可以充分利用的地方。)
这意味着,一旦我们发出PTRACE_TRACEME
请求并调用execve
系统调用以在跟踪中实际启动程序,由于execve
传递了SIGTRAP
,并且跟踪器中的等待事件捕获了该跟踪,所以该跟踪将立即停止。 我们如何继续? 正如人们所期望的那样, ptrace
有许多请求可用于告诉该跟踪继续进行下去:
-
PTRACE_CONT
:这是最简单的。 跟踪将一直运行,直到接收到信号为止,这时将等待事件传递到跟踪器。 这最常用于实现实际调试器的“ continue-until-breakpoint”和“ continue-forever”选项。 断点将在下面介绍。 -
PTRACE_SYSCALL
:非常相似PTRACE_CONT
,而是进入了一个系统调用之前停止,之前也一个系统调用返回到用户空间。 它可以与其他请求(我们将在本文后面介绍)结合使用,以监视和修改系统调用的参数或返回值。strace
(系统调用跟踪程序)大量使用此请求来确定进程进行了哪些系统调用。 -
PTRACE_SINGLESTEP
:这是不言自明的。 如果您之前使用过调试器,则此请求将执行下一条指令,但之后立即停止。
我们可以通过各种请求来停止该过程,但是如何获取示踪的状态呢? 进程的状态主要由其寄存器捕获,因此ptrace
当然有一个获取(或修改!)寄存器的请求:
-
PTRACE_GETREGS
:该请求将给出停止跟踪时的寄存器状态。 -
PTRACE_SETREGS
:如果跟踪器具有上一次调用PTRACE_GETREGS
的寄存器值,则可以通过该请求修改该结构中的值,并将寄存器设置为新值。 -
PTRACE_PEEKUSER
和PTRACE_POKEUSER
:这些允许从跟踪的USER
区域读取,该区域保存寄存器和其他有用信息。 这可用于修改单个寄存器,而无需更重的PTRACE_{GET,SET}REGS
。
在调试器中修改寄存器并不总是足够的。 调试器有时需要读取存储器的某些部分,甚至对其进行修改。 GNU项目调试器(GDB)可以使用print
获取内存位置或变量的值。 ptrace
具有实现此功能的功能:
-
PTRACE_PEEKTEXT
和PTRACE_POKETEXT
:这些允许在跟踪的地址空间中读取和写入单词。 当然,必须停止示踪才能起作用。
现实世界中的调试器还具有断点和观察点之类的功能。 在下一节中,我将深入介绍调试支持的体系结构细节。 为了清楚和简洁起见,本文仅考虑x86。
建筑支持
ptrace
很酷,但是它如何工作? 在上一节中,我们已经看到了ptrace
有相当多的做信号: SIGTRAP
可以单步执行前交付, execve
和之前或之后的系统调用。 信号可以通过多种方式生成,但是我们将看看两个特定的示例,调试器可以使用这些示例在给定位置停止程序(有效地创建断点!):
未定义的指令:当进程尝试执行未定义的指令时,CPU会引发异常。 通过CPU中断处理此异常,然后调用与内核中的中断对应的处理程序。 这将导致
SIGILL
发送到该进程。 反过来,这导致进程停止,并通过等待事件通知跟踪程序。 然后,它可以决定要做什么。 在x86上,保证ud2
指令ud2
是未定义的。调试中断:前一种方法的问题是
ud2
指令占用了两个字节的机器代码。 存在一条占用一个字节并引发中断的特殊指令。 它是int $3
,机器代码是0xCC
。 引发此中断时,内核将SIGTRAP
发送到进程,并且像以前一样,通知跟踪器。
很好,但是我们如何强迫示踪执行这些指令? 容易: ptrace
具有PTRACE_POKETEXT
,可以覆盖存储位置的单词。 调试器将使用PTRACE_PEEKTEXT
读取该位置处的原始单词,并将其替换为0xCC
,从而记住原始字节以及该字节处于其内部状态的断点这一事实。 下次在该位置执行跟踪时,将借助SIGTRAP
自动将其停止。 然后,调试器的最终用户可以决定如何继续(例如,检查寄存器)。
好的,我们已经介绍了断点,但是观察点呢? 当读取或写入某个内存位置时,调试器如何停止程序? 当然,您不会仅仅用int $3
覆盖可以读取或写入某些内存位置的每条指令。 满足调试寄存器,一组旨在更有效地实现此目标的寄存器:
-
DR0
至DR3
:每个寄存器都包含一个地址(存储位置),调试器出于某种原因希望该跟踪停止。 原因在DR7
指定为位掩码。 -
DR4
和DR5
:分别是DR6
和DR7
别名。 -
DR6
:调试状态。 包含有关哪个DR0
到DR3
引发调试异常的信息。 Linux使用它来确定与SIGTRAP
一起传递给跟踪的信息。 -
DR7
:调试控件。 使用这些寄存器中的位,调试器可以控制如何解释DR0
至DR3
中指定的地址。 位掩码控制观察点的大小(监视的是1、2、4还是8个字节),以及是否在执行,读取,写入或读取和写入时引发异常。
因为调试寄存器构成了进程USER
区域的一部分,所以调试器可以使用PTRACE_POKEUSER
将值写入调试寄存器。 调试寄存器仅与特定进程相关,因此在进程重新获得对CPU的控制之前,它们会抢占先恢复为该值。
冰山一角
我们看了一眼调试器的冰山一角:我们介绍了ptrace
,介绍了它的一些功能,然后看了如何实现ptrace
。 ptrace
某些部分可以用软件实现,而其他部分则必须用硬件实现,否则它们将非常昂贵,甚至不可能。
当然,我们没有涉及很多。 出现诸如“调试器如何知道变量在内存中的位置?”之类的问题。 由于时间和空间的限制,请保持开放状态,但我希望您从本文中学到了一些知识; 如果它激起了您的兴趣,可以在网上找到大量资源以了解更多信息。
有关更多信息,请参加Levente Kurusa的演讲, 让我们编写调试器! , 网址为linux.conf.au ,将于1月22日至26日在悉尼举行。
翻译自: https://opensource.com/article/18/1/how-debuggers-really-work
调试器原理