--- tags: sysprog --- # 透過 GDB 進行遠端除錯 > 資料整理: [jserv](http://wiki.csie.ncku.edu.tw/User/jserv) > 本實驗針對 GNU/Linux,以 Ubuntu Linux 18.04-LTS x86_64 為實驗環境 ## 遠端除錯為何重要? 試想一個情境:若你的手機裡頭的軟體 (特別是作業系統核心和關鍵的軟體框架) 出現非預期的錯誤,你該如何排除問題?你或許會聯想到除錯器 (debugger),但手機的硬體資源可能較為受限 (這裡指功能型手機),且手機可能反覆重新啟動,很難確保我們可穩定地執行除錯器,於是 GNU 工具集的開發者很早就提出 [Remote GDB](https://sourceware.org/gdb/current/onlinedocs/gdb/Remote-Debugging.html) 的機制,允許我們透過另一台電腦,對上述手機或特定的裝置進行除錯,可參閱 [遠端除錯](http://www.study-area.org/cyril/opentools/opentools/x1265.html) 先熟悉 GDB 命令和相關操作。 ![](https://i.imgur.com/Xhp58Cf.png) 上圖左邊是執行 GDB 的完整開發環境,開發者可在互動操作環境中,追蹤程式的執行。右邊則是我們想除錯的環境,可以是實體裝置,甚至是虛擬機器。 ## 使用案例:文字模式全螢幕更新的程式 就算不考慮手機或嵌入式裝置,[Remote GDB](https://sourceware.org/gdb/current/onlinedocs/gdb/Remote-Debugging.html) 在開發某些類型的程式依舊很管用,例如涉及到終端機畫面更新的程式,我們就不方便用 [printf](https://www.man7.org/linux/man-pages/man3/printf.3.html) 來印出特定的變數或記憶體內容,因為 [printf](https://www.man7.org/linux/man-pages/man3/printf.3.html) 的輸出可能會因終端機設定而屏蔽或顯示錯亂,而且也會干擾終端機畫面 (特別是文字模式的全螢幕畫面更新,如編輯器和電腦遊戲)。這時我們即可運用 [Remote GDB](https://sourceware.org/gdb/current/onlinedocs/gdb/Remote-Debugging.html) 來克服問題。 實驗之前,我們需要確保 GDB 和相關套件已正確安裝,以 Ubuntu Linux 為例,執行以下命令: ```shell $ sudo apt install gdb gdbserver $ sudo apt install build-essential manpages-dev ``` :::warning :information_source: ==`$ `== 開頭表示輸入在終端機的命令,以剛才的命令來說,實際你該輸入 ==`sudo apt install gdb gdbserver`== 並按下 Enter 按鍵 ::: 取得 [tic-tac-toe](https://github.com/jserv/tic-tac-toe) 原始程式碼: (最後一個命令是確保本文內容對應的原始程式碼彼此一致) ```shell $ git clone https://github.com/jserv/tic-tac-toe $ cd tic-tac-toe $ git checkout gdb-demo ``` [tic-tac-toe](https://github.com/jserv/tic-tac-toe) 是我們用來解說的程式,如名稱所示,這是個井字遊戲的實作,特別之處在於該程式直接控制 [tty](https://man7.org/linux/man-pages/man4/tty.4.html),以達到在終端機進行全螢幕更新的功能,這點跟 [vim](https://www.vim.org/) 或 [Emcas](https://www.gnu.org/software/emacs/) 一樣,因此我們不容易透過 [printf](https://www.man7.org/linux/man-pages/man3/printf.3.html) 進行文字輸出。 輸入 `$ make` 可產生 `ttt` 執行檔,[tic-tac-toe](https://github.com/jserv/tic-tac-toe) 執行畫面如下: ![](https://i.imgur.com/ciQw0BP.png) 我們試著修改 [tic-tac-toe](https://github.com/jserv/tic-tac-toe) 的程式碼,在 `game.c` 的 `game_session` 新增以下程式碼: ```diff @@ -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](https://github.com/jserv/tic-tac-toe) 在更新主畫面後,即刻遭遇記憶體存取錯誤,於是我們可見類似這樣的執行結果: ![](https://i.imgur.com/qBe4wEp.png) 畫面顯示 [Segmentation fault](https://en.wikipedia.org/wiki/Segmentation_fault),倘若我們透過 GDB 重新執行 [tic-tac-toe](https://github.com/jserv/tic-tac-toe),會遇到這樣的畫面: ![](https://i.imgur.com/oi1I3nX.png) 顯然,由於 [tic-tac-toe](https://github.com/jserv/tic-tac-toe) 變更 tty 的設定,現在終端機畫面就錯亂了,儘管我們仍可輸入 `where` 一類的 GDB 命令,但整個終端機從配色到行為都跟原本不一致,增加困擾。 這時我們可透過 [Remote GDB](https://sourceware.org/gdb/current/onlinedocs/gdb/Remote-Debugging.html) 來除錯,方法是再建立一個終端機視窗,例如: ![](https://i.imgur.com/t8YVcxh.png) 為了行文的便利,我們將實際執行 [tic-tac-toe](https://github.com/jserv/tic-tac-toe) 的終端機視窗稱為「ttt 視窗」,而即將執行 GDB 並輸入相關命令的終端機視窗稱為「GDB 視窗」。若你會操作 [tmux](https://en.wikipedia.org/wiki/Tmux),那可輕易地切割終端機畫面,並在兩者間切換,當然你若堅持用滑鼠在 Linux 桌面的視窗間切換,也可以,不影響我們進行遠端 GDB 實驗。 在「ttt 視窗」中輸入以下命令: ```shell $ gdbserver :1234 ttt ``` 這時會看到終端機畫面輸出以下: ``` Process tic-tac-toe/ttt created; pid = 95219 Listening on port 1234 ``` 這裡的 PID 表示 [process identifer](https://en.wikipedia.org/wiki/Process_identifier),也就是作業系統用來識別所有執行中的行程 (process) 的數值,本例是 `95219`,當然在你實際執行時會有不同的數值,而 `port 1234` 是開放給 GDB 透過 `1234` 這個埠號 (port),以 TCP/IP 連線,對 `ttt` 這個程式進行遠端除錯。 我們切換到「GDB 視窗」,嘗試執行以下命令: ```shell $ gdb -q ttt ``` 預期可見以下輸出: ``` Reading symbols from ttt...done. ``` 接著我們繼續在「GDB 視窗」輸入以下命令,以便連線到 `ttt` 程式: ```shell (gdb) target remote :1234 ``` :::warning :information_source: ==`(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 ``` :::info :warning: 為了行文便利,我們將目前這階段稱為「連線到 GDB 伺服器」 ::: 而在「ttt 視窗」也有更新: ``` Remote debugging from host 127.0.0.1 ``` 我們繼續在「GDB 視窗」輸入命令: ```shell (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` 命令查閱相關原始程式碼: ```shell (gdb) list 373 374 return 0; 375 } 376 377 int main() 378 { 379 return game_session(); 380 } ``` 再輸入 `continue` (可簡寫 `c`) 命令讓 `ttt` 程式得以繼續執行: ```shell (gdb) continue Continuing. Program received signal SIGSEGV, Segmentation fault. 0x0000000000000000 in ?? () ``` 這時可見我們稍早故意加入的記憶體錯誤操作,可用 `where` 命令觀察 [stack frame](https://en.wikipedia.org/wiki/Call_stack#STACK-FRAME): ```shell (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 行附近的程式碼: ```shell (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](https://en.wikipedia.org/wiki/Null_pointer)。當然你可以修改原始程式碼、重新編譯再執行觀察輸出,不過有了 GDB,就不需要反覆修改和重新編譯程式碼。 :::warning :information_source: 當然你需要確保編譯時,參數 `-g` 已正確傳遞給 gcc,以 [tic-tac-toe](https://github.com/jserv/tic-tac-toe) 來說,檔案 `Makefile` 的 `CFLAGS` 已指定 `-g` 編譯參數,因此 GDB 可自輸出的執行檔中讀取到除錯用的資訊。 ::: 先在「GDB 視窗」中,用 `detach` 命令離開遠端 GDB,並輸入 `quit` 命令離開 GDB。再重複上述「連線到 GDB 伺服器」的動作。繼續在「GDB 視窗」中,輸入以下命令: ```shell (gdb) b 319 ``` `b` 是 `break` 命令的簡寫,而 `319` 則是上面我們談到可疑的程式碼,我們在此設定中斷點,避免 GDB 實際執行到這行。接著執行 `c` 命令,要求 GDB 執行到第 319 行「以前」(注意: 第 319 行還沒執行),這時 GDB 輸出以下: ``` Breakpoint 1, game_session () at game.c:320 320 (*func_ptr)(); ``` 既然我們懷疑 `func_ptr` 會觸發 [null pointer](https://en.wikipedia.org/wiki/Null_pointer) 存取,於是我們直接跳過第 319 和 320 行,也就是要求跳躍到第 322 行 (即 `tui_save_term()`),輸入以下命令: ```shell (gdb) jump 322 ``` 注意這時 GDB 會提示以下: ``` Line 322 is not in 'game_session'. Jump anyway? (y or n) ``` 按下 `y` 即可讓 `ttt` 程式繼續執行。切換到「ttt 視窗」,就發現井字遊戲可以繼續玩,試著用方向鍵移動到合適的位置,並用 Enter 按鍵下子。 :::warning :warning: 避免讓井字遊戲過早結束,移動到井字中間那格下子。 ::: 切換到「GDB 視窗」,按下 Ctrl-C 組合按鍵,GDB 會顯示類似以下訊息: ``` Thread 1 "ttt" received signal SIGINT, Interrupt. ``` 輸入 `where` 命令,已得知所處的 [stack frame](https://en.wikipedia.org/wiki/Call_stack#STACK-FRAME),在 [read 系統呼叫](https://man7.org/linux/man-pages/man2/read.2.html)執行前,程式碼在 `tui.c` 的第 168 行,用 `list` 命令觀察: ```shell (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](https://en.wikipedia.org/wiki/Event_loop),我們可善用 GDB 來得知程式碼的運作,像是「Enter 鍵按下後,到底發生什麼事?」 :::warning 檔案 `tui.c` 實作以終端機為基礎的人機互動介面,即 terminal user interface,簡稱 `TUI`。 ::: 我們可用 GDB 命令 `next` (簡稱 `n`),允許我們單步執行: ```shell (gdb) next ``` 輸出我們還看不懂的訊息: ```shell (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 命令來觀察: ```shell (gdb) x tmp_key 0x7fffffffe210: 0x0080001b (gdb) p len $1 = 1 (gdb) p tmp_key[0] $2 = 27 '\033' ``` 儘管我們尚未細讀 [tic-tac-toe](https://github.com/jserv/tic-tac-toe) 原始程式碼,但從上述線索可推知: 1. 等待鍵盤輸入的 [event loop](https://en.wikipedia.org/wiki/Event_loop) 接受 Enter 按鍵,更新 `&tmp_key[1 - 1]` 的內容 (因 `len` 為 `1`,該表示式實際為 `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 啊?!好奇怪。 :::info 這是 tty 對於 [Escape character](https://en.wikipedia.org/wiki/Escape_character) 處理機制有關,先賣個關子,之後讀者可用 GDB 追蹤。 ::: 這時我們再輸入一次 `next` 命令,就會發現程式執行推進到第 169 行: ```shell (gdb) next 169 res = is_supported_key(tmp_key, key, len); ``` 執行 `where` 命令,可見到以下輸出: ```shell (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),以上述案例來說,可執行以下命令: ```shell $ gdb --tui -q ttt ``` 參考執行畫面如下: ![](https://i.imgur.com/9EiHjoS.png) 搭配 GDB 命令 `up` 和 `down` 在 [stack frame](https://en.wikipedia.org/wiki/Call_stack#STACK-FRAME) 間移動時,這個 GDB 文字介面的上方會顯示對應的原始程式碼。效果如下: ![](https://i.imgur.com/vxVyJZU.png) 如果你嫌這樣的文字介面還是太陽春,可改用 [cgdb](https://cgdb.github.io/)。 ## 參考資訊 * [gdb Debugging Full Example (Tutorial): ncurses](http://www.brendangregg.com/blog/2016-08-09/gdb-example-ncurses.html)