DWARF调试格式详解

文章目录

倘若我们编写的程序能够保证完全正确运行,无需任何调试工作,那将是极为理想的情况。但在这一理想愿景实现之前,常规的编程流程必然包含:编写程序、编译程序、执行程序,以及令人些许头疼的调试环节。之后重复这一系列步骤,直至程序达到预期的运行效果。

我们可以通过插入代码来打印特定关键变量的值,以此实现程序调试。事实上,在某些场景下(例如调试内核驱动程序),这或许是首选的调试方式。同时,也存在一些底层调试器,允许你逐条指令地单步执行可执行程序,并以二进制形式显示寄存器和内存内容。

但使用源码级调试器会便捷得多------它支持你在程序源码中单步执行、设置断点、打印变量值,还能提供一些其他功能,比如在调试过程中调用程序中的函数。核心问题在于,如何协调编译器和调试器这两个完全独立的程序,从而实现对目标程序的调试。

一、从源码到可执行文件的转换过程

将人类可读的源码转换为处理器可执行的二进制文件,这一过程极为复杂,但本质上是逐步将源码转化为更简洁形式的过程:在每个步骤中舍弃部分信息,最终生成处理器能够理解的简单操作序列、寄存器、内存地址和二进制值。毕竟,处理器并不关心你是否使用了面向对象编程、模板或智能指针,它仅能理解对有限寄存器和存储二进制值的内存单元执行的一组简单操作。

编译器读取并解析程序源码时,会收集程序相关的各类信息,例如变量或函数的声明及使用行号。语义分析会进一步完善这些信息,补充变量类型、函数参数等细节。优化过程可能会调整程序代码段的位置、合并相似代码、展开内联函数或删除无用部分。最后,代码生成阶段会根据程序的内部表示,生成实际的机器指令。通常,还会对机器指令进行额外处理,即"窥孔优化",进一步重新排列或修改代码(例如删除重复指令)。

总而言之,编译器的任务是将结构清晰、易于理解的源码,转换为高效但本质上难以解读的机器语言。编译器越能实现代码紧凑、运行快速的目标,生成的结果就越难以理解。

在这一转换过程中,编译器会收集后续调试程序时所需的信息。要做好这一点,需应对两大挑战:

  1. 转换过程的后期阶段,编译器可能难以将自身对程序的修改与程序员编写的原始源码关联起来。例如,窥孔优化器可能会删除某条指令,原因是它调整了C++模板实例化中内联函数生成代码的测试顺序。当优化器处理程序时,可能很难将其对底层代码的操作与生成该代码的原始源码对应起来。
  2. 如何详细描述可执行程序及其与原始源码的关联,以便调试器为程序员提供有用信息;同时,描述需足够简洁,避免占用过多存储空间或耗费大量处理器时间进行解析。而DWARF调试格式正是为解决这一问题而生------它以紧凑的形式表示可执行程序与源码之间的关联,便于调试器高效处理

二、调试过程

程序员使用调试器运行程序时,通常会执行一些特定操作。最常见的操作包括:设置断点(通过指定行号或函数名,让调试器在源码的特定位置暂停);当断点触发时,查看局部变量、全局变量或函数参数的值;查看调用栈(在存在多条执行路径的情况下,帮助程序员了解程序如何到达断点位置)。查看完这些信息后,程序员可让调试器继续执行被测试程序。

调试过程中还有许多其他实用操作。例如,逐行单步执行程序(可选择进入或跳过被调用的函数);对于C++程序,在模板或内联函数的每个实例处设置断点可能至关重要;在函数即将结束时暂停,以便查看或修改返回值;有时程序员可能希望跳过某个函数的执行,直接返回一个已知值,而非该函数(可能错误的)计算结果。

此外,还有一些与数据相关的实用操作。例如,显示变量类型可避免在源码文件中手动查找;以不同格式显示变量值,或按指定格式显示内存、寄存器内容,都能为调试提供帮助。

还有一些操作可称为高级调试功能:例如调试多线程程序或存储在只读内存中的程序;希望调试器(或其他程序分析工具)跟踪特定代码段是否已执行;部分调试器允许程序员调用被测试程序中的函数。在不久前,调试经过优化的程序还被视为一项高级功能。

调试器的核心任务是,以自然、易懂的方式为程序员呈现正在执行的程序,并允许对程序执行进行广泛控制。这意味着调试器必须在很大程度上逆向编译器精心设计的转换过程,将程序的数据和状态还原为程序员在源码中最初使用的表述形式。

而像DWARF这样的调试数据格式,其核心挑战就是让这一逆向过程成为可能,甚至变得简单。

三、调试格式

常见的调试格式有多种,例如stabs、COFF、PE-COFF、OMF、IEEE-695,以及DWARF的两个变体¹。在此不做详细描述,仅提及这些格式,以便将DWARF调试格式置于合适的背景中理解。

stabs源自"符号表字符串"(symbol table strings),因为其调试数据最初是以字符串形式存储在Unix系统a.out目标文件的符号表中。stabs通过文本字符串编码程序相关信息,最初设计简洁,但随着时间推移,已演变为一种相当复杂、偶尔晦涩且一致性欠佳的调试格式。stabs缺乏标准化,文档也不够完善²。太阳微系统公司(Sun Microsystems)对stabs进行了多项扩展,GCC也进行了其他扩展,同时还尝试逆向工程解析太阳微系统公司的扩展。尽管如此,stabs仍被广泛使用。

COFF全称为"通用目标文件格式"(Common Object File Format),源于Unix System V Release 3。COFF格式定义了基础的调试信息,但由于COFF支持命名段,因此多种不同的调试格式(如stabs)都可与COFF结合使用。COFF最主要的问题在于,尽管名称中包含"通用"(Common)一词,但在不同架构中的实现并不统一。COFF存在多种变体,包括XCOFF(用于IBM RS/6000)、ECOFF(用于MIPS和Alpha)以及Windows PE-COFF。这些变体的文档完善程度各不相同,但目标模块格式和调试信息均未实现标准化。

PE-COFF是微软Windows系统(从Windows 95开始)使用的目标模块格式,基于COFF格式,包含COFF调试数据和微软自身的专有CodeView(或CV4)调试数据格式。该调试格式的文档既简略又难以获取。

OMF全称为"目标模块格式"(Object Module Format),是CP/M、DOS、OS/2系统以及少数嵌入式系统中使用的目标文件格式。OMF为调试器定义了公共名称和行号信息,还可包含微软CV、IBM PM或AIX格式的调试数据。OMF仅为调试器提供最基础的支持。

IEEE-695是20世纪80年代末由Microtec Research和惠普(HP)联合为嵌入式环境开发的标准目标文件和调试格式,于1990年成为IEEE标准。它是一项灵活性极高的规范,旨在适用于几乎所有机器架构。该调试格式采用块结构,比其他格式更契合源码的组织方式。尽管是IEEE标准,但在许多方面,IEEE-695更接近专有格式。虽然原始标准可从IEEE获取,但Microtec Research为支持C++和优化代码进行了扩展,且这些扩展的文档极不完善。IEEE标准从未修订以纳入Microtec Research的扩展或其他变更。因此,尽管身为IEEE标准,其应用范围仍局限于少数小型处理器。

四、DWARF的简要历史

DWARF 1------Unix SVR4、sdb与PLSIG

DWARF 1由贝尔实验室的布莱恩·拉斯尔博士(Brian Russell)于1988年开发,最初用于Unix System V Release 4(SVR4)的C编译器和sdb调试器。1992年,Unix国际组织(Unix International)旗下的编程语言特别兴趣小组(PLSIG)将其文档化为DWARF版本1。尽管原始DWARF存在一些明显缺陷(最突出的是不够紧凑),但PLSIG决定仅做最小修改就将SVR4格式标准化。该版本在嵌入式领域得到广泛采用,至今仍在使用,尤其适用于小型处理器。

DWARF 2------PLSIG

PLSIG继续对DWARF进行扩展和文档完善,以解决多个问题,其中最重要的是减小生成的调试数据体积,同时增加对新兴语言(如C++)的支持。DWARF版本2于1993年作为草案标准发布。

然而,多米诺骨牌效应随之显现:PLSIG发布草案标准后不久,摩托罗拉(Motorola)的88000微处理器被发现存在致命缺陷。摩托罗拉停止了该处理器的研发,这直接导致了Open88联盟的解散------该联盟由多家公司组成,致力于开发基于88000处理器的计算机。而Open88是Unix国际组织的支持者,后者又是PLSIG的赞助方,最终导致Unix国际组织解体。Unix国际组织解散后,PLSIG仅剩下一个邮件列表和多个存储DWARF 2草案标准不同版本的FTP站点,正式标准从未发布。

由于Unix国际组织消失、PLSIG解散,多家机构开始独立扩展DWARF 1和2。部分扩展仅适用于特定架构,另一些则可能适用于所有架构。遗憾的是,这些机构并未就扩展内容展开合作,相关文档要么残缺不全,要么难以获取。或者,正如一位GCC开发者半开玩笑所说:扩展文档相当完善------你只需阅读编译器源码即可。DWARF险些重蹈COFF的覆辙,沦为一系列分歧的实现方案,而非行业标准。

DWARF 3------自由标准组织(Free Standards Group)

尽管Unix国际组织解体后,PLSIG的邮件列表在X/Open(后更名为开放群组)的赞助下得以保留,且围绕DWARF的在线讨论从未中断,但直到1999年底,修订(甚至最终确定)该文档的动力依然不足。彼时,业界希望扩展DWARF,以更好地支持惠普/英特尔IA-64架构,并完善C++程序所用ABI(应用程序二进制接口)的文档。这两项工作随后分离开来,作者接手并担任重组后的DWARF委员会主席。

经过超过18个月的开发工作并完成DWARF 3规范草案后,标准化工作陷入了一段停滞期。委员会(尤其是作者本人)希望确保DWARF标准易于获取,并避免因标准来源多样而导致分歧。2003年,DWARF委员会成为自由标准组织的DWARF工作组。2005年初,DWARF 3标准的积极开发和澄清工作重新启动,目标是解决标准中所有未决问题。同年10月,委员会发布了公开审查草案以征求公众意见,最终版本于2005年12月正式发布。

DWARF 4------DWARF调试格式委员会

2007年,自由标准组织与开源开发实验室(OSDL)合并成立Linux基金会后,DWARF委员会恢复独立地位,并在dwarfstd.org创建了自己的网站。2007年,DWARF 4的开发工作启动。该版本澄清了DWARF表达式,增加了对VLIW(超长指令字)架构的支持,改进了语言支持,泛化了对压缩数据的支持,新增了通过消除重复类型描述来压缩调试数据的技术,还增加了对基于剖面的编译器优化的支持,并对文档进行了大量编辑。经过公开审查后,DWARF 4标准于2010年6月发布。

DWARF 5的开发工作于2012年2月启动,预计于2014年完成。

五、DWARF概述⁴

大多数现代编程语言都采用块结构:每个实体(例如类定义或函数)都包含在另一个实体中。这形成了词法作用域,名称仅在其定义的作用域内有效。要查找程序中某个符号的定义,需先在当前作用域中查找,然后依次在包含它的外层作用域中查找,直至找到该符号。同一名称可能在不同作用域中有多个定义。编译器自然会将程序内部表示为树状结构。例如,C程序文件可能包含多个数据定义、多个变量定义和多个函数;每个C函数内部可能包含多个数据定义,其后紧跟可执行语句;语句可能是复合语句,进而包含更多数据定义和可执行语句。

DWARF遵循这一模型,同样采用块结构。除描述源文件的最顶层条目外,DWARF中的每个描述实体都包含在父条目内,且可能包含子实体。若一个节点包含多个实体,则这些实体互为兄弟关系。DWARF对程序的描述是树状结构,与编译器的内部树类似,每个节点可包含子节点或兄弟节点,节点可表示类型、变量或函数。这是一种紧凑格式,仅提供描述程序某方面所需的信息。该格式以统一方式扩展,因此调试器能够识别并忽略扩展内容,即使无法理解其含义(这比大多数其他调试格式更具优势------其他格式中,调试器遇到无法识别的数据时可能会致命崩溃)。DWARF还设计为可扩展至几乎所有过程式编程语言和任何机器架构,而非局限于描述某一种语言或某一语言版本,也不局限于特定范围的架构。

尽管DWARF最常与ELF目标文件格式关联,但它独立于目标文件格式,可与其他目标文件格式结合使用(且已有相关应用)。只需确保构成DWARF数据的不同数据段在目标文件或可执行文件中可识别即可。DWARF不会重复目标文件中已包含的信息,例如处理器架构标识或文件的字节序(大端或小端)。

六、调试信息条目(DIE)

6.1 标签与属性

DWARF中的基本描述实体是调试信息条目 (Debugging Information Entry,DIE)。除最顶层的DIE外,所有DIE都包含在父DIE中或归属于父DIE。每个DIE都有一个标签(TAG),用于指定其描述的对象,还有一组属性(attributes),用于补充细节并进一步描述该实体。属性可包含多种值:常量(例如函数名称)、变量(例如函数的起始地址)或对另一个DIE的引用(例如函数返回值的类型)。一个DIE可能有兄弟DIE或子DIE。

下图1展示了C语言经典的hello.c程序及其DWARF描述的简化图形表示。最顶层的DIE表示编译单元,它有两个"子节点":第一个是描述main函数的DIE,第二个是描述int基础类型(main函数的返回值类型)的DIE。子程序DIE是编译单元DIE的子节点,而基础类型DIE通过子程序DIE的类型属性引用。我们也会说一个DIE"拥有"或"包含"其子DIE。

6.2 DIE的类型

DIE可分为两大类:描述数据(包括数据类型)的DIE,以及描述函数和其他可执行代码的DIE。

七、描述数据与类型

大多数编程语言都有复杂的数据描述方式,包括多种内置数据类型、指针、各种数据结构,以及创建新数据类型的方法。由于DWARF旨在适用于多种语言,它提炼了核心要素,提供了一种可用于所有支持语言的表示方式。直接基于硬件实现的基本类型称为基础类型(base types),其他数据类型则通过组合这些基础类型构建而成。

7.1 基础类型

每种编程语言都定义了若干基本标量数据类型。例如,C和Java都定义了int和double。Java为这些类型提供了完整定义,而C仅指定了一些通用特性,允许编译器根据目标处理器选择最适合的实际规格。有些语言(如Pascal)允许定义新的基础类型,例如可存储0至100之间整数的整数类型。Pascal并未指定其实现方式:一个编译器可能将其实现为单字节,另一个可能使用16位整数,第三个可能将所有整数类型均实现为32位值,无论其定义如何。

在DWARF版本1和其他调试格式中,编译器和调试器需默认达成共识------例如int是16位、32位还是64位。但当同一硬件支持不同大小的整数,或不同编译器针对同一目标处理器做出不同实现决策时,这种默认共识会变得尴尬。这些通常未文档化的假设,导致不同编译器或调试器之间,甚至同一工具的不同版本之间难以兼容。

DWARF基础类型提供了简单数据类型与目标机器硬件实现之间的最低层级映射。这使得int的定义在Java和C中都明确可见,甚至允许在同一程序中使用不同的定义。图2a展示了典型32位处理器上描述int的DIE,其属性指定了名称(int)、编码方式(有符号二进制整数)和字节大小(4)。图2b展示了16位处理器上int的类似定义。(图2中使用了DWARF标准中定义的标签和属性名称,而非图1中更通俗的名称。所有标签名称均以DW_TAG为前缀,所有属性名称均以DW_AT为前缀。)

基础类型允许编译器描述编程语言标量类型与其在处理器上实际实现之间的几乎任何映射关系。图3描述了一个存储在4字节字高位16位中的16位整数值。在该基础类型中,位大小属性指定值为16位宽,高位偏移属性指定偏移量为0⁵。

DWARF基础类型支持描述多种编码方式,除二进制整数外,还包括地址、字符、定点数、浮点数和压缩十进制数。但仍存在一些模糊性:例如,浮点数的实际编码方式未指定,而是由硬件实际支持的编码方式决定。在支持IEEE-754标准32位和64位浮点数的处理器中,"float"表示的编码方式会因值的大小而异。

7.2 类型组合

命名变量由DIE描述,该DIE包含多种属性,其中之一是对类型定义的引用。图4描述了一个名为x的整数变量(暂时忽略描述变量的DIE中通常包含的其他信息)。int的基础类型DIE将其描述为占用4字节的有符号二进制整数。描述x的DW_TAG_variable DIE给出了其名称和类型属性,该类型属性引用基础类型DIE。为清晰起见,本示例及后续示例中的DIE按顺序编号;在实际DWARF数据中,对DIE的引用是该DIE相对于编译单元起始位置的偏移量。引用可指向先前定义的DIE(如图4所示),也可指向后续定义的DIE。一旦创建了int的基础类型DIE,同一编译单元中的任何变量都可引用该DIE⁶。

DWARF通过组合基础类型构建其他数据类型定义:新类型通过修改另一种类型创建。例如,图5展示了典型32位机器上指向int的指针。该DIE定义了指针类型,指定其大小为4字节,并引用int基础类型。其他DIE可描述const或volatile属性、C++引用类型或C的restrict类型。这些类型DIE可链接起来描述更复杂的数据类型,例如图6中描述的"const char **argv"。

八、数组

数组类型由DIE描述,该DIE定义数据的存储顺序------按列优先(如Fortran)或行优先(如C或C++)。数组的索引由子范围类型(subrange type)表示,给出每个维度的上下界。这使得DWARF既能描述C风格数组(最低索引始终为0),也能描述Pascal或Ada中的数组(上下界可为任意值)。

九、结构体、类、联合体与接口

大多数语言允许程序员将数据组合成结构体(C和C++中称为struct,C++中还称为class,Pascal中称为record)。结构体的每个组件通常有唯一名称,可能具有不同类型,且各自占用独立的内存空间。C和C++支持联合体(union),Pascal支持变体记录(variant record),它们与结构体类似,但组件共享相同的内存位置。Java接口具有C++类的部分属性,仅包含抽象方法和常量数据成员。

尽管每种语言都有自己的术语(C++将类的组件称为成员,Pascal称为字段),但底层组织方式均可通过DWARF描述。遵循其设计传统,DWARF使用C/C++/Java术语,提供描述struct、union、class和interface的DIE。此处以class DIE为例进行描述,其他类型的组织方式基本相同。

类的DIE是描述该类每个成员的DIE的父节点。每个类都有名称,可能还有其他属性。若实例大小在编译时已知,则会包含字节大小属性。每个成员的描述与简单变量的描述非常相似,但可能包含一些额外属性。例如,C++允许程序员指定成员的访问权限(public、private或protected),这些通过访问权限属性(accessibility attribute)描述。

C和C++允许位字段作为类成员(非简单变量),通过位偏移属性(bit offset,从类实例起始位置到位字段最高位的偏移量)和位大小属性(bit size,成员占用的位数)描述。

十、变量

变量通常较为简单:它们有一个名称,代表一块可存储某种值的内存(或寄存器)。变量可存储的值的类型,以及修改限制(如是否为const)由变量的类型描述。

变量的区别在于其值的存储位置和作用域。作用域定义了变量在程序中的可见范围,在一定程度上由变量的声明位置决定。在C中,函数或块内声明的变量具有函数或块作用域;函数外声明的变量具有全局或文件作用域。这使得不同文件中可定义同名变量而不冲突,也允许不同函数或编译单元引用同一变量。DWARF通过(文件、行号、列号)三元组记录变量在源文件中的声明位置。

DWARF将变量分为三类:常量、形式参数和普通变量。常量用于支持真正命名常量的语言(如Ada的参数)。(C语言本身不支持常量:声明const变量仅表示无法直接修改该变量,需通过显式强制类型转换才能修改。)形式参数表示传递给函数的值,后续将详细讨论。

有些语言(如C或C++,但不包括Pascal)允许声明变量而不定义它。这意味着该变量的实际定义应在其他位置(希望编译器或调试器能找到)。描述变量声明的DIE仅提供变量的描述,不告知调试器其存储位置。

大多数变量都有位置属性(location attribute),描述变量的存储位置。最简单的情况是变量存储在内存中,具有固定地址⁷。但许多变量(如C函数内声明的变量)是动态分配的,定位它们需要一些(通常是简单的)计算。例如,局部变量可能分配在栈上,定位时只需将固定偏移量与帧指针相加;有些变量可能存储在寄存器中;还有些变量可能需要更复杂的计算才能定位数据(如C++类的成员变量,可能需要复杂计算确定基类在派生类中的位置)。

十一、位置表达式

DWARF提供了一种通用方案描述变量对应数据的定位方式:位置表达式(location expression)包含一系列操作,告知调试器如何定位数据。图7展示了三个变量a、b和c的DIE:变量a存储在内存中的固定位置(实际地址由链接器填充),变量b存储在寄存器0中,变量c存储在当前函数栈帧偏移-12处。尽管a最先声明,但描述它的DIE在所有函数之后生成。

DWARF位置表达式可包含一系列运算符和值,由简单的栈式机器求值。这可以是任意复杂的计算,支持多种算术运算、表达式内的条件判断和分支、调用其他位置表达式求值,以及访问处理器的内存或寄存器。甚至有专门的操作描述拆分存储在不同位置的数据(如部分存储在内存、部分存储在寄存器的结构体)。

尽管这种高度灵活性在实际应用中很少使用,但位置表达式应能描述任何复杂语言定义或编译器优化下变量数据的位置。

十二、描述可执行代码

12.1 函数与子程序

DWARF将返回值的函数(functions)和不返回值的子程序(subroutines)视为同一概念的不同变体。略微偏离其C术语根源,DWARF使用子程序DIE(subprogram DIE)描述两者。该DIE包含名称、源位置三元组,以及一个属性------指示子程序是否为外部可见(即是否可在当前编译单元外访问)。

子程序DIE的属性包含子程序占用的内存地址范围:若为连续地址,则给出低PC地址和高PC地址;若函数占用的内存地址不连续,则给出地址范围列表。默认情况下,低PC地址被视为子程序的入口点,除非另有明确指定。

函数的返回值类型由类型属性指定;不返回值的子程序(如C的void函数)没有该属性。DWARF不描述函数的调用约定------这由特定架构的应用程序二进制接口(ABI)定义。可能存在一些属性帮助调试器定位子程序的数据或查找当前子程序的调用者:返回地址属性是一个位置表达式,指定调用者地址的存储位置;帧基地址属性是一个位置表达式,计算函数栈帧的地址。这些属性非常实用,因为编译器最常见的优化之一是删除显式保存返回地址或帧指针的指令。

子程序DIE拥有描述该子程序的所有DIE:传递给函数的参数由带有变量参数属性的变量DIE表示;若参数为可选或有默认值,由相应属性描述。参数的DIE顺序与函数的参数列表顺序一致,但可能穿插其他DIE(例如定义参数使用的类型)。

函数可定义局部或全局变量,描述这些变量的DIE紧跟在参数DIE之后。许多语言允许词法块嵌套,由词法块DIE表示,词法块DIE可拥有变量DIE或嵌套的词法块DIE。

以下是一个稍复杂的示例:代码片段(strndup.c文件)展示了gcc中的strndup函数源码(用于复制字符串),图8b列出了该文件生成的DWARF数据(省略了源行信息和位置属性)。

c 复制代码
#include "ansidecl.h"
#include <stddef.h>

extern size_t strlen (const char*);
extern PTR malloc (size_t);
extern PTR memcpy (PTR, const PTR, size_t);

char * strndup (const char *s, size_t n)
{
    char *result;
    size_t len = strlen (s);

    if (n < len)
        len = n;

    result = (char *) malloc (len + 1);
    if (!result)
        return 0;

    result[len] = '\0';
    return (char *) memcpy (result, s, len);
}

图8b中,DIE <2>展示了size_t的定义------它是unsigned int的类型定义(typedef)。这使得调试器能够将形式参数n的类型显示为size_t,同时将其值显示为无符号整数。DIE <5>描述strndup函数,包含指向其兄弟DIE <10>的指针,后续所有DIE均为该子程序DIE的子节点。该函数返回char指针(由DIE <10>描述),DIE <5>还将该子程序标记为外部可见和已原型化,并给出其低PC地址和高PC地址。子程序的形式参数和局部变量由DIE <6>至<9>描述。

12.2 编译单元

大多数复杂程序由多个文件组成:构成程序的每个源文件独立编译,然后与系统库链接形成最终程序。DWARF将每个独立编译的源文件称为一个编译单元(compilation unit)。

每个编译单元的DWARF数据以编译单元DIE(Compilation Unit DIE)开头,包含编译相关的通用信息:源文件的目录和名称、使用的编程语言、标识DWARF数据生成器的字符串,以及DWARF数据段中的偏移量(帮助定位行号和宏信息)。

若编译单元是连续的(即加载到内存中是一个完整的块),则会包含该单元的低内存地址和高内存地址,便于调试器识别特定内存地址的代码所属的编译单元;若编译单元不连续,编译器和链接器会提供代码占用的内存地址列表。

编译单元DIE是描述该编译单元的所有DIE的父节点。通常,前几个DIE描述数据类型,随后是全局数据,再之后是源文件中的函数。变量和函数的DIE顺序与它们在源文件中的出现顺序一致。

12.3 数据编码

从概念上讲,描述程序的DWARF数据是树状结构:每个DIE可能有兄弟节点和多个子节点,每个DIE有类型(标签)和多个属性,每个属性由属性类型和值表示。遗憾的是,这种表示方式的密度不高,未压缩的DWARF数据会非常繁琐。

DWARF提供了多种方式减小需随目标文件保存的数据体积:

  1. 树结构扁平化:按前缀顺序存储树结构。每种类型的DIE都定义为可包含子节点或不可包含子节点:不可包含子节点的DIE,下一个DIE是其兄弟节点;可包含子节点的DIE,下一个DIE是其第一个子节点,其余子节点表示为该第一个子节点的兄弟节点。这样可省去指向兄弟节点或子节点的链接。若编译器开发者认为需要直接跳转到某个DIE的兄弟节点(无需遍历其子节点,例如跳转到编译单元中的下一个函数),可在该DIE中添加兄弟属性。
  2. 使用缩写(abbreviations):尽管DWARF允许生成多种DIE和属性,但大多数编译器仅生成有限集合的DIE,且这些DIE的属性集合固定。因此,无需存储标签值和属性-值对,只需存储缩写表中的索引,后跟属性代码即可。每个缩写包含标签值、指示DIE是否有子节点的标志,以及属性列表(含属性预期的值类型)。图9展示了图8b中形式参数DIE使用的缩写,图8中的DIE <6>实际编码如图所示⁸。这种方式能显著减少需保存的数据量,但会增加一定的复杂性。

DWARF版本3和4还提供了一些较少使用的特性:允许从一个编译单元引用另一个编译单元或共享库中的DWARF数据。许多编译器会为每个编译单元生成相同的缩写表和基础类型,无论该编译单元是否实际使用所有缩写或类型。这些内容可保存在共享库中,供每个编译单元引用,而非在每个编译单元中重复存储。

十三、其他DWARF数据

13.1 行号表

DWARF行号表包含程序可执行代码的内存地址与对应源行的映射关系。最简单的形式可视为一个矩阵:一列是内存地址,另一列是该地址对应的源文件三元组(文件、行号、列号)。若要在某一行设置断点,行号表会提供存储断点指令的内存地址;反之,若程序在内存某位置出现错误(如使用无效指针),可通过行号表查找最接近该内存地址的源行。

随着编译器对程序的优化,指令可能被重排或删除:某条源语句对应的代码可能不连续存储,而是与附近其他源语句的指令分散交错。因此,DWARF扩展了行号表,增加了额外列以传递更多程序信息:例如,标记函数序幕代码的结束或尾声代码的开始,便于调试器在函数参数全部加载后或函数返回前暂停;对于支持多种指令集的处理器,增加列指示指定机器地址存储的指令集类型。

可想而知,若为每条机器指令存储一行行号表数据,数据量会非常庞大。DWARF通过将行号表编码为一系列指令(称为行号程序⁹)来压缩数据,这些指令由简单的有限状态机解释,以重建完整的行号表。

有限状态机初始化时带有一组默认值,行号表中的每一行通过执行一行或多行号程序操作码生成。操作码通常非常简单:例如,为机器地址或行号添加一个值、设置列号、设置标志(指示内存地址是源语句的开始、函数序幕的结束或尾声的开始)。一组特殊操作码将最常见的操作(递增内存地址并递增或递减源行号)组合为单个操作码。

最后,若行号表中某一行的源文件三元组与前一行相同,则该行号程序中不生成对应指令。图10列出了strndup.c的行号程序,仅存储了表示语句起始指令的机器地址。编译器未识别该代码中的基本块、函数序幕的结束或尾声的开始,该表在该行号程序中仅编码为31字节。

13.2 宏信息

大多数调试器在显示和调试包含宏的代码时面临很大困难:用户看到的是包含宏的原始源文件,而实际执行的代码是宏展开后的结果。

DWARF包含程序中定义的宏描述信息。这些信息虽然基础,但调试器可利用它们显示宏的值,或将宏转换为对应的源语言代码。

13.3 调用帧信息

每种处理器都有特定的函数调用和参数传递方式(通常由ABI定义)。最简单的情况下,所有函数的调用方式相同,调试器可准确找到参数值和返回地址。

但对于某些处理器,调用序列可能因函数编写方式而异(例如参数数量超过特定值);不同操作系统也可能有不同的调用序列。编译器会尝试优化调用序列,使代码更小、更快:例如,不调用其他函数的简单函数(叶函数)可能使用调用者的栈帧而非创建自己的栈帧;另一种优化可能是删除指向当前调用帧的寄存器。尽管调试器可能解析出调用序列或优化的所有可能变体,但过程既繁琐又容易出错------优化的微小变化可能导致调试器无法回溯栈找到调用函数。

DWARF调用帧信息(Call Frame Information,CFI)为调试器提供了足够的函数调用相关信息,使其能够定位函数的每个参数、当前调用帧和调用者的调用帧。调试器利用这些信息" unwind the stack"(栈回溯),找到前一个函数、函数调用位置和传递的参数值。

与行号表类似,CFI编码为一系列指令,通过解释生成表:表中每行对应一个包含代码的地址,第一列是机器地址,后续列是该地址指令执行时机器寄存器的值。若实际创建该表,数据量会非常庞大,但幸运的是,两条机器指令之间的变化很小,因此CFI编码非常紧凑。

13.4 变长数据

DWARF中广泛使用整数值表示各种信息,从数据段偏移量到数组或结构体大小。大多数情况下,无法限制这些值的大小。在传统数据结构中,这些值均使用默认整数大小表示,但由于大多数值仅需少数位即可表示,导致数据中包含大量零值¹⁰。

DWARF定义了一种变长整数类型------小端基128(Little Endian Base 128,LEB128),用于压缩这些整数值(无符号值称为ULEB,有符号值称为SLEB)。由于低位包含数据,高位全为零或一,LEB值会截取值的低7位;若剩余位全为零或一(符号扩展位),则该字节即为编码后的值;否则,将高位设为一,输出该字节,然后继续处理下7位低位。

13.5 减小DWARF数据体积

与DWARF版本1等未编码格式相比,DWARF使用的编码方案显著减小了调试信息的体积。但遗憾的是,许多程序的编译器生成的调试数据量仍然很大,通常远大于可执行代码和数据本身。

DWARF提供了进一步减小调试数据体积的方法:

  1. 字符串去重:DWARF调试数据中的大多数字符串实际上是对单独.debug_str段的引用。生成该段时可删除重复字符串;链接器可将多个编译单元的.debug_str段合并为一个更小的字符串段。
  2. 消除重复声明:许多程序包含在每个编译单元中重复的声明(例如,描述数千个C++模板函数声明的调试数据可能在每个编译单元中重复)。这些重复描述可保存在独立编译单元的唯一命名段中,链接器可使用COMDAT(公共数据)技术消除重复段。
  3. 仅生成使用的类型:许多程序引用大量包含类型定义的头文件,导致DWARF数据包含数千个对应类型的DIE。编译器可通过仅为编译中实际使用的类型生成DWARF数据来减小体积。DWARF版本4中,类型定义可保存到单独的.debug_types段,编译单元包含一个引用该独立类型单元的DIE和一个唯一的64位类型签名,链接器可识别定义相同类型单元的编译单元并消除重复。

13.6 ELF段

尽管DWARF的定义允许其与任何目标文件格式结合使用,但它最常与ELF结合。不同类型的DWARF数据存储在各自的段中,这些段的名称均以".debug_"开头。为提高效率,大多数对DWARF数据的引用使用相对于当前编译单元数据起始位置的偏移量,避免调试数据重定位,从而加快程序加载和调试速度。

ELF段及其内容如下表所示:

ELF段名称 内容描述
.debug_abbrev .debug_info段中使用的缩写
.debug_aranges 内存地址与编译单元的映射
.debug_frame 调用帧信息(CFI)
.debug_info 包含DIE的核心DWARF数据
.debug_line 行号程序
.debug_loc 位置描述
.debug_macinfo 宏描述
.debug_pubnames 全局对象和函数的查找表
.debug_pubtypes 全局类型的查找表
.debug_ranges DIE引用的地址范围
.debug_str .debug_info使用的字符串表
.debug_types 类型描述

总结

以上就是DWARF的核心内容------虽不能说是极简概括,但DWARF调试信息的基本概念非常直观:以紧凑、独立于语言和机器的方式,将程序描述为树状结构,节点表示源文件中的各种函数、数据和类型;行号表提供可执行指令与生成它们的源文件的映射;调用帧信息描述栈回溯的方法。

同时,DWARF也包含诸多精妙设计,需表达多种编程语言和不同机器架构的细微差异。DWARF的未来发展方向是改进优化代码的描述方式,使调试器能更好地处理高级编译器优化生成的代码。

完整的DWARF版本4标准可在DWARF官方网站(dwarfstd.org)免费下载。网站上还提供了用于讨论DWARF相关问题的邮件列表,以及订阅该邮件列表的说明。

注释说明

¹ DWARF版本1与版本2及后续版本差异显著。

² 1992年,作者编写了一份详细文档,描述太阳微系统公司编译器生成的stabs,但该文档从未广泛传播。

³ DWARF的名称带有双关意味------它与ELF目标文件格式一同开发,名称是"Debugging With Arbitrary Record Formats"(使用任意记录格式调试)的缩写。

⁴ 本文其余部分将讨论DWARF版本2及后续版本,除非另有说明,所有描述均适用于DWARF版本2至4。

⁵ 这是一个真实案例:某Pascal实现中,16位整数在栈上存储于字的高半部分。

⁶ 有些编译器在每个编译单元的开头定义一组通用类型定义,另一些则仅为程序中实际引用的类型生成定义,两种方式均有效。

⁷ 严格来说,可能不是固定地址,而是相对于可执行文件加载位置的固定偏移量。加载器会重定位可执行文件内部的地址引用,因此运行时位置属性包含实际内存地址;在目标文件中,位置属性是偏移量,配合相应的重定位表条目。

⁸ 编码条目还包含文件和行号值,图8b中未显示。

⁹ 将其称为行号程序并不完全准确------该程序描述的内容远不止行号,还包括指令集、基本块起始、函数序幕结束等。

¹⁰ 例如,目标文件的重定位目录中,文件偏移量和重定位值均由整数表示,大多数值都有前导零。

相关推荐
代码AC不AC8 天前
【Linux】调试器 gdb / cgdb
linux·gdb·调试器·cgdb
moringlightyn24 天前
进度条+ 基础开发工具----版本控制器git 调试器gdb/cgdb
笔记·git·其他·c·调试器·gdb/cgdb·进度条 倒计时
BS_Li1 个月前
【Linux系统编程】调试器-gdb/cgdb
linux·调试器·gdb/cgdb
子牙老师1 个月前
从零手写gdb调试器
c语言·linux内核·gdb·调试器
飞哥不鸽1 年前
《leetcode-runner》如何手搓一个debug调试器——引言
算法·leetcode·开源·调试器·插件开发·项目架构
网络研究院1 年前
x64dbg: 用于Windows的开源二进制调试器
网络安全·软件分析·程序分析·动态分析·逆向工程·调试器·反汇编程序
Android技术栈1 年前
鸿蒙(API 12 Beta2版)NDK开发【LLDB高性能调试器】调试和性能分析
华为·harmonyos·openharmony·性能·ndk·调试器·鸿蒙开发
program-learner2 年前
Linux基础环境开发工具的使用(三):gdb调试器
linux·gdb·linux基础开发工具的使用·调试器