技术改变命运
分类: LINUX
2016-07-31 22:02:52
原文链接:http://blog.csdn.net/wuhui_gdnt/article/details/7283483/
如果我们可以编写确保能正确工作且永远不需要调试的程序,这将非常美妙。在梦想成真之前,通常的编程周期还将是包括:编写一个程序,编译它,执行它,然后可怕的、灾难般的调试。然后重复以上步骤,直到程序如期工作。
通过插入打印各种感兴趣变量的值的代码来调试程序是可能的。事实上,在某些情形里,比如调试内核驱动,这可能是最好的方法。存在有允许你一条接一条指令单步执行可执行程序,并以二进制显示寄存器及内存的低级调试器。
但使用一个源代码级的调试器要容易得多,它允许你单步执行一个程序的源代码,设置断点,打印变量值,并且可能有其它一些功能,比如在调试器里允许你调用你程序中的一个函数。问题是如何协调两个完全不同的程序,编译器及调试器,使程序可以被调试。
把一个程序从人类可读的形式编译到一个处理器执行的二进制形式的过程是相当复杂的,但它本质上包括了持续地把源代码转换到越来越简单的形式,在每一步丢弃信息,直到最后产生包含简单操作、寄存器、内存地址,及处理器真正理解的二进制值的序列。最终,处理器实际上不在乎你是否使用面向对象编程、模板,还是智能指针;它仅能理解,在有限寄存器及包含二进制值的内存位置上的,非常简单的一组操作。
在一个编译器读入并解析一个程序的源代码时,它收集关于这个程序的各种信息,比如声明或使用一个变量或函数的行的行号。语义分析扩展这个信息的细节,比如变量及函数实参的类型。优化可能到处移动这个程序的部分、合并类似的片段、展开内联函数,或移除不需要的部分。最终,代码生成(code generation)接受该程序的这个内部表达形式,产生实际的机器指令。通常,在这个机器代码上有另一个遍(pass)执行被称为“窥孔(peephole)”的优化,它可能进一步重排或修改代码,例如,消除重复指令。
总而言之。编译器的任务是接受精心制作的、可理解的源代码。把它转换为高效但本质上难以理解的机器语言。构建紧凑、快速的代码这个目标,编译器实现得越好,结果越有可能难以理解。
在这个翻译过程中,编译器收集关于这个程序的信息,在后面调试这个程序时,这些信息将是有用的。要做好这点有两个挑战。第一个是,在这个处理的后半部分,编译器要把对该程序做出的修改关联到程序员写的原始源代码,可能很困难。例如,窥孔优化可能移除了一条指令,因为在由一个C++模板具现(instantiation)中的一个内联函数产生的代码里,它可以切换一个测试的次序。在优化了这个程序后,优化器要把它处理的低级代码与原始的源代码联系起来,就相当困难了。
第二个挑战是如何足够详细地描述可执行程序,以及它与源原始代码的关系,使得一个调试器可以向程序员提供有用的信息。同时,描述必须足够简洁,这样它不会占据大量的空间,或要求可观的处理器时间来解释。这正是DWARF调试格式要解决的:它是可执行程序与源代码关系的一个紧凑的表示,以一个对于调试器的处理合理有效的方式。
当一个程序员在一个调试器下运行一个程序时,他或她可能希望进行某些通用的操作。这些中最常用的是设置一个断点,通过指定行号或一个函数名,在源代码的一个特定地点停止调试器。当击中这个断点时,程序员通常会显示局部或全局变量的值,或者函数的实参。在有多个执行路径的情形下,显示调用栈使得程序员了解程序如何到达断点。在检查了这个信息后,程序员可以要求调试器继续执行测试的程序。
在调试中有另外一些有用的操作。例如,一行一行地单步通过一个程序,进入或跳过调用的函数,可能是有帮助的。在一个模板或内联函数的每个实例上设置一个断点,对于C++程序调试是重要的。在一个函数结束前停止是有用的,这样可以显示或改变返回值。有时,程序员可能希望绕过一个函数的执行,返回一个已知的值,而不是这个函数将(可能不正确地)算出的值。
仍有有用的、与操作相关的数据。例如,显示一个变量的类型可以避免在源文件中查看这个类型。以不同的格式显示一个变量的值,或者以一个指定的格式显示一个内存或寄存器,也是有帮助的。
有某些操作可以被称为先进的调试功能:例如,为了可以调试多线程程序,或保存在只写内存的程序,可能希望一个调试器(或其它某些程序分析工具)记录代码的特定节(section)是否已经执行。某些调试器允许程序员在正在测试的程序里调用函数。在不远的过去,调试被优化的程序被视为一个先进的特性。
一个调试器的任务是尽可能以自然、可理解的方式,向程序员提供执行程序的一个概观,同时允许对其执行进行多样各种不同的控制。这意味着在本质上,调试器必须逆向许多编译器精心制作的变换,把程序的数据及状态转换回,这个程序源代码里程序员原来使用的措辞(terms)。
对一个调试数据格式,像DWARF,的挑战是,使得这成为可能,甚至容易。
有几种调试格式:stabs,COFF,PECOFF,OMF,IEEE695,及3个版本的DWARF,它们是一些常用的格式。我不准备深入描述这些格式。这里的目的仅是提到它们,以引入DWARF调试格式。
Stabs的名字来自symbol table strings(符号表字符串),因为一开始,在Unix的a.out目标文件的符号表里,调试数据保存为字符串。Stabs以文本字符串编码一个程序的信息。起初相当简单,随着时间的推移,stabs演进为一个相当复杂,偶尔令人困惑及很少一致的(less-than-consistent)调试格式。Stabs不是标准化的,也没有很好的文档[1]。SunMicrosystems对stabs做了若干扩展。GCC做了其它的扩展,同时尝试逆向设计(reverse engineer)Sun的扩展。不管怎么说,stabs仍然在广泛使用。
COFF代表CommonObject File Format(通用目标文件格式),肇始于Unix System V Release 3。COFF格式定义了基本的调试信息,但因为COFF包括了对具名节(named section)的支持,各种各样不同的调试格式,比如stabs,已经用于COFF。COFF最重要的问题是,虽然它的名字中有通用(Common)字样,在每个使用这个格式的架构上,它是不相同的。COFF有许多变种,包括XCOFF(用在IBM RS/6000上),ECOFF(用在MIPS及Alpha上),以及Windows PECOFF。这些变种的文档在不同程度上可以获得,但目标模块的格式及调试信息都不是标准化的。
PECOFF是MicrosoftWindows从Windows 95开始使用的目标模块格式。它基于COFF格式,包含COFF调试数据,以及Microsoft自己私有的CodeView或CV4调试数据格式。关于这个调试格式的文档粗浅而且难以获取。
OMF代表ObjectModule Format(目标模块格式),它是在CP/M,DOS及OS/2系统,以及少数嵌入式系统使用的目标文件格式。OMF为调试器定义了公共名及行号信息,也可以包含MicrosoftCV,IBM PM,AIX格式的调试数据。OMF仅为调试器提供最基本的支持。
IEEE695是MicrotecResearch及HP在1980年代后期,为嵌入式环境联合开发的一个标准的目标文件及调试格式。在1990年,它成为一个IEEE标准。它是一个非常灵活的规范,目标是可用于几乎所有的机器架构。调试格式是块结构的,比其它格式更好地对应于源代码的构造。虽然它是一个IEEE标准,在许多方面,IEEE695更像私有的格式。虽然原始的标准能容易地从IEEE获取,Microtec制作了若干扩展以支持C++及优化代码,对此很少文档记载。从来没有修改这个IEEE标准来包括任何Microtec做出的、及其它的改变。
DWARF起源于Bell实验室在1980年代中期开发的Unix System V Release 4 (SVR4)中的C编译器及sdb调试器。编程语言特别兴趣小组(The Programming Languages Special Interest Group——PLSIG),UnixInternational(UI)的一部分,在1989年把由SVR4产生的DWARF记载为DWARF1。虽然原始的DWARF有几个明显的缺点,但最显著的是它不那么紧凑,PLSIG决定仅以最低限度的修改来标准化SVR4格式。它被明智地在嵌入节(embeddedsector)里采用了,时至今日它仍然在使用,特别是对于小的处理器。
PLSIG继续开发解决DWARF一些问题的扩展,并制作文档,其中最重要的是减少数据产生的数量。并添加了对新语言的支持,比如前途远大的C++。在1990年,DWARF2作为一个标准草案发布。
作为多米诺理论的现实例子,在PLSIG发布了标准草案后不久,在Motorola的88000微处理器里发现了致命的缺陷。Motorola终止了这个处理器,结果导致了Open88的消亡,一个使用88000开发计算机的公司联合体。反过来,Open88是Unix International的一个支持者,PLSIG的一个赞助商,这导致UI的解散。在UI结束时,PLSIG所留下只是一个邮件列表,及保存了DWARF 2标准草案各种版本的各个ftp站点。一个最终标准从来没发布。
因为Unix International的消失及PLSIG的解散,几个组织各自决定扩展DWARF 1及2。某些这些扩展特定于一个架构,但其它可能适用于任何架构。不幸,在这些扩展上,不同的组织没有一起工作。这些扩展的文档通常是零散的或者难以获取。或者作为一个GCC开发者可能提出的,追根溯源(tongue firmlyin cheek),这些扩展有良好的文档:你所要做的就是阅读编译器的源代码。DWARF遵循在COFF方面是良好的,并且正在成为分歧实现(divergent implementations)的一个集合,而不是成为一个工业标准。
尽管在PLSIG邮件列表(在UI解散后,它在X/Open(后来的开放组)下生存下来)上,有关于DWARF的几个在线讨论,直到1999年末,少有动力来修改(甚至完成)这个文档。在那个时候,在扩展DWARF为HP/Intel IA64架构提供更好的支持,以及更好地归档由C++程序使用的ABI方面,令人感兴趣。这两部分工作被分开了,作者接任为百劫余生的DWARF委员会主席。
在长达18个月的开发工作及DWARF 3一个规范草案的创建之后,标准化击中了一个可以被称为软补丁(a soft patch)的东西。该委员会(特别的,本作者)希望确保这个DWARF标准容易获取,并确保避免由这个标准的多个来源所导致的分歧。在2003年,DWARF委员会成为自由标准组织(Free Standards Group)里的DWARF工作组。在2005年早期,DWARF 3标准的积极开发及说明重新开始,目标是解决在这个标准里所有已知的问题。在10月,发布一个公开的送审稿以征求公众意见,而DWARF3标准的最终版本在2006年1月发布。在自由标准组织与开源代码开发实验室(Open Source Development Labs——OSDL)合并为Linux基金会后,DWARF委员会回到独立的状态,并创建了自己的网站。
大多数现代编程语言是块结构的:每个实体(例如,一个类定义或一个函数)被包含在另一个实体中。在一个C程序里,每个文件可能包含多个数据定义、多个变量定义,及多个函数。在每个C函数里,可能有几个数据定义,后跟可执行语句。一个语句进而可以是包含数据定义及可执行语句的复合语句。这构成了词法作用域(lexical scopes),其中名字仅在定义它们的作用域中已知。为了在一个程序里找出一个特定符号的定义,你首先在当前作用域中查找,然后在依次的封装作用域里,直到找到这个符号。在不同作用域中,相同的名字可能有多个定义。编译器非常自然地在内部把一个程序表示为一棵树。
DWARF遵循这个模型,因为它也是块结构的。在DWARF里每个描述性的实体(entity)(除了最顶层描述源文件的项(entry))被包含在一个父项(parententry)中,并且可能包含子实体(children entities)。如果一个节点包含多个实体,它们都是相互关联的兄弟。一个程序的DWARF描述是一个数结构,它类似于编译器的内部树,其中每个节点可以具有孩子或兄弟。这些节点可能表示类型,变量,或函数。这是一个紧凑的格式,它仅提供描述一个程序某一方面所需要的信息。这个格式可以一个统一的形式扩展,这样一个调试器可以识别并忽略一个扩展,即使它可能不能理解其含义。(这比其它大多数调试格式,在尝试读入修改后的数据时,调试器不可避免地被搞糊涂了的情形,要好得多)。DWARF也被设计为可扩展地描述几乎任何任何机器架构上的过程编程语言,而不是仅限于在有限范围架构上的,描述一个语言或一个语言的一个版本。
虽然DWARF最常见与ELF目标文件格式关联,它并不依赖于这个目标文件格式。它可以并且已经用于其它目标文件格式。所需要的一切是,在这个目标文件或可执行文件里,构成DWARF数据的不同数据节是可识别的。DWARF不会复制包含在这个目标文件里的信息,比如标记处理器架构,或者文件是bigendian,还是littleendian格式。
在DWARF里基本的描述项是调试信息项(DebuggingInformation Entry——DIE)。一个DIE有一个标签,它指明了这个DIE描述什么及一个填入了细节并进一步描述该项的属性列表。一个DIE(除了最顶层的)被一个父DIE包含(或者说拥有),并可能有兄弟DIE或子DIE。属性可能包含各种值:常量(比如一个函数名),变量(比如一个函数的起始地址),或对另一个DIE的引用(比如一个函数的返回值类型)。
图1显示了C的经典hello.c程序的DWARF描述的一个简化的图形化表示。最顶层的DIE表示编译单元。它有两个“孩子”,第一个是描述main的DIE,而第二个描述了基础类型int,这是main返回值的类型。子程序(subprogram)DIE是编译单元(compilation unit)DIE的一个孩子,而基础类型(base type)DIE被子程序DIE中的类型属性引用。我们还会谈及一个DIE“拥有”或“包含”子DIE。
[1] 在1992年,作者写了一篇全面的,描述了由Sun Microsytems的编译器产生的stabs的文档。很可惜,它没有广泛地传播开来。
[2] 名字DWARF某种程度是一个双关语,因为它与ELF目标文件格式一同开发。这个名字可能是“Debugging With Attributed RecordFormats(使用属性化记录格式调试)”的首字母缩写词,输入在在任何DWARF标准中都没有提到。
图1:DWARF数据的图形化表示
DIE可以被分为两个一般的类型。即描述数据,包括数据类型;及描述函数及其他可执行代码。
大多数编程语言具有复杂的数据描述。存在若干内置数据类型、指针、各种数据结构,及通常构建新的数据类型的方法。因为DWARF的目的是用于各种各样的语言,它抽象出了基本的概念,并提供了可以用于所有所支持语言的一个表示法。主要的类型是直接构建在硬件上的基本类型。其它数据类型构造为这些基本类型的集合(collections)或合成(compositions).
每个编程语言定义了几个基本的标量(scalar)数据类型。例如,C和Java定义了int及double。但Java提供了这些类型的一个完整的定义,C仅指定了某些通用的特性,允许编译器选择最适合目标处理器的实际规格。某些语言:像Pascal,允许定义新的基本类型,例如,一个可以保存0到100之间值的整数类型。Pascal没有指定这如何实现,一个编译器可能把这实现为单个字节,另一个可能使用一个16比特整数,第三个可能把所有整数类型实现为32比特值,不管怎么定义它们。
使用DWARF版本1及其他调试格式,编译器及调试器被假定共享关于一个int是16,32或甚至64比特的一个共同的理解。当相同的硬件可以支持不同大小的整数,或不同的编译器对相同的目标处理器有不同的实现决定时,这变得不合适。这些假定,通常没有文档记录,使得在不同编译器或调试器,甚至同一个工具不同的版本间很难兼容。
在简单数据类型与它们如何在目标机器硬件上实现之间,DWARF基本类型提供了最低级的映射。对于java及C,这明确了int的定义,并且甚至允许在同一个程序里使用不同的定义。图2a显示了描述在一个典型32位处理器上int的DIE。属性指明了名字(int)、一个编码(有符号二进制整数),及字节数(4)。图2b显示了在16位处理器上的int一个类似定义。(在图2里,我们使用了在DWARF标准中定义的标签及属性名,而不是上面使用的更随便的名字。标签的名字带有前缀DW_TAG,而属性的名字带有前缀DW_AT)。
图2a. 在32位处理器上的int基本类型
图2b. 在16位处理器上的int基本类型
基本类型允许编译器描述几乎任何一个编程语言标量类型与其在处理器上实际实现间的映射。图3描述了一个保存在4字节字高16位的16比特整数值。在这个基本类型里,有一个指明这个值是16比特宽,且到高位比特的偏移是0的比特大小属性。(这是一个真实生活的例子,取自Pascal一个在栈上在一个字的高半部分传递16比特整数的实现)。
图3. 保存在一个32位字高16位的16比特字类型
DWARF基本类型允许描述若干不同的编码,包括地址、字符、定点(fixed point)、浮点,及组合十进制(packeddecimal),除了二进制整数之外。但仍然有一点二义性:例如,没有指定一个浮点数的实际编码;这由硬件实际支持的编码来确定。在一个遵循IEEE754标准,支持32位及64位浮点值的处理器里,依赖于值的大小,由“float”表示的编码是不同的。
一个指定(named)变量由一个DIE描述,它有各种属性,其中之一是对一个类型定义的引用。图4描述了名为x的int变量。(目前,我们将忽略通常包含在一个描述一个变量的DIE中的其它信息)。
图4. “int x”的DWARF描述
int的基本类型把int描述为一个占4字节的有符号二进制整数。x的DW_TAG_variable给出了它的名字及一个类型属性,这个属性援引基本类型DIE。为了清晰起见,在这个及后面的例子里,DIE被依次标记;在真实的DWARF数据里,一个DIE的引用是到这个DIE所在编译单元起始的偏移。这些引用可以援引先定义的DIE,就像在图4中那样,或后面定义的DIE。一旦我们为int构建了一个基本类型DIE,在相同编译单元中的任何变量可以引用这个DIE[1].
图5. “int *px”的DWARF描述
图6. “const char **argv” 的DWARF描述数组类型由一个DIE描述,它定义了该数据是以列为主序(就像在Fortan里),还是以行为主序(就像在C或C++里)。该数组的索引由一个subrange类型表示,这个类型给出了每个维度的上下限。这允许DWARF描述C形式的、总是以0作为最小索引的数组;以及在Pascal或Ada中,可以任意值作为上下限的数组。
大多数语言允许程序员把数据集中到结构体(在C及C++里称为struct,在Pascal里称为record)。这个结构体的每个部分通常具有一个唯一的名字,可能具有不同的类型,并且每个都有自己的空间。C及C++拥具有union,而Pascal拥具有可变(variant)record,它们类似于一个结构体,但各部分占据相同的内存位置。Java接口具有一个C++class特性的一个子集, 因为它可能仅有抽象方法及常量数据成员。
虽然每个语言有自己的术语(C++把这些部分称为一个类成员,而Pascal称它们为域(field)),底层的构造可以描述在DWARF里。尊重其传统,DWARF使用C/C++术语,同时具有描述struct,union,class及interface的DIE。在这里我们将描述类(class)DIE,但其它DIE在本质上具有相同的构造。
用于一个类的DIE是描述这个类每个成员的DIE的父亲。每个类有一个名字,还可能有其它属性。如果在编译时刻一个实例的大小是已知的,那么它将具有一个字节大小属性。每个这些描述看起来非常类似一个简单变量的描述,虽然可能有某些额外的属性。例如, C++允许程序员指定一个成员是public,private,还是protected。这通过可访问性(accessibility)属性来描述。
C及C++允许不是简单变量的比特域(bit field)作为一个类成员。它们被描述为从这个类实例的开头到这个比特域最左侧比特的偏移,以及显示这个成员占据多少比特的一个比特大小(bit size)。
变量通常都相当简单。它们有一个名字,代表一块可以包含某个类型的一个值的内存(或寄存器)。这个变量可以包含的值的类型,以及修改的限制(即,它是否是常量),都由该变量的类型来描述。
区分变量的是:该变量保存在何处,及其作用域。一个变量的作用域定义了在这个程序的何处这个变量是已知的,并且在某种程度上,它由该变量在何处声明确定。在C中,在一个函数或块中声明的变量具有函数或块作用域。那些声明在一个函数外的变量具有全局或文件作用域。这允许在不同的文件中定义具有相同名字的、不同的变量,而不引起冲突。这也允许不同的函数或编译单元引用相同的变量。DWARF使用一个(file, line,column)三元组记录变量被定义在源文件中的何处。
DWARF把变量分为3个类别:常量,函数参数,及变量。一个常量用于描述具有真正命名常量(true named constants)的语言,比如Ada参数。(C没有把常量用作语言部分。声明一个变量const仅是告诉你,不使用一个显式的转换,你不能修改这个变量)。一个正式的参数代表传递给一个函数的值。稍后我们将回到这个话题。
某些语言,像C或C++(但不包括Pascal),允许声明一个变量而不定义它。这暗示在别的地方应该有该变量的一个真正的定义,在编译器或调试器可望找到的地方。一个描述一个变量声明的DIE提供了该变量的一个描述,但没有告诉调试器它在哪里。
大多数变量具有一个描述该变量保存在哪里的位置属性。在最简单的情形里,一个变量保存在内存中,并具有一个固定的地址[1]。但许多变量是被动态分配的,比如那些声明在一个C函数内的,并且定位它们要求某些(通常简单的)计算。例如,一个局部变量可能被分配在栈上,定位它可能只是向一个框指针(frame pointer)加上一个固定的偏移那么简单。在其它情形里,这个变量可能保存在一个寄存器中。其它变量可能要求稍微复杂的计算来定位数据。作为一个C++类成员的一个变量可能要求更加复杂的计算,来确定在一个派生类中基类的位置。
DWARF提供了一个非常通用的方案来描述如何定位由一个变量代表的数据。一个DWARF位置表达式包含了告诉一个调试器如何定位该数据的一连串操作。图7显示了3个名为a,b及c的变量的DIE。变量a在内存里有一个固定的位置,变量b在寄存器0里,而变量c在当前函数栈框内偏移–12处。虽然a被首先声明,描述它的DIE是在所有的函数之后产生的。a的实际地址将由链接器填入。
[1] 此外,可能不是一个固定地址,但是到这个可执行代码载入地址的一个固定偏移。载入器重定位(relocate)了对一个可执行映像中地址的引用,这样,在运行时,位置属性包含实际的内存地址。在一个目标文件里,位置属性是这个偏移连同一个合适的重定位表项(relocation table entry)。
图7. 变量a,b及c的DWARF描述
DWARF位置表达式可以包含由一个简单栈机器(stack machine)求值的一连串操作及值。这可以是一个任意复杂的计算,包含种类繁多的算术操作、在该表达式内的测试及跳转、对其它位置表达式的调用求值,以及访问处理器的内存或寄存器。甚至有操作用于描述分裂并保存在不同位置的数据,比如一个结构体,其中某些数据保存在内存里,而某些则保存在寄存器中。
虽然这巨大的灵活性实际上很少使用,位置表达式应该允许描述一个变量数据的位置,不管这个语言的定义如何的复杂,或这个编译器的优化如何的聪明。
DWARF把返回值的函数及不返回值的子例程处理作同一个事物的不同变体。稍微偏离其肇始的C的术语,DWARF使用一个Subprogram DIE描述两者。这个DIE有一个名字、一个源位置三原体(triplet),及一个表示这个子程序是否是外部的属性,即,在当前编译单元外可见。
一个Subprogram DIE具有的属性给出了这个子程序占据的上下限内存地址,如果子程序它是连续的;或者一个内存范围列表,如果该函数没有占据一组连续的内存地址。低的PC地址被假定为这个例程的入口,除非显式地指定了另一个。
一个函数返回的值由类型属性给出。不返回值的例程(像C的void函数)没有这个属性。DWARF不描述一个函数调用的约定;这定义在特定架构的应用二进制接口(Application Binary Interface——ABI)中。可能存在能帮助一个调试器定位该字程序数据,或找出当前子程序调用者的属性。返回地址属性是一个指明该调用者保存地址的位置表达式。框基址(frame base)属性是一个计算该函数栈框地址的位置表达式。这些是有用的,因为某些编译器有可能执行的、最常用的优化是:消除显式保存返回地址或框指针(frame pointer)的指令。
Subprogram DIE拥有描述这个子程序的DIE。由具有variable parameter属性的变量DIE来表示可能被传递给一个函数的参数。如果这个参数是可选的、或具有一个缺省值,这些都由属性来表示。这些参数DIE的次序与这个函数的实参列表相同,但中间可能插有额外的DIE,例如,定义由这些参数使用的类型。
一个函数可能定义了可以是局部或全局的变量。这些变量的DIE跟在参数DIE后面。许多语言允许嵌套词法块(lexical block)。这些由词法块DIE表示,它进而可能拥有变量DIE,或嵌套的词法块DIE。
这里是一个稍微长些的例子。图8a显示了strndup.c,GCC中复制一个字符串的函数的源代码。图8b列出了为这个文件产生的DWARF。就像在之前的例子中,没有显示源代码行信息及位置属性。
图8a. strndup.c源代码
在图8b里,DIE <2>显示了size_t的定义,它是unsigned int的一个typedef。这允许一个调试器把形参n的类型显示为一个size_t,而把其值显示为一个无符号整数。DIE <5>描述了函数strndup。它拥有到其兄弟DIE <10>的一个指针;接着的所有DIE都是这个Subprogram DIE的孩子。该函数返回一个描述在DIE<10>中的,指向char的指针。DIE <5>还把该子例程描述为外部的、有原型的函数,并给出了该例程的上下限PC值。该例程的形参及局部变量被描述在DIE<6>到<9>中。
图8b. strndup.c的DWARF描述
大多数有趣的程序包含多个文件。构成一个程序的每个源文件被独立编译,然后与系统库链接起来构成这个程序。DWARF把每个单独编译的源文件称为一个编译单元。
每个编译单元的DWARF数据以一个Compilation UnitDIE开始。这个DIE包含这个编译单元的通用信息,包括目录及源文件名、使用的编程语言、一个标识这个DWARF数据的产生者的字符串,以及到协助定位行号及宏信息的DWARF数据节的一个偏移。
如果该编译单元是连续的(即,它被载入一块内存中),那么有该单元内存上下限的值。这使得调试器更加容易识别哪个编译单元在一个特定内存地址构建代码。如果该编译单元不是连续的,那么由编译器及链接器提供一组该代码占据的内存地址。
Compilation Unit DIE是所有描述该编译单元的DIE的父亲。通常,开始的DIE(多个)将描述数据类型,跟着是全局数据,然后构成这个源文件的函数。用于变量及函数的DIE出现的次序与这些变量及函数在该源文件中出现的次序相同。
从概念上讲,描述一个程序的DWARF数据是一棵树。每个DIE可能有一个兄弟并且包含的若干DIE。每个这些DIE有一个类型(称为它的TAG)及若干属性。每个属性由一个属性类型及一个值表示。不幸的是,这不是一个非常紧凑的编码。没有压缩的话, DWARF数据是难以处理的。
DWARF版本2及3提供了几个方式来缩小这个需要与目标文件一起保存的数据。第一个是通过以前序(prefix order)保存“扁平化(flatten)”这棵树。DIE的每个类型被定义为要么有孩子,要么没有。如果DIE没有孩子,下一个DIE是其兄弟。如果DIE可以有孩子,那么下一个DIE是其第一个孩子。余下的孩子被表示为第一个孩子的兄弟。这样,到兄弟或子DIE的链接可以被消除。如果编译器的编写者认为,从一个DIE跳到其兄弟,而不需要逐个通过其子DIE,是有用的(例如,跳到在一个编译单元里的下一个函数),那么可以向这个DIE添加一个兄弟属性。
图9. 缩写项及编码形式
DWARF版本3允许从一个编译单元引用保存在另一个编译单元或共享库中的DWARF数据的特性较少使用。许多编译器为每个编译单元产生相同的缩略语表或基本类型,不管这个编译单元是否真正使用所有这些缩略语或类型。这些可以保存在一个共享库里,并由每个编译单元援引,而不是复制在每个编译单元里。
DWARF行表(linetable)包含了源代码行(用于一个程序的可执行部分)与包含对应机器代码的内存之间的映射。在最简单的形式中,这可以被看做一个矩阵,其中一列包含内存地址,而另一列包含源代码三元组(文件,行及列)。如果你希望在特定的一行上设置一个断点,这个表向你给出保存这个断点的内存地址。相反,如果你的程序在内存的某个位置上有一个缺陷(比如,使用一个坏的指针),你可以查看最接近这个内存地址的源代码行。
DWARF通过添加传送一个程序额外信息的列进行扩展。当一个编译器优化这个程序时,它可能移动或删除指令。一个给定源代码语句的代码可能没有保存为一个机器指令序列,而可能是分散的,并插入了附近其它语句的指令。识别代表一个函数prolog的结尾,或epilog的开始的代码是有用的。这样调试器可以在载入一个函数的所有实参之后,或在这个函数返回之前停止。某些处理器可以执行多个指令集,因此有另一个列表示在指定的机器位置保存了哪个集。
正如你可能想象的,如果这个表以每条机器指令一行来保存,它将是巨大的。DWARF通过把它编码为一个称作行号程序[1]的指令序列来压缩这个数据。这些指令由一个简单的有限状态机解释来重新构建完整的行号表。
这个有限状态机使用一组缺省值初始化。通过执行行号程序的一个或多个操作码产生这个行号表中的每行。通常这些操作码是相当简单的:例如,向机器地址或行号添加一个值,设置列号,或设置一个标记表示该内存地址代表一个源语句开始、函数prolog结束、或函数epilog开始。一组特别的操作码把最常用的操作(递增内存地址,及递增或递减源代码行号)合并入一个单操作码(a single opcode)。
最后,如果该行号表的一行有与前面的行相同的源代码三元组, 那么在行号程序中不为该行产生指令。图10列出了strndup.c的行号程序。注意仅保存了代表一个语句开始指令的机器地址。在这个代码中编译器不能识别基本块,函数prolog的结尾或epilog的开始。在行号程序中,这个表仅编码为31个字节。
图10. strndup.c的行号表
大多数调试器很难显示并调试带有宏的代码。用户查看原始的,带有这些宏的源文件,而代码对应这些宏产生的结果。
DWARF包括了定义在这个程序中的宏的描述。这是相当初级的信息,但可以被一个调试器用于显示一个宏的值,或有可能把这个宏翻译回源语言。
每个处理器有某种特定的方式调用函数以及传递实参,这通常定义在ABI里。在最简单的情形中,对于每个函数这都是相同的,并且调试器确切知道如何找到实参的值及函数的返回地址。
对于某些处理器,依赖于该函数如何写,可能有不同的调用序列,例如,如果实参数目多于一个特定的值,取决于操作系统,可能有不同的调用序列。编译器将尝试优化这个调用序列来使得代码既小又快。一个常用的优化是,当有一个简单的、不调用任何其它函数的函数(一个叶子函数)时,让它使用调用者的栈框,而不是构建它自己的。另一个优化可能是消除一个指向当前调用框的寄存器。在这个调用过程中,某些寄存器可能被保留,其它则不会。尽管让调试器推敲出调用序列或优化的所有可能的排列是可能的,但这既枯燥又容易出错。优化及调试器的一个小修改就可能不能在栈中移动到调用函数。
DWARF调用框信息(Call Frame Information——CFI)向调试器提供了足够的关于一个函数如何被调用的信息,因此它可以定位该函数的每个实参、定位当前调用框,以及定位调用函数的调用框。这个信息被调试器用来“回滚栈”,定位前一个函数、该函数被调用的位置,以及传递的值。
类似行号表,CFI被编码为一个将被解释产生一个表的指令序列。在这个表中,包含代码的每个地址对应一行。第一列包含机器地址,而随后的列包含在该地址指令执行时机器寄存器的值。类似于行号表,如果如果构建这个表,它将是巨大的。幸运的是,两个机器指令间的改变非常小,因此CFI编码相当紧凑。
虽然DWARF被定义为,允许它与任何目标文件格式一起使用,它最通常与ELF一起使用。每个不同类型的DWARF数据保存在它们自己的节里。所有这些节的名字都以".debug_"开始。为了提升效率,大多数DWARF数据的引用使用到该编译单元数据开头的一个偏移。这避免了重定位这个调试数据,可以加速程序的载入及调试。
ELF节及它们的内容是
.debug_abbrev 用在.debug_info节的缩写
.debug_aranges 内存地址与编译单元之间的一个映射
.debug_frame 调用框信息
.debug_info 包含DIE的核心DWARF数据
.debug_line 行号程序
.debug_loc 宏描述
.debug_macinfo 全局对象及函数的一个查找表
.debug_pubnames 全局对象及函数的一个查找表
.debug_pubtypes 全局类型的一个查找表
.debug_ranges DIE所援引的地址范围
.debug_str 由.debug_info使用的字符串表
现在你应该了解了——DWARF简明扼要的解释。嗯,也不是很简明扼要。DWARF调试信息的基本概念是简单的。一个程序被描述为一棵树,所带的节点以一个紧凑的语言及机器无关的方式表示源代码中的函数、数据及类型。行表提供了可执行指令与产生它们的源代码之间的映射。CFI描述了如何回滚栈。
同样,在DWARF中也有相当多微妙的地方,考虑到要为大范围的程序语言及不同的机器架构表达许多不同的微细差别。DWARF未来的方向是提高对优化代码的描述,这样调试器可以更好地在由先进的编译器优化产生的代码中行进。
完整的DWARF版本3标准在DWARF网站可以免费下载(dwarf.freestandards.org)。还有一个用于DWARF相关问题及讨论的邮件列表。在网站上还有注册这个邮件列表的指引。
我想感谢Sun Microsystems的ChrisQuenelle,HP前雇员Ron Brender,感谢他们关于这篇文章的意见与建议。同样感谢Susan Heimlich,她给出了很多编辑的建议。
使用gcc产生DWARF非常简单。只要指定–g选项产生调试信息。可以使用带有-h选项的objump来显示ELF节。
$ gcc –g –c strndup.c
$ objdump –h strndup.o
strndup.o: file format elf32-i386
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 0000007b 00000000 00000000 00000034 2**2
CONTENTS,ALLOC, LOAD, RELOC, READONLY, CODE
1 .data 00000000 00000000 00000000 000000b0 2**2
CONTENTS, ALLOC, LOAD, DATA
2 .bss 00000000 00000000 00000000 000000b0 2**2
ALLOC
3 .debug_abbrev 00000073 00000000 00000000 000000b0 2**0
CONTENTS, READONLY, DEBUGGING
4 .debug_info 00000118 00000000 00000000 00000123 2**0
CONTENTS,RELOC, READONLY, DEBUGGING
5 .debug_line 00000080 00000000 00000000 0000023b 2**0
CONTENTS, RELOC, READONLY, DEBUGGING
6 .debug_frame 00000034 00000000 00000000 000002bc 2**2
CONTENTS, RELOC, READONLY, DEBUGGING
7 .debug_loc 0000002c 00000000 00000000 000002f0 2**0
CONTENTS,READONLY, DEBUGGING
8 .debug_pubnames 0000001e 00000000 00000000 0000031c 2**0
CONTENTS,RELOC, READONLY, DEBUGGING
9 .debug_aranges 00000020 00000000 00000000 0000033a 2**0
CONTENTS, RELOC, READONLY,DEBUGGING
10 .comment 0000002a 00000000 00000000 0000035a 2**0
CONTENTS,READONLY
11 .note.GNU-stack 00000000 00000000 00000000 00000384 2**0
CONTENTS,READONLY
Readelf可以显示及解码在一个目标文件或可执行文件中的DWARF数据。这些选项是
-w displayall DWARF sections
-w[liaprmfFso] display specific sections
l line table
i debug info
a abbreviation table
p public names
r ranges
m macro table
f debug frame (encoded)
F debug frame (decoded)
s string table
o location lists
列出的DWARF,即使最小的程序也是相当多的,因此把readelf的输出重定向到一个文件,然后使用less或一个编辑器,比如vi,来浏览这个文件,是个好主意。