【翻译】编写一个Linux 调试器 (一)~(十)

emmmm,本来想发bobao360上的,后来他说这文章之前已经有人翻译过几篇了,就不能发布。(摊手¯\(ツ)
那我就发在博客上好了。

当学习一波ptrace也是极好的。

一口气直接1~10发完(`Δ´)!

(一):准备工作
(二):断点
(三):内存和寄存器
(四):Elves 和 dwarves
(五):源码和信号
(六):源码级单步执行
(七):源码级断点
(八):栈卷回
(九):处理变量
(十):进阶话题


原文链接:https://blog.tartanllama.xyz/writing-a-linux-debugger-setup/

作者:TartanLlama

译者:veritas501


(一):准备工作

任何一个写过比helloworld更加复杂的程序的人都应该已经使用过调试器了(如果你还没有用过,就放下你手头的事来学习一下吧)。然而,虽然这些工具被人们广泛使用,但目前却没有较多的资料(这些是一些已经公开的资料,如果你需要的话。1 2 3 4)来告诉我们他们的工作原理以及如何编写一个调试器,尤其是与其他工具链(比如编译器)相比。在我的这一系列文章中,我们将学习调试器的原理以及如何自己编写一个调试器。

我们将会提供以下的功能:

  • 启动,暂停,继续执行
  • 在不同地点设置断点
    • 内存地址
    • 程序源码
    • 函数入口点
  • 向内存或寄存器读取和写入值
  • 单步执行
    • 指令
    • 步入函数
    • 跳出函数
    • 跳过函数
  • 打印当前的代码地址
  • 打印函数调用堆栈
  • 打印变量的值

在最后一章,我会指出如何添加以下功能:

  • 远程调试
  • 共享库和动态加载
  • 表达式执行
  • 多线程调试

在此项目中,我会把重点放在C和C++上,但它也同样能够工作在被编译成机器码且输出标准DWARF调试信息的其他语言上(如果你还不知道那是什么,不用担心,我们马上会说到)。此外,我只关注于如何让程序运行起来且在大多数情况下运行,因此为了简便,我将避开鲁棒的错误处理。


准备工作

在我们开始之前,我们先配置好环境。在这系列教程中,我将使用两个依赖工具:Linenoise用于处理我们的命令行输入,libelfin则用于解析调试信息。你也可以使用传统的libdwarf 来替代libelfin,但是界面交互没有那么好,而且libelfin还提供了基本完备的DWARF 表达式执行器,能够在你需要读取变量值的时候节省大量时间。确认你使用的是我的fork的libelfin的fbreg分支,因为我对x86下的变量读取做了一些额外的支持。

一旦你在你的系统上安装或是在你喜欢的系统上编译好了这些依赖工具,我们就可以开始了。我在CMake文件中把它们设置为和我的其余一些代码一起编译。


运行可执行文件

在我们开始调试之前,我们需要启动被调试端(debugee),通过经典的fork/exec模式来完成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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) {
//我们在子进程中
//执行被调试端
}
else if (pid >= 1) {
//我们在父进程中
//执行调试器
}

我们通过调用fork来将我们的程序分离成两个进程。如果我们在子进程中,fork会返回0;如果我们在父进程中,fork会返回子进程的pid。

如果我们在子进程中,我们希望他变成我们需要调试的程序。

1
2
ptrace(PTRACE_TRACEME, 0, nullptr, nullptr);
execl(prog, prog, nullptr);

此处我们第一次遇到了ptrace,它将成为我们编写调试器过程中最好的伙伴。ptrace允许我们用过读取寄存器,内存,单步执行等方法来控制另一个进程。他的API非常简单,你需要提供一个枚举值给这个函数来指明你想进行的操作,后面的一些参数是使用还是被忽略取决于你所提供的值。下面是ptrace的原形:

1
2
long ptrace(enum __ptrace_request request, pid_t pid,
void *addr, void *data);

request是我们对被调试进程的操作;pid是被调试进程的进程ID;addr是一个内存地址,将在一些call中被用于指定被调试进程的地址;data与request的值有关;返回值一般是一些错误信息,因此你需要在你的代码中检测它。这里我为了简便就略过了,你可以通过查看ptrace的man手册来了解更多信息。

上面的代码中,我们使用的request是PTRACE_TRACEME。表示这个进程提出要求它的父进程来调试它。它的参数会被忽略因为API就是这样设计的。

接着,我们调用了execl,这是许多exec族函数中的一个。我们执行指定的程序,把他的名字作为命令行参数传递,并使用一个nullptr来终止这个列表。如果你需要,你可以传递其他执行你程序所需的参数。

当我们完成这些后,我们就完成了子进程的设置;在我们结束它之前它会一直运行下去。


添加调试器循环

现在我们已经启动了子进程,我们想要和它交互。因此,我们创建了debugger类,提供了一个循环来监听用户的输入,然后从父进程的main函数中启动。

1
2
3
4
5
else if (pid >= 1) {
//parent
debugger dbg{prog, pid};
dbg.run();
}
1
2
3
4
5
6
7
8
9
10
11
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)。

1
2
3
4
5
6
7
8
9
10
11
12
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 信号,表示这是一个跟踪或是遇到断点。我们通过watpid函数来等待直到收到这个信号。

当我们知道这个进程准备好被调试后,我们监听用户的输入,linenoise 函数会自己显示一个提示符并处理用户的输入。这意味着我们不需要做太多工作就能拥有一个拥有历史记录和导航的命令行。当我们获取到用户输入后,我们把命令发送到相应的处理函数中(我们马上会看到),然后我们将这个命令添加到 linenoise 历史并释放资源。


处理输入

我们的命令将和gdb和lldb保持相似。用户想要继续运行程序只需要输入continue或是cont或是c即可。如果他们想要在一个地址上设置断点,他们可以写break 0xDEADBEEF,0xDEADBEEF是用户期望的地址的16进制格式。让我们为这些命令添加支持。

1
2
3
4
5
6
7
8
9
10
11
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 是一对有用的小函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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());
}

我们为debugger类添加continue_execution 函数。

1
2
3
4
5
6
7
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++程序,通过调试器运行他们,看它能否停在入口点以及从调试中继续执行。在下一篇文章中,我们讲学习如何让我们的调试器设置断点,如果你遇到了任何问题,可以通过下面的评论告诉我。

你可以在这里找到本文的代码。


(二):断点

第一部分中我们写了一个小型的进程启动器作为我们的调试器,在这一篇中,我们将学习在x86 linux下断点是如何运作的以及为我们的工具添加设置断点的功能。


断点是怎么形成的?

断点的类型有两种:硬件断点和内存断点。硬件断点通常通过设置架构指定的寄存器来设置中断,而内存断点则是通过修改正在执行的代码来设置中断。这篇文章我们把精力集中在内存断点上,因为它们较为简单且没有数量限制。在x86上,你在同一时刻最多只能设置4个硬件断点,但它们不仅能在代码执行到此处的时候断下,还能在被读取或是被写入的时候触发。

前面说内存断点是通过修改当前正在执行的代码来实现的,那么问题是:

  • 我们如何修改代码?
  • 如何修改代码才能设置断点?
  • 如何让调试器注意到?

第一个问题的答案很显然,ptrace。我们之前使用它来设置我们的程序来跟踪以及继续执行,但我们也可以使用它来读取和写入内存。

当执行到断点位置的时候,我们的修改要让处理器暂停并向调试器发送信号。在x86上这是通过将需要下断的地址上的指令设置为int 3来实现的。x86上有一个中断向量表(interrupt vector table),操作系统通过中断向量表能够为许多事件注册处理函数,比如缺页中断(page faults),保护错误(protection faults),无效操作码(invalid opcodes)等。它有点像注册错误的回调函数,但是在硬件层面实现的。当处理器执行到int 3指令是,控制权就被传递给了断点中断处理程序(breakpoint interrupt handler),就Linux来说,是给进程发送SIGTRAP信号。下图展现了这个过程,当把mov指令的第一个字节覆盖为0xcc,即int 3的机器码。

最后一个问题是如何让调试器注意到这个中断。如果你还记得上一篇中我们使用waitpid函数来监听被调试端发送的信号的方法,我们在此处也可以用同样的方法来处理:设置断点,让程序继续执行,调用waitpid直到收到SIGTRAP信号。然后就可以通过打印当前代码的位置或改变图形界面中选中的行来将这个断点传达给用户。


实现内存断点

我们实现一个breakpoint类来表现一个断点断在某个位置,然后根据需求选择启用或是停用这个断点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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;
uint8_t m_saved_data; //存储断点地址
};

这些代码多数只是跟踪状态,真正实现部分在enabledisable函数中。

正如我们上面了解到的,我们需要将用户给定地址上的指令修改为int 3,即0xcc。我们还要保存那条指令原本的机器码,以便后续恢复这行代码。而且我们不能忘记去执行这行代码。

1
2
3
4
5
6
7
8
9
void breakpoint::enable() {
auto data = ptrace(PTRACE_PEEKDATA, m_pid, m_addr, nullptr);
m_saved_data = static_cast<uint8_t>(data & 0xff); //保存最低一字节
uint64_t int3 = 0xcc;
uint64_t data_with_int3 = ((data & ~0xff) | int3); //将最低一字节改为0xcc
ptrace(PTRACE_POKEDATA, m_pid, m_addr, data_with_int3);
m_enabled = true;
}

PTRACE_PEEKDATA这个request告诉ptrace如何去读取被调试程序的内存。我们传递给它一个pid和地址,然后它将指定地址上的64位长度的值返回给我们。 (m_saved_data & ~0xff)将返回数据的最低字节置零,然后我们通过OR指令把int 3和最低字节置零的指令做或操作,从而得到能产生中断的指令。最后,我们通过PTRACE_POKEDATA将这条指令写入内存原位置来设置断点。

disable比较简单,但也有点巧妙。因为ptrace的内存操作针对于words而不是一个字节,因此我们要先把words读回来,然后将最低一字节还原,再将words写回内存。

1
2
3
4
5
6
7
void breakpoint::disable() {
auto data = ptrace(PTRACE_PEEKDATA, m_pid, m_addr, nullptr);
auto restored_data = ((data & ~0xff) | m_saved_data);
ptrace(PTRACE_POKEDATA, m_pid, m_addr, restored_data);
m_enabled = false;
}

给调试器添加断点

为了能通过用户界面设置断点,我们需要对debugger类做三处修改。

  1. debugger添加断点数据储存结构体
  2. 添加一个set_breakpoint_at_address函数
  3. handle_command函数添加break指令

我把断点存在std::unordered_map<std::intptr_t, breakpoint>类型的结构体中,因此能够简洁迅速地检测给定的地址上是否已经有断点,如果有就取回这个断点对象。

1
2
3
4
5
6
7
8
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函数中我们将创建一个新的断点,启用它,把它加入结构体中,并向用户打印一条信息。你喜欢的话可以将所有的信息取出然后就可以像一个命令行工具一样使用你的调试器。为了简洁,我把它们都整合到了一起。

1
2
3
4
5
6
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;
}

现在我们在对命令处理程序做补充以便调用我们的新函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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}; //粗暴认定用户在地址前加了"0x"
set_breakpoint_at_address(std::stol(addr, 0, 16));
}
else {
std::cerr << "Unknown command\n";
}
}

我只是简单的删除了字符串中的前两个字符并对结果调用std::stol,你也可以在提高一下解析的鲁棒性。std::stol可以设置转换的基数,因此读入一个十六进制数据是很简单的。


从断点恢复执行

如果你尝试从断点恢复执行,你会发现啥都没发生。这是因为断点依然存在于内存中,因此这个断点被重复命中。简单的解决方法是禁用它,单步,重新启用,然后恢复运行。但是此外我们还需要修改程序计数器(program counter)指到断点的前面。因此我打算把这个留到下一篇讲解完寄存器的操作后在做介绍。


测试

当然,如果我们不知道把断点设置在什么位置,那这个功能并非很有用。以后我们会给调试器添加通过函数名或是源码行设置断点的方法,但现在我们只能时候来实现这一点。

为测试你的调试器,最简单的方法是写一个helloworld程序并通过std::cerr输出(避免缓存),并在输出的call上设置一个断点。如果你对被调试端使用continue,程序会断下并没有任何输出。你可以重启程序并在输出call的后面设置断点,然后你会看到成功的输出了消息。

找到这个地址的其中一个方法是使用objdump。如果你打开一个终端并执行 objdump -d <your program>,你应该会看到程序的反汇编代码。接着你就能找到main函数并定位到你想要设置断点的call指令。例如现在我编一个helloworld程序,对他反汇编,并得到了main函数的反汇编代码:

1
2
3
4
5
6
7
8
9
0000000000400936 <main>:
400936: 55 push rbp
400937: 48 89 e5 mov rbp,rsp
40093a: be 35 0a 40 00 mov esi,0x400a35
40093f: bf 60 10 60 00 mov edi,0x601060
400944: e8 d7 fe ff ff call 400820 <_ZStlsISt11char_traitsIcEERSt13basic_ostreamIcT_ES5_PKc@plt>
400949: b8 00 00 00 00 mov eax,0x0
40094e: 5d pop rbp
40094f: c3 ret

就如你所见的那样,如果想要没有输出,我们要将断点设置在0x400944;想要看到输出就要在0x400949处设置断点。


总结

你现在有了一个能启动程序并设置断点的调试器。下一次我们将添加对内存和寄存器进行读写的功能。如果你有任何问题,请在博客下面留言。

你可以在 这里找到本文的代码。


(三):内存和寄存器

在上一篇文章中,我们给调试器添加了简单的断点功能。这一次,我们将添加对寄存器和内存的读写能力,这使得我们随意修改PC指针,观察当前的状态和改变程序的行为。


注册我们的寄存器

在我们开始读取寄存器值之前,我们需要告诉调试器一些关于我们目标的信息,这里是x86_64平台。除了一系列通用和专用的寄存器外,x86_64还拥有浮点寄存器和向量寄存器。为了简洁,我将跳过最后两种,但如果你喜欢你也可以选择对它们提供支持。x86_64同样允许你像32,16,8位寄存器那样操作64位寄存器,但我只专注于64位。处于简化,对每一个寄存器我们只需要它的名称、他的DWARF寄存器编号以及它在ptrace返回的结构体中的储存位置。我选择使用范围枚举引用这些寄存器,然后我列出一个全局寄存器描述数组,其中元素的顺序和它在ptrace寄存器结构体的中顺序相同。

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
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里找到这个寄存器数据结构体(user_regs_struct )。DWARF寄存器编号来自System V x86_64 ABI

现在我们可以编写一堆函数来与寄存器做交互。我们想要从寄存器中读取和写入值,根据DWARF寄存器编号获取值,以及通过名称查找寄存器,反之亦然。让我们先实现get_register_value

1
2
3
4
5
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 request传递给ptrace

现在我们根据需求读取regs。我们可以写一个巨大的switch语句,因为我们的g_register_descriptors表的布局和 user_regs_struct相同,所以我们只需搜索寄存器描述符的索引,然后把 user_regs_struct 作为一个uint64_t的数组来操作即可(你也可以重拍reg枚举变量并用索引把他们转换成底层类型,但我第一次就是用这种方式写的,他能正常运作,我就懒得改了。)。

1
2
3
4
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)));

regs转换成 uint64_t类型是安全的,因为user_regs_struct是一个标准的布局类型。但我认为指针运算在技术上是未定义的行为(UB)。当前没有一个编译器对此发出警告而且我很懒,如果你想保证代码严格正确,就写一个大的switch语句吧。

set_register_value非常类似,我们只需写入相应的地址并在最后写回寄存器中:

1
2
3
4
5
6
7
8
9
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 信息。

1
2
3
4
5
6
7
8
9
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);
}

即将完工,现在我们已经有了寄存器名称查找功能:

1
2
3
4
5
6
7
8
9
10
11
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;
}

最后我会添加一个简单的帮助函数把所有寄存器的内容导出来:

1
2
3
4
5
6
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有简洁的接口来清晰地输出16进制数据。

这足够让我们在调试器的余下部分中轻松处理寄存器,因此我们现在可以加上UI界面了。


显示寄存器

我们所要做的就是为handle_command函数添加一个新的命令。通过下面的代码,用户就能输入诸如 register read rax, register write rax 0x42 的命令。

1
2
3
4
5
6
7
8
9
10
11
12
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}; //假定输入格式为0xVAL
set_register_value(m_pid, get_register_from_name(args[2]), std::stol(val, 0, 16));
}
}

接下来干什么?

当我们设置断点的时候,我们已经读取并写入内存了。因此我们只需要添加一些函数来包装ptrace的这些功能即可。

1
2
3
4
5
6
7
uint64_t debugger::read_memory(uint64_t address) {
return ptrace(PTRACE_PEEKDATA, m_pid, address, nullptr);
}
void debugger::write_memory(uint64_t address, uint64_t value) {
ptrace(PTRACE_POKEDATA, m_pid, address, value);
}

你可能想实现一次读写多个字节,你可以通过每次递增地址来读取下一个字节。如果你喜欢,你也可以使用process_vm_readvprocess_vm_writev(link) 或 /proc/<pid>/mem 来代替 ptrace

现在我们给UI添加一些命令:

1
2
3
4
5
6
7
8
9
10
11
else if(is_prefix(command, "memory")) {
std::string addr {args[2], 2}; //假定输入为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}; //假定输入为0xVAL
write_memory(std::stol(addr, 0, 16), std::stol(val, 0, 16));
}
}

给 continue_execution 做修补

在我们对测试更改前,我们现在可以实现一个更健全的continue_execution。由于我们可以获取PC指针,我们可以检查我们的断点表来判断我们是否在一个断点上。如果是,我们可以停用断点并在继续之前单步跳过。

首先为了阐明清晰,我们添加一些帮助函数:

1
2
3
4
5
6
7
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);
}

然后我们可以写一个函数来步过断点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void debugger::step_over_breakpoint() {
// - 1 是因为执行是跳过了断点
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();
}
}
}

首先我们检测当前PC所指代码时候已经被设置断点,如果是,我们先把PC改到断点前一句,禁用断点后再步过原来的指令,在重新启用它。

wait_for_signal 封装了我们常用的waitpid模式。

1
2
3
4
5
void debugger::wait_for_signal() {
int wait_status;
auto options = 0;
waitpid(m_pid, &wait_status, options);
}

最后我们重写 continue_execution函数:

1
2
3
4
5
void debugger::continue_execution() {
step_over_breakpoint();
ptrace(PTRACE_CONT, m_pid, nullptr, nullptr);
wait_for_signal();
}

测试

现在我们可以读取和修改寄存器,我们可以对helloworld程序做些事情。第一个测试,尝试在一个call上再次设置断点并继续执行。你应该会看到程序打印出Hello world。有趣的部分,在输出的call后面设置断点,然后将rip改到call的参数设置处并继续,你应该会看到程序打印出Hello world。以防你不知道在哪里设置断点,这里是我上一篇中objdump的输出:

1
2
3
4
5
6
7
8
9
0000000000400936 <main>:
400936: 55 push rbp
400937: 48 89 e5 mov rbp,rsp
40093a: be 35 0a 40 00 mov esi,0x400a35
40093f: bf 60 10 60 00 mov edi,0x601060
400944: e8 d7 fe ff ff call 400820 <_ZStlsISt11char_traitsIcEERSt13basic_ostreamIcT_ES5_PKc@plt>
400949: b8 00 00 00 00 mov eax,0x0
40094e: 5d pop rbp
40094f: c3 ret

为正确设置esiedi寄存器,你需要把PC指针设置到0x40093a

在下一篇文章中,我们将第一次接触到DWARF信息并为我们的调试器增添一系列单步调试的功能。这样我们就有了一个能单步执行代码,在想要的地方设置断点,修改数据等等功能的调试器了。

和之前一样,如果你有任何问题欢迎在我博客下面评论!

你可以在这里找到本文的代码。


#(四):Elves 和 dwarves

到现在为止,我们已经对dwarves有所耳闻,它是一种调试信息,一种理解源码而不用分析它的的方式。今天我们来介绍关于源码级调试的详细信息,以便后续教程中对它的使用。


ELF 和 DWARF简介

ELF和DWARF这两种组件你或许没听说过,但你可能已经使用过了。ELF(Executable and Linkable Format)是Linux中最广泛使用的一种文件格式。它指定了binary中不同部分的储存方式,例如代码,静态数据,调试信息和字符串。他还告诉loader如何获取二进制并准备执行,这涉及到二进制的不同部分应该放在内存的什么位置,哪些比特需要根据其他组件(重定位)等的位置来进行修复。我们在文章中对ELF介绍过多,如果你感兴趣你可以看一下这个漂亮的图表相关标准

DWARF是ELF中最常用的调试信息格式。它并不只限于ELF,但是它们两相互促进,一起工作的很好。这种格式允许编译器告诉调试器binary中待执行的部分在源码中的什么位置。这些信息在不同的ELF节中不同,每一部分都有自己的信息来中继。下面是定义的不同节,虽有些过时但信息很具体DWARF调试格式介绍

  • .debug_abbrev 是在.debug_info 节中使用的缩写
  • .debug_aranges 内存地址和编译间的映射
  • .debug_frame Call Frame的信息
  • .debug_info 是DWARF数据的核心,包含了DWARF信息的条目(DWARF Information Entries (DIEs))
  • .debug_line 程序的行号
  • .debug_loc 位置描述
  • .debug_macinfo 宏描述
  • .debug_pubnames 全局对象和函数的查找表
  • .debug_pubtypes 全局类型的查找表
  • .debug_ranges DIEs引用的地址范围
  • .debug_str.debug_info使用的字符串表
  • .debug_types 类型描述

我们最感兴趣的是.debug_line.debug_info节,所以让我们看一下一个简单的程序的DWARF信息。

1
2
3
4
5
6
int main() {
long a = 3;
long b = 2;
long c = a + b;
a = 4;
}

DWARF 行号表

如果你用-g选项来编译这个程序并对编译结果使用dwarfdump,你就会看到line number section的样子类似这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
.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表示函数序言(function prologue)的结束,这对这只函数入口断点很有帮助。ET表示转换单元的结束。信息实际上并不像这样编码,真正的编码是一种非常节省空间,且可以通过执行它来建立这些行信息的排序程序。

假设我们想在variable.cpp的第四行设置断点,我们该怎么做?我们先查找和该文件对应的条目,然后寻找对应的行条目,寻找对应的地址,然后在那里设置断点。在这个例子中,这条条目是:

1
0x00400686 [ 4,14] NS

我们想在0x00400686处设置断点。如果你想尝试你可以手动在已经编写好的调试器上尝试。

反过来也是这样。如果我们有一个内存地址,比如一个PC指针,我们想要找到它在源码中所对应的位置,我们只需从行号表中查找最接近的映射地址并将行号取出来。


DWARF调试信息

.debug_info节是DWARF的核心。它提供了类型,函数,变量的信息。这个节中最基本的单元是DWARF 信息条目(DWARF Information Entry),简称DIE。一个 DIE 包括一个能告诉你正在展现什么样的源码级实体的标签,后面跟着一系列该实体的属性。这是我上面展示的简单事例程序的 .debug_info 部分:

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
.debug_info
COMPILE_UNIT<header overall offset = 0x00000000>:
< 0><0x0000000b> DW_TAG_compile_unit
DW_AT_producer clang version 3.9.1 (tags/RELEASE_391/final)
DW_AT_language DW_LANG_C_plus_plus
DW_AT_name /super/secret/path/MiniDbg/examples/variable.cpp
DW_AT_stmt_list 0x00000000
DW_AT_comp_dir /super/secret/path/MiniDbg/build
DW_AT_low_pc 0x00400670
DW_AT_high_pc 0x0040069c
LOCAL_SYMBOLS:
< 1><0x0000002e> DW_TAG_subprogram
DW_AT_low_pc 0x00400670
DW_AT_high_pc 0x0040069c
DW_AT_frame_base DW_OP_reg6
DW_AT_name main
DW_AT_decl_file 0x00000001 /super/secret/path/MiniDbg/examples/variable.cpp
DW_AT_decl_line 0x00000001
DW_AT_type <0x00000077>
DW_AT_external yes(1)
< 2><0x0000004c> DW_TAG_variable
DW_AT_location DW_OP_fbreg -8
DW_AT_name a
DW_AT_decl_file 0x00000001 /super/secret/path/MiniDbg/examples/variable.cpp
DW_AT_decl_line 0x00000002
DW_AT_type <0x0000007e>
< 2><0x0000005a> DW_TAG_variable
DW_AT_location DW_OP_fbreg -16
DW_AT_name b
DW_AT_decl_file 0x00000001 /super/secret/path/MiniDbg/examples/variable.cpp
DW_AT_decl_line 0x00000003
DW_AT_type <0x0000007e>
< 2><0x00000068> DW_TAG_variable
DW_AT_location DW_OP_fbreg -24
DW_AT_name c
DW_AT_decl_file 0x00000001 /super/secret/path/MiniDbg/examples/variable.cpp
DW_AT_decl_line 0x00000004
DW_AT_type <0x0000007e>
< 1><0x00000077> DW_TAG_base_type
DW_AT_name int
DW_AT_encoding DW_ATE_signed
DW_AT_byte_size 0x00000004
< 1><0x0000007e> DW_TAG_base_type
DW_AT_name long int
DW_AT_encoding DW_ATE_signed
DW_AT_byte_size 0x00000008

第一个DIE 代表一个编译单元(CU),本质上是一个包含了所有的#includes和类似的源码文件。以下是带含义注释的属性:

1
2
3
4
5
6
7
8
9
10
11
12
DW_AT_producer clang version 3.9.1 (tags/RELEASE_391/final) <-- The compiler which produced
this binary
DW_AT_language DW_LANG_C_plus_plus <-- The source language
DW_AT_name /super/secret/path/MiniDbg/examples/variable.cpp <-- The name of the file which
this CU represents
DW_AT_stmt_list 0x00000000 <-- An offset into the line table
which tracks this CU
DW_AT_comp_dir /super/secret/path/MiniDbg/build <-- The compilation directory
DW_AT_low_pc 0x00400670 <-- The start of the code for
this CU
DW_AT_high_pc 0x0040069c <-- The end of the code for
this CU

其他的DIE也遵循相似的类型,你可以凭直觉猜到不同属性的意思。

现在我们使用新学的DWARF的知识来尝试解决一些实际的问题。


我现在在哪个函数里?

假设我们现在得到了PC指针,我们想知道我们现在在哪个函数里,最简单的方法是:

1
2
3
4
5
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

对大多数情况来说这都适用,但如果有成员函数或是内联代码,情况就会变得更加复杂。假设有内联代码,如果含有内联代码,当我们找到包含PC指针地址的函数时,我们需要递归遍历所有的子DIE以检查是否有内联函数来更好地匹配。我不会为我的调试器添加对内联代码的支持,但是如果你喜欢的话你可以自己加。


如何在函数上设置断点?

再次说明,这取决于你是否想要支持成员函数,命名空间以及其他类似的东西。对简单的函数你只需迭代遍历不同编译单元中的函数直到你找到正确的名字。如果你的编译器能填充.debug_pubnames节,那么就可以更快地找到正确的名字。

一旦找到了函数,你就能在DW_AT_low_pc提供的内存地址上设置断点。然而,那会在函数序言(function prologue)处设置断点,而在用户代码的开始处设置断点会更合适。由于行表信息可以指定序言结束的内存地址,你只需要在行表中查找DW_AT_low_pc的值,然后一直读取到被标记为序言结束的条目。一些编译器不会输出这些信息,因此另一种方式是在该函数的第二行条目指定的地址出设置断点。

假设我们想在main处设置断点,我们寻找叫做main的函数,然后获取它的DIE

1
2
3
4
5
6
7
8
9
< 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处开始。如果我们在行表中查找它,我们就可以得到这个条目:

1
0x00400670 [ 1, 0] NS uri: "/super/secret/path/MiniDbg/examples/variable.cpp"

我们想要跳过序言,因此我们再读一个条目:

1
0x00400676 [ 2,10] NS PE

Clang在这个条目中包含了序言结束的标志。因此我们知道要停在这里并在0x00400676处下断。


如何读取变量的内容?

读取变量可能会很复杂。因为变量是一种难以捉摸的东西,他们可以在函数中传递,保存在寄存器中,放在内存中,被优化掉,藏在角落里等等。好在我们的示例非常简单,如果我们想要读取变量a的值,我们只需看看它的DW_AT_location属性:

1
DW_AT_location DW_OP_fbreg -8

这告诉我们它的内存被保存在栈帧基址(base of the stack frame)的偏移为-8的地方。为了知道栈帧基址的值,我们查看所在函数的DW_AT_frame_base的属性。

1
DW_AT_frame_base DW_OP_reg6

System V x86_64 ABI的定义可知,reg6是x86上的栈指针寄存器。现在我们从栈指针中读取值并减8从而得到了我们的变量,我们需要看一下它的类型:

1
2
3
< 2><0x0000004c> DW_TAG_variable
DW_AT_name a
DW_AT_type <0x0000007e>

如果我们在调试信息中寻找这种类型,我们会得到以下的DIE:

1
2
3
4
< 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++中的类型,但这足以让你对它的工作原理有个基本的认识。

再来说说栈帧基址,Clang可以通过栈帧指针寄存器来跟踪栈帧基址。最新版本的GCC倾向于使用 DW_OP_call_frame_cfa,它包括解析.eh_frameELF部分,那是一个完全不同的文章,因此我不打算去写。如果你告诉GCC用DWARF 2而不是最近的版本,他会输出更便于阅读的位置列表:

1
2
3
4
5
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

位置列表会根据PC指针的位置来给出不同的位置。本例中,如果PC指针在DW_AT_low_pc偏移为 0x0 处,那么栈帧基址在reg7所保存值的偏移为8的位置,如果它在0x10x4 之间,那么栈帧基址就在偏移地址为16的地方。


Take a breath

这一篇中的内容有点多,需要你大脑好好消化一下,但好消息是在下面几篇中,我们将使用一个库来帮我们完成这些事情。理解概念依然是很有意义的,尤其是当某些错误发生或是你想支持的DWARF内容没有被任何你所用的DWARF库所实现的时候。

如果你想了解更多关于 DWARF的内容,你可以从这里获取其标准。在我写这篇文章的时候,DWARF 5刚刚发布,但DWARF 4的支持更多。


(五):源码和信号

在上一篇文章中,我们学习了有关DWARF信息的知识以及如何用它来读取变量的值和将我们的高级语言代码和正在被执行的机器码联系起来。这一篇中,我们把所学知识的利用起来,实现一些我们调试器后面会用到的DWARF原语(DWARF primitive)。借此机会,我们可以让我们的调试器在断点触发的同时打印当前源码上下文。


设置我们的DWARF解释器

正如我在这系列文章最初所注的那样,我们使用libelfin 来处理DWARF信息。希望你早已在第一部分设置好了这些,如果没有就现在赶紧去,并确保你用的是我fork的fbreg分支。

一旦你构建好了 libelfin,现在我们就可以把它加到我们的调试器中了。第一步就是解析我们所提供的ELF可执行文件并从中提取处DWARF信息。使用 libelfin能轻松实现这些,只需对debugger做如下更改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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 loader 需要给mmap传递一个UNIX文件描述符,从而它能把文件映射到内存而不是每次读取一点点。


##调试信息原语

下一步,我们可以实现一个从PC指针值提取条目(line entry)和函数DIEs的函数。我们从get_function_from_pc函数开始:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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"};
}

这里我使用了相对简单的方法,迭代遍历编译单元知道找到一个包含PC指针的单元,然后迭代遍历它的子节点直到找到我们找到相关函数(DW_TAG_subprogram)。正如我上一篇中所说,如果你想的话你可以处理成员函数或内联。

接下来是get_line_entry_from_pc

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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"};
}

我们还是简单的找到正确的编译单元,然后通过查询行表获取相关的条目。


打印源码

当我们触发一个断点或是在单步执行的时候,我们想要知道我们现在在源码中的什么位置。

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
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 ? "> " : " ");
//Write lines up until end_line
while (current_line <= end_line && file.get(c)) {
std::cout << c;
if (c == '\n') {
++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有一个request叫PTRACE_GETSIGINFO,它可以告诉我们关于进程发送的最后一个信号的信息。我们可以这样使用它:

1
2
3
4
5
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
34
35
siginfo_t {
int si_signo; /* 信号编号 (Signal number) */
int si_errno; /* errno值 (An errno value) */
int si_code; /* 信号代码 (Signal 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; /* 地址的最低有效位
(Least significant bit)
(自 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的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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_KERNELTRAP_BRKPT,单步执行完成时会发送 TRAP_TRACE,知道这些就足够了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void debugger::handle_sigtrap(siginfo_t info) {
switch (info.si_code) {
//如果触发断点,其中一个会被设置
case SI_KERNEL:
case TRAP_BRKPT:
{
set_pc(get_pc()-1); //PC指针指向正确地址
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的时候修正PC指针的值,因此我们把这些代码从step_over_breakpoint中移除,像这样:

1
2
3
4
5
6
7
8
9
10
11
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();
}
}
}

测试

现在你已经可以在指定地址上设置断点,运行程序,看到源码打印出来并标记出了正在被执行的行。

下一次,我们添加设置源码级断点的能力。同时,你可以在这里获取到本文的代码。


(六):源码级单步执行

在前几篇文章中,我们学习了DWARF信息以及它是如何让机器码和高等级源码联系起来的。这次我们试着把源码级单步调试加到我们的调试器中。


指令级单步执行

首先让我们通过UI界面来探索指令级单步执行。我决定把它分成两部分,能被其他部分的代码所使用的single_step_instruction 和确保断点被停用并重新启用的single_step_instruction_with_breakpoint_check

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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函数中:

1
2
3
4
5
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,我们只需要在函数return的地方设置断点并continue即可。我暂时还不想考虑栈展开(stack unwinding)的细节 - 后面会说到 - 返回地址就保存在栈帧开始的后8字节中。因此我们会读取栈指针的值然后在相应的地址上读取值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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是一个小帮助函数:

1
2
3
4
5
6
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。一个简单的算法是继续step over直到我们执行到新的一行。

1
2
3
4
5
6
7
8
9
10
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指令模拟器,因此我们要想一个更简单的解决方案。几个恐怖的方案是:一直单步直到我们发现我们在当前函数的新的一行中;或是在当前函数的所有行上设置断点。前者非常低效因为我们需要单步执行完调用图中的每一条指令,因此我打算采用方案二。

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
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);
}
}

这个函数有些复杂,因此我打算拆开来讲。

1
2
3
auto func = get_function_from_pc(get_pc());
auto func_entry = at_low_pc(func);
auto func_end = at_high_pc(func);

at_low_pcat_high_pclibelfin 中的函数,它们给我们提供了指定函数 DWARF 信息条目的最小程序计数器值和最大程序计数器值,即PC指针范围。

1
2
3
4
5
6
7
8
9
10
11
12
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 类型的变量中。为了设置所有断点,我们循环遍历行表条目直到找到一个不在函数范围内的断点。对于每一个行,我们都要确保它不是我们当前所在的行,而且还没有设置任何断点。

1
2
3
4
5
6
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

1
2
3
4
5
continue_execution();
for (auto addr : to_delete) {
remove_breakpoint(addr);
}

最后,我们继续执行直到触发断点,然后我们把所有临时设置的断点移除。

虽然不美观但暂时就这样吧。

当然,我们还需要把这个新功能加到我们的UI中:

1
2
3
4
5
6
7
8
9
else if(is_prefix(command, "step")) {
step_in();
}
else if(is_prefix(command, "next")) {
step_over();
}
else if(is_prefix(command, "finish")) {
step_out();
}

测试

我通过一个满是call的小程序来测试我们刚实现的功能:

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
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地址上设置断点,然后在程序中step in ,step over, step out。然如果你尝试跳出main或是跳入动态链接库中,那么非预期的事情将会发生。

你可以在这里找到本篇的源码。下一次我们将使用我们新学会的DWARF技巧来实现源码级断点。


(七):源码级断点

在内存地址上设置断点虽然很棒,但它对用户来说并不是最友好的工具。我们希望能够在源码行或是函数的入口点设置断点,因此我们能够像看我们的代码那样直观的调试它。

这篇文章我们将把源码级断点功能添加到我们的调试器中。有了我们之前那么多的准备工作,实现它比我们第一次听到它名字时简单了许多。我们还需要添加一个能够获取类型和符号的地址的命令,这对于定位代码或数据以及理解链接的概念很有帮助。


断点

DWARF

在第四篇中,我们描述了DWARF 调试信息的工作原理以及如何使用它将机器码映射到高级源码中。回想一下,DWARF 包含了函数的地址范围和一个允许你在抽象层之间转换代码位置的行表。我们将使用这些功能来实现我们的断点。

函数入口点

如果你将重载、成员函数等考虑在内,那么在函数名上设置断点可能有些复杂,但是我们将遍历所有的编译单元,并搜索与我们正在寻找的名称匹配的函数。DWARF信息如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
< 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(函数的开始地址)来设置我们的断点。

1
2
3
4
5
6
7
8
9
10
11
12
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; //跳过函数序言
set_breakpoint_at_address(entry->address);
}
}
}
}

这段代码中唯一有点奇怪的是++entry。问题是一个函数的DW_AT_low_pc实际上并不指向用户代码的起始地址,而是指向函数的序言。编译器通常会为函数生成序言和结尾(prologue and epilogue)来保存和恢复寄存器,操作栈指针等。这对我们来说并不是十分有用,因此我们递增入口的行号来到大用户代码的第一行而不是函数序言。DWARF行表实际上具有一些功能来将入口标记位函数序言后的第一行,但并不是所有的编译器都会输出这些,因此我们采用了手动的方法。

源码行

为了在一个高级源代码行上设置断点,我们需要把行号转换为我们在DWARF中寻你找的地址。我们遍历编译单元,寻找一个名称与给定文件匹配的编译单元,然后查找与给定行对应的入口。

这是DWARF的样子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
.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所在行对应的入口点并设置断点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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的作用是你可以通过输入c.cpp来代替 a/b/c.cpp。事实上,你应该使用路径处理库或是其他,但我太懒了。entry.is_stmt 检查行表入口是否被标记为一个语句的开头,这是由编译器根据它认为是断点的最佳目标的地址设置的。


符号查找

当我们到对象文件的层面时,符号是最重要的。函数是用符号命名的,全局变量使用符号命名的,每一个对象都有一个符号。在给定的对象文件中,一些符号可能引用了其他对象文件或是共享库.

符号可以在一张被正确命名的符号表中找到,这张表存储在二进制文件的ELF节中。幸运的是,libelfin提供了个一个非常不错的接口来完成这些,因此我们不用花大力气去自己处理ELF。为了让你了解到我们现在在处理什么,这里是用readelf工具得到的一个二进制文件的.symtab节的内容:

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
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的符号。

我们对符号的类型,名字和值(地址)感兴趣。type为symbol_type枚举类型,name为std::string ,address为std::uintptr_t

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
enum class symbol_type {
notype, // 无类型 (例: absolute symbol)
object, // 数据对象
func, // 函数入口点
section, // 和节相关联的符号
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获取到的符号类型映射到我们的枚举变量中。因为我们对所有东西都选用了相同的名字,所以这超级简单:

1
2
3
4
5
6
7
8
9
10
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 类型中。更智能的方法是建立一个从名称到符号的映射,这样你只要查看一次数据就够了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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> ->函数名断点
1
2
3
4
5
6
7
8
9
10
11
12
13
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]);
}
}

对于符号,我们将查找符号并打印出我们找到的匹配项:

1
2
3
4
5
6
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++ 代码,你还需要考虑名字修饰(name mangling)。

下一篇我将展示如何向调试器添加栈卷回(stack unwinding)支持。

你可以在这里)找到这篇文章的代码。


(八):栈卷回

有时候,你想知道的你当前程序运行状态的最重要的信息是程序是如何运行到那里的。这些信息通常可以通过执行backtrace命令来得到,它会打印出到目前位置的函数调用链。这一篇文章将会展示如何在x86_64上实现像backtrace一样的栈卷回(stack unwinding


以下面的程序为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void a() {
//断在这里
}
void b() {
a();
}
void c() {
a();
}
int main() {
b();
c();
}

如果现在程序断在//断在这里这一行,有两种方法可以到达那里:main->b->amain->c->a。如果我们使用LLDB在那里设置断点,continue,然后请求backtrace,我们会得到:

1
2
3
4
5
* 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中,是从函数b来的,再往前是从函数main来的。最后两个步是编译器如何引导程序到main函数上。

当前的问题是我们如何在x86_64上实现它。最鲁棒的方法是解析.eh_frame节,然后理清如何从那里栈卷回,但这方法太难了。你也可以使用libunwind或其他类似的来帮助你完成这些,但是这又太无聊了。相反,我们假设编译器已经使用某种确定的方法布置好了栈,我们只需要手动操作一遍。为了做到这一点,我们需要先了解栈布局。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
High
| ... |
+---------+
+24| Arg 1 |
+---------+
+16| Arg 2 |
+---------+
+ 8| Return |
+---------+
EBP+--> |Saved EBP|
+---------+
- 8| Var 1 |
+---------+
ESP+--> | Var 2 |
+---------+
| ... |
Low

如你所见,最后一个栈帧的栈指针被储存在当前栈帧的开始处,创建了一个栈指针的单项链表。栈通过这个栈指针的链表进行卷回。我们通过查找DWARF信息中的返回地址来找出链表中下一帧属于哪一个函数。有些编译器会忽略用EBP追踪帧基,因为这可以用ESP加偏移的方式来表示并且可以节省空间。向GCC或是Clang传递-fno-omit-frame-pointer参数就能强制它们遵循这个惯例,即使启用了优化。

我们将在print_backtrace 函数中实现这个功能:

1
void debugger::print_backtrace() {

我们需要尽快决定栈帧信息打印的格式,我使用了一个小lambda来完成:

1
2
3
4
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中通过查找当前程序的PC指针来获得这个栈帧的信息。

1
2
auto current_func = get_function_from_pc(get_pc());
output_frame(current_func);

下一步我们需要获取当前函数的栈指针和返回地址。栈指针存储在rbp寄存器中,而返回地址在栈指针上面8字节处。

1
2
auto frame_pointer = get_register_value(m_pid, reg::rbp);
auto return_address = read_memory(frame_pointer+8);

现在我们有了做栈回溯的所有信息。我将一直做回溯直到main函数。但你也可以选择回溯到到栈指针为0x0,这样你将获得main函数之前的函数。每一次我们都将打印出这个栈帧的栈指针值和返回地址。

1
2
3
4
5
6
7
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);
}
}

现在整个函数都在这里了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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);
}
}

添加命令

当然,我们需要把这个命令提供给用户。

1
2
3
else if(is_prefix(command, "backtrace")) {
print_backtrace();
}

测试

测试这个功能的好方法是写一个小程序,里面有一对的小函数相互调用。设置一些断点,跳过一些代码,然后确保你的栈回溯是准确的。


我们从一个只能运行和附加的程序已经走过了很长一段路。本系列文章的倒数第二篇将使调试器支持读取和存储变量。这里是本篇的代码。


(九):处理变量

变量非常神秘。刚在还呆在寄存器里,一转头就转入栈中。也可能编译器把他们优化掉了。无论变量在内存中移动的多么频繁,我们需要一些手段在我们的调试器中跟踪和操作它们。这一篇我将告诉你们如何在调试器中处理变量并使用libelfin实现一个demo。


在你开始之前,确认你使用的libelfin是我forkfbreg 分支。这个分支对libelfin做了一些修改,可以获取当前栈帧的基址以及对位置列表求值。这在原版的libelfin中是不存在的。你可能需要将-gdwarf-2参数传递给GCC来生成兼容的DWARF信息。但在开始实现之前,我将介绍位置信息在DWARF5 中是如何编码的(最新的规范)。如果你想获取更多的信息,你们在这里获取最新的标准。


DWARF 位置信息

在给定时刻中,一个变量的位置被编码在DWARF信息的DW_AT_location属性里。位置描述可以是一个单一的位置描述,也可以是复合位置描述或位置列表。

  • 简单的位置描述描述对象的一个​​连续块(通常是整个)的位置。简单的位置描述可以描述可寻址存储器或寄存器中的位置,或缺少位置(具有或不具有已知值)。
    • 例:
      • DW_OP_fbreg -32
      • 一个变量存储在栈帧地址-38字节偏移处
  • 复合位置描述描述了一个由多部分组成的对象,每一部分都包含在一个寄存器或储存在内存中,且各部分的位置不相互关联。
    • 例:
      • DW_OP_reg3 DW_OP_piece 4 DW_OP_reg10 DW_OP_piece 2
      • 一个变量,它的前四字节在reg3中,后两字节在reg10中。
  • 地址列表描述了一个拥有有限的生命周期或是在生命周期内会改变储存位置的对象。
    • 例:
      • <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
      • 根据当前PC指针的不同,变量的位置会在不同寄存器间移动。

DW_AT_location一共有三种编码方式,取决于位置描述的种类。exprloc编码了简单或是复合的位置描述。他们由一个字节组成,后面跟着一个DWARF表达式或者位置描述。loclistloclistptr编码了位置列表。它们向.debug_loclists节提供了反映实际位置列表的索引或偏移量。


DWARF 表达式

变量的实际位置是通过DWARF表达式计算出来的。这由一系列对栈上值的操作构成。因为DWARF的操作种类过多,因此我不会一一仔细介绍。我会为表达式的class写一个小示例,这样你就能了解可用的内容。此外,不必畏惧,libelfin将为我们处理好所有这些复杂的东西。

  • 文本编码
    • DW_OP_lit0, DW_OP_lit1, …, DW_OP_lit31
      • 将文本push到栈上
    • DW_OP_addr <addr>
      • 将地址操作数push到栈上
    • DW_OP_constu <unsigned>
      • 将无符号值push到栈上
  • 寄存器值
    • DW_OP_fbreg <offset>
      • 将值push到栈帧基地址指定偏移处
    • DW_OP_breg0, DW_OP_breg1, …, DW_OP_breg31 <offset>
      • 将指定的寄存器的内容加上指定偏移push到栈上
  • 栈操作
    • DW_OP_dup
      • 复制栈顶的值
    • DW_OP_deref
      • 将栈顶视为内存地址,并将其替换成地址的内容
  • 算数和逻辑运算
    • DW_OP_and
      • 从栈中POP两个值并将它们做AND运算后的值PUSH到栈中
    • DW_OP_plus
      • DW_OP_and相似, 但操作改为相加
  • 控制流操作
    • DW_OP_le, DW_OP_eq, DW_OP_gt,等。
      • POP两个值,比较它们,如果条件为真push 1,反之push 0
    • DW_OP_bra <offset>
      • 条件分支: 如果栈顶不是0,跳过或向前跳转指定的 offset
  • 类型转换
    • DW_OP_convert <DIE offset>
      • 把栈顶的值转换成不同的类型,用DWARF信息加上偏移的形式来描述。
  • 特殊操作
    • DW_OP_nop
      • 啥都不做

DWARF类型

DWARF的类型表示需要足够强大以给调试器的用户带来有用的类型表示。用户最希望能在程序级别进行调试而不是在机器级别进行调试,而且他们需要了解变量现在的值。

DWARF的类型和大多数调试信息一样被编码在DIE中。它们具有名称,编码形式,大小,字节顺序(endianness)等属性。有无数种类的标签来表示指针,数组,结构体,typedef,以及任何你能在C或C++中看到的种类。

以这个简单的结构体为例:

1
2
3
4
5
6
struct test{
int i;
float j;
int k[42];
test* next;
};

这个结构体的父DIE是这样的:

1
2
3
4
5
< 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描述了它的成员。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
< 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
< 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在我机器上是一个四字节的有符号整型,float是一个四字节的单精度浮点数。整数数组是通过int类型的指针定义的。sizetype(认为size_t)作为索引类型,有2a个元素。test*类型是DW_TAG_pointer_type,则是对testDIE的引用。


实现一个简单的变量读取函数

就如之前所说的,libelfin将会为我们处理绝大多数复杂的东西。然而它并没有实现关于表示变量位置的所有不同表示方法,而且在我们的代码中处理它们将会十分复杂。因此,我打算目前只支持exprloc。如果你很勇敢,你可以为libelfin提交一些补丁来帮助实现那些必要的支持!

处理变量主要是从内存或寄存器中定位变量的不同部分,然后将它们读取或写入。为简便起见,我只展示如何实现变量的读取。

首先我们需要告诉libelfin 如何从我们的进程中读取寄存器。我们从expr_context继承一个类,然后使用ptrace来处理所有的东西:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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 考虑size
return ptrace(PTRACE_PEEKDATA, m_pid, address, nullptr);
}
private:
pid_t m_pid;
};

读取将由我们debugger类中的read_variables 函数来处理:

1
2
3
4
5
6
7
void debugger::read_variables() {
using namespace dwarf;
auto func = get_function_from_pc(get_pc());
//...
}

我们上面做的第一件事是找到我们现在所在的函数。然后我们需要在这个函数的条目中循环查找变量:

1
2
3
4
5
for (const auto& die : func) {
if (die.tag == DW_TAG::variable) {
//...
}
}

我们通过在DIE中查找DW_AT_location条目来获取位置信息:

1
auto loc_val = die[DW_AT::location];

然后我们确认它是exprloc,并请求libelfin对表达式求值:

1
2
3
if (loc_val.get_type() == value::type::exprloc) {
ptrace_expr_context context {m_pid};
auto result = loc_val.as_exprloc().evaluate(&context);

对表达式求值完后,我们需要将内容从变量中读取出来。它可能在内存中也可能在寄存器中,两种情况我们都需要考虑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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"};
}

如你所见,我只是简单的把值打印出来,没有对他进行类型解释。希望你能通过这小段代码看出如何写入变量,通过给定的名字搜索变量。

最后,我们可以把它添加到我们的命令解释器中:

1
2
3
else if(is_prefix(command, "variables")) {
read_variables();
}

测试

编写一些具有多个变量的小函数,用禁用优化,输出调试信息来编译,然后测试看是否能从变量中读取值。尝试向变量储存的地址写入值,看程序的行为会有什么改变。

九篇写完了,只剩下最后一篇了!下一次我将谈谈一些进阶的概念,你可能会感兴趣。你可以在这里找到本篇的代码。


(十):进阶话题

我们终于来到这系列文章的最后一篇,这一次我们将从较高的层面概述调试中的一些更高级的概念:远程调试,共享库支持,表达式求值和多线程支持。这些想法更难实现,所以我不会详细的说明如何做,但如果你有任何概念上的问题,我乐于回答。


远程调试

对嵌入式系统或是环境差异影响的调试来说,远程调试是非常有用的。它还良好的区别了高级调试器操作和操作系统与硬件的交互。事实上,
像GDB或是LLDB那样的调试器几十载调试本地程序的时候也能像远程调试器那样操作。一般的架构是这样的:

调试器是我们通过命令行进行交互的组件。如果你在使用IDE那么可能还有一层通过机器接口和调试器进行通信。在目标机器(可能和主机相同)上会有一个调试存根(debug stub),理论上它是在系统调试库周围的一个包装器,它带来了所有低等级的调试指令,例如设置断点。我说“理论上”是因为存根目前在不断变大。在我机器上LLDB的调试存根大小为7.6MB。调试存根使用某些特定于系统的功能(在我们的例子中为ptrace)来与被调试端通信并通过一些远程协议和调试器通信。

在调试中,最常见的远程协议为GDB远程协议。这是一种基于文本的数据包格式,用于在调试器和调试存根间传递命令和信息。我不会详细介绍,但你可以在这里阅读所有你想了解的内容。如果你启动LLDB并执行命令log enable gdb-remote packets,你将得到所有通过远程协议发送的数据包。在GDB上你可以执行set remotelogfile <file>获得上述结果。

举个例子,这是设置断点发送的一个数据包:

1
$Z0,400570,1#43

$标记着数据包的开始,Z0代表插入一个内存断点,4005701是参数,前者是断点设置的地址,后者是目标特定的断点类型说明符。#43是一个校验和,确保数据没有被损坏。

GDB远程协议非常容易扩展自定义数据包,这对实现平台或是语言特定的功能非常有用。


共享库和动态加载支持

唯有当调试器知道被调试端加载了哪些共享库时,他才能设置断点,获取源码级的信息以及符号等。除了查找已经动态链接的库外,调试器还需要跟踪通过dlopen在运行时加载的库。为促进这一点,动态链接器会维护一个对接结构体(rendezvous structure)。这个结构体维护了一个共享库描述符的链表以及一个指向函数的指针,当链表被更新的时候这个指针指向的函数就会被调用。这个结构体存储在ELF文件的.dynamic节的加载处,在程序运行之前被初始化。

以下是一个简单的跟踪算法:

  • 跟踪器在ELF头部寻找程序的入口点(或通过存储在/proc/<pid>/aux中的辅助向量(auxillary vector))
  • 跟踪器在程序的入口点设置断点并开始执行
  • 当程序断下时,对接结构体的地址就可以通过查找ELF文件的.dynamic节的加载地址来找到
  • 检查对接结构体以确认获取到的是正确的加载库的列表
  • 在链接器更新函数上设置断点
  • 当断点断下时,列表已经被更新了
  • 跟踪器进入循环,继续执行程序等在被跟踪端发出退出的信号

我写了一个小demo,你可以在这里找到。如果有人感兴趣,我可以在以后写一份更详细的。


##表达式求值

表达式求值是一个能够让用户在调试期间用原始源语言对表达式进行求值的功能。举个例子,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附加到被调试端后设置一些选项:

1
ptrace(PTRACE_SETOPTIONS, m_pid, nullptr, PTRACE_O_TRACECLONE);

现在当clone被调用时,将会发送我们熟悉的SIGTRAP信号。对于本系列文章中的调试器,你可以添加一个handle_sigtrap的case来处理新线程的创建:

1
2
3
4
5
6
7
case (SIGTRAP | (PTRACE_EVENT_CLONE << 8)):
//获取新的线程ID
unsigned long event_message = 0;
ptrace(PTRACE_GETEVENTMSG, pid, nullptr, message);
//处理线程的创建
//...

这之后你可以查看/proc/<pid>/task/并读取memory maps来获取你所需的信息。

GDB使用libthread_db,它提供了一系列的帮助函数因此你不需要亲自完成所有的解释和处理。设置这个库的过程非常奇怪,因此我这里不会说他是怎么工作的,但如果你想使用你可以去看这篇教程

多线程中最复杂的部分是对调试器中的线程状态进行建模,特别是如果要支持不间断模式(non-stop mode)或某种异构调试(有不止一个CPU参与计算)。


The end

呼!这系列文章花了我不少时间来写,但我也在这个过程中学到了不少东西。如果你想和我聊聊调试或是对这系列文章有任何疑问,你可以Twitter @TartanLlama或是下方评论中联系我。如果你有其他关于调试的信息想发布你可以告诉我,我会发一个额外的帖子。