GDB7.0 是 2009 年 10 月份正式發布的。和多數程序員一樣,那則消息並不曾引起我的注意,因為 gdb 為數不多的幾個新版本都讓人覺得非常平淡。沒有讓人振奮的新特性。
一晃幾個月過去了,隨意浏覽 gdb 主頁的時候,我突然發現一個叫做反向調試 (reverse debug) 的特性,默默地列在不引人注目的地方。”反向調試”?我們調試總是下一步,下一步,反向調試就是上一步,上一步了?
經過簡單的試用,我發現這是一個非常有用的特性,值得我們去學習和掌握。利用該功能,您可以讓被調試進程反向執行。您可能會問,這有什麼用處呢?
嗯,我覺得軟件調試往往是一個猜測的過程,一般的普通人似乎不太可能第一次就將斷點設置在最正確的位置,所以我們經常會發現重要的代碼執行路徑已經錯過。比如運行到斷點之前,程序的某些狀態就已經不正確了。以往,我們只好退出當前調試會話,從頭再來。
每次錯誤的猜測都必須讓一切從頭再來,如果您的運氣不佳,很快就會覺得非常抓狂吧。
假如有反向調試功能,在這種情況下,我們無須重新啟動調試程序,只要簡單地讓被調試進程反向執行到我們懷疑的地方,如果這裡沒有問題,我們還可以繼續正向執行調試,如此這般。如同我們在學習英語時使用復讀機一樣,來回將聽不懂的部分重放,分析。這無疑將極大地提高工作效率。
反向調試的使用簡介
反向調試的基本原理為 record/replay。將被調試進程的執行過程錄制下來,然後便可以像 DVD 一樣的任意反向或正向回放。因此,為了使用反向調試,您首先需要使用 record 命令進行錄制。
此後的調試過程和您以前所熟悉的過程一樣,不過您現在多了幾個反向控制的命令:
表 1. 反向調試基本命令
Command name description Reverse-continue ('rc') Continueprogram being debugged but run it in reverse Reverse-finish Execute backward until just before the selected stack frame is called Reverse-next ('rn') Step program backward, proceeding through subroutine calls. Reverse-nexti ('rni') Step backward one instruction, but proceed through called subroutines. Reverse-step ('rs') Step program backward until it reaches the beginning of a previousline Reverse-stepi Step backward exactly one instruction set exec-direction Set direction of execution.
讓我們假設您已經使用”break 10”命令在程序的第 10 行設置斷點。然後輸入”run”讓進程開始執行。不久,程序會暫停在第 10 行代碼處,控制權返回 gdb,並等待您的命令。此時如果您使用 next 命令,那麼程序會順序執行第 11 行代碼,如果您使用 reverse-next,那麼程序將回退執行第 9 行代碼。
使用反向調試命令之後,任何時候,您還可以自由地使用其他的 gdb 命令。比如 print 來打印變量的值等等。非常方便。
反向調試的實現原理
除了使用這項特性所帶來的好處之外,或許更令人著迷的是該特性的實現原理吧。我們都不曾見過時光倒流,能夠回頭執行指令的處理器也貌似從未出現過,那麼 gdb 是如何實現反向執行的呢?
為了說明這個問題,首先我們需要回顧一些 gdb 的基本概念。
GDB 的基本概念
Gdb 的一些重要術語以及 gdb 的整體結構
進入一個陌生的國家前先學幾句他們的常用語會比較好。GDB 這個小世界中也經常使用一些特有的名詞,我們最好首先熟悉他們。
Exec,指一個可執行文件,可以是 ELF 格式,也可以是古老的 a.out。
Inferior,指一個正在運行的 exec,一般就是指被調試進程。
接下來最好我們能夠對 gdb 有一個整體的,高度概括的了解。
Gdb 的設計目標是為各種平台上的人們提供一個通用的調試器,因此它必須有可擴展性,以便人們可以將它移植到不同的硬件和軟件環境下。
為了實現這個目標,GDB 的體系結構采用分層和封裝的設計思想,將那些依賴於特定軟硬件環境的部分進行抽象和封裝。最重要的兩個封裝概念便是 gdbarch 和 target。他們和 gdb core 之間的關系大致可以用下圖來描述:
圖 1. GDB 的體系結構
當需要在不同的 OS 上運行 GDB 時,只需提供相應的 target 便可;同樣,當需要支持一種新的新的處理器時,也只需提供新的 gdbarch,而 gdb core 則無需任何修改。
關於 gdbarch
Gdbarch 是一個封裝了所有關於處理器硬件細節的數據結構。在這個數據結構中,不僅包括一些描述處理器特性的變量,也包括一些函數(喜歡 OO 的人會自然地聯想到類。)這些函數實現了對具體硬件的一些重要操作,比如分析 stack frame,等等。
完整的 gdbarch 數據結構非常龐大,無法一一列出,下表分類總結了 gdbarch 中的重要信息:
表 2. gdbarch 數據結構
分類
說明
描述硬件體系結構和 ABI 細節的信息
比如 :
endianism : 大端系統還是小端系統
比如 return_value:描述該處理器 ABI 中規定的處理函數返回值的方法
breakpoint_from_pc: 用於斷點替換的機器指令 , 比如 i386 中為 int3
struct gdbarch_tdep
additional target specific data, beyond that which is
covered by the standard struct gdbarch.
描述標准數據結構的信息
高級語言的 int, char 等標准數據結構的具體定義
訪問和顯示寄存器的函數
read_pc: 返回指令指針寄存器
num_regs: 返回寄存器的個數
訪問和分析 stack frame 的函數
不同體系結構的 stack frame 都不盡相同。這些函數提供了如何分析和創建 stack frame 的具體實現函數。
可以看到 gdbarch 封裝了所有 gdb 運行時所需要的硬件信息,以及如何訪問和處理這些信息的具體函數。類似於面向對象設計中的類的設計,將關於處理器硬件細節的數據和方法都封裝到 gdbarch 數據結構中。
關於 target
同 gdbarch 一樣,target 也是一種封裝。但 target 所封裝的概念更復雜一些。它不僅封裝某一種操作系統,也封裝了不同的”調試方式”。
首先,不同的操作系統對應不同的 target。同樣在 i386 處理器下工作,Linux 和 vxworks 對於 debug 的支持是不同的,比如如何創建進程,如何控制進程等。這些不同對於 gdb core 是透明的,由 target 來屏蔽。
此外,target 還封裝了不同的”調試方式”。這個詞比較抽象,最好是舉例說明。
比如,同樣是在 i386 Linux 下面,您即可以使用 native 方式調試 exec,也可以調試一個 core dump 文件,還可以 attach 一個正在運行的進程並調試它。打開一個可執行文件和一個 core dump 文件的方法是不同的,同樣,將一個可執行文件 load 進內存執行和 attach 到一個正在執行的進程也是不同的。
對於這些不同,gdb 也采用 target 進行封裝。對於 gdb core 來說,當它需要讓一個調試目標開始運行時,便調用 target 相應的回調函數,而不必關心這個回調函數如何實現。啟動進程的具體的細節由不同的 target 來具體實現。當 target 為 exec,即一個磁盤上的可執行文件時,可以使用 fork+exec 的方式;當 target 是一個遠程調試目標時,可以通過 TCP/IP 發送一個命令給 gdb server 進行遠程調試;當 target 是一個已經在運行的進程時,需要使用 ptrace 的 attach 命令掛載上去。諸如這些細節,gdb 統統由 target 這個概念來封裝。
這便是 target 這個概念的主要意義,不過,還有一些事實讓 target 更加復雜。
有時候,人們希望在同一個 gdb 會話中調試多個 target。最常見的例子是調試 core dump 文件時,往往需要同時打開產生 core dume 的可執行文件,以便讀取符號。
比如程序 a.out 產生了 core dump 文件 core.2629,當用 gdb 打開 core dump 文件後,使用 bt 命令查看調用順序時,人們不能看到函數名。
圖 2. 沒有符號信息的調用堆棧顯示
此時人們往往還需要用 file 命令打開 a.out 程序,即一個 exec。
圖 3. 有了符號信息的調用堆棧顯示
除了 core dump 分析,還有其它一些情況要求 gdb 同時管理多個 target。為了應對這些需求,gdb 對 target 采用了分層、優先級管理的堆棧模式。堆棧中的每一層由一個形如 xyz_stratum 的古怪名字來標示,如下圖所示:
圖 4. GDB 的 target stratum
這個堆棧的優先級從上到下遞增,gdb 總是采用最高優先級 target 所提供的函數進行操作。
以圖 2,3 中的命令為例,打開 core dump 文件時,core_stratum 層的 target 被 push 進入 target stack;當用戶使用命令 file a.out 時,一個 file_stratum 層的 target 被 push 進入 target stack。他們依照自身的優先級歸於不同的層,絕不會弄錯。
當 target stack 中只有 core_stratum 的 target 時,假如用戶希望執行 run 命令是不可能的,因為 core dump 文件無法運行,而當載入了 exec target 後,這個 file_stratum 層的 target 提供了和 run 命令相應的回調函數,從而使得 gdb 可以使用 run 命令啟動一個 inferior。您在稍後的章節中可以看出,這種分層結構對反向調試的實現非常有幫助。
下表列出了 target 數據結構的重要內容:
表 3. Target 數據結構
分類
說明
關於 target 的說明信息
比如 :
to_name: target 的名字
to_stratum: 該 target 在 target stack 中的層數
控制調試目標的函數
比如 :
to_open: 打開 target, 對於 exec 或 core dump 文件執行文件打開操作 ; 對於 remote target, 打開 socket 建立 TCP 連接等操作 .
訪問調試目標寄存器和內存的函數
比如 :
to_store_registers
處理斷點的函數
比如 :
Insert_break_point
控制調試進程的函數
比如 :
to_resume. Function to tell the target to start running again (or for the first time).
等等。
GDB 運行時的基本流程
對一個目標進程進行調試,需要操作系統提供相應的調試功能。比如將一個正在運行的進程暫停並讀寫其地址空間等。在傳統 Unix 中,一般由 ptrace 系統調用提供這些功能。本文不打算詳細介紹 ptrace,讀者可以通過參考資料 [5] 獲得更詳細的介紹。
但 ptrace 的編程模式極大地影響了 gdb 的設計,下面我們研究 gdb 如何使用 Ptrace。
首先,gdb 調用 ptrace 將一個目標進程暫停,然後,gdb 可以通過 ptrace 讀寫目標進程的地址空間,還可以通過 ptrace 讓目標進程進入單步執行狀態。Ptrace 函數返回之後,gdb 便開始等待目標進程發來的信號,以便進一步的工作。
以單步執行為例,gdb 調用 ptrace 將目標進程設置為單步執行模式之後,便開始等待 inferior 的消息。因為處於單步模式,inferior 每執行一條指令,Linux 便將該進程掛起,並發送一個信號給 ptrace 的調用者,即 gdb。Gdb 接受到這條信號 ( 通過 wait 系統調用 ) 後,便知道目標進程已經完成了一次單步,然後進行相應處理,比如判斷這裡是否有斷點,或進入交互界面等待用戶的命令等等。
這非常類似窗口系統中的消息循環模式。Ptrace 的這一工作模式影響了整個 gdb 的設計,無論具體的 target 是否支持 ptrace,gdb 都采用這種消息循環模式。
理解了以上的基礎知識,您就可以開始探索反向調試的具體實現細節了。
反向調試原理和代碼導讀
原理概述
如前所述,反向調試的基本原理是錄制回放。它將 inferior 運行過程中的每條指令的執行細節錄制下來,存放到 log 中。當需要回退時,從 log 中讀取前一條指令執行的細節,根據這些細節,執行 undo 操作,從而將 inferior 恢復到當時的狀態,如此便實現了“上一步”。
undo 就是將某條指令所做的工作取消。比如指令 A 將寄存器 reg1 的值加了 1,那麼 undo 就是將其減一。
原理很簡單,然而要將此想法付諸實現,人們必須解決幾個具體的問題:
如何錄制,又如何回放呢?
首先,gdb7.0 引入了一個新的 target,叫做 record target。這個 target 提供了錄制和回放的基本代碼框架。
其次,當我們說到一條指令的執行細節時,究竟是指那些具體內容呢?或者說我們究竟應該錄制些什麼呢?這些記錄如何組織?這便是 record list 的主要內容。下面我們一一來了解這些知識。
Record target
反向調試引入了一個新的 target,叫做”record”,它工作在 target stack 的第二層。Gdb target 的分層結構帶來了這樣一種好處:高層的 target 可以復用底層 target 的功能,並在其上添加額外的功能。我想我們可以這麼說:低層 target 完成基本的低級功能,高層 target 完成更高級的功能。
Record target 就是一個帶錄制功能的高層 target,它依賴低層 target 完成諸如啟動進程,插入斷點,控制單步等基本功能。在此之上,它將 inferior 執行過程中的每條指令的細節記錄下來。此外,它還處理幾個反向調試特有的命令,reverse next 等。
當用戶希望進行反向執行時,record target 並不需要低層 target 的幫助。因為 inferior 的執行過程都已經被記錄在一個 log 中,反向執行時,record target 從 log 中讀取記錄,並 undo 這些記錄的影響,比如恢復先前寄存器的值,恢復被指令修改的內存值等,從而達到了反向執行的效果。
下面我們詳細分析幾個重要的 record target 所提供的函數,從而對上述基本思想有更深入的理解。
首先看 record_open 操作。如前所述,Record target 可以看作對低層 target 的一個 wrapper。Record_open 時,首先將當前 target 的重要回調函數 ( 比如後續將說明的 to_resume, to_wait 等 ) 復制到一系列的 beneath function pointers 中。然後將”record target” push 到 target stack 的頂層。
第二個重要的操作是 record_resume。Record_resume 在 gdb 決定讓目標進程開始運行之前被調用,因此這裡是絕佳的錄制點。該函數的實現比較簡單:
清單 1. record_resume 函數
record_resume (struct target_ops *ops, ptid_t ptid, int step,
enum target_signal signal)
{
record_resume_step = step;
if (!RECORD_IS_REPLAY)
{
if (do_record_message (get_current_regcache (), signal))
{
record_resume_error = 0;
}
else
{
record_resume_error = 1;
return;
}
record_beneath_to_resume (record_beneath_to_resume_ops, ptid, 1,
signal);
}
}
Record_resume 首先調用 do_record_message 進行錄制,然後調用低層 target 的 to_resume 函數(已經保存在 beneath function pointer 中)完成基本的 resume 工作。
這裡需要注意一點,在調用 record_beneath_to_resume時,第三個參數 step為 1,即單步執行。這是因為 record target需要錄制目標進程的每條指令,因此假如用戶命令為 continue或 next,而不是 step時 ,目標進程將繼續執行下去直到遇到斷點為止,在此期間的指令 gdb無法獲知,便也無從記錄。因此 record target強制目標進程進入單步執行狀態。以便錄制每一條指令。
第三個重要的操作是 record_wait。從函數的名字便可以猜得該函數是 gdb 等待目標進程信號的函數。
當 record target 執行了 record_resume 之後,inferior 恢復執行。而 gdb 自己則開始等待 inferior 的信號。前面已經看到,record_resume 強行讓 inferior 進入單步狀態,因此 inferior 在執行完一條指令後,便會被強制掛起,並向 gdb 發送一個 SIGCHLD 信號。此時 record_wait() 便開始執行。
該函數首先判斷是否需要進行錄制,如果需要,則進一步判斷當前的 inferior 是否是單步執行狀態,如果是,則不需要進行錄制,因為馬上 inferior 就會停下來,而 gdb 再次讓 inferior 恢復執行時將調用 record_resume,那裡會執行錄制工作。
但如果當前的 inferior 不在單步狀態,且下一條指令不是斷點,那麼如果讓 inferior 繼續執行則意味著 record target 將錯過後續的指令執行而無法進行錄制。因此,在這種情況下,record_wait 將進入一個循環,在每次循環迭代中執行錄制,並讓 inferior 進入單步執行狀態,直到遇到斷點或者 Inferior 執行 exit 為止。偽代碼如下:
清單 2. 執行錄制的偽代碼
while(1)
{
waitForNextSignal();
recordThisInst();
resumeInferiorAndSingleStep();
if(this is a breakpoint || this is end of inferior)
break;
}
這樣,通過 record_wait 的處理,inferior 的每條執行指令都將被錄制下來。
Record_wait 的另外一半代碼是處理 replay 的。假如當前用戶希望反向執行,那麼 record_wait 就從日志中讀取 inferior 上一條執行指令的相關記錄,恢復寄存器,內存等上下文,從而實現“上一步”的操作。
Record list
每次執行一條指令,record target 便將關於該指令的信息錄制下來。這些信息可以完整地描述一條指令執行的效果。在目前的 record target 中,記錄的信息只包括兩類:寄存器和內存。
一條機器指令能夠改變的就是寄存器或內存。因此每次執行一條指令,record target 對該指令進行分析,如果它修改了內存,那麼便記錄下被修改的內存的地址和值;如果它修改了寄存器,便記錄下寄存器的序號和具體的值。
這些修改記錄由 struct record_entry 表示和存儲。
清單 3 單個記錄的數據結構
struct record_entry
{
struct record_entry *prev;
struct record_entry *next;
enum record_type type;
union
{
/* reg */
struct record_reg_entry reg;
/* mem */
struct record_mem_entry mem;
/* end */
struct record_end_entry end;
} u;
};
多個 record_entry 通過 prev 和 next 連接成 record_list 鏈表。一條機器指令可能既修改內存也修改寄存器,因此一條指令的執行效果由 record_list 中的多個 entry 組成。有三種 entry,表示寄存器的 entry,表示 memory 的 entry 和標志指令結束的 entry。顧名思義,register entry 用來記錄寄存器的修改情況;memory entry 用來記錄內存的修改;end entry 表示指令結束。
如下圖所示:
圖 5. 反向調試的 log 結構
第一條指令 inst1 由三個 entry 組成,一個 memory entry, 一個 reg entry 和一個 end entry。表明 inst1 修改了內存和寄存器;同理,inst2,3 等也使用了同樣的數據結構。
函數 do_record_message
函數 do_record_message 具體完成指令執行的錄制細節。拋開 gdb 代碼的層層調用細節,該函數的具體工作是調用 gdbarch 所提供的 process_record 回調函數。
對於 i386,具體的 process_record 函數為
清單 4. 函數 process_record 定義
int i386_process_record (struct gdbarch *gdbarch, struct regcache *regcache,
CORE_ADDR addr)
這是一個 1000 多行的巨型函數,我建議大家不必精讀其中的每一行代碼。。。
大體說來,該函數首先反匯編正在執行的機器指令,根據反匯編的結果分析該指令是否修改了寄存器或者內存。如果有所修改,就分別分配新的 reg entry 或者 mem entry 並插入到 record_list,當對該指令的所有執行結果都分配了相應的 record_entry 之後,調用 record_arch_list_add_end 插入一個 end entry,然後返回。這樣,do_record_message() 執行完後,關於當前指令的所有細節都被保存到 record_list 中了。
record target 執行錄制的時序圖
Record target 的錄制過程用時序圖來表示比較容易理解,因為將相關操作串起來的是事件而不是函數調用關系。假如您打算跟蹤函數的調用關系,那麼很快就會迷失到暈頭轉向。參考資料 [7] 是我看到的最好的關於 gdb 的文檔,我覺得其中最棒的部分就是 gdb 命令執行時的時序圖,這是一種非常好的表示方法。下面我打算用時序圖來完整地描述前面羅羅嗦嗦幾千字卻依然描述不清的東西。
最簡單的 record 命令序列為:
> b main
> run
> record
> continue
我們用圖 6 來表示上述命令序列所對應的、gdb 內部執行過程的時序圖。
圖 6. 錄制的時序圖
當用戶輸入 run 命令後,gdb 會調用 target_insert_breakpoint 將目前 active 的斷點設置到 inferior 中,對於 i386 的 arch 目標,會將 inferior 的斷點處的指令替換為 int 3 指令。然後,gdb 調用 target_resume,恢復 Inferior 的運行。此時,target stack 頂端的 target 會執行正常的 target_resume 操作,比如 ptrace(CONTINUE)。
當 inferior 執行到斷點地址時,此時的指令被替換為 int3,因此會產生一個 trap。該信號被 gdb 捕獲進入 target_wait。此時 gdb 重新獲得控制權,進入 current_interp_command_loop 等待用戶輸入命令。
接下來用戶輸入 record 命令,這將導致 gdb 調用 record_open 函數,將 record target 推入 target stack 的棧頂,並回到 gdb 的命令行等待用戶輸入新的命令。
下一條命令是 continue,gdb core 將調用當前 target 的 target_resume 函數來恢復 inferior 的運行。此時的 target_resume 函數已經變成 record_resume。通過前面的代碼分析,我們可以知道,此時 record_resume 將調用 do_record_message 完成第一條指令的錄制,target_resume 函數執行完將返回 process() 函數。Gdb core 此時將調用 target_wait() 函數來等待 inferior 的下一個消息。同樣,此時的 target_wait 為 record_wait(),因此 gdb 進入 record_wait()。
Record_wait 將完成剩余的錄制過程。因為當前的用戶命令為 continue,因此 record_wait 將進入代碼清單 2 所示的循環,循環執行下列操作:
錄制當前指令
調用下一層的 resume 函數並將 step 參數設置為一,強制 inferior 進入單步執行。
等待 inferior 的消息
由於單步執行,inferior 在執行完一條指令後又將 gdb 的 wait 操作喚醒,繼續上述循環。如此循環往復,直到遇到斷點或者執行到 exit 為止,從而完成錄制過程。
Recored target 回放功能的實現比較簡單,本文長度有限,讀者可以自行分析。
GDB 反向調試的局限
Gdb 的反向調試是從 2006 年左右開始研發的,雖然目前已經正式發布。但還是不太穩定,且有一些局限。簡述如下:
有 side effect 的語句雖然能夠回退執行,但其所造成的 side effect 則無法撤銷。比如打印到屏幕上的字符並不會因為打印語句的回退而自動消失。
因此,反向調試不適用於 IO 操作。
此外 , 支持反向調試的處理器體系結構還很有限 , 需要更多的研發人員參與進來 .
結束語
很多人都在問,反向調試究竟有多大的實際用處?我在本文的開頭便簡單介紹了一種使用它的場景,但我想這並不能令心存懷疑的人滿意。實際上,以我的個人經驗來看,50% 的程序員從來不使用調試器。對於很多實際工作,即使不使用調試器,通過不斷的打印和代碼分析最終也能夠解決問題。但假如能正確地使用調試器,或許能夠更加有效地解決問題,從而將人生的寶貴時間使用在其他更有意義的地方。正如 Norman Matloff 所說,調試是一種藝術。沒有藝術,人類依然可以生存,然而遠在 1 萬多年前,拉科斯的史前人也要在巖洞的牆壁上塗抹出一頭牛或者一匹馬,那有什麼實際用處呢?