translated by Arthur1989, 個人主頁: http:///
我想寫一個系列,解釋調試器是如何實現(xiàn)的,盡管我不確定總共會有多少篇文章,也不清楚主題到底應該包括些什么,我還是先寫了這第一篇.讓我們開始吧.
In this part
在這篇文章中,我將解釋實現(xiàn)Linux下的調試器的基石–ptrace系統(tǒng)調用.本文的所有例子都是在32位Ubuntu上編寫的,需要注意的是,這些代碼是高度依賴于計算機體系結構的,好在移植他們應該不會太難.
Motivation
試著想想調試器需要干些什么,它可以啟動一個進程并調試之,或者將自己綁定到一個正在運行的進程.調試器還可以在代碼中單步執(zhí)行,,設置斷點,查看變量值和堆棧跟蹤信息(stack trace)(譯注: 堆棧跟蹤很大程度上歸功于寄存器ebp的設計,由于ebp指向的地址保存的總是”上一層函數調用的ebp值”,一直遞歸尋找,就能找出目的函數的ebp,而在每層函數調用中,都能通過當層的ebp往棧底找到返回地址和參數值,往棧頂找出局部變量值.–這個設計真的是太精巧了—再注: 嚴重感謝DDD(libfetion項目發(fā)起者)的指點: “調試器可以通過保持”ebp”來處理每一個棧針,但有時候為了優(yōu)化程序,使得程序能更快的運行,有時候不會保存ebp的。因為程序每進行一次函數調用,系統(tǒng)就得將ebp壓一次棧,這個相對來說有一定的性能損耗的。另外一種解決方式是使用 unwind 來組織和解析棧信息。它的原理是在編譯的時候,將每個函數要壓棧的信息(這些信息是可以提前計算出來的)都保存到一個表里,調試器在解析棧的時候,通過查詢那個表的函數信息,從而可以完全的解析出整個棧。有關unwind的信息,你可以去看下“調試器是怎樣工作的: Part 3 – 調試信息”中講到的DWARF,它里面有這方面的信息?!?SPAN style="PADDING-BOTTOM: 0px; MARGIN: 0px; PADDING-LEFT: 0px; PADDING-RIGHT: 0px; COLOR: rgb(136,136,136); PADDING-TOP: 0px">–作為參考,不妨讀一讀這篇文章<Getting the call stack without a frame pointer>). 很多調試器還有一些高級特性,比如說執(zhí)行表達式,調用在被調試進程內存空間中函數,甚至對正在運行的進程的內存做出修改,并監(jiān)測它帶來的影響.
現(xiàn)代調試器都是集合了各種特技的怪獸[1],但是它們的實現(xiàn)基礎卻出奇的簡單,調試器不過是使用了操作系統(tǒng)和編譯器/鏈接器提供的一些基礎服務罷了,剩下的就是編程的問題了.
Linux debugging – ptrace
Linux下調試器的大殺器正是ptrace系統(tǒng)調用[2].它功能強大,但用起來卻很復雜,ptrace允許一個進程控制另外一個進程,甚至能夠peek and poke被跟蹤進程的內部[3].對ptrace的詳細解釋至少需要半本書的篇幅,所以下面我重點著墨于ptrace的使用.
現(xiàn)在,讓我們開始吧.
Stepping through the code of a process
這里有一份代碼,它是一個處于”被跟蹤”(“traced”)模式的進程,CPU將單步執(zhí)行的它機器代碼(匯編指令).完整的代碼請見后文.
首要的計劃是將代碼分為兩部分,一部分是執(zhí)行用戶所提供的指令(user-supplied command)的子進程,另外一部分是一個父進程,它跟蹤子進程.下面是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創(chuàng)建一個子進程[4]. 第二條if語句運行子進程(這里叫做”target”),接下來的else if 分支執(zhí)行父進程(這里叫做”debugger”).
這是target的代碼:
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原型如下(in sys/ptrace.h):
long ptrace(enum __ptrace_request request, pid_t pid,
void *addr, void *data);
參數request有很多預定義的可選常數PTRACE_*,系統(tǒng)調用ptrace將作用于ID為pid的進程之上.參數addr和data分別是地址和指向數據的指針,它們可以用來操作內存.上面代碼中的ptrace做出了PTRACE_TRACEME 請求,意味著子進程通知OS,讓父進程跟蹤自己.PTRACE_TRACEME在man-page中有很清楚的解釋:
Indicates that this process is to be traced by its parent. Any signal (except SIGKILL) delivered to this process will cause it to stop and its parent to be notified via wait(). Also, all subsequent calls to exec() by this process will cause a SIGTRAP to be sent to it, giving the parent a chance to gain control before the new program begins execution. A process probably shouldn’t make this request if its parent isn’t expecting to trace it. (pid, addr, and data are ignored.)
PTRACE_TRACEME被父進程用來跟蹤子進程,任何信號(除了SIGKILL)都會暫停子進程,接著阻塞于wait()等待的父進程被喚醒.子進程內部對exec()的調用將發(fā)出SIGTRAP信號,這可以讓父進程在子進程新程序開始運行之前就完全控制它.如果父進程不打算跟蹤子進程,子進程就不應該發(fā)出PTRACE_TRACEME請求.(在PTRACE_TRACEME請求時,參數pid,addr和data被忽略.)
注意run_target在做出ptrace調用之后的第一步,就是用execl執(zhí)行參數programname.正如引文解釋的那樣,子進程在執(zhí)行execl的新程序之前會被暫停,而且父進程將收到OS發(fā)送的信號.
是時候看看父進程了:
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);
}
回憶一下,子進程內部執(zhí)行exec調用將發(fā)出SIGTRAP信號,父進程中的第一條wait()就是等待這個信號的,父進程通過WIFSTOPPED查看子進程是否由于信號被暫停.
接下來發(fā)生的就是最有趣的事了,父進程通過request值為 PTRACE_SINGLESTEP的對pid為ID的進程做ptrace調用,告訴操作系統(tǒng),重新喚醒子進程,但是在每條機器指令運行之后暫停.再一次的,父進程阻塞等待子進程暫停并計數,子進程結束(WIFEXITED返回真)后,父進程跳出loop循環(huán).
icounter對子進程執(zhí)行的每條指令計數.這還挺有用的,哈哈.
A test run
編譯下面的代碼,運行之,讓它被跟蹤.
#include <stdio.h>
int main()
{
printf("Hello, world!\n");
return 0;
}
出乎意料的,跟蹤進程執(zhí)行了很長的時間,報告說上面的代碼執(zhí)行了超過100,000條機器指令.開什么玩笑,僅僅一行printf就..? 搞什么鬼啊? 答案很值得討論[5].Linux下gcc默認將程序動態(tài)鏈接到C運行庫,這意味著,當一個程序被運行時,發(fā)生的第一件事就是動態(tài)庫裝載器(dynamic library loader)搜索共享庫(shared libraries),這就解釋了為什么上面的小例子會執(zhí)行了這么多機器指令.
如果用-static標志編譯代碼,讓它靜態(tài)鏈接(這時候可執(zhí)行文件大小超過了500KB),這時跟蹤程序報告說樣例執(zhí)行的機器指令只有大概7,000條.還是有點多,但是考慮到在main之前l(fā)ibc的初始化,還有main退出后的清理工作,這也就說得過去了.再說了,printf也挺復雜的.
但是我還是不滿意,我想要的是可驗證的程序,也就是說,程序執(zhí)行的整個過程我都一清二楚.所以我寫了一個匯編版本的”Hello, world!”:
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條指令被執(zhí)行,很好,我可以很容易地驗證它.
Deep into the instruction stream
有了上面的匯編代碼,我們看看ptrace的另一個強悍功能吧–它可以用來跟蹤進程的狀態(tài).這是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循環(huán)的前面幾條代碼.有兩條ptrace語句,第一條讀取被跟蹤進程的寄存器,保存到數據結構user_regs_struct(參考sys/user.h)中.
在獲得了所有寄存器的值后,我們接著用PTRACE_PEEKTEXT讀取eip(x86的擴展指令指針(extended instruction pointer))對應的機器指令[6].看一下輸出結果:
$ 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,現(xiàn)在除了icounter,我們還能看到eip和它對應的機器指令.怎樣驗證輸出結果呢? 我們可以借助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
objdump -d的輸出和跟蹤進程的輸出是一致的.
Attaching to a running process
正如你知道的那樣調試器還可以調試正在運行的進程.很容易知道,這可以用PTRACE_ATTACH請求來實現(xiàn).我不再給出代碼了,因為這不難編寫.
The code
完整的代碼在這里.用gcc -Wall -pedantic –std=c99 code.c編譯它.
Conclusion and next steps
好吧,我承認,這篇文章覆蓋的內容很少 -– 離真正可以工作的調試器還差得很遠.不管怎么樣,我希望至少現(xiàn)在看來,調試器沒有那么神秘了.ptrace很好很強大,我們剛上路呢.
在C代碼中單步執(zhí)行確實很有用,但它不是萬能的.以C版本的”Hello, world!”為例,在執(zhí)行main函數之前,程序已經單步執(zhí)行了上萬條用于C庫初始化的機器指令了,可以看出,這很不方便.我們的需求其實是在main入口前面放置斷點,然后step到main.這該如何實現(xiàn)呢,請看part22.
References
我覺得這些資源不錯:
* Playing with ptrace, Part I
* Process tracing using ptrace
* How debugger works
注:
[1] 我沒查證過,但是我保證gdb的源代碼行數(LOC)至少有六位數.
[2] man 2 ptrace.
[3] Peek and poke是用以讀寫內存的黑話.
[4] 這篇文章要求讀者對Unix/Linux編程有一定的經驗.我假設讀者熟悉(至少是概念上的) fork, exec族和Unix信號.
[5] 假設你和我一樣,對底層細節(jié)著迷的話.:-)
[6] 提醒: 這篇文章很多內容是平臺相關的.我做了一些設定 –- 比如說,x86指令并不一定要求是4字節(jié).(在我的32位Ubuntu上unsigned的長度).