---
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)