我一直想知道调试器是如何工作的?特别是可以“附加”到已经运行的可执行文件的那个。我知道编译器将代码转换为机器语言,但是调试器如何“知道”它所附加的内容?
7 回答
调试器如何工作的细节将取决于您正在调试的内容以及操作系统是什么。对于 Windows 上的本机调试,您可以在 MSDN 上找到一些详细信息:Win32 调试 API。
用户通过名称或进程 ID 告诉调试器要附加到哪个进程。如果是名称,则调试器将查找进程 ID,并通过系统调用启动调试会话;在 Windows 下,这将是DebugActiveProcess。
连接后,调试器将像任何 UI 一样进入事件循环,但不是来自窗口系统的事件,而是操作系统将根据正在调试的进程中发生的情况生成事件——例如发生的异常。请参阅WaitForDebugEvent。
调试器能够读取和写入目标进程的虚拟内存,甚至可以通过操作系统提供的 API 调整其寄存器值。请参阅Windows的调试功能列表。
调试器能够使用符号文件中的信息将地址转换为源代码中的变量名称和位置。符号文件信息是一组单独的 API,并不是操作系统的核心部分。在 Windows 上,这是通过Debug Interface Access SDK实现的。
如果您正在调试托管环境(.NET、Java 等),该过程通常看起来相似,但细节不同,因为虚拟机环境提供调试 API 而不是底层操作系统。
据我了解:
对于 x86 上的软件断点,调试器将指令的第一个字节替换为CC
( int3
)。这是WriteProcessMemory
在 Windows 上完成的。当 CPU 到达该指令并执行 时int3
,这会导致 CPU 生成调试异常。操作系统接收到这个中断,意识到进程正在被调试,并通知调试器进程该断点被命中。
遇到断点并停止进程后,调试器会在其断点列表中查找,并将 替换为CC
最初存在的字节。调试器设置TF
, 陷阱标志(EFLAGS
通过修改CONTEXT
),并继续该过程。INT 1
陷阱标志使 CPU在下一条指令上自动生成单步异常 ( )。
当被调试的进程下一次停止时,调试器再次将断点指令的第一个字节替换为CC
,然后进程继续。
我不确定这是否正是所有调试器实现的方式,但我编写了一个 Win32 程序,它可以使用这种机制进行自我调试。完全没用,但有教育意义。
如果您使用的是 Windows 操作系统,一个很好的资源将是 John Robbins 的“Debugging Applications for Microsoft .NET and Microsoft Windows”:
(甚至是旧版本:“调试应用程序”)
这本书有一章是关于调试器如何工作的,其中包括几个简单(但工作)调试器的代码。
由于我不熟悉 Unix/Linux 调试的细节,这些东西可能根本不适用于其他操作系统。但我猜想,作为对一个非常复杂的主题的介绍,这些概念——如果不是细节和 API——应该“移植”到大多数操作系统。
我认为这里有两个主要问题需要回答:
1. 调试器如何知道发生了异常?
当正在调试的进程中发生异常时,调试器会在目标进程中定义的任何用户异常处理程序有机会响应异常之前得到操作系统的通知。如果调试器选择不处理此(第一次机会)异常通知,则异常分派序列将继续进行,然后目标线程将有机会处理该异常(如果它愿意)。如果目标进程没有处理 SEH 异常,则向调试器发送另一个调试事件,称为第二次机会通知,以通知它在目标进程中发生了未处理的异常。来源
2. 调试器如何知道如何在断点处停止?
简化的答案是:当您在程序中设置断点时,调试器会用 int3 指令替换您的代码,该指令是软件中断。作为结果,程序被挂起并调用调试器。
另一个了解调试的宝贵资源是 Intel CPU 手册(Intel® 64 and IA-32 Architectures Software Developer's Manual)。在第 3A 卷第 16 章中,介绍了调试的硬件支持,例如特殊异常和硬件调试寄存器。以下内容来自该章节:
T (trap) flag, TSS — 当尝试切换到 TSS 中设置了 T 标志的任务时,会生成调试异常 (#DB)。
我不确定 Window 或 Linux 是否使用这个标志,但阅读那一章非常有趣。
希望这可以帮助某人。
我的理解是,当你编译一个应用程序或 DLL 文件时,无论它编译成什么都包含代表函数和变量的符号。
当您进行调试构建时,这些符号比发布构建时要详细得多,从而允许调试器为您提供更多信息。当您将调试器附加到进程时,它会查看当前正在访问哪些函数并从这里解析所有可用的调试符号(因为它知道编译文件的内部结构是什么样的,它可以确定内存中可能存在什么,具有整数、浮点数、字符串等的内容)。就像第一张海报所说的那样,这些信息以及这些符号的工作方式很大程度上取决于环境和语言。