开源软件架构-GDB

GDB,即GNU调试器(GNU Debugger)。它诞生自开源软件基金会 (Free Software Foundation)成立之初的第一批程序,并一直是免费和开源软件系统中的主要成员。最初GDB只是 Unix 系统上一个简单的源码层次的调试器,代码量不过数千行 C 代码,后来逐步发展壮大,拓展到包括嵌入式系统在内多个平台,代码量也达到了上百万行。

原文地址:http://aosabook.org/en/gdb.html;作者:Stan Shebs

GDB在发展,不断地满足着新的用户需求并增加新的功能。这一节我们将介绍GDB的整体内部结构,探讨一下GDB是如何做到这一点的。

目标

GDB的设计目标是一个针对使用命令式(imperative)语言(例如 C、C++,Ada,Fortran等)编写的程序的符号调试器。使用GDB原始命令行界面的一个示例如下:

GDB能显示程序中的错误,开发者据此判断错误的类型并找到解决的方案。

设计GDB最需要考虑的是调试工具的交互性,因为用户在调试时提交的请求是不可预测的。此外,GDB还需要深入到系统最底层,因为编译器会充分利用硬件的各种选项来优化程序的性能。

GDB还要求能够调试不同编译器编译的程序(不仅仅是 GNU C 编译器),能够调试过时编译器编译的程序,能够调试符号信息丢失、过时或错误的程序。所以,另外一个设计要求是,即使程序中的数据丢失、损坏或干脆无法理解,GDB也能够继续工作并发挥作用。

接下来我假定读者熟悉GDB基本的命令行使用方法。如果你还是新手,建议先用一用 GDB并细读一下手册。

GDB 的起源

GDB程序历史悠久,早在1985年就已经存在。它的作者是 Richard Stallman,这个人还编 写了GCC,GNU Emacs和其它一些早期的GNU软件。(由于当时并没有软件仓库,GDB 开发过程的细节已不为人所知。)

GDB的最早的稳定版本在1988年发布,但在今天的GDB源码中已经找不到多少相似的地 方了,GDB被完全重写过至少一次。令人惊讶的是,早期的GDB并没有太大的野心,后来的平台移植和功能扩展并没有包括在GDB最初的计划之中。

GDB 结构框图

GDB 总体结构图

总体来讲,GDB内部结构可分为两大块:

  1. 符号端(Symbol),涉及到程序的符号信息。符号信息包含函数名,变量名,变量类型,行号,机器寄存器使用情况等等。符号端将程序可执行文件中的符号信息取出来,解析表达式,找到指定行号的内存地址,列出源代码,并大体上获取程序中的文本信息。
  2. 目标端(Target),涉及到目标系统的操控。目标端包含了基本的调试工具,包括启动和终止程序,读取或修改内存和寄存器,捕捉信号等等。这些工具的实现在不同的系统上可能会相差很大。大部分Unix类操作系统上都提供了一个系统函数ptraceptrace可以让一个进程读写另一个进程的状态。因此,GDB的目标端的主要工作就是调用 ptrace 和解析结果。对于嵌入式系统的交叉调试,过程有所不同,目标端通过数据线发送消息包,然后等待应答。

这两大模块相互较为独立,用户可以查看程序的代码,显示变量类型,但不需要实际地运行程序。反过来,不用符号信息完全使用机器码调试也是可能的。

将符号端和目标端连接起来的中间层是命令解释器和主程序运行控制循环。

操作例子

为了解 GDB 各部分是如何协同工作的,不妨考虑一下前面示例中提到的 print 命令。命令解释器会搜索 print 命令函数,该函数将表达式转化为一个简单的树结构,通过遍历树结构来运算这个表达式。运算器会查询符号表,它发现positive_variable 是一个全局整型变量,其存储地址是0x601028。随后,它会调用一个目标端中的函数来获取该地址中的4个字节内容,将结果传递给格式化函数,并显示为一个数字。

为了同步显示源码和对应的编译后代码,GDB同时读取源码文件和目标文件,然后使用编译器产生的行号信息将两者联系起来。在本例中,232行的地址是0x4004be,233行是0x4004ce,等等。

单步执行命令 step 背后则稍为复杂。当用户使用 step 请求执行到下一行时,目标端只执行程序的一个指令后就再次暂停(ptrace支持类似的操作)。当获悉程序已经停止了,GDB 读取程序计数器(PC:program counter)寄存器(另外一个目标端操作),并与符号端中记录的当前行的地址范围进行比较。如果程序计数器在这个范围之外,GDB允许程序停止,并获取新的代码行反馈给用户。如果程序计数器仍然在地址范围之内,GDB 会重复执行指令和检查计数器的操作,直到程序计数器到达新的代码行。这个简单的算法保证了调试的逻辑正确性,无论当前行是跳转还是子函数调用都不会出错,而且也不要求 GDB 去理解机器指令集的所有细节。但不足之处是,一个单步执行命令(step)会产生与目标之间的多个交互过程。对于嵌入式调试,这个算法会导致单步执行变得很慢。

可移植性

由于需要大量地访问芯片上的物理寄存器,GDB 最初的设计就考虑了面向不同系统的可移植性问题。但是 GDB 的可移植性策略却是与时俱进的。

最初,GDB 和当时的其它 GNU 程序一样,使用C语言的最小子集编码,结合预处理宏和 Makefile脚本来适应特定的硬件结构和操作系统。GNU项目的目标是完备的“GNU 操作系统”(实际上,很多年之后Linux内核才问世),因此其系统引导程序(bootstrapping)必须考虑多种已有平台。在面向多平台移植的过程中,configure 脚本是第一个关键步骤。configure 要做的事情很多,比如用符号链接将特定平台的文件统一为通用的头文件,比如从多个配置文件出发生成结果文件(在构建软件时,主要目标是生成 Makefile 文件)。

和cat、diff之类的程序一样,GCC和GDB也有额外的平台移植需求。随时间变化,GDB的可移植性问题分成了三类,每一类都有独立的 Makefile 脚本和头文件。

“主机(host)”定义:指 GDB 运行时所在的机器的信息,包含了主机整型大小等信息。原来 这些头文件是手工编写的,渐渐地人们发现 configure 可以自动完成这个工作。configure 脚本会调用一些测试程序,这些测试程序与 GDB 使用的是同一个编译器。这些都是autoconf的工作,几乎所有的 GNU 工具和许多 Unix 程序都在使用 autoconf 来生成configure脚本。

“目标(target)”定义:待调试程序所在的特定机器上的信息。如果目标机器和主机是同一台 机器,这样的调试称为本地(native)调试,否则称为交叉(cross)调试(主机和目标机器通过数据线连接)。目标定义又分为两个主要类别:

  • “结构(architecture)”定义:定义了如何分解机器码,如何访问调用堆栈,在断点处插入何种 trap 指令。最初这些工作由宏来完成,后来使用C语言编写的所谓”gdbarch“对象(后面会进一步介绍)。
  • “本地(native)”定义:定义了 ptrace 的参数规范(不同 Unix 之间变化很大),如何搜索已加载的动态链接库等等。本地定义只适用于本地调试的情况,它是从80年代的那些宏中遗留下来的,其它的都已经被 autoconf 取代了。

数据结构

在深入了解GDB各部分之前,让我们先看看GDB主要的数据结构。作为一个C程序,GDB必然使用 struct 而非 C++对象(object)。但是,struct 也是可以视为对象(object)的,而 GDB开发者也喜欢称其为对象,那么我们也就入乡随俗了。

断点(breakpoint)

断点是用户能够直接访问得到的主要对象。用户使用 break 命令创建一个断点,其参数为断点的位置,位置可以是函数名,源码行号或机器地址。GDB为每个断点对象指定一个正整数作为其标识符,用户将通过这个正整数来操纵断点。在 GDB 中,断点是一个内容丰富的C语言结构体。位置信息会被翻译为机器地址,但仍然保留其原始形式,因为机器地址可能会发生变化(比如在不退出会话的情况下重新编译运行)。

还有其它一些断点类对象也使用断点的数据结构,包括观察点(watchpoint),捕捉点(catchpoint),跟踪点(tracepoint)。数据结构的共享保证了一些公共操作(创建、操纵和删 除)对这些对象的通用性。

“位置”一词还可以指断点定义处的内存地址。对于 inline 函数和 C++模板,用户定义的一个断点可能会对应到多个地址。比如当一个断点定义到inline 函数上时,代码中所有使用这个函数的位置都会有断点存在。

符号和符号表

符号表是 GDB 中的核心数据结构,它的数据量很大,有时甚至会达到数G字节。从某种意义上说,这也是无法避免的。每个局部变量,每种类型,每个枚举值,都是独立的符号。一个大型C++程序本身就包含了数百万个符号,而它所引用的头文件同样会有数百万符号。

GDB 使用了很多技巧来减少符号表占用的空间,比如使用不完全符号表(partial symbol table,后面会有介绍),在结构体中使用比特位等等。

符号表的作用是建立字符串到地址和类型信息之间的映射,除此之外,GDB还建立了一些支持双向查询的行号表:从源码行查询地址,从地址查询源码行。(早前介绍的单步执行算法就严重依赖于地址到源码的映射。)

栈框架

GDB支持的过程式语言运行时都有一个相似过程,即函数调用会引起程序计数器,函数参数,以及局部参数的入栈。这些入栈数据的组合体称为“栈框架(stack frame)”,或简称“框架”。在程序执行的任何时刻,栈中都包含了多个串连在一起的框架。栈框架的细节取决于芯片体系结构,还和操作系统、编译器以及优化选项有关系。

将GDB迁移到新的芯片时需要编写大量的代码来分析栈,因为用户程序(特别是带 Bug 的 程序)可能在任何地方暂停运行,届时框架可能并不完整,部分甚至会被程序覆盖。更糟糕的是,为每个函数调用创建一个栈框架会影响程序效率,因而编译器在优化时会尽可能地简化栈框架,甚至完全消除(tail 调用即是如此)。

对于特定芯片的栈的分析结果保存在一系列的框架对象中。最初,GDB 使用一个固定框架指针寄存器来跟踪框架。但这个方法对 inline 函数调用以及其它编译器优化不起作用。从2002年开始,GDB开发人员引入了显式框架对象(explicit frame object)来记录每一框架 的信息,这些显式框架对象链接在一起,并映射到程序的栈框架上。

表达式

对于栈框架,GDB假定它所支持的不同语言的表达式具有一定的共性,并将表达式表达为一个由结点对象构成的树结构。实际上,结点的类型集合是所有不同语言中所有可能的表达式类型的一个联合。和编译器不一样,GDB允许 Fortran 变量和 C 变量之间的减法,虽然两种变量类型相差甚远并且结果会人大吃一惊。

值(value)

表达式计算得到的结果可能要比一个整数或内存地址更为复杂,GDB将这些结果保存在一个经过编号的历史列表中,以便在后面的表达式中能够访问得到。为实现这个功能,GDB有一个关于值(value)的数据结构。value结构体(struct)包含了大量的成员来记录其属性,包括标记这个值是左值还是右值(左值可以被赋值),以及这个值是否由懒构造(lazy construction)得到。

符号端

GDB的符号端的功能主要是读取可执行文件,提取所有的符号信息,然后构造一个符号表。读取可执行文件的首先要调用 BFD 软件库。 BFD 是一个通用的处理二进制和对象(object) 文件的软件库,支持从任意主机上读取 Unix 的 a.out 格式,COFF 格式(用于 System V Unix系统和微软 Windows 操作系统),ELF 格式(用于现代 Unix、GNU/Linux 和大部分嵌入式系统)以及其它文件格式。BFD 内部采用一个复杂的 C 语言宏,这个宏展开成代码之后能够深入到对象文件格式的复杂细节中去,而这些对象文件可能来自于几十个不同平台。 BFD 从1990年被引入到 GNU 汇编器和链接器,它对多种对象文件输出的支持成为跨平台开发的关键因素。(自然,将 GDB 移植到一个新的平台的首要条件是将 BFD 移植过去。)

GDB只用BFD来读取文件,将可执行文件中的数据块读到GDB的内存空间中去。GDB本身拥有两个层次的读入函数。第一个层次针对基本符号,或最简符号(minimal symbols),只包含了链接器需要的名称。这些基本符号只是一些带地址的字符串,在这一层次下,我们假定文本节(text section)中的地址都是函数,而数据节(data section)中的都是数据,依此类推。

第二个层次针对详细的符号信息,通常这种符号信息拥有与可执行文件不同的格式。例如,DWARF 调试格式中的信息存储在 ELF 文件中单独命名的节(section)内。而Berkeley Unix系统中用到的旧的 stabs 调试格式将这些详细符号信息加上特别的标记后存储在通用符号表中。

阅读符号信息的代码非常无聊,因为不同的符号格式都需要将源码中的每个类型信息进行编码,而每一种符号格式的编码方式又都不一样。GDB的文件阅读器的工作就是扫符号格式,将其转化为原来的形式。

不完全符号表

对于较大规模的程序(如 Emacs 或 Firefox),建立符号表是比较费时的,有可能会达到几分钟的时间。实践表明文件加载时间倒不是主要的,主要是瓶颈在于内存中 GDB 符号的构造。一个程序中往往存在着上百万个小对象相互联系着,处理起来时间开销非常大。

大部分符号信息在一个 GDB 会话中从来不会用到,因为它们来自于函数的局部作用域。所以,GDB第一次导入程序的符号时,它先扫 一下符号信息,只把全局可见的符号存进符号表。当用户在某个函数内暂停运行时,这个函数的完整的符号信息才会动态加载进来。

在GDB中,不完全符号表使得大程序也能在数秒内启动。(动态链接库的符号也会动态加载,但过程完全不同。当动态链接库被加载时,平台会通知 GDB 建立一个符号表,符号表中存储了动态链接地址对应的那些函数。这个过程取决于特定平台的消息机制,不同的平台会有所不同。)

语言支持

对源码语言的支持主要包括表达式解析和值的打印。表达式解析由语言自身负责,但一般来说表达式解析器是一个基于 Yacc 语法的词法分析器。为了让 GDB 在用户交互操作时具有更大的灵活性,解析器不需要对语法有严格的要求。比如,如果用户能合理地猜出来表达式的类型,那他就不需要显式地做类型转换。

GDB表达式解析器不需要考虑变量声明和类型声明,比完整的语言解析器要简单得多。类似的,值的打印,也只有考虑一部分类型的值,甚至还可以由特定语言的函数来实现。

目标端

目标端的功能是操纵程序的执行和处理底层原始数据。从某种意义上讲,目标端是一个完全低层次的调试器。如果只是逐个指令调试并打印原始内存,用户根本就不需符号信息。(如果程序刚好在一个被剥离符号的软件库中暂停,你也只能使用这种模式。)

目标向量和目标向量栈

最初,GDB的目标端由一些特定平台上的文件组成,用于处理 ptrace 的调用,启动可执行文件等等。但这对于长时间运行的 GDB 会话来说是不够灵活的,因为用户可能会中途变化调试目标或方式,比如从本地调试切换到远程调试,从调试 core 文件切换到调试运行的程序,从附加(attach)线程变为分离(detach)等等。1990年,John Gilmore重新设计了 GDB的目标端,使用目标向量来流水处理特定目标的操作。目标向量主要是由一类定义了目标系统特性的对象,每个目标向量是多个函数指针(通常称为”方法”)构成的结构体,这些方法的功能包括读写寄存器内存,恢复程序运行,设置处理共享库时的参数。GDB 中大概有40多个目标向量,包括有名的针对 Linux 的目标向量,以及不那么出名的操纵 Xilinx MicroBlaze的目标向量。对 Core dump 的支持使用了一个从 corefile 中获取数据的目标向量,对应的,还有从可执行文件中获取数据的目标向量。

通常将几个目标向量混合使用比较有利。以在Unix上打印一个已初始化的全局变量为例,在程序开始运行之前,GDB也要能够支持对这个变量的打印,但这个时候进程并没有启动,数据只能从可执行文件的 .data 节(section)获取,所以 GDB 只能使用针对可执行文件的目标向量来读取二进制文件。但是如果程序已经运行,数据就应该从进程的地址空间中获取。这时候,GDB就会使用“目标向量栈”,运行进程目标向量被推入栈顶,置于可执行文件目标向量之上,当进程退出时栈顶目标向量就会被弹出。

实际上,目标向量栈和你想像中的栈并不完全相同,目标向量之间并不是完全独立的。如果一个GDB会话同时调试一个可执行文件和一个运行进程,几乎总是让进程的方法覆盖可执行文件的方法。所以GDB出“阶层(stratum)”的概念,令所有“进程类”的目标向量位于较高的阶层,而所有“文件类”的目标向量位于较低阶层,目标向量栈支持目标向量的推入(push) 和弹出(pop),还支持插入操作。

虽然GDB的维护者们并不怎么喜欢目标向量栈,但是还没有人能提出或实现更好的的方 案。

Gdbarch

因为程序直接和 CPU 的指令打交道,GDB 需要深入了解芯片的细节,比如,所有的寄存器的信息,不同种类数据的大小,地址空间的大小和形状,调用约定是怎么工作的,什么指令会导致 trap 异常等等。GDB中这一类工作的代码量取决于芯片的复杂度,从1000行到10000行的 C 代码都是有可能的。

最初,这个工作是由特定目标的预处理宏来完成的,但是随着调试器变得越来越复杂,这些宏变得越来越长,以致于不得不让部分宏变成了 C 函数(由其它宏来调用)。虽然这暂时减小了宏的复杂度,但是无助于解决平台的多样性问题(ARM 或 Thumb、32位或64位、 64位MPIS 或 x86等等)。更糟糕的是,多体系结构设计开始出现,对此,宏已经无能为力。1995年,我提出使用面向对象的设计来解决这个问题。从1998年开始 Cygnus Solutions 公司资助 Andrew Cagney 来开始实现这个设计。(Cygnus Solutions 是一家1989年创立的供免费软件商业支持的公司,2000年被 Red Hat 收购)。在几十个黑客数年的努力下,这个工作终于完成,其代码量大概有80000 行。

新引入的结构称为gdbarch对象,目前它包含了多达130个方法和变量来定义目标体系结构,,其实一个简单的目标平台也许只需要几十个。

为了比较其差异,我们来看一下“将 x86平台下 long doubles 类型的大小定义为96”在新旧 方式下分别是如何实现的:

gdb/i38-tdep.c 中2012行处的代码(旧方式):

gdb/config/i386/tm-i386.h 中2002行(新方式):

运行控制

GDB的核心是运行控制循环,前面描述单步执行一行代码时提到过这个名词:用一个简单的循环来判断指令是否运行到了下一行源代码。这个循环称为 wait_for_inferior 或简称为 wfi。

从概念上看,wfi位于主程序命令循环内部,并且只有在程序恢复执行时才会进入 wfi 循环。当用户提交 continue 或 step 命令时,看起来似乎什么也没发生,其实这时候的 GDB 忙得很。

除了前面提到的单步运行循环,程序还可能会执行到 trap 指令并将此异常汇报给 GDB。如果遇到一个由断点引发的 trap 异常,GDB会判断这个断点的条件,如果条件为假,则移除此 trap 指令,继续执行单步运行循环,然后重新插入 trap 指令并令程序恢复执行。类似的,如果接收到一个信号,GDB可能会选择忽略,或根据预先指定的方式来处理。

所有这些活动都由 wait_for_inferior 来管理。最初 wfi 只是一个简单的循环,等待目标停止执行然后决定接下来怎么办,但移植到新的平台意味着增加新的需求,这个循环渐渐地增加到了1000行代码,而且变得难懂以至于不得不使用 goto 语句。随着增加对更多种 Unix 系统的支持,没有一个人能够理解所有的代码,也没有人能够对所有的代码进行回归测试。所以代码重构显得非常有必要,保留已有平台的行为然后使用 goto 语句跳过循环中的部分代码只是一个权宜之计。

这个庞大的循环在异步处理时也是有问题的。因为,在调试多线程程序时,用户需要在程序其它部分保持运行的同时调试 一个线程。

GDB从 wfi 转变为事件驱动模型花费了数年的时间。1999年,我将wait_for_inferior 拆分开来,引入了一个执行控制状态结构体,取代本地和全局的大量杂乱的变量,并将复杂的跳转封装到一些小型独立的函数中。同时 Elena Zannoni 和其它人引入了事件队列,该队列的输入既包含用户的操作,还包括来自底层的通知。

远程协议

虽然GDB的目标向量体系允许在不同计算机上以多种方式来控制程序的运行,但是我们倾向于使用单一的协议。这个协议并没有一个独立而准确的名称,它使用过的名称包括 “远程协议(remote protocol)”,“GDB 远程协议”, “远程串行协议(Remote serial protocal, 简写为 RSP)”,“远程 C 协议(用实现语言命名)”,或“桩协议(stub protocol)”,其实都是指目标系统对这个协议的实现。

基本的协议比较简单,主要面向19世纪80年代的小型嵌入式系统,其内存不过几千字节。GDB向所有的寄存器发出协议数据包$g,请求获得所有寄存器的所有内容,GDB假定这些寄存器的数目,大小和顺序都是已知的。

协议假定连接是可靠的,且每个发出去的数据包都能得到应答,在发包时只是加上一个检验和数字($g 发送成 $g#67)。

远程协议中必要的数据包类型并不多(对应于6个最重要的目标向量方法),但为了支持硬件断点、跟踪点(tracepoint)和共享库,又逐步加入了数十个可选的数据包格式。

对于目标平台本身来说,远程协议可以以多种形式来实现。GDB 的手册中有完整的协议文档,只要用户不违反GNU协议就可以实现自己的协议。事实上,许多设备制造商已经在实验或实践中实现了一些使用 GDB 远程协议的代码。比如,广为人知的 Cisco 的 IOS,就一 直运行在该公司的许多网络设备上。

目标平台对于远程协议的实现通常称为“调试桩(debugging stub)”,或者简称为“桩(stub)”,意指它不会独立完成任何工作。GDB的源码中包含了一些桩的示例代码,大约只有1000行左右的 C 代码。对于一个没有操作系统的电路板,桩必须能够自己处理硬件异常,特别是能够捕捉 trap 指令。如果硬件链接是串行的,它还需要有串行驱动的支持。实际的协议处理过程是比较简单的,因为所有必须的数据包都是单个字符,可以使用一个简单 的 switch 语句来解码。

另外一个实现远程协议的方法是构建一个“sprite”,作为 GDB 和调试硬件(包括 JTAG 设备,“wiggler”等)之间的接口。通常这些设备需要在与目标板相连的计算机上运行一个特殊的软件库,这个库的 API 往往与 GDB 内部结构不相容。所以,与其让 GDB 直接使用硬件控制库,还不如更简单地让 sprite 作为一个独立的程序运行,它能够理解远程协议并将数据包翻译成设备软件库函数。

GDBserver

GDB源码中已经包含了一个完整和可靠的目标端远程协议的实现:GDBserver。GDBserver是一个在目标操作系统上运行的本地程序,它响应通过远程协议接收到的数据包,控制目标操作系统上的其它程序来供本地调试支持。换句话说,它类似于本地调试的一个代理。

GDBserver不做本地 GDB 能力范围之外的事,也就是说,如果目标系统可以运行 GDBserver,那么理论上它也可以运行GDB。但是GDBserver 只有 GDB 软件规模的1/10, 而且不需要管理符号表,所以用于嵌入式 GNU/Linux 之类的系统的调试是非常方便。

GDB 和 GDBServer 共享相同的代码,虽然大家都知道要将平台依赖的控制代码封装起来,但是实际中 GDB 的这个迁移工作进展缓慢,因为将本地 GDB 中的依赖关系分离开来是比较困难的。

GDB 界面

GDB 本质上是一个命令行调试器。人们始终没有放弃尝试将其发展为一个图形窗口调试器,但是即使投入了大量的时间和努力,至今也没有一个得到广泛接受的方案。

命令行界面

命令行接口使用了标准的 GNU 软件库 readline 来处理 GDB 和用户之间的交互。readline 用于命令行的编辑和自动补全,因而用户可以像使用光标一样在命令行中移动和修改。

GDB接收 readline 返回的命令,然后在一个瀑布型的命令表结构中查询这条命令,命令中每个后续单词会选择一个额外的表格。比如,”set print elements 80“使用了 3个表格,第一个是包含了所有命令的表格,第二个是包含了 set 选项的表格,第三个是 print 选项的表格,其中 elements 选项用于控制打印一个集合体(如字符串或数组)中输出对象的个数。最后瀑布型表格将控制权交给一个实际的命令处理函数,命令的参数将传递给这个函数来解析。一些命令,比如 run,处理参数的方式和传统 C 语言的 argc/argv 标准类似,而其它一些命令,比如print,则假定参数是一个程序表达式,并将其完整传递给源码解析器。

机器界面

一种GUI调试器方案是将 GDB 作为图形用户界面程序的后端,将鼠标点击翻译成 GDB 命令,然后将打印的结果显示在窗口中。这种方案已经在一些软件中实现,比如 KDbg 和 DDD(Data Display Debugger)。但这个方法仍然不理想,因为有时候显示结果时为了可读性会省略掉一些细节,前端提供上下文的能力也会影响到结果的显示。

为解决这个问题,GDB 提供了一个被称为机器界面(Machine Interface, MI)的接口。本质 上 MI 仍然是一个命令行界面,但是命令和结果都增加了额外的语法,使得其意义更为显然:每个参数都使用了引号,复杂输出则使用定界符来分组,使用参数名来分块。此外, MI的命令还可以加上顺序标识符作为前缀,并在结果中返回,保证了结果和命令的匹配。

为了比较两种界面,分别给出它们对于同一命令的使用情况。下面是正常的 step 命令及 GDB 的响应:

下面是 MI 的输入和输出,虽然显得有些冗余,但更加精确,便于第三方软件进行解析:

Eclipse开发环境是最著名的使用 MI 的调试环境。

其它用户界面

其它GDB前端软件包括基于 tcl/tk 的 GDBtk 或 Insight,基于文字界面的 TUI(最初由 Hewlett-Packard 开发)。GDBtk 是一个传统的多面板图形用户界面,使用 tk 软件库开发,而 TUI 是一个在终端中使用的分屏文字界面。

开发过程

维护者

作为一个 GNU 程序,GDB 的开发遵循“大教堂(cathedral)”开发模型。GDB 最初由 Stallman 编写,随后维护者几易其人,每个人都是身兼设计师,补丁审查员,发布管理员数职,他们有权访问仅向少数 Cygnus 雇员开放的源码仓库。

1999年,GDB 被迁移到一个公共源码仓库,维护团队也扩展到了几十人,并且还有一些拥有签入(commit)权限的个人从旁协助。这个模式显著加速了 GDB 的开发,从原来的每周10个签入增加到了100个以上。

测试,测试

由于 GDB 高度依赖于特定平台,几乎涵盖全系列的计算设备,而且包含了数以百计的命令,选项以及使用风格,即使是一个经验丰富的 GDB 黑客也难以完全预料一个修改所产生的后果。

于是,测试套件变得举足轻重。GDB 的测试套件包含了众多测试程序以及 expect 脚本。使用一个基于 tcl 被称为 DejaGNU 的测试框架。其基本模式是,每个脚本驱动 GDB 去调试一个测试程序,然后向其发送命令,并使用模式匹配来判断结果正确与否。

这个测试套件还能进行交叉调试,既支持真实硬件也支持模拟器,它还能对于特定平台或配置进行测试。

到2011年底,GDB测试套件包含了大约18000个测试用例,包括了基本功能测试,语言特性测试,体系特性测试,和 MI 测试。所有这些测试都是通用的,适用于所有配置。 GDB需要志愿者来测试打补丁后的源码,新的功能也需要新的测试。但是,因为没有人能在所有平台上测试同一修改,要实现测试的完全通过是不现实的。对于本地调试来说, 主干GDB测试时失败10-20次左右是可以接受的,嵌入式系统则更容易出错。

经验教训

开放是王道

GDB 是“大教堂”开发模型的典范,在该模式下,维护者严密控制源码,而外部用户则跟踪其进度。补丁交数目较少,封闭的开发过程实际上并不鼓励补丁。自从采用开放模式之后,补丁数量显著增多,而软件质量则一如既往,甚至更好。

制订计划, 但计划赶不上变化

开源软件开发过程实际上会比较混乱,因为开发者之间是松散的,流动性很大。

但是,制订开发计划并发布仍然很有意义。这有助于指导开发者完成相关任务,而且能够吸引潜在的赞助者,另外志愿者在尝试做出贡献时也能有一定的依据。

但是不要尝试设置截止时间,即使是每个人都热情地朝着一个方向努力,也不要指望大家都能全身心地投入并按时完成任务。

鉴于此,不要坚持一个已经过时的计划。长期以来,GDB 都有重构为软件库 libgdb 的计划,这样,别的程序就可以通过使用 libgdb 来实现一个拥有 GUI 的调试器。开发人员甚至尝试过将构建 libgdb.a 作为整个构建过程的一个中间步骤。虽然这个想法一直存在,但随着 Eclipse 和 MI 的成功,libgdb 被搁置了起来,到2012年1月这个想法最终寿终正寝。

绝顶聪明该多好

看到曾经提交的修改,我们也许会想:为什么一开始不这么做呢?唉,只因为我们不够聪明。

我们本可以预料到 GDB 会如此流行,并且会移植到数以百计的平台上,还支持本地和交叉调试。如果事先知道这些,说不定一开始就会使用 gdbarch 对象,而不会数年来都在用陈旧的宏和全局变量,目标向量也早该出现。

我们本可以预料到 GDB 将会被用到GUI中,毕竟1986年Mac和X窗口系统已经出现了2 年。与其设计一个传统的命令行界面,我们更应该让其支持异步事件处理。

然而,真正的教训不在于 GDB 开发者们有多蠢,而是我们不可能如此聪明地未卜先知。1986年,窗口-鼠标风格的界面的未来还并不清晰,我们预料不到它会像今天这样流行,如果第一个版本的 GDB 就设计为在 GUI 下使用,我们就可以称得上天才了,但这种好运不是人人都能有的。相反,在一个有限的范围内让 GDB 有所作为,我们已经为今后的扩展和重构打下了用户基础。

学会接受缺陷

尽力完成过渡,但是时间总是太快,你只能接受缺陷。

在2003年的 GCC 峰会上,Zack Weinberg 哀叹 GCC 的“不完整过渡”,新的底层结构已经 引入,但是旧的却尾大不掉。GDB 有着同样的问题,但是我们应该看到积极的一面,因为毕竟一些过渡已经完成,比如目标向量,gdbarch 等等。虽然过渡需要多年来完成,调试却要一直继续。

谨防着迷于代码

当你遇到一个对你非常重要的项目,你会花费大量时间在单个代码上,你会很容易沉迷其中,甚至为了迎合代码而改变自己的想法。但是,很有可能你已经误入歧途,退一步说不定海阔天空。

这样的事情要杜绝发生。

所有代码都源自于 一系列清醒的判断:有些来自灵感,有些则不是。1991年节省空间的小伎俩对于2011年的数个 G 的内存来说是毫无意义的。

GDB 曾经支持 Gould 超级计算机。当他们在2000年关闭最后一台机器时,保留对这种机 器的支持已是毫无意义。那些代码只是 GDB 过往历史中的一些小小篇章,然而现在大部分的发行版中仍然有些“怀旧”。

事实上,很多激进的修改已经摆上日程或已经开展,包括对 Python 脚本的支持,对并行多核平台的支持,重编码为 C++等。这些修改可能要花费数年,但其动机却来自于今天(等到它们完成时说不定已经过时)。

你可能感兴趣的:(编译器调试器)