嵌入式Linux中文站

开发一个 Linux 调试器(四) ELF和DWARF


到目前为止,可能你已经听到了关于调试信息或者关于除了解析代码以外的理解源代码的方法的DWARF的只言片语。今天,我们将介绍源代码级的调试信息的细节,以备在该系列的余下部分使用它。

ELF和DWARF简介

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

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

  • .debug_abbrev.debug_info中使用的缩写
  • .debug_aranges内存地址和汇编间的映射
  • .debug_frame调用栈帧信息
  • .debug_info包含DWARF信息入口(DIEs)的核心数据
  • .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,如果它位于0x10x4之间,那么它距离寄存器7中存储的值偏移为16,等等。

休息休息

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

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

 

 

Linux平台下调试器的编写(五):源码和信号

在之前的几部分中我们学习了关于DWARF信息以及这些信息是如何在被执行的机器码和高级语言之间建立起联系的。在这部分中,我们将实现一些能够被调试器使用的DWARF相关原语。我们还将借此机会让调试器在命中断点之时输出当前源代码的上下文信息。

建立DWAR解析器

正如在再还系列的开始时所提到的,我们将会使用libelfin来处理DWARF信息。希望你在我的第一篇文章时就已经得到了该工具,如果没有的话,你可使用我从仓库fork出的fbreg分支。

一旦弄好了libelfin,就是时候把它加入到我们的调试器中了。第一步,解析ELF可执行文件并且从中获取DWARF信息。使用libelfin来完成这一步是非常简单的,仅仅需要对调试器做如下的改变:

class debugger {
public:
    debugger (std::string prog_name, pid_t pid)
         : m_prog_name{std::move(prog_name)}, m_pid{pid} {
        auto fd = open(m_prog_name.c_str(), O_RDONLY);

        m_elf = elf::elf{elf::create_mmap_loader(fd)};
        m_dwarf = dwarf::dwarf{dwarf::elf::create_loader(m_elf)};
    }
    //...

private:
    //...
    dwarf::dwarf m_dwarf;
    elf::elf m_elf;
};
## 调试信息原语
接下来我们可以实现根据RIP的值来检索行条目和函数DIE。先从```get_function_from_pc```开始吧:
```c++
dwarf::die debugger::get_function_from_pc(uint64_t pc) {
    for (auto &cu : m_dwarf.compilation_units()) {
        if (die_pc_range(cu.root()).contains(pc)) {
            for (const auto& die : cu.root()) {
                if (die.tag == dwarf::DW_TAG::subprogram) {
                    if (die_pc_range(die).contains(pc)) {
                        return die;
                    }
                }
            }
        }
    }

    throw std::out_of_range{"Cannot find function"};
}

这里我采取了一个比较笨拙的方法,只需遍历编译单元,直到知道到包含RIP的代码,然后一直迭代,直到在子节点中找到相关函数(DW_TAG_subprogram)。正如在上篇提到的,你可以想成员函数一样来处理这些,如果你想的话你还可以使用内联。 接下来是get_line_entry_from_pc:

dwarf::line_table::iterator debugger::get_line_entry_from_pc(uint64_t pc) {
    for (auto &cu : m_dwarf.compilation_units()) {
        if (die_pc_range(cu.root()).contains(pc)) {
            auto &lt = cu.get_line_table();
            auto it = lt.find_address(pc);
            if (it == lt.end()) {
                throw std::out_of_range{"Cannot find line entry"};
            }
            else {
                return it;
            }
        }
    }

    throw std::out_of_range{"Cannot find line entry"};
}

同样的,我们只需找到正确的便宜单元,然后请求行列表来获取相关条目。

输出源码

当命中断点的时候或者在源码上单步的时候,我们需要知道源代码被执行到哪里了。

void debugger::print_source(const std::string& file_name, unsigned line, unsigned n_lines_context) {
    std::ifstream file {file_name};

    //Work out a window around the desired line
    auto start_line = line <= n_lines_context ? 1 : line - n_lines_context;
    auto end_line = line + n_lines_context + (line < n_lines_context ? n_lines_context - line : 0) + 1;

    char c{};
    auto current_line = 1u;
    //Skip lines up until start_line
    while (current_line != start_line && file.get(c)) {
        if (c == '\n') {
            ++current_line;
        }
    }

    //Output cursor if we're at the current line
    std::cout << (current_line==line ? "> " : "  ");

    //Write lines up until end_line
    while (current_line <= end_line && file.get(c)) {
        std::cout << c;
        if (c == '\n') {
            ++current_line;
            //Output cursor if we're at the current line
            std::cout << (current_line==line ? "> " : "  ");
        }
    }

    //Write newline and make sure that the stream is flushed properly
    std::cout << std::endl;
}

现在,可以输出源码了,只需要将其挂载到我们的调试器中。当调试器从断点或者(实际上)但不中获取信号的时候是显示源码的上好时机了。这样做的话,调试器就需要一个更好的信号处理了。

更好的信号处理

我们希望能够输出什么样的信号被发送给了进程,同时亦希望知道该信号是如何被产生的。例如,我们想知道收到的SIGTRAP信号是由于命中断点还是一个单步执行完产生的,亦或者是由于新线程建立而产生的,等等。 幸运的是,ptrace再一次支援了我们。ptrace有一个参数PTRACE_GETSIGINFO,该参数将会给出进程之前发出的信号的相关信息。如下:

siginfo_t debugger::get_signal_info() {
    siginfo_t info;
    ptrace(PTRACE_GETSIGINFO, m_pid, nullptr, &info);
    return info;
}

这里出现了一个siginfo_t的对象,它提供了如下的信息:

siginfo_t {
    int      si_signo;     /* Signal number */
    int      si_errno;     /* An errno value */
    int      si_code;      /* Signal code */
    int      si_trapno;    /* Trap number that caused
                              hardware-generated signal
                              (unused on most architectures) */
    pid_t    si_pid;       /* Sending process ID */
    uid_t    si_uid;       /* Real user ID of sending process */
    int      si_status;    /* Exit value or signal */
    clock_t  si_utime;     /* User time consumed */
    clock_t  si_stime;     /* System time consumed */
    sigval_t si_value;     /* Signal value */
    int      si_int;       /* POSIX.1b signal */
    void    *si_ptr;       /* POSIX.1b signal */
    int      si_overrun;   /* Timer overrun count;
                              POSIX.1b timers */
    int      si_timerid;   /* Timer ID; POSIX.1b timers */
    void    *si_addr;      /* Memory location which caused fault */
    long     si_band;      /* Band event (was int in
                              glibc 2.3.2 and earlier) */
    int      si_fd;        /* File descriptor */
    short    si_addr_lsb;  /* Least significant bit of address
                              (since Linux 2.6.32) */
    void    *si_lower;     /* Lower bound when address violation
                              occurred (since Linux 3.19) */
    void    *si_upper;     /* Upper bound when address violation
                              occurred (since Linux 3.19) */
    int      si_pkey;      /* Protection key on PTE that caused
                              fault (since Linux 4.6) */
    void    *si_call_addr; /* Address of system call instruction
                              (since Linux 3.5) */
    int      si_syscall;   /* Number of attempted system call
                              (since Linux 3.5) */
    unsigned int si_arch;  /* Architecture of attempted system call
                              (since Linux 3.5) */
}

我将使用si——signo来找出是哪一个信号被发送,然后使用si_code来获取有关该信号的更多信息。放置该段代码的最佳地方是在我们的wait_for_signal函数中:

void debugger::wait_for_signal() {
    int wait_status;
    auto options = 0;
    waitpid(m_pid, &wait_status, options);

    auto siginfo = get_signal_info();

    switch (siginfo.si_signo) {
    case SIGTRAP:
        handle_sigtrap(siginfo);
        break;
    case SIGSEGV:
        std::cout << "Yay, segfault. Reason: " << siginfo.si_code << std::endl;
        break;
    default:
        std::cout << "Got signal " << strsignal(siginfo.si_signo) << std::endl;
    }
}

现在处理SIGTRAP只需知道SI_KERNEL或者TRAP_BPKPT将会在断点命中时被发送,TRAP_TRACE将会在单步完成的时候被发送:

void debugger::handle_sigtrap(siginfo_t info) {
    switch (info.si_code) {
    //one of these will be set if a breakpoint was hit
    case SI_KERNEL:
    case TRAP_BRKPT:
    {
        set_pc(get_pc()-1); //put the pc back where it should be
        std::cout << "Hit breakpoint at address 0x" << std::hex << get_pc() << std::endl;
        auto line_entry = get_line_entry_from_pc(get_pc());
        print_source(line_entry->file->path, line_entry->line);
        return;
    }
    //this will be set if the signal was sent by single stepping
    case TRAP_TRACE:
        return;
    default:
        std::cout << "Unknown SIGTRAP code " << info.si_code << std::endl;
        return;
    }
}

你可以处理一堆不同风格的信号。详情请参阅man sigaction。 由于我们现在在得到SIGTRAP时修正RIP,所以可以去掉step_over_breakpoint中的部分代码:

void debugger::step_over_breakpoint() {
    if (m_breakpoints.count(get_pc())) {
        auto& bp = m_breakpoints[get_pc()];
        if (bp.is_enabled()) {
            bp.disable();
            ptrace(PTRACE_SINGLESTEP, m_pid, nullptr, nullptr);
            wait_for_signal();
            bp.enable();
        }
    }
}

测试

现在,你应该可以在某些地址设置断点,运行程序,查看鼠标标记的正在被执行的代码的源代码了。

下一次我们将添加源码级的断点。可以在此处获取源码

 

本文永久更新链接:http://embeddedlinux.org.cn/emb-linux/system-development/201707/04-6925.html



分享:

评论