Try   HackMD

透過 GDB 進行遠端除錯

資料整理: jserv
本實驗針對 GNU/Linux,以 Ubuntu Linux 18.04-LTS x86_64 為實驗環境

遠端除錯為何重要?

試想一個情境:若你的手機裡頭的軟體 (特別是作業系統核心和關鍵的軟體框架) 出現非預期的錯誤,你該如何排除問題?你或許會聯想到除錯器 (debugger),但手機的硬體資源可能較為受限 (這裡指功能型手機),且手機可能反覆重新啟動,很難確保我們可穩定地執行除錯器,於是 GNU 工具集的開發者很早就提出 Remote GDB 的機制,允許我們透過另一台電腦,對上述手機或特定的裝置進行除錯,可參閱 遠端除錯 先熟悉 GDB 命令和相關操作。

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

上圖左邊是執行 GDB 的完整開發環境,開發者可在互動操作環境中,追蹤程式的執行。右邊則是我們想除錯的環境,可以是實體裝置,甚至是虛擬機器。

使用案例:文字模式全螢幕更新的程式

就算不考慮手機或嵌入式裝置,Remote GDB 在開發某些類型的程式依舊很管用,例如涉及到終端機畫面更新的程式,我們就不方便用 printf 來印出特定的變數或記憶體內容,因為 printf 的輸出可能會因終端機設定而屏蔽或顯示錯亂,而且也會干擾終端機畫面 (特別是文字模式的全螢幕畫面更新,如編輯器和電腦遊戲)。這時我們即可運用 Remote GDB 來克服問題。

實驗之前,我們需要確保 GDB 和相關套件已正確安裝,以 Ubuntu Linux 為例,執行以下命令:

$ sudo apt install gdb gdbserver
$ sudo apt install build-essential manpages-dev

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →
$ 開頭表示輸入在終端機的命令,以剛才的命令來說,實際你該輸入
sudo apt install gdb gdbserver 並按下 Enter 按鍵

取得 tic-tac-toe 原始程式碼: (最後一個命令是確保本文內容對應的原始程式碼彼此一致)

$ git clone https://github.com/jserv/tic-tac-toe
$ cd tic-tac-toe
$ git checkout gdb-demo 

tic-tac-toe 是我們用來解說的程式,如名稱所示,這是個井字遊戲的實作,特別之處在於該程式直接控制 tty,以達到在終端機進行全螢幕更新的功能,這點跟 vimEmcas 一樣,因此我們不容易透過 printf 進行文字輸出。

輸入 $ make 可產生 ttt 執行檔,tic-tac-toe 執行畫面如下:

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

我們試著修改 tic-tac-toe 的程式碼,在 game.cgame_session 新增以下程式碼:

@@ -316,6 +316,9 @@ static int game_session()
     main_screen();
     update_game_field(0, 0);
 
+    void (*func_ptr)() = 0;
+    (*func_ptr)();
+
     tui_save_term();
     tui_set_term(0, 0, 1, 0, 1);
     tui_set_cursor(0); /* hide cursor */

此舉強制 tic-tac-toe 在更新主畫面後,即刻遭遇記憶體存取錯誤,於是我們可見類似這樣的執行結果:

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

畫面顯示 Segmentation fault,倘若我們透過 GDB 重新執行 tic-tac-toe,會遇到這樣的畫面:

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

顯然,由於 tic-tac-toe 變更 tty 的設定,現在終端機畫面就錯亂了,儘管我們仍可輸入 where 一類的 GDB 命令,但整個終端機從配色到行為都跟原本不一致,增加困擾。

這時我們可透過 Remote GDB 來除錯,方法是再建立一個終端機視窗,例如:

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

為了行文的便利,我們將實際執行 tic-tac-toe 的終端機視窗稱為「ttt 視窗」,而即將執行 GDB 並輸入相關命令的終端機視窗稱為「GDB 視窗」。若你會操作 tmux,那可輕易地切割終端機畫面,並在兩者間切換,當然你若堅持用滑鼠在 Linux 桌面的視窗間切換,也可以,不影響我們進行遠端 GDB 實驗。

在「ttt 視窗」中輸入以下命令:

$ gdbserver :1234 ttt

這時會看到終端機畫面輸出以下:

Process tic-tac-toe/ttt created; pid = 95219
Listening on port 1234

這裡的 PID 表示 process identifer,也就是作業系統用來識別所有執行中的行程 (process) 的數值,本例是 95219,當然在你實際執行時會有不同的數值,而 port 1234 是開放給 GDB 透過 1234 這個埠號 (port),以 TCP/IP 連線,對 ttt 這個程式進行遠端除錯。

我們切換到「GDB 視窗」,嘗試執行以下命令:

$ gdb -q ttt

預期可見以下輸出:

Reading symbols from ttt...done.

接著我們繼續在「GDB 視窗」輸入以下命令,以便連線到 ttt 程式:

(gdb) target remote :1234

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →
(gdb) 開頭表示輸入在 GDB 命令提示列要輸入的命令,以剛才的命令來說,實際你該輸入
target remote :1234 並按下 Enter 按鍵

這時就會發現「GDB 視窗」輸出類似以下訊息:

Remote debugging using :1234
Reading /lib64/ld-linux-x86-64.so.2 from remote target...
warning: File transfers from remote targets can be slow. Use "set sysroot" to access files locally instead.
Reading /lib64/ld-linux-x86-64.so.2 from remote target...
Reading symbols from target:/lib64/ld-linux-x86-64.so.2...Reading /lib64/ld-2.27.so from remote target...
Reading /lib64/.debug/ld-2.27.so from remote target...
(no debugging symbols found)...done.
0x00007ffff7dd6090 in ?? () from target:/lib64/ld-linux-x86-64.so.2

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →
為了行文便利,我們將目前這階段稱為「連線到 GDB 伺服器」

而在「ttt 視窗」也有更新:

Remote debugging from host 127.0.0.1

我們繼續在「GDB 視窗」輸入命令:

(gdb) break main
(gdb) continue

預期會有以下輸出:

Breakpoint 1 at 0x555555555386: file game.c, line 378.
Continuing.
Reading /lib/x86_64-linux-gnu/libpthread.so.0 from remote target...
Reading /lib/x86_64-linux-gnu/libc.so.6 from remote target...
Reading /lib/x86_64-linux-gnu/libc-2.27.so from remote target...
Reading /lib/x86_64-linux-gnu/.debug/libc-2.27.so from remote target...

Breakpoint 1, main () at game.c:378
378	{

中斷點停在 main 函式,用 list 命令查閱相關原始程式碼:

(gdb) list
373	
374	    return 0;
375	}
376	
377	int main()
378	{
379	    return game_session();
380	}

再輸入 continue (可簡寫 c) 命令讓 ttt 程式得以繼續執行:

(gdb) continue 
Continuing.

Program received signal SIGSEGV, Segmentation fault.
0x0000000000000000 in ?? ()

這時可見我們稍早故意加入的記憶體錯誤操作,可用 where 命令觀察 stack frame:

(gdb) where 
#0  0x0000000000000000 in ?? ()
#1  0x0000555555555594 in game_session () at game.c:320
#2  main () at game.c:379

既然 main 函式只是呼叫 game_session 函式,後者才是嫌疑犯,觀察 game.c 第 320 行附近的程式碼:

(gdb) list game.c:320
315	
316	    main_screen();
317	    update_game_field(0, 0);
318	
319	    void (*func_ptr)() = 0;
320	    (*func_ptr)();
321	
322	    tui_save_term();
323	    tui_set_term(0, 0, 1, 0, 1);
324	    tui_set_cursor(0); /* hide cursor */

其中第 319 到 320 行就是致命處,即存取到 null pointer。當然你可以修改原始程式碼、重新編譯再執行觀察輸出,不過有了 GDB,就不需要反覆修改和重新編譯程式碼。

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →
當然你需要確保編譯時,參數 -g 已正確傳遞給 gcc,以 tic-tac-toe 來說,檔案 MakefileCFLAGS 已指定 -g 編譯參數,因此 GDB 可自輸出的執行檔中讀取到除錯用的資訊。

先在「GDB 視窗」中,用 detach 命令離開遠端 GDB,並輸入 quit 命令離開 GDB。再重複上述「連線到 GDB 伺服器」的動作。繼續在「GDB 視窗」中,輸入以下命令:

(gdb) b 319

bbreak 命令的簡寫,而 319 則是上面我們談到可疑的程式碼,我們在此設定中斷點,避免 GDB 實際執行到這行。接著執行 c 命令,要求 GDB 執行到第 319 行「以前」(注意: 第 319 行還沒執行),這時 GDB 輸出以下:

Breakpoint 1, game_session () at game.c:320
320	    (*func_ptr)();

既然我們懷疑 func_ptr 會觸發 null pointer 存取,於是我們直接跳過第 319 和 320 行,也就是要求跳躍到第 322 行 (即 tui_save_term()),輸入以下命令:

(gdb) jump 322

注意這時 GDB 會提示以下:

Line 322 is not in 'game_session'.  Jump anyway? (y or n)

按下 y 即可讓 ttt 程式繼續執行。切換到「ttt 視窗」,就發現井字遊戲可以繼續玩,試著用方向鍵移動到合適的位置,並用 Enter 按鍵下子。

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →
避免讓井字遊戲過早結束,移動到井字中間那格下子。

切換到「GDB 視窗」,按下 Ctrl-C 組合按鍵,GDB 會顯示類似以下訊息:

Thread 1 "ttt" received signal SIGINT, Interrupt.

輸入 where 命令,已得知所處的 stack frame,在 read 系統呼叫執行前,程式碼在 tui.c 的第 168 行,用 list 命令觀察:

(gdb) list tui.c:168
163	        return -1;
164	
165	    int len = 1;
166	    int res;
167	    char tmp_key[KEY_MAX_LEN];
168	    while (read(0, &(tmp_key[len - 1]), 1) > 0 && len <= KEY_MAX_LEN) {
169	        res = is_supported_key(tmp_key, key, len);
170	        if (res <= 0)
171	            return res;
172	        len++;

這就是等待鍵盤輸入的 event loop,我們可善用 GDB 來得知程式碼的運作,像是「Enter 鍵按下後,到底發生什麼事?」

檔案 tui.c 實作以終端機為基礎的人機互動介面,即 terminal user interface,簡稱 TUI

我們可用 GDB 命令 next (簡稱 n),允許我們單步執行:

(gdb) next

輸出我們還看不懂的訊息:

(gdb) next
28	in ../sysdeps/unix/sysv/linux/read.c

沒關係,我們再執行一次 next 命令,會發現輸出新的訊息:

tui_read_key (key=0x7fffffffe27c) at tui.c:168
168	    while (read(0, &(tmp_key[len - 1]), 1) > 0 && len <= KEY_MAX_LEN) {

這時切換到「ttt 視窗」按下右鍵 (cursor right),再切換到「GDB 視窗」,運用 GDB 命令來觀察:

(gdb) x tmp_key 
0x7fffffffe210:	0x0080001b
(gdb) p len
$1 = 1
(gdb) p tmp_key[0]
$2 = 27 '\033'

儘管我們尚未細讀 tic-tac-toe 原始程式碼,但從上述線索可推知:

  1. 等待鍵盤輸入的 event loop 接受 Enter 按鍵,更新 &tmp_key[1 - 1] 的內容 (因 len1,該表示式實際為 tmp_key[0])
  2. 檔案 tui.c 的第 168 行在接受有效按鍵後,應可進入迴圈
  3. tmp_key[0] 的內含值 27 ('\033') 和我們輸入的按鍵有關

我們可建立另一個新的終端機視窗,執行 $ man ascii 命令,觀察 ASCII 字元集:

       Oct   Dec   Hex   Char                        Oct   Dec   Hex   Char
       ────────────────────────────────────────────────────────────────────────
...
       033   27    1B    ESC (escape)                133   91    5B    [

可是我們剛才明明輸入右鍵,而不是 ESC 啊?!好奇怪。

這是 tty 對於 Escape character 處理機制有關,先賣個關子,之後讀者可用 GDB 追蹤。

這時我們再輸入一次 next 命令,就會發現程式執行推進到第 169 行:

(gdb) next
169	        res = is_supported_key(tmp_key, key, len);

執行 where 命令,可見到以下輸出:

(gdb) where 
#0  is_supported_key (len=1, key=0x7fffffffe27c, sequence=0x7fffffffe210 "\033[C") at tui.c:136
#1  tui_read_key (key=0x7fffffffe27c) at tui.c:169
#2  0x00005555555556dc in game_session () at game.c:335
#3  main () at game.c:379

注意到實際傳入至 is_supported_key 函式的參數是 \033[C",並非單一字元,而是 3 個字元。

我們可反覆在 GDB 輸入 next 命令,就會發現程式碼在特定幾行間跳躍執行。在 GDB 的命令提示列中,若要像上面那般想重複執行之前的命令,只要按下 Enter 鍵即可,因此你可以先輸入一個 next 命令,然後持續按下 Enter 鍵,就可達到單步執行。

強化 GDB 的顯示畫面

GDB 有個參數是 --tui 即 text user interface (TUI),以上述案例來說,可執行以下命令:

$ gdb --tui -q ttt

參考執行畫面如下:

搭配 GDB 命令 updownstack frame 間移動時,這個 GDB 文字介面的上方會顯示對應的原始程式碼。效果如下:

如果你嫌這樣的文字介面還是太陽春,可改用 cgdb

參考資訊