原文:blog.tartanllama.xyz/writing-a-l… Writing a Linux Debugger Part 1: Setup on March 21, 2017 by Sy Brand
对于开发者而言,调试器(debugger)可谓是最有价值的工具之一。然而,相较于编译器等工具,尽管调试器使用得如此广泛,却少有资料介绍其工作原理,更不要说讲解如何编写一个调试器了*1。在本系列教程中,我们将了解调试器的机制,并编写一个能够调试运行在 Linux 上程序的迷你调试器 minidbg
。
本教程分为 10 个部分,可以在 GitHub 上找到最终代码以及每个章节对应的分支。
Windows 用户可以使用 WSL 来实践文章中的内容。
minidbg
调试器将支持以下功能:
- 启动、暂停和继续执行待调试的程序
- 在内存地址、源代码特定行或函数入口上设置断点
- 读写寄存器和内存
- 单步执行(支持逐条执行〔汇编语言的〕指令、步入、步出和跳过)
- 打印在源代码中的当前位置
- 打印函数调用信息(backtrace,回溯调用栈)
- 打印简单变量的值
在本教程的最后一部分,我将大致讲解一下如何将以下功能添加到调试器中:
- 远程调试
- 支持共享库和动态加载
- 表达式求值
- 支持多线程调试
在本教程中,我将重点介绍如何为运行在 Linux 中的 C 和 C++ 程序编写调试器,不过,涉及到的知识和方法也适用于其他语言的程序,只要这种语言会被编译为机器代码并输出标准的 DWARF 调试信息(不知道这是什么也先不用担心,我们很快就会介绍到)。此外,我将重点讲解如何编写能够在大多数情况下启动并运行的调试器,因此为了简单起见,省略了诸如错误处理等重要而琐碎的细节。
准备工作
在正式开始编写调试器之前,先来搭建开发环境。在本教程中,我将使用两个库,一个是 linenoise,用于处理用户在命令行中的输入,另一个是 libelfin,用于解析调试信息。你也可以使用更传统的 libdwarf 库来代替 libelfin,但 libelfin 的接口更为简洁,并提供了一个几乎完整的 DWARF 表达式求值器。特别是在读取变量时,直接使用这个现成的求值器能够节省大量的开发时间。另外,请确保使用的是我维护的 libelfin 版本(fork)中的 fbreg 分支,该版本对在 x86 上读取变量提供了一些额外的支持。
如果已经安装好了这两个库,或者已经将它们作为依赖项与你常用的构建系统一起构建,那么就可以开始调试器之旅了。我使用的方法是在 CMake 中,将这两个库设置为与其余代码文件一起构建。
可以使用
$ git clone --recurse-submodules https://github.com/TartanLlama/minidbg.git
命令克隆该教程的代码,这样就会自动初始化作为子模块的 linenoise 和 libelfin 了。然后切换到tut_setup
分支,并依次执行cmake .
和make
来构建minidbg
和用于测试的小程序。——译者注
启动可执行文件
在开始调试之前,必须先启动待调试的程序。我们将使用经典的 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()
会将程序分成两个进程。在子进程中,fork()
返回 0
,而在父进程中,返回的是子进程的进程 ID。
在子进程中,我们将用待调试的程序替换将要执行的内容:
ptrace(PTRACE_TRACEME, 0, nullptr, nullptr);
execl(prog, prog, nullptr);
这是我们第一次遇到 ptrace()
系统调用,它可是编写调试器的关键。ptrace()
支持读取寄存器、读取内存、单步执行等操作,我们正是通过它来观察和控制另一个进程的。不过,这个系统调用的 API 较为丑陋。上述那些操作都是通过这个系统调用完成的,第一个参数是一个枚举值,表示要执行的操作,然后是这个操作需要的参数,不同的操作需要不同形式的参数。该函数的签名如下所示:
long ptrace(enum __ptrace_request request, pid_t pid,
void *addr, void *data);
request
表示对待调试进程执行的操作;pid
是待调试进程的进程 ID;addr
是内存地址,在某些操作中用于指定待调试进程中的地址;data
是某些操作需要的资源。该函数的返回值通常会提供错误信息,因此在实际代码中应该总是检查返回值,但为了简洁,我省略了检查的代码。可以查看该函数的手册获取更多信息。
在上面的代码中, request
参数为 PTRACE_TRACEME
,该枚举值表示此进程允许其父进程调试它,其他参数都为零值。API 还真是能用就行,有什么可设计的呢?
接下来,我们调用 execl
,它是 exec
系统调用家族中的一员。该系统调用用于运行指定的程序,我们将程序名称作为第一个参数,并将该名称作为命令行参数之一,最后传递 nullptr
来终止参数列表。如果需要,可以继续传递执行程序所需的任何其他参数,最后只需要以 nullptr
来终止参数列表即可。
完成此操作后,子进程就就绪了,在终止之前,我们只需要让它继续运行。
加入调试器循环
接下来,我们希望能够与已启动的子进程进行交互。为此,我们创建了一个 debugger
类,其中包含一个用于监听用户输入的循环,并在对应着父进程的分支中启动它。调试器启动后将打印出子进程的进程 ID pid
,该 ID 将在后续教程中派上用场。
else if (pid >= 1) {
//parent
std::cout << "Started debugging process " << pid << '\n';
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
信号,该信号表示一个跟踪或断点陷阱(trap)。调试器使用 waitpid
系统调用等待此信号的发送。
当运行在子进程中的程序已准备好接受调试后,就可以开始处理用户的输入了。linenoise()
函数会显示提示符并接收用户的输入。这意味着无需写太多代码即可获得一个带有历史记录和支持方向键导航(navigation)的命令行接口(界面)了。获得用户输入的命令后,就可以将命令交给接下来将要编写的 handle_command()
函数处理了,最后我们还要将命令添加到 linenoise 库管理的历史记录中并释放资源。
处理输入
minidbg
将支持与 gdb
和 lldb
类似的命令格式。例如,要继续执行程序,只需要输入 continue
或 cont
,也可以仅仅输入一个字母 c
。如果用户想在某个内存地址上设置断点,则需要输入 break 0xDEADBEEF
,这里的 0xDEADBEEF
是十六进制数的内存地址。我们先来添加对 continue
命令的支持,
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);
}
在这个方法中,我们再次使用 ptrace()
来通知子进程继续运行,然后还是使用 waitpid()
等待子进程收到该通知。
现在我们可以用 C 或 C++ 写一个小程序,通过刚刚编写的 minidbg
调试器运行它。我们会看到这个程序启动后就暂停了,但只要向调试器发送 continue
命令就能让它继续执行。下一章将讲解如何在调试器中设置断点。无论遇到什么问题,都请在评论中告诉我!
可以在此处找到本教程涉及的代码。
原文:blog.tartanllama.xyz/writing-a-l… Writing a Linux Debugger Part 1: Setup on March 21, 2017 by Sy Brand