本文的主要内容
這裡我将說明調試器中的斷點機制是如何實作的。斷點機制是調試器的兩大主要支柱之一 ——另一個是在被調試程序的記憶體空間中檢視變量的值。我們已經在第一篇文章中稍微涉及到了一些監視被調試程序的知識,但斷點機制仍然還是個迷。閱讀完本文之後,這将不再是什麼秘密了。
軟中斷
要在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信号。
這就是全部——真的!現在回顧一下本系列文章的第一篇,跟蹤(調試器)程序可以獲得所有其子程序(或者被關聯到的程序)所得到信号的通知,現在你知道我們該做什麼了吧?
就是這樣,再沒有什麼計算機體系結構方面的東東了,該寫代碼了。
手動設定斷點
現在我要展示如何在程式中設定斷點。用于這個示例的目标程式如下:
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 0×80指令之後吧,也就是mov edx, len2。首先,我需要知道這條指令對應的位址是什麼。運作objdump –d:
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
通過上面的輸出,我們知道要設定的斷點位址是0×8048096。等等,真正的調試器不是像這樣工作的,對吧?真正的調試器可以根據代碼行數或者函數名稱來設定斷點,而不是基于什麼記憶體位址吧?非常正确。但是我們離那個标準還差的遠——如果要像真正的調試器那樣設定斷點,我們還需要涵蓋符号表以及調試資訊方面的知識,這需要用另一篇文章來說明。至于現在,我們還必須得通過記憶體位址來設定斷點。
看到這裡我真的很想再扯一點題外話,是以你有兩個選擇。如果你真的對于為什麼位址是0×8048096,以及這代表什麼意思非常感興趣的話,接着看下一節。如果你對此毫無興趣,隻是想看看怎麼設定斷點,可以略過這一部分。
題外話——程序位址空間以及入口點
坦白的說,0×8048096本身并沒有太大意義,這隻不過是相對可執行鏡像的代碼段(text section)開始處的一個偏移量。如果你仔細看看前面objdump出來的結果,你會發現代碼段的起始位置是0×08048080。這告訴了作業系統要将代碼段映射到程序虛拟位址空間的這個位置上。在Linux上,這些位址可以是絕對位址(比如,有的可執行鏡像加載到記憶體中時是不可重定位的),因為在虛拟記憶體系統中,每個程序都有自己獨立的記憶體空間,并把整個32位的位址空間都看做是屬于自己的(稱為線性位址)。
如果我們通過readelf工具來檢查可執行檔案的ELF頭,我們将得到如下輸出:
$ 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”同樣指向的是0×8048080。是以,如果我們把ELF檔案中的這個部分解釋給作業系統的話,就表示:
1. 将代碼段映射到位址0×8048080處
2. 從入口點處開始執行——位址0×8048080
但是,為什麼是0×8048080呢?它的出現是由于曆史原因引起的。每個程序的位址空間的前128MB被保留給棧空間了(注:這一部分原因可參考Linkers and Loaders)。128MB剛好是0×80000000,可執行鏡像中的其他段可以從這裡開始。0×8048080是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一個子程序,然後對其跟蹤)。無論如何,本文結尾處會給出完整源碼的連結。
/* 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);
這裡調試器從被跟蹤程序中擷取到指令指針,然後檢查目前位于位址0×8048096處的字長内容。運作本文前面列出的彙編碼程式,将列印出:
[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指令插入到目标位址上的。這部分代碼将列印出:
[13028] After trap, data at 0x08048096: 0x000007cc
再一次如同預計的那樣——0xba被0xcc取代了。調試器現在運作子程序然後等待子程序在斷點處停止住。
/* 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,”在斷點之前列印出來了——同我們計劃的一樣。同時我們發現子程序已經停止運作了——就在這個單位元組的陷阱指令執行之後。
/* 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以同樣的作用而被保留。為什麼要這樣做呢?因為這允許我們在插入一個斷點時覆寫到的指令不會多于一條。這很重要,考慮下面的示例代碼:
.. some code ..
jz foo
dec eax
foo:
call bar
.. some code ..
假設我們要在dec eax上設定斷點。這恰好是條單位元組指令(操作碼是0×48)。如果替換為斷點的指令長度超過1位元組,我們就被迫改寫了接下來的下一條指令(call),這可能會産生一些完全非法的行為。考慮一下條件分支jz foo,這時程序可能不會在dec eax處停止下來(我們在此設定的斷點,改寫了原來的指令),而是直接執行了後面的非法指令。
通過對int 3指令采用一個特殊的單位元組編碼就能解決這個問題。因為x86架構上指令最短的長度就是1位元組,這樣我們可以保證隻有我們希望停止的那條指令被修改。
封裝細節
前面幾節中的示例代碼展示了許多底層的細節,這些可以很容易地通過API進行封裝。我已經做了一些封裝,使其成為一個小型的調試庫——debuglib。代碼在本文末尾處可以下載下傳。這裡我隻想介紹下它的用法,我們要開始調試C程式了。
跟蹤C程式
目前為止為了簡單起見我把重點放在對彙程式設計式的跟蹤上了。現在升一級來看看我們該如何跟蹤一個C程式。
其實事情并沒有很大的不同——隻是現在有點難以找到放置斷點的位置。考慮如下這個簡單的C程式:
#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部分就好了。
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 <[email protected]>
80483f6: c9 leave
80483f7: c3 ret
好的,是以我們應該把斷點設定在0x080483e4上,這是do_stuff的第一條指令。另外,由于這個函數是在循環體中調用的,我們希望在循環全部結束前保留斷點,讓程式可以在每一輪循環中都在斷點處停下。我将使用debuglib來簡化代碼編寫。這裡是完整的調試器函數:
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程式後的列印輸出:
$ 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的這一行上停住”,然後調試器就能照辦。在下一篇文章中,我将向您展示這是如何做到的。