資料整理: jserv
本實驗針對 GNU/Linux,以 Ubuntu Linux 18.04-LTS x86_64 為實驗環境
試想一個情境:若你的手機裡頭的軟體 (特別是作業系統核心和關鍵的軟體框架) 出現非預期的錯誤,你該如何排除問題?你或許會聯想到除錯器 (debugger),但手機的硬體資源可能較為受限 (這裡指功能型手機),且手機可能反覆重新啟動,很難確保我們可穩定地執行除錯器,於是 GNU 工具集的開發者很早就提出 Remote GDB 的機制,允許我們透過另一台電腦,對上述手機或特定的裝置進行除錯,可參閱 遠端除錯 先熟悉 GDB 命令和相關操作。
上圖左邊是執行 GDB 的完整開發環境,開發者可在互動操作環境中,追蹤程式的執行。右邊則是我們想除錯的環境,可以是實體裝置,甚至是虛擬機器。
就算不考慮手機或嵌入式裝置,Remote GDB 在開發某些類型的程式依舊很管用,例如涉及到終端機畫面更新的程式,我們就不方便用 printf 來印出特定的變數或記憶體內容,因為 printf 的輸出可能會因終端機設定而屏蔽或顯示錯亂,而且也會干擾終端機畫面 (特別是文字模式的全螢幕畫面更新,如編輯器和電腦遊戲)。這時我們即可運用 Remote GDB 來克服問題。
實驗之前,我們需要確保 GDB 和相關套件已正確安裝,以 Ubuntu Linux 為例,執行以下命令:
$
開頭表示輸入在終端機的命令,以剛才的命令來說,實際你該輸入sudo apt install gdb gdbserver
並按下 Enter 按鍵
取得 tic-tac-toe 原始程式碼: (最後一個命令是確保本文內容對應的原始程式碼彼此一致)
tic-tac-toe 是我們用來解說的程式,如名稱所示,這是個井字遊戲的實作,特別之處在於該程式直接控制 tty,以達到在終端機進行全螢幕更新的功能,這點跟 vim 或 Emcas 一樣,因此我們不容易透過 printf 進行文字輸出。
輸入 $ make
可產生 ttt
執行檔,tic-tac-toe 執行畫面如下:
我們試著修改 tic-tac-toe 的程式碼,在 game.c
的 game_session
新增以下程式碼:
此舉強制 tic-tac-toe 在更新主畫面後,即刻遭遇記憶體存取錯誤,於是我們可見類似這樣的執行結果:
畫面顯示 Segmentation fault,倘若我們透過 GDB 重新執行 tic-tac-toe,會遇到這樣的畫面:
顯然,由於 tic-tac-toe 變更 tty 的設定,現在終端機畫面就錯亂了,儘管我們仍可輸入 where
一類的 GDB 命令,但整個終端機從配色到行為都跟原本不一致,增加困擾。
這時我們可透過 Remote GDB 來除錯,方法是再建立一個終端機視窗,例如:
為了行文的便利,我們將實際執行 tic-tac-toe 的終端機視窗稱為「ttt 視窗」,而即將執行 GDB 並輸入相關命令的終端機視窗稱為「GDB 視窗」。若你會操作 tmux,那可輕易地切割終端機畫面,並在兩者間切換,當然你若堅持用滑鼠在 Linux 桌面的視窗間切換,也可以,不影響我們進行遠端 GDB 實驗。
在「ttt 視窗」中輸入以下命令:
這時會看到終端機畫面輸出以下:
這裡的 PID 表示 process identifer,也就是作業系統用來識別所有執行中的行程 (process) 的數值,本例是 95219
,當然在你實際執行時會有不同的數值,而 port 1234
是開放給 GDB 透過 1234
這個埠號 (port),以 TCP/IP 連線,對 ttt
這個程式進行遠端除錯。
我們切換到「GDB 視窗」,嘗試執行以下命令:
預期可見以下輸出:
接著我們繼續在「GDB 視窗」輸入以下命令,以便連線到 ttt
程式:
(gdb)
開頭表示輸入在 GDB 命令提示列要輸入的命令,以剛才的命令來說,實際你該輸入target remote :1234
並按下 Enter 按鍵
這時就會發現「GDB 視窗」輸出類似以下訊息:
而在「ttt 視窗」也有更新:
我們繼續在「GDB 視窗」輸入命令:
預期會有以下輸出:
中斷點停在 main
函式,用 list
命令查閱相關原始程式碼:
再輸入 continue
(可簡寫 c
) 命令讓 ttt
程式得以繼續執行:
這時可見我們稍早故意加入的記憶體錯誤操作,可用 where
命令觀察 stack frame:
既然 main
函式只是呼叫 game_session
函式,後者才是嫌疑犯,觀察 game.c
第 320 行附近的程式碼:
其中第 319 到 320 行就是致命處,即存取到 null pointer。當然你可以修改原始程式碼、重新編譯再執行觀察輸出,不過有了 GDB,就不需要反覆修改和重新編譯程式碼。
-g
已正確傳遞給 gcc,以 tic-tac-toe 來說,檔案 Makefile
的 CFLAGS
已指定 -g
編譯參數,因此 GDB 可自輸出的執行檔中讀取到除錯用的資訊。
先在「GDB 視窗」中,用 detach
命令離開遠端 GDB,並輸入 quit
命令離開 GDB。再重複上述「連線到 GDB 伺服器」的動作。繼續在「GDB 視窗」中,輸入以下命令:
b
是 break
命令的簡寫,而 319
則是上面我們談到可疑的程式碼,我們在此設定中斷點,避免 GDB 實際執行到這行。接著執行 c
命令,要求 GDB 執行到第 319 行「以前」(注意: 第 319 行還沒執行),這時 GDB 輸出以下:
既然我們懷疑 func_ptr
會觸發 null pointer 存取,於是我們直接跳過第 319 和 320 行,也就是要求跳躍到第 322 行 (即 tui_save_term()
),輸入以下命令:
注意這時 GDB 會提示以下:
按下 y
即可讓 ttt
程式繼續執行。切換到「ttt 視窗」,就發現井字遊戲可以繼續玩,試著用方向鍵移動到合適的位置,並用 Enter 按鍵下子。
切換到「GDB 視窗」,按下 Ctrl-C 組合按鍵,GDB 會顯示類似以下訊息:
輸入 where
命令,已得知所處的 stack frame,在 read 系統呼叫執行前,程式碼在 tui.c
的第 168 行,用 list
命令觀察:
這就是等待鍵盤輸入的 event loop,我們可善用 GDB 來得知程式碼的運作,像是「Enter 鍵按下後,到底發生什麼事?」
檔案 tui.c
實作以終端機為基礎的人機互動介面,即 terminal user interface,簡稱 TUI
。
我們可用 GDB 命令 next
(簡稱 n
),允許我們單步執行:
輸出我們還看不懂的訊息:
沒關係,我們再執行一次 next
命令,會發現輸出新的訊息:
這時切換到「ttt 視窗」按下右鍵 (cursor right),再切換到「GDB 視窗」,運用 GDB 命令來觀察:
儘管我們尚未細讀 tic-tac-toe 原始程式碼,但從上述線索可推知:
&tmp_key[1 - 1]
的內容 (因 len
為 1
,該表示式實際為 tmp_key[0]
)tui.c
的第 168 行在接受有效按鍵後,應可進入迴圈tmp_key[0]
的內含值 27
('\033'
) 和我們輸入的按鍵有關我們可建立另一個新的終端機視窗,執行 $ man ascii
命令,觀察 ASCII 字元集:
可是我們剛才明明輸入右鍵,而不是 ESC 啊?!好奇怪。
這是 tty 對於 Escape character 處理機制有關,先賣個關子,之後讀者可用 GDB 追蹤。
這時我們再輸入一次 next
命令,就會發現程式執行推進到第 169 行:
執行 where
命令,可見到以下輸出:
注意到實際傳入至 is_supported_key
函式的參數是 \033[C"
,並非單一字元,而是 3 個字元。
我們可反覆在 GDB 輸入 next
命令,就會發現程式碼在特定幾行間跳躍執行。在 GDB 的命令提示列中,若要像上面那般想重複執行之前的命令,只要按下 Enter 鍵即可,因此你可以先輸入一個 next
命令,然後持續按下 Enter 鍵,就可達到單步執行。
GDB 有個參數是 --tui
即 text user interface (TUI),以上述案例來說,可執行以下命令:
參考執行畫面如下:
搭配 GDB 命令 up
和 down
在 stack frame 間移動時,這個 GDB 文字介面的上方會顯示對應的原始程式碼。效果如下:
如果你嫌這樣的文字介面還是太陽春,可改用 cgdb。