開發一個 Linux 調試器(六):源碼級逐步執行
在前幾篇博文中我們學習了 DWARF 信息以及它如何使我們將機器碼和上層源碼聯繫起來。這一次我們通過為我們的調試器添加源碼級逐步調試將該知識應用於實際。
系列文章索引
隨著後面文章的發布,這些鏈接會逐漸生效。
揭秘指令級逐步執行
我們正在超越了自我。首先讓我們通過用戶介面揭秘指令級單步執行。我決定將它切分為能被其它部分代碼利用的 single_step_instruction
和確保是否啟用了某個斷點的 single_step_instruction_with_breakpoint_check
兩個函數。
void debugger::single_step_instruction() {
ptrace(PTRACE_SINGLESTEP, m_pid, nullptr, nullptr);
wait_for_signal();
}
void debugger::single_step_instruction_with_breakpoint_check() {
//首先,檢查我們是否需要停用或者啟用某個斷點
if (m_breakpoints.count(get_pc())) {
step_over_breakpoint();
}
else {
single_step_instruction();
}
}
正如以往,另一個命令被集成到我們的 handle_command
函數:
else if(is_prefix(command, "stepi")) {
single_step_instruction_with_breakpoint_check();
auto line_entry = get_line_entry_from_pc(get_pc());
print_source(line_entry->file->path, line_entry->line);
}
利用新增的這些函數我們可以開始實現我們的源碼級逐步執行函數。
實現逐步執行
我們打算編寫這些函數非常簡單的版本,但真正的調試器有 thread plan 的概念,它封裝了所有的單步信息。例如,調試器可能有一些複雜的邏輯去決定斷點的位置,然後有一些回調函數用於判斷單步操作是否完成。這其中有非常多的基礎設施,我們只採用一種樸素的方法。我們可能會意外地跳過斷點,但如果你願意的話,你可以花一些時間把所有的細節都處理好。
對於跳出 step_out
,我們只是在函數的返回地址處設一個斷點然後繼續執行。我暫時還不想考慮調用棧展開的細節 - 這些都會在後面的部分介紹 - 但可以說返回地址就保存在棧幀開始的後 8 個位元組中。因此我們會讀取棧指針然後在內存相對應的地址讀取值:
void debugger::step_out() {
auto frame_pointer = get_register_value(m_pid, reg::rbp);
auto return_address = read_memory(frame_pointer+8);
bool should_remove_breakpoint = false;
if (!m_breakpoints.count(return_address)) {
set_breakpoint_at_address(return_address);
should_remove_breakpoint = true;
}
continue_execution();
if (should_remove_breakpoint) {
remove_breakpoint(return_address);
}
}
remove_breakpoint
是一個小的幫助函數:
void debugger::remove_breakpoint(std::intptr_t addr) {
if (m_breakpoints.at(addr).is_enabled()) {
m_breakpoints.at(addr).disable();
}
m_breakpoints.erase(addr);
}
接下來是跳入 step_in
。一個簡單的演算法是繼續逐步執行指令直到新的一行。
void debugger::step_in() {
auto line = get_line_entry_from_pc(get_pc())->line;
while (get_line_entry_from_pc(get_pc())->line == line) {
single_step_instruction_with_breakpoint_check();
}
auto line_entry = get_line_entry_from_pc(get_pc());
print_source(line_entry->file->path, line_entry->line);
}
跳過 step_over
對於我們來說是三個中最難的。理論上,解決方法就是在下一行源碼中設置一個斷點,但下一行源碼是什麼呢?它可能不是當前行後續的那一行,因為我們可能處於一個循環、或者某種條件結構之中。真正的調試器一般會檢查當前正在執行什麼指令然後計算出所有可能的分支目標,然後在所有分支目標中設置斷點。對於一個小的項目,我不打算實現或者集成一個 x86 指令模擬器,因此我們要想一個更簡單的解決辦法。有幾個可怕的選擇,一個是一直逐步執行直到當前函數新的一行,或者在當前函數的每一行都設置一個斷點。如果我們是要跳過一個函數調用,前者將會相當的低效,因為我們需要逐步執行那個調用圖中的每個指令,因此我會採用第二種方法。
void debugger::step_over() {
auto func = get_function_from_pc(get_pc());
auto func_entry = at_low_pc(func);
auto func_end = at_high_pc(func);
auto line = get_line_entry_from_pc(func_entry);
auto start_line = get_line_entry_from_pc(get_pc());
std::vector<std::intptr_t> to_delete{};
while (line->address < func_end) {
if (line->address != start_line->address && !m_breakpoints.count(line->address)) {
set_breakpoint_at_address(line->address);
to_delete.push_back(line->address);
}
++line;
}
auto frame_pointer = get_register_value(m_pid, reg::rbp);
auto return_address = read_memory(frame_pointer+8);
if (!m_breakpoints.count(return_address)) {
set_breakpoint_at_address(return_address);
to_delete.push_back(return_address);
}
continue_execution();
for (auto addr : to_delete) {
remove_breakpoint(addr);
}
}
這個函數有一點複雜,我們將它拆開來看。
auto func = get_function_from_pc(get_pc());
auto func_entry = at_low_pc(func);
auto func_end = at_high_pc(func);
at_low_pc
和 at_high_pc
是 libelfin
中的函數,它們能給我們指定函數 DWARF 信息條目的最小和最大程序計數器值。
auto line = get_line_entry_from_pc(func_entry);
auto start_line = get_line_entry_from_pc(get_pc());
std::vector<std::intptr_t> breakpoints_to_remove{};
while (line->address < func_end) {
if (line->address != start_line->address && !m_breakpoints.count(line->address)) {
set_breakpoint_at_address(line->address);
breakpoints_to_remove.push_back(line->address);
}
++line;
}
我們需要移除我們設置的所有斷點,以便不會泄露出我們的逐步執行函數,為此我們把它們保存到一個 std::vector
中。為了設置所有斷點,我們循環遍歷行表條目直到找到一個不在我們函數範圍內的。對於每一個,我們都要確保它不是我們當前所在的行,而且在這個位置還沒有設置任何斷點。
auto frame_pointer = get_register_value(m_pid, reg::rbp);
auto return_address = read_memory(frame_pointer+8);
if (!m_breakpoints.count(return_address)) {
set_breakpoint_at_address(return_address);
to_delete.push_back(return_address);
}
這裡我們在函數的返回地址處設置一個斷點,正如跳出 step_out
。
continue_execution();
for (auto addr : to_delete) {
remove_breakpoint(addr);
}
最後,我們繼續執行直到命中它們中的其中一個斷點,然後移除所有我們設置的臨時斷點。
它並不美觀,但暫時先這樣吧。
當然,我們還需要將這個新功能添加到用戶界面:
else if(is_prefix(command, "step")) {
step_in();
}
else if(is_prefix(command, "next")) {
step_over();
}
else if(is_prefix(command, "finish")) {
step_out();
}
測試
我通過實現一個調用一系列不同函數的簡單函數來進行測試:
void a() {
int foo = 1;
}
void b() {
int foo = 2;
a();
}
void c() {
int foo = 3;
b();
}
void d() {
int foo = 4;
c();
}
void e() {
int foo = 5;
d();
}
void f() {
int foo = 6;
e();
}
int main() {
f();
}
你應該可以在 main
地址處設置一個斷點,然後在整個程序中跳入、跳過、跳出函數。如果你嘗試跳出 main
函數或者跳入任何動態鏈接庫,就會出現意料之外的事情。
你可以在這裡找到這篇博文的相關代碼。下次我們會利用我們新的 DWARF 技巧來實現源碼級斷點。
via: https://blog.tartanllama.xyz/c++/2017/05/06/writing-a-linux-debugger-dwarf-step/
作者:Simon Brand 譯者:ictlyh 校對:wxy
本文轉載來自 Linux 中國: https://github.com/Linux-CN/archive