本文是關於偵錯程式工作原理探究系列的第二篇。在開始閱讀本文前,請先確保你已經讀過本系列的第一篇(基礎篇)。
本文的主要內容
這裡我將說明偵錯程式中的斷點機制是如何實現的。斷點機制是偵錯程式的兩大主要支柱之一 ——另一個是在被除錯程式的記憶體空間中檢視變數的值。我們已經在第一篇文章中稍微涉及到了一些監視被除錯程式的知識,但斷點機制仍然還是個迷。閱讀完本文之後,這將不再是什麼祕密了。
軟中斷
要在x86體系結構上實現斷點我們要用到軟中斷(也稱為“陷阱”trap)。在我們深入細節之前,我想先大致解釋一下中斷和陷阱的概念。
CPU有一個單獨的執行序列,會一條指令一條指令的順序執行。要處理類似IO或者硬體時鐘這樣的非同步事件時CPU就要用到中斷。硬體中斷通常是一個專門的電訊號,連線到一個特殊的“響應電路”上。這個電路會感知中斷的到來,然後會使CPU停止當前的執行流,儲存當前的狀態,然後跳轉到一個預定義的地址處去執行,這個地址上會有一箇中斷處理例程。當中斷處理例程完成它的工作後,CPU就從之前停止的地方恢復執行。
軟中斷的原理類似,但實際上有一點不同。CPU支援特殊的指令允許通過軟體來模擬一箇中斷。當執行到這個指令時,CPU將其當做一箇中斷——停止當前正常的執行流,儲存狀態然後跳轉到一個處理例程中執行。這種“陷阱”讓許多現代的作業系統得以有效完成很多複雜任務(任務排程、虛擬記憶體、記憶體保護、除錯等)。
一些程式設計錯誤(比如除0操作)也被CPU當做一個“陷阱”,通常被認為是“異常”。這裡軟中斷同硬體中斷之間的界限就變得模糊了,因為這裡很難說這種異常到底是硬體中斷還是軟中斷引起的。我有些偏離主題了,讓我們回到關於斷點的討論上來。
關於int 3指令
看過前一節後,現在我可以簡單地說斷點就是通過CPU的特殊指令——int 3來實現的。int就是x86體系結構中的“陷阱指令”——對預定義的中斷處理例程的呼叫。x86支援int指令帶有一個8位的運算元,用來指定所發生的中斷號。因此,理論上可以支援256種“陷阱”。前32個由CPU自己保留,這裡第3號就是我們感興趣的——稱為“trap to debugger”。
不多說了,我這裡就引用“聖經”中的原話吧(這裡的聖經就是Intel’s Architecture software developer’s manual, volume2A):
“INT 3指令產生一個特殊的單位元組操作碼(CC),這是用來呼叫除錯異常處理例程的。(這個單位元組形式非常有價值,因為這樣可以通過一個斷點來替換掉任何指令的第一個位元組,包括其它的單位元組指令也是一樣,而不會覆蓋到其它的操作碼)。”
上面這段話非常重要,但現在解釋它還是太早,我們稍後再來看。
使用int 3指令
是的,懂得事物背後的原理是很棒的,但是這到底意味著什麼?我們該如何使用int 3來實現斷點機制?套用常見的程式設計問答中出現的對話——請用程式碼說話!
實際上這真的非常簡單。一旦你的程式執行到int 3指令時,作業系統就將它暫停。在Linux上(本文關注的是Linux平臺),這會給該程式傳送一個SIGTRAP訊號。
這就是全部——真的!現在回顧一下本系列文章的第一篇,跟蹤(偵錯程式)程式可以獲得所有其子程式(或者被關聯到的程式)所得到訊號的通知,現在你知道我們該做什麼了吧?
就是這樣,再沒有什麼計算機體系結構方面的東東了,該寫程式碼了。
手動設定斷點
現在我要展示如何在程式中設定斷點。用於這個示例的目標程式如下:
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 31 32 33 34 35 36 |
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, len1 mov ecx, msg1 mov ebx, 1 mov eax, 4 ; Execute the sys_write system call 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,', 0xa len1 equ $ - msg1 msg2 db 'world!', 0xa len2 equ $ - msg2 |
我現在使用的是組合語言,這是為了避免當使用C語言時涉及到的編譯和符號的問題。上面列出的程式功能就是在一行中列印“Hello,”,然後在下一行中列印“world!”。這個例子與上一篇文章中用到的例子很相似。
我希望設定的斷點位置應該在第一條列印之後,但恰好在第二條列印之前。我們就讓斷點打在第一個int 0x80指令之後吧,也就是mov edx, len2。首先,我需要知道這條指令對應的地址是什麼。執行objdump –d:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
traced_printer2: file format elf32-i386 Sections: 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, DATA Disassembly 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 |
通過上面的輸出,我們知道要設定的斷點地址是0x8048096。等等,真正的偵錯程式不是像這樣工作的,對吧?真正的偵錯程式可以根據程式碼行數或者函式名稱來設定斷點,而不是基於什麼記憶體地址吧?非常正確。但是我們離那個標準還差的遠——如果要像真正的偵錯程式那樣設定斷點,我們還需要涵蓋符號表以及除錯資訊方面的知識,這需要用另一篇文章來說明。至於現在,我們還必須得通過記憶體地址來設定斷點。
看到這裡我真的很想再扯一點題外話,所以你有兩個選擇。如果你真的對於為什麼地址是0x8048096,以及這代表什麼意思非常感興趣的話,接著看下一節。如果你對此毫無興趣,只是想看看怎麼設定斷點,可以略過這一部分。
題外話——程式地址空間以及入口點
坦白的說,0x8048096本身並沒有太大意義,這只不過是相對可執行映象的程式碼段(text section)開始處的一個偏移量。如果你仔細看看前面objdump出來的結果,你會發現程式碼段的起始位置是0x08048080。這告訴了作業系統要將程式碼段對映到程式虛擬地址空間的這個位置上。在Linux上,這些地址可以是絕對地址(比如,有的可執行映象載入到記憶體中時是不可重定位的),因為在虛擬記憶體系統中,每個程式都有自己獨立的記憶體空間,並把整個32位的地址空間都看做是屬於自己的(稱為線性地址)。
如果我們通過readelf工具來檢查可執行檔案的ELF頭,我們將得到如下輸出:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
$ readelf -h traced_printer2 ELF 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”同樣指向的是0x8048080。因此,如果我們把ELF檔案中的這個部分解釋給作業系統的話,就表示:
1. 將程式碼段對映到地址0x8048080處
2. 從入口點處開始執行——地址0x8048080
但是,為什麼是0x8048080呢?它的出現是由於歷史原因引起的。每個程式的地址空間的前128MB被保留給棧空間了(注:這一部分原因可參考Linkers and Loaders)。128MB剛好是0x80000000,可執行映象中的其他段可以從這裡開始。0x8048080是Linux下的連結器ld所使用的預設入口點。這個入口點可以通過傳遞引數-Ttext給ld來進行修改。
因此,得到的結論是這個地址並沒有什麼特別的,我們可以自由地修改它。只要ELF可執行檔案的結構正確且在ELF頭中的入口點地址同程式程式碼段(text section)的實際起始地址相吻合就OK了。
通過int 3指令在偵錯程式中設定斷點
要在被除錯程式中的某個目標地址上設定一個斷點,偵錯程式需要做下面兩件事情:
1. 儲存目標地址上的資料
2. 將目標地址上的第一個位元組替換為int 3指令
然後,當偵錯程式向作業系統請求開始執行程式時(通過前一篇文章中提到的PTRACE_CONT),程式最終一定會碰到int 3指令。此時程式停止,作業系統將傳送一個訊號。這時就是偵錯程式再次出馬的時候了,接收到一個其子程式(或被跟蹤程式)停止的訊號,然後偵錯程式要做下面幾件事:
1. 在目標地址上用原來的指令替換掉int 3
2. 將被跟蹤程式中的指令指標向後遞減1。這麼做是必須的,因為現在指令指標指向的是已經執行過的int 3之後的下一條指令。
3. 由於程式此時仍然是停止的,使用者可以同被除錯程式進行某種形式的互動。這裡偵錯程式可以讓你檢視變數的值,檢查呼叫棧等等。
4. 當使用者希望程式繼續執行時,偵錯程式負責將斷點再次加到目標地址上(由於在第一步中斷點已經被移除了),除非使用者希望取消斷點。
讓我們看看這些步驟如何轉化為實際的程式碼。我們將沿用第一篇文章中展示過的偵錯程式“模版”(fork一個子程式,然後對其跟蹤)。無論如何,本文結尾處會給出完整原始碼的連結。
1 2 3 4 5 6 7 8 |
/* 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); |
這裡偵錯程式從被跟蹤程式中獲取到指令指標,然後檢查當前位於地址0x8048096處的字長內容。執行本文前面列出的彙編碼程式,將列印出:
1 2 |
[13028] Child started. EIP = 0x08048080 [13028] Original data at 0x08048096: 0x000007ba |
目前為止一切順利,下一步:
1 2 3 4 5 6 7 |
/* 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指令插入到目標地址上的。這部分程式碼將列印出:
1 |
[13028] After trap, data at 0x08048096: 0x000007cc |
再一次如同預計的那樣——0xba被0xcc取代了。偵錯程式現在執行子程式然後等待子程式在斷點處停止住。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
/* 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); |
這段程式碼列印出:
1 2 3 |
Hello, [13028] Child got a signal: Trace/breakpoint trap [13028] Child stopped at EIP = 0x08048097 |
注意,“Hello,”在斷點之前列印出來了——同我們計劃的一樣。同時我們發現子程式已經停止執行了——就在這個單位元組的陷阱指令執行之後。
1 2 3 4 5 6 7 8 9 10 11 |
/* 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); |
這會使子程式列印出“world!”然後退出,同之前計劃的一樣。
注意,我們這裡並沒有重新載入斷點。這可以在單步模式下執行,然後將陷阱指令加回去,再做PTRACE_CONT就可以了。本文稍後介紹的debug庫實現了這個功能。
更多關於int 3指令
現在是回過頭來說說int 3指令的好機會,以及解釋一下Intel手冊中對這條指令的奇怪說明。
“這個單位元組形式非常有價值,因為這樣可以通過一個斷點來替換掉任何指令的第一個位元組,包括其它的單位元組指令也是一樣,而不會覆蓋到其它的操作碼。”
x86架構上的int指令佔用2個位元組——0xcd加上中斷號。int 3的二進位制形式可以被編碼為cd 03,但這裡有一個特殊的單位元組指令0xcc以同樣的作用而被保留。為什麼要這樣做呢?因為這允許我們在插入一個斷點時覆蓋到的指令不會多於一條。這很重要,考慮下面的示例程式碼:
1 2 3 4 5 6 |
.. some code .. jz foo dec eax foo: call bar .. some code .. |
假設我們要在dec eax上設定斷點。這恰好是條單位元組指令(操作碼是0x48)。如果替換為斷點的指令長度超過1位元組,我們就被迫改寫了接下來的下一條指令(call),這可能會產生一些完全非法的行為。考慮一下條件分支jz foo,這時程式可能不會在dec eax處停止下來(我們在此設定的斷點,改寫了原來的指令),而是直接執行了後面的非法指令。
通過對int 3指令採用一個特殊的單位元組編碼就能解決這個問題。因為x86架構上指令最短的長度就是1位元組,這樣我們可以保證只有我們希望停止的那條指令被修改。
封裝細節
前面幾節中的示例程式碼展示了許多底層的細節,這些可以很容易地通過API進行封裝。我已經做了一些封裝,使其成為一個小型的除錯庫——debuglib。程式碼在本文末尾處可以下載。這裡我只想介紹下它的用法,我們要開始除錯C程式了。
跟蹤C程式
目前為止為了簡單起見我把重點放在對彙編程式的跟蹤上了。現在升一級來看看我們該如何跟蹤一個C程式。
其實事情並沒有很大的不同——只是現在有點難以找到放置斷點的位置。考慮如下這個簡單的C程式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
#include <stdio.h> void do_stuff() { printf("Hello, "); } int main() { for (int i = 0; i < 4; ++i) do_stuff(); printf("world!\n"); return 0; } |
假設我想在do_stuff的入口處設定一個斷點。我將請出我們的老朋友objdump來反彙編可執行檔案,但得到的輸出太多。其實,檢視text段不太管用,因為這裡麵包含了大量的初始化C執行時庫的程式碼,我目前對此並不感興趣。所以,我們只需要在dump出來的結果裡看do_stuff部分就好了。
1 2 3 4 5 6 7 8 |
080483e4 <do_stuff>: 80483e4: 55 push %ebp 80483e5: 89 e5 mov %esp,%ebp 80483e7: 83 ec 18 sub $0x18,%esp 80483ea: c7 04 24 f0 84 04 08 movl $0x80484f0,(%esp) 80483f1: e8 22 ff ff ff call 8048318 <puts@plt> 80483f6: c9 leave 80483f7: c3 ret |
好的,所以我們應該把斷點設定在0x080483e4上,這是do_stuff的第一條指令。另外,由於這個函式是在迴圈體中呼叫的,我們希望在迴圈全部結束前保留斷點,讓程式可以在每一輪迴圈中都在斷點處停下。我將使用debuglib來簡化程式碼編寫。這裡是完整的偵錯程式函式:
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 31 32 33 34 35 36 37 38 39 |
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"); int rc = resume_from_breakpoint(child_pid, bp); if (rc == 0) { procmsg("child exited\n"); break; } else if (rc == 1) { continue; } else { procmsg("unexpected: %d\n", rc); break; } } cleanup_breakpoint(bp); } |
我們不用手動修改EIP指標以及目標程式的記憶體空間,我們只需要通過create_breakpoint, resume_from_breakpoint以及cleanup_breakpoint來操作就可以了。我們來看看當跟蹤這個簡單的C程式後的列印輸出:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
$ 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] resuming Hello, [13363] child stopped at breakpoint. EIP = 0x080483E5 [13363] resuming Hello, [13363] child stopped at breakpoint. EIP = 0x080483E5 [13363] resuming Hello, [13363] child stopped at breakpoint. EIP = 0x080483E5 [13363] resuming Hello, world! [13363] child exited |
跟預計的情況一模一樣!
程式碼
這裡是完整的原始碼。在資料夾中你會發現:
debuglib.h以及debuglib.c——封裝了偵錯程式的一些內部工作。
bp_manual.c —— 本文一開始介紹的“手動”式設定斷點。用到了debuglib庫中的一些樣板程式碼。
bp_use_lib.c—— 大部分程式碼用到了debuglib,這就是本文中用於說明跟蹤一個C程式中的迴圈的示例程式碼。
結論及下一步要做的
我們已經涵蓋了如何在偵錯程式中實現斷點機制。儘管實現細節根據作業系統的不同而有所區別,但只要你使用的是x86架構的處理器,那麼一切變化都基於相同的主題——在我們希望停止的指令上將其替換為int 3。
我敢肯定,有些讀者就像我一樣,對於通過指定原始地址來設定斷點的做法不會感到很激動。我們更希望說“在do_stuff上停住”,甚至是“在do_stuff的這一行上停住”,然後偵錯程式就能照辦。在下一篇文章中,我將向您展示這是如何做到的。