gdb 除錯入門,大牛寫的高質量指南

逆旅發表於2016-11-23

沒想到Brendan Gregg這樣的大牛,會寫出這樣一篇gdb tutorials文章:gdb Debugging Full Example (Tutorial): ncurses 。但可能正如文章開頭所說,大牛對網上的gdb文章都不太滿意,所以才有了這篇高質量指南,gdb入門者的福音。—— 何登成

如果你是系統管理員,但還不認識 Brendan Gregg,那網上流傳甚廣的 3 張 Linux 效能工具圖(連結),你應該看過的。—— 伯小樂。

brendan-gregg

( Brendan Gregg)

gdb 除錯 ncurses 全過程:

發現網上的“gdb 示例”只有命令而沒有對應的輸出,我有點不滿意。gdb 是 GNU 偵錯程式,Linux 上的標配偵錯程式。當我看 Greg Law 在 CppCon 2015 上的演講《給我 15 分鐘,我將改變你的對 GDB 的認知》的時候,我想起了示例輸出的不足,幸運的是,這次有輸出!這 15 分鐘太值了。

它也啟發我去分享一個完整的 gdb 除錯例項,包含輸出和每個步驟,甚至鑽牛角尖的情況。這不是一個特別有趣或奇怪的問題,只是常規的 gdb 除錯會話。但它包含了基礎的東西可以勉強作為教程使用,記住 gdb 裡還有很多東西我這裡沒用到。

我會以 root 許可權執行下面的命令,因為我在除錯一個工具,它需要 root 許可權(目前)。需要的時候可用 sudo 獲取 root 許可權。你也沒必要通讀全篇︰ 我已列出每一步,你可以瀏覽它們找感興趣的看。

1. 問題概述

BPF 工具箱裡的 bcc 工具集有一個對cachetop.py 的 pull 請求,它通過程式使用 top-like display 顯示 page cache 的統計。太好了 !然而,當我測試它時,遇到了段錯誤︰

注意它說的是“段錯誤”,不是“段錯誤(核心已轉儲)”。我想要一個核心轉儲檔案用來除錯。(核心轉儲檔案是程式記憶體的拷貝 – 這個名字來源於磁芯儲存器時代 – 可用偵錯程式分析)

分析核心轉儲檔案是一種方法,但不是除錯這個問題的唯一方法。我可以在 gdb 中執行此程式,來檢查這個問題。我也可以在段錯誤發生時,用外部追蹤器去抓資料和棧幀。我們從核心轉儲檔案入手。

2. 解決核心轉儲問題

我檢查一下核心轉儲的設定:

ulimit -c 顯示核心轉儲檔案大小的最大值,這裡是零:禁止核心轉儲(對於本程式和它的子程式)。

/proc/…/core_pattern 僅僅被設為 “core”,表示會在當前目錄下生成一個檔名為 “core” 的 核心轉儲檔案。目前這樣就行了,但是我要演示如何把它設定為全域性位置。

你可以進一步定製 core_pattern;例如,%h 為主機名,%t 為轉儲的時間。這些選項被寫在 Linux 核心原始碼 Documentation/sysctl/kernel.txt中。

要使 core_pattern 保持不變,重啟之後仍然有效,你可以通過設定 /etc/sysctl.conf 裡的 “kernel.core_pattern” 實現。

再來一次:

好多了:我們有了自己的核心轉儲檔案。

3. 啟動 GDB

現在我要用 gdb 啟動目標程式(用 shell 替換符,”`”,不過在你確定能用的情況下,也可指定完整路徑),和核心轉儲檔案:

最後兩行很有趣:它告訴我們這個段錯誤發生在 libncursesw 庫裡 doupdate() 函式中。可以先在網上搜一下,以防這是個很常見的問題。我搜了一下,可是沒發現一個常見的原因。

我已經猜到 libncursesw 是什麼了,如果你對它很陌生,它在 “/lib” 目錄下以 “.so.*” 結尾表明這是一個動態庫檔案,可能有 man 手冊、網站、包描述等。

我是碰巧在 Ubuntu 上除錯,但用什麼 Linux發行版對使用 gdb 並沒有影響。

4. 回溯

棧回溯顯示我們是如何到達失敗點的,通常足夠幫助我們確定常見的問題。bt (backtrace的簡寫)常常是我在 gdb 中使用的第一條命令:

從下往上,按照從父函式到子函式的順序看。有 “??” 的地方是因為符號解析失敗。遍歷棧 – 用來生成棧幀 — 也會失敗。在這種情況下你可能會看到一個正常的棧幀,跟著一個小數值的假地址。如果符號或棧破損很嚴重,導致無法理解棧回溯,這裡有幾個常用的辦法來修復:安裝 debug info 包(給 gdb 提供更多的符號,讓它來做基於 DWARF 的棧遍歷),或者重新用原始碼編譯(-fno-omit-frame-pointer -g)一個帶幀指標和除錯資訊的版本。以上大多數 “??”
可以通過安裝 python-dbg 包來修復。

這些棧看起來不太有用:幀 5 到 17 (左邊的索引)在 Python 內部,雖然還看不到 Python 方法。幀 4 是 _curses 庫,然後就到了 libncursesw。看起來呼叫順序是 wgetch()->wrefresh()->doupdate()。根據函式名來看,我猜是重新整理視窗。為什麼會導致核心轉儲 呢?

5. 反彙編

我從反彙編發生段錯誤的函式 doupdate() 開始:

部分輸出。(我也可以只輸入 “disas” 它會預設反彙編 doupdate )

“=>” 指向段錯誤地址,此處是一條 mov 指令 mov 0x10(%rsi),%rdi:從%rsi中指向記憶體地址的值加偏移量 0x10 處取值,送到 %rdi 暫存器中。接下來我會檢查暫存器的狀態。

6. 檢視暫存器

使用 i r(info registers 的簡寫)列印暫存器值:

哦,%rsi是零,這就是我們的問題所在!零不太可能是有效地址,並且解引用一個未初始化的指標或空指標引起的段錯誤是常見的軟體 bug。

7. 記憶體對映

你可以使用 i proc m (info proc mappings 的簡寫)核查零是不是有效地址:

第一個有效的虛擬地址是 0x400000。任何小於它的地址都是非法的,這些地址如果被引用,就會引起段錯誤。

目前有幾種不同的方式可做進一步分析。我先一步一步的看指令。

8. 斷點

先回到反彙編:

看這四條指令:好像是從棧中取東西放到 %rax,然後解引用 %rax 到 %rsi,再將 %eax 置零( xor 是一個優化,替換掉移動 0 的動作),最後將 %rsi 解引用再加一個偏移,不過我們知道 %rsi 是零。這幾條指令用來訪問資料結構。 可能 %rax 會很有趣,但是它已經被前面的指令置零,所以我們在核心轉儲檔案的暫存器裡看不到它的值。

我可以在 doupdate+289 下個斷點,然後逐條指令檢視暫存器的值如何變化。首先,我需要啟動 gdb 把程式跑起來:

現在用 b (break 的簡寫)來下斷點:

哦。我想演示這個錯誤來解釋為什麼我們經常以在主函式設定斷點作為開始,因為這時候符號可能被載入,可以設定感興趣的斷點。我直接在 doupdate 函式設斷點,避開這個問題,一旦斷點被觸發就設定加了偏移的斷點。

我們到了斷點處。

如果你之前沒有做這些,r (run) 命令會把引數傳給我們早先在命令列指定的 gdb 目標(python)。這樣的話程式會以執行 “python cachetop.py” 結束。

9. 單步除錯

我跳到下一條指令(si,stepi的簡寫),然後檢查暫存器:

又一條線索。所以我們解引用的空指標好像是一個叫 “cur_term” 的符號(p/a 是 print/a 的簡寫,這裡 “/a” 指以地址的形式)。考慮到這是 ncurses, 是我們的環境變數 TERM 設定有問題嗎?

我試過將其設定為 vt100 並執行程式,還是遇到了同樣的段錯誤。

注意我只是在 doupdate() 第一次被呼叫的時候檢視了暫存器,但是它可以被多次呼叫,所以問題可能出在後邊的呼叫中。我可以通過執行 c( continue 的簡寫)一步步到達出問題的地方。如果它被呼叫幾次的話這樣做是可行的,如果它被呼叫幾千次的話我得用別的辦法。(我會在 15 節的裡介紹。)

10. 回退

gdb 有一個超棒的功能叫回退,Greg Law 在他的演講中提到過。這裡有一個例子。

我再啟動一個 python 會話,從頭演示:

和之前一樣我在 doupdate 下斷點,一旦觸發,我就啟動 recording,然後繼續執行程式直到崩潰。Recording 會增加相當大的開銷,所以我不想在主函式裡就將它開啟。

這裡我可以逐行或逐條指令的回退。它通過播放我們記錄的暫存器狀態來工作。我回退兩條指令,然後列印暫存器值:

所以,又找到了 “cur_term” 的線索。我很想看這裡的原始碼,但我將從除錯資訊入手。

11. 除錯資訊

這是 libncursesw,我沒有安裝除錯資訊(Ubuntu):

我把它裝上:

太好了,版本匹配。那麼現在我們的段錯誤是什麼樣子呢?

棧回溯看起來不太一樣:我們確實不在 doupdate() 裡邊,而是在 ClrBlank() 中,它內聯在 ClrUpdate() 裡,ClrUpdate() 又內聯在 doupdate() 中。

現在我真的要看原始碼了。

12. 原始碼

安裝了除錯資訊之後,gdb 可以同時列出原始碼和彙編:

好極了!看 “=>” 和它上邊的程式碼。所以我們的段錯誤發生在 “if (back_color_erase)” ?看起來不可能。

這裡我檢查了一下,我的除錯資訊版本是對的,重新在 gdb 裡邊執行程式直到發生段錯誤。錯誤相同。

back_color_erase 有什麼特殊嗎?我們現在在 ClrBlank() 中,我先列出原始碼:

啊,在這個函式裡邊沒定義,難道是全域性變數?

13. TUI

有必要看看這些程式碼在 gdb 的文字使用者介面(TUI)裡是什麼樣的,我用的不多,是看了 Greg 的演講之後受到的啟發。

你可以用 –tui 來啟動:

它在抱怨沒有 Python 原始碼。我可以搞定,但是我們是在 libncursesw 裡邊崩潰的。所以不管它敲回車讓它完成載入,在發生錯誤的地方載入了 libncursesw 除錯資訊裡的原始碼:

棒極了!

“>” 指向發生崩潰的那行程式碼。更棒的是:用 layout split 命令,我們可以在不同的視窗檢視原始碼和彙編程式碼。

Greg 演示這個的時候,和這裡的順序相反,因此你可想像同時檢視原始碼和彙編的情景(這裡我需要一個視訊來演示)。

14. 外部工具:cscope

我需要對 back_color_erase 有更多瞭解,我可以試試 gdb 的 搜尋命令,但是我發現用一個外部工具:cscope 更快。 cscope 是一個基於文字的程式碼瀏覽器 ,誕生於80年代的貝爾實驗室。如果你有喜歡的現代 IDE,可以不用它。

安裝 cscope:

cscope -bqR 用來建立查詢資料庫。cscope -dq 用來啟動 cscope。

查詢 back_color_erase 的定義:

敲回車:

哦,一個巨集定義。(作為巨集定義的常見的形式,它們至少應該大寫)

好吧,那麼 CUR 是什麼呢? 用 cscope 查詢定義易如反掌。

起碼這個巨集定義是大寫的!

我們通過逐條檢視指令和暫存器找更早定義的 cur_term 。它是什麼呢?

cscope 讀取了 /usr/include/term.h 。好吧,更多的巨集。我用加粗來突出這行程式碼, 我認為它產生了影響。為什麼這裡會有 “if 0 && !0 … elif 0” ?我不清楚(需要再讀些程式碼)。有時程式設計師會在他們想要在產品中失效的除錯程式碼附近使用 “#if 0”,可是,這個好像是自動生成的。

查詢 NCURSES_EXPORT_VAR 發現:

… 和 NCURSES_IMPEXP:

… 還有 TERMINAL:

嗨!TERMINAL 是大寫的。和巨集混在一起,這個程式碼不太好跟蹤 …

好吧,到底是誰給 cur_term 賦的值呢?記住我們的問題是它被賦值為零,也許因為它未被初始化或顯式賦值。瀏覽給它賦值的程式碼路徑可能會找到更多的線索,來回答為什麼沒被初始化,或為什麼被賦值為零。使用 cscope 的第一個選項:

快速瀏覽項發現:

我加了高亮。甚至函式名稱都被封裝在巨集裡。但至少我們發現了 cur_term 如何被賦值的:通過 set_curterm()。也許它沒被呼叫?

15. 外部工具:perf-tools/ftrace/uprobes

我稍後將介紹如何用 gdb 解決這個問題,可是我忍不住嘗試我 perf-tools 工具箱裡的 uprobe 工具,它使用 Linux 下的 ftrace 和 uprobes。用 tracers 的一個好處是它不會終止目標程式,像 gdb 一樣(儘管對於這裡的 cachetop.py 沒什麼用)。另一個好處是追蹤幾個和幾千個程式一樣容易。

我應該能追蹤 libncursesw 對 set_curterm() 的呼叫,甚至列印出它的第一個引數:

咦,沒起作用。set_curterm() 在哪?有很多方法可以找到它,比如 gdb 或 objdump:

gdb 表現的好些。此外如果仔細看原始碼,我注意到它是為 libtinfo 構建的。

試著在 libtinfo 裡邊查詢 set_curterm() :

找到了。所以 set_curterm() 被呼叫了,並且被呼叫了四次。最後一次被傳了一個零,看起來這就是問題所在。

如果你覺得疑惑,我怎麼就知道 %di 暫存器就是第一個引數呢,因為 AMD64/x86_64 ABI 寫著呢(假設這個庫和 ABI 相容)。這裡有提示:

我還想知道呼叫 arg1=0x0 的堆疊資訊,但是 ftrace 還不支援棧追蹤。

16. 外部工具:bcc/BPF

由於我們在除錯 bcc 工具 cachetop.py,值得注意的是 bcc 裡的 trace.py 有和我的老工具 uprobe 類似的功能:

是的,我們在用 bcc 除錯 bcc !

如果你對 bcc 不熟悉,它值得一看。它為 Linux4.x 系列裡的 BPF 新特性提供了 Python 和 lua 介面 。總之,它能讓很多以前不可能或昂貴以致無法執行的效能工具執行起來。我以前發過貼介紹如何在 Ubuntu Xenial 上執行它。

bcc 的 trace.py 工具應該有一個開關來決定是否列印使用者堆疊,因為核心從 Linux4.6 開始具備 BPF 堆疊功能,不過到寫這篇文章的時候我們還沒有加上這個開關。

17. 更多的斷點

我真的應該從在 set_curterm() 下了斷點的 gdb 入手,可是我覺得我們走的彎路,使用ftrace和BPF的還是蠻有趣的。

回到實時執行模式:

好的,在這個斷點我們可以看到 set_curterm() 被呼叫了,被傳了一個 termp = 0x0 的引數, 多虧了 debuginfo 提供的資訊。如果沒有 debuginfo ,我只能在每個斷點處列印暫存器值。

我列印棧幀出來,這樣我們可以看到是誰將 curterm 設為零的。

好了,有了更多的線索…我認為。我們在 llvm::sys::Process::FileDescriptorHasColors()裡邊。llvm 編譯器有問題?

18. 外部工具:cscope,再來一次

程式碼較多的時候使用 cscope 檢視,這次是 llvm。FileDescriptorHasColors() 函式:

這是較早版本中使用的程式碼:

用空指標呼叫 set_curterm() 變成了 “愚蠢的舞蹈” 。

19. 寫記憶體

作為實驗,我要修改程式記憶體來避免 set_curterm() 被置零,用來探索可能的解決方法。

執行 gdb ,在 set_curterm() 下斷點,跑到零呼叫的地方:

這裡我用 set 命令來改寫記憶體,把零換成在前面看到的 set_curterm() 引數 0xbecb90 ,希望它仍是合法的。

警告:寫記憶體不安全!gdb 不會問你 “你確定?”。如果你寫錯了或者敲錯了,會搞壞程式。最好的情況是你的程式立即奔潰,你意識到自己做錯了。最糟的情況,程式使用壞的資料繼續執行幾年之後被發現是錯的。

這裡,我在不用於生產的實驗室機器上做試驗,所以我繼續。
我以16進位制(p/x)的形式列印 %rdi 的值,然後將其設為之前的地址,再列印一次,最後列印所有暫存器的值:

(因為這裡我已經安裝了除錯資訊,因此不必使用暫存器,我可以設定傳給 set_curterm() 的引數引數 “termp”,而不是 $rdi。)

現在 %rdi 被用到了,所以那些暫存器看起來還能繼續用。

好的,在呼叫 set_curterm() 時程式沒崩!但遇到另一個引數也是零的問題。我們故技重施:

啊。這就是我寫記憶體的後果。所以這次試驗以另一個段錯誤結束。

20. 條件斷點

在前面一節,我用了 3 個 continues 到達斷點的正確呼叫處。如果有幾百次呼叫的話,就得用條件斷點了。這裡有個例子。

和之前一樣我執行程式,在 set_curterm() 下斷點:

現在我要將 1 號斷點變成條件斷點,這樣它只會在 %rdi 的值為零是被觸發:

漂亮!cond 是 conditional 的簡寫。為什麼當我第一次建立 “pending” 斷點的時候沒有立即執行它呢?因為我發現在 pending 斷點上條件不管用,至少在這個版本的 gdb 上是這樣。(要麼是我哪裡做錯了。)我也用 i b (info breakpoints)列出了斷點資訊。

21. 返回命令

我曾經試過另一個改值的方法,但是這次我要改指令而不是資料。

警告:看前邊的警告,這裡也適用。

和之前一樣我們來到 set_curterm 零斷點處,然後敲入 ret (return 的簡寫),就會立即從此函式返回並且不執行這個函式。我想用不執行函式的方式讓全域性變數 curterm 不被置零。

又崩了。這是我搞砸的現場。

再試一次。在多看了一點程式碼之後,我想第二次嘗試 ret,以防父函式被捲進來。再來一次,這只是一次非常規試驗:

螢幕清空暫停…然後重新整理:

哇!成功了!

22. 更好的方案

我已經把除錯輸出釋出到 github,因為 BPF 首席工程師,Alexei Starovoitov 對 llvm 也很精通,問題的根源好像是 llvm 的一個 bug。當我在用寫記憶體和返回命令瞎搞的時候,他建議我在 bcc 加上 llvm 選項 -fno-color-diagnostics,來避免這個問題。成功了!把它加到 bcc 裡是一個解決辦法。(我還是希望 llvm 的 bug 能被修復)

23. Python 環境

至此問題已經解決了,但是你可能會好奇想看修復好的堆疊回溯。

安裝 python-dbg:

現在我回到 gdb 來看堆疊回溯:

沒有 “??” 了,但也沒什麼大用。

python 除錯包給 gdb 加入了別的功能。現在我們可以看 python 的回溯:

… 和Python 原始碼:

它識別出了我們之前執行的 python 程式碼中的段錯誤。真是太棒了!

原先堆疊回溯的問題是我們看到了 python 內部在執行方法,卻看不到方法本身。如果你除錯別的語言,要取決於它的編譯選項和執行環境,還有怎麼結束執行程式碼。如果你在網上搜尋 “語言名” 和 “gdb” 你可能會找到像 Python 一樣的 gdb 擴充套件。如果沒有的話,壞訊息是你需要自己寫,好訊息是這樣做是可行的!當它們可以用 Python 來寫的時候,請搜尋 “adding new GDB commands in Python” 的資料。

24. 更多命令

看起來好像我寫了一個 gdb 的全面介紹,但我真的沒有:gdb 裡還有很多命令我沒提到。help 命令列出了主要部分:

你可以對每一類命令執行 help。例如,這是 breakpoints 類的全部清單:

這些幫助表明了 gdb 有很多功能,也說明了我在示例中用到的只是一小部分。

25. 結語

好吧,這個問題有點噁心:一個 LLVM bug 破壞了 ncurses 並引起了 Python 程式的段錯誤。但是我用來除錯的命令和步驟很常見:看堆疊,檢查暫存器,下斷點,逐步排查,看原始碼。

當我第一次使用 gdb 的時候(多年前),我真的不喜歡它。覺得它不靈活而且功能有限。從那之後 gdb 進步了很多,我也掌握了 gdb 的技巧,我現在認為它是一個強大的現代偵錯程式。不同的偵錯程式特性可能不同,但是 gdb 可能是目前基於文字的最強大的偵錯程式,lldb 正奮起直追。

我希望我分享的有完整輸出的 gdb 示例和我提到的不同的警告,會對搜到它的人有幫助。有機會的話我會發布更多的 gdb 示例,特別是其他執行環境比如Java。

用 q 退出 gdb。

打賞支援我翻譯更多好文章,謝謝!

打賞譯者

打賞支援我翻譯更多好文章,謝謝!

gdb 除錯入門,大牛寫的高質量指南

相關文章