标签 调试器 下的文章

在上一部分我们学习了关于 DWARF 的信息,以及它如何被用于读取变量和将被执行的机器码与我们的高级语言的源码联系起来。在这一部分,我们将进入实践,实现一些我们调试器后面会使用的 DWARF 原语。我们也会利用这个机会,使我们的调试器可以在命中一个断点时打印出当前的源码上下文。

系列文章索引

随着后面文章的发布,这些链接会逐渐生效。

  1. 准备环境
  2. 断点
  3. 寄存器和内存
  4. Elves 和 dwarves
  5. 源码和信号
  6. 源码级逐步执行
  7. 源码级断点
  8. 调用栈展开
  9. 读取变量
  10. 下一步

设置我们的 DWARF 解析器

正如我在这系列文章开始时备注的,我们会使用 libelfin 来处理我们的 DWARF 信息。希望你已经在第一部分设置好了这些,如果没有的话,现在做吧,确保你使用我仓库的 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;
};

我们使用了 open 而不是 std::ifstream,因为 elf 加载器需要传递一个 UNIX 文件描述符给 mmap,从而可以将文件映射到内存而不是每次读取一部分。

调试信息原语

下一步我们可以实现从程序计数器的值中提取行条目(line entry)以及函数 DWARF 信息条目(function DIE)的函数。我们从 get_function_from_pc 开始:

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"};
}

这里我采用了朴素的方法,迭代遍历编译单元直到找到一个包含程序计数器的,然后迭代遍历它的子节点直到我们找到相关函数(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};

    //获得一个所需行附近的窗口
    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;
    //跳过 start_line 之前的行
    while (current_line != start_line && file.get(c)) {
        if (c == '\n') {
            ++current_line;
        }
    }

    //如果我们在当前行则输出光标
    std::cout << (current_line==line ? "> " : "  ");

    //输出行直到 end_line
    while (current_line <= end_line && file.get(c)) {
        std::cout << c;
        if (c == '\n') {
            ++current_line;
            //如果我们在当前行则输出光标
            std::cout << (current_line==line ? "> " : "  ");
        }
    }

    //输出换行确保恰当地清空了流
    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;     /* 信号编号 */
    int      si_errno;     /* errno 值 */
    int      si_code;      /* 信号代码 */
    int      si_trapno;    /* 导致生成硬件信号的陷阱编号
                              (大部分架构中都没有使用) */
    pid_t    si_pid;       /* 发送信号的进程 ID */
    uid_t    si_uid;       /* 发送信号进程的用户 ID */
    int      si_status;    /* 退出值或信号 */
    clock_t  si_utime;     /* 消耗的用户时间 */
    clock_t  si_stime;     /* 消耗的系统时间 */
    sigval_t si_value;     /* 信号值 */
    int      si_int;       /* POSIX.1b 信号 */
    void    *si_ptr;       /* POSIX.1b 信号 */
    int      si_overrun;   /* 计时器 overrun 计数;
                              POSIX.1b 计时器 */
    int      si_timerid;   /* 计时器 ID; POSIX.1b 计时器 */
    void    *si_addr;      /* 导致错误的内存地址 */
    long     si_band;      /* Band event (在 glibc 2.3.2 和之前版本中是 int 类型) */
    int      si_fd;        /* 文件描述符 */
    short    si_addr_lsb;  /* 地址的最不重要位
                              (自 Linux 2.6.32) */
    void    *si_lower;     /* 出现地址违规的下限 (自 Linux 3.19) */
    void    *si_upper;     /* 出现地址违规的上限 (自 Linux 3.19) */
    int      si_pkey;      /* PTE 上导致错误的保护键 (自 Linux 4.6) */
    void    *si_call_addr; /* 系统调用指令的地址
                              (自 Linux 3.5) */
    int      si_syscall;   /* 系统调用尝试次数
                              (自 Linux 3.5) */
    unsigned int si_arch;  /* 尝试系统调用的架构
                              (自 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_KERNELTRAP_BRKPT,而逐步执行结束时会发送 TRAP_TRACE 就足够了:

void debugger::handle_sigtrap(siginfo_t info) {
    switch (info.si_code) {
    //如果命中了一个断点其中的一个会被设置
    case SI_KERNEL:
    case TRAP_BRKPT:
    {
        set_pc(get_pc()-1); //将程序计数器的值设置为它应该指向的地方
        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;
    }
    //如果信号是由逐步执行发送的,这会被设置
    case TRAP_TRACE:
        return;
    default:
        std::cout << "Unknown SIGTRAP code " << info.si_code << std::endl;
        return;
    }
}

这里有一大堆不同风格的信号你可以处理。查看 man sigaction 获取更多信息。

由于当我们收到 SIGTRAP 信号时我们已经修正了程序计数器的值,我们可以从 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();
        }
    }
}

测试

现在你应该可以在某个地址设置断点,运行程序然后看到打印出了源码,而且正在被执行的行被光标标记了出来。

后面我们会添加设置源码级别断点的功能。同时,你可以从这里获取该博文的代码。


via: https://blog.tartanllama.xyz/c++/2017/04/24/writing-a-linux-debugger-source-signal/

作者:TartanLlama 译者:ictlyh 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

到目前为止,你已经偶尔听到了关于 dwarves、调试信息、一种无需解析就可以理解源码方式。今天我们会详细介绍源码级的调试信息,作为本指南后面部分使用它的准备。

系列文章索引

随着后面文章的发布,这些链接会逐渐生效。

  1. 准备环境
  2. 断点
  3. 寄存器和内存
  4. Elves 和 dwarves
  5. 源码和信号
  6. 源码级逐步执行
  7. 源码级断点
  8. 调用栈展开
  9. 读取变量
  10. 下一步

ELF 和 DWARF 简介

ELF 和 DWARF 可能是两个你没有听说过,但可能大部分时间都在使用的组件。ELF(Executable and Linkable Format,可执行和可链接格式)是 Linux 系统中使用最广泛的目标文件格式;它指定了一种存储二进制文件的所有不同部分的方式,例如代码、静态数据、调试信息以及字符串。它还告诉加载器如何加载二进制文件并准备执行,其中包括说明二进制文件不同部分在内存中应该放置的地点,哪些位需要根据其它组件的位置固定(重分配)以及其它。在这些博文中我不会用太多篇幅介绍 ELF,但是如果你感兴趣的话,你可以查看这个很好的信息图该标准

DWARF是通常和 ELF 一起使用的调试信息格式。它不一定要绑定到 ELF,但它们两者是一起发展的,一起工作得很好。这种格式允许编译器告诉调试器最初的源代码如何和被执行的二进制文件相关联。这些信息分散到不同的 ELF 部分,每个部分都衔接有一份它自己的信息。下面不同部分的定义,信息取自这个稍有过时但非常重要的 DWARF 调试格式简介

  • .debug_abbrev .debug_info 部分使用的缩略语
  • .debug_aranges 内存地址和编译的映射
  • .debug_frame 调用帧信息
  • .debug_info 包括 DWARF 信息条目 DWARF Information Entries (DIEs)的核心 DWARF 数据
  • .debug_line 行号程序
  • .debug_loc 位置描述
  • .debug_macinfo 宏描述
  • .debug_pubnames 全局对象和函数查找表
  • .debug_pubtypes 全局类型查找表
  • .debug_ranges DIEs 的引用地址范围
  • .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 的信息 - 主要的行号数据从以 0x00400670 开头的行开始。实际上这是一个代码内存地址到文件中行列号的映射。NS 表示地址标记一个新语句的开始,这通常用于设置断点或逐步执行。PE 表示函数序言(LCTT 译注:在汇编语言中,function prologue 是程序开始的几行代码,用于准备函数中用到的栈和寄存器)的结束,这对于设置函数断点非常有帮助。ET 表示转换单元的结束。信息实际上并不像这样编码;真正的编码是一种非常节省空间的排序程序,可以通过执行它来建立这些行信息。

那么,假设我们想在 variable.cpp 的第 4 行设置断点,我们该怎么做呢?我们查找和该文件对应的条目,然后查找对应的行条目,查找对应的地址,在那里设置一个断点。在我们的例子中,条目是:

0x00400686  [   4,14] NS

假设我们想在地址 0x00400686 处设置断点。如果你想尝试的话你可以在已经编写好的调试器上手动实现。

反过来也是如此。如果我们已经有了一个内存地址 - 例如说,一个程序计数器值 - 想找到它在源码中的位置,我们只需要从行表信息中查找最接近的映射地址并从中抓取行号。

DWARF 调试信息

.debug_info 部分是 DWARF 的核心。它给我们关于我们程序中存在的类型、函数、变量、希望和梦想的信息。这部分的基本单元是 DWARF 信息条目(DWARF Information Entry),我们亲切地称之为 DIEs。一个 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),实际上是一个包括了所有 #includes 和类似语句的源文件。下面是带含义注释的属性:

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  <-- 该 CU 表示的文件名称
DW_AT_stmt_list  0x00000000                                      <-- 跟踪该 CU 的行表偏移
DW_AT_comp_dir   /super/secret/path/MiniDbg/build                  <-- 编译目录
DW_AT_low_pc     0x00400670                                      <-- 该 CU 的代码起始
DW_AT_high_pc    0x0040069c                                      <-- 该 CU 的代码结尾

其它的 DIEs 遵循类似的模式,你也很可能推测出不同属性的含义。

现在我们可以根据新学到的 DWARF 知识尝试和解决一些实际问题。

当前我在哪个函数?

假设我们有一个程序计数器值然后想找到当前我们在哪一个函数。一个解决该问题的简单算法:

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

这对于很多目的都有效,但如果有成员函数或者内联(inline),就会变得更加复杂。假如有内联,一旦我们找到其范围包括我们的程序计数器(PC)的函数,我们需要递归遍历该 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

这告诉我们内容被保存在以栈帧基(base of the stack frame)偏移为 -8 的地方。为了找到栈帧基,我们查找所在函数的 DW_AT_frame_base 属性。

DW_AT_frame_base            DW_OP_reg6

System V x86\_64 ABI 我们可以知道 reg6 在 x86 中是帧指针寄存器。现在我们读取帧指针的内容,从中减去 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++ 的类型,但是这能给你它们如何工作的基本认识。

再次回到帧基(frame base),Clang 可以通过帧指针寄存器跟踪帧基。最近版本的 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

位置列表取决于程序计数器所处的位置给出不同的位置。这个例子告诉我们如果程序计数器是在 DW_AT_low_pc 偏移量为 0x0 的位置,那么帧基就在和寄存器 7 中保存的值偏移量为 8 的位置,如果它是在 0x10x4 之间,那么帧基就在和相同位置偏移量为 16 的位置,以此类推。

休息一会

这里有很多的信息需要你的大脑消化,但好消息是在后面的几篇文章中我们会用一个库替我们完成这些艰难的工作。理解概念仍然很有帮助,尤其是当出现错误或者你想支持一些你使用的 DWARF 库所没有实现的 DWARF 概念时。

如果你想了解更多关于 DWARF 的内容,那么你可以从这里获取其标准。在写这篇博客时,刚刚发布了 DWARF 5,但更普遍支持 DWARF 4。


via: https://blog.tartanllama.xyz/c++/2017/04/05/writing-a-linux-debugger-elf-dwarf/

作者:Simon Brand 译者:ictlyh 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

上一篇博文中我们给调试器添加了一个简单的地址断点。这次,我们将添加读写寄存器和内存的功能,这将使我们能够使用我们的程序计数器、观察状态和改变程序的行为。

系列文章索引

随着后面文章的发布,这些链接会逐渐生效。

  1. 准备环境
  2. 断点
  3. 寄存器和内存
  4. Elves 和 dwarves
  5. 源码和信号
  6. 源码级逐步执行
  7. 源码级断点
  8. 调用栈展开
  9. 读取变量
  10. 下一步

注册我们的寄存器

在我们真正读取任何寄存器之前,我们需要告诉调试器一些关于我们的目标平台的信息,这里是 x8664 平台。除了多组通用和专用目的寄存器,x8664 还提供浮点和向量寄存器。为了简化,我将跳过后两种寄存器,但是你如果喜欢的话也可以选择支持它们。x86\_64 也允许你像访问 32、16 或者 8 位寄存器那样访问一些 64 位寄存器,但我只会介绍 64 位寄存器。由于这些简化,对于每个寄存器我们只需要它的名称、它的 DWARF 寄存器编号以及 ptrace 返回结构体中的存储地址。我使用范围枚举引用这些寄存器,然后我列出了一个全局寄存器描述符数组,其中元素顺序和 ptrace 中寄存器结构体相同。

enum class reg {
    rax, rbx, rcx, rdx,
    rdi, rsi, rbp, rsp,
    r8,  r9,  r10, r11,
    r12, r13, r14, r15,
    rip, rflags,    cs,
    orig_rax, fs_base,
    gs_base,
    fs, gs, ss, ds, es
};

constexpr std::size_t n_registers = 27;

struct reg_descriptor {
    reg r;
    int dwarf_r;
    std::string name;
};

const std::array<reg_descriptor, n_registers> g_register_descriptors {{
    { reg::r15, 15, "r15" },
    { reg::r14, 14, "r14" },
    { reg::r13, 13, "r13" },
    { reg::r12, 12, "r12" },
    { reg::rbp, 6, "rbp" },
    { reg::rbx, 3, "rbx" },
    { reg::r11, 11, "r11" },
    { reg::r10, 10, "r10" },
    { reg::r9, 9, "r9" },
    { reg::r8, 8, "r8" },
    { reg::rax, 0, "rax" },
    { reg::rcx, 2, "rcx" },
    { reg::rdx, 1, "rdx" },
    { reg::rsi, 4, "rsi" },
    { reg::rdi, 5, "rdi" },
    { reg::orig_rax, -1, "orig_rax" },
    { reg::rip, -1, "rip" },
    { reg::cs, 51, "cs" },
    { reg::rflags, 49, "eflags" },
    { reg::rsp, 7, "rsp" },
    { reg::ss, 52, "ss" },
    { reg::fs_base, 58, "fs_base" },
    { reg::gs_base, 59, "gs_base" },
    { reg::ds, 53, "ds" },
    { reg::es, 50, "es" },
    { reg::fs, 54, "fs" },
    { reg::gs, 55, "gs" },
}};

如果你想自己看看的话,你通常可以在 /usr/include/sys/user.h 找到寄存器数据结构,另外 DWARF 寄存器编号取自 System V x86\_64 ABI

现在我们可以编写一堆函数来和寄存器交互。我们希望可以读取寄存器、写入数据、根据 DWARF 寄存器编号获取值,以及通过名称查找寄存器,反之类似。让我们先从实现 get_register_value 开始:

uint64_t get_register_value(pid_t pid, reg r) {
    user_regs_struct regs;
    ptrace(PTRACE_GETREGS, pid, nullptr, &regs);
    //...
}

ptrace 使得我们可以轻易获得我们想要的数据。我们只需要构造一个 user_regs_struct 实例并把它和 PTRACE_GETREGS 请求传递给 ptrace

现在根据要请求的寄存器,我们要读取 regs。我们可以写一个很大的 switch 语句,但由于我们 g_register_descriptors 表的布局顺序和 user_regs_struct 相同,我们只需要搜索寄存器描述符的索引,然后作为 uint64_t 数组访问 user_regs_struct 就行。(你也可以重新排序 reg 枚举变量,然后使用索引把它们转换为底层类型,但第一次我就使用这种方式编写,它能正常工作,我也就懒得改它了。)

        auto it = std::find_if(begin(g_register_descriptors), end(g_register_descriptors),
                               [r](auto&& rd) { return rd.r == r; });

        return *(reinterpret_cast<uint64_t*>(&regs) + (it - begin(g_register_descriptors)));

uint64_t 的转换是安全的,因为 user_regs_struct 是一个标准布局类型,但我认为指针算术技术上是 未定义的行为 undefined behavior 。当前没有编译器会对此产生警告,我也懒得修改,但是如果你想保持最严格的正确性,那就写一个大的 switch 语句。

set_register_value 非常类似,我们只是写入该位置并在最后写回寄存器:

void set_register_value(pid_t pid, reg r, uint64_t value) {
    user_regs_struct regs;
    ptrace(PTRACE_GETREGS, pid, nullptr, &regs);
    auto it = std::find_if(begin(g_register_descriptors), end(g_register_descriptors),
                           [r](auto&& rd) { return rd.r == r; });

    *(reinterpret_cast<uint64_t*>(&regs) + (it - begin(g_register_descriptors))) = value;
    ptrace(PTRACE_SETREGS, pid, nullptr, &regs);
}

下一步是通过 DWARF 寄存器编号查找。这次我会真正检查一个错误条件以防我们得到一些奇怪的 DWARF 信息。

uint64_t get_register_value_from_dwarf_register (pid_t pid, unsigned regnum) {
    auto it = std::find_if(begin(g_register_descriptors), end(g_register_descriptors),
                           [regnum](auto&& rd) { return rd.dwarf_r == regnum; });
    if (it == end(g_register_descriptors)) {
        throw std::out_of_range{"Unknown dwarf register"};
    }

    return get_register_value(pid, it->r);
}

就快完成啦,现在我们已经有了寄存器名称查找:

std::string get_register_name(reg r) {
    auto it = std::find_if(begin(g_register_descriptors), end(g_register_descriptors),
                           [r](auto&& rd) { return rd.r == r; });
    return it->name;
}

reg get_register_from_name(const std::string& name) {
    auto it = std::find_if(begin(g_register_descriptors), end(g_register_descriptors),
                           [name](auto&& rd) { return rd.name == name; });
    return it->r;
}

最后我们会添加一个简单的帮助函数用于导出所有寄存器的内容:

void debugger::dump_registers() {
    for (const auto& rd : g_register_descriptors) {
        std::cout << rd.name << " 0x"
                  << std::setfill('0') << std::setw(16) << std::hex << get_register_value(m_pid, rd.r) << std::endl;
    }
}

正如你看到的,iostreams 有非常精确的接口用于美观地输出十六进制数据(啊哈哈哈哈哈哈)。如果你喜欢你也可以通过 I/O 操纵器来摆脱这种混乱。

这些已经足够支持我们在调试器接下来的部分轻松地处理寄存器,所以我们现在可以把这些添加到我们的用户界面。

显示我们的寄存器

这里我们要做的就是给 handle_command 函数添加一个命令。通过下面的代码,用户可以输入 register read raxregister write rax 0x42 以及类似的语句。

    else if (is_prefix(command, "register")) {
        if (is_prefix(args[1], "dump")) {
            dump_registers();
        }
        else if (is_prefix(args[1], "read")) {
            std::cout << get_register_value(m_pid, get_register_from_name(args[2])) << std::endl;
        }
        else if (is_prefix(args[1], "write")) {
            std::string val {args[3], 2}; //assume 0xVAL
            set_register_value(m_pid, get_register_from_name(args[2]), std::stol(val, 0, 16));
        }
    }

接下来做什么?

设置断点的时候我们已经读取和写入内存,因此我们只需要添加一些函数用于隐藏 ptrace 调用。

uint64_t debugger::read_memory(uint64_t address) {
    return ptrace(PTRACE_PEEKDATA, m_pid, address, nullptr);
}

void debugger::write_memory(uint64_t address, uint64_t value) {
    ptrace(PTRACE_POKEDATA, m_pid, address, value);
}

你可能想要添加支持一次读取或者写入多个字节,你可以在每次希望读取另一个字节时通过递增地址来实现。如果你需要的话,你也可以使用 process_vm_readvprocess_vm_writev/proc/<pid>/mem 代替 ptrace

现在我们会给我们的用户界面添加命令:

    else if(is_prefix(command, "memory")) {
        std::string addr {args[2], 2}; //assume 0xADDRESS

        if (is_prefix(args[1], "read")) {
            std::cout << std::hex << read_memory(std::stol(addr, 0, 16)) << std::endl;
        }
        if (is_prefix(args[1], "write")) {
            std::string val {args[3], 2}; //assume 0xVAL
            write_memory(std::stol(addr, 0, 16), std::stol(val, 0, 16));
        }
    }

continue_execution 打补丁

在我们测试我们的更改之前,我们现在可以实现一个更健全的 continue_execution 版本。由于我们可以获取程序计数器,我们可以检查我们的断点映射来判断我们是否处于一个断点。如果是的话,我们可以停用断点并在继续之前跳过它。

为了清晰和简洁起见,首先我们要添加一些帮助函数:

uint64_t debugger::get_pc() {
    return get_register_value(m_pid, reg::rip);
}

void debugger::set_pc(uint64_t pc) {
    set_register_value(m_pid, reg::rip, pc);
}

然后我们可以编写函数来跳过断点:

void debugger::step_over_breakpoint() {
    // - 1 because execution will go past the breakpoint
    auto possible_breakpoint_location = get_pc() - 1;

    if (m_breakpoints.count(possible_breakpoint_location)) {
        auto& bp = m_breakpoints[possible_breakpoint_location];

        if (bp.is_enabled()) {
            auto previous_instruction_address = possible_breakpoint_location;
            set_pc(previous_instruction_address);

            bp.disable();
            ptrace(PTRACE_SINGLESTEP, m_pid, nullptr, nullptr);
            wait_for_signal();
            bp.enable();
        }
    }
}

首先我们检查当前程序计算器的值是否设置了一个断点。如果有,首先我们把执行返回到断点之前,停用它,跳过原来的指令,再重新启用断点。

wait_for_signal 封装了我们常用的 waitpid 模式:

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

最后我们像下面这样重写 continue_execution

void debugger::continue_execution() {
    step_over_breakpoint();
    ptrace(PTRACE_CONT, m_pid, nullptr, nullptr);
    wait_for_signal();
}

测试效果

现在我们可以读取和修改寄存器了,我们可以对我们的 hello world 程序做一些有意思的更改。类似第一次测试,再次尝试在 call 指令处设置断点然后从那里继续执行。你可以看到输出了 Hello world。现在是有趣的部分,在输出调用后设一个断点、继续、将 call 参数设置代码的地址写入程序计数器(rip)并继续。由于程序计数器操纵,你应该再次看到输出了 Hello world。为了以防你不确定在哪里设置断点,下面是我上一篇博文中的 objdump 输出:

0000000000400936 <main>:
  400936:   55                      push   rbp
  400937:   48 89 e5                mov    rbp,rsp
  40093a:   be 35 0a 40 00          mov    esi,0x400a35
  40093f:   bf 60 10 60 00          mov    edi,0x601060
  400944:   e8 d7 fe ff ff          call   400820 <_ZStlsISt11char_traitsIcEERSt13basic_ostreamIcT_ES5_PKc@plt>
  400949:   b8 00 00 00 00          mov    eax,0x0
  40094e:   5d                      pop    rbp
  40094f:   c3                      ret

你要将程序计数器移回 0x40093a 以便正确设置 esiedi 寄存器。

在下一篇博客中,我们会第一次接触到 DWARF 信息并给我们的调试器添加一系列逐步调试的功能。之后,我们会有一个功能工具,它能逐步执行代码、在想要的地方设置断点、修改数据以及其它。一如以往,如果你有任何问题请留下你的评论!

你可以在这里找到这篇博文的代码。


via: https://blog.tartanllama.xyz/c++/2017/03/31/writing-a-linux-debugger-registers/

作者:TartanLlama 译者:ictlyh 校对:jasminepeng

本文由 LCTT 原创编译,Linux中国 荣誉推出

在该系列的第一部分,我们写了一个小的进程启动器,作为我们调试器的基础。在这篇博客中,我们会学习在 x86 Linux 上断点是如何工作的,以及如何给我们工具添加设置断点的能力。

系列文章索引

随着后面文章的发布,这些链接会逐渐生效。

  1. 准备环境
  2. 断点
  3. 寄存器和内存
  4. Elves 和 dwarves
  5. 源码和信号
  6. 源码层逐步执行
  7. 源码层断点
  8. 调用栈
  9. 读取变量 10.之后步骤

断点是如何形成的?

有两种类型的断点:硬件和软件。硬件断点通常涉及到设置与体系结构相关的寄存器来为你产生断点,而软件断点则涉及到修改正在执行的代码。在这篇文章中我们只会关注软件断点,因为它们比较简单,而且可以设置任意多断点。在 x86 机器上任一时刻你最多只能有 4 个硬件断点,但是它们能让你在读取或者写入给定地址时触发,而不是只有当代码执行到那里的时候。

我前面说软件断点是通过修改正在执行的代码实现的,那么问题就来了:

  • 我们如何修改代码?
  • 为了设置断点我们要做什么修改?
  • 如何告知调试器?

第一个问题的答案显然是 ptrace。我们之前已经用它为我们的程序设置跟踪并继续程序的执行,但我们也可以用它来读或者写内存。

当执行到断点时,我们的更改要让处理器暂停并给程序发送信号。在 x86 机器上这是通过 int 3 重写该地址上的指令实现的。x86 机器有个 中断向量表 interrupt vector table ,操作系统能用它来为多种事件注册处理程序,例如页故障、保护故障和无效操作码。它就像是注册错误处理回调函数,但是是在硬件层面的。当处理器执行 int 3 指令时,控制权就被传递给断点中断处理器,对于 Linux 来说,就是给进程发送 SIGTRAP 信号。你可以在下图中看到这个进程,我们用 0xcc 覆盖了 mov 指令的第一个字节,它是 init 3 的指令代码。

断点

谜题的最后一个部分是调试器如何被告知中断的。如果你回顾前面的文章,我们可以用 waitpid 来监听被发送给被调试的程序的信号。这里我们也可以这样做:设置断点、继续执行程序、调用 waitpid 并等待直到发生 SIGTRAP。然后就可以通过打印已运行到的源码位置、或改变有图形用户界面的调试器中关注的代码行,将这个断点传达给用户。

实现软件断点

我们会实现一个 breakpoint 类来表示某个位置的断点,我们可以根据需要启用或者停用该断点。

class breakpoint {
public:
    breakpoint(pid_t pid, std::intptr_t addr)
        : m_pid{pid}, m_addr{addr}, m_enabled{false}, m_saved_data{}
    {}

    void enable();
    void disable();

    auto is_enabled() const -> bool { return m_enabled; }
    auto get_address() const -> std::intptr_t { return m_addr; }

private:
    pid_t m_pid;
    std::intptr_t m_addr;
    bool m_enabled;
    uint64_t m_saved_data; //data which used to be at the breakpoint address
};

这里的大部分代码都是跟踪状态;真正神奇的地方是 enabledisable 函数。

正如我们上面学到的,我们要用 int 3 指令 - 编码为 0xcc - 替换当前指定地址的指令。我们还要保存该地址之前的值,以便后面恢复该代码;我们不想忘了执行用户(原来)的代码。

void breakpoint::enable() {
    m_saved_data = ptrace(PTRACE_PEEKDATA, m_pid, m_addr, nullptr);
    uint64_t int3 = 0xcc;
    uint64_t data_with_int3 = ((m_saved_data & ~0xff) | int3); //set bottom byte to 0xcc
    ptrace(PTRACE_POKEDATA, m_pid, m_addr, data_with_int3);

    m_enabled = true;
}

PTRACE_PEEKDATA 请求告知 ptrace 如何读取被跟踪进程的内存。我们给它一个进程 ID 和一个地址,然后它返回给我们该地址当前的 64 位内容。 (m_saved_data & ~0xff) 把这个数据的低位字节置零,然后我们用它和我们的 int 3 指令按位或(OR)来设置断点。最后我们通过 PTRACE_POKEDATA 用我们的新数据覆盖那部分内存来设置断点。

disable 的实现比较简单,我们只需要恢复用 0xcc 所覆盖的原始数据。

void breakpoint::disable() {
    ptrace(PTRACE_POKEDATA, m_pid, m_addr, m_saved_data);
    m_enabled = false;
}

在调试器中增加断点

为了支持通过用户界面设置断点,我们要在 debugger 类修改三个地方:

  1. debugger 添加断点存储数据结构
  2. 添加 set_breakpoint_at_address 函数
  3. 给我们的 handle_command 函数添加 break 命令

我会将我的断点保存到 std::unordered_map<std::intptr_t, breakpoint> 结构,以便能简单快速地判断一个给定的地址是否有断点,如果有的话,取回该 breakpoint 对象。

class debugger {
    //...
    void set_breakpoint_at_address(std::intptr_t addr);
    //...
private:
    //...
    std::unordered_map<std::intptr_t,breakpoint> m_breakpoints;
}

set_breakpoint_at_address 函数中我们会新建一个 breakpoint 对象,启用它,把它添加到数据结构里,并给用户打印一条信息。如果你喜欢的话,你可以重构所有的输出信息,从而你可以将调试器作为库或者命令行工具使用,为了简便,我把它们都整合到了一起。

void debugger::set_breakpoint_at_address(std::intptr_t addr) {
    std::cout << "Set breakpoint at address 0x" << std::hex << addr << std::endl;
    breakpoint bp {m_pid, addr};
    bp.enable();
    m_breakpoints[addr] = bp;
}

现在我们会在我们的命令处理程序中增加对我们新函数的调用。

void debugger::handle_command(const std::string& line) {
    auto args = split(line,' ');
    auto command = args[0];

    if (is_prefix(command, "cont")) {
        continue_execution();
    }
    else if(is_prefix(command, "break")) {
        std::string addr {args[1], 2}; //naively assume that the user has written 0xADDRESS
        set_breakpoint_at_address(std::stol(addr, 0, 16));
    }
    else {
        std::cerr << "Unknown command\n";
    }
}

我删除了字符串中的前两个字符并对结果调用 std::stol,你也可以让该解析更健壮一些。std::stol 可以将字符串按照所给基数转化为整数。

从断点继续执行

如果你尝试这样做,你可能会发现,如果你从断点处继续执行,不会发生任何事情。这是因为断点仍然在内存中,因此一直被重复命中。简单的解决办法就是停用这个断点、运行到下一步、再次启用这个断点、然后继续执行。不过我们还需要更改程序计数器,指回到断点前面,这部分内容会留到下一篇关于操作寄存器的文章中介绍。

测试它

当然,如果你不知道要在哪个地址设置,那么在某些地址设置断点并非很有用。后面我们会学习如何在函数名或者代码行设置断点,但现在我们可以通过手动实现。

测试你调试器的简单方法是写一个 hello world 程序,这个程序输出到 std::err(为了避免缓存),并在调用输出操作符的地方设置断点。如果你继续执行被调试的程序,执行很可能会停止而不会输出任何东西。然后你可以重启调试器并在调用之后设置一个断点,现在你应该看到成功地输出了消息。

查找地址的一个方法是使用 objdump。如果你打开一个终端并执行 objdump -d <your program>,然后你应该看到你的程序的反汇编代码。你就可以找到 main 函数并定位到你想设置断点的 call 指令。例如,我编译了一个 hello world 程序,反汇编它,然后得到了如下的 main 的反汇编代码:

0000000000400936 <main>:
  400936:   55                      push   %rbp
  400937:   48 89 e5                mov    %rsp,%rbp
  40093a:   be 35 0a 40 00          mov    $0x400a35,%esi
  40093f:   bf 60 10 60 00          mov    $0x601060,%edi
  400944:   e8 d7 fe ff ff          callq  400820 <_ZStlsISt11char_traitsIcEERSt13basic_ostreamIcT_ES5_PKc@plt>
  400949:   b8 00 00 00 00          mov    $0x0,%eax
  40094e:   5d                      pop    %rbp
  40094f:   c3                      retq

正如你看到的,要没有输出,我们要在 0x400944 设置断点,要看到输出,要在 0x400949 设置断点。

总结

现在你应该有了一个可以启动程序、允许在内存地址上设置断点的调试器。后面我们会添加读写内存和寄存器的功能。再次说明,如果你有任何问题请在评论框中告诉我。

你可以在这里 找到该项目的代码。


via: http://blog.tartanllama.xyz/c++/2017/03/24/writing-a-linux-debugger-breakpoints/

作者:Simon Brand 译者:ictlyh 校对:jasminepeng

本文由 LCTT 原创编译,Linux中国 荣誉推出

任何写过比 hello world 复杂一些的程序的人都应该使用过调试器(如果你还没有,那就停下手头的工作先学习一下吧)。但是,尽管这些工具已经得到了广泛的使用,却并没有太多的资源告诉你它们的工作原理以及如何开发,尤其是和其它那些比如编译器等工具链技术相比而言。

此处有一些其它的资源可以参考:

我们将会支持以下功能:

  • 启动、暂停、继续执行
  • 在不同地方设置断点

    • 内存地址
    • 源代码行
    • 函数入口
  • 读写寄存器和内存
  • 单步执行

    • 指令
    • 进入函数
    • 跳出函数
    • 跳过函数
  • 打印当前代码地址
  • 打印函数调用栈
  • 打印简单变量的值

在最后一部分,我还会大概介绍如何给你的调试器添加下面的功能:

  • 远程调试
  • 共享库和动态库支持
  • 表达式计算
  • 多线程调试支持

在本项目中我会将重点放在 C 和 C++,但对于那些将源码编译为机器码并输出标准 DWARE 调试信息的语言也应该能起作用(如果你还不知道这些东西是什么,别担心,马上就会介绍到啦)。另外,我只关注如何将程序运行起来并在大部分情况下能正常工作,为了简便,会避开类似健壮错误处理方面的东西。

系列文章索引

随着后面文章的发布,这些链接会逐渐生效。

  1. 准备环境
  2. 断点
  3. 寄存器和内存
  4. Elves 和 dwarves
  5. 源码和信号
  6. 源码层逐步执行
  7. 源码层断点
  8. 调用栈
  9. 读取变量
  10. 之后步骤

LCTT 译注:ELF —— 可执行文件格式 Executable and Linkable Format ;DWARF(一种广泛使用的调试数据格式,参考 WIKI)。

准备环境

在我们正式开始之前,我们首先要设置环境。在这篇文章中我会依赖两个工具:Linenoise 用于处理命令行输入,libelfin 用于解析调试信息。你也可以使用更传统的 libdwarf 而不是 libelfin,但是界面没有那么友好,另外 libelfin 还提供了基本完整的 DWARF 表达式求值器,当你想读取变量的值时这能帮你节省很多时间。确认你使用的是 libelfin 我的 fbreg 分支,因为它提供 x86 上读取变量的额外支持。

一旦你在系统上安装或者使用你喜欢的编译系统编译好了这些依赖工具,就可以开始啦。我在 CMake 文件中把它们设置为和我其余的代码一起编译。

启动可执行程序

在真正调试任何程序之前,我们需要启动被调试的程序。我们会使用经典的 fork/exec 模式。

int main(int argc, char* argv[]) {
    if (argc < 2) {
        std::cerr << "Program name not specified";
        return -1;
    }

    auto prog = argv[1];

    auto pid = fork();
    if (pid == 0) {
        //we're in the child process
        //execute debugee

    }
    else if (pid >= 1)  {
        //we're in the parent process
        //execute debugger
    }

我们调用 fork 把我们的程序分成两个进程。如果我们是在子进程,fork 返回 0,如果我们是在父进程,它会返回子进程的进程 ID。

如果我们是在子进程,我们要用希望调试的程序替换正在执行的程序。

   ptrace(PTRACE_TRACEME, 0, nullptr, nullptr);
   execl(prog.c_str(), prog.c_str(), nullptr);

这里我们第一次遇到了 ptrace,它会在我们编写调试器的时候经常遇到。ptrace 通过读取寄存器、内存、逐步调试等让我们观察和控制另一个进程的执行。其 API 非常简单;你需要给这个简单函数提供一个枚举值指定你想要进行的操作,然后是一些取决于你所提供的值可能会被使用也可能会被忽略的参数。函数原型看起来类似:

long ptrace(enum __ptrace_request request, pid_t pid,
            void *addr, void *data);

request 是我们想对被跟踪进程进行的操作;pid 是被跟踪进程的进程 ID;addr 是一个内存地址,用于在一些调用中指定被跟踪程序的地址;datarequest 相应的资源。返回值通常是一些错误信息,因此在你实际的代码中你也许应该检查返回值;为了简洁我这里就省略了。你可以查看 man 手册获取更多(关于 ptrace)的信息。

上面代码中我们发送的请求 PTRACE_TRACEME 表示这个进程应该允许父进程跟踪它。所有其它参数都会被忽略,因为 API 设计并不是很重要,哈哈。

下一步,我们会调用 execl,这是很多诸多的 exec 函数格式之一。我们执行指定的程序,通过命令行参数传递它的名称,然后用一个 nullptr 终止列表。如果你愿意,你还可以传递其它执行你的程序所需的参数。

在完成这些后,我们就会和子进程一起结束;在我们结束它之前它会一直执行。

添加调试循环

现在我们已经启动了子进程,我们想要能够和它进行交互。为此,我们会创建一个 debugger 类,循环监听用户输入,然后在我们父进程的 main 函数中启动它。

else if (pid >= 1)  {
    //parent
    debugger dbg{prog, pid};
    dbg.run();
}
class debugger {
public:
    debugger (std::string prog_name, pid_t pid)
        : m_prog_name{std::move(prog_name)}, m_pid{pid} {}

    void run();

private:
    std::string m_prog_name;
    pid_t m_pid;
};

run 函数中,我们需要等待,直到子进程完成启动,然后一直从 linenoise 获取输入直到收到 EOFCTRL+D)。

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

    char* line = nullptr;
    while((line = linenoise("minidbg> ")) != nullptr) {
        handle_command(line);
        linenoiseHistoryAdd(line);
        linenoiseFree(line);
    }
}

当被跟踪的进程启动时,会发送一个 SIGTRAP 信号给它,这是一个跟踪或者断点中断。我们可以使用 waitpid 函数等待这个信号发送。

当我们知道进程可以被调试之后,我们监听用户输入。linenoise 函数它自己会用一个窗口显示和处理用户输入。这意味着我们不需要做太多的工作就会有一个支持历史记录和导航命令的命令行。当我们获取到输入时,我们把命令发给我们写的小程序 handle_command,然后我们把这个命令添加到 linenoise 历史并释放资源。

处理输入

我们的命令类似 gdb 以及 lldb 的格式。要继续执行程序,用户需要输入 continuecont 甚至只需 c。如果他们想在一个地址中设置断点,他们会输入 break 0xDEADBEEF,其中 0xDEADBEEF 就是所需地址的 16 进制格式。让我们来增加对这些命令的支持吧。

void debugger::handle_command(const std::string& line) {
    auto args = split(line,' ');
    auto command = args[0];

    if (is_prefix(command, "continue")) {
        continue_execution();
    }
    else {
        std::cerr << "Unknown command\n";
    }
}

splitis_prefix 是一对有用的小程序:

std::vector<std::string> split(const std::string &s, char delimiter) {
    std::vector<std::string> out{};
    std::stringstream ss {s};
    std::string item;

    while (std::getline(ss,item,delimiter)) {
        out.push_back(item);
    }

    return out;
}

bool is_prefix(const std::string& s, const std::string& of) {
    if (s.size() > of.size()) return false;
    return std::equal(s.begin(), s.end(), of.begin());
}

我们会把 continue_execution 函数添加到 debuger 类。

void debugger::continue_execution() {
    ptrace(PTRACE_CONT, m_pid, nullptr, nullptr);

    int wait_status;
    auto options = 0;
    waitpid(m_pid, &wait_status, options);
}

现在我们的 continue_execution 函数会用 ptrace 告诉进程继续执行,然后用 waitpid 等待直到收到信号。


总结

现在你应该编译一些 C 或者 C++ 程序,然后用你的调试器运行它们,看它是否能在函数入口暂停、从调试器中继续执行。在下一篇文章中,我们会学习如何让我们的调试器设置断点。如果你遇到了任何问题,在下面的评论框中告诉我吧!

你可以在这里找到该项目的代码。


via: http://blog.tartanllama.xyz/c++/2017/03/21/writing-a-linux-debugger-setup/

作者:Simon Brand 译者:ictlyh 校对:jasminepeng

本文由 LCTT 原创编译,Linux中国 荣誉推出

这是调试器的工作原理系列文章的第三篇。阅读这篇文章之前应当先阅读第一篇第二篇

这篇文章的主要内容

本文将解释调试器是如何在机器码中查找它将 C 语言源代码转换成机器语言代码时所需要的 C 语言函数、变量、与数据。

调试信息

现代编译器能够将有着各种缩进或嵌套的程序流程、各种数据类型的变量的高级语言代码转换为一大堆称之为机器码的 0/1 数据,这么做的唯一目的是尽可能快的在目标 CPU 上运行程序。通常来说一行 C 语言代码能够转换为若干条机器码。变量被分散在机器码中的各个部分,有的在堆栈中,有的在寄存器中,或者直接被优化掉了。数据结构与对象在机器码中甚至不“存在”,它们只是用于将数据按一定的结构编码存储进缓存。

那么调试器怎么知道,当你需要在某个函数入口处暂停时,程序要在哪停下来呢?它怎么知道当你查看某个变量值时,它怎么找到这个值?答案是,调试信息。

编译器在生成机器码时同时会生成相应的调试信息。调试信息代表了可执行程序与源代码之间的关系,并以一种提前定义好的格式,同机器码存放在一起。过去的数年里,人们针对不同的平台与可执行文件发明了很多种用于存储这些信息的格式。不过我们这篇文章不会讲这些格式的历史,而是将阐述这些调试信息是如何工作的,所以我们将专注于一些事情,比如 DWARFDWARF 如今十分广泛的用作 Linux 和类 Unix 平台上的可执行文件的调试格式。

ELF 中的 DWARF

根据它的维基百科 所描述,虽然 DWARF 是同 ELF 一同设计的(DWARF 是由 DWARF 标准委员会推出的开放标准。上文中展示的图标就来自这个网站。),但 DWARF 在理论上来说也可以嵌入到其他的可执行文件格式中。

DWARF 是一种复杂的格式,它吸收了过去许多年各种不同的架构与操作系统的格式的经验。正是因为它解决了一个在任何平台与 ABI (应用二进制接口)上为任意高级语言产生调试信息这样棘手的难题,它也必须很复杂。想要透彻的讲解 DWARF 仅仅是通过这单薄的一篇文章是远远不够的,说实话我也并没有充分地了解 DWARF 到每一个微小的细节,所以我也不能十分透彻的讲解 (如果你感兴趣的话,文末有一些能够帮助你的资源。建议从 DWARF 教程开始上手)。这篇文章中我将以浅显易懂的方式展示 DWARF,以说明调试信息是如何实际工作的。

ELF 文件中的调试部分

首先让我们看看 DWARF 处在 ELF 文件中的什么位置。ELF 定义了每一个生成的目标文件中的每一节。 节头表 section header table 声明并定义了每一节及其名字。不同的工具以不同的方式处理不同的节,例如连接器会寻找连接器需要的部分,调试器会查找调试器需要的部分。

我们本文的实验会使用从这个 C 语言源文件构建的可执行文件,编译成 tracedprog2

#include <stdio.h>

void do_stuff(int my_arg)、
{
    int my_local = my_arg + 2;
    int i;

    for (i = 0; i < my_local; ++i)
        printf("i = %d\n", i);
}

int main()
{
    do_stuff(2);
    return 0;
}

使用 objdump -h 命令检查 ELF 可执行文件中的 节头 section header ,我们会看到几个以 .debug_ 开头的节,这些就是 DWARF 的调试部分。

26 .debug_aranges 00000020  00000000  00000000  00001037
                 CONTENTS, READONLY, DEBUGGING
27 .debug_pubnames 00000028  00000000  00000000  00001057
                 CONTENTS, READONLY, DEBUGGING
28 .debug_info   000000cc  00000000  00000000  0000107f
                 CONTENTS, READONLY, DEBUGGING
29 .debug_abbrev 0000008a  00000000  00000000  0000114b
                 CONTENTS, READONLY, DEBUGGING
30 .debug_line   0000006b  00000000  00000000  000011d5
                 CONTENTS, READONLY, DEBUGGING
31 .debug_frame  00000044  00000000  00000000  00001240
                 CONTENTS, READONLY, DEBUGGING
32 .debug_str    000000ae  00000000  00000000  00001284
                 CONTENTS, READONLY, DEBUGGING
33 .debug_loc    00000058  00000000  00000000  00001332
                 CONTENTS, READONLY, DEBUGGING

每个节的第一个数字代表了该节的大小,最后一个数字代表了这个节开始位置距离 ELF 的偏移量。调试器利用这些信息从可执行文件中读取节。

现在让我们看看一些在 DWARF 中查找有用的调试信息的实际例子。

查找函数

调试器的最基础的任务之一,就是当我们在某个函数处设置断点时,调试器需要能够在入口处暂停。为此,必须为高级代码中的函数名称与函数在机器码中指令开始的地址这两者之间建立起某种映射关系。

为了获取这种映射关系,我们可以查找 DWARF 中的 .debug_info 节。在我们深入之前,需要一点基础知识。DWARF 中每一个描述类型被称之为调试信息入口(DIE)。每个 DIE 都有关于它的类型、属性之类的标签。DIE 之间通过兄弟节点或子节点相互连接,属性的值也可以指向其它的 DIE

运行以下命令:

objdump --dwarf=info tracedprog2

输出文件相当的长,为了方便举例我们只关注这些行(从这里开始,无用的冗长信息我会以 (...)代替,方便排版):

<1><71>: Abbrev Number: 5 (DW_TAG_subprogram)
    <72>   DW_AT_external    : 1
    <73>   DW_AT_name        : (...): do_stuff
    <77>   DW_AT_decl_file   : 1
    <78>   DW_AT_decl_line   : 4
    <79>   DW_AT_prototyped  : 1
    <7a>   DW_AT_low_pc      : 0x8048604
    <7e>   DW_AT_high_pc     : 0x804863e
    <82>   DW_AT_frame_base  : 0x0      (location list)
    <86>   DW_AT_sibling     : <0xb3>

<1><b3>: Abbrev Number: 9 (DW_TAG_subprogram)
    <b4>   DW_AT_external    : 1
    <b5>   DW_AT_name        : (...): main
    <b9>   DW_AT_decl_file   : 1
    <ba>   DW_AT_decl_line   : 14
    <bb>   DW_AT_type        : <0x4b>
    <bf>   DW_AT_low_pc      : 0x804863e
    <c3>   DW_AT_high_pc     : 0x804865a
    <c7>   DW_AT_frame_base  : 0x2c     (location list)

上面的代码中有两个带有 DW_TAG_subprogram 标签的入口,在 DWARF 中这是对函数的指代。注意,这是两个节的入口,其中一个是 do_stuff 函数的入口,另一个是主(main)函数的入口。这些信息中有很多值得关注的属性,但其中最值得注意的是 DW_AT_low_pc。它代表了函数开始处程序指针的值(在 x86 平台上是 EIP)。此处 0x8048604 代表了 do_stuff 函数开始处的程序指针。下面我们将利用 objdump -d 命令对可执行文件进行反汇编。来看看这块地址中都有什么:

08048604 <do_stuff>:
 8048604:       55           push   ebp
 8048605:       89 e5        mov    ebp,esp
 8048607:       83 ec 28     sub    esp,0x28
 804860a:       8b 45 08     mov    eax,DWORD PTR [ebp+0x8]
 804860d:       83 c0 02     add    eax,0x2
 8048610:       89 45 f4     mov    DWORD PTR [ebp-0xc],eax
 8048613:       c7 45 (...)  mov    DWORD PTR [ebp-0x10],0x0
 804861a:       eb 18        jmp    8048634 <do_stuff+0x30>
 804861c:       b8 20 (...)  mov    eax,0x8048720
 8048621:       8b 55 f0     mov    edx,DWORD PTR [ebp-0x10]
 8048624:       89 54 24 04  mov    DWORD PTR [esp+0x4],edx
 8048628:       89 04 24     mov    DWORD PTR [esp],eax
 804862b:       e8 04 (...)  call   8048534 <printf@plt>
 8048630:       83 45 f0 01  add    DWORD PTR [ebp-0x10],0x1
 8048634:       8b 45 f0     mov    eax,DWORD PTR [ebp-0x10]
 8048637:       3b 45 f4     cmp    eax,DWORD PTR [ebp-0xc]
 804863a:       7c e0        jl     804861c <do_stuff+0x18>
 804863c:       c9           leave
 804863d:       c3           ret

显然,0x8048604do_stuff 的开始地址,这样一来,调试器就可以建立函数与其在可执行文件中的位置间的映射关系。

查找变量

假设我们当前在 do_staff 函数中某个位置上设置断点停了下来。我们想通过调试器取得 my_local 这个变量的值。调试器怎么知道在哪里去找这个值呢?很显然这要比查找函数更为困难。变量可能存储在全局存储区、堆栈、甚至是寄存器中。此外,同名变量在不同的作用域中可能有着不同的值。调试信息必须能够反映所有的这些变化,当然,DWARF 就能做到。

我不会逐一去将每一种可能的状况,但我会以调试器在 do_stuff 函数中查找 my_local 变量的过程来举个例子。下面我们再看一遍 .debug_infodo_stuff 的每一个入口,这次连它的子入口也要一起看。

<1><71>: Abbrev Number: 5 (DW_TAG_subprogram)
    <72>   DW_AT_external    : 1
    <73>   DW_AT_name        : (...): do_stuff
    <77>   DW_AT_decl_file   : 1
    <78>   DW_AT_decl_line   : 4
    <79>   DW_AT_prototyped  : 1
    <7a>   DW_AT_low_pc      : 0x8048604
    <7e>   DW_AT_high_pc     : 0x804863e
    <82>   DW_AT_frame_base  : 0x0      (location list)
    <86>   DW_AT_sibling     : <0xb3>
 <2><8a>: Abbrev Number: 6 (DW_TAG_formal_parameter)
    <8b>   DW_AT_name        : (...): my_arg
    <8f>   DW_AT_decl_file   : 1
    <90>   DW_AT_decl_line   : 4
    <91>   DW_AT_type        : <0x4b>
    <95>   DW_AT_location    : (...)       (DW_OP_fbreg: 0)
 <2><98>: Abbrev Number: 7 (DW_TAG_variable)
    <99>   DW_AT_name        : (...): my_local
    <9d>   DW_AT_decl_file   : 1
    <9e>   DW_AT_decl_line   : 6
    <9f>   DW_AT_type        : <0x4b>
    <a3>   DW_AT_location    : (...)      (DW_OP_fbreg: -20)
<2><a6>: Abbrev Number: 8 (DW_TAG_variable)
    <a7>   DW_AT_name        : i
    <a9>   DW_AT_decl_file   : 1
    <aa>   DW_AT_decl_line   : 7
    <ab>   DW_AT_type        : <0x4b>
    <af>   DW_AT_location    : (...)      (DW_OP_fbreg: -24)

看到每个入口处第一对尖括号中的数字了吗?这些是嵌套的等级,在上面的例子中,以 <2> 开头的入口是以 <1> 开头的子入口。因此我们得知 my_local 变量(以 DW_TAG_variable 标签标记)是 do_stuff 函数的局部变量。除此之外,调试器也需要知道变量的数据类型,这样才能正确的使用与显示变量。上面的例子中 my_local 的变量类型指向另一个 DIE <0x4b>。如果使用 objdump 命令查看这个 DIE 的话,我们会发现它是一个有符号 4 字节整型数据。

而为了在实际运行的程序内存中查找变量的值,调试器需要使用到 DW_AT_location 属性。对于 my_local 而言,是 DW_OP_fbreg: -20。这个代码段的意思是说 my_local 存储在距离它所在函数起始地址偏移量为 -20 的地方。

do_stuff 函数的 DW_AT_frame_base 属性值为 0x0 (location list)。这意味着这个属性的值需要在 location list 中查找。下面我们来一起看看。

$ objdump --dwarf=loc tracedprog2

tracedprog2:     file format elf32-i386

Contents of the .debug_loc section:

    Offset   Begin    End      Expression
    00000000 08048604 08048605 (DW_OP_breg4: 4 )
    00000000 08048605 08048607 (DW_OP_breg4: 8 )
    00000000 08048607 0804863e (DW_OP_breg5: 8 )
    00000000 <End of list>
    0000002c 0804863e 0804863f (DW_OP_breg4: 4 )
    0000002c 0804863f 08048641 (DW_OP_breg4: 8 )
    0000002c 08048641 0804865a (DW_OP_breg5: 8 )
    0000002c <End of list>

我们需要关注的是第一列(do_stuff 函数的 DW_AT_frame_base 属性包含 location list0x0 的偏移量。而 main 函数的相同属性包含 0x2c 的偏移量,这个偏移量是第二套地址列表的偏移量)。对于调试器可能定位到的每一个地址,它都会指定当前栈帧到变量间的偏移量,而这个偏移就是通过寄存器来计算的。对于 x86 平台而言,bpreg4 指向 esp,而 bpreg5 指向 ebp

让我们再看看 do_stuff 函数的头几条指令。

08048604 <do_stuff>:
 8048604:       55          push   ebp
 8048605:       89 e5       mov    ebp,esp
 8048607:       83 ec 28    sub    esp,0x28
 804860a:       8b 45 08    mov    eax,DWORD PTR [ebp+0x8]
 804860d:       83 c0 02    add    eax,0x2
 8048610:       89 45 f4    mov    DWORD PTR [ebp-0xc],eax

只有当第二条指令执行后,ebp 寄存器才真正存储了有用的值。当然,前两条指令的基址是由上面所列出来的地址信息表计算出来的。一但 ebp 确定了,计算偏移量就十分方便了,因为尽管 esp 在操作堆栈的时候需要移动,但 ebp 作为栈底并不需要移动。

究竟我们应该去哪里找 my_local 的值呢?在 0x8048610 这块地址后, my_local 的值经过在 eax 中的计算后被存在了内存中,从这里开始我们才需要关注 my_local 的值。调试器会利用 DW_OP_breg5: 8 这个栈帧来查找。我们回想下,my_localDW_AT_location 属性值为 DW_OP_fbreg: -20。所以应当从基址中 -20 ,同时由于 ebp 寄存器需要 +8,所以最终结果为 ebp - 12。现在再次查看反汇编代码,来看看数据从 eax 中被移动到哪里了。当然,这里 my_local 应当被存储在了 ebp - 12 的地址中。

查看行号

当我们谈到在调试信息寻找函数的时候,我们利用了些技巧。当调试 C 语言源代码并在某个函数出放置断点的时候,我们并不关注第一条“机器码”指令(函数的调用准备工作已经完成而局部变量还没有初始化)。我们真正关注的是函数的第一行“C 代码”。

这就是 DWARF 完全覆盖映射 C 源代码中的行与可执行文件中机器码地址的原因。下面是 .debug_line 节中所包含的内容,我们将其转换为可读的格式展示如下。

$ objdump --dwarf=decodedline tracedprog2

tracedprog2:     file format elf32-i386

Decoded dump of debug contents of section .debug_line:

CU: /home/eliben/eli/eliben-code/debugger/tracedprog2.c:
File name           Line number    Starting address
tracedprog2.c                5           0x8048604
tracedprog2.c                6           0x804860a
tracedprog2.c                9           0x8048613
tracedprog2.c               10           0x804861c
tracedprog2.c                9           0x8048630
tracedprog2.c               11           0x804863c
tracedprog2.c               15           0x804863e
tracedprog2.c               16           0x8048647
tracedprog2.c               17           0x8048653
tracedprog2.c               18           0x8048658

很容易就可以看出其中 C 源代码与反汇编代码之间的对应关系。第 5 行指向 do_stuff 函数的入口,0x8040604。第 6 行,指向 0x804860a ,正是调试器在调试 do_stuff 函数时需要停下来的地方。这里已经完成了函数调用的准备工作。上面的这些信息形成了行号与地址间的双向映射关系。

  • 当在某一行设置断点的时候,调试器会利用这些信息去查找相应的地址来做断点工作(还记得上篇文章中的 int 3 指令吗?)
  • 当指令造成段错误时,调试器会利用这些信息来查看源代码中发生问题的行。

libdwarf - 用 DWARF 编程

尽管使用命令行工具来获得 DWARF 很有用,但这仍然不够易用。作为程序员,我们希望知道当我们需要这些调试信息时应当怎么编程来获取这些信息。

自然我们想到的第一种方法就是阅读 DWARF 规范并按规范操作阅读使用。有句话说的好,分析 HTML 应当使用库函数,永远不要手工分析。对于 DWARF 来说正是如此。DWARF 比 HTML 要复杂得多。上面所展示出来的只是冰山一角。更糟糕的是,在实际的目标文件中,大部分信息是以非常紧凑的压缩格式存储的,分析起来更加复杂(信息中的某些部分,例如位置信息与行号信息,在某些虚拟机下是以指令的方式编码的)。

所以我们要使用库来处理 DWARF。下面是两种我熟悉的主要的库(还有些不完整的库这里没有写)

  1. BFD (libbfd),包含了 objdump (对,就是这篇文章中我们一直在用的这货),ldGNU 连接器)与 asGNU 编译器)。BFD 主要用于 GNU binutils
  2. libdwarf ,同它的哥哥 libelf 一同用于 SolarisFreeBSD 中的调试信息分析。

相比较而言我更倾向于使用 libdwarf,因为我对它了解的更多,并且 libdwarf 的开源协议更开放(LGPL 对比 GPL)。

因为 libdwarf 本身相当复杂,操作起来需要相当多的代码,所以我在这不会展示所有代码。你可以在 这里 下载代码并运行试试。运行这些代码需要提前安装 libelfandlibdwarf ,同时在使用连接器的时候要使用参数 -lelf-ldwarf

这个示例程序可以接受可执行文件并打印其中的函数名称与函数入口地址。下面是我们整篇文章中使用的 C 程序经过示例程序处理后的输出。

$ dwarf_get_func_addr tracedprog2
DW_TAG_subprogram: 'do_stuff'
low pc  : 0x08048604
high pc : 0x0804863e
DW_TAG_subprogram: 'main'
low pc  : 0x0804863e
high pc : 0x0804865a

libdwarf 的文档很棒,如果你花些功夫,利用 libdwarf 获得这篇文章中所涉及到的 DWARF 信息应该并不困难。

结论与计划

原理上讲,调试信息是个很简单的概念。尽管实现细节可能比较复杂,但经过了上面的学习我想你应该了解了调试器是如何从可执行文件中获取它需要的源代码信息的了。对于程序员而言,程序只是代码段与数据结构;对可执行文件而言,程序只是一系列存储在内存或寄存器中的指令或数据。但利用调试信息,调试器就可以将这两者连接起来,从而完成调试工作。

此文与这系列的前两篇,一同介绍了调试器的内部工作过程。利用这里所讲到的知识,再敲些代码,应该可以完成一个 Linux 中最简单、基础但也有一定功能的调试器。

下一步我并不确定要做什么,这个系列文章可能就此结束,也有可能我要讲些堆栈调用的事情,又或者讲 Windows 下的调试。你们有什么好的点子或者相关材料,可以直接评论或者发邮件给我。

参考



via: http://eli.thegreenplace.net/2011/02/07/how-debuggers-work-part-3-debugging-information

作者:Eli Bendersky 译者:YYforymj 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出