在前幾篇博文中我們學習了 dwarf 資訊以及它如何使我們将機器碼和上層源碼聯系起來。這一次我們通過為我們的調試器添加源碼級逐漸調試将該知識應用于實際。
系列文章索引
随着後面文章的釋出,這些連結會逐漸生效。
<a href="http://os.51cto.com/art/201706/543147.htm">準備環境</a>
<a href="http://os.51cto.com/art/201706/543616.htm">斷點</a>
<a href="http://os.51cto.com/art/201707/544074.htm">寄存器和記憶體</a>
<a href="http://os.51cto.com/art/201707/545736.htm">elves 和 dwarves</a>
<a href="http://os.51cto.com/art/201708/549402.htm">源碼和信号</a>
<a href="https://blog.tartanllama.xyz/c++/2017/05/06/writing-a-linux-debugger-dwarf-step/">源碼級逐漸執行</a>
源碼級斷點
調用棧展開
讀取變量
下一步
揭秘指令級逐漸執行
我們正在超越了自我。首先讓我們通過使用者接口揭秘指令級單步執行。我決定将它切分為能被其它部分代碼利用的 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();
跳過 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;
to_delete.push_back(return_address);
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);
這裡我們在函數的傳回位址處設定一個斷點,正如跳出 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 技巧來實作源碼級斷點。
作者:simon brand
來源:51cto