偵錯程式應該每一個程式設計師都會用,但肯定不是每一個人都知道是怎麼回事。
我對偵錯程式非常著迷。它們太可愛了,我最近開發了一個小巧、非常基礎的偵錯程式,作為我的一個小專案。在這篇博文中,我將記錄學到的一些如何設定斷點的知識。本文可以被分為如下幾個小節。
- 什麼是斷點?
- 什麼是偵錯程式?
- 設定斷點,偵錯程式需要做什麼?
- 偵錯程式如何暫停被除錯程式?
什麼是斷點?
斷點是程式中的某一點,一旦程式執行到這一點就會停止。
什麼是偵錯程式?
你可以認為偵錯程式是這樣一個程式,它使用 forks() 建立一個子程式,然後呼叫 execl() 載入準備除錯的程式。我的程式碼裡使用 execl(),但是任何 exec 家族的系統呼叫函式均可使用。
下面是 run_child() 函式,在其中呼叫了 execl() 函式,並傳入待除錯程式的可執行檔名稱及路徑作為引數。
我們看到在呼叫 execl() 之前呼叫 ptrace()。我們暫時不要深究 ptrace() 的細節,雖然理解它對理解偵錯程式工作原理非常重要。稍後我們再討論它。
現在我們有兩個活躍程式:
- 作為父程式的偵錯程式。
- 作為子程式的被除錯程式。
現在讓我們以一種簡化的方式,抽象理解一下偵錯程式通過哪些工作,才能在子程式中設定斷點。偵錯程式需要子程式在斷點處停下來,那麼該怎麼做呢?
偵錯程式需要做些什麼才能設定一個斷點?
我們首先仔細研究一番“設定一個斷點”這句話。我們知道,當一個程式處於執行狀態時,它的指令會被處理器依次執行。那麼指令載入在哪裡呢?在程式的虛擬記憶體的 text/code 段中!
我們設定一個斷點,希望被除錯程式可以在指定點暫停。也就是說,我們希望被除錯程式在某條指令之前停下來。什麼指令可以實現呢?如果在函式開始處設定一個斷點,這條指令就是函式的第一條指令;如果在原始碼中某一行設定斷點,這條指令,就是在該行對應的若干指令之前的那條。
那麼,偵錯程式需要讓被除錯程式就在執行這條指令時,停下來。
在我的專案裡,我使用截圖中下劃線標註的指令。
偵錯程式如何使被除錯程式在執行指定指令前暫停?
偵錯程式在被除錯程式啟動時,就將被除錯程式的某條指令(或者某條指令的一部分),替換為可產生一個軟中斷的指令。因此,當這條被修改的指令被處理器執行時,就會產生 SIGTRAP 訊號,這就足以使得程式停止了。這裡,我略過了很多細節,不過隨著我們的進展,都會逐漸明朗的。
好了,我們首先探討一下,偵錯程式如何修改一條指令?
一系列指令儲存在程式的 text 段,並在載入時由虛擬記憶體進行對映。所以,要想修改指令,偵錯程式需要知道那條指令的地址。
偵錯程式如何找到一條指令的地址?
如果你編譯 C/C++ 程式,你可以使用 “-g” 引數,令編譯器生成一些額外資訊。這些額外資訊中,包含了上述對映資訊,並被儲存在一種稱之為“DWARF” 格式的物件檔案中。在 Linux 系統上,DWARF 格式被用於在 ELF 檔案中儲存除錯資訊。是不是很 Cool!ELF 是 Executable Linkable Format (可執行連線格式)的意思。這是一種表示物件檔案、可執行檔案、共享庫的格式。
偵錯程式用什麼指令替代了原有指令?
偵錯程式將原有指令所在位置的第一個位元組,重寫為 “int 3”。“int 3” 是一個單位元組操作碼,也就是說,偵錯程式只需要修改指定記憶體地址的第一個位元組即可。
“int 3”是什麼指令? “int n”指令會呼叫一個異常回撥,這個回撥由運算元 n 指定。“int 3”會產生一個到除錯異常回撥的呼叫。該回撥函式是核心程式碼的一部分,會向目標程式發出 SIGTRAP 訊號,在我們的例子中,這個程式就是被除錯程式。
偵錯程式是什麼時候、如何改變被除錯程式的指令的?
終於到了見證奇蹟的時刻!我們將會使用一個非常強大的系統呼叫—— ptrace()。
我們先了解下 ptrace。
ptrace() 能做些什麼?我們的偵錯程式將通過 ptrace,控制我們除錯的程式的執行情況。偵錯程式通過 ptrace() 檢視、修改被除錯程式的記憶體和暫存器。
如果你檢視一下原始碼,在用 execl() 啟動被除錯程式之前,我們呼叫 ptrace() 函式,並傳入 PTRACE_TRACEME 引數(表示當前程式被其父程式追蹤,也就是這裡的偵錯程式)。
ptrace 的 man 文件中提到:
如果 PTRACE_O_TRACEEXEC 選項未生效,只要被跟蹤程式成功呼叫 execl(2) ,被跟蹤程式就會收到一個 SIGTRAP 訊號,在該程式開始執行之前,使父程式得到一個獲得控制權的機會。
簡而言之:在被除錯程式啟動之前,偵錯程式通過 wait()/waitpid() 系統呼叫,偵錯程式會得到一個通知。此時,偵錯程式就得到一個黃金時機,修改被除錯程式的 text/code 段。
另外,每當被除錯程式收到一個訊號時,跟蹤程式(偵錯程式)都會在下一次呼叫 waitpid() 時得到通知。所以,當被除錯程式收到 SIGTRAP 訊號時,它會暫停執行,然後偵錯程式會得到通知,而這就是我們期望的。我們希望被除錯程式暫停,當被除錯程式執行 “int 3”指令事,偵錯程式得到通知。偵錯程式通過 waitpid() 的返回值,確定被除錯程式暫停相關資訊。
SIGTRAP 訊號的預設行為是程式映象轉儲,之後退出程式,但是我們無法除錯一個被殺死的程式,不是麼?因此,偵錯程式會忽略 SIGTRAP 訊號,然後讓被除錯程式繼續執行。
下面是將“int 3”指令設定到原始指令第一個位元組的程式碼。首先,還是用 ptrace() 函式獲取指定地址的原始指令,我們將其儲存下來以便稍後恢復之用;然後,繼續用 ptrace() 函式,但是傳入不同的引數 PTRACE_POKETEXT,設定一個新的指令,該指令的第一個位元組是 “int 3”。
當 “命中斷點” 時,偵錯程式需要做什麼?
- 首先,偵錯程式需要將原始指令,恢復到設定斷點的地址。
- 然後,恢復完成後,原始指令應當被執行一次,偵錯程式將繼續被除錯程式的執行。
偵錯程式如何恢復原始指令?與通過設定“int 3”設定斷點的方法一樣。下面是程式碼。當設定斷點的時候,我們將原始指令儲存下來了,現在我們需要做的是,將它設定回給定記憶體地址。
被除錯程式的原始指令又是如何被執行的?
現在,被除錯程式的程式計數器已經指向下一條指令,當前地址已經被執行過“int 3”了。
為了保證處理器能執行被除錯程式的原始指令,我們需要重設其程式計數器 (對 x86 機器 %eip,對 x86 64 機器 %rip)為原始指令的地址。
我們如何才能設定被除錯程式的指令指標?
用 ptrace() 啊!ptrace() 擁有這種碉堡的能力,可以讓我們“修改被跟蹤程式記憶體和暫存器”。PTRACE_GETREGS 引數可以令 ptrace 將被除錯程式的通用暫存器狀態複製到一個結構體中。PTRACE_SETREGS 則可以修改被除錯程式的通用暫存器狀態。下面的程式碼實現了這些功能。
一旦偵錯程式重置了被除錯程式的程式計數器,被除錯程式就可以繼續執行。可以參照如下做法——
以上就是偵錯程式設定斷點的方法。
偵錯程式的完整程式碼在這裡。
我在週四晚 RC 上的演講中介紹過這個問題。你可以在這裡下載PPT。
參考:
Eli Bendersky’s articles on debuggers
Call to interrupt procedures
Interrupts and interrupt handlers