(转)elf 和 dwarf 格式 - (sunznx) 振翅飞翔
31 August 2019
原文: 开发一个 Linux 调试器(四) ELF 和 DWARF

ELF 和 DWARF 简介

ELF 和 DWARF 可能是在程序员日常生活中经常使用但是可能却没有听说过的两个部件。ELF(Executable and Linkable Format)是 Linux 世界最广泛中使用的一种 Object File Format;它指定了一种将各部分数据存储在二进制文件的方式,比如说代码,静态数据,调试信息,以及一些字符串等这些数据。同时,也告诉加载器以何种方式对待二进制文件以及准备好执行,这涉及到将二进制文件的不同部分加载到内存中,以及根据其他一些组件的位置来修复(重定位)相关的数据位等等。我不会在文章中包含太多的 ELF 相关的知识,但是如果感兴趣的话你可以看一下这个精彩的图表或者这个 ELF 标准文档。

DWARF 是 ELF 文件通常使用的调试信息格式。通常来讲 DWARF 对 ELF 来说并不是必须的,但是这两者是被串联开发在一起的,并且一起使用非常好。这个格式允许编译器告诉调试器源代码是如何与被执行的二进制文件相关的。调试信息被分割在 ELF 不同的区段中,每一部分都传达了本区块的相关信息。一下是一些预定义的一些区段,如果信息过时的话,可以从这里获取最新信息,DWARF 调试信息简介:

.debug_info       包含 DWARF 信息入口(DIEs)的核心数据
.debug_abbrev     在.debug_info 中使用的缩写
.debug_aranges    内存地址和汇编间的映射
.debug_frame      调用栈帧信息
.debug_line       行号信息
.debug_loc        位置描述
.debug_macinfo    宏定义描述
.debug_pubnames   全局对象和函数查找表
.debug_pubtypes   全局类型查找表
.debug_rangesDIEs 引用地址范围
.debug_str        在.debug_info 中使用的字符串表
.debug_types      类型描述信息

我们最感兴趣的是 .debug_line.debug_info 区段,所以让我们用一个简单的程序来看一下一些 DWARF 信息吧:

int main() {
    long a = 3;
    long b = 2;
    long c = a + b;
    a = 4;
}

DWARF 行号表

如果在编译程序的时候指定了 -g 选项,然后通过 dwarfdump 运行结果,应该类似以下信息的行号区段:

.debug_line: line number info for a single cu
Source lines (from CU-DIE at .debug_info offset 0x0000000b):

            NS new statement, BB new basic block, ET end of text sequence
            PE prologue end, EB epilogue begin
            IS=val ISA number, DI=val discriminator value
<pc>        [lno,col] NS BB ET PE EB IS= DI= uri: "filepath"
0x00400670  [   1, 0] NS uri: "/home/simon/play/MiniDbg/examples/variable.cpp"
0x00400676  [   2,10] NS PE
0x0040067e  [   3,10] NS
0x00400686  [   4,14] NS
0x0040068a  [   4,16]
0x0040068e  [   4,10]
0x00400692  [   5, 7] NS
0x0040069a  [   6, 1] NS
0x0040069c  [   6, 1] NS ET

开始的一大串信息是关于如何理解 dump 的一些说明,主行号信息从 0x00400770 这行开始。本质上,它映射了代码内存地址和在文件中的行和列信息。
NS 表示该地址标志着新语句的开始,这通常用于设置断点或单步。
PE 标志着函数头部的结束,这有助于设置函数入口断点。
ET 标示该映射块的结尾。
信息实际上并不是像这样编码,实际的编码是一种非常节省空间的程序,由它来建立这些行号信息。

那么,如果我们想在 variable.cpp 中的 第 4 行 下一个断点,应该怎么做呢? 查找与该文件相对应的条目,然后找到相关的行号,找到相关的地址,然后设置一个断点就可以了。在我们的小程序中,就是这一条:

0x00400686  [   4,14] NS

所以我们需要在 0x00400686 地址处设置一个断点。如果你想尝试一下,你可以用你已经写过的调试器手工完成。
相反的工作也是如此,如果我们有一个内存位置 - 比如一个 RIP,并且想要找出它在源代码中的哪个位置,只需在行号信息表中找到最接近的映射地址,并从中获取行号即可。

DWARF 调试信息

.debug_info 是 DWARF 的核心所在。它给了我们程序中存在的关于类型,功能,变量,希望和梦想的信息。该区段的基本单位是 DWARF 信息入口,也就是被亲切地称为 DIE 的东西。DIE 包含一个标签,告诉你代表什么样的源代码级的条目,后面是一系列适用于该条目的属性。以下是之前的那个简单程序的 .debug_info :

.debug_info

COMPILE_UNIT<header overall offset = 0x00000000>:
< 0><0x0000000b>  DW_TAG_compile_unit
                  DW_AT_producer              clang version 3.9.1 (tags/RELEASE_391/final)
                  DW_AT_language              DW_LANG_C_plus_plus
                  DW_AT_name                  /super/secret/path/MiniDbg/examples/variable.cpp
                  DW_AT_stmt_list             0x00000000
                  DW_AT_comp_dir              /super/secret/path/MiniDbg/build
                  DW_AT_low_pc                0x00400670
                  DW_AT_high_pc               0x0040069c

LOCAL_SYMBOLS:
< 1><0x0000002e>  DW_TAG_subprogram
                  DW_AT_low_pc                0x00400670
                  DW_AT_high_pc               0x0040069c
                  DW_AT_frame_base            DW_OP_reg6
                  DW_AT_name                  main
                  DW_AT_decl_file             0x00000001 /super/secret/path/MiniDbg/examples/variable.cpp
                  DW_AT_decl_line             0x00000001
                  DW_AT_type                  <0x00000077>
                  DW_AT_external              yes(1)
< 2><0x0000004c>  DW_TAG_variable
                  DW_AT_location              DW_OP_fbreg -8
                  DW_AT_name                  a
                  DW_AT_decl_file             0x00000001 /super/secret/path/MiniDbg/examples/variable.cpp
                  DW_AT_decl_line             0x00000002
                  DW_AT_type                  <0x0000007e>
< 2><0x0000005a>  DW_TAG_variable
                  DW_AT_location              DW_OP_fbreg -16
                  DW_AT_name                  b
                  DW_AT_decl_file             0x00000001 /super/secret/path/MiniDbg/examples/variable.cpp
                  DW_AT_decl_line             0x00000003
                  DW_AT_type                  <0x0000007e>
< 2><0x00000068>  DW_TAG_variable
                  DW_AT_location              DW_OP_fbreg -24
                  DW_AT_name                  c
                  DW_AT_decl_file             0x00000001 /super/secret/path/MiniDbg/examples/variable.cpp
                  DW_AT_decl_line             0x00000004
                  DW_AT_type                  <0x0000007e>
< 1><0x00000077>  DW_TAG_base_type
                  DW_AT_name                  int
                  DW_AT_encoding              DW_ATE_signed
                  DW_AT_byte_size             0x00000004
< 1><0x0000007e>  DW_TAG_base_type
                  DW_AT_name                  long int
                  DW_AT_encoding              DW_ATE_signed
                  DW_AT_byte_size             0x00000008

第一个 DIE 表示一个编译单元(CU),它本质上是一个源文件,其中包含所有 #include 并且被解析的包含文件。以下是它们的包含注释的属性:

DW_AT_producer   clang version 3.9.1 (tags/RELEASE_391/final)    <-- The compiler which produced
this binary
DW_AT_language   DW_LANG_C_plus_plus                             <-- The source language
DW_AT_name       /super/secret/path/MiniDbg/examples/variable.cpp  <-- The name of the file which
this CU represents
DW_AT_stmt_list  0x00000000                                      <-- An offset into the line table
which tracks this CU
DW_AT_comp_dir   /super/secret/path/MiniDbg/build                  <-- The compilation directory
DW_AT_low_pc     0x00400670                                      <-- The start of the code for
this CU
DW_AT_high_pc    0x0040069c                                      <-- The end of the code for
this CU

其他 DIE 遵循类似的方案,你可以直观地看出不同属性的含义。
现在我们可以尝试使用我们新发现的 DWARF 知识来解决一些实际问题。

此刻处于哪个函数中?

比如说我们有一个 RIP,并想弄清楚我们处在那个函数中。一个简单的算法是:

for each compile unit:
    if the pc is between DW_AT_low_pc and DW_AT_high_pc:
        for each function in the compile unit:
            if the pc is between DW_AT_low_pc and DW_AT_high_pc:
                return function information

这可以用于大多数目标,但是在成员函数和内联存在的情况下,事情会变得更加困难。例如,存在内联的情况下,一旦我们发现某个函数范围包含了 RIP,需要对该 DIE 的子条目进行递归,以查看是否有任何更匹配的内联函数。我不会在这个调试器的代码中处理内联,但是如果你喜欢,你可以添加对它的支持。

如何在函数上下断点?

同样的,这取决于是否要支持成员函数,命名空间等。对于单独的函数,你可以在不同的编译单元中的函数中迭代查找,直到找到具有正确名称的函数。如果你的编译器足够友好的填写了.debug_pubnames 部分,则可以更有效地做到这一点。

一旦找到该函数,就可以在给定的内存地址 DW_AT_low_pc 上设置断点。但是,这将会在在函数头部开始时中断,最好在用户代码开始时中断。由于行表信息可以指定指定函数头部结束的内存地址,因此可以直接在行表中查找 DW_AT_low_pc 的值,然后继续读取,直到找到标记为函数头部结尾的条目。有些编译器不会输出这个信息,所以另外一个选择是在该函数的第二行条目给出的地址上设置一个断点。

假设我们要在示例程序中的 main 设置一个断点。我们搜索 main 函数,并得到这个 DIE:

< 1><0x0000002e>   DW_TAG_subprogram
                   DW_AT_low_pc                0x00400670
                   DW_AT_high_pc               0x0040069c
                   DW_AT_frame_base            DW_OP_reg6
                   DW_AT_name                  main
                   DW_AT_decl_file             0x00000001 /super/secret/path/MiniDbg/examples/variable.cpp
                   DW_AT_decl_line             0x00000001
                   DW_AT_type                  <0x00000077>
                   DW_AT_external              yes(1)

这告诉我们,函数从 0x00400670 开始。如果我们在行号表中查看,我们得到这个条目:

0x00400670  [   1, 0] NS uri: "/super/secret/path/MiniDbg/examples/variable.cpp"

我们想跳过函数头部,所以我们读取下一个条目:

0x00400676  [   2,10] NS PE

Clang 在这个条目中包含了头部结尾标志,所以我们知道在这里停下来,并在地址 0x00400676 上设置一个断点。

如何读取变量内容?

读取变量可能非常复杂。它们是可以在整个函数中变化的难以捉摸的东西,存储在寄存器中,放在内存中,被优化,被隐藏在角落里,等等等等乱七八糟。还好,我们简单的例子确实很简单。如果我们想要读取变量 a 的内容,则需要查看一下它的 DW_AT_location 属性。

DW_AT_location              DW_OP_fbreg -8

reg6 在 x86 架构上是 RBP,由 System V x86_64 ABI 指定。现在我们读取 RBP 的内容,从中减去 8,就找到了我们的变量。如果我们想实际上的理解这个变量,还需要查看它的类型:

< 2><0x0000004c>     DW_TAG_variable
                     DW_AT_name                  a
                     DW_AT_type                  <0x0000007e>

如果在调试信息中查找这种类型,我们得到这个 DIE:

< 1><0x0000007e>   DW_TAG_base_type
                   DW_AT_name                  long int
                   DW_AT_encoding              DW_ATE_signed
                   DW_AT_byte_size             0x00000008

这告诉我们,该类型是一个 8 字节(64 位)有符号整数类型,因此我们可以直接将这些字节解释为 int64_t 并将其显示给用户。

当然,这些类型可能会比这更复杂,因为它们必须能够表达类似于 C++ 类型的东西,但是这给出了它们如何工作的基本思想。
暂时回到 RBP ,Clang 可以很好地根据 RBP 来追踪帧基址。最近版本的 GCC 更倾向于 DW_OP_call_frame_cfa ,它涉及解析 .eh_frame ELF 部分,这是一个完全不同的文章,我并不打算写。如果你告诉 GCC 使用 DWARF 2 而不是更新的版本,它会倾向于输出位置列表,这更容易阅读:

DW_AT_frame_base           <loclist at offset 0x00000000 with 4 entries follows>
low-off : 0x00000000 addr  0x00400696 high-off  0x00000001 addr 0x00400697>DW_OP_breg7+8
low-off : 0x00000001 addr  0x00400697 high-off  0x00000004 addr 0x0040069a>DW_OP_breg7+16
low-off : 0x00000004 addr  0x0040069a high-off  0x00000031 addr 0x004006c7>DW_OP_breg6+16
low-off : 0x00000031 addr  0x004006c7 high-off  0x00000032 addr 0x004006c8>DW_OP_breg7+8

位置列表根据 RIP 给出不同的位置。这个例子展示了如果 RIP 位于距 DW_AT_low_pc0x0 偏移的位置,那么帧基址距离寄存器 7 中存储的值的偏移量为 8,如果它位于 0x1 和 0x4 之间,那么它距离寄存器 7 中存储的值偏移为 16,等等。

休息休息

这么多信息会让你的头脑晕晕乎乎,但好消息是,在接下来的几篇文章中,我们将有一个库来为我们完成这些艰难的工作。理解实际操作中的内容,特别是在出现问题时,或者你希望支持一些 DWARF 内容(在使用的任何 DWARF 库中未实现)时仍然有用。

如果你想了解有关 DWARF 的更多信息,那么可以从这里获取相关标准。在撰写本文时,DWARF 5 刚刚被发布,但是 DWARF 4 更受欢迎。