開發一個 Linux 調試器(二):斷點
![](https://linuxstory.org/wp-content/uploads/2024/06/085227v29kih3cftuftnk5-800x400.jpg)
在該系列的第一部分,我們寫了一個小的進程啟動器,作為我們調試器的基礎。在這篇博客中,我們會學習在 x86 Linux 上斷點是如何工作的,以及如何給我們工具添加設置斷點的能力。
系列文章索引
隨著後面文章的發布,這些鏈接會逐漸生效。
斷點是如何形成的?
有兩種類型的斷點:硬體和軟體。硬體斷點通常涉及到設置與體系結構相關的寄存器來為你產生斷點,而軟體斷點則涉及到修改正在執行的代碼。在這篇文章中我們只會關注軟體斷點,因為它們比較簡單,而且可以設置任意多斷點。在 x86 機器上任一時刻你最多只能有 4 個硬體斷點,但是它們能讓你在讀取或者寫入給定地址時觸發,而不是只有當代碼執行到那裡的時候。
我前面說軟體斷點是通過修改正在執行的代碼實現的,那麼問題就來了:
- 我們如何修改代碼?
- 為了設置斷點我們要做什麼修改?
- 如何告知調試器?
第一個問題的答案顯然是 ptrace
。我們之前已經用它為我們的程序設置跟蹤並繼續程序的執行,但我們也可以用它來讀或者寫內存。
當執行到斷點時,我們的更改要讓處理器暫停並給程序發送信號。在 x86 機器上這是通過 int 3
重寫該地址上的指令實現的。x86 機器有個 中斷向量表 ,操作系統能用它來為多種事件註冊處理程序,例如頁故障、保護故障和無效操作碼。它就像是註冊錯誤處理回調函數,但是是在硬體層面的。當處理器執行 int 3
指令時,控制權就被傳遞給斷點中斷處理器,對於 Linux 來說,就是給進程發送 SIGTRAP
信號。你可以在下圖中看到這個進程,我們用 0xcc
覆蓋了 mov
指令的第一個位元組,它是 init 3
的指令代碼。
謎題的最後一個部分是調試器如何被告知中斷的。如果你回顧前面的文章,我們可以用 waitpid
來監聽被發送給被調試的程序的信號。這裡我們也可以這樣做:設置斷點、繼續執行程序、調用 waitpid
並等待直到發生 SIGTRAP
。然後就可以通過列印已運行到的源碼位置、或改變有圖形用戶界面的調試器中關注的代碼行,將這個斷點傳達給用戶。
實現軟體斷點
我們會實現一個 breakpoint
類來表示某個位置的斷點,我們可以根據需要啟用或者停用該斷點。
class breakpoint {
public:
breakpoint(pid_t pid, std::intptr_t addr)
: m_pid{pid}, m_addr{addr}, m_enabled{false}, m_saved_data{}
{}
void enable();
void disable();
auto is_enabled() const -> bool { return m_enabled; }
auto get_address() const -> std::intptr_t { return m_addr; }
private:
pid_t m_pid;
std::intptr_t m_addr;
bool m_enabled;
uint64_t m_saved_data; //data which used to be at the breakpoint address
};
這裡的大部分代碼都是跟蹤狀態;真正神奇的地方是 enable
和 disable
函數。
正如我們上面學到的,我們要用 int 3
指令 - 編碼為 0xcc
- 替換當前指定地址的指令。我們還要保存該地址之前的值,以便後面恢復該代碼;我們不想忘了執行用戶(原來)的代碼。
void breakpoint::enable() {
m_saved_data = ptrace(PTRACE_PEEKDATA, m_pid, m_addr, nullptr);
uint64_t int3 = 0xcc;
uint64_t data_with_int3 = ((m_saved_data & ~0xff) | int3); //set bottom byte to 0xcc
ptrace(PTRACE_POKEDATA, m_pid, m_addr, data_with_int3);
m_enabled = true;
}
PTRACE_PEEKDATA
請求告知 ptrace
如何讀取被跟蹤進程的內存。我們給它一個進程 ID 和一個地址,然後它返回給我們該地址當前的 64 位內容。 (m_saved_data & ~0xff)
把這個數據的低位位元組置零,然後我們用它和我們的 int 3
指令按位或(OR
)來設置斷點。最後我們通過 PTRACE_POKEDATA
用我們的新數據覆蓋那部分內存來設置斷點。
disable
的實現比較簡單,我們只需要恢復用 0xcc
所覆蓋的原始數據。
void breakpoint::disable() {
ptrace(PTRACE_POKEDATA, m_pid, m_addr, m_saved_data);
m_enabled = false;
}
在調試器中增加斷點
為了支持通過用戶界面設置斷點,我們要在 debugger 類修改三個地方:
- 給
debugger
添加斷點存儲數據結構 - 添加
set_breakpoint_at_address
函數 - 給我們的
handle_command
函數添加break
命令
我會將我的斷點保存到 std::unordered_map<std::intptr_t, breakpoint>
結構,以便能簡單快速地判斷一個給定的地址是否有斷點,如果有的話,取回該 breakpoint 對象。
class debugger {
//...
void set_breakpoint_at_address(std::intptr_t addr);
//...
private:
//...
std::unordered_map<std::intptr_t,breakpoint> m_breakpoints;
}
在 set_breakpoint_at_address
函數中我們會新建一個 breakpoint 對象,啟用它,把它添加到數據結構里,並給用戶列印一條信息。如果你喜歡的話,你可以重構所有的輸出信息,從而你可以將調試器作為庫或者命令行工具使用,為了簡便,我把它們都整合到了一起。
void debugger::set_breakpoint_at_address(std::intptr_t addr) {
std::cout << "Set breakpoint at address 0x" << std::hex << addr << std::endl;
breakpoint bp {m_pid, addr};
bp.enable();
m_breakpoints[addr] = bp;
}
現在我們會在我們的命令處理程序中增加對我們新函數的調用。
void debugger::handle_command(const std::string& line) {
auto args = split(line,' ');
auto command = args[0];
if (is_prefix(command, "cont")) {
continue_execution();
}
else if(is_prefix(command, "break")) {
std::string addr {args[1], 2}; //naively assume that the user has written 0xADDRESS
set_breakpoint_at_address(std::stol(addr, 0, 16));
}
else {
std::cerr << "Unknown commandn";
}
}
我刪除了字元串中的前兩個字元並對結果調用 std::stol
,你也可以讓該解析更健壯一些。std::stol
可以將字元串按照所給基數轉化為整數。
從斷點繼續執行
如果你嘗試這樣做,你可能會發現,如果你從斷點處繼續執行,不會發生任何事情。這是因為斷點仍然在內存中,因此一直被重複命中。簡單的解決辦法就是停用這個斷點、運行到下一步、再次啟用這個斷點、然後繼續執行。不過我們還需要更改程序計數器,指回到斷點前面,這部分內容會留到下一篇關於操作寄存器的文章中介紹。
測試它
當然,如果你不知道要在哪個地址設置,那麼在某些地址設置斷點並非很有用。後面我們會學習如何在函數名或者代碼行設置斷點,但現在我們可以通過手動實現。
測試你調試器的簡單方法是寫一個 hello world 程序,這個程序輸出到 std::err
(為了避免緩存),並在調用輸出操作符的地方設置斷點。如果你繼續執行被調試的程序,執行很可能會停止而不會輸出任何東西。然後你可以重啟調試器並在調用之後設置一個斷點,現在你應該看到成功地輸出了消息。
查找地址的一個方法是使用 objdump
。如果你打開一個終端並執行 objdump -d <your program>
,然後你應該看到你的程序的反彙編代碼。你就可以找到 main
函數並定位到你想設置斷點的 call
指令。例如,我編譯了一個 hello world 程序,反彙編它,然後得到了如下的 main
的反彙編代碼:
0000000000400936 <main>:
400936: 55 push %rbp
400937: 48 89 e5 mov %rsp,%rbp
40093a: be 35 0a 40 00 mov $0x400a35,%esi
40093f: bf 60 10 60 00 mov $0x601060,%edi
400944: e8 d7 fe ff ff callq 400820 <_ZStlsISt11char_traitsIcEERSt13basic_ostreamIcT_ES5_PKc@plt>
400949: b8 00 00 00 00 mov $0x0,%eax
40094e: 5d pop %rbp
40094f: c3 retq
正如你看到的,要沒有輸出,我們要在 0x400944
設置斷點,要看到輸出,要在 0x400949
設置斷點。
總結
現在你應該有了一個可以啟動程序、允許在內存地址上設置斷點的調試器。後面我們會添加讀寫內存和寄存器的功能。再次說明,如果你有任何問題請在評論框中告訴我。
你可以在這裡 找到該項目的代碼。
via: http://blog.tartanllama.xyz/c++/2017/03/24/writing-a-linux-debugger-breakpoints/
作者:Simon Brand 譯者:ictlyh 校對:jasminepeng
本文轉載來自 Linux 中國: https://github.com/Linux-CN/archive