我们终于来到这个系列的最后一篇文章！这一次，我将对调试中的一些更高级的概念进行高层的概述：远程调试、共享库支持、表达式计算和多线程支持。这些想法实现起来比较复杂，所以我不会详细说明如何做，但是如果你有问题的话，我很乐意回答有关这些概念的问题。系列索引准备环境断点寄存器和内存Elves 和 dwarves源码和信号源码层逐步执行源码层断点调用栈处理变量高级主题远程调试远程调试对于嵌入式系统或对不同环境进行调试非常有用。它还在高级调试器操作和与操作系统和硬件的交互之间设置了一个很好的分界线。事实上，像 GDB 和 LLDB 这样的调试器即使在调试本地程序时也可以作为远程调试器运行。一般架构是这样的：调试器是我们通过命令行交互的组件。也许如果你使用的是 IDE，那么在其上有另一个层可以通过机器接口与调试器进行通信。在目标机器上（可能与本机一样）有一个 调试存根 debug stub ，理论上它是一个非常小的操作系统调试库的包装程序，它执行所有的低级调试任务，如在地址上设置断点。我说“在理论上”，因为如今调试存根变得越来越大。例如，我机器上的 LLDB 调试存根大小是 7.6MB。调试存根通过使用一些特定于操作系统的功能（在我们的例子中是 ptrace）和被调试进程以及通过远程协议的调试器通信。最常见的远程调试协议是 GDB 远程协议。这是一种基于文本的数据包格式，用于在调试器和调试存根之间传递命令和信息。我不会详细介绍它，但你可以在这里进一步阅读。如果你启动 LLDB 并执行命令 log enable gdb-remote packets，那么你将获得通过远程协议发送的所有数据包的跟踪信息。在 GDB 上，你可以用 set remotelogfile <file> 做同样的事情。作为一个简单的例子，这是设置断点的数据包：$Z0,400570,1#43 $ 标记数据包的开始。Z0 是插入内存断点的命令。400570 和 1 是参数，其中前者是设置断点的地址，后者是特定目标的断点类型说明符。最后，#43 是校验值，以确保数据没有损坏。GDB 远程协议非常易于扩展自定义数据包，这对于实现平台或语言特定的功能非常有用。共享库和动态加载支持调试器需要知道被调试程序加载了哪些共享库，以便它可以设置断点、获取源代码级别的信息和符号等。除查找被动态链接的库之外，调试器还必须跟踪在运行时通过 dlopen 加载的库。为了达到这个目的，动态链接器维护一个 交汇结构体。该结构体维护共享库描述符的链表，以及一个指向每当更新链表时调用的函数的指针。这个结构存储在 ELF 文件的 .dynamic 段中，在程序执行之前被初始化。一个简单的跟踪算法：追踪程序在 ELF 头中查找程序的入口（或者可以使用存储在 /proc/<pid>/aux 中的辅助向量）。追踪程序在程序的入口处设置一个断点，并开始执行。当到达断点时，通过在 ELF 文件中查找 .dynamic 的加载地址找到交汇结构体的地址。检查交汇结构体以获取当前加载的库的列表。链接器更新函数上设置断点。每当到达断点时，列表都会更新。追踪程序无限循环，继续执行程序并等待信号，直到追踪程序信号退出。我给这些概念写了一个小例子，你可以在这里找到。如果有人有兴趣，我可以将来写得更详细一点。表达式计算表达式计算是程序的一项功能，允许用户在调试程序时对原始源语言中的表达式进行计算。例如，在 LLDB 或 GDB 中，可以执行 print foo() 来调用 foo 函数并打印结果。根据表达式的复杂程度，有几种不同的计算方法。如果表达式只是一个简单的标识符，那么调试器可以查看调试信息，找到该变量并打印出该值，就像我们在本系列最后一部分中所做的那样。如果表达式有点复杂，则可能将代码编译成中间表达式 （IR） 并解释来获得结果。例如，对于某些表达式，LLDB 将使用 Clang 将表达式编译为 LLVM IR 并将其解释。如果表达式更复杂，或者需要调用某些函数，那么代码可能需要 JIT 到目标并在被调试者的地址空间中执行。这涉及到调用 mmap 来分配一些可执行内存，然后将编译的代码复制到该块并执行。LLDB 通过使用 LLVM 的 JIT 功能来实现。如果你想更多地了解 JIT 编译，我强烈推荐 Eli Bendersky 关于这个主题的文章。多线程调试支持本系列展示的调试器仅支持单线程应用程序，但是为了调试大多数真实程序，多线程支持是非常需要的。支持这一点的最简单的方法是跟踪线程的创建，并解析 procfs 以获取所需的信息。Linux 线程库称为 pthreads。当调用 pthread_create 时，库会使用 clone 系统调用来创建一个新的线程，我们可以用 ptrace 跟踪这个系统调用（假设你的内核早于 2.5.46）。为此，你需要在连接到调试器之后设置一些 ptrace 选项：ptrace(PTRACE_SETOPTIONS, m_pid, nullptr, PTRACE_O_TRACECLONE); 现在当 clone 被调用时，该进程将收到我们的老朋友 SIGTRAP 信号。对于本系列中的调试器，你可以将一个例子添加到 handle_sigtrap 来处理新线程的创建：case (SIGTRAP | (PTRACE_EVENT_CLONE << 8)): //get the new thread ID unsigned long event_message = 0; ptrace(PTRACE_GETEVENTMSG, pid, nullptr, message); //handle creation //... 一旦收到了，你可以看看 /proc/<pid>/task/ 并查看内存映射之类来获得所需的所有信息。GDB 使用 libthread_db，它提供了一堆帮助函数，这样你就不需要自己解析和处理。设置这个库很奇怪，我不会在这展示它如何工作，但如果你想使用它，你可以去阅读这个教程。多线程支持中最复杂的部分是调试器中线程状态的建模，特别是如果你希望支持不间断模式或当你计算中涉及不止一个 CPU 的某种异构调试。最后！呼！这个系列花了很长时间才写完，但是我在这个过程中学到了很多东西，我希望它是有帮助的。如果你有关于调试或本系列中的任何问题，请在 Twitter @TartanLlama或评论区联系我。如果你有想看到的其他任何调试主题，让我知道我或许会再发其他的文章。via: https://blog.tartanllama.xyz/writing-a-linux-debugger-advanced-topics/作者：Simon Brand 译者：geekpi 校对：wxy本文由 LCTT 原创编译，Linux中国 荣誉推出
变量是偷偷摸摸的。有时，它们会很高兴地呆在寄存器中，但是一转头就会跑到堆栈中。为了优化，编译器可能会完全将它们从窗口中抛出。无论变量在内存中的如何移动，我们都需要一些方法在调试器中跟踪和操作它们。这篇文章将会教你如何处理调试器中的变量，并使用 libelfin 演示一个简单的实现。系列文章索引准备环境断点寄存器和内存ELF 和 DWARF源码和信号源码级逐步执行源码级断点堆栈展开处理变量高级话题在开始之前，请确保你使用的 libelfin 版本是我分支上的 fbreg。这包含了一些 hack 来支持获取当前堆栈帧的基址并评估位置列表，这些都不是由原生的 libelfin 提供的。你可能需要给 GCC 传递 -gdwarf-2 参数使其生成兼容的 DWARF 信息。但是在实现之前，我将详细说明 DWARF 5 最新规范中的位置编码方式。如果你想要了解更多信息，那么你可以从这里获取该标准。DWARF 位置某一给定时刻的内存中变量的位置使用 DW_AT_location 属性编码在 DWARF 信息中。位置描述可以是单个位置描述、复合位置描述或位置列表。简单位置描述：描述了对象的一个​​连续的部分（通常是所有部分）的位置。简单位置描述可以描述可寻址存储器或寄存器中的位置，或缺少位置（具有或不具有已知值）。比如，DW_OP_fbreg -32： 一个整个存储的变量 - 从堆栈帧基址开始的32个字节。复合位置描述：根据片段描述对象，每个对象可以包含在寄存器的一部分中或存储在与其他片段无关的存储器位置中。比如， DW_OP_reg3 DW_OP_piece 4 DW_OP_reg10 DW_OP_piece 2：前四个字节位于寄存器 3 中，后两个字节位于寄存器 10 中的一个变量。位置列表：描述了具有有限生存期或在生存期内更改位置的对象。比如：<loclist with 3 entries follows>[ 0]<lowpc=0x2e00><highpc=0x2e19>DW_OP_reg0[ 1]<lowpc=0x2e19><highpc=0x2e3f>DW_OP_reg3[ 2]<lowpc=0x2ec4><highpc=0x2ec7>DW_OP_reg2根据程序计数器的当前值，位置在寄存器之间移动的变量。根据位置描述的种类，DW_AT_location 以三种不同的方式进行编码。exprloc 编码简单和复合的位置描述。它们由一个字节长度组成，后跟一个 DWARF 表达式或位置描述。loclist 和 loclistptr 的编码位置列表，它们在 .debug_loclists 部分中提供索引或偏移量，该部分描述了实际的位置列表。DWARF 表达式使用 DWARF 表达式计算变量的实际位置。这包括操作堆栈值的一系列操作。有很多 DWARF 操作可用，所以我不会详细解释它们。相反，我会从每一个表达式中给出一些例子，给你一个可用的东西。另外，不要害怕这些；libelfin 将为我们处理所有这些复杂性。字面编码DW_OP_lit0、DW_OP_lit1……DW_OP_lit31将字面量压入堆栈DW_OP_addr <addr>将地址操作数压入堆栈DW_OP_constu <unsigned>将无符号值压入堆栈寄存器值DW_OP_fbreg <offset>压入在堆栈帧基址找到的值，偏移给定值DW_OP_breg0、DW_OP_breg1…… DW_OP_breg31 <offset>将给定寄存器的内容加上给定的偏移量压入堆栈堆栈操作DW_OP_dup复制堆栈顶部的值DW_OP_deref将堆栈顶部视为内存地址，并将其替换为该地址的内容算术和逻辑运算DW_OP_and弹出堆栈顶部的两个值，并压回它们的逻辑 ANDDW_OP_plus与 DW_OP_and 相同，但是会添加值控制流操作DW_OP_le、DW_OP_eq、DW_OP_gt 等弹出前两个值，比较它们，并且如果条件为真，则压入 1，否则为 0DW_OP_bra <offset>条件分支：如果堆栈的顶部不是 0，则通过 offset 在表达式中向后或向后跳过输入转化DW_OP_convert <DIE offset>将堆栈顶部的值转换为不同的类型，它由给定偏移量的 DWARF 信息条目描述特殊操作DW_OP_nop什么都不做！DWARF 类型DWARF 类型的表示需要足够强大来为调试器用户提供有用的变量表示。用户经常希望能够在应用程序级别进行调试，而不是在机器级别进行调试，并且他们需要了解他们的变量正在做什么。DWARF 类型与大多数其他调试信息一起编码在 DIE 中。它们可以具有指示其名称、编码、大小、字节等的属性。无数的类型标签可用于表示指针、数组、结构体、typedef 以及 C 或 C++ 程序中可以看到的任何其他内容。以这个简单的结构体为例：struct test{ int i; float j; int k[42]; test* next; }; 这个结构体的父 DIE 是这样的：< 1><0x0000002a> DW_TAG_structure_type DW_AT_name "test" DW_AT_byte_size 0x000000b8 DW_AT_decl_file 0x00000001 test.cpp DW_AT_decl_line 0x00000001 上面说的是我们有一个叫做 test 的结构体，大小为 0xb8，在 test.cpp 的第 1 行声明。接下来有许多描述成员的子 DIE。< 2><0x00000032> DW_TAG_member DW_AT_name "i" DW_AT_type <0x00000063> DW_AT_decl_file 0x00000001 test.cpp DW_AT_decl_line 0x00000002 DW_AT_data_member_location 0 < 2><0x0000003e> DW_TAG_member DW_AT_name "j" DW_AT_type <0x0000006a> DW_AT_decl_file 0x00000001 test.cpp DW_AT_decl_line 0x00000003 DW_AT_data_member_location 4 < 2><0x0000004a> DW_TAG_member DW_AT_name "k" DW_AT_type <0x00000071> DW_AT_decl_file 0x00000001 test.cpp DW_AT_decl_line 0x00000004 DW_AT_data_member_location 8 < 2><0x00000056> DW_TAG_member DW_AT_name "next" DW_AT_type <0x00000084> DW_AT_decl_file 0x00000001 test.cpp DW_AT_decl_line 0x00000005 DW_AT_data_member_location 176(as signed = -80) 每个成员都有一个名称、一个类型（它是一个 DIE 偏移量）、一个声明文件和行，以及一个指向其成员所在的结构体的字节偏移。其类型指向如下。< 1><0x00000063> DW_TAG_base_type DW_AT_name "int" DW_AT_encoding DW_ATE_signed DW_AT_byte_size 0x00000004 < 1><0x0000006a> DW_TAG_base_type DW_AT_name "float" DW_AT_encoding DW_ATE_float DW_AT_byte_size 0x00000004 < 1><0x00000071> DW_TAG_array_type DW_AT_type <0x00000063> < 2><0x00000076> DW_TAG_subrange_type DW_AT_type <0x0000007d> DW_AT_count 0x0000002a < 1><0x0000007d> DW_TAG_base_type DW_AT_name "sizetype" DW_AT_byte_size 0x00000008 DW_AT_encoding DW_ATE_unsigned < 1><0x00000084> DW_TAG_pointer_type DW_AT_type <0x0000002a> 如你所见，我笔记本电脑上的 int 是一个 4 字节的有符号整数类型，float是一个 4 字节的浮点数。整数数组类型通过指向 int 类型作为其元素类型，sizetype（可以认为是 size_t）作为索引类型，它具有 2a 个元素。 test * 类型是 DW_TAG_pointer_type，它引用 test DIE。实现简单的变量读取器如上所述，libelfin 将为我们处理大部分复杂性。但是，它并没有实现用于表示可变位置的所有方法，并且在我们的代码中处理这些将变得非常复杂。因此，我现在选择只支持 exprloc。请根据需要添加对更多类型表达式的支持。如果你真的有勇气，请提交补丁到 libelfin 中来帮助完成必要的支持！处理变量主要是将不同部分定位在存储器或寄存器中，读取或写入与之前一样。为了简单起见，我只会告诉你如何实现读取。首先我们需要告诉 libelfin 如何从我们的进程中读取寄存器。我们创建一个继承自 expr_context 的类并使用 ptrace 来处理所有内容：class ptrace_expr_context : public dwarf::expr_context { public: ptrace_expr_context (pid_t pid) : m_pid{pid} {} dwarf::taddr reg (unsigned regnum) override { return get_register_value_from_dwarf_register(m_pid, regnum); } dwarf::taddr pc() override { struct user_regs_struct regs; ptrace(PTRACE_GETREGS, m_pid, nullptr, &regs); return regs.rip; } dwarf::taddr deref_size (dwarf::taddr address, unsigned size) override { //TODO take into account size return ptrace(PTRACE_PEEKDATA, m_pid, address, nullptr); } private: pid_t m_pid; }; 读取将由我们 debugger 类中的 read_variables 函数处理：void debugger::read_variables() { using namespace dwarf; auto func = get_function_from_pc(get_pc()); //... } 我们上面做的第一件事是找到我们目前进入的函数，然后我们需要循环访问该函数中的条目来寻找变量： for (const auto& die : func) { if (die.tag == DW_TAG::variable) { //... } } 我们通过查找 DIE 中的 DW_AT_location 条目获取位置信息： auto loc_val = die[DW_AT::location]; 接着我们确保它是一个 exprloc，并请求 libelfin 来评估我们的表达式： if (loc_val.get_type() == value::type::exprloc) { ptrace_expr_context context {m_pid}; auto result = loc_val.as_exprloc().evaluate(&context); 现在我们已经评估了表达式，我们需要读取变量的内容。它可以在内存或寄存器中，因此我们将处理这两种情况： switch (result.location_type) { case expr_result::type::address: { auto value = read_memory(result.value); std::cout << at_name(die) << " (0x" << std::hex << result.value << ") = " << value << std::endl; break; } case expr_result::type::reg: { auto value = get_register_value_from_dwarf_register(m_pid, result.value); std::cout << at_name(die) << " (reg " << result.value << ") = " << value << std::endl; break; } default: throw std::runtime_error{"Unhandled variable location"}; } 你可以看到，我根据变量的类型，打印输出了值而没有解释。希望通过这个代码，你可以看到如何支持编写变量，或者用给定的名字搜索变量。最后我们可以将它添加到我们的命令解析器中： else if(is_prefix(command, "variables")) { read_variables(); } 测试一下编写一些具有一些变量的小功能，不用优化并带有调试信息编译它，然后查看是否可以读取变量的值。尝试写入存储变量的内存地址，并查看程序改变的行为。已经有九篇文章了，还剩最后一篇！下一次我会讨论一些你可能会感兴趣的更高级的概念。现在你可以在这里找到这个帖子的代码。via: https://blog.tartanllama.xyz/writing-a-linux-debugger-variables/作者：Simon Brand 译者：geekpi 校对：wxy本文由 LCTT 原创编译，Linux中国 荣誉推出
有时你需要知道的最重要的信息是什么，你当前的程序状态是如何到达那里的。有一个 backtrace 命令，它给你提供了程序当前的函数调用链。这篇文章将向你展示如何在 x86\_64 上实现堆栈展开以生成这样的回溯。系列索引这些链接将会随着其他帖子的发布而上线。准备环境断点寄存器和内存ELF 和 DWARF源码和信号源码级逐步执行源码级断点堆栈展开读取变量之后步骤用下面的程序作为例子：void a() { //stopped here } void b() { a(); } void c() { a(); } int main() { b(); c(); } 如果调试器停在 //stopped here' 这行，那么有两种方法可以达到：main->b->a或main->c->a`。如果我们用 LLDB 设置一个断点，继续执行并请求一个回溯，那么我们将得到以下内容：* frame #0: 0x00000000004004da a.out`a() + 4 at bt.cpp:3 frame #1: 0x00000000004004e6 a.out`b() + 9 at bt.cpp:6 frame #2: 0x00000000004004fe a.out`main + 9 at bt.cpp:14 frame #3: 0x00007ffff7a2e830 libc.so.6`__libc_start_main + 240 at libc-start.c:291 frame #4: 0x0000000000400409 a.out`_start + 41 这说明我们目前在函数 a 中，a 从函数 b 中跳转，b 从 main 中跳转等等。最后两个帧是编译器如何引导 main 函数的。现在的问题是我们如何在 x86\_64 上实现。最稳健的方法是解析 ELF 文件的 .eh_frame 部分，并解决如何从那里展开堆栈，但这会很痛苦。你可以使用 libunwind 或类似的来做，但这很无聊。相反，我们假设编译器以某种方式设置了堆栈，我们将手动遍历它。为了做到这一点，我们首先需要了解堆栈的布局。 High | ... | +---------+ +24| Arg 1 | +---------+ +16| Arg 2 | +---------+ + 8| Return | +---------+ EBP+--> |Saved EBP| +---------+ - 8| Var 1 | +---------+ ESP+--> | Var 2 | +---------+ | ... | Low 如你所见，最后一个堆栈帧的帧指针存储在当前堆栈帧的开始处，创建一个链接的指针列表。堆栈依据这个链表解开。我们可以通过查找 DWARF 信息中的返回地址来找出列表中下一帧的函数。一些编译器将忽略跟踪 EBP 的帧基址，因为这可以表示为 ESP 的偏移量，并可以释放一个额外的寄存器。即使启用了优化，传递 -fno-omit-frame-pointer 到 GCC 或 Clang 会强制它遵循我们依赖的约定。我们将在 print_backtrace 函数中完成所有的工作：void debugger::print_backtrace() { 首先要决定的是使用什么格式打印出帧信息。我用了一个 lambda 来推出这个方法： auto output_frame = [frame_number = 0] (auto&& func) mutable { std::cout << "frame #" << frame_number++ << ": 0x" << dwarf::at_low_pc(func) << ' ' << dwarf::at_name(func) << std::endl; }; 打印输出的第一帧是当前正在执行的帧。我们可以通过查找 DWARF 中的当前程序计数器来获取此帧的信息： auto current_func = get_function_from_pc(get_pc()); output_frame(current_func); 接下来我们需要获取当前函数的帧指针和返回地址。帧指针存储在 rbp 寄存器中，返回地址是从帧指针堆栈起的 8 字节。 auto frame_pointer = get_register_value(m_pid, reg::rbp); auto return_address = read_memory(frame_pointer+8); 现在我们拥有了展开堆栈所需的所有信息。我只需要继续展开，直到调试器命中 main，但是当帧指针为 0x0 时，你也可以选择停止，这些是你在调用 main 函数之前调用的函数。我们将从每帧抓取帧指针和返回地址，并打印出信息。 while (dwarf::at_name(current_func) != "main") { current_func = get_function_from_pc(return_address); output_frame(current_func); frame_pointer = read_memory(frame_pointer); return_address = read_memory(frame_pointer+8); } } 就是这样！以下是整个函数：void debugger::print_backtrace() { auto output_frame = [frame_number = 0] (auto&& func) mutable { std::cout << "frame #" << frame_number++ << ": 0x" << dwarf::at_low_pc(func) << ' ' << dwarf::at_name(func) << std::endl; }; auto current_func = get_function_from_pc(get_pc()); output_frame(current_func); auto frame_pointer = get_register_value(m_pid, reg::rbp); auto return_address = read_memory(frame_pointer+8); while (dwarf::at_name(current_func) != "main") { current_func = get_function_from_pc(return_address); output_frame(current_func); frame_pointer = read_memory(frame_pointer); return_address = read_memory(frame_pointer+8); } } 添加命令当然，我们必须向用户公开这个命令。 else if(is_prefix(command, "backtrace")) { print_backtrace(); } 测试测试此功能的一个方法是通过编写一个测试程序与一堆互相调用的小函数。设置几个断点，跳到代码附近，并确保你的回溯是准确的。我们已经从一个只能产生并附加到其他程序的程序走了很长的路。本系列的倒数第二篇文章将通过支持读写变量来完成调试器的实现。在此之前，你可以在这里找到这个帖子的代码。via: https://blog.tartanllama.xyz/c++/2017/06/24/writing-a-linux-debugger-unwinding/作者：Simon Brand 译者：geekpi 校对：wxy本文由 LCTT 原创编译，Linux中国 荣誉推出
在内存地址上设置断点虽然不错，但它并没有提供最方便用户的工具。我们希望能够在源代码行和函数入口地址上设置断点，以便我们可以在与代码相同的抽象级别中进行调试。这篇文章将会添加源码级断点到我们的调试器中。通过所有我们已经支持的功能，这要比起最初听起来容易得多。我们还将添加一个命令来获取符号的类型和地址，这对于定位代码或数据以及理解链接概念非常有用。系列索引随着后面文章的发布，这些链接会逐渐生效。准备环境断点寄存器和内存Elves 和 dwarves源码和信号源码级逐步执行源码级断点调用栈读取变量之后步骤断点DWARFElves 和 dwarves 这篇文章，描述了 DWARF 调试信息是如何工作的，以及如何用它来将机器码映射到高层源码中。回想一下，DWARF 包含了函数的地址范围和一个允许你在抽象层之间转换代码位置的行表。我们将使用这些功能来实现我们的断点。函数入口如果你考虑重载、成员函数等等，那么在函数名上设置断点可能有点复杂，但是我们将遍历所有的编译单元，并搜索与我们正在寻找的名称匹配的函数。DWARF 信息如下所示：< 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_name foo ... ... <14><0x000000b0> DW_TAG_subprogram DW_AT_low_pc 0x00400700 DW_AT_high_pc 0x004007a0 DW_AT_name bar ... 我们想要匹配 DW_AT_name 并使用 DW_AT_low_pc（函数的起始地址）来设置我们的断点。void debugger::set_breakpoint_at_function(const std::string& name) { for (const auto& cu : m_dwarf.compilation_units()) { for (const auto& die : cu.root()) { if (die.has(dwarf::DW_AT::name) && at_name(die) == name) { auto low_pc = at_low_pc(die); auto entry = get_line_entry_from_pc(low_pc); ++entry; //skip prologue set_breakpoint_at_address(entry->address); } } } } 这代码看起来有点奇怪的唯一一点是 ++entry。 问题是函数的 DW_AT_low_pc 不指向该函数的用户代码的起始地址，它指向 prologue 的开始。编译器通常会输出一个函数的 prologue 和 epilogue，它们用于执行保存和恢复堆栈、操作堆栈指针等。这对我们来说不是很有用，所以我们将入口行加一来获取用户代码的第一行而不是 prologue。DWARF 行表实际上具有一些功能，用于将入口标记为函数 prologue 之后的第一行，但并不是所有编译器都输出它，因此我采用了原始的方法。源码行要在高层源码行上设置一个断点，我们要将这个行号转换成 DWARF 中的一个地址。我们将遍历编译单元，寻找一个名称与给定文件匹配的编译单元，然后查找与给定行对应的入口。DWARF 看上去有点像这样：.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" 0x004004a7 [ 1, 0] NS uri: "/super/secret/path/a.hpp" 0x004004ab [ 2, 0] NS 0x004004b2 [ 3, 0] NS 0x004004b9 [ 4, 0] NS 0x004004c1 [ 5, 0] NS 0x004004c3 [ 1, 0] NS uri: "/super/secret/path/b.hpp" 0x004004c7 [ 2, 0] NS 0x004004ce [ 3, 0] NS 0x004004d5 [ 4, 0] NS 0x004004dd [ 5, 0] NS 0x004004df [ 4, 0] NS uri: "/super/secret/path/ab.cpp" 0x004004e3 [ 5, 0] NS 0x004004e8 [ 6, 0] NS 0x004004ed [ 7, 0] NS 0x004004f4 [ 7, 0] NS ET 所以如果我们想要在 ab.cpp 的第五行设置一个断点，我们将查找与行 (0x004004e3) 相关的入口并设置一个断点。void debugger::set_breakpoint_at_source_line(const std::string& file, unsigned line) { for (const auto& cu : m_dwarf.compilation_units()) { if (is_suffix(file, at_name(cu.root()))) { const auto& lt = cu.get_line_table(); for (const auto& entry : lt) { if (entry.is_stmt && entry.line == line) { set_breakpoint_at_address(entry.address); return; } } } } } 我这里做了 is_suffix hack，这样你可以输入 c.cpp 代表 a/b/c.cpp 。当然你实际上应该使用大小写敏感路径处理库或者其它东西，但是我比较懒。entry.is_stmt 是检查行表入口是否被标记为一个语句的开头，这是由编译器根据它认为是断点的最佳目标的地址设置的。符号查找当我们在对象文件层时，符号是王者。函数用符号命名，全局变量用符号命名，你得到一个符号，我们得到一个符号，每个人都得到一个符号。 在给定的对象文件中，一些符号可能引用其他对象文件或共享库，链接器将从符号引用创建一个可执行程序。可以在正确命名的符号表中查找符号，它存储在二进制文件的 ELF 部分中。幸运的是，libelfin 有一个不错的接口来做这件事，所以我们不需要自己处理所有的 ELF 的事情。为了让你知道我们在处理什么，下面是一个二进制文件的 .symtab 部分的转储，它由 readelf 生成：Num: Value Size Type Bind Vis Ndx Name 0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND 1: 0000000000400238 0 SECTION LOCAL DEFAULT 1 2: 0000000000400254 0 SECTION LOCAL DEFAULT 2 3: 0000000000400278 0 SECTION LOCAL DEFAULT 3 4: 00000000004002c8 0 SECTION LOCAL DEFAULT 4 5: 0000000000400430 0 SECTION LOCAL DEFAULT 5 6: 00000000004004e4 0 SECTION LOCAL DEFAULT 6 7: 0000000000400508 0 SECTION LOCAL DEFAULT 7 8: 0000000000400528 0 SECTION LOCAL DEFAULT 8 9: 0000000000400558 0 SECTION LOCAL DEFAULT 9 10: 0000000000400570 0 SECTION LOCAL DEFAULT 10 11: 0000000000400714 0 SECTION LOCAL DEFAULT 11 12: 0000000000400720 0 SECTION LOCAL DEFAULT 12 13: 0000000000400724 0 SECTION LOCAL DEFAULT 13 14: 0000000000400750 0 SECTION LOCAL DEFAULT 14 15: 0000000000600e18 0 SECTION LOCAL DEFAULT 15 16: 0000000000600e20 0 SECTION LOCAL DEFAULT 16 17: 0000000000600e28 0 SECTION LOCAL DEFAULT 17 18: 0000000000600e30 0 SECTION LOCAL DEFAULT 18 19: 0000000000600ff0 0 SECTION LOCAL DEFAULT 19 20: 0000000000601000 0 SECTION LOCAL DEFAULT 20 21: 0000000000601018 0 SECTION LOCAL DEFAULT 21 22: 0000000000601028 0 SECTION LOCAL DEFAULT 22 23: 0000000000000000 0 SECTION LOCAL DEFAULT 23 24: 0000000000000000 0 SECTION LOCAL DEFAULT 24 25: 0000000000000000 0 SECTION LOCAL DEFAULT 25 26: 0000000000000000 0 SECTION LOCAL DEFAULT 26 27: 0000000000000000 0 SECTION LOCAL DEFAULT 27 28: 0000000000000000 0 SECTION LOCAL DEFAULT 28 29: 0000000000000000 0 SECTION LOCAL DEFAULT 29 30: 0000000000000000 0 SECTION LOCAL DEFAULT 30 31: 0000000000000000 0 FILE LOCAL DEFAULT ABS init.c 32: 0000000000000000 0 FILE LOCAL DEFAULT ABS crtstuff.c 33: 0000000000600e28 0 OBJECT LOCAL DEFAULT 17 __JCR_LIST__ 34: 00000000004005a0 0 FUNC LOCAL DEFAULT 10 deregister_tm_clones 35: 00000000004005e0 0 FUNC LOCAL DEFAULT 10 register_tm_clones 36: 0000000000400620 0 FUNC LOCAL DEFAULT 10 __do_global_dtors_aux 37: 0000000000601028 1 OBJECT LOCAL DEFAULT 22 completed.6917 38: 0000000000600e20 0 OBJECT LOCAL DEFAULT 16 __do_global_dtors_aux_fin 39: 0000000000400640 0 FUNC LOCAL DEFAULT 10 frame_dummy 40: 0000000000600e18 0 OBJECT LOCAL DEFAULT 15 __frame_dummy_init_array_ 41: 0000000000000000 0 FILE LOCAL DEFAULT ABS /super/secret/path/MiniDbg/ 42: 0000000000000000 0 FILE LOCAL DEFAULT ABS crtstuff.c 43: 0000000000400818 0 OBJECT LOCAL DEFAULT 14 __FRAME_END__ 44: 0000000000600e28 0 OBJECT LOCAL DEFAULT 17 __JCR_END__ 45: 0000000000000000 0 FILE LOCAL DEFAULT ABS 46: 0000000000400724 0 NOTYPE LOCAL DEFAULT 13 __GNU_EH_FRAME_HDR 47: 0000000000601000 0 OBJECT LOCAL DEFAULT 20 _GLOBAL_OFFSET_TABLE_ 48: 0000000000601028 0 OBJECT LOCAL DEFAULT 21 __TMC_END__ 49: 0000000000601020 0 OBJECT LOCAL DEFAULT 21 __dso_handle 50: 0000000000600e20 0 NOTYPE LOCAL DEFAULT 15 __init_array_end 51: 0000000000600e18 0 NOTYPE LOCAL DEFAULT 15 __init_array_start 52: 0000000000600e30 0 OBJECT LOCAL DEFAULT 18 _DYNAMIC 53: 0000000000601018 0 NOTYPE WEAK DEFAULT 21 data_start 54: 0000000000400710 2 FUNC GLOBAL DEFAULT 10 __libc_csu_fini 55: 0000000000400570 43 FUNC GLOBAL DEFAULT 10 _start 56: 0000000000000000 0 NOTYPE WEAK DEFAULT UND __gmon_start__ 57: 0000000000400714 0 FUNC GLOBAL DEFAULT 11 _fini 58: 0000000000000000 0 FUNC GLOBAL DEFAULT UND __libc_start_main@@GLIBC_ 59: 0000000000400720 4 OBJECT GLOBAL DEFAULT 12 _IO_stdin_used 60: 0000000000601018 0 NOTYPE GLOBAL DEFAULT 21 __data_start 61: 00000000004006a0 101 FUNC GLOBAL DEFAULT 10 __libc_csu_init 62: 0000000000601028 0 NOTYPE GLOBAL DEFAULT 22 __bss_start 63: 0000000000601030 0 NOTYPE GLOBAL DEFAULT 22 _end 64: 0000000000601028 0 NOTYPE GLOBAL DEFAULT 21 _edata 65: 0000000000400670 44 FUNC GLOBAL DEFAULT 10 main 66: 0000000000400558 0 FUNC GLOBAL DEFAULT 9 _init 你可以在对象文件中看到用于设置环境的很多符号，最后还可以看到 main 符号。我们对符号的类型、名称和值（地址）感兴趣。我们有一个该类型的 symbol_type 枚举，并使用一个 std::string 作为名称，std::uintptr_t 作为地址：enum class symbol_type { notype, // No type (e.g., absolute symbol) object, // Data object func, // Function entry point section, // Symbol is associated with a section file, // Source file associated with the }; // object file std::string to_string (symbol_type st) { switch (st) { case symbol_type::notype: return "notype"; case symbol_type::object: return "object"; case symbol_type::func: return "func"; case symbol_type::section: return "section"; case symbol_type::file: return "file"; } } struct symbol { symbol_type type; std::string name; std::uintptr_t addr; }; 我们需要将从 libelfin 获得的符号类型映射到我们的枚举，因为我们不希望依赖关系破环这个接口。幸运的是，我为所有的东西选了同样的名字，所以这样很简单：symbol_type to_symbol_type(elf::stt sym) { switch (sym) { case elf::stt::notype: return symbol_type::notype; case elf::stt::object: return symbol_type::object; case elf::stt::func: return symbol_type::func; case elf::stt::section: return symbol_type::section; case elf::stt::file: return symbol_type::file; default: return symbol_type::notype; } }; 最后我们要查找符号。为了说明的目的，我循环查找符号表的 ELF 部分，然后收集我在其中找到的任意符号到 std::vector 中。更智能的实现可以建立从名称到符号的映射，这样你只需要查看一次数据就行了。std::vector<symbol> debugger::lookup_symbol(const std::string& name) { std::vector<symbol> syms; for (auto &sec : m_elf.sections()) { if (sec.get_hdr().type != elf::sht::symtab && sec.get_hdr().type != elf::sht::dynsym) continue; for (auto sym : sec.as_symtab()) { if (sym.get_name() == name) { auto &d = sym.get_data(); syms.push_back(symbol{to_symbol_type(d.type()), sym.get_name(), d.value}); } } } return syms; } 添加命令一如往常，我们需要添加一些更多的命令来向用户暴露功能。对于断点，我使用 GDB 风格的接口，其中断点类型是通过你传递的参数推断的，而不用要求显式切换：0x<hexadecimal> -> 断点地址<line>:<filename> -> 断点行号<anything else> -> 断点函数名 else if(is_prefix(command, "break")) { if (args[1][0] == '0' && args[1][1] == 'x') { std::string addr {args[1], 2}; set_breakpoint_at_address(std::stol(addr, 0, 16)); } else if (args[1].find(':') != std::string::npos) { auto file_and_line = split(args[1], ':'); set_breakpoint_at_source_line(file_and_line[0], std::stoi(file_and_line[1])); } else { set_breakpoint_at_function(args[1]); } } 对于符号，我们将查找符号并打印出我们发现的任何匹配项：else if(is_prefix(command, "symbol")) { auto syms = lookup_symbol(args[1]); for (auto&& s : syms) { std::cout << s.name << ' ' << to_string(s.type) << " 0x" << std::hex << s.addr << std::endl; } } 测试一下在一个简单的二进制文件上启动调试器，并设置源代码级别的断点。在一些 foo 函数上设置一个断点，看到我的调试器停在它上面是我这个项目最有价值的时刻之一。符号查找可以通过在程序中添加一些函数或全局变量并查找它们的名称来进行测试。请注意，如果你正在编译 C++ 代码，你还需要考虑名称重整。本文就这些了。下一次我将展示如何向调试器添加堆栈展开支持。你可以在这里找到这篇文章的代码。via: https://blog.tartanllama.xyz/c++/2017/06/19/writing-a-linux-debugger-source-break/作者：Simon Brand 译者：geekpi 校对：wxy本文由 LCTT 原创编译，Linux中国 荣誉推出
开发一个 Linux 调试器（六）：源码级逐步执行
在前几篇博文中我们学习了 DWARF 信息以及它如何使我们将机器码和上层源码联系起来。这一次我们通过为我们的调试器添加源码级逐步调试将该知识应用于实际。系列文章索引随着后面文章的发布，这些链接会逐渐生效。准备环境断点寄存器和内存Elves 和 dwarves源码和信号源码级逐步执行源码级断点调用栈展开读取变量下一步揭秘指令级逐步执行我们正在超越了自我。首先让我们通过用户接口揭秘指令级单步执行。我决定将它切分为能被其它部分代码利用的 single_step_instruction 和确保是否启用了某个断点的 single_step_instruction_with_breakpoint_check 两个函数。void debugger::single_step_instruction() { ptrace(PTRACE_SINGLESTEP, m_pid, nullptr, nullptr); wait_for_signal(); } void debugger::single_step_instruction_with_breakpoint_check() { //首先，检查我们是否需要停用或者启用某个断点 if (m_breakpoints.count(get_pc())) { step_over_breakpoint(); } else { single_step_instruction(); } } 正如以往，另一个命令被集成到我们的 handle_command 函数：else if(is_prefix(command, "stepi")) { single_step_instruction_with_breakpoint_check(); auto line_entry = get_line_entry_from_pc(get_pc()); print_source(line_entry->file->path, line_entry->line); } 利用新增的这些函数我们可以开始实现我们的源码级逐步执行函数。实现逐步执行我们打算编写这些函数非常简单的版本，但真正的调试器有 thread plan 的概念，它封装了所有的单步信息。例如，调试器可能有一些复杂的逻辑去决定断点的位置，然后有一些回调函数用于判断单步操作是否完成。这其中有非常多的基础设施，我们只采用一种朴素的方法。我们可能会意外地跳过断点，但如果你愿意的话，你可以花一些时间把所有的细节都处理好。对于跳出 step_out，我们只是在函数的返回地址处设一个断点然后继续执行。我暂时还不想考虑调用栈展开的细节 - 这些都会在后面的部分介绍 - 但可以说返回地址就保存在栈帧开始的后 8 个字节中。因此我们会读取栈指针然后在内存相对应的地址读取值：void debugger::step_out() { auto frame_pointer = get_register_value(m_pid, reg::rbp); auto return_address = read_memory(frame_pointer+8); bool should_remove_breakpoint = false; if (!m_breakpoints.count(return_address)) { set_breakpoint_at_address(return_address); should_remove_breakpoint = true; } continue_execution(); if (should_remove_breakpoint) { remove_breakpoint(return_address); } } remove_breakpoint 是一个小的帮助函数：void debugger::remove_breakpoint(std::intptr_t addr) { if (m_breakpoints.at(addr).is_enabled()) { m_breakpoints.at(addr).disable(); } m_breakpoints.erase(addr); } 接下来是跳入 step_in。一个简单的算法是继续逐步执行指令直到新的一行。void debugger::step_in() { auto line = get_line_entry_from_pc(get_pc())->line; while (get_line_entry_from_pc(get_pc())->line == line) { single_step_instruction_with_breakpoint_check(); } auto line_entry = get_line_entry_from_pc(get_pc()); print_source(line_entry->file->path, line_entry->line); } 跳过 step_over 对于我们来说是三个中最难的。理论上，解决方法就是在下一行源码中设置一个断点，但下一行源码是什么呢？它可能不是当前行后续的那一行，因为我们可能处于一个循环、或者某种条件结构之中。真正的调试器一般会检查当前正在执行什么指令然后计算出所有可能的分支目标，然后在所有分支目标中设置断点。对于一个小的项目，我不打算实现或者集成一个 x86 指令模拟器，因此我们要想一个更简单的解决办法。有几个可怕的选择，一个是一直逐步执行直到当前函数新的一行，或者在当前函数的每一行都设置一个断点。如果我们是要跳过一个函数调用，前者将会相当的低效，因为我们需要逐步执行那个调用图中的每个指令，因此我会采用第二种方法。void debugger::step_over() { auto func = get_function_from_pc(get_pc()); auto func_entry = at_low_pc(func); auto func_end = at_high_pc(func); auto line = get_line_entry_from_pc(func_entry); auto start_line = get_line_entry_from_pc(get_pc()); std::vector<std::intptr_t> to_delete{}; while (line->address < func_end) { if (line->address != start_line->address && !m_breakpoints.count(line->address)) { set_breakpoint_at_address(line->address); to_delete.push_back(line->address); } ++line; } auto frame_pointer = get_register_value(m_pid, reg::rbp); auto return_address = read_memory(frame_pointer+8); if (!m_breakpoints.count(return_address)) { set_breakpoint_at_address(return_address); to_delete.push_back(return_address); } continue_execution(); for (auto addr : to_delete) { remove_breakpoint(addr); } } 这个函数有一点复杂，我们将它拆开来看。 auto func = get_function_from_pc(get_pc()); auto func_entry = at_low_pc(func); auto func_end = at_high_pc(func); at_low_pc 和 at_high_pc 是 libelfin 中的函数，它们能给我们指定函数 DWARF 信息条目的最小和最大程序计数器值。 auto line = get_line_entry_from_pc(func_entry); auto start_line = get_line_entry_from_pc(get_pc()); std::vector<std::intptr_t> breakpoints_to_remove{}; while (line->address < func_end) { if (line->address != start_line->address && !m_breakpoints.count(line->address)) { set_breakpoint_at_address(line->address); breakpoints_to_remove.push_back(line->address); } ++line; } 我们需要移除我们设置的所有断点，以便不会泄露出我们的逐步执行函数，为此我们把它们保存到一个 std::vector 中。为了设置所有断点，我们循环遍历行表条目直到找到一个不在我们函数范围内的。对于每一个，我们都要确保它不是我们当前所在的行，而且在这个位置还没有设置任何断点。 auto frame_pointer = get_register_value(m_pid, reg::rbp); auto return_address = read_memory(frame_pointer+8); if (!m_breakpoints.count(return_address)) { set_breakpoint_at_address(return_address); to_delete.push_back(return_address); } 这里我们在函数的返回地址处设置一个断点，正如跳出 step_out。 continue_execution(); for (auto addr : to_delete) { remove_breakpoint(addr); } 最后，我们继续执行直到命中它们中的其中一个断点，然后移除所有我们设置的临时断点。它并不美观，但暂时先这样吧。当然，我们还需要将这个新功能添加到用户界面： else if(is_prefix(command, "step")) { step_in(); } else if(is_prefix(command, "next")) { step_over(); } else if(is_prefix(command, "finish")) { step_out(); } 测试我通过实现一个调用一系列不同函数的简单函数来进行测试：void a() { int foo = 1; } void b() { int foo = 2; a(); } void c() { int foo = 3; b(); } void d() { int foo = 4; c(); } void e() { int foo = 5; d(); } void f() { int foo = 6; e(); } int main() { f(); } 你应该可以在 main 地址处设置一个断点，然后在整个程序中跳入、跳过、跳出函数。如果你尝试跳出 main 函数或者跳入任何动态链接库，就会出现意料之外的事情。你可以在这里找到这篇博文的相关代码。下次我们会利用我们新的 DWARF 技巧来实现源码级断点。via: https://blog.tartanllama.xyz/c++/2017/05/06/writing-a-linux-debugger-dwarf-step/作者：Simon Brand 译者：ictlyh 校对：wxy本文由 LCTT 原创编译，Linux中国 荣誉推出
在上一部分我们学习了关于 DWARF 的信息，以及它如何被用于读取变量和将被执行的机器码与我们的高级语言的源码联系起来。在这一部分，我们将进入实践，实现一些我们调试器后面会使用的 DWARF 原语。我们也会利用这个机会，使我们的调试器可以在命中一个断点时打印出当前的源码上下文。系列文章索引随着后面文章的发布，这些链接会逐渐生效。准备环境断点寄存器和内存Elves 和 dwarves源码和信号源码级逐步执行源码级断点调用栈展开读取变量下一步设置我们的 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_KERNEL 或 TRAP_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、调试信息、一种无需解析就可以理解源码方式。今天我们会详细介绍源码级的调试信息，作为本指南后面部分使用它的准备。系列文章索引随着后面文章的发布，这些链接会逐渐生效。准备环境断点寄存器和内存Elves 和 dwarves源码和信号源码级逐步执行源码级断点调用栈展开读取变量下一步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 : < 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 PEClang 在这个条目中包括了序言结束标记，因此我们知道在这里停止，然后在地址 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 的位置，如果它是在 0x1 和 0x4 之间，那么帧基就在和相同位置偏移量为 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中国 荣誉推出
上一篇博文中我们给调试器添加了一个简单的地址断点。这次，我们将添加读写寄存器和内存的功能，这将使我们能够使用我们的程序计数器、观察状态和改变程序的行为。系列文章索引随着后面文章的发布，这些链接会逐渐生效。准备环境断点寄存器和内存Elves 和 dwarves源码和信号源码级逐步执行源码级断点调用栈展开读取变量下一步注册我们的寄存器在我们真正读取任何寄存器之前，我们需要告诉调试器一些关于我们的目标平台的信息，这里是 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 rax、 register 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_readv 和 process_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 : 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 以便正确设置 esi 和 edi 寄存器。在下一篇博客中，我们会第一次接触到 DWARF 信息并给我们的调试器添加一系列逐步调试的功能。之后，我们会有一个功能工具，它能逐步执行代码、在想要的地方设置断点、修改数据以及其它。一如以往，如果你有任何问题请留下你的评论！你可以在这里找到这篇博文的代码。via: https://blog.tartanllama.xyz/c++/2017/03/31/writing-a-linux-debugger-registers/作者：TartanLlama 译者：ictlyh 校对：jasminepeng本文由 LCTT 原创编译，Linux中国 荣誉推出
在该系列的第一部分，我们写了一个小的进程启动器，作为我们调试器的基础。在这篇博客中，我们会学习在 x86 Linux 上断点是如何工作的，以及如何给我们工具添加设置断点的能力。系列文章索引随着后面文章的发布，这些链接会逐渐生效。准备环境断点寄存器和内存Elves 和 dwarves源码和信号源码层逐步执行源码层断点调用栈读取变量 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 }; 这里的大部分代码都是跟踪状态；真正神奇的地方是 enable 和 disable 函数。正如我们上面学到的，我们要用 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 类修改三个地方：给 debugger 添加断点存储数据结构添加 set_breakpoint_at_address 函数给我们的 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 : 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 复杂一些的程序的人都应该使用过调试器（如果你还没有，那就停下手头的工作先学习一下吧）。但是，尽管这些工具已经得到了广泛的使用，却并没有太多的资源告诉你它们的工作原理以及如何开发，尤其是和其它那些比如编译器等工具链技术相比而言。此处有一些其它的资源可以参考：http://eli.thegreenplace.net/2011/01/23/how-debuggers-work-part-1https://t-a-w.blogspot.co.uk/2007/03/how-to-code-debuggers.htmlhttps://www.codeproject.com/Articles/43682/Writing-a-basic-Windows-debuggerhttp://system.joekain.com/debugger/我们将会支持以下功能：启动、暂停、继续执行在不同地方设置断点内存地址源代码行函数入口读写寄存器和内存单步执行指令进入函数跳出函数跳过函数打印当前代码地址打印函数调用栈打印简单变量的值在最后一部分，我还会大概介绍如何给你的调试器添加下面的功能：远程调试共享库和动态库支持表达式计算多线程调试支持在本项目中我会将重点放在 C 和 C++，但对于那些将源码编译为机器码并输出标准 DWARE 调试信息的语言也应该能起作用（如果你还不知道这些东西是什么，别担心，马上就会介绍到啦）。另外，我只关注如何将程序运行起来并在大部分情况下能正常工作，为了简便，会避开类似健壮错误处理方面的东西。系列文章索引随着后面文章的发布，这些链接会逐渐生效。准备环境断点寄存器和内存Elves 和 dwarves源码和信号源码层逐步执行源码层断点调用栈读取变量之后步骤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 是一个内存地址，用于在一些调用中指定被跟踪程序的地址；data 是 request 相应的资源。返回值通常是一些错误信息，因此在你实际的代码中你也许应该检查返回值；为了简洁我这里就省略了。你可以查看 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 获取输入直到收到 EOF（CTRL+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 的格式。要继续执行程序，用户需要输入 continue 或 cont 甚至只需 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"; } } split 和 is_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中国 荣誉推出