Suchitra Venugopal, 咨询系统分析员, IBM
Adarsh Thampan, 开发经理, IBM

 

简介: 了解 STAB 和 DWARF 这两种流行调试格式的更多信息。了解如何调试和分析构成 DWARF 和 STAB 格式的 UNIX 可执行文件。对于处理编译器和调试器的程序员以及对读取或写入 DWARF 和 STAB 信息感兴趣的任何人,本文内容非常有用。

 概述

二进制文件中存储的调试信息可以帮助程序员或调试人员寻找程序中出问题的地方,并在应用程序出错时找到栈跟踪。也可以将这些信息用于流行的调试工具,如 gdb 和 wdb。调试数据格式是用来存储有关已编译程序的信息的一种方式,这些信息将用于高级调试器。现代调试数据格式可以存储足够多的信息来支持源代码级别的调试。

调试器和调试信息可用于或存在于所有软件组件中,然而,并不是所有用户都了解调试数据的组成以及数据的存储方式。

如果您需要处理调试格式,并且您是一名具有任意 UNIX 相关平台经验以及编译器和调试器基础知识的 C/C++ 程序员,或者正在选择适当的调试格式,那么本文非常适合您。

我们将介绍如何从两种流行的调试格式 STAB 和 DWARF 提取调试信息。我们将讨论这两种格式各自的优点。此外,我们还提供了示例应用程序,从 DWARF 格式的二进制文件中提取信息,帮助您试验和了解这种格式。

DWARF 和 STAB 格式是使用最广泛的两种可执行和链接格式 (ELF)。

程序员可以在以下场合使用这种文档:

  • 在缺少调试器的情况下执行应用程序调试,方式是分别提取调试信息,并将它们存储到不同的文件中。在将产品投入生产之后,就会从二进制文件中删除所有调试信息。如果可以在删除信息之前捕捉到必要的调试信息,并且将它们放入不同的文件中,那么当应用程序出现任何异常或崩溃时,就可以使用这些文件生成详细的跟踪信息。
  • 识别适用于特定平台的调试格式,UNIX 平台和 Solaris 都使用 STAB,而 HP Itanium 使用的是 DWARF。
  • 判断某个特定的二进制文件是否对应正确的源代码版本。例如,如果将新函数添加到源代码中并创建了相应的二进制文件,则需要验证源文件和二进制文件是同步的。如果能够查看二进制文件中的调试信息,则很容易实现这一点。当修改源代码并重新构建二进制文件时,二进制文件的内部布局也将发生相应变化,以适应新的变化。因此,检查调试信息可以确保二进制文件与源文件是同步的。包含的样例应用程序为用户提供了有关任何二进制文件的重要调试信息。
 

调试格式

调试数据格式是用来存储有关已编译计算机程序的信息的一种方式,这些信息将用于高级调试器。现代调试数据格式存储了足够多的信息来支持源代码级别的调试。

有多种调试格式可供使用。STAB、COFF、PECOFF、OMF、IEEE695 以及 DWARF 的三种版本是一些常见的选择。接下来我们将比较 STAB 和 DWARF,并讨论如何从这两种格式中提取调试消息。

 

STAB

调试信息的传统格式被称为 STAB(符号表)。STAB 信息保存在 ELF 文件的 .stab 和 .stabstr 部分。

STAB 调试格式是一种记录不完整的半标准格式,用于调试 COFF 和 ELF 对象文件中的信息。调试信息是作为对象文件的符号表的一部分进行存储的,因此复杂性和范围是有限的。尽管如此,STAB 在旧的 UNIX 和兼容系统上仍然是一种常见的调试格式。

对于某些对象文件格式,调试信息被封装到统称为 STAB 指令的汇编程序指令中,这些指令分布在生成的代码中。STAB 是 a.out 和 XCOFF 对象文件格式的调试信息的本机格式。GNU 工具也可以在 COFF 和 ECOFF 对象文件格式中生成 STAB。

汇编程序创建了两个自定义部分:

  • .stab,包含一组具有固定长度的结构,每个 stab 包含一个结构
  • .stabstr,包含所有可变长度的字符串,这些字符串在 .stab 部分是通过 stab 引用的。

STAB 二进制数据的字节顺序取决于对象文件格式。

程序的结构

组成使用 STAB 编码的程序结构的元素包括主函数的名称、源名称和 include 文件的名称、行号、过程名称和类型,以及代码的起始和结束块。

大多数语言允许主程序使用任意名称。N_MAIN STAB 类型会告诉调试器在程序中使用的主程序名称。只有字符串字段是至关重要的;它表示构成主程序的函数的名称。大多数 C 编译器不会使用该 STAB(它们希望调试器假设该名称是主程序的名称),但是一些 C 编译器会发出 N_MAIN STAB 来获得主函数。

在出现其他任何 STAB 之前,必须用一个 STAB 指定源文件。这一信息包含在 STAB 类型 N_SO 的符号中。字符串字段包含文件的名称。符号的值是与该文件对应的文本部分的起始地址。

N_SLINE 符号表示源代码行的起始位置。desc 字段包含行号,而 value 包含源代码行的起始部分的代码地址。在大多数机器上,该地址是绝对地址;对于 STAB,该地址相是对于出现 N_SLINE 符号的函数的相对地址。

以下所有 STAB 通常对函数使用 N_FUN 符号类型:

其他常见的部分包括:

  • N_SLINE, N_XLINE 是行号
  • N_LBRAC 是左括号,即函数的开始部分
  • N_RBRAC 是右括号,即函数的结束部分
  • N_SOL 是所有包含在该二进制文件中的文件
 

DWARF

DWARF(使用任意记录格式调试)是面向 ELF 文件的一种较新的格式。创建该格式是为了弥补 STAB 中的一些缺陷,从而能够提供更详细、更简便的数据结构描述、变化的数据移动和复杂的语言结构,比如 C 中的语言结构。调试信息存储在对象文件的各个部分中。这种格式是可执行程序与源代码之间关系的简单表示,为了便于调试器对该关系进行处理。

调试信息条目的所属关系可以很自然地得到实现,因为调试信息是用树的形式来表示的。树的节点就是调试信息条目。任何节点的子条目正是该节点拥有的调试信息条目。树本身是按照前缀顺序将调试信息扁平化的一种表示方法。每个调试信息条目被定义为拥有子条目,或不包含任何子条目。如果条目被定义为不包含子条目,那么下一个条目就会成为前一个条目的兄弟条目。如果条目被定义为包含子条目,那么下一个条目则为前一个条目的第一个子条目。父条目的其他子条目被表示为第一个子条目的兄弟条目。兄弟条目链是用一个空 (null) 条目终止的。

调试信息条目

DWARF 中的基本描述实体是调试信息条目 (DIE)。DIE 中包含一个指定 DIE 所描述内容的标记,以及许多用来填充细节并进一步描述实体的属性。DIE(除了最顶部的 DIE 以外)包含在父 DIE 中(或归该父 DIE 所有),可以有兄弟 DIE 或子 DIE。属性可能包含不同的值:常量(如函数名)、变量(如函数的起始地址)或对另一个 DIE 的引用(例如函数返回值的类型)。

编译单元

编译单元 (CU) 是 DIE 的一种类型。大多数有趣的程序都由多个文件组成。构成程序的每个源文件都是独立编译的,然后通过系统库链接在一起并组成程序。DWARF 将每个分别编译的源文件称为一个编译单元。每个编译单元的 DWARF 数据的起始部分均为 CU DIE。该 DIE 包含有关编译的一般信息,包括:源文件的目录和名称、使用的编程语言、标识 DWARF 数据生成者的字符串,以及帮助确定行号和宏信息的 DWARF 数据段的位移。如果 CU 是连续的(即完整地加载到内存中),则可以获得编译单元的高位、低位内存地址值。这使得调试器可以更容易地确定哪一个编译单元在某个特定内存地址创建了代码。CU DIE 是所有描述编译单元的 DIE 的父元素。

CU 通常用一个可重新定位的对象文件表示可执行文件的文本和数据。该文件可能来自多个源文件,包括预处理的 “include 文件”。

CU 条目可能包含以下属性:

  • 一个 DW_AT_low_pc 属性,它的值表示为该编译单元生成的第一个机器指令的重定位地址。
  • 一个 DW_AT_high_pc 属性,它的值表示为该编译单元生成的最后一个机器指令之后的第一个位置的重定位地址。

其他 DIE 类型包括:

  • DW_TAG_subprogram:一个全局或文件静态子例程或函数
  • DW_TAG_inlined_subroutine:子例程或函数的特定的内联实例
  • DW_TAG_entry_point:一个 Fortran 入口点
  • DW_TAG_variable:一个变量
  • DW_TAG_pointer_type:一个指针变量
  • DW_TAG_formal_parameter:函数参数包中的每个参数都由一个带此标记的调试消息条目表示

子例程 DIE 包含描述该子例程的 DIE。传递给函数的参数由各个 DIE 表示,这些 DIE 包含不同的参数属性。

行号表

DWARF 行表包含源行(属于程序的可执行部分)与内存(包含与源代码对应的代码)之间的映射关系。简单来说,可以将它看作是一个矩阵,其中一列包含内存地址,另一列包含源 triplet(文件、代码行和列)。如果希望在某个特定行设置一个断点,那么该表给出了用于存储断点指令的内存地址。相反,如果您的程序在内存中某个位置存在错误(例如,使用了一个错误指针),那么可以查找最接近该内存地址的源代码行。

程序被描述为树的形式,其中的节点表示源文件中不同的函数、数据和类型,并采用了一种简洁的语言和独立于机器的方式。行表提供了可执行指令与生成它们的源代码之间的映射关系。

 

从 STAB 和 DWARF 格式获取信息

STAB 和 DWARF 是表示二进制文件中的调试信息的两种不同方式。下一节将描述如何从 STAB 和 DWARF 部分中捕捉调试信息。

接下来的步骤是扫描每个二进制文件中的调试信息,并判断哪些文件名构成该二进制文件。对于每个文件,将捕捉每个函数的所有函数名称和行号(第一个行号和最后一个行号)。可以使用相连的列表存储这些信息。

STAB 格式的信息

对二进制文件进行扫描,查看文件中的 stab 部分。如果找到 N_SOL,则表示二进制文件中包含该文件的名称,并且文件名可以存储到该符号中。在获取文件名后,下一步是获取该文件中包含的所有函数。N_FUN 表示 stab 部分中的函数。如果我们从 N_SOL 符号(文件名)后获得该符号,则意味着以上文件名遇到一个函数。函数名和细节可以存储在 N_FUN 中。

此后,如果返回 N_LBRAC 符号,该符号表示一个左括号和函数的开始部分。现在可以确定,上述函数将从这里开始。紧随着该符号的是 N_SLINE 或 N_XLINE,它们表示函数中的代码行,即第一个行号和最后一个行号,或者需要的话,所有行号都可以存储在该符号中。

在几个行号条目之后(N_SLINE 或 N_XLINE),将捕捉到 N_RBRAC 符号。如果遇到该符号,则表示函数的结束。此时,将捕捉到一个信息集,对于二进制文件来说,该信息集包含一个 included 文件名、一个函数以及函数的细节。简单来说,我们可以获取 included 文件中的所有函数,以及该二进制文件中的所有 included 文件的细节。要获取有关二进制文件的完整信息,则需要执行以上这些步骤。

符号表存储了二进制文件中所有符号(函数)的细节。清单 1 以图表形式展示了从 STAB 格式收集一个函数的信息。


清单 1. 显示 STAB 类型的简单程序 

				
abcd.c    ----> Symbol is N_SOL
int function_name(int a, int b)   ------> Symbol is N_FUN
{     ----> Symbol is N_LBRAC
    int i;       ----->Symbol is N_SLINE
    i = a+b;
    return i;
} -----> Symbol is N_RBRAC
			

 

图 1 和图 2 中的流程图显示了从 STAB 格式收集信息的算法。

首先获取下一个 stab 部分,然后:

  • 到达 stab 部分的末端后,将从不同 stab 部分收集到的所有信息整合在一起,然后将它们存储到数据结构中。如果没有到达 stab 部分的末端,则执行以下操作:
    1. 检查该部分是否为 = N_SOL,如果是,则提取源文件名并返回到初始部分。
    2. 如果不等于 N_SOL,那么是否等于 = N_UNDF/N_ENDM?如果是,则重新设置当前函数。返回到初始部分。
    3. 如果不等于 = N_UNDF/N_ENDM,则检查它是否 = N_FUN,如果等于该值的话,则寻找子程序名。如果需要的话,请存储 stab 数。返回到初始部分。
    4. 如果该部分不等于 N_FUN,则检查它是否等于 N_LBRAC,如果是等于该值的话,那么它表示子程序的开始部分。(如果已经捕捉到 N_FUN 部分,那么可以为此设置一个标记)。返回到初始部分。
    5. 如果该部分不等于 N_LBRAC,则检查它是否等于 N_SLINE/n_XLINE;N_LBRAC 之后还有许多代码行。如果等于该值的话,则存储子程序的第一行和最后一行代码,以及行号。返回到初始部分。
    6. 如果该部分不等于 N_SLINE/n_XLINE,则检查它是否等于 N_RBRAC。如果等于该值的话,那么它表示函数的结束部分。返回到初始部分。如果该部分不等于 N_RBRAC,则忽略这个 stab 部分并回到初始部分。


图 1. 从 STAB 提取调试信息的流程图
显示从 STAB 提取调试信息的算法的流程图 

图 2. 从 STAB 提取调试信息的流程图(续)
显示从 STAB 提取调试信息的算法的流程图(续) 

采用 DWARF 格式的信息

在 DWARF 格式中,所有信息都采用树形格式,如 清单 2 所示。


清单 2. DWARF 树格式

				
COMPILE_UNIT<header overall offset = 0>:
<0><   11>	dw_tag_compile_unit		0
		DW_AT_stmt_list			0
		DW_AT_HP_actuals_stmt_list	0
		DW_AT_macro_info		0
		DW_AT_name			/usres/mainsoft-v52-orig/mw/init.C
		DW_AT_producer
		DW_AT_lanugage			DW_LANG_C_plus_plus
		DW_AT_HP_proc_per_section	yes(1)
		DW_AT_comp_dir			/home/yadvecha/ISLHPIA02/RTWIN/730/Source

LOCAL_SYMBOLS:
<1><  109>	DW_TAG_module
		DW_AT_name			/users/mainsoft-v52-orig/mw/init.C
		DW_AT_sibling			<365>
<2><  149>	DW_TAG_class_type
		DW_AT_name			setInFunc
		DW_AT_declaration		yes(1)
		DW_AT_sibling			<333>
<3><  166>	DW_TAG_subprogram
		DW_AT_name			~setInFunc
		DW_AT_HP_linkage_name		_ZN9setInFuncD1Ev
		DW_AT_declaration		no
		DW_AT_external			yes (1)
		DW_AT_accessibility		DW_ACCESS_public
		DW_AT_sibling			<229>
<4><  203>	DW_TAG_formal_parameter
		DW_AT_name			this
		DW_AT_artificial		yes (1)
		DW_AT_location			DW_OP_regx 34
		DW_AT_type			<365>

 

DWARF 中的符号表部分与 STAB 中的相同。从 DWARF 提取调试信息的过程如下所示:

将对二进制文件中的每个 DIE 进行解析,如果找到 CU DIE,则将存储 CU 的名称,该名称表示包含在该二进制文件中的文件的名称。保存 CU 名称的另一个目的是为了查找行号表,从而查找该 CU 中所有函数的行号。在 CU DIE 后捕捉到 DW_TAG_subprogram DIE 时,表示该子程序属于 CU DIE 下捕捉到的文件。当遇到 DW_TAG_subprogram 时,将保存函数的细节。从子程序的属性中,获得与该子程序对应的低位地址和高位地址,它们分别表示与子程序的起始部分和结束部分对应的地址。要计算与这些地址对应的行号,则需要从 CU DIE 的 debug_line 部分(行缓冲)中获取行缓冲。要获得 CU 的行缓冲的每一行,则需要检查低位地址匹配以找到函数的第一行的行号。函数的最后一行的行号从 debug_line 部分获得,其中,行号地址低于或等于函数的高位地址。

使用捕获的信息,我们可以创建源文件名、函数名和函数行号的列表。调试器使用这些基本信息片段来实现调试目的。

清单 3 展示了子程序的 DIE,以及如何从行表中获得行号。


清单 3. DWARF 行号映射 

				
				DW_TAG_subprogram 
				DW_AT_sibling = 10
DW_AT_external = 1
DW_AT_name = strndup
DW_AT_prototyped = 1
DW_AT_type = 10
DW_AT_low_pc = 0
DW_AT_high_pc = 0x7b
Address      File      Line     Col 

0x0 	       0 	       42        0 	   
0x9            0           44        0 
0x1a           0           44        0
0x24           0           46        0 
0x2c           0           47        0 
0x32           0           49        0 
0x41           0           50        0
0x47           0           51        0 
0x50           0           53        0
0x59           0           54        0  
0x6a           0           54        0 
0x73           0           55        0 
0x7b           0           56        0 
File 0: strndup.c

 

该子程序的起始行号和结束行号为 42 和 56,分别对应地址 0x0(DW_AT_low_pc) 和 0x7b(DW_AT_high_pc)

图 3 展示了从 DWARF 获取信息的算法的流程图:

首先应该初始化 DWARF 并从中获取信息。如果不存在 DIE,则结束此过程。如果存在 DIE,则执行以下操作:

  • 检查当前 DIE 中的 DWARF 标记名。如果 Tag = DW_TAG_compile_unit,则存储编译单元名称,该名称是二进制文件中包含的文件名。
  • 如果不包含 Tag = DW_TAG_compile_unit,则存储子程序名称。从子程序的 debug_line 部分获取 lowpc 和 highpc,计算它的初始行号和结束行号。然后将信息保存到相应的数据结构中。
  • 如果无法保存子程序名,则检查 DIE 是否可用,并重新执行相关操作。


图 3. 从 DWARF 提取调试信息的流程图
显示从 DWARF 提取调试信息的算法的流程图 

 

应该使用哪一种格式?

DWARF 是对程序源代码以及如何转换为可执行代码的扩展描述,它采用了块结构。在 DWARF 中添加新的描述或扩展现有描述非常简单。此外,DWARF 是一种比较高级的格式,可以描述更复杂的执行环境,例如不连续的范围、栈结构和堆栈解退 (stack unwinding),这些都是 STAB 无法实现的功能。另一方面,STAB 更加传统,并且在表达能力方面具有局限性。它依赖于预定义的符号和类型定义,并且不容易进行修改或扩展。

使用 DWARF 的一些主要优点包括:

  • 独立于平台的工具可以更轻松地读取 DWARF,不用解析复杂的 stab。
  • 目前处于积极的开发阶段。
  • 更容易进行扩展,因此可以轻松实现高级功能和优化的代码调试。较旧的工具可能会忽略新数据。

DWARF 适用于使用调试信息的新应用程序。如上所述,它提供了更多信息,并且易于扩展。但是,如果需要处理的应用程序的调试信息已经保存到 STAB 中,那么最好继续使用 STAB,除非拥有足够多的时间修改设计并完全切换到 DWARF 中。一般情况下,DWARF 是一种更有吸引力的选择。

 

结束语

下载 示例中提供了一个应用程序 (dwarfexample),它可以从二进制文件中读取 DWARF 格式的重要调试信息。该示例还提供了一个二进制文件 (stacktrace) 和源代码 (stack_tracing.c),因此用户可以使用二进制文件运行 “dwarfexample” 应用程序,并将应用程序输出与源代码中的实际数据(如 stack_tracing.c 所示)进行匹配。使用该应用程序显示的调试信息包括源文件(包含在二进制文件中)和每个源文件中的函数细节(函数名和行号)。通过编辑样例程序的源代码 (stack_tracing.c),可以添加或删除函数,或修改行号,并在使用新的二进制文件运行样例应用程序时,观察应用程序的输出是否反映了对样例程序所作的更改。

在样例应用程序(参见 下载)中,输出显示了二进制文件中的文件、函数和行号。您可以扩展该应用程序,以便从二进制文件中获得更多信息,比如内联子程序和调用帧信息。

 

下载

描述名字大小下载方法
从二进制文件提取调试信息的程序DwarfExample.zip10KBHTTP

关于下载方法的信息


参考资料

学习

讨论

作者简介

Suchitra Venugopal 的照片

Suchitra Venugopal 在软件行业有 9 年多的经验,曾担任过不同的职务。她主要关注于开源系统技术,从事过从 IBM Optim 迁移到 HP Itanium 平台的迁移工作,以及 zLinux 上的 ISW 测试自动化方面的工作。目前,她主要进行 SPSS 到 zOS 平台的迁移工作。

 

 

Adarsh Thampan 的照片

Adarsh Thampan 拥有 10 年的行业经验,所从事的工作包括在 HP Itanium 平台上迁移 IBM WebSphere Federation Server 和 Optim,以及在 System Z 和 z/OS 上将 IBM 产品迁移到 Linux。他还与他人合著了白皮书 Mixed Platform Stack Project: Linux on System z and IBM z/OS