GDB,GNU 调试器,是为自由软件基金会编写的最早的程序之一,从那时起它就一直是自由和开源软件系统的重要组成部分。最初它被设计为一个简单的 Unix 源代码级调试器,但后来扩展到广泛的用途,包括与许多嵌入式系统的使用,并且已经从几千行 C 代码增长到超过 50 万行。
本章将深入探讨 GDB 的整体内部结构,展示它如何随着时间的推移随着新的用户需求和新功能的出现而逐渐发展。
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 显示了一些错误,开发人员会说“啊哈”或“嗯”,然后必须决定错误是什么以及如何修复它。
对于设计而言,重要的是像 GDB 这样的工具本质上是一个用于在程序中探查的交互式工具箱,因此它需要对不可预测的一系列请求做出响应。此外,它将与编译器优化过的程序以及利用每个硬件选项来提高性能的程序一起使用,因此它需要了解系统的最低级别。
GDB 还需要能够调试由不同编译器编译的程序(不仅仅是 GNU C 编译器),调试由早已过时的编译器版本编译的程序,以及调试其符号信息丢失、过时或根本不正确的程序;因此,另一个设计考虑因素是,即使程序数据丢失、损坏或根本无法理解,GDB 也应该继续工作并有用。
以下部分假设您对从命令行使用 GDB 有所了解。如果您不熟悉 GDB,请尝试一下并阅读手册。[SPS+00]
GDB 是一个古老的程序。它诞生于 1985 年左右,由理查德·斯托曼与 GCC、GNU Emacs 和 GNU 的其他早期组件一起编写。(在那些日子里,没有公共源代码控制库,大部分详细的开发历史现在都丢失了。)
最早可获得的版本来自 1988 年,与当今的源代码相比,只有几行代码非常相似;GDB 的几乎所有部分都至少重写过一次。关于早期版本的 GDB 的另一个引人注目的事情是,最初的目标相当适度,此后的许多工作都是将 GDB 扩展到最初计划中没有的环境和用途。
在最大范围内,可以认为 GDB 有两面
ptrace
的特殊系统调用,它使一个进程能够读取和写入另一个进程的状态。因此,GDB 的目标面主要与进行 ptrace
调用并解释结果有关。但是,对于交叉调试嵌入式系统,目标面会构建要通过电线发送的消息包,并等待响应包的返回。这两面在某种程度上彼此独立;您可以查看程序代码、显示变量类型等,而无需实际运行程序。反之,即使没有符号可用,也可以进行纯粹的机器语言调试。
在中间,将这两面联系在一起的是命令解释器和主要的执行控制循环。
为了简单起见,以如何将所有这些联系在一起为例,考虑上面的 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 解释机器指令集的所有细节。缺点是,每次单步执行都需要与目标进行多次交互,对于某些嵌入式目标而言,这会导致明显的单步执行速度慢。
作为一个需要广泛访问一直到芯片上物理寄存器的程序,GDB 从一开始就被设计为可移植到各种系统。但是,它的可移植性策略多年来发生了很大变化。
最初,GDB 的起点与当时的其他 GNU 程序类似;用 C 的最小公用子集编写,并使用预处理器宏和 Makefile 片段的组合来适应特定的体系结构和操作系统。虽然 GNU 项目的既定目标是自包含的“GNU 操作系统”,但引导必须在各种现有系统上完成;Linux 内核的出现还有几年时间。configure
shell 脚本是该过程的第一关键步骤。它可以执行各种操作,例如将系统特定文件到通用头文件名的符号链接,或从各个部分构建文件,更重要的是构建程序所使用的 Makefile。
像 GCC 和 GDB 这样的程序比 cat
或 diff
有更多的可移植性需求,随着时间的推移,GDB 的可移植性位被分为三个类别,每个类别都有自己的 Makefile 片段和头文件。
configure
运行一些小程序,使用将用于构建工具的相同编译器来计算这些定义。这就是 autoconf
[aut12] 的全部意义,如今几乎所有 GNU 工具以及许多(如果不是大多数)Unix 程序都使用 autoconf
生成的 configure 脚本。ptrace
参数的细节(在不同的 Unix 版本之间差异很大)、如何查找已加载的共享库等等,这些仅适用于本地调试情况。本地定义是 1980 年代风格宏的最后堡垒,尽管大多数现在都是使用 autoconf
确定的。在深入探讨 GDB 的各个部分之前,让我们先看一下 GDB 使用的主要数据结构。由于 GDB 是一个 C 程序,因此它们是用 struct
实现的,而不是用 C++ 风格的对象实现的,但在大多数情况下,它们被视为对象,这里我们遵循 GDB 用户的常见做法,称它们为对象。
断点是用户可以直接访问的主要对象类型。用户使用 break
命令创建断点,该命令的参数指定一个 *位置*,可以是函数名、源代码行号或机器地址。GDB 为断点对象分配一个小的正整数,用户随后使用它来操作断点。在 GDB 中,断点是一个 C struct
,包含许多字段。该位置被转换为机器地址,但也以其原始形式保存,因为该地址可能会更改并需要重新计算,例如,如果程序被重新编译并重新加载到会话中。
实际上,几种类型的类似断点对象共享断点 struct
,包括观察点、捕获点和跟踪点。这有助于确保创建、操作和删除功能始终可用。
术语“位置”也指要安装断点的内存地址。在内联函数和 C++ 模板的情况下,一个用户指定的断点可能对应于多个地址;例如,函数的每个内联副本都包含一个单独的位置,用于设置在函数主体中的源代码行上的断点。
符号表是 GDB 的关键数据结构,它可能相当大,有时会增长到占用多个 GB 的 RAM。在某种程度上,这是不可避免的;大型 C++ 应用程序本身可能包含数百万个符号,并且它会引入包含数百万个符号的系统头文件。每个局部变量、每个命名类型、每个枚举值——所有这些都是独立的符号。
GDB 使用一些技巧来减少符号表空间,例如部分符号表(稍后将详细介绍)、struct
中的位域等。
除了基本上将字符串映射到地址和类型信息的符号表之外,GDB 还构建了支持双向查找的行表;从源代码行到地址,然后从地址返回到源代码行。(例如,前面描述的单步算法的关键依赖于地址到源代码的映射。)
为 GDB 设计的程序语言共享一个通用的运行时架构,因为函数调用会导致程序计数器被压入堆栈,以及一些函数参数和局部参数的组合。这个组合被称为堆栈帧,简称“帧”,在程序执行的任何时刻,堆栈都由一系列链接在一起的帧组成。堆栈帧的细节从一种芯片架构到另一种芯片架构差异很大,并且还取决于操作系统、编译器和优化选项。
将 GDB 移植到新的芯片可能需要大量的代码来分析堆栈,因为程序(尤其是调试器用户最感兴趣的有错误的程序)可以在任何地方停止,帧可能不完整或部分被程序覆盖。更糟糕的是,为每个函数调用构建堆栈帧会减慢应用程序的速度,并且优秀的优化编译器会抓住一切机会简化堆栈帧,甚至完全消除它们,例如尾递归调用。
GDB 对特定于芯片的堆栈分析的结果记录在一系列帧对象中。最初,GDB 通过使用固定帧指针寄存器的字面值来跟踪帧。这种方法在内联函数调用和其他类型的编译器优化时失效,并且从 2002 年开始,GDB 开发人员引入了显式的帧对象,记录了每个帧中已确定的事项,并且彼此链接,反映了程序的堆栈帧。
与堆栈帧一样,GDB 假设它支持的各种语言的表达式之间存在一定程度的共性,并将它们都表示为由节点对象构建的树结构。节点类型集实际上是所有不同语言中所有可能表达式的类型的并集;与编译器不同,没有理由阻止用户尝试从 C 变量中减去 Fortran 变量——也许这两个变量的差是一个明显的 2 的幂,这给了我们“aha”时刻。
评估的结果本身可能比整数或内存地址更复杂,并且 GDB 也在编号的历史记录列表中保留评估结果,然后可以在以后的表达式中引用这些结果。为了让所有这些都能工作,GDB 有一个用于值的 数据结构。Value struct
有许多字段记录各种属性;重要的字段包括一个指示值是右值还是左值(左值可以被赋值,如 C 中)以及值是否要延迟构造。
GDB 的符号方面主要负责读取可执行文件,提取找到的任何符号信息,并将其构建成符号表。
读取过程从 BFD 库开始。BFD 是一种处理二进制文件和目标文件的通用库;它可以在任何主机上运行,可以读取和写入原始 Unix a.out
格式、COFF(在 System V Unix 和 MS Windows 上使用)、ELF(现代 Unix、GNU/Linux 和大多数嵌入式系统)以及其他一些文件格式。在内部,该库具有复杂的 C 宏结构,这些宏扩展到包含数十个不同系统的目标文件格式的复杂细节的代码。BFD 于 1990 年推出,也被 GNU 汇编器和链接器使用,它能够为任何目标生成目标文件,这是使用 GNU 工具进行交叉开发的关键。(将 BFD 移植也是将工具移植到新目标的关键第一步。)
GDB 仅使用 BFD 读取文件,使用它将可执行文件中的数据块拉入 GDB 的内存。然后,GDB 具有两个级别的自己的读取函数。第一级是用于基本符号或“最小符号”,它们只是链接器完成其工作所需的名称。它们是带有地址的字符串,除此之外没有其他信息;我们假设文本部分中的地址是函数,数据部分中的地址是数据,等等。
第二级是详细的符号信息,它通常具有与基本可执行文件格式不同的格式;例如,DWARF 调试格式中的信息包含在 ELF 文件的专用命名部分中。相比之下,伯克利 Unix 的旧 stabs
调试格式使用存储在一般符号表中的专用标记符号。
读取符号信息的代码有些单调乏味,因为不同的符号格式对可能在源程序中的每种类型信息进行编码,但每种格式都以其独特的方式进行。GDB 读取器只需遍历该格式,构造我们认为与符号格式意图相对应的 GDB 符号。
对于一个尺寸很大的程序(例如 Emacs 或 Firefox),符号表的构建可能需要相当长的时间,甚至可能需要几分钟。测量结果始终表明,时间并非如人们预期的那样花在文件读取上,而是在内存中构建 GDB 符号上。实际上涉及数百万个小型相互关联的对象,时间会累加起来。
大多数符号信息在会话中永远不会被查看,因为它对用户可能永远不会检查的函数是本地的。因此,当 GDB 首次引入程序的符号时,它会对符号信息进行粗略扫描,只查找全局可见的符号,并将它们记录在符号表中。仅当用户在函数或方法内部停止时才会填充函数或方法的完整符号信息。
部分符号表允许 GDB 即使对于大型程序也能在几秒钟内启动。(共享库符号也是动态加载的,但过程略有不同。通常,GDB 使用特定于系统的方法来通知何时加载库,然后使用动态链接器决定的地址构建符号表。)
源语言支持主要包括表达式解析和值打印。表达式解析的细节留给每种语言处理,但通常解析器基于由手工制作的词法分析器提供的 Yacc 语法。为了符合 GDB 为交互式用户提供更多灵活性的目标,解析器不希望特别严格;例如,如果它可以推测出一个表达式的合理类型,它只会假设该类型,而不是要求用户添加强制转换或类型转换。
由于解析器不需要处理语句或类型声明,因此它比完整的语言解析器简单得多。类似地,对于打印,只需要打印少数几种类型的 value,并且通常特定于语言的打印函数可以调用通用代码来完成工作。
目标方面完全是关于程序执行和原始数据的操作。从某种意义上说,目标方面是一个完整的低级调试器;如果您满足于按指令单步执行并转储原始内存,那么您就可以使用 GDB,而不需要任何符号。(如果程序恰好停止在符号已被剥离的库中,您最终可能会以这种模式运行。)
最初,GDB 的目标方面由少量特定于平台的文件组成,这些文件处理调用 ptrace
、启动可执行文件等的细节。对于长时间运行的调试会话,这还不够灵活,在调试会话中,用户可能从本地调试切换到远程调试,从文件切换到核心转储到实时程序,附加和分离等等,因此在 1990 年,John Gilmore 重新设计了 GDB 的目标方面,通过目标向量发送所有特定于目标的操作,目标向量基本上是一类对象,每个对象都定义了一种类型目标系统的具体细节。每个目标向量都实现为一个包含数十个函数指针(通常称为“方法”)的结构,其用途从读取和写入内存和寄存器,到恢复程序执行,到设置处理共享库的参数。GDB 中大约有 40 个目标向量,范围从常用的 Linux 目标向量到鲜为人知的向量,例如操作 Xilinx MicroBlaze 的向量。核心转储支持使用从读取核心文件获取数据的目标向量,另一个目标向量从可执行文件读取数据。
混合使用几种目标向量的方法通常很有用。考虑在 Unix 上打印初始化的全局变量;在程序开始运行之前,打印该变量应该可以工作,但在此时没有可读取的进程,并且字节需要来自可执行文件的 .data
部分。因此,GDB 使用可执行文件的目标向量并从二进制文件读取。但是,当程序正在运行时,字节应该来自进程的地址空间。因此,GDB 有一个“目标堆栈”,当进程开始运行时,实时进程的目标向量会被压入可执行文件目标向量的顶部,并在进程退出时弹出。
实际上,目标堆栈并不像人们想象的那样堆栈式。目标向量彼此并不完全正交;如果您在会话中既有可执行文件又有实时进程,虽然将实时进程的方法覆盖可执行文件的方法是有意义的,但反过来几乎没有意义。因此,GDB 已经有了分层的概念,其中“进程式”目标向量都在一个分层中,而“文件式”目标向量被分配到较低的分层中,并且目标向量可以被插入,也可以被压入和弹出。
(虽然 GDB 维护人员并不喜欢目标堆栈,但没有人提出过——或者原型化——任何更好的替代方案。)
作为一个直接使用 CPU 指令工作的程序,GDB 需要深入了解芯片的细节。它需要了解所有寄存器、不同类型数据的尺寸、地址空间的尺寸和形状、调用约定如何工作、哪条指令会导致陷阱异常,等等。GDB 为所有这些编写的代码通常从 1,000 行到 10,000 行以上的 C 代码不等,具体取决于架构的复杂性。
最初,这是使用特定于目标的预处理器宏来处理的,但是随着调试器变得越来越复杂,这些宏变得越来越大,并且随着时间的推移,长宏定义被转换为从宏中调用的普通 C 函数。虽然这有所帮助,但这对架构变体(ARM 与 Thumb、MIPS 或 x86 的 32 位与 64 位版本等)帮助不大,更糟糕的是,多架构设计即将出现,宏将无法为此类设计工作。1995 年,我建议使用基于对象的設計来解决这个问题,并且从 1998 年开始,Cygnus Solutions 为 Andrew Cagney 提供资金来启动更改。 (Cygnus Solutions 是一家于 1989 年成立的为自由软件提供商业支持的公司,于 2000 年被 Red Hat 收购。) 完成这项工作花费了几年时间,并且获得了数十位黑客的贡献,影响了大约 80,000 行代码。
引入的构造称为 gdbarch
对象,在此时可能包含多达 130 种方法和变量,用于定义目标架构,尽管简单的目标可能只需要其中十几种方法和变量。
为了了解新旧方法之间的比较方式,请参见大约 2002 年来自 gdb/config/i386/tm-i386.h
的声明,即 x86 长双精度数为 96 位。
#define TARGET_LONG_DOUBLE_BIT 96
以及来自gdb/i386-tdep.c
,于 2012 年
i386_gdbarch_init( [...] ) { [...] set_gdbarch_long_double_bit (gdbarch, 96); [...] }
GDB 的核心是它的执行控制循环。我们在前面描述单步执行一行代码时已经涉及到它;该算法需要循环遍历多个指令,直到找到与不同源代码行相关的指令。这个循环被称为wait_for_inferior
,简称 "wfi"。
从概念上讲,它位于主命令循环内部,只有当命令导致程序恢复执行时才会进入。当用户键入continue
或step
并等待,而似乎没有任何事情发生时,GDB 实际上可能非常繁忙。除了上面提到的单步执行循环之外,程序可能会遇到陷阱指令并向 GDB 报告异常。如果异常是由于陷阱是 GDB 插入的断点导致的,它会测试断点的条件,如果为假,它会删除陷阱,单步执行原始指令,重新插入陷阱,然后让程序继续执行。类似地,如果引发信号,GDB 可能会选择忽略它,或者以预先指定的一种方式处理它。
所有这些活动都由wait_for_inferior
管理。最初,这只是一个简单的循环,等待目标停止,然后决定如何处理它,但是随着各种系统移植需要特殊处理,它发展到了一千行,带有 goto 语句,出于难以理解的原因相互交叉。例如,随着 Unix 变体的激增,没有人能理解它们的所有细微之处,我们也没有机会访问所有这些变体以进行回归测试,因此有强烈的动机以一种精确保留现有端口行为的方式修改代码——而一个跳过循环部分的 goto 语句是一个过于容易的策略。
单个大型循环对于任何类型的异步处理或线程程序的调试也是一个问题,在这种情况下,用户希望启动和停止单个线程,同时允许程序的其余部分继续运行。
向事件驱动模型的转换花费了几年的时间。我在 1999 年拆分了wait_for_inferior
,引入了一个执行控制状态结构来代替一堆局部变量和全局变量,并将混乱的跳转转换为更小的独立函数。同时,Elena Zannoni 和其他人引入了事件队列,其中包括来自用户的输入和来自下级的通知。
虽然 GDB 的目标向量架构允许使用各种方式来控制在不同计算机上运行的程序,但我们有一个首选协议。它没有独特的名称,通常被称为 "远程协议"、"GDB 远程协议"、"远程串行协议"(缩写为 "RSP")、"remote.c 协议"(以实现它的源文件命名),或者有时被称为 "stub 协议",指的是目标对该协议的实现。
基本协议很简单,反映了希望它在 1980 年代的 小型嵌入式系统上工作的愿望,这些系统的内存以 KB 为单位。例如,协议包$g
请求所有寄存器,并期望一个回复,其中包含所有寄存器的所有字节,所有字节都连续运行——假设它们的數量、大小和顺序与 GDB 所知的相同。
协议期望对发送的每个数据包进行单次回复,并假设连接是可靠的,只在发送的数据包中添加校验和(因此$g
实际上通过线路发送为$g#67
)。
虽然只有少数必需的数据包类型(对应于最重要的六种目标向量方法),但随着时间的推移,添加了数十个可选数据包,以支持从硬件断点到跟踪点到共享库的所有内容。
在目标本身,远程协议的实现可以采用多种形式。该协议在 GDB 手册中得到了充分的记录,这意味着可以编写不受 GNU 许可证约束的实现,事实上,许多设备制造商已经在实验室和现场中包含了使用 GDB 远程协议的代码。Cisco 的 IOS(运行着他们的大部分网络设备)就是一个著名的例子。
目标对协议的实现通常被称为 "调试桩" 或 "桩",暗示它本身不需要做太多工作。GDB 源代码包含几个示例桩,这些桩通常是大约 1,000 行低级 C 代码。在一个没有操作系统的完全裸板中,桩程序必须为硬件异常安装自己的处理程序,最重要的是捕获陷阱指令。如果硬件链路是串行线路,它还需要串行驱动程序代码。实际的协议处理很简单,因为所有必需的数据包都是单个字符,可以使用 switch 语句对其进行解码。
另一种远程协议方法是构建一个 "精灵",它在 GDB 和专用调试硬件(包括 JTAG 设备、"wiggler" 等)之间建立接口。通常这些设备有一个库必须在物理连接到目标板的计算机上运行,并且该库 API 通常与 GDB 内部结构在架构上不兼容。因此,虽然 GDB 的配置直接调用了硬件控制库,但事实证明,将精灵作为一个独立的程序运行(它理解远程协议并将数据包转换为设备库调用)更简单。
GDB 源代码确实包含一个完整的、可工作的远程协议目标端实现:GDBserver。GDBserver 是一个本地程序,它在目标的操作系统下运行,并使用目标操作系统的本地调试支持控制目标操作系统上的其他程序,以响应通过远程协议接收到的数据包。换句话说,它充当本地调试的代理。
GDBserver 不会执行本地 GDB 无法执行的任何操作;如果您的目标系统可以运行 GDBserver,那么理论上它就可以运行 GDB。但是,GDBserver 的体积小 10 倍,不需要管理符号表,因此它非常适合嵌入式 GNU/Linux 使用等。
GDB 和 GDBserver 共享一些代码,但虽然封装操作系统特定的进程控制是一个显而易见的想法,但在分离本地 GDB 中的隐含依赖关系方面存在实际困难,因此过渡过程进展缓慢。
GDB 从根本上来说是一个命令行调试器。随着时间的推移,人们尝试了各种方案将其变成一个图形化的窗口调试器,但尽管付出了所有时间和努力,这些方案中没有一个是得到普遍接受的。
命令行界面使用标准的 GNU 库readline
来处理与用户的逐字符交互。Readline 负责诸如行编辑和命令补全之类的操作;用户可以执行诸如使用光标键在行中后退并修复字符之类的操作。
然后,GDB 获取readline
返回的命令,并使用级联的命令表结构对其进行查找,其中命令的每个后续单词都会选择一个额外的表。例如,set print elements 80
涉及三个表;第一个是所有命令的表,第二个是可以set
的选项表,第三个是值打印选项表,其中elements
是限制从聚合(如字符串或数组)中打印的对象数量的选项。一旦级联表调用了实际的命令处理函数,它就会接管控制权,并且参数解析完全由函数负责。某些命令(例如run
)处理其参数的方式类似于传统的 Cargc
/argv
标准,而其他命令(例如print
)假设行中的其余部分是一个单独的编程语言表达式,并将整行交给特定于语言的解析器。
提供调试 GUI 的一种方法是使用 GDB 作为图形界面程序的 "后端",将鼠标点击转换为命令,并将打印结果格式化为窗口。这种方法已经成功实现过几次,包括 KDbg 和 DDD(数据显示调试器),但它不是理想的方法,因为有时结果会以适合人类阅读的方式进行格式化,省略了细节并依赖于人类提供上下文的能力。
为了解决这个问题,GDB 有一个替代的 "用户" 界面,被称为机器界面或简称 MI。它从根本上来说仍然是一个命令行界面,但命令和结果都有额外的语法,使一切都变得明确——每个参数都被引号括起来,复杂的结果对子组和参数名称使用分隔符。此外,MI 命令可以在前面加上序列标识符,这些标识符会在结果中回显,确保报告的结果与正确的命令匹配。
为了了解这两种形式之间的对比,这里有一个正常的 step 命令和 GDB 的响应
(gdb) step buggy_function (arg1=45, arg2=92) at ex.c:232 232 result = positive_variable * arg1 + arg2;
使用 MI,输入和输出更加冗长,但更容易被其他软件准确解析
4321-exec-step 4321^done,reason="end-stepping-range", frame={addr="0x00000000004004be", func="buggy_function", args=[{name="arg1",value="45"}, {name="arg2",value="92"}], file="ex.c", fullname="/home/sshebs/ex.c", line="232"}
Eclipse[ecl12] 开发环境是 MI 最著名的客户端。
其他前端包括一个基于 tcl/tk 的版本,称为 GDBtk 或 Insight,以及一个基于 curses 的界面,称为 TUI,最初由惠普贡献。GDBtk 是一个使用 tk 库构建的传统多窗格图形界面,而 TUI 是一个分屏界面。
作为最初的 GNU 程序,GDB 的开发最初遵循 "教堂" 式的开发模式。最初由 Stallman 编写,GDB 之后经历了一系列 "维护者",每一位维护者都是架构师、补丁审查者和发布管理者的结合,并且只有少数 Cygnus 员工可以访问源代码库。
1999 年,GDB 迁移到公共源代码库,并扩展到一个由几十位维护者组成的团队,并且得到了数十位拥有提交权限的个人的帮助。这大大加快了开发速度,每周 10 多个提交增加到 100 个或更多。
由于 GDB 具有高度的系统依赖性,并且具有大量针对从最小到最大计算机的各种系统的移植,并且拥有数百条命令、选项和使用方式,即使是经验丰富的 GDB 黑客也很难预测更改的所有影响。
这就是测试套件的用武之地。测试套件包含许多测试程序,以及expect
脚本,使用基于 tcl 的测试框架 DejaGNU。基本模型是每个脚本在调试测试程序时驱动 GDB,发送命令,然后将输出与正则表达式进行模式匹配。
测试套件还具有运行交叉调试以进行实时硬件和模拟器调试的能力,以及针对单个架构或配置的特定测试。
截至2011年底,测试套件包含约18,000个测试用例,其中包括基本功能测试、特定语言测试、特定架构测试和MI测试。大多数测试是通用的,适用于任何配置。GDB贡献者应在修补后的源代码上运行测试套件,并观察没有回归,新的测试应伴随每个新功能。然而,由于没有人能够访问可能受更改影响的所有平台,因此很少能完全达到零故障;对于配置为本地调试的trunk快照,10到20个故障通常是合理的,而一些嵌入式目标将出现更多故障。
GDB最初是“大教堂”开发过程的典范,在这种过程中,维护者对源代码保持严格控制,外部世界只能通过定期快照看到进展。这种做法是通过相对较少的补丁提交来合理化的,但封闭的过程实际上是在阻碍补丁提交。自从采用开放式过程以来,补丁的数量比以往任何时候都多,而且质量也一样好甚至更好。
开源开发过程本质上是有些混乱的,因为不同的人会持续一段时间在代码上工作,然后离开,留下其他人继续进行。
但是,制定开发计划并发布仍然是有意义的。它可以帮助开发人员在完成相关任务时进行指导,可以向潜在的资助者展示,并可以让志愿者思考如何推进它。
但是,不要试图强制日期或时间范围;即使每个人都对某个方向充满热情,也不可能保证人们能够长时间保证全职工作以在选定的日期前完成。
就此而言,如果计划已经过时,不要坚持计划本身。很长一段时间以来,GDB一直有一个计划,即重构为一个库,`libgdb`,它具有定义明确的API,可以链接到其他程序(特别是带有GUI的程序);构建过程甚至被更改为构建一个`libgdb.a`作为中间步骤。虽然这个想法自那时起已经周期性地出现,但Eclipse和MI的优先地位意味着该库的主要理由已被回避,截至2012年1月,我们已经放弃了库的概念,并且正在清除现在毫无意义的代码片段。
在看到我们做的一些更改后,你可能会想:为什么我们一开始没有把事情做好?好吧,我们只是不够聪明。
当然,我们可以预料到GDB将会非常受欢迎,并且会被移植到数十个架构上,包括本地架构和交叉架构。如果我们知道这一点,我们可以从gdbarch对象开始,而不是花费数年时间来升级旧的宏和全局变量;目标向量也是如此。
当然,我们可以预料到GDB将会与GUI一起使用。毕竟在1986年,Mac和X Window System都已经被推出两年了!与其设计传统的命令界面,我们可以在设计时设置它以异步处理事件。
真正的教训是,不是GDB开发者很笨,而是我们不可能聪明到能够预料到GDB需要如何演变。在1986年,窗口和鼠标界面将变得无处不在,这一点并不明确;如果GDB的第一个版本完美地适应了GUI使用,我们看起来会像天才,但这纯粹是运气。相反,通过在更有限的范围内使GDB有用,我们建立了一个用户群,使我们能够在之后进行更广泛的开发和重新设计。
尽量完成转换,但这可能需要一段时间;预计转换会不完整。
在2003年的GCC峰会上,Zack Weinberg对GCC中的“不完整转换”表示惋惜,其中引入了新的基础设施,但旧的基础设施无法删除。GDB也有这些,但我们可以指出一些已经完成的转换,例如目标向量和gdbarch。即使如此,它们也可能需要数年时间才能完成,在此期间,人们必须继续运行调试器。
当你长时间使用一组代码时,它是一个重要的程序,而且还能支付账单,很容易对它产生依恋,甚至将你的思维模式塑造成适合代码,而不是相反。
不要。
代码中的所有内容都源于一系列有意识的决定:一些是受启发的,一些是较少的。1991年的巧妙节省空间技巧在2011年的多吉字节RAM中是一个毫无意义的复杂性。
GDB曾经支持Gould超级计算机。当他们在2000年左右关闭最后一台机器时,保留那些代码片段确实没有意义。这事件是GDB过时过程的起源,大多数版本现在都包含了一些代码的退休。
事实上,有很多根本性的改变正在进行或正在进行中,从采用Python进行脚本编写,到支持高度并行多核系统的调试,再到用C++重新编码。这些变化可能需要数年时间才能完成;更应该现在就开始做。