說(shuō)明調(diào)試器中的斷點(diǎn)機(jī)制是如何實(shí)現(xiàn)的。斷點(diǎn)機(jī)制是調(diào)試器的兩大主要支柱之一 ——另一個(gè)是在被調(diào)試進(jìn)程的內(nèi)存空間中查看變量的值。我們已經(jīng)在第一篇文章中稍微涉及到了一些監(jiān)視被調(diào)試進(jìn)程的知識(shí),但斷點(diǎn)機(jī)制仍然還是個(gè)迷。
軟中斷
要在x86體系結(jié)構(gòu)上實(shí)現(xiàn)斷點(diǎn)我們要用到軟中斷(也稱為“陷阱”trap)。在我們深入細(xì)節(jié)之前,我想先大致解釋一下中斷和陷阱的概念。
CPU有一個(gè)單獨(dú)的執(zhí)行序列,會(huì)一條指令一條指令的順序執(zhí)行。要處理類似IO或者硬件時(shí)鐘這樣的異步事件時(shí)CPU就要用到中斷。硬件中斷通常是一個(gè)專門的電信號(hào),連接到一個(gè)特殊的“響應(yīng)電路”上。這個(gè)電路會(huì)感知中斷的到來(lái),然后會(huì)使CPU停止當(dāng)前的執(zhí)行流,保存當(dāng)前的狀態(tài),然后跳轉(zhuǎn)到一個(gè)預(yù)定義的地址處去執(zhí)行,這個(gè)地址上會(huì)有一個(gè)中斷處理例程。當(dāng)中斷處理例程完成它的工作后,CPU就從之前停止的地方恢復(fù)執(zhí)行。
軟中斷的原理類似,但實(shí)際上有一點(diǎn)不同。CPU支持特殊的指令允許通過(guò)軟件來(lái)模擬一個(gè)中斷。當(dāng)執(zhí)行到這個(gè)指令時(shí),CPU將其當(dāng)做一個(gè)中斷——停止當(dāng)前正常的執(zhí)行流,保存狀態(tài)然后跳轉(zhuǎn)到一個(gè)處理例程中執(zhí)行。這種“陷阱”讓許多現(xiàn)代的操作系統(tǒng)得以有效完成很多復(fù)雜任務(wù)(任務(wù)調(diào)度、虛擬內(nèi)存、內(nèi)存保護(hù)、調(diào)試等)。
一些編程錯(cuò)誤(比如除0操作)也被CPU當(dāng)做一個(gè)“陷阱”,通常被認(rèn)為是“異常”。這里軟中斷同硬件中斷之間的界限就變得模糊了,因?yàn)檫@里很難說(shuō)這種異常到底是硬件中斷還是軟中斷引起的。我有些偏離主題了,讓我們回到關(guān)于斷點(diǎn)的討論上來(lái)。
關(guān)于int 3指令
看過(guò)前一節(jié)后,現(xiàn)在我可以簡(jiǎn)單地說(shuō)斷點(diǎn)就是通過(guò)CPU的特殊指令——int 3來(lái)實(shí)現(xiàn)的。int就是x86體系結(jié)構(gòu)中的“陷阱指令”——對(duì)預(yù)定義的中斷處理例程的調(diào)用。x86支持int指令帶有一個(gè)8位的操作數(shù),用來(lái)指定所發(fā)生的中斷號(hào)。因此,理論上可以支持256種“陷阱”。前32個(gè)由CPU自己保留,這里第3號(hào)就是我們感興趣的——稱為“trap to debugger”。
不多說(shuō)了,我這里就引用“圣經(jīng)”中的原話吧(這里的圣經(jīng)就是Intel’s Architecture software developer’s manual, volume2A):
“INT 3指令產(chǎn)生一個(gè)特殊的單字節(jié)操作碼(CC),這是用來(lái)調(diào)用調(diào)試異常處理例程的。(這個(gè)單字節(jié)形式非常有價(jià)值,因?yàn)檫@樣可以通過(guò)一個(gè)斷點(diǎn)來(lái)替換掉任何指令的第一個(gè)字節(jié),包括其它的單字節(jié)指令也是一樣,而不會(huì)覆蓋到其它的操作碼)?!?/span>
上面這段話非常重要,但現(xiàn)在解釋它還是太早,我們稍后再來(lái)看。
使用int 3指令
是的,懂得事物背后的原理是很棒的,但是這到底意味著什么?我們?cè)撊绾问褂胕nt 3來(lái)實(shí)現(xiàn)斷點(diǎn)機(jī)制?套用常見(jiàn)的編程問(wèn)答中出現(xiàn)的對(duì)話——請(qǐng)用代碼說(shuō)話!
實(shí)際上這真的非常簡(jiǎn)單。一旦你的進(jìn)程執(zhí)行到int 3指令時(shí),操作系統(tǒng)就將它暫停。在Linux上(本文關(guān)注的是Linux平臺(tái)),這會(huì)給該進(jìn)程發(fā)送一個(gè)SIGTRAP信號(hào)。
這就是全部——真的!現(xiàn)在回顧一下本系列文章的第一篇,跟蹤(調(diào)試器)進(jìn)程可以獲得所有其子進(jìn)程(或者被關(guān)聯(lián)到的進(jìn)程)所得到信號(hào)的通知,現(xiàn)在你知道我們?cè)撟鍪裁戳税桑?/p>
就是這樣,再?zèng)]有什么計(jì)算機(jī)體系結(jié)構(gòu)方面的東東了,該寫代碼了。
手動(dòng)設(shè)定斷點(diǎn)
現(xiàn)在我要展示如何在程序中設(shè)定斷點(diǎn)。用于這個(gè)示例的目標(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)在使用的是匯編語(yǔ)言,這是為了避免當(dāng)使用C語(yǔ)言時(shí)涉及到的編譯和符號(hào)的問(wèn)題。上面列出的程序功能就是在一行中打印“Hello,”,然后在下一行中打印“world!”。這個(gè)例子與上一篇文章中用到的例子很相似。
我希望設(shè)定的斷點(diǎn)位置應(yīng)該在第一條打印之后,但恰好在第二條打印之前。我們就讓斷點(diǎn)打在第一個(gè)int 0×80指令之后吧,也就是mov edx, len2。首先,我需要知道這條指令對(duì)應(yīng)的地址是什么。運(yùn)行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
?
通過(guò)上面的輸出,我們知道要設(shè)定的斷點(diǎn)地址是0×8048096。等等,真正的調(diào)試器不是像這樣工作的,對(duì)吧?真正的調(diào)試器可以根據(jù)代碼行數(shù)或者函數(shù)名稱來(lái)設(shè)定斷點(diǎn),而不是基于什么內(nèi)存地址吧?非常正確。但是我們離那個(gè)標(biāo)準(zhǔn)還差的遠(yuǎn)——如果要像真正的調(diào)試器那樣設(shè)定斷點(diǎn),我們還需要涵蓋符號(hào)表以及調(diào)試信息方面的知識(shí),這需要用另一篇文章來(lái)說(shuō)明。至于現(xiàn)在,我們還必須得通過(guò)內(nèi)存地址來(lái)設(shè)定斷點(diǎn)。
看到這里我真的很想再扯一點(diǎn)題外話,所以你有兩個(gè)選擇。如果你真的對(duì)于為什么地址是0×8048096,以及這代表什么意思非常感興趣的話,接著看下一節(jié)。如果你對(duì)此毫無(wú)興趣,只是想看看怎么設(shè)定斷點(diǎn),可以略過(guò)這一部分。
題外話——進(jìn)程地址空間以及入口點(diǎn)
坦白的說(shuō),0×8048096本身并沒(méi)有太大意義,這只不過(guò)是相對(duì)可執(zhí)行鏡像的代碼段(text section)開(kāi)始處的一個(gè)偏移量。如果你仔細(xì)看看前面objdump出來(lái)的結(jié)果,你會(huì)發(fā)現(xiàn)代碼段的起始位置是0×08048080。這告訴了操作系統(tǒng)要將代碼段映射到進(jìn)程虛擬地址空間的這個(gè)位置上。在Linux上,這些地址可以是絕對(duì)地址(比如,有的可執(zhí)行鏡像加載到內(nèi)存中時(shí)是不可重定位的),因?yàn)樵谔摂M內(nèi)存系統(tǒng)中,每個(gè)進(jìn)程都有自己獨(dú)立的內(nèi)存空間,并把整個(gè)32位的地址空間都看做是屬于自己的(稱為線性地址)。
如果我們通過(guò)readelf工具來(lái)檢查可執(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文件中的這個(gè)部分解釋給操作系統(tǒng)的話,就表示:
1. ?將代碼段映射到地址0×8048080處
2. ?從入口點(diǎn)處開(kāi)始執(zhí)行——地址0×8048080
但是,為什么是0×8048080呢?它的出現(xiàn)是由于歷史原因引起的。每個(gè)進(jìn)程的地址空間的前128MB被保留給棧空間了(注:這一部分原因可參考Linkers and Loaders)。128MB剛好是0×80000000,可執(zhí)行鏡像中的其他段可以從這里開(kāi)始。0×8048080是Linux下的鏈接器ld所使用的默認(rèn)入口點(diǎn)。這個(gè)入口點(diǎn)可以通過(guò)傳遞參數(shù)-Ttext給ld來(lái)進(jìn)行修改。
因此,得到的結(jié)論是這個(gè)地址并沒(méi)有什么特別的,我們可以自由地修改它。只要ELF可執(zhí)行文件的結(jié)構(gòu)正確且在ELF頭中的入口點(diǎn)地址同程序代碼段(text section)的實(shí)際起始地址相吻合就OK了。
通過(guò)int 3指令在調(diào)試器中設(shè)定斷點(diǎn)
要在被調(diào)試進(jìn)程中的某個(gè)目標(biāo)地址上設(shè)定一個(gè)斷點(diǎn),調(diào)試器需要做下面兩件事情:
1. ?保存目標(biāo)地址上的數(shù)據(jù)
2. ?將目標(biāo)地址上的第一個(gè)字節(jié)替換為int 3指令
然后,當(dāng)調(diào)試器向操作系統(tǒng)請(qǐng)求開(kāi)始運(yùn)行進(jìn)程時(shí)(通過(guò)前一篇文章中提到的PTRACE_CONT),進(jìn)程最終一定會(huì)碰到int 3指令。此時(shí)進(jìn)程停止,操作系統(tǒng)將發(fā)送一個(gè)信號(hào)。這時(shí)就是調(diào)試器再次出馬的時(shí)候了,接收到一個(gè)其子進(jìn)程(或被跟蹤進(jìn)程)停止的信號(hào),然后調(diào)試器要做下面幾件事:
1. ?在目標(biāo)地址上用原來(lái)的指令替換掉int 3
2. ?將被跟蹤進(jìn)程中的指令指針向后遞減1。這么做是必須的,因?yàn)楝F(xiàn)在指令指針指向的是已經(jīng)執(zhí)行過(guò)的int 3之后的下一條指令。
3. ?由于進(jìn)程此時(shí)仍然是停止的,用戶可以同被調(diào)試進(jìn)程進(jìn)行某種形式的交互。這里調(diào)試器可以讓你查看變量的值,檢查調(diào)用棧等等。
4. ?當(dāng)用戶希望進(jìn)程繼續(xù)運(yùn)行時(shí),調(diào)試器負(fù)責(zé)將斷點(diǎn)再次加到目標(biāo)地址上(由于在第一步中斷點(diǎn)已經(jīng)被移除了),除非用戶希望取消斷點(diǎn)。
讓我們看看這些步驟如何轉(zhuǎn)化為實(shí)際的代碼。我們將沿用第一篇文章中展示過(guò)的調(diào)試器“模版”(fork一個(gè)子進(jìn)程,然后對(duì)其跟蹤)。無(wú)論如何,本文結(jié)尾處會(huì)給出完整源碼的鏈接。
/* 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ù)計(jì)的那樣——0xba被0xcc取代了。調(diào)試器現(xiàn)在運(yùn)行子進(jìn)程然后等待子進(jìn)程在斷點(diǎ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,”在斷點(diǎn)之前打印出來(lái)了——同我們計(jì)劃的一樣。同時(shí)我們發(fā)現(xiàn)子進(jìn)程已經(jīng)停止運(yùn)行了——就在這個(gè)單字節(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);
這會(huì)使子進(jìn)程打印出“world!”然后退出,同之前計(jì)劃的一樣。
注意,我們這里并沒(méi)有重新加載斷點(diǎn)。這可以在單步模式下執(zhí)行,然后將陷阱指令加回去,再做PTRACE_CONT就可以了。本文稍后介紹的debug庫(kù)實(shí)現(xiàn)了這個(gè)功能。
更多關(guān)于int 3指令
現(xiàn)在是回過(guò)頭來(lái)說(shuō)說(shuō)int 3指令的好機(jī)會(huì),以及解釋一下Intel手冊(cè)中對(duì)這條指令的奇怪說(shuō)明。
“這個(gè)單字節(jié)形式非常有價(jià)值,因?yàn)檫@樣可以通過(guò)一個(gè)斷點(diǎn)來(lái)替換掉任何指令的第一個(gè)字節(jié),包括其它的單字節(jié)指令也是一樣,而不會(huì)覆蓋到其它的操作碼?!?/p>
x86架構(gòu)上的int指令占用2個(gè)字節(jié)——0xcd加上中斷號(hào)。int 3的二進(jìn)制形式可以被編碼為cd 03,但這里有一個(gè)特殊的單字節(jié)指令0xcc以同樣的作用而被保留。為什么要這樣做呢?因?yàn)檫@允許我們?cè)诓迦胍粋€(gè)斷點(diǎn)時(shí)覆蓋到的指令不會(huì)多于一條。這很重要,考慮下面的示例代碼:
.. some code .. jz foo dec eaxfoo: call bar .. some code ..
假設(shè)我們要在dec eax上設(shè)定斷點(diǎn)。這恰好是條單字節(jié)指令(操作碼是0×48)。如果替換為斷點(diǎn)的指令長(zhǎng)度超過(guò)1字節(jié),我們就被迫改寫了接下來(lái)的下一條指令(call),這可能會(huì)產(chǎn)生一些完全非法的行為??紤]一下條件分支jz foo,這時(shí)進(jìn)程可能不會(huì)在dec eax處停止下來(lái)(我們?cè)诖嗽O(shè)定的斷點(diǎn),改寫了原來(lái)的指令),而是直接執(zhí)行了后面的非法指令。
通過(guò)對(duì)int 3指令采用一個(gè)特殊的單字節(jié)編碼就能解決這個(gè)問(wèn)題。因?yàn)閤86架構(gòu)上指令最短的長(zhǎng)度就是1字節(jié),這樣我們可以保證只有我們希望停止的那條指令被修改。
封裝細(xì)節(jié)
前面幾節(jié)中的示例代碼展示了許多底層的細(xì)節(jié),這些可以很容易地通過(guò)API進(jìn)行封裝。我已經(jīng)做了一些封裝,使其成為一個(gè)小型的調(diào)試庫(kù)——debuglib。代碼在本文末尾處可以下載。這里我只想介紹下它的用法,我們要開(kāi)始調(diào)試C程序了。
跟蹤C(jī)程序
目前為止為了簡(jiǎn)單起見(jiàn)我把重點(diǎn)放在對(duì)匯編程序的跟蹤上了?,F(xiàn)在升一級(jí)來(lái)看看我們?cè)撊绾胃櫼粋€(gè)C程序。
其實(shí)事情并沒(méi)有很大的不同——只是現(xiàn)在有點(diǎn)難以找到放置斷點(diǎn)的位置??紤]如下這個(gè)簡(jiǎn)單的C程序:
#include
假設(shè)我想在do_stuff的入口處設(shè)置一個(gè)斷點(diǎn)。我將請(qǐng)出我們的老朋友objdump來(lái)反匯編可執(zhí)行文件,但得到的輸出太多。其實(shí),查看text段不太管用,因?yàn)檫@里面包含了大量的初始化C運(yùn)行時(shí)庫(kù)的代碼,我目前對(duì)此并不感興趣。所以,我們只需要在dump出來(lái)的結(jié)果里看do_stuff部分就好了。
080483e4
好的,所以我們應(yīng)該把斷點(diǎn)設(shè)定在0x080483e4上,這是do_stuff的第一條指令。另外,由于這個(gè)函數(shù)是在循環(huán)體中調(diào)用的,我們希望在循環(huán)全部結(jié)束前保留斷點(diǎn),讓程序可以在每一輪循環(huán)中都在斷點(diǎn)處停下。我將使用debuglib來(lái)簡(jiǎn)化代碼編寫。這里是完整的調(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);}
我們不用手動(dòng)修改EIP指針以及目標(biāo)進(jìn)程的內(nèi)存空間,我們只需要通過(guò)create_breakpoint, resume_from_breakpoint以及cleanup_breakpoint來(lái)操作就可以了。我們來(lái)看看當(dāng)跟蹤這個(gè)簡(jiǎn)單的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ù)計(jì)的情況一模一樣!
代碼
這里是完整的源碼。在文件夾中你會(huì)發(fā)現(xiàn):
debuglib.h以及debuglib.c——封裝了調(diào)試器的一些內(nèi)部工作。
bp_manual.c —— 本文一開(kāi)始介紹的“手動(dòng)”式設(shè)定斷點(diǎn)。用到了debuglib庫(kù)中的一些樣板代碼。
bp_use_lib.c—— 大部分代碼用到了debuglib,這就是本文中用于說(shuō)明跟蹤一個(gè)C程序中的循環(huán)的示例代碼。
結(jié)論及下一步要做的
我們已經(jīng)涵蓋了如何在調(diào)試器中實(shí)現(xiàn)斷點(diǎn)機(jī)制。盡管實(shí)現(xiàn)細(xì)節(jié)根據(jù)操作系統(tǒng)的不同而有所區(qū)別,但只要你使用的是x86架構(gòu)的處理器,那么一切變化都基于相同的主題——在我們希望停止的指令上將其替換為int 3。
評(píng)論