开发一个 Linux 调试器(一):准备环境

2024年 7月 19日 70.6k 0

开发一个 Linux 调试器(一):准备环境-1

任何写过比 hello world 复杂一些的程序的人都应该使用过调试器(如果你还没有,那就停下手头的工作先学习一下吧)。但是,尽管这些工具已经得到了广泛的使用,却并没有太多的资源告诉你它们的工作原理以及如何开发,尤其是和其它那些比如编译器等工具链技术相比而言。

此处有一些其它的资源可以参考:

  • http://eli.thegreenplace.net/2011/01/23/how-debuggers-work-part-1
  • https://t-a-w.blogspot.co.uk/2007/03/how-to-code-debuggers.html
  • https://www.codeproject.com/Articles/43682/Writing-a-basic-Windows-debugger
  • http://system.joekain.com/debugger/

我们将会支持以下功能:

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

在最后一部分,我还会大概介绍如何给你的调试器添加下面的功能:

  • 远程调试
  • 共享库和动态库支持
  • 表达式计算
  • 多线程调试支持

在本项目中我会将重点放在 C 和 C++,但对于那些将源码编译为机器码并输出标准 DWARE 调试信息的语言也应该能起作用(如果你还不知道这些东西是什么,别担心,马上就会介绍到啦)。另外,我只关注如何将程序运行起来并在大部分情况下能正常工作,为了简便,会避开类似健壮错误处理方面的东西。

系列文章索引

随着后面文章的发布,这些链接会逐渐生效。

  • 准备环境
  • 断点
  • 寄存器和内存
  • Elves 和 dwarves
  • 源码和信号
  • 源码层逐步执行
  • 源码层断点
  • 调用栈
  • 读取变量
  • 之后步骤
  • LCTT 译注:ELF —— 可执行文件格式 ( Executable and Linkable Format ) ;DWARF(一种广泛使用的调试数据格式,参考 WIKI)。

    准备环境

    在我们正式开始之前,我们首先要设置环境。在这篇文章中我会依赖两个工具:Linenoise 用于处理命令行输入,libelfin 用于解析调试信息。你也可以使用更传统的 libdwarf 而不是 libelfin,但是界面没有那么友好,另外 libelfin 还提供了基本完整的 DWARF 表达式求值器,当你想读取变量的值时这能帮你节省很多时间。确认你使用的是 libelfin 我的 fbreg 分支,因为它提供 x86 上读取变量的额外支持。

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

    启动可执行程序

    在真正调试任何程序之前,我们需要启动被调试的程序。我们会使用经典的 fork/exec 模式。

    int main(int argc, char* argv[]) {
        if (argc < 2) {
            std::cerr = 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 获取输入直到收到 EOFCTRL+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 信号给它,这是一个跟踪或者断点中断。我们可以使用 waitpid 函数等待这个信号发送。

    当我们知道进程可以被调试之后,我们监听用户输入。linenoise 函数它自己会用一个窗口显示和处理用户输入。这意味着我们不需要做太多的工作就会有一个支持历史记录和导航命令的命令行。当我们获取到输入时,我们把命令发给我们写的小程序 handle_command,然后我们把这个命令添加到 linenoise 历史并释放资源。

    处理输入

    我们的命令类似 gdb 以及 lldb 的格式。要继续执行程序,用户需要输入 continuecont 甚至只需 c。如果他们想在一个地址中设置断点,他们会输入 break 0xDEADBEEF,其中 0xDEADBEEF 就是所需地址的 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  of.size()) return false;
        return std::equal(s.begin(), s.end(), of.begin());
    }
    

    我们会把 continue_execution 函数添加到 debuger 类。

    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++ 程序,然后用你的调试器运行它们,看它是否能在函数入口暂停、从调试器中继续执行。在下一篇文章中,我们会学习如何让我们的调试器设置断点。如果你遇到了任何问题,在下面的评论框中告诉我吧!

    你可以在这里找到该项目的代码。

    via: http://blog.tartanllama.xyz/c++/2017/03/21/writing-a-linux-debugger-setup/

    作者:Simon Brand 译者:ictlyh 校对:jasminepeng

    本文由 LCTT 原创编译,Linux中国 荣誉推出

    相关文章

    Linux 命令行的聊天工具 CenterIM
    Linux 桌面年仍未到来 但 Linux 移动之年已到来
    12 个在线学习 Linux 技能网站
    Linux Mint : 会是另一个新的 Ubuntu 吗?
    W3Conf 开发者大会将于下周召开
    Ubuntu 10.04 ARM 处理器上网本版本结束服务期

    发布评论