Linux中國

開發一個 Linux 調試器(八):堆棧展開

有時你需要知道的最重要的信息是什麼,你當前的程序狀態是如何到達那裡的。有一個 backtrace 命令,它給你提供了程序當前的函數調用鏈。這篇文章將向你展示如何在 x86_64 上實現堆棧展開以生成這樣的回溯。

系列索引

這些鏈接將會隨著其他帖子的發布而上線。

  1. 準備環境
  2. 斷點
  3. 寄存器和內存
  4. ELF 和 DWARF
  5. 源碼和信號
  6. 源碼級逐步執行
  7. 源碼級斷點
  8. 堆棧展開
  9. 讀取變數
  10. 之後步驟

用下面的程序作為例子:

void a() {
    //stopped here
}

void b() {
     a();
}

void c() {
     a();
}

int main() {
    b();
    c();
}

如果調試器停在 //stopped here' 這行,那麼有兩種方法可以達到:main->b->amain->c->a`。如果我們用 LLDB 設置一個斷點,繼續執行並請求一個回溯,那麼我們將得到以下內容:

* 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 中,a 從函數 b 中跳轉,bmain 中跳轉等等。最後兩個幀是編譯器如何引導 main 函數的。

現在的問題是我們如何在 x86_64 上實現。最穩健的方法是解析 ELF 文件的 .eh_frame 部分,並解決如何從那裡展開堆棧,但這會很痛苦。你可以使用 libunwind 或類似的來做,但這很無聊。相反,我們假設編譯器以某種方式設置了堆棧,我們將手動遍歷它。為了做到這一點,我們首先需要了解堆棧的布局。

            High
        |   ...   |
        +---------+
     +24|  Arg 1  |
        +---------+
     +16|  Arg 2  |
        +---------+
     + 8| Return  |
        +---------+
EBP+--> |Saved EBP|
        +---------+
     - 8|  Var 1  |
        +---------+
ESP+--> |  Var 2  |
        +---------+
        |   ...   |
            Low

如你所見,最後一個堆棧幀的幀指針存儲在當前堆棧幀的開始處,創建一個鏈接的指針列表。堆棧依據這個鏈表解開。我們可以通過查找 DWARF 信息中的返回地址來找出列表中下一幀的函數。一些編譯器將忽略跟蹤 EBP 的幀基址,因為這可以表示為 ESP 的偏移量,並可以釋放一個額外的寄存器。即使啟用了優化,傳遞 -fno-omit-frame-pointer 到 GCC 或 Clang 會強制它遵循我們依賴的約定。

我們將在 print_backtrace 函數中完成所有的工作:

void debugger::print_backtrace() {

首先要決定的是使用什麼格式列印出幀信息。我用了一個 lambda 來推出這個方法:

    auto output_frame = [frame_number = 0] (auto&& func) mutable {
        std::cout << "frame #" << frame_number++ << ": 0x" << dwarf::at_low_pc(func)
                  << &apos; &apos; << dwarf::at_name(func) << std::endl;
    };

列印輸出的第一幀是當前正在執行的幀。我們可以通過查找 DWARF 中的當前程序計數器來獲取此幀的信息:

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

接下來我們需要獲取當前函數的幀指針和返回地址。幀指針存儲在 rbp 寄存器中,返回地址是從幀指針堆棧起的 8 位元組。

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

現在我們擁有了展開堆棧所需的所有信息。我只需要繼續展開,直到調試器命中 main,但是當幀指針為 0x0 時,你也可以選擇停止,這些是你在調用 main 函數之前調用的函數。我們將從每幀抓取幀指針和返回地址,並列印出信息。

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

就是這樣!以下是整個函數:

void debugger::print_backtrace() {
    auto output_frame = [frame_number = 0] (auto&& func) mutable {
        std::cout << "frame #" << frame_number++ << ": 0x" << dwarf::at_low_pc(func)
                  << &apos; &apos; << 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);
    }
}

添加命令

當然,我們必須向用戶公開這個命令。

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

測試

測試此功能的一個方法是通過編寫一個測試程序與一堆互相調用的小函數。設置幾個斷點,跳到代碼附近,並確保你的回溯是準確的。

我們已經從一個只能產生並附加到其他程序的程序走了很長的路。本系列的倒數第二篇文章將通過支持讀寫變數來完成調試器的實現。在此之前,你可以在這裡找到這個帖子的代碼。

via: https://blog.tartanllama.xyz/c++/2017/06/24/writing-a-linux-debugger-unwinding/

作者:Simon Brand 譯者:geekpi 校對:wxy

本文由 LCTT 原創編譯,Linux中國 榮譽推出


本文轉載來自 Linux 中國: https://github.com/Linux-CN/archive

對這篇文章感覺如何?

太棒了
0
不錯
0
愛死了
0
不太好
0
感覺很糟
0
雨落清風。心向陽

    You may also like

    Leave a reply

    您的電子郵箱地址不會被公開。 必填項已用 * 標註

    此站點使用Akismet來減少垃圾評論。了解我們如何處理您的評論數據

    More in:Linux中國