Simon Brand 发布的文章

到目前为止,你已经偶尔听到了关于 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中国 荣誉推出