說明調(diào)試器中的斷點機(jī)制是如何實現(xiàn)的。斷點機(jī)制是調(diào)試器的兩大主要支柱之一 ——另一個是在被調(diào)試進(jìn)程的內(nèi)存空間中查看變量的值。我們已經(jīng)在第一篇文章中稍微涉及到了一些監(jiān)視被調(diào)試進(jìn)程的知識,但斷點機(jī)制仍然還是個迷。
軟中斷
要在x86體系結(jié)構(gòu)上實現(xiàn)斷點我們要用到軟中斷(也稱為“陷阱”trap)。在我們深入細(xì)節(jié)之前,我想先大致解釋一下中斷和陷阱的概念。
CPU有一個單獨的執(zhí)行序列,會一條指令一條指令的順序執(zhí)行。要處理類似IO或者硬件時鐘這樣的異步事件時CPU就要用到中斷。硬件中斷通常是一個專門的電信號,連接到一個特殊的“響應(yīng)電路”上。這個電路會感知中斷的到來,然后會使CPU停止當(dāng)前的執(zhí)行流,保存當(dāng)前的狀態(tài),然后跳轉(zhuǎn)到一個預(yù)定義的地址處去執(zhí)行,這個地址上會有一個中斷處理例程。當(dāng)中斷處理例程完成它的工作后,CPU就從之前停止的地方恢復(fù)執(zhí)行。
軟中斷的原理類似,但實際上有一點不同。CPU支持特殊的指令允許通過軟件來模擬一個中斷。當(dāng)執(zhí)行到這個指令時,CPU將其當(dāng)做一個中斷——停止當(dāng)前正常的執(zhí)行流,保存狀態(tài)然后跳轉(zhuǎn)到一個處理例程中執(zhí)行。這種“陷阱”讓許多現(xiàn)代的操作系統(tǒng)得以有效完成很多復(fù)雜任務(wù)(任務(wù)調(diào)度、虛擬內(nèi)存、內(nèi)存保護(hù)、調(diào)試等)。
一些編程錯誤(比如除0操作)也被CPU當(dāng)做一個“陷阱”,通常被認(rèn)為是“異常”。這里軟中斷同硬件中斷之間的界限就變得模糊了,因為這里很難說這種異常到底是硬件中斷還是軟中斷引起的。我有些偏離主題了,讓我們回到關(guān)于斷點的討論上來。
關(guān)于int 3指令
看過前一節(jié)后,現(xiàn)在我可以簡單地說斷點就是通過CPU的特殊指令——int 3來實現(xiàn)的。int就是x86體系結(jié)構(gòu)中的“陷阱指令”——對預(yù)定義的中斷處理例程的調(diào)用。x86支持int指令帶有一個8位的操作數(shù),用來指定所發(fā)生的中斷號。因此,理論上可以支持256種“陷阱”。前32個由CPU自己保留,這里第3號就是我們感興趣的——稱為“trap to debugger”。
不多說了,我這里就引用“圣經(jīng)”中的原話吧(這里的圣經(jīng)就是Intel’s Architecture software developer’s manual, volume2A):
“INT 3指令產(chǎn)生一個特殊的單字節(jié)操作碼(CC),這是用來調(diào)用調(diào)試異常處理例程的。(這個單字節(jié)形式非常有價值,因為這樣可以通過一個斷點來替換掉任何指令的第一個字節(jié),包括其它的單字節(jié)指令也是一樣,而不會覆蓋到其它的操作碼)。”
上面這段話非常重要,但現(xiàn)在解釋它還是太早,我們稍后再來看。
使用int 3指令
是的,懂得事物背后的原理是很棒的,但是這到底意味著什么?我們該如何使用int 3來實現(xiàn)斷點機(jī)制?套用常見的編程問答中出現(xiàn)的對話——請用代碼說話!
實際上這真的非常簡單。一旦你的進(jìn)程執(zhí)行到int 3指令時,操作系統(tǒng)就將它暫停。在Linux上(本文關(guān)注的是Linux平臺),這會給該進(jìn)程發(fā)送一個SIGTRAP信號。
這就是全部——真的!現(xiàn)在回顧一下本系列文章的第一篇,跟蹤(調(diào)試器)進(jìn)程可以獲得所有其子進(jìn)程(或者被關(guān)聯(lián)到的進(jìn)程)所得到信號的通知,現(xiàn)在你知道我們該做什么了吧?
就是這樣,再沒有什么計算機(jī)體系結(jié)構(gòu)方面的東東了,該寫代碼了。
手動設(shè)定斷點
現(xiàn)在我要展示如何在程序中設(shè)定斷點。用于這個示例的目標(biāo)程序如下:
??
section .text ; The _start symbol must be declared forthe linker (ld) global _start _start: ; Prepare arguments forthe sys_write systemcall: ; - eax: systemcall number (sys_write) ; - ebx: file descriptor (stdout) ; - ecx: pointer to string ; - edx: string length mov edx, len1 mov ecx, msg1 mov ebx, 1 mov eax, 4 ; Execute the sys_write systemcall int 0x80 ; Now print the other message mov edx, len2 mov ecx, msg2 mov ebx, 1 mov eax, 4 int 0x80 ; Execute sys_exit mov eax, 1 int 0x80 section .data msg1 db 'Hello,', 0xalen1 equ $ - msg1msg2 db 'world!', 0xalen2 equ $ - msg2
?
我現(xiàn)在使用的是匯編語言,這是為了避免當(dāng)使用C語言時涉及到的編譯和符號的問題。上面列出的程序功能就是在一行中打印“Hello,”,然后在下一行中打印“world!”。這個例子與上一篇文章中用到的例子很相似。
我希望設(shè)定的斷點位置應(yīng)該在第一條打印之后,但恰好在第二條打印之前。我們就讓斷點打在第一個int 0×80指令之后吧,也就是mov edx, len2。首先,我需要知道這條指令對應(yīng)的地址是什么。運行objdump –d:
?
traced_printer2: file format elf32-i386Sections:Idx Name Size VMA LMA File off Algn 0 .text 00000033 08048080 08048080 00000080 2**4 CONTENTS, ALLOC, LOAD, READONLY, CODE 1 .data 0000000e 080490b4 080490b4 000000b4 2**2 CONTENTS, ALLOC, LOAD, DATADisassembly of section .text:08048080 <.text>: 8048080: ba 07 00 00 00 mov $0x7,%edx 8048085: b9 b4 90 04 08 mov $0x80490b4,%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: ba 07 00 00 00 mov $0x7,%edx 804809b: b9 bb 90 04 08 mov $0x80490bb,%ecx 80480a0: bb 01 00 00 00 mov $0x1,%ebx 80480a5: b8 04 00 00 00 mov $0x4,%eax 80480aa: cd 80 int $0x80 80480ac: b8 01 00 00 00 mov $0x1,%eax 80480b1: cd 80 int $0x80
?
通過上面的輸出,我們知道要設(shè)定的斷點地址是0×8048096。等等,真正的調(diào)試器不是像這樣工作的,對吧?真正的調(diào)試器可以根據(jù)代碼行數(shù)或者函數(shù)名稱來設(shè)定斷點,而不是基于什么內(nèi)存地址吧?非常正確。但是我們離那個標(biāo)準(zhǔn)還差的遠(yuǎn)——如果要像真正的調(diào)試器那樣設(shè)定斷點,我們還需要涵蓋符號表以及調(diào)試信息方面的知識,這需要用另一篇文章來說明。至于現(xiàn)在,我們還必須得通過內(nèi)存地址來設(shè)定斷點。
看到這里我真的很想再扯一點題外話,所以你有兩個選擇。如果你真的對于為什么地址是0×8048096,以及這代表什么意思非常感興趣的話,接著看下一節(jié)。如果你對此毫無興趣,只是想看看怎么設(shè)定斷點,可以略過這一部分。
題外話——進(jìn)程地址空間以及入口點
坦白的說,0×8048096本身并沒有太大意義,這只不過是相對可執(zhí)行鏡像的代碼段(text section)開始處的一個偏移量。如果你仔細(xì)看看前面objdump出來的結(jié)果,你會發(fā)現(xiàn)代碼段的起始位置是0×08048080。這告訴了操作系統(tǒng)要將代碼段映射到進(jìn)程虛擬地址空間的這個位置上。在Linux上,這些地址可以是絕對地址(比如,有的可執(zhí)行鏡像加載到內(nèi)存中時是不可重定位的),因為在虛擬內(nèi)存系統(tǒng)中,每個進(jìn)程都有自己獨立的內(nèi)存空間,并把整個32位的地址空間都看做是屬于自己的(稱為線性地址)。
如果我們通過readelf工具來檢查可執(zhí)行文件的ELF頭,我們將得到如下輸出:
$ readelf -h traced_printer2ELF Header: Magic: 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00 Class: ELF32 Data: 2's complement, little endian Version: 1 (current) OS/ABI: UNIX - System V ABI Version: 0 Type: EXEC (Executable file) Machine: Intel 80386 Version: 0x1 Entry point address: 0x8048080 Start of program headers: 52 (bytes into file) Start of section headers: 220 (bytes into file) Flags: 0x0 Size of this header: 52 (bytes) Size of program headers: 32 (bytes) Number of program headers: 2 Size of section headers: 40 (bytes) Number of section headers: 4 Section header string table index: 3
注意,ELF頭的“entry point address”同樣指向的是0×8048080。因此,如果我們把ELF文件中的這個部分解釋給操作系統(tǒng)的話,就表示:
1. ?將代碼段映射到地址0×8048080處
2. ?從入口點處開始執(zhí)行——地址0×8048080
但是,為什么是0×8048080呢?它的出現(xiàn)是由于歷史原因引起的。每個進(jìn)程的地址空間的前128MB被保留給棧空間了(注:這一部分原因可參考Linkers and Loaders)。128MB剛好是0×80000000,可執(zhí)行鏡像中的其他段可以從這里開始。0×8048080是Linux下的鏈接器ld所使用的默認(rèn)入口點。這個入口點可以通過傳遞參數(shù)-Ttext給ld來進(jìn)行修改。
因此,得到的結(jié)論是這個地址并沒有什么特別的,我們可以自由地修改它。只要ELF可執(zhí)行文件的結(jié)構(gòu)正確且在ELF頭中的入口點地址同程序代碼段(text section)的實際起始地址相吻合就OK了。
通過int 3指令在調(diào)試器中設(shè)定斷點
要在被調(diào)試進(jìn)程中的某個目標(biāo)地址上設(shè)定一個斷點,調(diào)試器需要做下面兩件事情:
1. ?保存目標(biāo)地址上的數(shù)據(jù)
2. ?將目標(biāo)地址上的第一個字節(jié)替換為int 3指令
然后,當(dāng)調(diào)試器向操作系統(tǒng)請求開始運行進(jìn)程時(通過前一篇文章中提到的PTRACE_CONT),進(jìn)程最終一定會碰到int 3指令。此時進(jìn)程停止,操作系統(tǒng)將發(fā)送一個信號。這時就是調(diào)試器再次出馬的時候了,接收到一個其子進(jìn)程(或被跟蹤進(jìn)程)停止的信號,然后調(diào)試器要做下面幾件事:
1. ?在目標(biāo)地址上用原來的指令替換掉int 3
2. ?將被跟蹤進(jìn)程中的指令指針向后遞減1。這么做是必須的,因為現(xiàn)在指令指針指向的是已經(jīng)執(zhí)行過的int 3之后的下一條指令。
3. ?由于進(jìn)程此時仍然是停止的,用戶可以同被調(diào)試進(jìn)程進(jìn)行某種形式的交互。這里調(diào)試器可以讓你查看變量的值,檢查調(diào)用棧等等。
4. ?當(dāng)用戶希望進(jìn)程繼續(xù)運行時,調(diào)試器負(fù)責(zé)將斷點再次加到目標(biāo)地址上(由于在第一步中斷點已經(jīng)被移除了),除非用戶希望取消斷點。
讓我們看看這些步驟如何轉(zhuǎn)化為實際的代碼。我們將沿用第一篇文章中展示過的調(diào)試器“模版”(fork一個子進(jìn)程,然后對其跟蹤)。無論如何,本文結(jié)尾處會給出完整源碼的鏈接。
/* Obtain and show child's instruction pointer */ptrace(PTRACE_GETREGS, child_pid, 0, ?s);procmsg("Child started. EIP = 0x%08x\n", regs.eip); /* Look at the word at the address we're interested in */unsigned addr = 0x8048096;unsigned data = ptrace(PTRACE_PEEKTEXT, child_pid, (void*)addr, 0);procmsg("Original data at 0x%08x: 0x%08x\n", addr, data);
[13028] Child started. EIP = 0x08048080[13028] Original data at 0x08048096: 0x000007ba
目前為止一切順利,下一步:
/* Write the trap instruction 'int 3' into the address */unsigned data_with_trap = (data & 0xFFFFFF00) | 0xCC;ptrace(PTRACE_POKETEXT, child_pid, (void*)addr, (void*)data_with_trap); /* See what's there again... */unsigned readback_data = ptrace(PTRACE_PEEKTEXT, child_pid, (void*)addr, 0);procmsg("After trap, data at 0x%08x: 0x%08x\n", addr, readback_data);
注意看我們是如何將int 3指令插入到目標(biāo)地址上的。這部分代碼將打印出:
[13028] After trap, data at 0x08048096: 0x000007cc
再一次如同預(yù)計的那樣——0xba被0xcc取代了。調(diào)試器現(xiàn)在運行子進(jìn)程然后等待子進(jìn)程在斷點處停止住。
/* Let the child run to the breakpoint and wait for it to** reach it*/ptrace(PTRACE_CONT, child_pid, 0, 0);wait(&wait_status);if(WIFSTOPPED(wait_status)) { procmsg("Child got a signal: %s\n", strsignal(WSTOPSIG(wait_status)));} else { perror("wait"); return;}/* See where the child is now */ptrace(PTRACE_GETREGS, child_pid, 0, ?s);procmsg("Child stopped at EIP = 0x%08x\n", regs.eip);
這段代碼打印出:
Hello,[13028] Child got a signal: Trace/breakpoint trap[13028] Child stopped at EIP = 0x08048097
注意,“Hello,”在斷點之前打印出來了——同我們計劃的一樣。同時我們發(fā)現(xiàn)子進(jìn)程已經(jīng)停止運行了——就在這個單字節(jié)的陷阱指令執(zhí)行之后。
/* Remove the breakpoint by restoring the previous data** at the target address, and unwind the EIP back by 1 to** let the CPU execute the original instruction that was** there.*/ptrace(PTRACE_POKETEXT, child_pid, (void*)addr, (void*)data);regs.eip -= 1;ptrace(PTRACE_SETREGS, child_pid, 0, ?s); /* The child can continue running now */ptrace(PTRACE_CONT, child_pid, 0, 0);
這會使子進(jìn)程打印出“world!”然后退出,同之前計劃的一樣。
注意,我們這里并沒有重新加載斷點。這可以在單步模式下執(zhí)行,然后將陷阱指令加回去,再做PTRACE_CONT就可以了。本文稍后介紹的debug庫實現(xiàn)了這個功能。
更多關(guān)于int 3指令
現(xiàn)在是回過頭來說說int 3指令的好機(jī)會,以及解釋一下Intel手冊中對這條指令的奇怪說明。
“這個單字節(jié)形式非常有價值,因為這樣可以通過一個斷點來替換掉任何指令的第一個字節(jié),包括其它的單字節(jié)指令也是一樣,而不會覆蓋到其它的操作碼。”
x86架構(gòu)上的int指令占用2個字節(jié)——0xcd加上中斷號。int 3的二進(jìn)制形式可以被編碼為cd 03,但這里有一個特殊的單字節(jié)指令0xcc以同樣的作用而被保留。為什么要這樣做呢?因為這允許我們在插入一個斷點時覆蓋到的指令不會多于一條。這很重要,考慮下面的示例代碼:
.. some code .. jz foo dec eaxfoo: call bar .. some code ..
假設(shè)我們要在dec eax上設(shè)定斷點。這恰好是條單字節(jié)指令(操作碼是0×48)。如果替換為斷點的指令長度超過1字節(jié),我們就被迫改寫了接下來的下一條指令(call),這可能會產(chǎn)生一些完全非法的行為。考慮一下條件分支jz foo,這時進(jìn)程可能不會在dec eax處停止下來(我們在此設(shè)定的斷點,改寫了原來的指令),而是直接執(zhí)行了后面的非法指令。
通過對int 3指令采用一個特殊的單字節(jié)編碼就能解決這個問題。因為x86架構(gòu)上指令最短的長度就是1字節(jié),這樣我們可以保證只有我們希望停止的那條指令被修改。
封裝細(xì)節(jié)
前面幾節(jié)中的示例代碼展示了許多底層的細(xì)節(jié),這些可以很容易地通過API進(jìn)行封裝。我已經(jīng)做了一些封裝,使其成為一個小型的調(diào)試庫——debuglib。代碼在本文末尾處可以下載。這里我只想介紹下它的用法,我們要開始調(diào)試C程序了。
跟蹤C(jī)程序
目前為止為了簡單起見我把重點放在對匯編程序的跟蹤上了。現(xiàn)在升一級來看看我們該如何跟蹤一個C程序。
其實事情并沒有很大的不同——只是現(xiàn)在有點難以找到放置斷點的位置。考慮如下這個簡單的C程序:
#include
假設(shè)我想在do_stuff的入口處設(shè)置一個斷點。我將請出我們的老朋友objdump來反匯編可執(zhí)行文件,但得到的輸出太多。其實,查看text段不太管用,因為這里面包含了大量的初始化C運行時庫的代碼,我目前對此并不感興趣。所以,我們只需要在dump出來的結(jié)果里看do_stuff部分就好了。
080483e4
好的,所以我們應(yīng)該把斷點設(shè)定在0x080483e4上,這是do_stuff的第一條指令。另外,由于這個函數(shù)是在循環(huán)體中調(diào)用的,我們希望在循環(huán)全部結(jié)束前保留斷點,讓程序可以在每一輪循環(huán)中都在斷點處停下。我將使用debuglib來簡化代碼編寫。這里是完整的調(diào)試器函數(shù):
void run_debugger(pid_t child_pid){ procmsg("debugger started\n"); /* Wait for child to stop on its first instruction */ wait(0); procmsg("child now at EIP = 0x%08x\n", get_child_eip(child_pid)); /* Create breakpoint and run to it*/ debug_breakpoint* bp = create_breakpoint(child_pid, (void*)0x080483e4); procmsg("breakpoint created\n"); ptrace(PTRACE_CONT, child_pid, 0, 0); wait(0); /* Loop as long as the child didn't exit */ while(1) { /* The child is stopped at a breakpoint here. Resume its ** execution until it either exits or hits the ** breakpoint again. */ procmsg("child stopped at breakpoint. EIP = 0x%08X\n", get_child_eip(child_pid)); procmsg("resuming\n"); intrc = resume_from_breakpoint(child_pid, bp); if(rc == 0) { procmsg("child exited\n"); break; } elseif (rc == 1) { continue; } else { procmsg("unexpected: %d\n", rc); break; } } cleanup_breakpoint(bp);}
我們不用手動修改EIP指針以及目標(biāo)進(jìn)程的內(nèi)存空間,我們只需要通過create_breakpoint, resume_from_breakpoint以及cleanup_breakpoint來操作就可以了。我們來看看當(dāng)跟蹤這個簡單的C程序后的打印輸出:
$ bp_use_lib traced_c_loop[13363] debugger started[13364] target started. will run 'traced_c_loop'[13363] child now at EIP = 0x00a37850[13363] breakpoint created[13363] child stopped at breakpoint. EIP = 0x080483E5[13363] resumingHello,[13363] child stopped at breakpoint. EIP = 0x080483E5[13363] resumingHello,[13363] child stopped at breakpoint. EIP = 0x080483E5[13363] resumingHello,[13363] child stopped at breakpoint. EIP = 0x080483E5[13363] resumingHello,world![13363] child exited
跟預(yù)計的情況一模一樣!
代碼
這里是完整的源碼。在文件夾中你會發(fā)現(xiàn):
debuglib.h以及debuglib.c——封裝了調(diào)試器的一些內(nèi)部工作。
bp_manual.c —— 本文一開始介紹的“手動”式設(shè)定斷點。用到了debuglib庫中的一些樣板代碼。
bp_use_lib.c—— 大部分代碼用到了debuglib,這就是本文中用于說明跟蹤一個C程序中的循環(huán)的示例代碼。
結(jié)論及下一步要做的
我們已經(jīng)涵蓋了如何在調(diào)試器中實現(xiàn)斷點機(jī)制。盡管實現(xiàn)細(xì)節(jié)根據(jù)操作系統(tǒng)的不同而有所區(qū)別,但只要你使用的是x86架構(gòu)的處理器,那么一切變化都基于相同的主題——在我們希望停止的指令上將其替換為int 3。
評論