http://www./article/5916 2012 作者:Stan Shebs GDB, 即GNU调试器(GNU Debugger)。它诞生自开源软件基金会(Free Software Foundation)成立之初的第一批程序,并一直是免费和开源软件系统中的主要成员。最初GDB只是Unix系统上一个简单的源码层次的调试器,代码量不过数千行C代码,后来逐步发展壮大,拓展到包括嵌入式系统在内多个平台,代码量也达到了上百万行。 GDB在发展,不断地满足着新的用户需求并增加新的功能。这一章将我们将介绍GDB的整体内部结构,探讨一下GDB是如何做到这一点的。 4.1 目标 GDB的设计目标是一个针对使用命令式(imperative)语言(例如C,C++,Ada,Fortran等)编写的程序的符号调试器。使用GDB原始命令行界面的一个示例如下:
GDB能显示程序中的错误,开发者据此判断错误的类型并找到解决的方案。 设计GDB最需要考虑的是调试工具的交互性,因为用户在调试时提交的请求是不可预测的。此外,GDB还需要深入到系统最底层,因为编译器会充分利用硬件的各种选项来优化程序的性能。 GDB还要求能够调试不同编译器编译的程序(不仅仅是GNU C编译器),能够调试过时编译器编译的程序,能够调试符号信息丢失、过时或错误的程序。所以,另外一个设计要求是,即使程序中的数据丢失、损坏或干脆无法理解,GDB也能够继续工作并发挥作用。 接下来的几章假定读者熟悉GDB基本的命令行使用方法。如果你还是新手,建议先用一用GDB并细读一下手册[SPS+00]。 4.2 GDB的起源 GDB程序历史悠久,早在1985年就已经存在。它的作者是Richard Stallman,这个人还编写了GCC,GNU Emacs和其它一些早期的GNU软件。(由于当时并没有软件仓库,GDB开发过程的细节已不为人所知。) GDB的最早的稳定版本在1988年发布,但在今天的GDB源码中已经找不到多少相似的地方了,GDB被完全重写过至少一次。 令人惊讶的是,早期的GDB并没有太大的野心,后来的平台移植和功能扩展并没有包括在GDB最初的计划之中。 4.3 GDB结构框图 图4.1 GDB总体结构图 总体来讲,GDB内部结构可分为两大块:
4.4 操作实例 为了解GDB各部分是如何协同工作的,不妨考虑一下前面示例中提到的 为了同步显示源码和对应的编译后代码,GDB同时读取源码文件和目标文件,然后使用编译器产生的行号信息将两者联系起来。在本例中,232行的地址是
单步执行命令 4.5 可移植性 由于需要大量地访问芯片上的物理寄存器,GDB最初的设计就考虑了面向不同系统的可移植性问题。 但是GDB的可移植性策略却是与时俱进的。 最初, GDB和当时的其它GNU程序一样, 使用C语言的最小子集编码, 结合预处理宏和 和
4.6 数据结构 在深入了解GDB各部分之前, 让我们先看看GDB主要的数据结构。作为一个C程序,GDB必然使用 断点(breakpoint) 断点是用户能够直接访问得到的主要对象。用户使用 还有其它一些断点类对象也使用断点的数据结构,包括观察点(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结构体( 4.7. 符号端 GDB的符号端的功能主要是读取可执行文件,提取所有的符号信息,然后构造一个符号表。 读取可执行文件的首先要调用BFD软件库。 BFD是一个通用的处理二进制和对象(object)文件的软件库,支持从任意主机上读取Unix的 GDB只用BFD来读取文件,将可执行文件中的数据块读到GDB的内存空间中去。GDB本身拥有两个层次的读入函数。第一个层次针对基本符号,或最简符号(minimal symbols),只包含了链接器需要的名称。这些基本符号只是一些带地址的字符串,在这一层次下,我们假定文本节(text section)中的地址都是函数,而数据节(data section)中的都是数据,依此类推。 第二个层次针对详细的符号信息,通常这种符号信息拥有与可执行文件不同的格式。例如,DWARF调试格式中的信息存储在ELF文件中单独命名的节(section)内。而Berkeley Unix系统中用到的旧的 阅读符号信息的代码非常无聊,因为不同的符号格式都需要将源码中的每个类型信息进行编码,而每一种符号格式的编码方式又都不一样。GDB的文件阅读器的工作就是扫描符号格式,将其转化为原来的形式。 不完全符号表 对于较大规模的程序(如Emacs或Firefox),建立符号表是比较费时的,有可能会达到几分钟的时间。实践表明文件加载时间倒不是主要的,主要是瓶颈在于内存中GDB符号的构造。一个程序中往往存在着上百万个小对象相互联系着,处理起来时间开销非常大。 大部分符号信息在一个GDB会话中从来不会用到,因为它们来自于函数的局部作用域。所以,GDB第一次导入程序的符号时,它先扫描一下符号信息,只把全局可见的符号存进符号表。当用户在某个函数内暂停运行时,这个函数的完整的符号信息才会动态加载进来。 在GDB中,不完全符号表使得大程序也能在数秒内启动。(动态链接库的符号也会动态加载,但过程完全不同。当动态链接库被加载时, 平台会通知GDB建立一个符号表,符号表中存储了动态链接地址对应的那些函数。这个过程取决于特定平台的消息机制,不同的平台会有所不同。) 语言支持 对源码语言的支持主要包括表达式解析和值的打印。表达式解析由语言自身负责,但一般来说表达式解析器是一个基于Yacc语法的词法分析器。为了让GDB在用户交互操作时具有更大的灵活性,解析器不需要对语法有严格的要求。比如,如果用户能合理地猜出来表达式的类型,那他就不需要显式地做类型转换。 GDB表达式解析器不需要考虑变量声明和类型声明,比完整的语言解析器要简单得多。类似的,值的打印,也只有考虑一部分类型的值,甚至还可以由特定语言的函数来实现。 4.8. 目标端 目标端的功能是操纵程序的执行和处理底层原始数据。从某种意义上讲,目标端是一个完全低层次的调试器。如果只是逐个指令调试并打印原始内存,用户根本就不需符号信息。(如果程序刚好在一个被剥离符号的软件库中暂停,你也只能使用这种模式。) 目标向量和目标向量栈 最初,GDB的目标端由一些特定平台上的文件组成,用于处理ptrace的调用,启动可执行文件等等。但这对于长时间运行的GDB会话来说是不够灵活的,因为用户可能会中途变化调试目标或方式,比如从本地调试切换到远程调试,从调试core文件切换到调试运行的程序,从附加(attach)线程变为分离(detach),等等。1990年,John Gilmore重新设计了GDB的目标端,使用目标向量来流水处理特定目标的操作。目标向量主要是由一类定义了目标系统特性的对象,每个目标向量是多个函数指针(通常称为"方法")构成的结构体,这些方法的功能包括读写寄存器内存,恢复程序运行,设置处理共享库时的参数。GDB中大概有40多个目标向量,包括有名的针对Linux的目标向量,以及不那么出名的操纵Xilinx MicroBlaze的目标向量。对Core dump的支持使用了一个从corefile中获取数据的目标向量,对应的,还有从可执行文件中获取数据的目标向量。 通常将几个目标向量混合使用比较有利。以在Unix上打印一个已初始化的全局变量为例,在程序开始运行之前,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行。 新引入的结构称为 为了比较其差异,我们来看一下"将x86平台下long doubles类型的大小定义为96"在新旧方式下分别是如何实现的:
运行控制 GDB的核心是运行控制循环, 前面描述单步执行一行代码时提到过这个名词: 用一个简单的循环,来判断指令是否运行到了下一行源代码。这个循环称为 从概念上看, wfi位于主程序命令循环内部, 并且只有在程序恢复执行时才会进入wfi循环。当用户提交 所有这些活动都由 这个庞大的循环在异步处理时也是有问题的。 因为,在调试多线程程序时, 用户需要在程序其它部分保持运行的同时调试某一个线程。 GDB从wfi转变为事件驱动模型花费了数年的时间。1999年,我将 远程协议 虽然GDB的目标向量体系允许在不同计算机上以多种方式来控制程序的运行, 但是我们倾向于使用单一的协议。这个协议并没有一个独立而准确的名称,它使用过的名称包括"远程协议(remote protocol)","GDB远程协议", "远程串行协议(Remote serial protocal, 简写为RSP)","远程C协议(用实现语言命名)",或"桩协议(stub protocol)",其实都是指目标系统对这个协议的实现。 基本的协议比较简单,主要面向19世纪80年代的小型嵌入式系统,其内存不过几千字节。GDB向所有的寄存器发出协议数据包 协议假定连接是可靠的,且每个发出去的数据包都能得到应答,在发包时只是加上一个检验和数字( 远程协议中必要的数据包类型并不多(对应于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之类的系统的调试是非常方便。 图4.2: GDBserver GDB和GDBServer共享相同的代码,虽然大家都知道要将平台依赖的控制代码封装起来,但是实际中GDB的这个迁移工作进展缓慢,因为将本地GDB中的依赖关系分离开来是比较困难的。 4.9. GDB界面 GDB本质上是一个命令行调试器。人们始终没有放弃尝试将其发展为一个图形窗口调试器,但是即使投入了大量的时间和努力,至今也没有一个得到广泛接受的方案。 命令行界面 命令行接口使用了标准的GNU软件库 GDB接收 机器界面 一种GUI调试器方案是将GDB作为图形用户界面程序的后端,将鼠标点击翻译成GDB命令,然后将打印的结果显示在窗口中。这种方案已经在一些软件中实现,比如KDbg和DDD(Data Display Debugger)。但这个方法仍然不理想,因为有时候显示结果时为了可读性会省略掉一些细节,前端提供上下文的能力也会影响到结果的显示。 为解决这个问题,GDB提供了一个被称为机器界面(Machine Interface,MI)的接口。本质上MI仍然是一个命令行界面,但是命令和结果都增加了额外的语法,使得其意义更为显然:每个参数都使用了引号,复杂输出则使用定界符来分组,使用参数名来分块。此外, MI的命令还可以加上顺序标识符作为前缀, 并在结果中返回,保证了结果和命令的匹配。 为了比较两种界面, 分别给出它们对于同一命令的使用情况。下面是正常的step命令及GDB的响应
下面是MI的输入和输出,虽然显得有些冗余,但更加精确,便于第三方软件进行解析。
Eclipse[ecl12]开发环境是最著名的使用MI的调试环境。 其它用户界面 其它GDB前端软件包括基于tcl/tk的GDBtk或Insight,基于文字界面的TUI(最初由Hewlett-Packard开发)。GDBtk是一个传统的多面板图形用户界面,使用tk软件库开发,而TUI是一个在终端中使用的分屏文字界面。 4.10. 开发过程 维护者 作为一个GNU程序,GDB的开发遵循"大教堂(cathedral)"开发模型。GDB最初由Stallman编写,随后维护者几易其人,每个人都是身兼设计师,补丁审查员,发布管理员数职,他们有权访问仅向少数Cygnus雇员开放的源码仓库。 1999年,GDB被迁移到一个公共源码仓库,维护团队也扩展到了几十人,并且还有一些拥有签入(commit)权限的个人从旁协助。这个模式显著加速了GDB的开发,从原来的每周10个签入增加到了100个以上。 测试,测试 由于GDB高度依赖于特定平台,几乎涵盖全系列的计算设备,而且包含了数以百计的命令,选项以及使用风格,即使是一个经验丰富的GDB黑客也难以完全预料一个修改所产生的后果。 于是,测试套件变得举足轻重。GDB的测试套件包含了众多测试程序以及 这个测试套件还能进行交叉调试,既支持真实硬件也支持模拟器,它还能对于特定平台或配置进行测试。 到2011年底,GDB测试套件包含了大约18000个测试用例,包括了基本功能测试,语言特性测试,体系特性测试,和MI测试。所有这些测试都是通用的,适用于所有配置。GDB需要志愿者来测试打补丁后的源码,新的功能也需要新的测试。但是,因为没有人能在所有平台上测试同一修改,要实现测试的完全通过是不现实的。对于本地调试来说, 主干GDB测试时失败10-20次左右是可以授受的, 嵌入式系统则更容易出错。 4.11. 经验教训 开放是王道 GDB是"大教堂"开发模型的典范,在该模式下,维护者严密控制源码,而外部用户则跟踪其进度。补丁提交数目较少,封闭的开发过程实际上并不鼓励补丁。自从采用开放模式之后,补丁数量显著增多,而软件质量则一如既往,甚至更好。 制订计划, 但计划赶不上变化 开源软件开发过程实际上会比较混乱,因为开发者之间是松散的,流动性很大。 但是,制订开发计划并发布仍然很有意义。这有助于指导开发者完成相关任务,而且能够吸引潜在的赞助者,另外志愿者在尝试做出贡献时也能有一定的依据。 但是不要尝试设置截止时间,即使是每个人都热情地朝着一个方向努力,也不要指望大家都能全身心地投入并按时完成任务。 鉴于此,不要坚持一个已经过时的计划。长期以来,GDB都有重构为软件库 无比聪明该多好 看到曾经提交的修改,我们也许会想:为什么一开始不这么做呢? 唉,只因为我们不够聪明。 我们本可以预料到GDB会如此流行,并且会移植到数以百计的平台上,还支持本地和交叉调试。如果事先知道这些,说不定一开始就会使用 我们本可以预料到GDB将会被用到GUI中, 毕竟1986年Mac和X窗口系统已经出现了2年。与其设计一个传统的命令行界面,我们更应该让其支持异步事件处理。 然而,真正的教训不在于GDB开发者们有多蠢,而是我们不可能如此聪明地未卜先知。1986年, 窗口-鼠标风格的界面的未来还并不清晰, 我们预料不到它会像今天这样流行,如果第一个版本的GDB就设计为在GUI下使用,我们就可以称得上天才了,但这种好运不是人人都能有的。相反,在一个有限的范围内让GDB有所作为,我们已经为今后的扩展和重构打下了用户基础。 学会接受缺陷 尽力完成过渡,但是时间总是太快,你只能接受缺陷。 在2003年的GCC峰会上, Zack Weinberg哀叹GCC的"不完整过渡",新的底层结构已经引入,但是旧的却尾大不掉。GDB有着同样的问题,但是我们应该看到积极的一面,因为毕竟一些过渡已经完成,比如目标向量, 谨防着迷于代码 当你遇到一个对你非常重要的项目,你会花费大量时间在单个代码上, 你会很容易沉迷其中,甚至为了迎合代码而改变自己的想法。但是,很有可能你已经误入歧途,退一步说不定海阔天空。 这样的事情要杜绝发生。 所有代码都源自于一系列清醒的判断:有些来自灵感,有些则不是。1991年节省空间的小伎俩对于2011年的数个G的内存来说是毫无意义的。 GDB曾经支持Gould超级计算机。当他们在2000年关闭最后一台机器时,保留对这种机器的支持已是毫无意义。那些代码只是GDB过往历史中的一些小小篇章,然而现在大部分的发行版中仍然有些"怀旧"。 事实上,很多激进的修改已经摆上日程或已经开展,包括对Python脚本的支持,对并行多核平台的支持,重编码为C++等。这些修改可能要花费数年,但其动机却来自于今天(等到它们完成时说不定已经过时)。 |
|