调试器如何工作?

Posted

技术标签:

【中文标题】调试器如何工作?【英文标题】:How does a debugger work? 【发布时间】:2010-09-18 00:36:25 【问题描述】:

我一直想知道调试器是如何工作的?特别是可以“附加”到已经运行的可执行文件的那个。我知道编译器会将代码翻译成机器语言,但是调试器如何“知道”它所附加的内容?

【问题讨论】:

Eli 的文章已移至eli.thegreenplace.net/2011/01/23/how-debuggers-work-part-1 @Oktalist 这篇文章很有趣,但只讨论了在 Linux 上调试的 API 级抽象。我猜 OP 想了解更多关于幕后的信息。 【参考方案1】:

调试器如何工作的细节取决于您要调试的内容以及操作系统是什么。对于 Windows 上的本机调试,您可以在 MSDN 上找到一些详细信息:Win32 Debugging API。

用户通过名称或进程 ID 告诉调试器要附加到哪个进程。如果是名称,则调试器将查找进程 ID,并通过系统调用启动调试会话;在 Windows 下,这将是 DebugActiveProcess。

一旦附加,调试器将进入一个事件循环,就像任何 UI 一样,但不是来自窗口系统的事件,操作系统将根据正在调试的进程中发生的情况生成事件 - 例如发生的异常。见WaitForDebugEvent。

调试器可以读写目标进程的虚拟内存,甚至可以通过操作系统提供的API来调整它的寄存器值。请参阅 Windows 的 debugging functions 列表。

调试器能够使用符号文件中的信息将地址转换为源代码中的变量名和位置。符号文件信息是一组单独的 API,并不是操作系统的核心部分。在 Windows 上,这是通过 Debug Interface Access SDK。

如果您正在调试托管环境(.NET、Java 等),该过程通常看起来相似,但细节有所不同,因为虚拟机环境提供调试 API 而不是底层操作系统。

【讨论】:

这个问题可能听起来很愚蠢,但是如果到达程序内的特定地址,操作系统如何跟踪。例如。在地址 0x7710cafe 上设置了一个断点。随着指令指针的变化,操作系统(或者可能是 CPU)必须将指令指针与所有断点地址进行比较,还是我弄错了?这是如何工作的..? @StefanFalk 我写了an answer 解决了一些较低级别的细节(在 x86 上)。 你能解释一下变量名是如何映射到地址的吗?应用程序每次运行时是否对变量使用相同的内存地址?我一直认为它只是从可用内存中找到映射,但从未真正考虑过字节是否会直接映射到应用程序内存空间中的同一位置。这似乎是一个重大的安全问题。 @JamesJoshuaStreet 我想这是调试器特有的细节。 这个答案揭示了一些东西。但我认为 op 对一些底层细节更感兴趣,而不是一些 API 抽象。【参考方案2】:

我的理解是,当你编译一个应用程序或 DLL 文件时,无论它编译成什么都包含代表函数和变量的符号。

当您进行调试构建时,这些符号比发布构建时要详细得多,因此调试器可以为您提供更多信息。当您将调试器附加到进程时,它会查看当前正在访问哪些函数并从这里解析所有可用的调试符号(因为它知道编译文件的内部结构是什么样的,它可以确定内存中可能存在什么,具有整数、浮点数、字符串等的内容)。正如第一张海报所说,这些信息以及这些符号的工作方式很大程度上取决于环境和语言。

【讨论】:

这只是关于符号。调试远不止符号。【参考方案3】:

如果您使用的是 Windows 操作系统,一个很好的资源是 John Robbins 的“调试 Microsoft .NET 和 Microsoft Windows 的应用程序”:

http://www.amazon.com/dp/0735615365

(甚至更早的版本:"Debugging Applications")

本书有一章介绍调试器的工作原理,其中包含几个简单(但有效)调试器的代码。

由于我不熟悉 Unix/Linux 调试的细节,这些东西可能根本不适用于其他操作系统。但我猜想,作为对一个非常复杂的主题的介绍,这些概念——如果不是细节和 API——应该“移植”到大多数操作系统。

【讨论】:

【参考方案4】:

在 Linux 中,调试进程从 ptrace(2) 系统调用开始。 This article 有一个很棒的教程,教你如何使用 ptrace 来实现一些简单的调试结构。

【讨论】:

(2) 是否告诉我们比“ptrace 是一个系统调用”更多(或更少)的信息? @eSKay,不,不是。 (2) 是手册章节号。有关手册部分的说明,请参阅 en.wikipedia.org/wiki/Man_page#Manual_sections。 @AdamRosenfield 除了第 2 节专门是“系统调用”这一事实。所以间接地,是的,它告诉我们ptrace 是一个系统调用。 更实际地,(2) 告诉我们,我们可以输入 man 2 ptrace 并获得正确的联机帮助页——这并不重要,因为没有其他 ptrace 可以消除歧义,但可以比较 man printf在 Linux 上使用 man 3 printf【参考方案5】:

了解调试的另一个有价值的来源是英特尔 CPU 手册(英特尔® 64 和 IA-32 架构 软件开发人员手册)。在第 3A 卷第 16 章中,介绍了调试的硬件支持,例如特殊异常和硬件调试寄存器。以下内容来自该章节:

T (trap) 标志,TSS — 尝试失败时生成调试异常 (#DB) 切换到在其 TSS 中设置了 T 标志的任务。

我不确定 Window 或 Linux 是否使用这个标志,但读那一章很有趣。

希望这对某人有所帮助。

【讨论】:

【参考方案6】:

据我了解:

对于 x86 上的软件断点,调试器将指令的第一个字节替换为 CC (int3)。这是在 Windows 上使用 WriteProcessMemory 完成的。当 CPU 到达该指令并执行 int3 时,这会导致 CPU 生成调试异常。操作系统接收到这个中断,意识到进程正在被调试,并通知调试器进程该断点被命中。

在断点被中断并且进程停止后,调试器会在它的断点列表中查找,并将CC 替换为最初存在的字节。调试器在EFLAGS 中设置TF, the Trap Flag(通过修改CONTEXT),并继续该过程。 Trap Flag 使 CPU 在下一条指令时自动生成单步异常 (INT 1)。

当被调试的进程下一次停止时,调试器再次将断点指令的第一个字节替换为CC,进程继续进行。

我不确定这是否正是所有调试器的实现方式,但我编写了一个 Win32 程序,它可以使用这种机制进行自我调试。完全没用,但很有教育意义。

【讨论】:

【参考方案7】:

我认为这里有两个主要问题需要回答:

1.调试器如何知道发生了异常?

当正在调试的进程中发生异常时,在目标进程中定义的任何用户异常处理程序有机会响应异常之前,操作系统会通知调试器。如果调试器选择不处理此(第一次机会)异常通知,则异常分派序列将继续进行,然后目标线程将有机会处理该异常(如果它愿意)。如果目标进程没有处理 SEH 异常,则向调试器发送另一个调试事件,称为第二次机会通知,以通知它在目标进程中发生了未处理的异常。 Source


2。调试器如何知道如何在断点处停止?

简化的answer 是:当你在程序中设置一个断点时,调试器会用一个int3 指令替换你的代码,即software interrupt。作为结果,程序被挂起并调用调试器。

【讨论】:

以上是关于调试器如何工作?的主要内容,如果未能解决你的问题,请参考以下文章

调试器如何工作?

如何在 nx 工作区的 VSCode 中运行 NextJS 客户端调试器

如何使 GDB 与外部程序一起工作

了解 Java 调试如何真正在幕后工作

VSCode -- 如何设置调试工作目录

远程调试如何工作?代码需要在本地机器上编译吗?