對於那些具有高階程式語言諸如: Ruby、Scheme、Haskell 等背景的人來說,學習 C 語言是具有挑戰性的。除了糾結於 C 語言中像手動記憶體管理和指標等底層特性外,你必須在沒有 REPL ( Read-Eval-Print Loop ) 的條件下完成工作。一旦你已經習慣於在 REPL 環境下進行探索性的程式設計,必須進行“編寫-編譯-執行”這樣迴圈實在有點令人生厭。
最近我發現其實可以用 GDB 來作為 C 語言的偽 REPL。我一直嘗試使用 GDB 作為學習 C 語言的工具,而不僅僅是用來除錯 C 程式,事實上這非常有趣。
這篇文章我的目的就是向你展示 GDB 是一個非常好的學習 C 語言工具。下面我將會向你介紹一些我最喜歡的 GDB 命令,然後我會向你闡述怎樣使用 GDB 來理解 C 語言中一個出了名的複雜問題:陣列和指標的區別。
GDB 簡介
從建立一個簡單的 C 程式開始,minimal.c:
1 2 3 4 5 |
int main() { int i = 1337; return 0; } |
注意這個程式並沒有做任何事情,也沒有一條輸出指令。擁抱使用 GDB 學習 C 語言的美麗新世界吧!
使用 -g 引數進行編譯,這樣會生成一些有助於 debug,gdb 可以利用的資訊,編譯後用 GDB 執行起來:
1 2 |
$ gcc -g minimal.c -o minimal $ gdb minimal |
你現在應該能看到明顯的 GDB 提示行。我之前告訴你這是一個 REPL,下面我們就來試試:
1 2 |
(gdb) print 1 + 2 $1 = 3 |
多麼神奇! print 是 GDB 的內建命令,他能夠列印出一個 C 語言命令的返回值。如果你不確定一個 GDB 命令是做什麼,嘗試在 GDB 提示下執行命令 help。
然後是一個更有趣的例子:
1 2 |
(gbd) print (int) 2147483648 $2 = -2147483648 |
這裡我先忽略為什麼 2147483648 == -2147483648;我想要說明的是即使是算術運算在 C 語言中也是有很多坑的,GDB 能夠理解執行 C 語言中的算術運算。
現在讓我們在主函式中設定一個斷點然後執行程式:
1 2 |
(gdb) break main (gdb) run |
現在程式在第 3 行處暫停,正好在 i 進行初始化之前。有趣的是,儘管 i 還沒有被初始化,我們依然能夠使用 print 命令看到它的值。
1 2 |
(gdb) print i $3 = 32767 |
在 C 語言中,一個未被初始化的區域性變數的值是沒有定義的,所以你用 GDB 列印出的值可能與這裡的不一樣。
我們可以用 next 命令來執行當前斷點這一行:
1 2 3 |
(gdb) next (gdb) print i $4 = 1337 |
使用 x 命令檢查記憶體
在 C 語言中變數用來標示一塊連續的記憶體區間。一個變數的記憶體區間由兩個數字決定:
- 這塊記憶體第一個位元組數的數值地址
- 記憶體的大小,單位是位元組。變數所佔內容的大小取決於變數的型別。
C 語言中一個獨特的特性是你能夠直接訪問變數所佔的記憶體。操作符 & 可以計算一個變數的地址,操作符 sizeof 計算變數所佔記憶體的大小。
你可以在 GDB 中測試以上兩個概念:
1 2 3 4 |
(gdb) print &i $5 = (int *) 0x7fff5fbff584 (gdb) print sizeof(i) $6 = 4 |
字面上看,i 所佔記憶體起始於地址 0x7fff5fbff5b4,佔記憶體 4 個位元組。
我前面提到的變數在記憶體中的大小取決於它的型別,所以操作符 sizeof 能夠直接作用於型別:
1 2 3 4 |
(gdb) print sizeof(int) $7 = 4 (gdb) print sizeof(double) $8 = 8 |
以上顯示意味著,至少在我的計算機上 int 變數佔 4 個位元組空間,double 變數佔 8 個位元組。
GDB 帶來了一個功能強大的工具,能夠直接檢測記憶體:x 命令。x 命令從一個特定的地址開始檢測記憶體。結合一些結構化的命令和這些已給的命令能精確控制你想檢測多少位元組,你想怎樣列印它們。當你有疑問時,嘗試在 GDB 提示下執行 help x。
& 操作符計算變數的地址,這意味著我們能將 &i 返回給 x,從而看到 i 值背後原始的位元組。
1 2 |
(gdb) x/4xb &i 0x7fff5fbff584: 0x39 0x05 0x00 0x00 |
標識參數列示我想要檢查 4 個值,格式是十六進位制,一次顯示一個位元組。我選擇檢查 4 個位元組,是因為 i 在記憶體中的大小是 4 位元組;逐位元組列印出 i 在記憶體中的表示。
在 Intel 機器上有一個坑應當記得,逐位元組檢測時位元組數是以“小端”順序儲存:不像人類一般使用的標記方法,一個數字的低位在記憶體中排在前面(個位數在十位數之前)。
為了讓這個問題更加明顯,我們可以為 i 賦一個特別的值,然後重新檢測所佔記憶體。
1 2 3 |
(gdb) set var i = 0x12345678 (gdb) x/4xb &i 0x7fff5fbff584: 0x78 0x56 0x34 0x12 |
使用 ptype 檢查型別
ptype 命令可能是我最喜愛的命令。它告訴你一個 C 語言表示式的型別。
1 2 3 4 5 6 |
(gdb) ptype i type = int (gdb) ptype &i type = int * (gdb) ptype main type = int (void) |
C 語言中的型別可以變得很複雜,但是好在 ptype 允許你互動式地檢視他們。
指標和陣列
陣列在C語言中是非常難以捉摸的概念。這節的計劃是寫出一個簡單的程式,然後在 GDB 中執行,直至它的意義變得清晰易懂。
編寫如下的程式,array.c:
1 2 3 4 5 |
int main() { int a[] = {1,2,3}; return 0; } |
使用 -g 作為命令列引數進行編譯,在 GDB 中執行,然後輸入 next,執行初始化那一行
1 2 3 4 5 |
$ gcc -g arrays.c -o arrays $ gdb arrays (gdb) break main (gdb) run (gdb) next |
在這裡,你應該能夠列印出 a 的內容並檢查它的型別:
1 2 3 4 |
(gdb) print a $1 = {1, 2, 3} (gdb) ptype a type = int [3] |
現在我們的程式已經在 GDB 中執行起來了,我們應該做的第一件事是使用 x 看看 a 在記憶體中是什麼樣子。
1 2 3 |
(gdb) x/12xb &a 0x7fff5fbff56c: 0x01 0x00 0x00 0x00 0x02 0x00 0x00 0x00 0x7fff5fbff574: 0x03 0x00 0x00 0x00 |
以上意思是 a 所佔記憶體開始於地址 0x7fff5fbff5dc。起始的四個位元組儲存 a[0], 隨後的四個位元組儲存 a[1], 最後的四個位元組儲存 a[2]。事實上你可以通過 sizeof 得到,a 在記憶體中的大小是 12 位元組。
1 2 |
(gdb) print sizeof(a) $2 = 12 |
現在,陣列好像確實有個陣列的樣子。他們有自己的陣列型別,在連續的記憶體空間中儲存自己的成員。然而在某些情況下,陣列表現得更像指標。例如,我們能在 a 上進行指標運算。
1 2 3 4 |
= preserve do :escaped (gdb) print a + 1 $3 = (int *) 0x7fff5fbff570 |
字面上看,a+1 是一個指向 int 的指標,佔據地址 0x7fff5fbff570。這時,你應該反過來將指標傳遞給 x 命令,讓我們看看會發生什麼:
1 2 3 4 |
= preserve do :escaped (gdb) x/4xb a + 1 0x7fff5fbff570: 0x02 0x00 0x00 0x00 |
注意 0x7fff5fbff570 比 0x7fff5fbff56c 大 4,後者是 a 在記憶體地址中的第一個位元組。考慮到 int 值佔 4 位元組,這意味著 a+1 指向 a[1].
事實上,在 C 語言中陣列索引是指標運算的語法糖:a[i] 等於 *(a+i)。你可以在 GDB 中嘗試一下。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
= preserve do :escaped (gdb) print a[0] $4 = 1 (gdb) print *(a + 0) $5 = 1 (gdb) print a[1] $6 = 2 (gdb) print *(a + 1) $7 = 2 (gdb) print a[2] $8 = 3 (gdb) print *(a + 2) $9 = 3 |
我們已經看到在某些情況下,a 表現的像一個陣列,在另一些情況下表現得像一個指向它首元素的指標。接下來會發生什麼呢?
答案是當一個陣列名在 C 語言表示式中使用時,它“退化”成指向這個陣列首元素的指標。這個規則只有兩個例外:當陣列名傳遞給 sizeof 函式時,當陣列名傳遞給運算元 & 時。
事實上,a 在傳遞給運算元 & 時並沒有“退化”成一個指標,這就帶來一個有趣的問題:由“退化”變成的指標和 &a 存在區別嗎?
數值上講,他們都表示相同的地址:
1 2 3 4 5 6 |
= preserve do :escaped (gdb) x/4xb a 0x7fff5fbff56c: 0x01 0x00 0x00 0x00 (gdb) x/4xb &a 0x7fff5fbff56c: 0x01 0x00 0x00 0x00 |
然而,他們的型別是不同的。我們已經看到 a 退化的值是指向 a首元素的指標;這個必須是型別 int *。對於型別 &a,我們可以直接詢問 GDB:
1 2 3 4 |
= preserve do :escaped (gdb) ptype &a type = int (*)[3] |
從顯示上看,&a 是一個指向 3 個整數陣列的指標。這就說明:當傳遞給 & 時,a 沒有退化,a 有了一個型別,是 int[3]。
通過測試他們在指標運算時的表現,你可以觀察到 a 的退化值和 &a 的明顯區別。
1 2 3 4 5 6 |
= preserve do :escaped (gdb) print a + 1 $10 = (int *) 0x7fff5fbff570 (gdb) print &a + 1 $11 = (int (*)[3]) 0x7fff5fbff578 |
注意到對 a 增加 1 等於對 a 的地址增加 4,與此同時,對 &a 增加 1 等於對 a 的地址增加 12!
實際上 a 退化成的指標是 &a[0];
1 2 3 4 |
= preserve do :escaped (gdb) print &a[0] $11 = (int *) 0x7fff5fbff56c |
結論
希望我已經向你證明 GDB 是學習 C 語言的一個靈巧而有富有探索性的環境。你能使用 print 列印表示式的值,使用 x 檢視記憶體中原始位元組,使用 ptype 配合型別系統進行問題修補。
如果你想要進一步對使用 GDB 學習 C 語言進行嘗試,我有一些建議如下:
- 1.用 gdb 通過 Ksplice 指標挑戰。
- 2.研究結構體是怎樣在記憶體中儲存的? 他們與陣列比較又有什麼異同?
- 3.使用 GDB 的 disassemble 命令學習組合語言!一個特別有趣的練習是研究函式呼叫棧是如何工作的。
- 4.試試 GDB 的 “ tui ”模式,這個模式在常規 GDB 頂層提供一個影像化的 ncurses 層(Ncurses 提供字元終端處理庫,包括皮膚和選單)。在 OS X 系統中,你可能需要用原始碼安裝 GDB。
Alan 是 Hacker School 的推廣者。他想要感謝 David Albert、Tom Ballinger、Nicholas Bergson-Shilcock 和 Amy Dyer 給予非常有幫助的反饋。