GNU调试器GDB是最早为自由软件基金会编写的程序之一,从那以后它一直是免费和开源软件系统的主要部分。它最初设计为普通的Unix源代码级调试器,后来扩展到广泛的用途,包括与许多嵌入式系统一起使用,并且从几千行C增加到超过五十万。
本章将深入研究GDB的整体内部结构,展示随着新用户需求和新功能的不断涌现,它如何逐步发展。
4.1 目标
GDB旨在成为用C,C ++,Ada和Fortran等编译命令式语言编写的程序的符号调试器。使用其原始命令行界面,典型用法如下所示:
% gdb myprog
[...]
(gdb) break buggy_function
Breakpoint 1 at 0x12345678: file myprog.c, line 232.
(gdb) run 45 92
Starting program: myprog
Breakpoint 1, buggy_function (arg1=45, arg2=92) at myprog.c:232
232 result = positive_variable * arg1 + arg2;
(gdb) print positive_variable
$$1 = -34
(gdb)
GDB显示了一些不正确的东西,开发人员说“aha”或“嗯”,然后必须决定错误是什么以及如何解决它。
设计的重点在于像GDB这样的工具基本上是一个用于在程序中进行探索的交互式工具箱,因此它需要响应不可预测的一系列请求。此外,它将与已由编译器优化的程序以及利用每个硬件选项以获得性能的程序一起使用,因此需要具有详细知识,直至系统的最低级别。
GDB还需要能够调试由不同编译器(不仅仅是GNU C编译器)编译的程序,调试多年前由长期过时版本的编译器编译的程序,以及调试其符号信息缺失,过时的程序,或者只是不正确;因此,另一个设计考虑因素是GDB应该继续工作并且即使有关该程序的数据丢失,损坏或者只是难以理解,也应该有用。
以下部分假设您熟悉从命令行使用GDB。如果您是GDB新手,请试一试并仔细阅读本手册。[SPS + 00]
4.2 GDB的起源
GDB是一个老程序。它于1985年左右出现,由Richard Stallman和GCC,GNU Emacs以及GNU的其他早期组件编写。 (在那些日子里,没有公共源代码控制存储库,现在大部分详细的开发历史都已丢失。)
最早的现成版本是从1988年开始的,与现今资料的比较表明,只有少数几行具有相似之处;几乎所有的GDB都被重写了至少一次。关于早期版本的GDB的另一个引人注目的事情是,最初的目标是相当适度的,从那以后的大部分工作都是将GDB扩展到不属于原始计划的环境和用法中。
4.3 框图
图4.1:GDB的总体结构
从最大规模来看,GDB可以说有两个方面:
- “符号方面”涉及有关该程序的符号信息。符号信息包括函数和变量名称和类型,行号,机器寄存器使用等。符号端从程序的可执行文件中提取符号信息,解析表达式,查找给定行号的内存地址,列出源代码,并且通常在程序员编写时使用该程序。
- “目标方”涉及目标系统的操纵。它具有启动和停止程序,读取存储器和寄存器,修改它们,捕获信号等功能。如何做到这一点的具体细节可能在系统之间有很大差异;大多数Unix类型的系统提供一个名为ptrace的特殊系统调用,它使一个进程能够读写不同进程的状态。因此,GDB的目标方面主要是关于进行ptrace调用和解释结果。但是,对于嵌入式系统的交叉调试,目标端构造消息包以通过线路发送,并等待响应数据包作为回报。
双方在某种程度上相互独立;您可以查看程序代码,显示变量类型等,而无需实际运行程序。相反,即使没有可用的符号,也可以进行纯机器语言调试。
在中间,将两侧绑在一起,是命令解释器和主执行控制循环。
4.4 操作示例
为了简单说明它们如何结合在一起,请考虑上面的print命令。命令解释器找到print命令函数,它将表达式解析为一个简单的树结构,然后通过遍历树来计算它。在某些时候,求值程序将查询符号表以找出positive_variable是一个整数全局变量,存储在例如内存地址0x601028中。然后它调用目标端函数来读取该地址处的四个字节的内存,并将字节交给格式化函数,该函数将它们显示为十进制数。
为了显示源代码及其编译版本,GDB组合了源文件和目标系统的读取,然后使用编译器生成的行号信息来连接两者。在此处的示例中,行232具有地址0x4004be,行233具有0x4004ce,依此类推。
[...]
232 result = positive_variable * arg1 + arg2;
0x4004be <+10>: mov 0x200b64(%rip),%eax # 0x601028 <positive_variable>
0x4004c4 <+16>: imul -0x14(%rbp),%eax
0x4004c8 <+20>: add -0x18(%rbp),%eax
0x4004cb <+23>: mov %eax,-0x4(%rbp)
233 return result;
0x4004ce <+26>: mov -0x4(%rbp),%eax
[...]
step命令步骤隐藏了幕后复杂的逻辑。当用户要求步进程序中的下一行时,要求目标端只执行程序的单个指令,然后再次停止(这是ptrace可以做的事情之一)。在被通知程序已经停止时,GDB请求程序计数器(PC)寄存器(另一个目标端操作),然后将其与符号侧所说的与当前行相关联的地址范围进行比较。如果PC超出该范围,则GDB将程序停止,找出新的源代码行,并将其报告给用户。如果PC仍然在当前行的范围内,那么GDB将按照另一条指令再次检查并重复检查,直到PC到达另一条行。这种基本算法的优点是它始终做正确的事情,无论线路是否有跳转,子程序调用等,并且不需要GDB来解释机器指令集的所有细节。缺点是对于每个单步骤存在与目标的许多交互,对于一些嵌入的目标,其导致明显缓慢的步进。
4.5 可移植性
作为一个需要广泛访问的程序,一直到芯片上的物理寄存器,GDB从一开始就被设计为可以在各种系统中移植。然而,多年来,其可移植性策略发生了很大变化。
最初,GDB的开端类似于当时的其他GNU程序;在C的最小公共子集中编码,并使用预处理器宏和Makefile片段的组合来适应特定的体系结构和操作系统。虽然GNU项目的既定目标是一个独立的“GNU操作系统”,但是必须在各种现有系统上进行自举; Linux内核在未来还需要几年时间。 configure shell脚本是该过程的第一个关键步骤。它可以执行各种操作,例如从特定于系统的文件创建符号链接到通用标头名称,或者从片段构造文件,更重要的是用于构建程序的Makefile。
像GCC和GDB这样的程序比像cat或diff这样的程序具有额外的可移植性需求,随着时间的推移,GDB的可移植性位被分成三个类,每个类都有自己的Makefile片段和头文件。
- “主机”定义适用于GDB本身运行的机器,可能包括主机整数类型的大小。最初是作为人工编写的头文件完成的,人们最终可以通过配置运行小测试程序来计算它们,使用将用于构建工具的相同编译器。这就是autoconf [aut12]的全部内容,而今天几乎所有的GNU工具和许多(如果不是大多数)Unix程序都使用autoconf生成的配置脚本。
- “目标”定义特定于运行正在调试的程序的机器。如果目标与主机相同,那么我们正在进行“本机”调试,否则它是“交叉”调试,使用连接两个系统的某种线路。目标定义又分为两大类:
- “架构”定义:这些定义定义了如何反汇编机器代码,如何遍历调用堆栈以及在断点处插入哪个陷阱指令。最初使用宏完成,它们被迁移到通过“gdbarch”对象访问的常规C,下面将更详细地描述。
- “Native”定义:这些定义了ptrace参数的细节(在Unix的各种风格之间有很大差异),如何查找已加载的共享库等等,这些仅适用于本机调试案例。本机定义是20世纪80年代风格宏的最后一个版本,尽管大多数现在使用autoconf计算出来。
4.6 数据结构
在深入研究GDB的各个部分之前,让我们来看看GDB使用的主要数据结构。由于GDB是一个C程序,它们被实现为结构而不是C ++风格的对象,但在大多数情况下它们被视为对象,在这里我们遵循GDBers经常练习它们的对象。
断点
断点是用户可直接访问的主要对象类型。用户使用break命令创建断点,该命令的参数指定位置,该位置可以是函数名称,源行号或机器地址。 GDB为断点对象分配一个小的正整数,用户随后使用该整数对断点进行操作。在GDB中,断点是一个包含许多字段的C结构。该位置被转换为机器地址,但也以其原始形式保存,因为地址可能会更改并需要重新计算,例如,如果程序被重新编译并重新加载到会话中。
几种类似断点的对象实际上共享断点结构,包括观察点,捕获点和跟踪点。这有助于确保始终可以使用创建,操作和删除工具。
术语“位置”还指要安装断点的存储器地址。在内联函数和C ++模板的情况下,可能是单个用户指定的断点可能对应于多个地址;例如,函数的每个内联副本都需要一个单独的位置,用于在函数体内的源代码行上设置的断点。
符号和符号表
符号表是GDB的关键数据结构,可能非常大,有时会增加占用多个GB的RAM。在某种程度上,这是不可避免的; C ++中的大型应用程序本身可以拥有数百万个符号,并且它可以提取系统头文件,这些文件可以包含数百万个符号。每个局部变量,每个命名类型,枚举的每个值 - 所有这些都是单独的符号。
GDB使用许多技巧来减少符号表空间,例如部分符号表(稍后更多关于这些),结构中的位字段等。
除了基本上将字符串映射到地址和类型信息的符号表之外,GDB还构建了支持在两个方向上查找的行表;从源代码行到地址,然后从地址返回到源代码行。 (例如,前面描述的单步算法至关重要地取决于地址到源的映射。)
堆栈帧
设计GDB的过程语言共享一个共同的运行时体系结构,因为函数调用会导致程序计数器被压入堆栈,以及函数参数和本地参数的某种组合。该组合称为堆栈帧,或简称为“帧”,并且在程序执行的任何时刻,堆栈由链接在一起的一系列帧组成。堆栈帧的细节从一个芯片架构到下一个芯片架构有很大不同,并且还取决于操作系统,编译器和优化选项。
GDB到新芯片的端口可能需要相当大量的代码来分析堆栈,因为程序(特别是有缺陷的程序,用户最感兴趣的程序)可以在任何地方停止,框架可能不完整,或部分覆盖通过该计划。更糟糕的是,为每个函数调用构造一个堆栈帧会降低应用程序的速度,一个好的优化编译器将利用每个机会简化堆栈帧,甚至完全消除它们,例如尾部调用。
GDB芯片特定堆栈分析的结果记录在一系列帧对象中。最初,GDB通过使用固定帧指针寄存器的字面值来跟踪帧。这种方法对内联函数调用和其他类型的编译器优化进行了细分,从2002年开始,GDBers引入了显式框架对象,记录了每个框架已经找到的内容,并且链接在一起,镜像了程序的堆栈框架。
表达式
与堆栈帧一样,GDB假定它支持的各种语言的表达式具有一定程度的通用性,并将它们全部表示为由节点对象构建的树结构。节点类型集实际上是所有不同语言中可能的所有表达类型的联合;与编译器不同,没有理由阻止用户尝试从C变量中减去Fortran变量 - 也许两者的差异显然是2的幂,这给了我们“aha”时刻。
值
评估的结果本身可能比整数或内存地址更复杂,并且GDB还将评估结果保留在编号的历史列表中,然后可以在后面的表达式中引用它。为了完成所有这些工作,GDB具有值的数据结构。值结构有许多记录各种属性的字段;重要的包括指示值是r值还是l值(l值可以分配给,如C中所示),以及该值是否是懒惰构造的。