1. 程式人生 > >從一個例項來認識GDB與高效除錯

從一個例項來認識GDB與高效除錯

GDB的全稱是GNU project debugger,是類Unix系統上一個十分強大的偵錯程式。這裡通過一個簡單的例子(插入演算法)來介紹如何使用gdb進行除錯,特別是如何通過中斷來高效地找出死迴圈;我們還可以看到,在修正了程式錯誤並重新編譯後,我們仍然可以通過原先的GDB session進行除錯(而不需要重開一個GDB),這避免了一些重複的設定工作;同時,在某些受限環境中(比如某些實時或嵌入式系統),往往只有一個Linux字元介面可供除錯。這種情況下,可以使用job在程式碼編輯器、編譯器(編譯環境)、偵錯程式之間做到無縫切換。這也是高效除錯的一個方法。

先來看看這段插入排序演算法(a.cpp),裡面有一些錯誤。

// a.cpp
#include <stdio.h>
#include <stdlib.h>

int x[10];
int y[10];
int num_inputs;
int num_y = 0;

void get_args(int ac, char **av)
{ 
   num_inputs = ac - 1;
   for (int i = 0; i < num_inputs; i++)
      x[i] = atoi(av[i+1]);
}

void scoot_over(int jj)
{ 
   for (int k = num_y-1; k > jj; k++)
      y[k] = y[k-1];
}

void insert(int new_y)
{ 
   if (num_y = 0)
   {
      y[0] = new_y;
      return;
   }

   for (int j = 0; j < num_y; j++)
   {
      if (new_y < y[j])
      {
         scoot_over(j);
         y[j] = new_y;
         return;
      }
   }
}

void process_data()
{
   for (num_y = 0; num_y < num_inputs; num_y++)
      insert(x[num_y]);
}

void print_results()
{ 
   for (int i = 0; i < num_inputs; i++)
      printf("%d\n",y[i]);
}

int main(int argc, char ** argv)
{ 
   get_args(argc,argv);
   process_data();
   print_results();
   return 0;
}

程式碼就不分析了,稍微花點時間應該就能明白。你能發現幾個錯誤?

使用gcc編譯:

gcc -g -Wall -o insert_sort a.cpp

"-g"告訴gcc在二進位制檔案中加入除錯資訊,如符號表資訊,這樣gdb在除錯時就可以把地址和函式、變數名對應起來。在除錯的時候你就可以根據變數名檢視它的值、在原始碼的某一行加一個斷點等,這是除錯的先決條件。“-Wall”是把所有的警告開關開啟,這樣編譯時如果遇到warning就會打印出來。一般情況下建議開啟所有的警告開關。

執行編譯後的程式(./insert_sort),才發現程式根本停不下來。上偵錯程式!(有些bug可能一眼就能看出來,這裡使用GDB只是為了介紹相關的基本功能)

TUI模式

現在版本的GDB都支援所謂的終端使用者介面模式(Terminal User Interface),就是在顯示GDB命令列的同時可以顯示原始碼。好處是你可以隨時看到當前執行到哪條語句。之所以叫TUI,應該是從GUI抄過來的。注意,可以通過ctrl + x + a來開啟或關閉TUI模式。

gdb -tui ./insert_sort


死迴圈

進入GDB後執行run命令,傳入命令列引數,也就是要排序的陣列。當然,程式也是停不下來:


為了讓程式停下來,我們可以傳送一箇中斷訊號(ctrl + c),GDB捕捉到該訊號後會掛起被除錯程序。注意,什麼時候傳送這個中斷有點技巧,完全取決於我們的經驗和程式的特點。像這個簡單的程式,正常情況下幾乎立刻就會執行完畢。如果感覺到延遲就說明已經發生了死迴圈(或其他什麼),這時候發出中斷肯定落在死迴圈的迴圈體中。這樣我們才能通過檢查上下文來找到有用資訊。大型程式如果正常情況下就需要跑個幾秒鐘甚至幾分鐘,那麼你至少需要等到它超時後再去中斷。


此時,程式暫停在第44行(第44行還未執行),TUI模式下第44行會被高亮顯示。我們知道,這一行是某個死迴圈體中的一部分。

因為暫停的程式碼有一定的隨機性,可以多執行幾次,看看每次停留的語句有什麼不同。後面執行run命令的時候可以不用再輸入命令列引數(“12 5”),GDB會記住。還有,再執行run的時候GDB會問是否重頭開始執行程式,當然我們要從頭開始執行。

基本確定位置後(如上面的44行),因為這個程式很小,可以單步(step)一條條語句檢視。不難發現問題出在第24行,具體的步驟就省略了。

無縫切換

在編碼、除錯的時候,除非你有整合開發環境,一般你會需要開啟三個視窗:程式碼編輯器(比如很多人用的VIM)、編譯器(新開的視窗執行gcc或者make命令、執行程式等)、偵錯程式。整合開發環境當然好,但某些倒閉的場合下你無法使用任何GUI工具,比如一些僅提供字元介面的嵌入式裝置——你只有一個Linux命令列可以使用。顯然,如果在VIM中修改好程式碼後需要先關閉VIM才能敲入gcc的編譯命令,或者除錯過程中發現問題需要先關閉偵錯程式才能重新開啟VIM修改程式碼、編譯、再重新開啟偵錯程式,那麼不言而喻,這個過程太痛苦了!

好在可以通過Linux的作業管理機制,通過ctrl + z把當前任務掛起,返回終端做其他事情。通過jobs命令可以檢視當前shell有哪些任務。比如,當我暫停GDB時,jobs顯示我的VIM編輯器程序與GDB目前都處於掛起狀態。


以下是些相關的命令,比較常用

fg %1         // 開啟VIM,1是VIM對應的作業號
fg %2         // 開啟GDB
bg %1         // 讓VIM到後臺執行
kill %1 && fg // 徹底殺死VIM程序

GDB的“線上重新整理”

好了,剛才介紹了無縫切換,那我們可以在不關閉GDB的情況下(注意,ctrl + z不是關閉GDB這個程序,只是掛起)切換到VIM中去修改程式碼來消除死迴圈(把第24行的“if (num_y = 0)" 改成"if (num_y == 0)")。動作序列可以是:

ctrl + z // 掛起GDB
jobs     // 檢視VIM對應的作業號,假設為1
fg %1    // 進入VIM,修改程式碼..
ctrl + z // 修改完後掛起VIM
gcc -g -Wall -o insert_sort a.cpp // 重新編譯程式
fg %2    // 進入GDB,假設GDB的作業號為2

現在,我們又返回GDB除錯介面了!但在除錯前還有一步,如何讓GDB識別新的程式(因為程式已經重新編譯)?只要再次執行run就可以了。因為GDB沒有關閉,所以之前設定的斷點、執行run時傳入的命令列引數等還保留著,不需要重新輸入。很好用吧!

GDB自動檢測到程式發生改變,重新載入符號。

其他bug

關於本例中的其他bug,這裡就不多說了。有興趣的同學,可以和我討論。