到目前为止,可能你已经听到了关于调试信息或者关于除了解析代码以外的理解源代码的方法的DWARF的只言片语。今天,我们将介绍源代码级的调试信息的细节,以备在该系列的余下部分使用它。
ELF和DWARF简介
ELF和DWARF可能是在程序员日常生活中经常使用但是可能却没有听说过的两个部件。ELF(Executable and Linkable Format)是 Linux 世界最广泛中使用的一种Object File Format;它指定了一种将各部分数据存储在二进制文件的方式,比如说代码,静态数据,调试信息,以及一些 字符 串等这些数据。同时,也告诉加载器以何种方式对待二进制文件以及准备好执行,这涉及到将二进制文件的不同部分加载到内存中,以及根据其他一些组件的位置来修复(重定位)相关的数据位等等。我不会在文章中包含太多的ELF相关的知识,但是如果感兴趣的话你可以看一下这个精彩的图表或者这个ELF标准文档。
DWARF是ELF文件通常使用的调试信息格式。通常来讲DWARF对ELF来说并不是必须的,但是这两者是被串联开发在一起的,并且一起使用非常好。这个格式允许编译器告诉调试器源代码是如何与被执行的二进制文件相关的。调试信息被分割在ELF不同的区段中,每一部分都传达了本区块的相关信息。一下是一些预定义的一些区段,如果信息过时的话,可以从这里获取最新信息,DWARF调试信息简介:
.debug_abbrev在.debug_info中使用的缩写
.debug_aranges内存地址和汇编间的映射
.debug_f ram e调用栈帧信息
.debug_info包含DWARF信息入口(DIEs)的核心数据
.debug_line行号信息
.debug_loc 位置描述
.debug_macinfo宏定义描述
.debug_pubnames全局对象和函数查找表
.debug_pubtypes全局类型查找表
.debug_range sDI Es引用地址范围
.debug_str在.debug_info中使用的字符串表
.debug_types类型描述信息
我们最感兴趣的是.debug_line和.debug_info区段,所以让我们用一个简单的程序来看一下一些DWARF信息吧:
int m ai n() { long a = 3; long b = 2; long c = a + b; a = 4;}
DWARF行号表
如果在编译程序的时候指定了-g选项,然后通过dwarfdump运行结果,应该类似以下信息的行号区段:
.debug_line: line number info for a single cuSource lines (f rom CU-DIE at .debug_info offset 0x0000000b): NS new sta te ment, BB new b asic block, ET end of text sequence PE prologue end, EB e pi logue begin IS=val ISA number, DI=val discri mi nator value [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 PE0x0040067e [ 3,10] NS0x00400686 [ 4,14] NS0x0040068a [ 4,16]0x0040068e [ 4,10]0x00400692 [ 5, 7] NS0x0040069a [ 6, 1] NS0x0040069c [ 6, 1] NS ET
开始的一大串信息是关于如何理解dump的一些说明,主行号信息从0x00400770这行开始。本质上,它映射了代码内存地址和在文件中的行和列信息。NS表示该地址标志着新语句的开始,这通常用于设置断点或单步。PE标志着函数头部的结束,这有助于设置函数入口断点。ET标示该映射块的结尾。信息实际上并不是像这样编码,实际的编码是一种非常节省空间的程序,由它来建立这些行号信息。
那么,如果我们想在variable.cpp中的第4行下一个断点,应该怎么做呢? 查找与该文件相对应的条目,然后找到相关的行号,找到相关的地址,然后设置一个断点就可以了。在我们的小程序中,就是这一条:
0x00400686 [ 4,14] NS
所以我们需要在0x00400686地址处设置一个断点。如果你想尝试一下,你可以用你已经写过的调试器手工完成。
相反的工作也是如此,如果我们有一个内存位置 - 比如一个RIP,并且想要找出它在源代码中的哪个位置,只需在行号信息表中找到最接近的映射地址,并从中获取行号即可。
DWARF调试信息
.debug_info是DWARF的核心所在。它给了我们程序中存在的关于类型,功能,变量,希望和梦想的信息。该区段的基本单位是DWARF信息入口,也就是被亲切地称为DIE的东西。DIE包含一个标签,告诉你代表什么样的源代码级的条目,后面是一系列适用于该条目的属性。以下是之前的那个简单程序的.debug_info:
.debug_infoCOMPILE_UNIT
:< 0><0x0000000b> DW_TAG_compile_unit DW_AT_producer clang ve rs ion 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 0x0040069cLOCAL_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_loca ti on 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_loca TI on 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_loca TI on DW_OP_fbreg -24 DW_AT_name c DW_AT_decl_file 0x00000001 /super/secret/path/MiniDbg/examples/variable.cpp DW_AT_decl_line 0x00000004 DW_AT_type <0x0000007e>< 1><0x00000077> DW_TAG_base_type DW_AT_name int DW_AT_encoding DW_ATE_signed DW_AT_byte_size 0x00000004< 1><0x0000007e> DW_TAG_base_type DW_AT_name long int DW_AT_encoding DW_ATE_signed DW_AT_byte_size 0x00000008
第一个DIE表示一个编译单元(CU),它本质上是一个源文件,其中包含所有#include并且被解析的包含文件。以下是它们的包含注释的属性:
DW_AT_producer clang version 3.9.1 (tags/RELEASE_391/final) <-- The compiler which produced this binaryDW_AT_language DW_LANG_C_plus_plus <-- The source languageDW_AT_name /super/secret/path/MiniDbg/examples/variable.cpp <-- The name of the file which this CU representsDW_AT_stmt_list 0x00000000 <-- An offset into the line table which tracks this CUDW_AT_comp_dir /super/secret/path/MiniDbg/build <-- The compila TI on directoryDW_AT_low_pc 0x00400670 <-- The start of the code for this CUDW_AT_high_pc 0x0040069c <-- The end of the code for this CU
其他DIE遵循类似的方案,你可以直观地看出不同属性的含义。
现在我们可以尝试使用我们新发现的DWARF知识来解决一些实际问题。
此刻处于哪个函数中?
比如说我们有一个RIP,并想弄清楚我们处在那个函数中。一个简单的算法是:
for each compile unit: if the pc is between DW_AT_low_pc and DW_AT_high_pc: for each func TI on in the compile unit: if the pc is between DW_AT_low_pc and DW_AT_high_pc: return function information
这可以用于大多数目标,但是在成员函数和内联存在的情况下,事情会变得更加困难。例如,存在内联的情况下,一旦我们发现某个函数范围包含了RIP,需要对该DIE的子条目进行递归,以查看是否有任何更匹配的内联函数。我不会在这个调试器的代码中处理内联,但是如果你喜欢,你可以添加对它的支持。
如何在函数上下断点?
同样的,这取决于是否要支持成员函数,命名空间等。对于单独的函数,你可以在不同的编译单元中的函数中迭代查找,直到找到具有正确名称的函数。如果你的编译器足够友好的填写了.debug_pubnames部分,则可以更有效地做到这一点。
一旦找到该函数,就可以在给定的内存地址DW_AT_low_pc上设置断点。但是,这将会在在函数头部开始时中断,最好在用户代码开始时中断。由于行表信息可以指定指定函数头部结束的内存地址,因此可以直接在行表中查找DW_AT_low_pc的值,然后继续读取,直到找到 标记 为函数头部结尾的条目。有些编译器不会输出这个信息,所以另外一个选择是在该函数的第二行条目给出的地址上设置一个断点。
假设我们要在示例程序中的main设置一个断点。我们搜索main函数,并得到这个DIE:
< 1><0x0000002e> DW_TAG_subprogram DW_AT_low_pc 0x00400670 DW_AT_high_pc 0x0040069c DW_AT_frame_base DW_OP_reg6 DW_AT_name main DW_AT_decl_file 0x00000001 /super/secret/path/MiniDbg/examples/variable.cpp DW_AT_decl_line 0x00000001 DW_AT_type <0x00000077> DW_AT_external yes(1)
这告诉我们,函数从0x00400670开始。如果我们在行号表中查看,我们得到这个条目:
0x00400670 [ 1, 0] NS uri: "/super/secret/path/MiniDbg/examples/variable.cpp"
我们想跳过函数头部,所以我们读取下一个条目:
0x00400676 [ 2,10] NS PE
Clang在这个条目中包含了头部结尾标志,所以我们知道在这里停下来,并在地址0x00400676上设置一个断点。
如何读取变量内容?
读取变量可能非常复杂。它们是可以在整个函数中变化的难以捉摸的东西,存储在 寄存器 中,放在内存中,被优化,被隐藏在角落里,等等等等乱七八糟。还好,我们简单的例子确实很简单。如果我们想要读取变量a的内容,则需要查看一下它的DW_AT_location 属性。
DW_AT_location DW_OP_fbreg -8
reg6 在x86架构上是RBP,由System V x86_64 ABI指定。现在我们读取RBP的内容,从中减去8,就找到了我们的变量。如果我们想实际上的理解这个变量,还需要查看它的类型:
< 2><0x0000004c> DW_TAG_variable DW_AT_name a DW_AT_type <0x0000007e>
如果在调试信息中查找这种类型,我们得到这个DIE:
< 1><0x0000007e> DW_TAG_base_type DW_AT_name long int DW_AT_encoding DW_ATE_signed DW_AT_byte_size 0x00000008
这告诉我们,该类型是一个8字节(64位)有符号整数类型,因此我们可以直接将这些字节解释为int64_t并将其显示给用户。
当然,这些类型可能会比这更复杂,因为它们必须能够表达类似于C ++类型的东西,但是这给出了它们如何工作的基本思想。
暂时回到RBP,Clang可以很好地根据RBP来追踪帧基址。最近版本的GCC更倾向于DW_OP_call_frame_cfa,它涉及解析.eh_frame ELF部分,这是一个完全不同的文章,我并不打算写。如果你告诉GCC使用DWARF 2而不是更新的版本,它会倾向于输出位置列表,这更容易阅读:
DW_AT_frame_base low-off : 0x00000000 addr 0x00400696 high-off 0x00000001 addr 0x00400697>DW_OP_breg7+8 low-off : 0x00000001 addr 0x00400697 high-off 0x00000004 addr 0x0040069a>DW_OP_breg7+16 low-off : 0x00000004 addr 0x0040069a high-off 0x00000031 addr 0x004006c7>DW_OP_breg6+16 low-off : 0x00000031 addr 0x004006c7 high-off 0x00000032 addr 0x004006c8>DW_OP_breg7+8
位置列表根据RIP给出不同的位置。这个例子展示了如果RIP位于距DW_AT_low_pc的0x0偏移的位置,那么帧基址距离寄存器7中存储的值的偏移量为8,如果它位于0x1和0x4之间,那么它距离寄存器7中存储的值偏移为16,等等。
休息休息
这么多信息会让你的头脑晕晕乎乎,但好消息是,在接下来的几篇文章中,我们将有一个库来为我们完成这些艰难的工作。理解实际操作中的内容,特别是在出现问题时,或者你希望支持一些DWARF内容(在使用的任何DWARF库中未实现)时仍然有用。
如果你想了解有关DWARF的更多信息,那么可以从这里获取相关标准。在撰写本文时,DWARF 5刚刚被发布,但是DWARF 4更受欢迎。
Linux平台下调试器的编写(五):源码和信号
在之前的几部分中我们学习了关于DWARF信息以及这些信息是如何在被执行的机器码和高级语言之间建立起联系的。在这部分中,我们将实现一些能够被调试器使用的DWARF相关原语。我们还将借此机会让调试器在命中断点之时输出当前源代码的上下文信息。
建立DWAR 解析器
正如在再还系列的开始时所提到的,我们将会使用libelfin来处理DWARF信息。希望你在我的第一篇文章时就已经得到了该工具,如果没有的话,你可使用我从仓库fork出的fbreg分支。
一旦弄好了libelfin,就是时候把它加入到我们的调试器中了。第一步,解析ELF可执行文件并且从中获取DWARF信息。使用libelfin来完成这一步是非常简单的,仅仅需要对调试器做如下的改变:
class debugger {public: debugger (std::string prog_name, pid_t pid) : m_prog_name{std::move(prog_name)}, m_pid{pid} { auto fd = open(m_prog_name.c_str(), O_RDONLY); m_elf = elf::elf{elf::create_mmap_loader(fd)}; m_dwarf = dwarf::dwarf{dwarf::elf::create_loader(m_elf)}; } //...private: //... dwarf::dwarf m_dwarf; elf::elf m_elf;};
## 调试信息原语接下来我们可以实现根据RIP的值来检索行条目和函数DIE。先从```get_function_from_pc```开始吧:``` c++ dwarf::die debugger::get_function_from_pc(uint64_t pc) { for (auto &cu : m_dwarf.compilation_units()) { if (die_pc_range(cu.root()).contains(pc)) { for (const auto& die : cu.root()) { if (die.tag == dwarf::DW_TAG::subprogram) { if (die_pc_range(die).contains(pc)) { return die; } } } } } throw std::out_of_range{" Can not find function"};}
这里我采取了一个比较笨拙的方法,只需遍历编译单元,直到知道到包含RIP的代码,然后一直迭代,直到在子节点中找到相关函数(DW_TAG_subprogram)。正如在上篇提到的,你可以想成员函数一样来处理这些,如果你想的话你还可以使用内联。 接下来是get_line_entry_from_pc:
dwarf::line_table::iterator debugger::get_line_entry_from_pc(uint64_t pc) { for (auto &cu : m_dwarf.compilation_units()) { if (die_pc_range(cu.root()).contains(pc)) { auto < = cu.get_line_table(); auto it = lt.find_address(pc); if (it == lt.end()) { throw std::out_of_range{"Cannot find line entry"}; } else { return it; } } } throw std::out_of_range{"Cannot find line entry"};}
同样的,我们只需找到正确的便宜单元,然后请求行列表来获取相关条目。
输出源码
当命中断点的时候或者在源码上单步的时候,我们需要知道源代码被执行到哪里了。
void debugger::print_source(const std::string& file_name, unsigned line, unsigned n_lines_context) { std::ifstream file {file_name}; //Work out a window around the desired line auto start_line = line <= n_lines_context ? 1 : line - n_lines_context; auto end_line = line + n_lines_context + (line < n_lines_context ? n_lines_context - line : 0) + 1; char c{}; auto current_line = 1u; //Skip lines up until start_line while (current_line != start_line && file.get(c)) { if (c == '\n') { ++current_line; } } //Output cursor if we're at the current line std::cout << (current_line==line ? "> " : " "); //Write lines up until end_line while (current_line <= end_line && file.get(c)) { std::cout << c; if (c == '\n') { ++current_line; //Output cursor if we're at the current line std::cout << (current_line==line ? "> " : " "); } } //Write newline and make sure that the stream is flushed properly std::cout << std::endl;}
现在,可以输出源码了,只需要将其挂载到我们的调试器中。当调试器从断点或者(实际上)但不中获取信号的时候是显示源码的上好时机了。这样做的话,调试器就需要一个更好的信号处理了。
更好的信号处理
我们希望能够输出什么样的信号被发送给了进程,同时亦希望知道该信号是如何被产生的。例如,我们想知道收到的SIGTRAP信号是由于命中断点还是一个单步执行完产生的,亦或者是由于新线程建立而产生的,等等。 幸运的是,ptrace再一次支援了我们。ptrace有一个参数PTRACE_GETSIGINFO,该参数将会给出进程之前发出的信号的相关信息。如下:
siginfo_t debugger::get_signal_info() { siginfo_t info; ptrace(PTRACE_GETSIGINFO, m_pid, nullptr, &info); return info;}
这里出现了一个siginfo_t的对象,它提供了如下的信息:
siginfo_t { int si_signo; /* Signal number */ int si_errno; /* An errno value */ int si_code; /* Signal code */ int si_trapno; /* Trap number that caused hardware-generated signal (unused on most architectures) */ pid_t si_pid; /* Sending process ID */ uid_t si_uid; /* Real user ID of sending process */ int si_status; /* Exit value or signal */ clock_t si_utime; /* User time consumed */ clock_t si_stime; /* System time consumed */ sigval_t si_value; /* Signal value */ int si_int; /* POSIX.1b signal */ void *si_ptr; /* POSIX.1b signal */ int si_overrun; /* Timer overrun count; POSIX.1b timers */ int si_timerid; /* Timer ID; POSIX.1b timers */ void *si_addr; /* Memory location which caused fault */ long si_band; /* Band event (was int in glibc 2.3.2 and earlier) */ int si_fd; /* File descriptor */ short si_addr_lsb; /* Least significant bit of address (since Linux 2.6.32) */ void *si_lower; /* Lower bound when address violation occurred (since Linux 3.19) */ void *si_upper; /* Upper bound when address violation occurred (since Linux 3.19) */ int si_pkey; /* Protection key on PTE that caused fault (since Linux 4.6) */ void *si_call_addr; /* Address of system call instruction (since Linux 3.5) */ int si_syscall; /* Number of attempted system call (since Linux 3.5) */ unsigned int si_arch; /* Architecture of attempted system call (since Linux 3.5) */}
我将使用si——signo来找出是哪一个信号被发送,然后使用si_code来获取有关该信号的更多信息。放置该段代码的最佳地方是在我们的wait_for_signal函数中:
void debugger::wait_for_signal() { int wait_status; auto options = 0; waitpid(m_pid, &wait_status, options); auto siginfo = get_signal_info(); switch (siginfo.si_signo) { case SIGTRAP: handle_sigtrap(siginfo); break; case SIGSEGV: std::cout << "Yay, segfault. Reason: " << siginfo.si_code << std::endl; break; default: std::cout << "Got signal " << strsignal(siginfo.si_signo) << std::endl; }}
现在处理SIGTRAP只需知道SI_KERNEL或者TRAP_BPKPT将会在断点命中时被发送,TRAP_TRACE将会在单步完成的时候被发送:
void debugger::handle_sigtrap(siginfo_t info) { switch (info.si_code) { //one of these will be set if a breakpoint was hit case SI_KERNEL: case TRAP_BRKPT: { set_pc(get_pc()-1); //put the pc back where it should be std::cout << "Hit breakpoint at address 0x" << std::hex << get_pc() << std::endl; auto line_entry = get_line_entry_from_pc(get_pc()); print_source(line_entry->file->path, line_entry->line); return; } //this will be set if the signal was sent by single stepping case TRAP_TRACE: return; default: std::cout << "Unknown SIGTRAP code " << info.si_code << std::endl; return; }}
你可以处理一堆不同风格的信号。详情请参阅man sigaction。 由于我们现在在得到SIGTRAP时修正RIP,所以可以去掉step_over_breakpoint中的部分代码:
void debugger::step_over_breakpoint() { if (m_breakpoints.count(get_pc())) { auto& bp = m_breakpoints[get_pc()]; if (bp.is_enabled()) { bp.disable(); ptrace(PTRACE_SINGLESTEP, m_pid, nullptr, nullptr); wait_for_signal(); bp.enable(); } }}
现在,你应该可以在某些地址设置断点,运行程序,查看鼠标标记的正在被执行的代码的源代码了。
下一次我们将添加源码级的断点。可以在此处获取源码