調試器工作原理(二):斷點
這是調試器工作原理系列文章的第二部分,閱讀本文前,請確保你已經讀過第一部分。
關於本文
我將會演示如何在調試器中實現斷點。斷點是調試的兩大利器之一,另一個是可以在被調試進程的內存中檢查變數值。我們在系列的第一部分已經了解過值檢查,但是斷點對我們來說依然神秘。不過本文過後,它們就不再如此了。
軟體中斷
為了在 x86 架構機器上實現斷點,軟體中斷(也被稱作「陷阱」)被會派上用場。在我們深入細節之前,我想先大致解釋一下中斷和陷阱的概念。
CPU 有一條單獨的執行流,一條指令接一條的執行(在更高的層面看是這樣的,但是在底層的細節上來說,現在的許多 CPU 都會並行執行多個指令,這其中的一些指令就不是按照原本的順序執行的)。為了能夠處理非同步的事件,如 IO 和 硬體定時器,CPU 使用了中斷。硬體中斷通常是一個特定的電子信號,並附加了一個特別的」響應電路」。該電路通知中斷激活,並讓 CPU 停止當前執行,保存狀態,然後跳轉到一個預定義的地址,也就是中斷處理程序的位置。當處理程序完成其工作後,CPU 又從之前停止的地方重新恢復運行。
軟體中斷在規則上與硬體相似,但實際操作中有些不同。CPU 支持一些特殊的指令,來允許軟體模擬出一個中斷。當這樣的一個指令被執行時,CPU 像對待一個硬體中斷那樣 —— 停止正常的執行流,保存狀態,然後跳轉到一個處理程序。這種「中斷」使得許多現代 OS 的驚嘆設計得以高效地實現(如任務調度,虛擬內存,內存保護,調試)。
許多編程錯誤(如被 0 除)也被 CPU 當做中斷對待,常常也叫做「異常」, 這時候硬體和軟體中斷之間的界限就模糊了,很難說這種異常到底是硬體中斷還是軟體中斷。但我已經偏離今天主題太遠了,所以現在讓我們回到斷點上來。
int 3 理論
前面說了很多,現在簡單來說斷點就是一個部署在 CPU 上的特殊中斷,叫 int 3
。int
是一個 「中斷指令」的 x86 術語,該指令是對一個預定義中斷處理的調用。x86 支持 8 位的 int 指令操作數,這決定了中斷的數量,所以理論上可以支持 256 個中斷。前 32 個中斷為 CPU 自己保留,而 int 3 就是本文關注的 —— 它被叫做 「調試器專用中斷」。
避免更深的解釋,我將引用「聖經」里一段話(這裡說的「聖經」,當然指的是英特爾的體系結構軟體開發者手冊, 卷 2A)。
INT 3 指令生成一個以位元組操作碼(CC),用於調用該調試異常處理程序。(這個一位元組格式是非常有用的,因為它可以用於使用斷點來替換任意指令的第一個位元組 ,包括哪些一位元組指令,而不會覆寫其它代碼)
上述引用非常重要,但是目前去解釋它還是為時過早。本文後面我們會回過頭再看。
int 3 實踐
沒錯,知道事物背後的理論非常不錯,不過,這些理論到底意思是啥?我們怎樣使用 int 3
部署斷點?或者怎麼翻譯成通用的編程術語 —— 請給我看代碼!
實際上,實現非常簡單。一旦你的程序執行了 int 3
指令, OS 就會停止程序( OS 是怎麼做到像這樣停止進程的? OS 註冊其 int 3 的控制程序到 CPU 即可,就這麼簡單)。在 Linux(這也是本文比較關心的地方) 上, OS 會發送給進程一個信號 —— 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 0x80
,其後指令是 mov edx, len2
。(等等,再次 int?是的,Linux 使用 int 0x80
來實現用戶進程到系統內核的系統調用。用戶將系統調用的號碼及其參數放到寄存器,並執行 int 0x80
。然後 CPU 會跳到相應的中斷處理程序,其中, OS 註冊了一個過程,該過程查看寄存器並決定要執行的系統調用。)首先,我們需要知道該指令所映射的地址。運行 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
所以,我們要設置斷點的地址是 0x8048096
。等等,這不是調試器工作的真實姿勢,對吧?真正的調試器是在代碼行和函數上設置斷點,而不是赤裸裸的內存地址?完全正確,但是目前我們仍然還沒到那一步,為了更像真正的調試器一樣設置斷點,我們仍不得不首先理解一些符號和調試信息。所以現在,我們就得面對內存地址。
在這點上,我真想又偏離一下主題。所以現在你有兩個選擇,如果你真的感興趣想知道為什麼那個地址應該是 0x8048096
,它代表著什麼,那就看下面的部分。否則你只是想了解斷點,你可以跳過這部分。
題外話 —— 程序地址和入口
坦白說,0x8048096
本身沒多大意義,僅僅是可執行程序的 text 部分開端偏移的一些位元組。如果你看上面導出來的列表,你會看到 text 部分從地址 0x08048080
開始。這告訴 OS 在分配給進程的虛擬地址空間里,將該地址映射到 text 部分開始的地方。在 Linux 上面,這些地址可以是絕對地址(例如,當可執行程序載入到內存中時它不做重定位),因為通過虛擬地址系統,每個進程獲得自己的一塊內存,並且將整個 32 位地址空間看做自己的(稱為 「線性」 地址)。
如果我們使用 readelf
命令檢查 ELF 文件頭部(ELF,可執行和可鏈接格式,是 Linux 上用於對象文件、共享庫和可執行程序的文件格式),我們會看到:
$ 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
注意頭部里的 Entry point address
,它同樣指向 0x8048080
。所以我們在系統層面解釋該 elf 文件的編碼信息,它意思是:
- 映射 text 部分(包含所給的內容)到地址
0x8048080
- 從入口 —— 地址
0x8048080
處開始執行
但是,為什麼是 0x8048080
呢?事實證明是一些歷史原因。一些 Google 的結果把我引向源頭,宣傳每個進程的地址空間的前 128M 是保留在棧里的。128M 對應為 0x8000000
,該地址是可執行程序其他部分可以開始的地方。而 0x8048080
,比較特別,是 Linux ld
鏈接器使用的默認入口地址。該入口可以通過給 ld
傳遞 -Ttext
參數改變。
總結一下,這地址沒啥特別的,我們可以隨意修改它。只要 ELF 可執行文件被合理的組織,並且頭部里的入口地址與真正的程序代碼(text 部分)開始的地址匹配,一切都沒問題。
用 int 3 在調試器中設置斷點
為了在被追蹤進程的某些目標地址設置一個斷點,調試器會做如下工作:
- 記住存儲在目標地址的數據
- 用 int 指令替換掉目標地址的第一個位元組
然後,當調試器要求 OS 運行該進程的時候(通過上一篇文章中提過的 PTRACE_CONT
),進程就會運行起來直到遇到 int 3
,此處進程會停止運行,並且 OS 會發送一個信號給調試器。調試器會收到一個信號表明其子進程(或者說被追蹤進程)停止了。調試器可以做以下工作:
- 在目標地址,用原來的正常執行指令替換掉 int 3 指令
- 將被追蹤進程的指令指針回退一步。這是因為現在指令指針位於剛剛執行過的 int 3 之後。
- 允許用戶以某些方式與進程交互,因為該進程仍然停止在特定的目標地址。這裡你的調試器可以讓你取得變數值,調用棧等等。
- 當用戶想繼續運行,調試器會小心地把斷點放回目標地址去(因為它在第 1 步時被移走了),除非用戶要求取消該斷點。
讓我們來看看,這些步驟是如何翻譯成具體代碼的。我們會用到第一篇里的調試器 「模板」(fork 一個子進程並追蹤它)。無論如何,文末會有一個完整樣例源代碼的鏈接
/* Obtain and show child's instruction pointer */
ptrace(PTRACE_GETREGS, child_pid, 0, ®s);
procmsg("Child started. EIP = 0x%08xn", 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%08xn", addr, data);
這裡調試器從被追蹤的進程中取回了指令指針,也檢查了在 0x8048096
的字。當開始追蹤運行文章開頭的彙編代碼,將會列印出:
[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%08xn", 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: %sn", 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%08xn", 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 時做到恢復斷點。文章稍後會展示 debuglib 如何做到這點。
更多關於 int 3
現在可以回過頭去看看 int 3
和因特爾手冊里那個神秘的說明,原文如下:
這個一位元組格式是非常有用的,因為它可以用於使用斷點來替換任意指令的第一個位元組 ,包括哪些一位元組指令,而不會覆寫其它代碼
int 指令在 x86 機器上佔兩個位元組 —— 0xcd
緊跟著中斷數(細心的讀者可以在上面列出的轉儲中發現 int 0x80
翻譯成了 cd 80
)。int 3
被編碼為 cd 03
,但是為其還保留了一個單位元組指令 —— 0xcc
。
為什麼這樣呢?因為這可以允許我們插入一個斷點,而不需要重寫多餘的指令。這非常重要,考慮下面的代碼:
.. some code ..
jz foo
dec eax
foo:
call bar
.. some code ..
假設你想在 dec eax
這裡放置一個斷點。這對應一個單位元組指令(操作碼為 0x48
)。由於替換斷點的指令長於一個位元組,我們不得不強制覆蓋掉下個指令(call
)的一部分,這就會篡改 call
指令,並很可能導致一些完全不合理的事情發生。這樣一來跳轉到 foo
分支的 jz foo
指令會導致什麼?就會不在 dec eax 這裡停止,CPU 徑直去執行後面一些無效的指令了。
而有了單位元組的 int 3
指令,這個問題就解決了。 1 位元組是在 x86 上面所能找到的最短指令,這樣我們可以保證僅改變我們想中斷的指令。
封裝一些晦澀的細節
很多上述章節樣例代碼的底層細節,都可以很容易封裝在方便使用的 API 里。我已經做了很多封裝的工作,將它們都放在一個叫做 debuglib 的通用庫里 —— 文末可以去下載。這裡我僅僅是想展示它的用法示例,但是繞了一圈。下面我們將追蹤一個用 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
反彙編一下可執行文件,但是列印出的東西太多。尤其看到很多無用,也不感興趣的 C 程序運行時的初始化代碼。所以我們僅看一下 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 <puts@plt>
80483f6: c9 leave
80483f7: c3 ret
那麼,我們將會把斷點放在 0x080483e4
,這是 do_stuff
第一條指令執行的地方。而且,該函數是在循環裡面調用的,我們想要在斷點處一直停止執行直到循環結束。我們將會使用 debuglib 來簡化該流程,下面是完整的調試函數:
void run_debugger(pid_t child_pid)
{
procmsg("debugger startedn");
/* Wait for child to stop on its first instruction */
wait(0);
procmsg("child now at EIP = 0x%08xn", get_child_eip(child_pid));
/* Create breakpoint and run to it*/
debug_breakpoint* bp = create_breakpoint(child_pid, (void*)0x080483e4);
procmsg("breakpoint createdn");
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%08Xn", get_child_eip(child_pid));
procmsg("resumingn");
int rc = resume_from_breakpoint(child_pid, bp);
if (rc == 0) {
procmsg("child exitedn");
break;
}
else if (rc == 1) {
continue;
}
else {
procmsg("unexpected: %dn", 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 庫。
- bpuselib.c - 大部分代碼使用了 debuglib 庫,用於在第二個代碼範例中演示在 C 程序的循環中追蹤。
引文
在準備本文的時候,我搜集了如下的資源和文章:
- How debugger works
- Understanding ELF using readelf and objdump
- Implementing breakpoints on x86 Linux
- NASM manual
- SO discussion of the ELF entry point
- This Hacker News discussion of the first part of the series
- GDB Internals
via: http://eli.thegreenplace.net/2011/01/27/how-debuggers-work-part-2-breakpoints
作者:Eli Bendersky 譯者:wi-cuckoo 校對:wxy
本文轉載來自 Linux 中國: https://github.com/Linux-CN/archive