轉自http://blog.jobbole.com/23632/
本文是關於調試器工作原理探究系列的第二篇。在開始閱讀本文前,請先確保你已經讀過本系列的第一篇(基礎篇)。
本文的主要內容
這里我將說明調試器中的斷點機制是如何實現的。斷點機制是調試器的兩大主要支柱之一 ——另一個是在被調試進程的內存空間中查看變量的值。我們已經在第一篇文章中稍微涉及到了一些監視被調試進程的知識,但斷點機制仍然還是個迷。閱讀完本文之后,這將不再是什么秘密了。
軟中斷
要在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 0×80指令之后吧,也就是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
|
通過上面的輸出,我們知道要設定的斷點地址是0×8048096。等等,真正的調試器不是像這樣工作的,對吧?真正的調試器可以根據代碼行數或者函數名稱來設定斷點,而不是基於什么內存地址吧?非常正確。但是我們離那個標准還差的遠——如果要像真正的調試器那樣設定斷點,我們還需要涵蓋符號表以及調試信息方面的知識,這需要用另一篇文章來說明。至於現在,我們還必須得通過內存地址來設定斷點。
看到這里我真的很想再扯一點題外話,所以你有兩個選擇。如果你真的對於為什么地址是0×8048096,以及這代表什么意思非常感興趣的話,接着看下一節。如果你對此毫無興趣,只是想看看怎么設定斷點,可以略過這一部分。
題外話——進程地址空間以及入口點
坦白的說,0×8048096本身並沒有太大意義,這只不過是相對可執行鏡像的代碼段(text section)開始處的一個偏移量。如果你仔細看看前面objdump出來的結果,你會發現代碼段的起始位置是0×08048080。這告訴了操作系統要將代碼段映射到進程虛擬地址空間的這個位置上。在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”同樣指向的是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一個子進程,然后對其跟蹤)。無論如何,本文結尾處會給出完整源碼的鏈接。
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);
|
這里調試器從被跟蹤進程中獲取到指令指針,然后檢查當前位於地址0×8048096處的字長內容。運行本文前面列出的匯編碼程序,將打印出:
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上設定斷點。這恰好是條單字節指令(操作碼是0×48)。如果替換為斷點的指令長度超過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的這一行上停住”,然后調試器就能照辦。在下一篇文章中,我將向您展示這是如何做到的。