编写Linux调试器(2017)
Writing a Linux Debugger (2017)

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

本文开启了一个从零构建Linux调试器的系列文章。目标是创建一个具有断点、寄存器/内存访问、单步执行、栈回溯和变量检查等功能的调试器。重点是C和C++调试,利用DWARF调试信息。 第一步是使用Linenoise(用于命令行输入)和libelfin(用于解析调试信息)来设置环境。待调试程序使用`fork/exec`模式启动。介绍了`ptrace`系统调用,它允许调试器控制和观察目标进程。 调试器类包含一个主循环,该循环使用Linenoise等待用户输入。实现了基本的命令处理,特别是“continue”命令,它使用`ptrace`恢复被调试程序的执行。`waitpid`函数对于调试器与被调试进程的同步至关重要,在启动或接收信号后暂停。

Hacker News 最新 | 往期 | 评论 | 提问 | 展示 | 招聘 | 提交 登录 编写一个Linux调试器(2017)(tartanllama.xyz) 17 分,作者 ibobev,12 小时前 | 隐藏 | 往期 | 收藏 | 2 条评论 Rochus 3小时前 | 下一条 [–] 感谢你的提示。作者还有一本750页的书,看了样章和目录后我立刻买了下来:https://nostarch.com/building-a-debugger 回复 D4ckard 10小时前 | 上一条 [–] 这是一系列很棒的文章,在我之前在这里看到它们时就激发了我编写调试器的灵感。非常有趣!这是另一个关于该主题的有见地的系列:https://eli.thegreenplace.net/2011/01/23/how-debuggers-work-... 回复 加入我们,参加6月16日至17日在旧金山举办的AI创业学校! 指南 | 常见问题 | 列表 | API | 安全 | 法律 | 申请YC | 联系我们 搜索:

原文

This series has been expanded into a book! It covers many more topics in much greater detail. You can now pre-order Building a Debugger.

Debuggers are one of the most valuable tools in any developer’s kit. However, although these tools are in such widespread use, there aren’t a lot of resources which tell you how they work and how to write one, especially when compared to other toolchain technologies like compilers. In this post series we’ll learn what makes debuggers tick and write one for debugging Linux programs.

This tutorial is split into 10 parts and you can find the final code, along with branches for each part, on GitHub.

If you’re on Windows, you can still follow along using WSL.

Our debugger will support the following features:

  • Launch, halt, and continue execution
  • Set breakpoints on
    • Memory addresses
    • Source code lines
    • Function entry
  • Read and write registers and memory
  • Single stepping
    • Instruction
    • Step in
    • Step out
    • Step over
  • Print current source location
  • Print backtrace
  • Print values of simple variables

In the final part I’ll also outline how you could add the following to your debugger:

  • Remote debugging
  • Shared library and dynamic loading support
  • Expression evaluation
  • Multi-threaded debugging support

I’ll be focusing on C and C++ for this project, but it should work just as well with any language which compiles down to machine code and outputs standard DWARF debug information (if you don’t know what that is yet, don’t worry, this will be covered soon). Additionally, my focus will be on getting something up and running which works most of the time, so things like robust error handling will be eschewed in favour of simplicity.


Series index

  1. Setup
  2. Breakpoints
  3. Registers and memory
  4. Elves and dwarves
  5. Source and signals
  6. Source-level stepping
  7. Source-level breakpoints
  8. Stack unwinding
  9. Handling variables
  10. Advanced topics

Getting set up

Before we jump into things, let’s get our environment set up. I’ll be using two dependencies in this tutorial: Linenoise for handling our command line input, and libelfin for parsing the debug information. You could use the more traditional libdwarf instead of libelfin, but the interface is nowhere near as nice, and libelfin also provides a mostly complete DWARF expression evaluator, which will save you a lot of time if you want to read variables. Make sure that you use the fbreg branch of my fork of libelfin, as it hacks on some extra support for reading variables on x86.

Once you’ve either installed these on your system, or got them building as dependencies with whatever build system you prefer, it’s time to get started. I set them to build along with the rest of my code in my CMake files.


Launching the executable

Before we actually debug anything, we’ll need to launch the debugee program. We’ll do this with the classic fork/exec pattern.

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
    }

We call fork and this causes our program to split into two processes. If we are in the child process, fork returns 0, and if we are in the parent process, it returns the process ID of the child process.

If we’re in the child process, we want to replace whatever we’re currently executing with the program we want to debug.

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

Here we have our first encounter with ptrace, which is going to become our best friend when writing our debugger. ptrace allows us to observe and control the execution of another process by reading registers, reading memory, single stepping and more. The API is very ugly; it’s a single function which you provide with an enumerator value for what you want to do, and then some arguments which will either be used or ignored depending on which value you supply. The signature looks like this:

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

request is what we would like to do to the traced process; pid is the process ID of the traced process; addr is a memory address, which is used in some calls to designate an address in the tracee; and data is some request-specific resource. The return value often gives error information, so you probably want to check that in your real code; I’m omitting it for brevity. You can have a look at the man pages for more information.

The request we send in the above code, PTRACE_TRACEME, indicates that this process should allow its parent to trace it. All of the other arguments are ignored, because API design isn’t important /s.

Next, we call execl, which is one of the many exec flavours. We execute the given program, passing the name of it as a command-line argument and a nullptr to terminate the list. You can pass any other arguments needed to execute your program here if you like.

After we’ve done this, we’re finished with the child process; we’ll let it keep running until we’re finished with it.


Adding our debugger loop

Now that we’ve launched the child process, we want to be able to interact with it. For this, we’ll create a debugger class, give it a loop for listening to user input, and launch that from our parent fork of our main function. We’ll also print out the child pid, as it’ll come in useful in later articles.

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

In our run function, we need to wait until the child process has finished launching, then keep on getting input from linenoise until we get an 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);
    }
}

When the traced process is launched, it will be sent a SIGTRAP signal, which is a trace or breakpoint trap. We can wait until this signal is sent using the waitpid function.

After we know the process is ready to be debugged, we listen for user input. The linenoise function takes a prompt to display and handles user input by itself. This means we get a nice command line with history and navigation commands without doing much work at all. When we get the input, we give the command to a handle_command function which we’ll write shortly, then we add this command to the linenoise history and free the resource.


Handling input

Our commands will follow a similar format to gdb and lldb. To continue the program, a user will type continue or cont or even just c. If they want to set a breakpoint on an address, they’ll write break 0xDEADBEEF, where 0xDEADBEEF is the desired address in hexadecimal format. Let’s add support for these commands.

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 and is_prefix are a couple of small helper functions:

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

We’ll add continue_execution to the debugger class.

void debugger::continue_execution() {
    ptrace(PTRACE_CONT, m_pid, nullptr, nullptr);

    int wait_status;
    auto options = 0;
    waitpid(m_pid, &wait_status, options);
}

For now our continue_execution function will use ptrace to tell the process to continue, then waitpid until it’s signalled.


Finishing up

Now you should be able to compile some C or C++ program, run it through your debugger, see it halting on entry, and be able to continue execution from your debugger. In the next part we’ll learn how to get our debugger to set breakpoints. If you come across any issues, please let me know in the comments!

You can find the code for this post here.

Next post


联系我们 contact @ memedata.com