本文是一系列探究調試器工作原理的文章的第一篇。我還不確定這個系列需要包括多少篇文章以及它們所涵蓋的主題,但我打算從基礎知識開始說起。
關於本文
我打算在這篇文章中介紹關於Linux下的調試器實現的主要組成部分——ptrace系統調用。本文中出現的代碼都在32位的Ubuntu系統上開發。請注意,這裏出現的代碼是同平臺緊密相關的,但移植到別的平臺上應該不會太難。
動機
要想理解我們究竟要做什麼,試着想象一下調試器是如何工作的。調試器可以啓動某些進程,然後對其進行調試,或者將自己本身關聯到一個已存在的進程之上。它可以單步運行代碼,設置斷點然後運行程序,檢查變量的值以及跟蹤調用棧。許多調試器已經擁有了一些高級特性,比如執行表達式並在被調試進程的地址空間中調用函數,甚至可以直接修改進程的代碼並觀察修改後的程序行爲。
儘管現代的調試器都是複雜的大型程序,但令人驚訝的是構建調試器的基礎確是如此的簡單。調試器只用到了幾個由操作系統以及編譯器/鏈接器提供的基礎服務,剩下的僅僅就是簡單的編程問題了。(可查閱維基百科中關於這個詞條的解釋,作者是在反諷)
Linux下的調試——ptrace
Linux下調試器擁有一個瑞士軍刀般的工具,這就是ptrace系統調用。這是一個功能衆多且相當複雜的工具,能允許一個進程控制另一個進程的運行,而且可以監視和滲入到進程內部。ptrace本身需要一本中等篇幅的書才能對其進行完整的解釋,這就是爲什麼我只打算通過例子把重點放在它的實際用途上。讓我們繼續深入探尋。
遍歷進程的代碼
我現在要寫一個在“跟蹤”模式下運行的進程的例子,這裏我們要單步遍歷這個進程的代碼——由CPU所執行的機器碼(彙編指令)。我會在這裏給出例子代碼,解釋每個部分,本文結尾處你可以通過鏈接下載一份完整的C程序文件,可以自行編譯執行並研究。從高層設計來說,我們要寫一個程序,它產生一個子進程用來執行一個用戶指定的命令,而父進程跟蹤這個子進程。首先,main函數是這樣的:
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語句塊處理父進程(這裏稱爲“調試器”)。下面是目標進程:
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):
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中指定的程序之前停止該進程,併發送一個信號給父進程。
因此,是時候看看父進程需要做些什麼了:
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指令總數。讓我們看看實際運行的情況。
實際測試
我編譯了下面這個簡單的程序,然後在我們的跟蹤程序下執行:
#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)爲如下的彙編碼:
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函數的另一個版本:
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中。這兒有個有趣的地方——如果你打開這個頭文件看看,靠近文件頂端的地方有一條這樣的註釋:
/* 本文件的唯一目的是爲GDB,且只爲GDB所用。對於這個文件,不要看的太多。除了GDB以外不要用於任何其他目的,除非你知道你正在做什麼。*/
現在,我不知道你是怎麼想的,但我感覺我們正處於正確的跑道上。無論如何,回到我們的例子上來。一旦我們將所有的寄存器值獲取到regs中,我們就可以通過PTRACE_PEEKTEXT標誌以及將regs.eip(x86架構上的擴展指令指針)做參數傳入ptrace來調用。我們所得到的就是指令。讓我們在彙編代碼上運行這個新版的跟蹤程序。
$ 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來實現:
$ 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函數入口處設置一個斷點,從斷點處開始單步執行。下一篇文章中我將向您展示該如何實現斷點機制。