编写Linux平台下的调试器 Part1:准备工作
2017-03-21
所有写过比hello world程序更复杂的人都应该在某些地方用到过调试器(如果没有用过,赶紧放下手头的工作去学习使用调试器!!)。尽管这些工具被如此广泛的使用,但是鲜有资料来介绍它们是如何工作的以及如何自己动手来写一个这样的工具,特别是相较于编译器这样的工具链以及技术体系来讲。在这个系列中,我们将会学习一些调试器的技巧以及实现一个能够调试Linux程序的调试器。
我们的调试器将具备以下功能:
- 运行,挂起以及继续执行程序
- 能够在一下位置设置断点
- 读/写寄存器及内存
- 可单步执行
- 输出当前代码位置
- 输出回溯过程
- 输出简单变量的值
在最后,我们将会列出一个功能大纲,以供你添加到自己的调试器中:
- 远程调试
- 支持共享库和动态加载
- 表达式计算
- 多线程调试支持
在此项目中,我将专注于C和C++,但这些知识对于可以编译到机器代码和输出标准DWARF调试信息(如果你目前还不了解这是写什么东西,不要担心,后边会一一揭晓)的任何语言都应该是适用的。此外,我的重点将在于调试器中的一些事情,以及大多数时间运行的部分,因此,为了简单化,像强大的错误处理这样的事情并不会涉及到。
系列文章索引
以下的链接将会随着其他帖子的发布而上线:
- 准备工作
- 断点
- 寄存器和内存
- Elves and dwarves
- Stepping, source and signals
- Stepping on dwarves
- Source-level breakpoints
- Stack unwinding
- Reading variables
- Next steps
准备工作
在我们投入工作以前,让我们先准备好我们的环境。在此教程中,我将依赖于2个组件:Linenoise,用来处理我们的命令行输入;libelfin用来解析调试信息。你可以使用更加传统的libdwarf来替代libefin,但是libdwarf的界面基本已经见不到了,并且libefin提供了一个几乎完整的DWARF 表达式计算器,这个计算器将会在你读取变量信息的时候节省大量的时间。 请确保使用了我fork的libelfin的fbreg分支,该分支在某些地方做了一些hack,用以支持在x86平台上读取变量。
一旦在系统中装完这些库,或者其他的你喜欢的任何支持库,那么是时候来开工了。我使用CMake文件来让这些库和我的其他代码一起编译。
运行可执行文件
在我们正式调试一些东西以前,我们需要运行这个调试程序。让我们用经典的fork/exec模式来完成这个过程。
int main(int argc, char* argv[]) {
if (argc < 2) {
std::cerr << "Program name not specified";
return -1;
}
auto prog = argv[1];
auto pid = fork();
if (pid == 0) {
//we're in the child process
//execute debugee
}
else if (pid >= 1) {
//we're in the parent process
//execute debugger
}
调用fork
会使我们的程序分裂成2个进程。在子进程中fork
返回0
,而在父进程中将会返回子进程的进程ID。
在子进程中,我们要用我们要调试的程序替换我们当前执行的任何东西。
ptrace(PTRACE_TRACEME, 0, nullptr, nullptr);
execl(prog.c_str(), prog.c_str(), nullptr);
在这里,我们第一次遇到了ptrace
这个函数,对于编写一个调试来讲,这个函数可是大有帮助。ptrace
允许我们通过读取寄存器,读取内存,单步以及其他的一些功能来观察和控制另一个进程的执行。它的API非常丑陋,这是一个单一的函数,提供了一个你想要做的事情的枚举值,剩下的参数根据你给定的参数被使用或者被忽略。它看起来就像这样:
long ptrace(enum __ptrace_request request, pid_t pid,
void *addr, void *data);
request
参数用来说明要对我们想要调试程序执行何种操作;pid
是被调试进程的PID;addr
是在某些调试环节中需要用到的内存地址;data
是特定请求的资源。ptrace的返回值一般会给出错误信息,因此你可能需要在你的代码中来检查实际的返回值;我为了简洁,所以忽略了。更多的信息可以参考man page。
之前的代码中,我们发送的请求PTRACE_TRACEME
指明了该进程应该允许被父进程调试。其他所有的参数都被忽略,因为API设计不重要(译注:/嘲讽脸)。
接下来,调用exec
的诸多形式之一的execl
函数。执行指定的程序,将它的名称作为命令行参数,用nullptr
来终止参数列表。如果需要,此处可以传进任何参数。
完成这些之后,我们的子进程的工作就完成了;就让它一直跑着吧,等待我们对它调试工作的完成。
增加调试循环
现在子进程已经跑起来了,我们需要能够和它进行交互。为了达成这个目的,我们需要创建一个debugger
类,在类中创建一个循环来监听我们的输入,然后从我们的父进程的main
函数中运行。
else if (pid >= 1) {
//parent
debugger dbg{prog, pid};
dbg.run();
}
class debugger {
public:
debugger (std::string prog_name, pid_t pid)
: m_prog_name{std::move(prog_name)}, m_pid{pid} {}
void run();
private:
std::string m_prog_name;
pid_t m_pid;
};
在代码中的run
函数中,我需们要一直等待,直到子进程运行完成,然后一直从linenoise
函数获取输入直到接收到EOF(CTRL+D)为止。
void debugger::run() {
int wait_status;
auto options = 0;
waitpid(m_pid, &wait_status, options);
char* line = nullptr;
while((line = linenoise("minidbg> ")) != nullptr) {
handle_command(line);
linenoiseHistoryAdd(line);
linenoiseFree(line);
}
}
当被调试的进程运行起来之后,将会收到一个SIGTRAP
信号,表明了这是trace事件或者断点。我们可以一直等待直到waitpid
发出这个信号。
当被调试进程已经准备就绪时,就可以监听我们的输入了。linenoise
函数自动显示提示和处理用户的输入。这意味着我们不需要有太多的工作就得到了一个很好的历史命令以及命令导航。当获取输入之后,将输入传递给handle_command
这个短小的函数,然后将命令加入到命令行历史中,释放资源。
处理输入
我们的命令将会和dbg和lldb有相似的格式。用户输入continue
或者cont
甚至只是一个c
来是程序继续运行。如果用户项在某个地址设置一个断点,那么就需要输入break 0xDEADFEEF
,break
之后的地址0xDEADFEEF
需要同16进制来指明。让我们加入这些命令吧。
void debugger::handle_command(const std::string& line) {
auto args = split(line,' ');
auto command = args[0];
if (is_prefix(command, "continue")) {
continue_execution();
}
else {
std::cerr << "Unknown command\n";
}
}
split
和is_prefix
是一对小辅助函数:
std::vector<std::string> split(const std::string &s, char delimiter) {
std::vector<std::string> out{};
std::stringstream ss {s};
std::string item;
while (std::getline(ss,item,delimiter)) {
out.push_back(item);
}
return out;
}
bool is_prefix(const std::string& s, const std::string& of) {
if (s.size() > of.size()) return false;
return std::equal(s.begin(), s.end(), of.begin());
}
在debugger类中加入continue_execution
:
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++代码然后跑在你自己的调试器里了,就能看看它在入口处停下,然后从调试器中继续执行。接下来的部分我们将会学习如何使用我们的调试器来下断点。如果有任何疑问,请在评论中告诉我!
可以在此处找到这篇文章的代码。
注1:如果您想要其他资源,这里有一些之前就存在的资源:1 2 3 4
编写Linux平台下的调试器 Part2:断点
断点是如何形成的?
有两种主要的断点:硬件断点和软件断点。相较于软件断点需要修改正在运行的代码产生,硬件断点通常通过设置特定架构的寄存器来产生。在此系列文章中,我们仅仅涉及软件断点,因为软件断点比较简单,并且可以设置任意个数。在x86平台上,在任意时刻最多只能设置4个硬件断点,但是你可以控制指定地址的断点类型是读或是写,而不是仅仅是执行断点。
上文中提到了软件断点就是修改正在执行的代码,问题来了:
- 我们如何修改代码?
- 设置一个断点需要做什么修改?
- 调试器是如何知道断点产生的?
对于第一个问题,毋庸置疑,就是ptrace了。之前我们使用它来设置进程的调试环境然后让程序继续执行,同样的,我们可以使用它来读写内存。
当执行到断点处也就是我们做修改的地址的时候,处理器会暂停程序的运行然后通知调试器。在x86架构上,这个步骤是通过在指定地址处写入int 3
指令来完成的。x86架构有一个中断向量表,操作系统可以向该表注册处理例程来处理多种事件,比如说分页错误,保护错误,非法指令等。它有点像注册的错误处理函数,但是却是运行在硬件环境下。当处理器运行int 3
指令的时候,程序的控制权就转交给了调试器,在Linux下,调试器会收到SIGTRAP
信号。一下的流程图显示了将代码中mov
指令的第一个字节修改为0xCC
,也就是int 3
的机器码。
最后一个问题是调试器是如何收到断点的通知的。如果你还记得之前的文章的话,我们可以使用waitpid
来监听发送到调试器的信号。在这里也可以这样做:设置断点,继续程序,调用waitpid
然后阻塞一直等到收到SIGTRAP
信号为止。然后可以将该断点传达给 用户,比如输出当前运行到的源码的位置,或者在一个GUI界面的调试器中更改当前停下的这一行。
软件断点的实现
我们将一个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
中PTRACE_PEEKDATA
参数指明了如何读取被调试进程的内存。给这个函数一个PID和一个内存地址,之后它就会返回这个地址一个64位的数据。(m_saved_data & ~0xff)
将该数据的最低字节置零,然后将int 3
和该数据进行位或|
来设置断点。最终,通过传入PTRACE_POKEDATA
来将新的数据写入之前读入的地址。
disable
实现起来就简单多了,只需写入被0xCC
替换的原始的数据即可。
void breakpoint::disable() {
ptrace(PTRACE_POKEDATA, m_pid, m_addr, m_saved_data);
m_enabled = false;
}
向调试添加断点功能
接下来对我们的debugger类做三个修改,以便支持通过我们的接口来设置断点。
- 在
debugger
类中添加一个存储断点的数据结构。 - 实现一个
set_breakpoint_at_address
函数。 - 增加一个
break
命令到我们的handle_command
函数中。
我将会在std::unordered_map<std::intptr_t, breakpoint>
结构体中存储断点,这样就很容易迅速的检测指定地址是否有一个断点,如果需要检测的话,只需在breakpoint对象中检索了。
class debugger {
//...
void set_breakpoint_at_address(std::intptr_t addr);
//...
private:
//...
std::unordered_map<std::intptr_t,breakpoint> m_breakpoints;
}
set_breakpoint_at_address
函数中,将会创建一个心得断点,然后使其有效,再加入保存断点的数据结构中,然后向用户输出信息。如果你喜欢的话,你可以考虑打印所有的消息,将调试器作为一个库和命令行工具来使用,我只不过是为了简便而把它们混合在一起了。
void debugger::set_breakpoint_at_address(std::intptr_t addr) {
std::cout << "Set breakpoint at address 0x" << std::hex << addr << std::endl;
breakpoint bp {m_pid, addr};
bp.enable();
m_breakpoints[addr] = bp;
}
现在我们将增加我们的命令处理程序来调用我们的新函数。
void debugger::handle_command(const std::string& line) {
auto args = split(line,' ');
auto command = args[0];
if (is_prefix(command, "cont")) {
continue_execution();
}
else if(is_prefix(command, "break")) {
std::string addr {args[1], 2}; //naively assume that the user has written 0xADDRESS
set_breakpoint_at_address(std::stol(addr, 0, 16));
}
else {
std::cerr << "Unknown command\n";
}
}
I’ve simply removed the first two characters o
我在结果上只是删除了字符串的前两个字符,并调用了std :: stol
,当然你想要的话,可以使解析过程更加健壮一些。std::stol
可以指定基数转换,这使得十六进制读取变的更加简便。
从断点处继续执行
如果你已经尝试过了,可能会发现如果继续从断点处执行,没有任何效果。这是因为断点还在内存中呢,于是再一次的命中了。一个简便的做法是禁止掉断点,单步,然后重新使断点有效,继续执行即可。不幸的是,我们同样需要修改EIP到断点之前,暂时放下这个问题,在下一篇文章中我们将会学习如何控制寄存器。
测试
当然了,如果在一些地址上设置断点可能是无效的如果你不知道这个地址是什么的话。在将来,我们的调试器将会具备在函数名称上设置断点,或者在源代码行数上设置断点,现在,我们可以手动来完成这点。
一个简单的测试我们的调试器的方法是写一个hello world程序,该程序向std::cerr
(避免缓冲)输出,然后在调用输出操作的地方下断点。如果继续调试,那么期望中的执行将会停止而不输出任何东西。这时可以重新开始调试,在函数调用之后再下断点,这是就能看见消息被成功的输出了。
可以使用objdump
来找到我们需要的地址。如果打开一个终端然后执行objdump -d <your program>
命令,应该可以看见代码的反汇编。看见反汇编之后,就能找到main
函数并且确定下断点的call
指令的位置。举个例子,下边是我写的hello world程序,反汇编之后得到了main
函数:
0000000000400936 <main>:
400936: 55 push %rbp
400937: 48 89 e5 mov %rsp,%rbp
40093a: be 35 0a 40 00 mov $0x400a35,%esi
40093f: bf 60 10 60 00 mov $0x601060,%edi
400944: e8 d7 fe ff ff callq 400820 <_ZStlsISt11char_traitsIcEERSt13basic_ostreamIcT_ES5_PKc@plt>
400949: b8 00 00 00 00 mov $0x0,%eax
40094e: 5d pop %rbp
40094f: c3 retq
可以看见,应该是在0x400944
处设置断点没有输出,在0x400949
处设置断点有输出。
结尾
现在,你应该有了一个具备运行程序并且允许用户在内存地址上设置软件断点的调试器。下一次,我们将增加读写内存和寄存器的功能。当然,可以在评论中留下你的问题!
你可以在这里找到本篇文章的代码。
Linxu平台下调试器的编写(三):寄存器和内存
上一篇文章中,我们在调试器中加入了简单的地址断点。这一次,我们将给调试器加入读写寄存器和内存的功能,这样就可以在控制RIP,观察程序的状态,以及改变程序的行为了。
注册我们的寄存器
在我们正真的读取寄存器前,调试器需要知道一些关于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, ®s);
//...
}
同样的,ptrace
给了我们一种简单的访问我们想要的数据的方式。只需构建一个user_regs_struct
实例,然后和PTRACE_GETREGS
请求一起传给ptrace
即可。
现在,我们想根据被请求的寄存器读取regs
。可以通过写一个繁杂的switch case结构,但是由于我们已经构建了g_register_descriptors
这个表,表中的寄存器顺序和user_regs_struct
完全一致,于是就可以通过索引来查找寄存器描述符,并且以uint64_t
数组的方式来访问user_regs_struct
。
auto it = std::find_if(begin(g_register_descriptors), end(g_register_descriptors),
[r](auto&& rd) { return rd.r == r; });//译注:此处是lambda表达式
return *(reinterpret_cast<uint64_t*>(®s) + (it - begin(g_register_descriptors)));
转换到uint_64_t
是安全的,因为user_regs_struct
是标准的布局类型,但是我认为指针在算数运算上是unsigned byte(译注:实际上是signed byte,参考内核地址高20(intel架构)位全被置1)。现有编译器甚至对此没有警告,我比较懒,也不想多花心思了,但是如果你想保持最大可能的正确性就需要一个大的switch case了。
set_register_value
也是一样的,我仅仅是写到相应位置,然后在最后写回寄存器:
void set_register_value(pid_t pid, reg r, uint64_t value) {
user_regs_struct regs;
ptrace(PTRACE_GETREGS, pid, nullptr, ®s);
auto it = std::find_if(begin(g_register_descriptors), end(g_register_descriptors),
[r](auto&& rd) { return rd.r == r; });
*(reinterpret_cast<uint64_t*>(®s) + (it - begin(g_register_descriptors))) = value;
ptrace(PTRACE_SETREGS, pid, nullptr, ®s);
}
接下来就是通过DWARF寄存器号来查找相应的值了。这一次我会检查一个错误条件,以防万得到一些奇怪的DWARF信息:
uint64_t get_register_value_from_dwarf_register (pid_t pid, unsigned regnum) {
auto it = std::find_if(begin(g_register_descriptors), end(g_register_descriptors),
[regnum](auto&& rd) { return rd.dwarf_r == regnum; });
if (it == end(g_register_descriptors)) {
throw std::out_of_range{"Unknown dwarf register"};
}
return get_register_value(pid, it->r);
}
差不多完成了,现在我们就有了下边看起来这样的寄存器值了:
std::string get_register_name(reg r) {
auto it = std::find_if(begin(g_register_descriptors), end(g_register_descriptors),
[r](auto&& rd) { return rd.r == r; });
return it->name;
}
reg get_register_from_name(const std::string& name) {
auto it = std::find_if(begin(g_register_descriptors), end(g_register_descriptors),
[name](auto&& rd) { return rd.name == name; });
return it->r;
}
最后,加一些简单的辅助函数来dump寄存器的内容:
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有一个非常简洁的接口,可以很好地输出十六进制数据。如果你喜欢,可以封装一些IO操作来避免混乱。
这些就足够支持我们在调试器其它部分处理寄存器了,现在,可以将其添加到UI中去了。
操作寄存器
我们需要做的就是将一个新的命令加入到handle_command
函数中。在下边的代码示意中,用户可以通过输入register read rax
或者register write rax 0x42
以及其他的命令来操纵寄存器。
else if (is_prefix(command, "register")) {
if (is_prefix(args[1], "dump")) {
dump_registers();
}
else if (is_prefix(args[1], "read")) {
std::cout << get_register_value(m_pid, get_register_from_name(args[2])) << std::endl;
}
else if (is_prefix(args[1], "write")) {
std::string val {args[3], 2}; //assume 0xVAL
set_register_value(m_pid, get_register_from_name(args[2]), std::stol(val, 0, 16));
}
}
思路
在设置断点时,我们已经读取和写入内存,所以只需要添加一些函数来封装一下ptrace调用。
uint64_t debugger::read_memory(uint64_t address) {
return ptrace(PTRACE_PEEKDATA, m_pid, address, nullptr);
}
void debugger::write_memory(uint64_t address, uint64_t value) {
ptrace(PTRACE_POKEDATA, m_pid, address, value);
}
你可能希望一次添加对读取和写入大于WORD(16位)型数据的支持,只需通过在每次要读取另一个WORD时递增地址即可。同时也可以使用process_vm_readv
和process_vm_writev
或者使用/proc/<pid>/mem
来替代ptrace
。
现在,为我们的UI加入相关命令:
else if(is_prefix(command, "memory")) {
std::string addr {args[2], 2}; //assume 0xADDRESS
if (is_prefix(args[1], "read")) {
std::cout << std::hex << read_memory(std::stol(addr, 0, 16)) << std::endl;
}
if (is_prefix(args[1], "write")) {
std::string val {args[3], 2}; //assume 0xVAL
write_memory(std::stol(addr, 0, 16), std::stol(val, 0, 16));
}
}
修复continue_execution
110/5000
您是不是要找: Before we test out our changes, we’re now in a position to implement a more sane version of continue execution)
在测试更改之前,我们现在可以执行一个更加正确的版本的continue_execution
。因为可以获取RIP,所以只需检查我们的断点保存结构来确定是否运行到了一个断点的位置。如果是,先禁止断点然后在继续运行前步过一次。
首先,为了清晰简洁,先添加几个辅助函数:
uint64_t debugger::get_pc() {
return get_register_value(m_pid, reg::rip);
}
void debugger::set_pc(uint64_t pc) {
set_register_value(m_pid, reg::rip, pc);
}
然后,可以写一个步过断点的函数:
void debugger::step_over_breakpoint() {
// - 1 because execution will go past the breakpoint
auto possible_breakpoint_location = get_pc() - 1;
if (m_breakpoints.count(possible_breakpoint_location)) {
auto& bp = m_breakpoints[possible_breakpoint_location];
if (bp.is_enabled()) {
auto previous_instruction_address = possible_breakpoint_location;
set_pc(previous_instruction_address);
bp.disable();
ptrace(PTRACE_SINGLESTEP, m_pid, nullptr, nullptr);
wait_for_signal();
bp.enable();
}
}
}
首先,检查此刻RIP所处的位置是不是被设置了断点,如果是,将RIP后退一个字节(译注:0xCC断点触发时0xCC本身已经被执行过了,所以停下的位置和下断点的位置差了一个字节,需要将RIP回拨一个字节),禁用断点(译注:将原始的指令数据写回来),单步步过此处原来的指令,然后重新设置断点(译注:再将0xCC写回去)R
wait_for_signal
函数将封装一些常用的waitpid
模式:
void debugger::wait_for_signal() {
int wait_status;
auto options = 0;
waitpid(m_pid, &wait_status, options);
}
最后,重新写的continue_execution
就像这样:
void debugger::continue_execution() {
step_over_breakpoint();
ptrace(PTRACE_CONT, m_pid, nullptr, nullptr);
wait_for_signal();
}
测试
现在我们可以读取和修改寄存器,hello world程序于是就可以有一些乐子了。首先来测试一下在call指令上下断点,然后从断点处继续运行吧。应该可以看见Hello world
已经被输出。乐子来了,在输出的那个call后边下一个断点,继续运行,然后将设置调用参数的代码的地址写入RIP并继续。你应该可以看见由于RIP被改变Hello world
被输出了两次。以防你不知道在哪里设置断点,下边我给出我的objdump
:
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
你需要将RIP移回到0x40093a
,以便对esi
和edi
进行正确的赋值。
在下一篇文章中,我们将会首次探索一下DWARF信息,以及向调试器加入几种单步操作。之后,我们将有一个具备大部分功能的工具,可以通过代码来单步,设置断点到想要的地方去,修改数据以及更多功能。有问题,尽管在回复区提问!
Linxu平台下调试器的编写(四)
到目前为止,可能你已经听到了关于调试信息或者关于除了解析代码以外的理解源代码的方法的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_frame
调用栈帧信息.debug_info
包含DWARF信息入口(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信息吧:
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的一些说明,主行号信息从0x00400770
这行开始。本质上,它映射了代码内存地址和在文件中的行和列信息。NS
表示该地址标志着新语句的开始,这通常用于设置断点或单步。PE
标志着函数头部的结束,这有助于设置函数入口断点。ET
标示该映射块的结尾。信息实际上并不是像这样编码,实际的编码是一种非常节省空间的程序,由它来建立这些行号信息。
那么,如果我们想在variable.cpp中的第4行下一个断点,应该怎么做呢? 查找与该文件相对应的条目,然后找到相关的行号,找到相关的地址,然后设置一个断点就可以了。在我们的小程序中,就是这一条:
0x00400686 [ 4,14] NS
所以我们需要在0x00400686
地址处设置一个断点。如果你想尝试一下,你可以用你已经写过的调试器手工完成。
相反的工作也是如此,如果我们有一个内存位置 - 比如一个RIP,并且想要找出它在源代码中的哪个位置,只需在行号信息表中找到最接近的映射地址,并从中获取行号即可。
DWARF调试信息
.debug_info
是DWARF的核心所在。它给了我们程序中存在的关于类型,功能,变量,希望和梦想的信息。该区段的基本单位是DWARF信息入口,也就是被亲切地称为DIE的东西。DIE包含一个标签,告诉你代表什么样的源代码级的条目,后面是一系列适用于该条目的属性。以下是之前的那个简单程序的.debug_info
:
.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),它本质上是一个源文件,其中包含所有#include
并且被解析的包含文件。以下是它们的包含注释的属性:
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知识来解决一些实际问题。
此刻处于哪个函数中?
比如说我们有一个RIP,并想弄清楚我们处在那个函数中。一个简单的算法是:
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
这可以用于大多数目标,但是在成员函数和内联存在的情况下,事情会变得更加困难。例如,存在内联的情况下,一旦我们发现某个函数范围包含了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 <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
位置列表根据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{"Cannot 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();
}
}
}
测试
现在,你应该可以在某些地址设置断点,运行程序,查看鼠标标记的正在被执行的代码的源代码了。
下一次我们将添加源码级的断点。可以在此处获取源码
Linxu平台下调试器的编写(六)
之前的几篇文章中,我们了解了DWARF信息,以及如何让机器码与高级代码相关联。本篇文章中,我们将会通过向调试器添加源码级单步而把这些知识付诸实践。
指令级单步揭秘
我们正在超越我们自己!首先,让我们在用户界面上显示源码级单步。我决定将其拆分成可以使得其他代码使用的single_step_instruction
和一个single_step_instruction_with_break_check
,以确保任何断点都可以被禁用或启用。
void debugger::single_step_instruction() {
ptrace(PTRACE_SINGLESTEP, m_pid, nullptr, nullptr);
wait_for_signal();
}
void debugger::single_step_instruction_with_breakpoint_check() {
//first, check to see if we need to disable and enable a breakpoint
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);
}
添加完这些功能之后,我们可以开始实现源级单步的功能。
实施步骤
让我们先来个简单的版本,但是真正的调试器往往倾向于使用线程计划来封装所有的单步信息。举例,调试器可能会有一些非常复杂的逻辑来决定断点位置,然后使用一些回调函数来决定单步操作是否已经被完成。这要求许多底层构建的完整程度,因此,我们仅仅是采用一种可能比较简单的方法。最后我可能会意外的步过了某些断点,但是如果你愿意,你可以多花点时间,来完善这些细节。
对于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);
}
}
```c++
void debugger::remove_breakpoint(std::intptr_t addr) {
if (m_breakpoints.at(addr).is_enabled()) {
m_breakpoints.at(addr).disable();
}
m_breakpoints.erase(addr);
}
下边的step_in
。一个简单的算法是一直步过指令直到我们到达源码新的一行。
void debugger::step_in() {
auto line = get_line_entry_from_pc(get_pc())->line;
while (get_line_entry_from_pc(get_pc())->line == line) {
single_step_instruction_with_breakpoint_check();
}
auto line_entry = get_line_entry_from_pc(get_pc());
print_source(line_entry->file->path, line_entry->line);
}
``step_over```是最难的部分。理论上来讲,解决方案仅需在下一行代码上设置断点即可,但是下一行代码是什么?它可能并不是直接在该行源码之后的下一行,因为有可能此处正处于一个循环中或者一些条件构造中。实际的调试器通常会检查正在执行的指令,并解决所有可能的分支目标,然后在所有的分支上都下断点。我可不希望仅为了这么个小项目就去实现一个x86的仿真器,因此我们需要一个更加简单的方案。几个糟糕的选项是保持步进,直到到达该函数新的一行或者仅是在当前函数的所有代码行上都设置一个断点。如果我们步过一个函数的时候,前者将会变的非常低效,因为需要单步走过调用图中的每条指令,所以我会选择第二种方案。
void debugger::step_over() {
auto func = get_function_from_pc(get_pc());
auto func_entry = at_low_pc(func);
auto func_end = at_high_pc(func);
auto line = get_line_entry_from_pc(func_entry);
auto start_line = get_line_entry_from_pc(get_pc());
std::vector<std::intptr_t> to_delete{};
while (line->address < func_end) {
if (line->address != start_line->address && !m_breakpoints.count(line->address)) {
set_breakpoint_at_address(line->address);
to_delete.push_back(line->address);
}
++line;
}
auto frame_pointer = get_register_value(m_pid, reg::rbp);
auto return_address = read_memory(frame_pointer+8);
if (!m_breakpoints.count(return_address)) {
set_breakpoint_at_address(return_address);
to_delete.push_back(return_address);
}
continue_execution();
for (auto addr : to_delete) {
remove_breakpoint(addr);
}
}
这个函数有点复杂,所以我会把它分解一下。
auto func = get_function_from_pc(get_pc());
auto func_entry = at_low_pc(func);
auto func_end = at_high_pc(func);
```c++
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;
}
我们需要删除设置的断点使其不会从step函数中泄漏出去,所以需要在std::vector
中持续的跟踪这些断点。为了设置所有的断点,需要遍历行表的Entry,直到命中超出范围的值为止。对于每一个行Entry,需要确保它不是我们当前的行,并且在该位置没有设置断点。
auto frame_pointer = get_register_value(m_pid, reg::rbp);
auto return_address = read_memory(frame_pointer+8);
if (!m_breakpoints.count(return_address)) {
set_breakpoint_at_address(return_address);
to_delete.push_back(return_address);
}
这里,我们在函数的返回地址上设置一个断点,就像在step_out
中的那样。
continue_execution();
for (auto addr : to_delete) {
remove_breakpoint(addr);
}
最后,继续运行,直到命中断点,然后删除临时断点。
这明不是很漂亮的方法,但是此时此刻却是很有效的。
当然了,还需要在UI中添加这个新功能:
else if(is_prefix(command, "step")) {
step_in();
}
else if(is_prefix(command, "next")) {
step_over();
}
else if(is_prefix(command, "finish")) {
step_out();
}
测试一下下
我用一个简单的程序测试了一下以上函数的实现,这个程序调用了一下不同的函数:
void a() {
int foo = 1;
}
void b() {
int foo = 2;
a();
}
void c() {
int foo = 3;
b();
}
void d() {
int foo = 4;
c();
}
void e() {
int foo = 5;
d();
}
void f() {
int foo = 6;
e();
}
int main() {
f();
}
你应该能在main函数上设置断点,然后在整个程序中步入,步过,步出。如果尝试在main函数上步过或者步入某些动态链接库时,希望也会断下来。
可以在此处找到本篇文章的代码。下一次,我们将使用新的知识来实现源码级断点。
原文链接:
本文由看雪翻译小组zplusplus翻译
l
[课程]FART 脱壳王!加量不加价!FART作者讲授!