蚊章汇 - 调试器
type: channel
link: https://linuxcn.bwsl.wang/tag/%E8%B0%83%E8%AF%95%E5%99%A8/
items: 在 Python 调试过程中设置不中断的断点
items: Python 调试器入门
items: 开发一个 Linux 调试器（十）：高级主题
items: 开发一个 Linux 调试器（九）：处理变量
items: 开发一个 Linux 调试器（七）：源码级断点
items: 开发一个 Linux 调试器（六）：源码级逐步执行
items: 开发一个 Linux 调试器（五）：源码和信号
items: 开发一个 Linux 调试器（四）：Elves 和 dwarves
items: 开发一个 Linux 调试器（三）：寄存器和内存
items: 开发一个 Linux 调试器（二）：断点

在 Python 调试过程中设置不中断的断点
type: item
date: 2020-03-12T18:45:00+08:00
description: 你对如何让调试器变得更快产生过兴趣吗？本文将分享我们在为 Python 构建调试器时得到的一些经验。整段故事讲的是我们在 Rookout 公司的团队为 Python 调试器开发不中断断点的经历，以及开发过程中得到的经验。我将在本月于旧金山举办的 PyBay 2019 上介绍有关 Python 调试过程的更多细节，但现在就让我们立刻开始这段故事。Python 调试器的心脏：sys.set\_trace在诸多可选的 Python 调试器中，使用最广泛的三个是：pdb，它是 Python 标准库的一部分PyDev，它是内嵌在 Eclipse 和 Pycharm 等 IDE 中的调试器ipdb，它是 IPython 的调试器Python 调试器的选择虽多，但它们几乎都基于同一个函数：sys.settrace。 值得一提的是， sys.settrace 可能也是 Python 标准库中最复杂的函数。简单来讲，settrace 的作用是为解释器注册一个跟踪函数，它在下列四种情形发生时被调用：函数调用语句执行函数返回异常抛出一个简单的跟踪函数看上去大概是这样：def simple_tracer(frame, event, arg): co = frame.f_code func_name = co.co_name line_no = frame.f_lineno print("{e} {f} {l}".format( e=event, f=func_name, l=line_no)) return simple_tracer在分析函数时我们首先关注的是参数和返回值，该跟踪函数的参数分别是：frame，当前堆栈帧，它是包含当前函数执行时解释器里完整状态的对象event，事件，它是一个值可能为 call、line、return 或 exception 的字符串arg，参数，它的取值基于 event 的类型，是一个可选项该跟踪函数的返回值是它自身，这是由于解释器需要持续跟踪两类跟踪函数：全局跟踪函数（每线程）：该跟踪函数由当前线程调用 sys.settrace 来设置，并在解释器创建一个新的堆栈帧时被调用（即代码中发生函数调用时）。虽然没有现成的方式来为不同的线程设置跟踪函数，但你可以调用 threading.settrace 来为所有新创建的 threading 模块线程设置跟踪函数。局部跟踪函数（每一帧）：解释器将该跟踪函数的值设置为全局跟踪函数创建帧时的返回值。同样也没有现成的方法能够在帧被创建时自动设置局部跟踪函数。该机制的目的是让调试器对被跟踪的帧有更精确的把握，以减少对性能的影响。简单三步构建调试器 （我们最初的设想）仅仅依靠上文提到的内容，用自制的跟踪函数来构建一个真正的调试器似乎有些不切实际。幸运的是，Python 的标准调试器 pdb 是基于 Bdb 构建的，后者是 Python 标准库中专门用于构建调试器的基类。基于 Bdb 的简易断点调试器看上去是这样的：import bdb import inspect class Debugger(bdb.Bdb): def __init__(self): Bdb.__init__(self) self.breakpoints = dict() self.set_trace() def set_breakpoint(self, filename, lineno, method): self.set_break(filename, lineno) try : self.breakpoints[(filename, lineno)].add(method) except KeyError: self.breakpoints[(filename, lineno)] = [method] def user_line(self, frame): if not self.break_here(frame): return # Get filename and lineno from frame (filename, lineno, _, _, _) = inspect.getframeinfo(frame) methods = self.breakpoints[(filename, lineno)] for method in methods: method(frame)这个调试器类的全部构成是：继承 Bdb，定义一个简单的构造函数来初始化基类，并开始跟踪。添加 set_breakpoint 方法，它使用 Bdb 来设置断点，并跟踪这些断点。重载 Bdb 在当前用户行调用的 user_line 方法，该方法一定被一个断点调用，之后获取该断点的源位置，并调用已注册的断点。这个简易的 Bdb 调试器效率…

Python 调试器入门
type: item
date: 2018-09-04T20:09:00+08:00
description: Python 生态系统包含丰富的工具和库，可以让开发人员更加舒适。 例如，我们之前已经介绍了如何使用交互式 shell 增强 Python。本文重点介绍另一种可以节省时间并提高 Python 技能的工具：Python 调试器。Python 调试器Python 标准库提供了一个名为 pdb 的调试器。此调试器提供了调试所需的大多数功能，如断点、单行步进、堆栈帧的检查等等。了解一些pdb 的基本知识很有用，因为它是标准库的一部分。 你可以在无法安装其他增强的调试器的环境中使用它。运行 pdb运行 pdb 的最简单方法是从命令行，将程序作为参数传递来调试。 看看以下脚本：# pdb_test.py #!/usr/bin/python3 from time import sleep def countdown(number): for i in range(number, 0, -1): print(i) sleep(1) if __name__ == "__main__": seconds = 10 countdown(seconds)你可以从命令行运行 pdb，如下所示：$ python3 -m pdb pdb_test.py > /tmp/pdb_test.py(1)<module>() -> from time import sleep (Pdb)使用 pdb 的另一种方法是在程序中设置断点。为此，请导入 pdb 模块并使用set_trace 函数：# pdb_test.py #!/usr/bin/python3 from time import sleep def countdown(number): for i in range(number, 0, -1): import pdb; pdb.set_trace() print(i) sleep(1) if __name__ == "__main__": seconds = 10 countdown(seconds)$ python3 pdb_test.py > /tmp/pdb_test.py(6)countdown() -> print(i) (Pdb)脚本在断点处停止，pdb 显示脚本中的下一行。 你也可以在失败后执行调试器。 这称为 事后调试 postmortem debugging 。穿行于执行堆栈调试中的一个常见用例是在执行堆栈中穿行。 Python 调试器运行后，可以使用以下命令：w(here)：显示当前执行的行以及执行堆栈的位置。$ python3 test_pdb.py > /tmp/test_pdb.py(10)countdown() -> print(i) (Pdb) w /tmp/test_pdb.py(16)<module>() -> countdown(seconds) > /tmp/test_pdb.py(10)countdown() -> print(i) (Pdb)l(ist)：显示当前位置周围更多的上下文（代码）。$ python3 test_pdb.py > /tmp/test_pdb.py(10)countdown() -> print(i) (Pdb) l 5 6 7 def countdown(number): 8 for i in range(number, 0, -1): 9 import pdb; pdb.set_trace() 10 -> print(i) 11 sleep(1) 12 13 14 if __name__ == "__main__": 15 seconds = 10u(p)/d(own)：向上或向下穿行调用堆栈。$ py3 test_pdb.py > /tmp/test_pdb.py(10)countdown() -> print(i) (Pdb) up > /tmp/test_pdb.py(16)<module>() -> countdown(seconds) (Pdb) down > /tmp/test_pdb.py(10)countdown() -> print(i) (Pdb)单步执行程序pdb提供以下命令来执行和单步执行代码：n(ext)：继续执行，直到达到当前函数中的下一行，或者返回s(tep)：执行当前行并在第一个可能的场合停止（在被调用的函数或当前函数中）c(ontinue)：继续执行，仅在断点处停止。$ py3 test_pdb.py > /tmp/test_pdb.py(10)countdown() -> print(i) (Pdb) n 10 > /tmp/test_pdb.py(11)countdown() -> sleep(1) (Pdb) n > /tmp/test_pdb.py…

开发一个 Linux 调试器（十）：高级主题
type: item
date: 2017-10-12T18:38:00+08:00
description: 我们终于来到这个系列的最后一篇文章！这一次，我将对调试中的一些更高级的概念进行高层的概述：远程调试、共享库支持、表达式计算和多线程支持。这些想法实现起来比较复杂，所以我不会详细说明如何做，但是如果你有问题的话，我很乐意回答有关这些概念的问题。系列索引准备环境断点寄存器和内存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…

开发一个 Linux 调试器（九）：处理变量
type: item
date: 2017-10-07T02:10:00+08:00
description: 变量是偷偷摸摸的。有时，它们会很高兴地呆在寄存器中，但是一转头就会跑到堆栈中。为了优化，编译器可能会完全将它们从窗口中抛出。无论变量在内存中的如何移动，我们都需要一些方法在调试器中跟踪和操作它们。这篇文章将会教你如何处理调试器中的变量，并使用 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 以及…

开发一个 Linux 调试器（七）：源码级断点
type: item
date: 2017-09-22T17:39:25+08:00
description: 在内存地址上设置断点虽然不错，但它并没有提供最方便用户的工具。我们希望能够在源代码行和函数入口地址上设置断点，以便我们可以在与代码相同的抽象级别中进行调试。这篇文章将会添加源码级断点到我们的调试器中。通过所有我们已经支持的功能，这要比起最初听起来容易得多。我们还将添加一个命令来获取符号的类型和地址，这对于定位代码或数据以及理解链接概念非常有用。系列索引随着后面文章的发布，这些链接会逐渐生效。准备环境断点寄存器和内存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 numb…

开发一个 Linux 调试器（六）：源码级逐步执行
type: item
date: 2017-08-28T18:21:00+08:00
description: 在前几篇博文中我们学习了 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 …

开发一个 Linux 调试器（五）：源码和信号
type: item
date: 2017-08-27T01:55:00+08:00
description: 在上一部分我们学习了关于 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::i…

开发一个 Linux 调试器（四）：Elves 和 dwarves
type: item
date: 2017-07-24T22:41:03+08:00
description: 到目前为止，你已经偶尔听到了关于 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 行设置断点，我们该怎么做呢？我们查找和该文件…

开发一个 Linux 调试器（三）：寄存器和内存
type: item
date: 2017-07-04T16:08:00+08:00
description: 上一篇博文中我们给调试器添加了一个简单的地址断点。这次，我们将添加读写寄存器和内存的功能，这将使我们能够使用我们的程序计数器、观察状态和改变程序的行为。系列文章索引随着后面文章的发布，这些链接会逐渐生效。准备环境断点寄存器和内存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 相同，我们只需要搜…

开发一个 Linux 调试器（二）：断点
type: item
date: 2017-06-27T16:53:00+08:00
description: 在该系列的第一部分，我们写了一个小的进程启动器，作为我们调试器的基础。在这篇博客中，我们会学习在 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 用我们的新数据覆盖那部分内存来设置…