在上一部分我们学习了关于 DWARF 的信息,以及它如何被用于读取变量和将被执行的机器码与我们的高级语言的源码联系起来。在这一部分,我们将进入实践,实现一些我们调试器后面会使用的 DWARF 原语。我们也会利用这个机会,使我们的调试器可以在命中一个断点时打印出当前的源码上下文。
系列文章索引
随着后面文章的发布,这些链接会逐渐生效。
- 准备环境
- 断点
- 寄存器和内存
- Elves 和 dwarves
- 源码和信号
- 源码级逐步执行
- 源码级断点
- 调用栈展开
- 读取变量
- 下一步
设置我们的 DWARF 解析器
正如我在这系列文章开始时备注的,我们会使用 libelfin 来处理我们的 DWARF 信息。希望你已经在第一部分设置好了这些,如果没有的话,现在做吧,确保你使用我仓库的 fbreg
分支。
一旦你构建好了 libelfin
,就可以把它添加到我们的调试器。第一步是解析我们的 ELF 可执行程序并从中提取 DWARF 信息。使用 libelfin
可以轻易实现,只需要对调试器
作以下更改:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| 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
开始:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| 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
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| 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"}; }
|
同样,我们可以简单地找到正确的编译单元,然后查询行表获取相关的条目。
打印源码
当我们命中一个断点或者逐步执行我们的代码时,我们会想知道处于源码中的什么位置。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| 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; while (current_line != start_line && file.get(c)) { if (c == '\n') { ++current_line; } }
std::cout << (current_line==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
,它会给你被发送给进程的最后一个信号的信息。我们类似这样使用它:
1 2 3 4 5 6
| siginfo_t debugger::get_signal_info() { siginfo_t info; ptrace(PTRACE_GETSIGINFO, m_pid, nullptr, &info); return info; }
|
这会给我们一个 siginfo_t
对象,它能提供以下信息:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| siginfo_t { int si_signo; int si_errno; int si_code; int si_trapno;
pid_t si_pid; uid_t si_uid; int si_status; clock_t si_utime; clock_t si_stime; sigval_t si_value; int si_int; void *si_ptr; int si_overrun;
int si_timerid; void *si_addr; long si_band; int si_fd; short si_addr_lsb;
void *si_lower; void *si_upper; int si_pkey; void *si_call_addr;
int si_syscall;
unsigned int si_arch;
}
|
我只需要使用 si_signo
就可以找到被发送的信号,使用 si_code
来获取更多关于信号的信息。放置这些代码的最好位置是我们的 wait_for_signal
函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| 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
就足够了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| 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
中移除这些代码,现在它看起来类似:
1 2 3 4 5 6 7 8 9 10 11 12
| 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中国 荣誉推出