開發一個 Linux 偵錯程式(八):堆疊展開

Simon Brand發表於2017-10-04

有時你需要知道的最重要的資訊是什麼,你當前的程式狀態是如何到達那裡的。有一個 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)
                  << ' ' << 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)
                  << ' ' << 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中國 榮譽推出

相關文章