偵錯程式工作原理(1):基礎篇

發表於2013-04-15

本文是一系列探究偵錯程式工作原理的文章的第一篇。我還不確定這個系列需要包括多少篇文章以及它們所涵蓋的主題,但我打算從基礎知識開始說起。

關於本文

我打算在這篇文章中介紹關於Linux下的偵錯程式實現的主要組成部分——ptrace系統呼叫。本文中出現的程式碼都在32位的Ubuntu系統上開發。請注意,這裡出現的程式碼是同平臺緊密相關的,但移植到別的平臺上應該不會太難。

動機

要想理解我們究竟要做什麼,試著想象一下偵錯程式是如何工作的。偵錯程式可以啟動某些程式,然後對其進行除錯,或者將自己本身關聯到一個已存在的程式之上。它可以單步執行程式碼,設定斷點然後執行程式,檢查變數的值以及跟蹤呼叫棧。許多偵錯程式已經擁有了一些高階特性,比如執行表示式並在被除錯程式的地址空間中呼叫函式,甚至可以直接修改程式的程式碼並觀察修改後的程式行為。

儘管現代的偵錯程式都是複雜的大型程式,但令人驚訝的是構建偵錯程式的基礎確是如此的簡單。偵錯程式只用到了幾個由作業系統以及編譯器/連結器提供的基礎服務,剩下的僅僅就是簡單的程式設計問題了。(可查閱維基百科中關於這個詞條的解釋,作者是在反諷)

Linux下的除錯——ptrace

Linux下偵錯程式擁有一個瑞士軍刀般的工具,這就是ptrace系統呼叫。這是一個功能眾多且相當複雜的工具,能允許一個程式控制另一個程式的執行,而且可以監視和滲入到程式內部。ptrace本身需要一本中等篇幅的書才能對其進行完整的解釋,這就是為什麼我只打算通過例子把重點放在它的實際用途上。讓我們繼續深入探尋。

 

遍歷程式的程式碼

我現在要寫一個在“跟蹤”模式下執行的程式的例子,這裡我們要單步遍歷這個程式的程式碼——由CPU所執行的機器碼(彙編指令)。我會在這裡給出例子程式碼,解釋每個部分,本文結尾處你可以通過連結下載一份完整的C程式檔案,可以自行編譯執行並研究。從高層設計來說,我們要寫一個程式,它產生一個子程式用來執行一個使用者指定的命令,而父程式跟蹤這個子程式。首先,main函式是這樣的:

程式碼相當簡單,我們通過fork產生一個新的子程式。隨後的if語句塊處理子程式(這裡稱為“目標程式”),而else if語句塊處理父程式(這裡稱為“偵錯程式”)。下面是目標程式:

這部分最有意思的地方在ptrace呼叫。ptrace的原型是(在sys/ptrace.h):

第一個引數是request,可以是預定義的以PTRACE_打頭的常量值。第二個引數指定了程式id,第三以及第四個引數是地址和指向資料的指標,用來對記憶體做操作。上面程式碼段中的ptrace呼叫使用了PTRACE_TRACEME請求,這表示這個子程式要求作業系統核心允許它的父程式對其跟蹤。這個請求在man手冊中解釋的非常清楚:

“表明這個程式由它的父程式來跟蹤。任何發給這個程式的訊號(除了SIGKILL)將導致該程式停止執行,而它的父程式會通過wait()獲得通知。另外,該程式之後所有對exec()的呼叫都將使作業系統產生一個SIGTRAP訊號傳送給它,這讓父程式有機會在新程式開始執行之前獲得對子程式的控制權。如果不希望由父程式來跟蹤的話,那就不應該使用這個請求。(pid、addr、data被忽略)”

我已經把這個例子中我們感興趣的地方高亮顯示了。注意,run_target在ptrace呼叫之後緊接著做的是通過execl來呼叫我們指定的程式。這裡就會像我們高亮顯示的部分所解釋的那樣,作業系統核心會在子程式開始執行execl中指定的程式之前停止該程式,併傳送一個訊號給父程式。

因此,是時候看看父程式需要做些什麼了:

通過上面的程式碼我們可以回顧一下,一旦子程式開始執行exec呼叫,它就會停止然後接收到一個SIGTRAP訊號。父程式通過第一個wait呼叫正在等待這個事件發生。一旦子程式停止(如果子程式由於傳送的訊號而停止執行,WIFSTOPPED就返回true),父程式就去檢查這個事件。

父程式接下來要做的是本文中最有意思的地方。父程式通過PTRACE_SINGLESTEP以及子程式的id號來呼叫ptrace。這麼做是告訴作業系統——請重新啟動子程式,但當子程式執行了下一條指令後再將其停止。然後父程式再次等待子程式的停止,整個迴圈繼續得以執行。當從wait中得到的不是關於子程式停止的訊號時,迴圈結束。在正常執行這個跟蹤程式時,會得到子程式正常退出(WIFEXITED會返回true)的訊號。

icounter會統計子程式執行的指令數量。因此我們這個簡單的例子實際上還是做了點有用的事情——通過在命令列上指定一個程式名,我們的例子會執行這個指定的程式,然後統計出從開始到結束該程式執行過的CPU指令總數。讓我們看看實際執行的情況。

 

實際測試

我編譯了下面這個簡單的程式,然後在我們的跟蹤程式下執行:

令我驚訝的是,我們的跟蹤程式執行了很長的時間然後報告顯示一共有超過100000條指令得到了執行。僅僅只是一個簡單的printf呼叫,為什麼會這樣?答案非常有意思。預設情況下,Linux中的gcc編譯器會動態連結到C執行時庫。這意味著任何程式在執行時首先要做的事情是載入動態庫。這需要很多程式碼實現——記住,我們這個簡單的跟蹤程式會針對每一條被執行的指令計數,不僅僅是main函式,而是整個程式。

因此,當我採用-static標誌靜態連結這個測試程式時(注意到可執行檔案因此增加了500KB的大小,因為它靜態連結了C執行時庫),我們的跟蹤程式報告顯示只有7000條左右的指令被執行了。這還是非常多,但如果你瞭解到libc的初始化工作仍然先於main的執行,而清理工作會在main之後執行,那麼這就完全說得通了。而且,printf也是一個複雜的函式。

我們還是不滿足於此,我希望能看到一些可檢測的東西,例如我可以從整體上看到每一條需要被執行的指令是什麼。這一點我們可以通過彙編程式碼來得到。因此我把這個“Hello,world”程式彙編(gcc -S)為如下的彙編碼:

這就足夠了。現在跟蹤程式會報告有7條指令得到了執行,我可以很容易地從彙編程式碼來驗證這一點。

 

深入指令流

彙編碼程式得以讓我為大家介紹ptrace的另一個強大的功能——詳細檢查被跟蹤程式的狀態。下面是run_debugger函式的另一個版本:

同前個版本相比,唯一的不同之處在於while迴圈的開始幾行。這裡有兩個新的ptrace呼叫。第一個讀取程式的暫存器值到一個結構體中。結構體user_regs_struct定義在sys/user.h中。這兒有個有趣的地方——如果你開啟這個標頭檔案看看,靠近檔案頂端的地方有一條這樣的註釋:

現在,我不知道你是怎麼想的,但我感覺我們正處於正確的跑道上。無論如何,回到我們的例子上來。一旦我們將所有的暫存器值獲取到regs中,我們就可以通過PTRACE_PEEKTEXT標誌以及將regs.eip(x86架構上的擴充套件指令指標)做引數傳入ptrace來呼叫。我們所得到的就是指令。讓我們在彙編程式碼上執行這個新版的跟蹤程式。

OK,所以現在除了icounter以外,我們還能看到指令指標以及每一步的指令。如何驗證這是否正確呢?可以通過在可執行檔案上執行objdump –d來實現:

用這份輸出對比我們的跟蹤程式輸出,應該很容易觀察到相同的地方。

 

關聯到執行中的程式上

你已經知道了偵錯程式也可以關聯到已經處於執行狀態的程式上。看到這裡,你應該不會感到驚訝,這也是通過ptrace來實現的。這需要通過PTRACE_ATTACH請求。這裡我不會給出一段樣例程式碼,因為通過我們已經看到的程式碼,這應該很容易實現。基於教學的目的,這裡採用的方法更為便捷(因為我們可以在子程式剛啟動時立刻將它停止)。

 

程式碼

本文給出的這個簡單的跟蹤程式的完整程式碼(更高階一點,可以將具體指令列印出來)可以在這裡找到。程式通過-Wall –pedantic –std=c99編譯選項在4.4版的gcc上編譯。

 

結論及下一步要做的

誠然,本文並沒有涵蓋太多的內容——我們離一個真正可用的偵錯程式還差的很遠。但是,我希望這篇文章至少已經揭開了除錯過程的神祕面紗。ptrace是一個擁有許多功能的系統呼叫,目前我們只展示了其中少數幾種功能。

能夠單步執行程式碼是很有用處的,但作用有限。以“Hello, world”為例,要到達main函式,需要先遍歷好幾千條初始化C執行時庫的指令。這就不太方便了。我們所希望的理想方案是可以在main函式入口處設定一個斷點,從斷點處開始單步執行。下一篇文章中我將向您展示該如何實現斷點機制。

 

參考文獻

寫作本文時我發現下面這些文章很有幫助:

 

偵錯程式工作原理(2):實現斷點

偵錯程式工作原理(3):除錯資訊

 

相關文章