本文是一系列探究偵錯程式工作原理的文章的第一篇。我還不確定這個系列需要包括多少篇文章以及它們所涵蓋的主題,但我打算從基礎知識開始說起。
關於本文
我打算在這篇文章中介紹關於Linux下的偵錯程式實現的主要組成部分——ptrace系統呼叫。本文中出現的程式碼都在32位的Ubuntu系統上開發。請注意,這裡出現的程式碼是同平臺緊密相關的,但移植到別的平臺上應該不會太難。
動機
要想理解我們究竟要做什麼,試著想象一下偵錯程式是如何工作的。偵錯程式可以啟動某些程式,然後對其進行除錯,或者將自己本身關聯到一個已存在的程式之上。它可以單步執行程式碼,設定斷點然後執行程式,檢查變數的值以及跟蹤呼叫棧。許多偵錯程式已經擁有了一些高階特性,比如執行表示式並在被除錯程式的地址空間中呼叫函式,甚至可以直接修改程式的程式碼並觀察修改後的程式行為。
儘管現代的偵錯程式都是複雜的大型程式,但令人驚訝的是構建偵錯程式的基礎確是如此的簡單。偵錯程式只用到了幾個由作業系統以及編譯器/連結器提供的基礎服務,剩下的僅僅就是簡單的程式設計問題了。(可查閱維基百科中關於這個詞條的解釋,作者是在反諷)
Linux下的除錯——ptrace
Linux下偵錯程式擁有一個瑞士軍刀般的工具,這就是ptrace系統呼叫。這是一個功能眾多且相當複雜的工具,能允許一個程式控制另一個程式的執行,而且可以監視和滲入到程式內部。ptrace本身需要一本中等篇幅的書才能對其進行完整的解釋,這就是為什麼我只打算通過例子把重點放在它的實際用途上。讓我們繼續深入探尋。
遍歷程式的程式碼
我現在要寫一個在“跟蹤”模式下執行的程式的例子,這裡我們要單步遍歷這個程式的程式碼——由CPU所執行的機器碼(彙編指令)。我會在這裡給出例子程式碼,解釋每個部分,本文結尾處你可以通過連結下載一份完整的C程式檔案,可以自行編譯執行並研究。從高層設計來說,我們要寫一個程式,它產生一個子程式用來執行一個使用者指定的命令,而父程式跟蹤這個子程式。首先,main函式是這樣的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
int main(int argc, char** argv) { pid_t child_pid; if (argc < 2) { fprintf(stderr, "Expected a program name as argument\n"); return -1; } child_pid = fork(); if (child_pid == 0) run_target(argv[1]); else if (child_pid > 0) run_debugger(child_pid); else { perror("fork"); return -1; } return 0; } |
程式碼相當簡單,我們通過fork產生一個新的子程式。隨後的if語句塊處理子程式(這裡稱為“目標程式”),而else if語句塊處理父程式(這裡稱為“偵錯程式”)。下面是目標程式:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
void run_target(const char* programname) { procmsg("target started. will run '%s'\n", programname); /* Allow tracing of this process */ if (ptrace(PTRACE_TRACEME, 0, 0, 0) < 0) { perror("ptrace"); return; } /* Replace this process's image with the given program */ execl(programname, programname, 0); } |
這部分最有意思的地方在ptrace呼叫。ptrace的原型是(在sys/ptrace.h):
1 |
long ptrace(enum __ptrace_request request, pid_t pid, void *addr, void *data); |
第一個引數是request,可以是預定義的以PTRACE_打頭的常量值。第二個引數指定了程式id,第三以及第四個引數是地址和指向資料的指標,用來對記憶體做操作。上面程式碼段中的ptrace呼叫使用了PTRACE_TRACEME請求,這表示這個子程式要求作業系統核心允許它的父程式對其跟蹤。這個請求在man手冊中解釋的非常清楚:
“表明這個程式由它的父程式來跟蹤。任何發給這個程式的訊號(除了SIGKILL)將導致該程式停止執行,而它的父程式會通過wait()獲得通知。另外,該程式之後所有對exec()的呼叫都將使作業系統產生一個SIGTRAP訊號傳送給它,這讓父程式有機會在新程式開始執行之前獲得對子程式的控制權。如果不希望由父程式來跟蹤的話,那就不應該使用這個請求。(pid、addr、data被忽略)”
我已經把這個例子中我們感興趣的地方高亮顯示了。注意,run_target在ptrace呼叫之後緊接著做的是通過execl來呼叫我們指定的程式。這裡就會像我們高亮顯示的部分所解釋的那樣,作業系統核心會在子程式開始執行execl中指定的程式之前停止該程式,併傳送一個訊號給父程式。
因此,是時候看看父程式需要做些什麼了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
void run_debugger(pid_t child_pid) { int wait_status; unsigned icounter = 0; procmsg("debugger started\n"); /* Wait for child to stop on its first instruction */ wait(&wait_status); while (WIFSTOPPED(wait_status)) { icounter++; /* Make the child execute another instruction */ if (ptrace(PTRACE_SINGLESTEP, child_pid, 0, 0) < 0) { perror("ptrace"); return; } /* Wait for child to stop on its next instruction */ wait(&wait_status); } procmsg("the child executed %u instructions\n", icounter); } |
通過上面的程式碼我們可以回顧一下,一旦子程式開始執行exec呼叫,它就會停止然後接收到一個SIGTRAP訊號。父程式通過第一個wait呼叫正在等待這個事件發生。一旦子程式停止(如果子程式由於傳送的訊號而停止執行,WIFSTOPPED就返回true),父程式就去檢查這個事件。
父程式接下來要做的是本文中最有意思的地方。父程式通過PTRACE_SINGLESTEP以及子程式的id號來呼叫ptrace。這麼做是告訴作業系統——請重新啟動子程式,但當子程式執行了下一條指令後再將其停止。然後父程式再次等待子程式的停止,整個迴圈繼續得以執行。當從wait中得到的不是關於子程式停止的訊號時,迴圈結束。在正常執行這個跟蹤程式時,會得到子程式正常退出(WIFEXITED會返回true)的訊號。
icounter會統計子程式執行的指令數量。因此我們這個簡單的例子實際上還是做了點有用的事情——通過在命令列上指定一個程式名,我們的例子會執行這個指定的程式,然後統計出從開始到結束該程式執行過的CPU指令總數。讓我們看看實際執行的情況。
實際測試
我編譯了下面這個簡單的程式,然後在我們的跟蹤程式下執行:
1 2 3 4 5 6 |
#include <stdio.h> int main() { printf(“Hello, world!\n”); return 0; } |
令我驚訝的是,我們的跟蹤程式執行了很長的時間然後報告顯示一共有超過100000條指令得到了執行。僅僅只是一個簡單的printf呼叫,為什麼會這樣?答案非常有意思。預設情況下,Linux中的gcc編譯器會動態連結到C執行時庫。這意味著任何程式在執行時首先要做的事情是載入動態庫。這需要很多程式碼實現——記住,我們這個簡單的跟蹤程式會針對每一條被執行的指令計數,不僅僅是main函式,而是整個程式。
因此,當我採用-static標誌靜態連結這個測試程式時(注意到可執行檔案因此增加了500KB的大小,因為它靜態連結了C執行時庫),我們的跟蹤程式報告顯示只有7000條左右的指令被執行了。這還是非常多,但如果你瞭解到libc的初始化工作仍然先於main的執行,而清理工作會在main之後執行,那麼這就完全說得通了。而且,printf也是一個複雜的函式。
我們還是不滿足於此,我希望能看到一些可檢測的東西,例如我可以從整體上看到每一條需要被執行的指令是什麼。這一點我們可以通過彙編程式碼來得到。因此我把這個“Hello,world”程式彙編(gcc -S)為如下的彙編碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
section .text ; The _start symbol must be declared for the linker (ld) global _start _start: ; Prepare arguments for the sys_write system call: ; - eax: system call number (sys_write) ; - ebx: file descriptor (stdout) ; - ecx: pointer to string ; - edx: string length mov edx, len mov ecx, msg mov ebx, 1 mov eax, 4 ; Execute the sys_write system call int 0x80 ; Execute sys_exit mov eax, 1 int 0x80 section .data msg db 'Hello, world!', 0xa len equ $ - msg |
這就足夠了。現在跟蹤程式會報告有7條指令得到了執行,我可以很容易地從彙編程式碼來驗證這一點。
深入指令流
彙編碼程式得以讓我為大家介紹ptrace的另一個強大的功能——詳細檢查被跟蹤程式的狀態。下面是run_debugger函式的另一個版本:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
void run_debugger(pid_t child_pid) { int wait_status; unsigned icounter = 0; procmsg("debugger started\n"); /* Wait for child to stop on its first instruction */ wait(&wait_status); while (WIFSTOPPED(wait_status)) { icounter++; struct user_regs_struct regs; ptrace(PTRACE_GETREGS, child_pid, 0, ®s); unsigned instr = ptrace(PTRACE_PEEKTEXT, child_pid, regs.eip, 0); procmsg("icounter = %u. EIP = 0x%08x. instr = 0x%08x\n", icounter, regs.eip, instr); /* Make the child execute another instruction */ if (ptrace(PTRACE_SINGLESTEP, child_pid, 0, 0) < 0) { perror("ptrace"); return; } /* Wait for child to stop on its next instruction */ wait(&wait_status); } procmsg("the child executed %u instructions\n", icounter); } |
同前個版本相比,唯一的不同之處在於while迴圈的開始幾行。這裡有兩個新的ptrace呼叫。第一個讀取程式的暫存器值到一個結構體中。結構體user_regs_struct定義在sys/user.h中。這兒有個有趣的地方——如果你開啟這個標頭檔案看看,靠近檔案頂端的地方有一條這樣的註釋:
1 |
/* 本檔案的唯一目的是為GDB,且只為GDB所用。對於這個檔案,不要看的太多。除了GDB以外不要用於任何其他目的,除非你知道你正在做什麼。*/ |
現在,我不知道你是怎麼想的,但我感覺我們正處於正確的跑道上。無論如何,回到我們的例子上來。一旦我們將所有的暫存器值獲取到regs中,我們就可以通過PTRACE_PEEKTEXT標誌以及將regs.eip(x86架構上的擴充套件指令指標)做引數傳入ptrace來呼叫。我們所得到的就是指令。讓我們在彙編程式碼上執行這個新版的跟蹤程式。
1 2 3 4 5 6 7 8 9 10 11 12 |
$ simple_tracer traced_helloworld [5700] debugger started [5701] target started. will run 'traced_helloworld' [5700] icounter = 1. EIP = 0x08048080. instr = 0x00000eba [5700] icounter = 2. EIP = 0x08048085. instr = 0x0490a0b9 [5700] icounter = 3. EIP = 0x0804808a. instr = 0x000001bb [5700] icounter = 4. EIP = 0x0804808f. instr = 0x000004b8 [5700] icounter = 5. EIP = 0x08048094. instr = 0x01b880cd Hello, world! [5700] icounter = 6. EIP = 0x08048096. instr = 0x000001b8 [5700] icounter = 7. EIP = 0x0804809b. instr = 0x000080cd [5700] the child executed 7 instructions |
OK,所以現在除了icounter以外,我們還能看到指令指標以及每一步的指令。如何驗證這是否正確呢?可以通過在可執行檔案上執行objdump –d來實現:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
$ objdump -d traced_helloworld traced_helloworld: file format elf32-i386 Disassembly of section .text: 08048080 <.text>: 8048080: ba 0e 00 00 00 mov $0xe,%edx 8048085: b9 a0 90 04 08 mov $0x80490a0,%ecx 804808a: bb 01 00 00 00 mov $0x1,%ebx 804808f: b8 04 00 00 00 mov $0x4,%eax 8048094: cd 80 int $0x80 8048096: b8 01 00 00 00 mov $0x1,%eax 804809b: cd 80 int $0x80 |
用這份輸出對比我們的跟蹤程式輸出,應該很容易觀察到相同的地方。
關聯到執行中的程式上
你已經知道了偵錯程式也可以關聯到已經處於執行狀態的程式上。看到這裡,你應該不會感到驚訝,這也是通過ptrace來實現的。這需要通過PTRACE_ATTACH請求。這裡我不會給出一段樣例程式碼,因為通過我們已經看到的程式碼,這應該很容易實現。基於教學的目的,這裡採用的方法更為便捷(因為我們可以在子程式剛啟動時立刻將它停止)。
程式碼
本文給出的這個簡單的跟蹤程式的完整程式碼(更高階一點,可以將具體指令列印出來)可以在這裡找到。程式通過-Wall –pedantic –std=c99編譯選項在4.4版的gcc上編譯。
結論及下一步要做的
誠然,本文並沒有涵蓋太多的內容——我們離一個真正可用的偵錯程式還差的很遠。但是,我希望這篇文章至少已經揭開了除錯過程的神祕面紗。ptrace是一個擁有許多功能的系統呼叫,目前我們只展示了其中少數幾種功能。
能夠單步執行程式碼是很有用處的,但作用有限。以“Hello, world”為例,要到達main函式,需要先遍歷好幾千條初始化C執行時庫的指令。這就不太方便了。我們所希望的理想方案是可以在main函式入口處設定一個斷點,從斷點處開始單步執行。下一篇文章中我將向您展示該如何實現斷點機制。
參考文獻
寫作本文時我發現下面這些文章很有幫助:
偵錯程式工作原理(2):實現斷點
偵錯程式工作原理(3):除錯資訊