购买
下载掌阅APP,畅读海量书库
立即打开
畅读海量书库
扫码下载掌阅APP

1.3 调试器的内部结构

大多数程序员都是通过实践来学习如何使用调试器的,有些人会比其他人更熟悉调试器的各种命令的使用,然而只有少数人了解调试器的内部结构。在本节中,笔者将从用户的角度来讨论调试器的一些实现细节。这不仅是为了满足读者对调试器的好奇心,更重要的是,它有助于读者更深入地理解调试器以便充分利用这个工具。

实际上调试器只不过是另一个应用程序。有趣的是,我们可以用一个调试器进程来跟踪正在运行的另一个调试器,这实际上是理解调试器工作原理的有效方式。笔者曾经为了常规调试任务编译了GDB调试器的调试版本,每当笔者对调试器本身有疑问时,就会启动一个GDB程序,并将它附加到正在使用的GDB进程上。这样就能看到它所有的内部数据结构了。

源代码级别的调试器通常由3个模块组成:用户界面、符号管理和目标管理。

1.3.1 用户界面

用户界面是调试器的表现层,也就是前端。它与用户的交互方式和其他应用程序非常相似。调试器可能有图形用户界面(GUI)或命令行界面(CLI),或者两者都有。它的基本功能是将用户的输入转换为对后端调试引擎的API调用。几乎每个菜单项或按钮都直接映射到后端命令。事实上,许多具有GUI的调试器,如DDD(Data Display Debugger)、Windbg和sunstudio,都有一个命令窗口,可以让用户直接向底层调试器输入命令。

1.3.2 符号管理模块

符号管理模块负责提供调试目标的调试符号。这个模块的基本功能包括读取二进制文件并解析其中的调试符号、创建调试符号的内部表示、为打印变量提供类型信息等。调试符号的可用性和内容的完整性决定了调试器的功能和限制。如果调试符号错误或不完整,那么调试器将无法正常工作。例如,不匹配的文件(可执行文件或程序数据库文件)拥有错误的调试符号;去除了调试符号的可执行文件或没有pdb文件的DLL,又或者只有部分调试符号的文件,都只能提供有限的调试能力。

在前面的章节中,我们已经看到调试符号是如何在文件中组织和存储的。首先,调试器会按照给定的调试符号路径来搜索文件,然后检查文件的大小、时间戳、校验和等信息,以验证其与被调试进程加载的映像文件的一致性。如果没有匹配正确的调试符号,调试器将无法正常工作。例如,如果没有匹配的内核符号,Windows调试器Windbg会发出如下警告信息:

注意msvcr80.dll系统运行库的前两帧。Windbg在此时提示无法找到该DLL的调试符号。不仅如此,由于系统库默认开启了FPO编译器选项,这使得优化的代码需要FPO调试符号以成功回溯调用栈,否则可能会呈现出逻辑不通的调用栈。在本案例中,可以设置Windbg从微软的在线调试符号服务器下载这些符号。稍后,我们将进一步讨论Windows符号服务器。

如果调试符号匹配良好,调试器的符号管理将打开文件,并读取其中的调试部分或者单独的数据库中的调试符号,然后解析这些调试符号,创建内部表现。为了避免在启动时消耗大量时间和空间,调试器通常不会一次性读取所有调试符号。例如,行号表和基准栈信息表会在需要时生成。调试器开始时只会扫描文件,快速定位基本信息,如源文件和当前作用域的符号。当用户命令需要某些详细调试符号时(例如,打印变量),调试器会从对应文件按需读取详细的调试符号。值得注意的是,GDB的符号加载命令中的“-readnow”选项允许用户覆写这种分阶段的符号加载策略。

1.3.3 目标管理模块

目标管理模块在系统和硬件层面处理被调试的进程。例如,控制被调试进程的运行、读写其内存、检索线程调用栈等。因为这些底层操作依赖于特定平台,在Linux以及许多其他UNIX变种中,内核提供了一个系统调用ptrace,允许一个进程(调试器或其他工具,如系统调用追踪器strace)查询和控制另一个进程(被调试程序)的执行。Linux内核使用信号来同步调试器和被调试进程。ptrace提供了以下功能:

· 追踪进程或与其分离。被追踪的进程在被追踪时,会收到一个SIGTRAP或者SIGSTOP信号。

· 读写被调试进程内存地址空间中的内容,包括文本和数据段。

· 查询和修改被调试进程的用户区域信息,例如寄存器等。

· 查询和修改被调试进程的信号信息和设置。

· 设置事件触发器,例如在调用系统API(如fork、clone、exec等)或被调试进程退出时停止被调试进程。

· 控制被调试进程的运行,例如使其从停止状态恢复运行,或者在下一个系统调用时停止,或者执行到下一条指令。

· 向被调试进程发送各种信号,例如发送SIGKILL信号结束进程。

这些内核服务为实现各种调试器特性提供了基础,稍后将以断点为例进行讲解。ptrace的函数原型在头文件sys/ptrace.h中声明,其中包括4个参数:请求类型、被调试进程的ID、被调试进程中将被读写的内存地址以及将被读写的内存字节缓冲区。

在下面的例子中我们用strace命令打印出调试器GDB引发的所有ptrace调用(更多关于strace的功能将在第10章详细讨论)。这里的调试器进程正在进行一个简单的调试会话,但我们并不关心程序a.out做了什么,只关注调试器的操作。在这个例子中,GDB在测试程序的入口函数main处设置了一个断点,然后运行这个程序。当程序运行结束后,GDB也结束这个调试会话。系统调用跟踪程序打印了许多ptrace调用,笔者在这里仅列出一部分内容,目的是突出展示GDB的底层实现方式。

如上所示,GDB通过PTRACE_GETREGS和PTRACE_SETREGS请求获取和修改被调试进程的上下文,又通过PTRACE_PEEKTEXT和PTRACE_POKETEXT请求读取和写入被调试进程的内存,以及执行其他一系列操作。当有事件发生时,内核通过发送SIGCHLD信号来暂停调试器。

下面让我们通过上述例子深入了解断点是如何工作的。在GDB控制台中,我们能够看到一个断点被设置在了函数main的起始地址0x400590处,其执行过程如下:

(1)调试器读取地址0x400590处的代码,即{0xbf 0x04 0x00 0x00 0x00 0xe8 0x06 0xff}。需要注意的是,x86_64架构使用小端序(详情可参见4.1.2节)。

(2)GDB使用PTRACE_POKEDATA请求替换该地址的代码。原始数据0xff06e800000004bf被更改为0xff06e800000004cc,其中第1字节从0xbf更改为0xcc。这里的0xcc是一种特殊的陷阱指令。

(3)通过此操作,调试器在被调试进程的代码段中设定了断点,并使用PTRACE_CONT命令继续执行该进程。

(4)当程序执行到陷阱指令0xcc时,会触发断点并被内核终止。内核检查后发现进程正在被调试,于是会向调试器发送一个信号。

(5)GDB显示这一信息并等待用户输入。在本例中,我们选择继续执行程序。

(6)为确保程序的完整性,GDB会替换回原始指令0xbf,并使用PTRACE_SINGLESTEP请求执行单个指令。执行后,为使断点再次生效,调试器重新插入0xcc。

(7)如果用户设置的是一次性断点,上述操作则不会进行。处理完断点后,调试器使用PTRACE_CONT让程序继续执行。

调试器在一个持续的循环中工作,时刻等待被调试进程产生的事件或用户的手动干预。当被调试进程出现事件并进入暂停状态时,内核会向调试器发出信号。此时,调试器会对该事件进行检查,并基于事件类型采取相应的行动。 IjDqfGTqB4aapvlgYGkSCmBwi6F5NfBShUm8LNuZHyVqdaspLyq2JnFWZKR7iVEd

点击中间区域
呼出菜单
上一章
目录
下一章
×