调试器是如何工作的?

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

59805 次浏览

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

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

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

调试器能够读写目标进程的虚拟内存,甚至通过操作系统提供的api调整其寄存器值。参见Windows的调试功能列表。

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

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

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

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

如果你使用的是Windows操作系统,John Robbins写的《调试。net和Windows应用程序》是一个很好的参考资料:

(甚至是旧版本:“调试应用程序”)

这本书有一章是关于调试器如何工作的,其中包括几个简单(但可以工作的)调试器的代码。

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

在Linux中,调试进程从ptrace (2)系统调用开始。这篇文章有一个关于如何使用ptrace来实现一些简单的调试构造的很好的教程。

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

T (trap)标志,TSS -当尝试时生成一个调试异常(#DB) 在TSS中设置了T标志的任务。

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

希望这能帮助到一些人。

我的理解是:

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

在击中断点并停止进程后,调试器查看断点列表,并用原来的字节替换CC。调试器在EFLAGS中设置TF,陷阱标志(通过修改CONTEXT),并继续该过程。Trap标志导致CPU在下一条指令上自动生成一个单步异常(INT 1)。

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

我不确定这是否是所有调试器都实现的方式,但我已经编写了一个Win32程序,它使用这种机制来调试自己。完全没用,但有教育意义。

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

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

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

enter image description here


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

简化的回答是:当你在程序中放入一个断点时,调试器会用一个int3指令替换你的代码,这个int3指令是一个软件中断。结果是程序被挂起并调用调试器。