當你用 GDB 的時候,可以看到它完全控制了應用程式程式。當你在程式執行的時候用 Ctrl + C
,程式的執行就能夠終止,而GDB能展示它的當前地址、堆疊跟蹤資訊之類的內容。
但是它是怎麼辦到的呢?
但是它們怎麼不工作呢?
開始,讓我們先研究它怎樣才會不工作。它不能通過閱讀和分析程式的二進位制資訊來模擬程式的執行。它其實能做,而那應該能起作用(Valgrind
記憶體偵錯程式就是這樣工作的),但是這樣的話會很慢。Valgrind
會讓程式慢1000倍,但是GDB不會。它的工作機制與Qemu虛擬機器一樣。
所以到底是怎麼回事?黑魔法?……不,如果那樣的話就太簡單了。
另一種猜想?……?破解!是的,這裡正是這樣的。作業系統核心也提供了一些幫助。
首先,關於Linux的程式機制需要了解一件事:父程式可以獲得子程式的附加資訊,也能夠ptrace
它們。並且你可以猜到的是,偵錯程式是被除錯的程式的父程式(或者它會變成父程式,在Linux中程式可以將一個程式變為自己子程式:-))
Linux Ptrace API
Linux Ptrace API 允許一個(偵錯程式)程式來獲取低等級的其他(被除錯的)程式的資訊。特別的,這個偵錯程式可以:
- 讀寫被除錯程式的記憶體 :
PTRACE_PEEKTEXT
、PTRACE_PEEKUSER
、PTRACE_POKE
……- 讀寫被除錯程式的CPU暫存器
PTRACE_GETREGSET、PTRACE_SETREGS
- 因系統活動而被提醒:PTRACE_O_TRACEEXEC, PTRACE_O_TRACECLONE, PTRACE_O_EXITKILL, PTRACE_SYSCALL(你可以通過這些標識區分exec syscall、clone、exit以及其他系統呼叫)
- 控制它的執行:PTRACE_SINGLESTEP、PTRACE_KILL、PTRACE_INTERRUPT、PTRACE_CONT (注意,CPU在這裡是單步執行)
- 修改它的訊號處理
:PTRACE_GETSIGINFO、PTRACE_SETSIGINFO
Ptrace是如何實現的?
Ptrace的實現不在本文討論的範圍內,所以我不想進一步討論,只是簡單地解釋它是如何工作的(我不是核心專家,如果我說錯了請一定指出來,並原諒我過分簡化:-))
Ptrace 是Linux核心的一部分,所以它能夠獲取程式所有核心級資訊:
- 讀寫資料?Linux有
copy_to/from_user
。
- 獲取CPU暫存器?用copy_regset_to/from_user很輕鬆(這裡沒有什麼複雜的,因為CPU暫存器在程式未被排程時儲存在Linux的
struct task_struct *
排程結構中)。 - 修改訊號處理?更新域last_siginfo
- 單步執行?在處理器出發執行前,設定程式task結構的right flag(ARM、x86)
- Ptrace是在很多計劃的操作中被Hooked(搜尋
ptrace_event
函式),所以它可以在被詢問時(PTRACE_O_TRACEEXEC
選項和與它相關的),向偵錯程式發出一個SIGTRAP
訊號。
沒有Ptrace的系統會怎麼樣呢?
這個解釋超出了特定的Linux本地除錯,但是對於大部分其他環境是合理的。要了解GDB在不同目標平臺請求的內容,你可以看一下它在目標棧裡面的操作。
在這個目標介面裡,你可以看到所有C除錯需要的高階操作:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
struct target_ops { struct target_ops *beneath; /* To the target under this one. */ const char *to_shortname; /* Name this target type */ const char *to_longname; /* Name for printing */ const char *to_doc; /* Documentation. Does not include trailing newline, and starts with a one-line descrip- tion (probably similar to to_longname). */ void (*to_attach) (struct target_ops *ops, const char *, int); void (*to_fetch_registers) (struct target_ops *, struct regcache *, int); void (*to_store_registers) (struct target_ops *, struct regcache *, int); int (*to_insert_breakpoint) (struct target_ops *, struct gdbarch *, struct bp_target_info *); int (*to_insert_watchpoint) (struct target_ops *, CORE_ADDR, int, int, struct expression *); ... } |
普通的GDB呼叫這些函式,然後目標相關的元件再實現它們。(概念上)這是一個棧,或者一個金字塔:棧頂的是非常通用的,比如:
那個遠端目標很有趣,因為它通過一個連線協議(TCP/IP、串列埠)把兩臺“電腦”間的執行棧分離開來。
那個遠端的部分可以是執行在另一臺Linux機器上的gdbserver
。但是它也可以是一個硬體除錯埠的介面(JTAG) 或者一個虛擬的機器管理程式(比如 Qemu),並能夠代替核心和ptrace的功能。那個遠端根偵錯程式會查詢管理程式的結構,或者直接地查詢處理器硬體暫存器來代替對OS核心結構的查詢。
想要深層次學習這個遠端協議,Embecosm 寫了一篇一個關於不同資訊的詳細指南。Gdbserver的事件處理迴圈在這,而也可以在這裡找到Qemu gdb-server stub 。
總結一下
我們能看到ptrace
的API提供了這裡所有底層機制被要求實現的偵錯程式:
- 獲取exec系統呼叫並從呼叫的地方阻止它執行
- 查詢CPU的暫存器來獲得處理器當前指令以及棧的地址
- 獲取
clone或fork事件
來檢測新執行緒- 檢視並改變資料地址讀取並改變記憶體的變數
但是這就是一個偵錯程式的全部工作嗎?不,這只是那些非常低階的部分……它還會處理符號。這是,連結源程式和二進位制檔案。被忽視可能也是最重要的的一件事:斷點!我會首先解釋一下斷點是如何工作的,因為這部分內容非常有趣且需要技巧,然後回到符號處理。
斷點不是Ptrace API的一部分
就像我們之前看到的那樣,斷點不是ptrace
API的一部分。但是我們可以改動記憶體並獲取被除錯的程式訊號。你看不到其中的相關之處?這是因為斷點的實現比較需要技巧並且還要一點hack!讓我們來檢驗一下如何在一個指定的地址設定一個斷點。
1、這個偵錯程式讀取(ptrace追蹤)存在地址裡的二進位制指令,並儲存在它自己的資料結構中。
2、它在這個位置寫入一個不合法的指令。不管這個指令是啥,只要它是不合法的。
3、當被除錯的程式執行到這個不合法的指令時(或者更準確地說,處理器將記憶體中的內容設定好時)它不會繼續執行(因為它是不合法的)。
4、在現代多工系統中,一個不合法的指令不會使整個系統崩潰掉,但是會通過引發一箇中斷(或錯誤)把控制權交回給系統核心。
5、這個中斷被Linux翻譯成一個SIGTRAP
訊號,然後被髮送到處理器……或者發給它的父程式,就像偵錯程式希望的那樣。
6、偵錯程式獲得訊號並檢視被除錯的程式指令指標的值(換言之,是陷入 trap發生的地方)。如果這個IP地址是在斷點列表中,那麼就是一個偵錯程式的斷點(否則就是一個程式中的錯誤,只需要傳過訊號並讓它崩潰)。
7、現在,那個被除錯的程式已經停在了斷點,偵錯程式可以讓使用者來做任何他/她想要做的事,等待時機合適繼續執行。
8、為了要繼續執行,這個偵錯程式需要 1、寫入正確的指令來回到被除錯的程式的記憶體; 2、單步執行(繼續執行單個CPU指令,伴隨著ptrace 單步執行); 3、把非法指令寫回去(使得這個執行過程下一次可以再次停止) ;4、讓這個執行正常執行
很整潔,是不是?作為一個旁觀的評論,你可以注意到,如果不是所有執行緒同時停止的話這個演算法是不會工作的(因為執行的執行緒可能會在合法的指令出現時傳出斷點)。我不會詳細討論GDB是如何解決這個問題的,但在這篇論文裡已經說得很詳細了:使用GDB不間斷除錯多執行緒程式。簡要地說,他們把指令寫到記憶體中的其他地方,然後把那個指令的指標指向那個地址並單步執行處理器。但是問題在於一些指令是和地址相關的,比如跳轉和條件跳轉……
處理符號和除錯資訊
現在,讓我們回到訊號和除錯資訊處理。我沒有詳細地學習這部分,所以只是大體地說一說。
首先,我們是否可以不使用除錯資訊和訊號地址來除錯呢?答案是可以。因為正如我們看到過的那樣,所有的低階指令是對CPU暫存器和記憶體地址來操作的,不是源程式層面的資訊。因此,這個到源程式的連結只是為了方便使用者。沒有除錯資訊的時候,你看程式的方式就像是處理器(和核心)看到的一樣:二進位制(彙編)指令和記憶體位元組。GDB不需要進一步的資訊來把二進位制資訊翻譯成CPU指令:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
(gdb) x/10x $pc # heXadecimal representation 0x402c60: 0x56415741 0x54415541 0x55f48949 0x4853fd89 0x402c70: 0x03a8ec81 0x8b480000 0x8b48643e 0x00282504 0x402c80: 0x89480000 0x03982484 (gdb) x/10i $pc # Instruction representation => 0x402c60: push %r15 0x402c62: push %r14 0x402c64: push %r13 0x402c66: push %r12 0x402c68: mov %rsi,%r12 0x402c6b: push %rbp 0x402c6c: mov %edi,%ebp 0x402c6e: push %rbx 0x402c6f: sub $0x3a8,%rsp 0x402c76: mov (%rsi),%rdi |
現在,如果我們加上除錯資訊,GDB能夠把符號名稱和地址配對:
1 2 |
(gdb) $pc $1 = (void (*)()) 0x402c60 <main> |
你可以通過 nm -a $file
來獲取ELF二進位制的符號列表:
1 2 |
nm -a /usr/lib/debug/usr/bin/ls.debug | grep " main" 0000000000402c60 T main |
GDB還會能夠展示堆疊跟蹤資訊(稍後會詳細說),但是隻有感興趣的那部分:
1 2 3 4 5 6 7 8 |
(gdb) where #0 write () #1 0x0000003d492769e3 in _IO_new_file_write () #2 0x0000003d49277e4c in new_do_write () #3 _IO_new_do_write () #4 0x0000003d49278223 in _IO_new_file_overflow () #5 0x00000000004085bb in print_current_files () #6 0x000000000040431b in main () |
我們現在有了PC地址和相應的函式,就是這樣。在一個函式中,你將需要對著彙編來除錯!
現在讓我們加入除錯資訊:就是DWARF規範下的gcc -g
選項。我不是特別熟悉這個規範,但我知道它提供的:
- 地址到程式碼行和行到地址的配對
- 資料型別的定義,包括typedef和structure
- 本地變數和函式引數以及它們的型別
1 2 3 4 |
$ dwarfdump /usr/lib/debug/usr/bin/ls.debug | grep 402ce4 0x00402ce4 [1289, 0] NS $ addr2line -e /usr/lib/debug/usr/bin/ls.debug 0x00402ce4 /usr/src/debug/coreutils-8.21/src/ls.c:1289 |
試一試dwarfdump
來檢視二進位制檔案裡嵌入的資訊。addr2line
也能用到這些資訊:
很多原始碼層的除錯命令會依賴於這些資訊,比如next
命令,這會在下一行的地址設定一個斷點,那個print
命令會依賴於變數的型別來輸出(char
、int
、float
,而不是二進位制或十六進位制)。
最後總結
我們已經見過偵錯程式內部的好多方面了,所以我只會最後說幾點:
- 這個堆疊跟蹤資訊也是通過當前的幀是向上“解開(unwinded)”的(
$sp
和$bp
/#fp
),每個堆疊幀處理一次。函式的名稱和引數以及本地變數名可以在除錯資訊中找到。監視點(&amp;amp;lt;code&amp;amp;gt;watchpoints
)是通過處理器的幫助(如果有)實現的:在暫存器裡標記哪些地址應該被監控,然後它會在那記憶體被讀寫的時候引發一個異常。如果不支援這項功能,或者你請求的斷點超過了處理器所支援的……那麼偵錯程式就會回到“手動”監視:一個指令一個指令地執行這個程式,並檢查是否當前的操作到達了一個監視點的地址。是的,這很慢!- 反向除錯也可以這樣進行,記錄每個操作的效果,並反向執行。
- 條件斷點是正常的斷點,除非在內部,偵錯程式在將控制權交給使用者前檢查當前的情況。如果當前的情況不滿足,程式將會默默地繼續執行。
還可以玩gdb gdb
,或者更好的(好多了)gdb --pid $(pid of gdb)
,因為把兩個偵錯程式放到同一個終端裡是瘋狂的:-)。還可以除錯系統:
1 2 3 |
qemu-system-i386 -gdb tcp::1234 gdb --pid $(pidof qemu-system-i386) gdb /boot/vmlinuz --exec "target remote localhost:1234" |
但我會在另一篇文章裡提到!