调试器到底怎样工作
你也许用过调速器检查过你的代码,但你知道它们是如何做到的吗?
调试器是大多数(即使不是每个)开发人员在软件工程职业生涯中至少使用过一次的那些软件之一,但是你们中有多少人知道它们到底是如何工作的?我在悉尼 linux.conf.au 2018 的演讲中,将讨论从头开始编写调试器……使用 Rust!
在本文中,术语 调试器 和 跟踪器 可以互换。 “ 被跟踪者 ”是指正在被跟踪器跟踪的进程。
ptrace 系统调用
大多数调试器严重依赖称为 ptrace(2)
的系统调用,其原型如下:
1 |
|
这是一个可以操纵进程几乎所有方面的系统调用;但是,在调试器可以连接到一个进程之前,“被跟踪者”必须以请求 PTRACE_TRACEME
调用 ptrace
。这告诉 Linux,父进程通过 ptrace
连接到这个进程是合法的。但是……我们如何强制一个进程调用 ptrace
?很简单!fork/execve
提供了在 fork
之后但在被跟踪者真正开始使用 execve
之前调用 ptrace
的简单方法。很方便地,fork
还会返回被跟踪者的 pid
,这是后面使用 ptrace
所必需的。
现在被跟踪者可以被调试器追踪,重要的变化发生了:
- 每当一个信号被传送到被跟踪者时,它就会停止,并且一个可以被
wait
系列的系统调用捕获的等待事件被传送给跟踪器。 - 每个
execve
系统调用都会导致SIGTRAP
被传递给被跟踪者。(与之前的项目相结合,这意味着被跟踪者在一个execve
完全发生之前停止。)
这意味着,一旦我们发出 PTRACE_TRACEME
请求并调用 execve
系统调用来实际在被跟踪者(进程上下文)中启动程序时,被跟踪者将立即停止,因为 execve
会传递一个 SIGTRAP
,并且会被跟踪器中的等待事件捕获。我们如何继续?正如人们所期望的那样,ptrace
有大量的请求可以用来告诉被跟踪者可以继续:
PTRACE_CONT
:这是最简单的。 被跟踪者运行,直到它接收到一个信号,此时等待事件被传递给跟踪器。这是最常见的实现真实世界调试器的“继续直至断点”和“永远继续”选项的方式。断点将在下面介绍。PTRACE_SYSCALL
:与PTRACE_CONT
非常相似,但在进入系统调用之前以及在系统调用返回到用户空间之前停止。它可以与其他请求(我们将在本文后面介绍)结合使用来监视和修改系统调用的参数或返回值。系统调用追踪程序strace
很大程度上使用这个请求来获知进程发起了哪些系统调用。PTRACE_SINGLESTEP
:这个很好理解。如果您之前使用过调试器(你会知道),此请求会执行下一条指令,然后立即停止。
我们可以通过各种各样的请求停止进程,但我们如何获得被调试者的状态?进程的状态大多是通过其寄存器捕获的,所以当然 ptrace
有一个请求来获得(或修改)寄存器:
PTRACE_GETREGS
:这个请求将给出被跟踪者刚刚被停止时的寄存器的状态。PTRACE_SETREGS
:如果跟踪器之前通过调用PTRACE_GETREGS
得到了寄存器的值,它可以在参数结构中修改相应寄存器的值,并使用PTRACE_SETREGS
将寄存器设为新值。PTRACE_PEEKUSER
和PTRACE_POKEUSER
:这些允许从被跟踪者的USER
区读取信息,这里保存了寄存器和其他有用的信息。 这可以用来修改单一寄存器,而避免使用更重的PTRACE_{GET,SET}REGS
请求。
在调试器仅仅修改寄存器是不够的。调试器有时需要读取一部分内存,甚至对其进行修改。GDB 可以使用 print
得到一个内存位置或变量的值。ptrace
通过下面的方法实现这个功能:
PTRACE_PEEKTEXT
和PTRACE_POKETEXT
:这些允许读取和写入被跟踪者地址空间中的一个字。当然,使用这个功能时被跟踪者要被暂停。
真实世界的调试器也有类似断点和观察点的功能。 在接下来的部分中,我将深入体系结构对调试器支持的细节。为了清晰和简洁,本文将只考虑 x86。
体系结构的支持
ptrace
很酷,但它是如何工作? 在前面的部分中,我们已经看到 ptrace
跟信号有很大关系:SIGTRAP
可以在单步跟踪、execve
之前以及系统调用前后被传送。信号可以通过一些方式产生,但我们将研究两个具体的例子,以展示信号可以被调试器用来在给定的位置停止程序(有效地创建一个断点!):
- 未定义的指令:当一个进程尝试执行一个未定义的指令,CPU 将产生一个异常。此异常通过 CPU 中断处理,内核中相应的中断处理程序被调用。这将导致一个
SIGILL
信号被发送给进程。 这依次导致进程被停止,跟踪器通过一个等待事件被通知,然后它可以决定后面做什么。在 x86 上,指令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
的某些部分可以用软件实现,但其它部分必须用硬件来实现,否则实现代价会非常高甚至无法实现。
当然有很多我们没有涉及。例如“调试器如何知道变量在内存中的位置?”等问题由于空间和时间限制而尚未解答,但我希望你从本文中学到了一些东西;如果它激起你的兴趣,网上有足够的资源可以了解更多。
想要了解更多,请查看 linux.conf.au 中 Levente Kurusa 的演讲 Let’s Write a Debugger!,于一月 22-26 日在悉尼举办。
via: https://opensource.com/article/18/1/how-debuggers-really-work
作者:Levente Kurusa 译者:stephenxs 校对:wxy