對于大多數Cocoa程 序員來說,最常用的debugger莫過于Xcode自帶的調試工具了。而實際上,它正是gdb的一個圖形化包裝。相對于gdb,圖形化帶來了很多便利, 但同時也缺少了一些重要功能。而且在某些情況下,gdb反而更加友善。是以,學習gdb,了解一下幕後的實質,也是有必要的。
gdb可以通過終端運作,也可以在Xcode的控制台調用指令。本文将通過終端講述一些gdb的基本指令和技巧。
首先,我們來看一個例子:
#import <Foundation/Foundation.h>
int main(int argc, char **argv)
{
NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init];
NSLog(@"Hello, world!");
[pool release];
return 0;
}
我們把檔案命名為test.m,然後編譯:
gcc -g -framework Foundation test.m
準備工作已經完成。現在我們可以開始調試了。隻要把要調試的檔案名作為參數,啟動gdb:
gdb a.out
gdb啟動後會輸出很多法律聲明之類的資訊。無視它們,最後我們看到一個提示:
(gdb)
成功!現在debugger和剛才編譯好的程式都被裝載了。不過,現在程式還沒有開始運作。因為gdb在程式開始前把它暫停了,好讓我們有機會設定調試參數。這次我們不需要做特别設定,是以馬上開始運作吧:
(gdb) run
Starting program: /Users/mikeash/shell/a.out
Reading symbols for shared libraries .++++....................... done
2011-06-16 20:28:53.658 a.out[2946:a0f] Hello, world!
Program exited normally.
(gdb)
糟糕,程式竟然exited normally了(==|||)。這可不行,我們得讓他崩潰才行。是以我們給這個小程式添加一個bug:
int x = 42;
NSLog("Hello, world! x = %@", x);
nice。這樣一來程式就會漂亮地崩潰了:
(gdb) run
Starting program: /Users/mikeash/shell/a.out
Reading symbols for shared libraries .++++....................... done
Program received signal EXC_BAD_ACCESS, Could not access memory.
Reason: 13 at address: 0x0000000000000000
0x00007fff84f1011c in objc_msgSend ()
(gdb)
如果我們是在shell中直接運作的程式,在崩潰後就會回到shell。不過現在我們是通過gdb運作的,是以現在并沒有跳出。gdb暫停了我們的程式,但依然使之駐留在記憶體中,讓我們有機會做調試。
首先,我們想知道具體是哪裡導緻了程式崩潰。gdb已經通過剛才的輸出告知了我們: 函數objc_msgSend就是禍之根源。但是這個資訊并不足夠,因為這個objc_msgSend是objc運作時庫中的函數。我們并不知道它是怎麼調用的。我們關注的是我們自己的代碼。
要知道這一點,我們需要得到目前程序的函數調用棧的情況,以此回溯找到我們自己的方法。這時我們需要用到backtrace指令,一般簡寫為bt:
(gdb) bt
#0 0x00007fff84f1011c in objc_msgSend ()
#1 0x00007fff864ff30b in _CFStringAppendFormatAndArgumentsAux ()
#2 0x00007fff864ff27d in _CFStringCreateWithFormatAndArgumentsAux ()
#3 0x00007fff8657e9ef in _CFLogvEx ()
#4 0x00007fff87beab3e in NSLogv ()
#5 0x00007fff87beaad6 in NSLog ()
#6 0x0000000100000ed7 in main () at test.m:10
現在我們可以看到,程式在test.m的第10行,調用NSLog方法時崩潰了。接下來我們想看一下這次調用的詳細資訊。這時我們要用到up指令。up指令可以在棧的各層之間跳轉。本例中,我們的代碼main是#6:
(gdb) up 6
#6 0x0000000100000ed7 in main () at test.m:10
9 NSLog("Hello, world! x = %@", x);
這回不僅是函數名,連出錯的那行代碼也列印出來了。但是,我們還可以使用list(簡寫為l)指令,列印出更多資訊:
ps: 如果需要回到棧清單。可以使用down指令。
(gdb) l
5
6 int main(int argc, char **argv)
7 {
8 NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init];
9 int x = 42;
10 NSLog("Hello, world! x = %@", x);
11 [pool release];
12
13 return 0;
14 }
啊,整個代碼都被列出來了。雖然我們用編輯器打開test.m檔案然後找到第10行也可以打到同樣效果,但顯然沒有上面的方法更有效率。(當然沒有Xcode自帶的那個快就是了)
好了,現在我們再來看看這個bug(雖然是我們自己弄出來的)。很明顯,在格式化字元串前少加了一個@。我們改正它,并重新運作一遍程式:
(gdb) run
Starting program: /Users/mikeash/shell/a.out
Reading symbols for shared libraries .++++....................... done
Program received signal EXC_BAD_ACCESS, Could not access memory.
Reason: KERN_INVALID_ADDRESS at address: 0x000000000000002a
0x00007fff84f102b3 in objc_msgSend_fixup ()
(gdb) bt
#0 0x00007fff84f102b3 in objc_msgSend_fixup ()
#1 0x0000000000000000 in ?? ()
啊咧,程式還是崩潰了。更杯具的是,棧資訊沒有顯示出這個objc_msgSend_fixup方法是從哪裡調用的。這樣我們就沒法用上面的方法找到目标代碼了。這時,我們隻好請出一個debugger最常用的功能:斷點。
在gdb中,設定斷點通過break指令實作。它可以簡寫為b。有兩種方法可以确定斷點的位置:傳入一個已定義的符号,或是直接地通過一個file:line對設定位置。
現在讓我們在main函數的開始處設定一個斷點:
(gdb) b test.m:8
Breakpoint 1 at 0x100000e8f: file test.m, line 8.
debugger給了我們一個回應,告訴我們斷點設定成功了,而且這個斷點的标号是1。斷點的标号很有用,可以用來給斷點排序&停用&啟用&删除等。不過我們現在不需要理會,我們隻是接着運作程式:
(gdb) run
The program being debugged has been started already.
Start it from the beginning? (y or n) y
Starting program: /Users/mikeash/shell/a.out
Breakpoint 1, main (argc=1, argv=0x7fff5fbff628) at test.m:8
8 NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init];
debugger在在我們期望的地方停下了。現在我們使用next(簡寫n)指令單步調試程式,看看它到底是在哪一行崩潰的:
(gdb) n
9 int x = 42;
(gdb)
10 NSLog(@"Hello, world! x = %@", x);
(gdb)
Program received signal EXC_BAD_ACCESS, Could not access memory.
Reason: KERN_INVALID_ADDRESS at address: 0x000000000000002a
0x00007fff84f102b3 in objc_msgSend_fixup ()
值得注意的是,我隻鍵入了一次n指令,随後直接敲了2次回車。這樣做的原因是gdb把任何空輸入當作最近一次輸入指令的重複。是以這裡相當于輸入了3次n。
現在我們可以看到,崩潰之處依然是NSLog。原因嘛,當然是在格式化輸出的地方用%@表示int型變量x了。我們仔細看一下輸出資訊:崩潰原因是錯誤地 通路了0x000000000000002a這個位址。而2a的十進制表示正是42--我們為x賦的值。編譯器把它當作位址了。
輸出數值
一個很重要的調試方法是輸出表達式和變量的值。在gdb中,這是通過print指令完成的。
(gdb) p x
$1 = 42
在print指令後追加/format可以格式化輸出。/format是一個gdb的格式化字元串,比較有用的格式化字元有 x:十進制數; c:字元; a:位址等。
(gdb) p/x x
$2 = 0x2a
print-object方法(簡寫為po)用來輸出obj-c中的對象。它的工作原理是,向被調用的對象發送名為debugDescription的消息。它和常見的description消息很像。
舉例來說,讓我們輸出一下autorelease pool:
(gdb) po pool
<NSAutoreleasePool: 0x10010e820>
這個指令不僅僅可以輸出顯式定義的對象,也可以輸出表達式的結果。這次我們測試一下nsobject中debugDescription的方法簽名:
(gdb) po [NSObject instanceMethodSignatureForSelector: @selector(debugDescription)]
<NSMethodSignature: 0x10010f320>
number of arguments = 2
frame size = 224
is special struct return? NO
return value: -------- -------- -------- --------
type encoding (@) '@'
flags {isObject}
modifiers {}
frame {offset = 0, offset adjust = 0, size = 8, size adjust = 0}
memory {offset = 0, size = 8}
argument 0: -------- -------- -------- --------
type encoding (@) '@'
flags {isObject}
modifiers {}
frame {offset = 0, offset adjust = 0, size = 8, size adjust = 0}
memory {offset = 0, size = 8}
argument 1: -------- -------- -------- --------
type encoding (:) ':'
flags {}
modifiers {}
frame {offset = 8, offset adjust = 0, size = 8, size adjust = 0}
memory {offset = 0, size = 8}
是不是很友善。但是要注意,gdb也許會不能識别NSObject這樣的類名。這時我們就要使用一些小技巧,比如說用NSClassFromString來獲得類名:
(gdb) po [NSClassFromString(@"NSObject") instanceMethodSignatureForSelector: @selector(debugDescription)]
傳回值是對象的表達式可以用po指令輸出結果,那麼傳回值是基本類型的方法又怎樣呢?顯然,它們是可以用p指令輸出的。但是要小心,因為gdb并不能自動識别出傳回值的類型。是以我們在輸出前要顯式地轉換一下:
(gdb) p [NSObject instancesRespondToSelector: @selector(doesNotExist)]
Unable to call function "objc_msgSend" at 0x7fff84f100f4: no return type information available.
To call this function anyway, you can cast the return type explicitly (e.g. 'print (float) fabs (3.0)')
(gdb) p (char)[NSObject instancesRespondToSelector: @selector(doesNotExist)]
$5 = 0 '00'
你也許發現了,doesNotExist方法的傳回值是BOOL,而我們做的轉換卻是char。這是因為gdb也不能識别那些用typedef定義的類型。不僅僅是你定義的,即使是Cocoa架構裡定義的也不行。
你也許已經注意到,在用p進行輸出的時侯,輸出值前面會有一個類似"$1="的字首。它們是gdb變量。它們可以在後面的表達式中使用,來指代它後面的 值。在下面的例子裡,我們開辟了一塊記憶體,将其置零,然後釋放。在這個過程中,我們使用了gdb變量,這樣就不用一遍遍地複制粘貼位址了。
(gdb) p (int *)malloc(4)
$6 = (int *) 0x100105ab0
(gdb) p (void)bzero($6, 4)
$7 = void
(gdb) p *$6
$8 = 0
(gdb) p (void)free($6)
$9 = void
我們也想把這個技巧用到對象上,但不幸的是po指令并不會把它的傳回值存儲到變量裡。是以我們在得到一個新的對象時必須先使用p指令:
(gdb) p (void *)[[NSObject alloc] init]
$10 = (void *) 0x100105950
(gdb) po $10
<NSObject: 0x100105950>
(gdb) p (long)[$10 retainCount]
$11 = 1
(gdb) p (void)[$10 release]
$12 = void
檢查記憶體
有些時候,僅僅輸出一個數值還不能幫助我們查找出錯誤。我們需要一次性地列印出一整塊記憶體來窺視全局。這時候我們就需要使用x指令。
x指令的格式是x/format address。其中address很簡單,它通常是指向一塊記憶體的表達式。但是format的文法就有點複雜了。它由三個部分組成:
第一個是要顯示的塊的數量;第二個是顯示格式(如x代表16進制,d代表十進制,c代表字元);第三個是每個塊的大小。值得注意的是第三部分,即塊大小是用字元對應的。用b, h, w, g 分别表示1, 2, 4, 8 bytes。舉例來說,用十六進制方式,列印從ptr開始的4個4-byte塊應該這樣寫:
(gdb) x/4xw ptr
接下來舉一個比較實際的例子。我們看一下NSObject類的内容:
(gdb) x/4xg (void *)[NSObject class]
0x7fff70adb468 <OBJC_CLASS_$_NSObject>: 0x00007fff70adb440 0x0000000000000000
0x7fff70adb478 <OBJC_CLASS_$_NSObject+16>: 0x0000000100105ac0 0x0000000100104ac0
接下來再看看一個NSObject執行個體的内容:
(gdb) x/1xg (void *)[NSObject new]
0x100105ba0: 0x00007fff70adb468
現在我們看到,在執行個體開頭引用了類的位址。
設定變量
有時,檢視數值程度的能力還是稍弱了一點,我們還想能夠修改變量。這也很簡單,隻需要使用set指令:
(gdb) set x = 43
我們可以用任意表達式給一個變量指派。比如說新建立一個對象然後指派:
(gdb) set obj = (void *)[[NSObject alloc] init]
斷點
我們可以在程式的某個位置設定斷點,這樣當程式運作到那裡的時候就會暫停,而把控制權轉移給調試器。就像之前提到的,我們用break指令來設定斷點。下面詳細地列出了如何設定斷點的目标:
SymbolName: 為斷點指定一個函數名。這樣斷點就會設定在該函數上。
file.c:1234: 把斷點設定在指定檔案的一行。
-[ClassName method:name:]: 把斷點設定在objc的方法上。用+代表類方法。
*0xdeadbeef: 在記憶體的指定位置設定斷點。這不是很常用,一般在沒有源碼的調試時使用。
斷點可以用enable指令和disable指令來切換到使用和停用狀态,也可以通過delete指令徹底删除。想要檢視現有斷點的話,使用info breakpoints指令(可以簡寫成info b,或是i b)。
另外,我們也可以用if指令,把斷點更新成條件斷點。顧名思義,條件斷點隻會在設定的條件成真時起作用。舉例來說,下面的語句為MyMethod添加了一個條件斷點,它隻在參數等于5的時候有效:
(gdb) b -[Class myMethod:] if parameter == 5
最後,在斷點上可以附加gdb指令。這樣,當斷點中斷時,附帶的指令會自動執行。附加指令使用commands breakpointnumber。這時gdb就會進入斷點指令輸入狀态。
斷點指令就是一個以end結尾的标準gdb指令序列。舉個例子,我們想在每次NSLog被調用時輸出棧資訊:
(gdb) b NSLog
Breakpoint 4 at 0x7fff87beaa62
(gdb) commands
Type commands for when breakpoint 4 is hit, one per line.
End with a line saying just "end".
>bt
>end
這很好了解,隻有一點需要提一下:如果commands指令是作用在剛設定的斷點上的話,那麼就可以省略斷點序号。
有些時候,我們希望調試器輸出一些資訊,但是并不想中斷程式運作。這實際上也可以通過追加指令實作。我們隻需要在指令的最後增加continue指令就行了。在下面的例子裡,我們在斷點中斷後列印棧資訊和參數資訊,随後繼續運作:
(gdb) b -[Class myMethod:]
Breakpoint 5 at 0x7fff864f1404
(gdb) commands
Type commands for when breakpoint 5 is hit, one per line.
End with a line saying just "end".
>bt
>p parameter
>continue
>end
最後一個奇特的運用是return指令。它和c中的同名指令一樣,都用來跳出目前函數。如果設定了參數,這參數會作為函數的傳回值。
比如說,我們可以用這個技巧屏蔽掉NSLog函數:
(gdb) commands
Type commands for when breakpoint 6 is hit, one per line.
End with a line saying just "end".
>return
>continue
>end
有一點需要提醒:雖然上述的技巧很有用,但同時它會帶來副作用。例如上面屏蔽NSLog的技巧會嚴重拖慢程式的運作速度。因為每次斷點中斷,都會使控制權轉移到debugger一邊,然後運作指令。這些跨程序的操作很耗時間。
有時候也許看不出來,但當執行的斷點變多,或是你在諸如objc_msgSend這樣的方法上添加了條件斷點,那麼也許你的程式會一直運作到天荒地老。
無源碼時的參數
有時我們需要在沒有代碼的地方調試。比如說,我們在用xcode調試時,經常會發現程式在Cocoa架構裡的某個地方崩潰了。我們需要找到到底是在哪裡出錯了。這種時候,一個可行的方法就是檢視崩潰處的參數,看看到底發生了什麼。
這是一篇很好的文章,它講解了在不同的體系結構下,參數是如何存儲的。不過它并沒有講到ARM(= =)。所幸ARM的存儲很簡單,參數隻是按順序被存儲在$r0, $r1, $r2, $r3寄存器裡。記住,在所有通過寄存器傳遞參數的體系結構裡(i386不是),隻有在函數開頭的一小段裡,寄存器裡存的才是參數。因為在程式進行的過程中,它們随時都可能被其他變量替換掉。
舉例來說,我們可以列印出傳給NSLog的參數:
Breakpoint 2, 0x00007fff87beaa62 in NSLog ()
(gdb) po $rdi
Hello, world!
這裡有個很常見的技巧:如果我們想給NSLog添加斷點來巡查崩潰,就可以根據輸出内容設定一下判斷,讓debugger不至于在每次NSLog時都中斷:
(gdb) break NSLog if (char)[$rdi hasPrefix: @"crashing"]
記住,方法的前兩個參數是self和_cmd。是以我們的參數應該從$rdx(x86_64)或$rd2(ARM)開始計算。
異常
異常會被運作時方法objc_exception_throw抛出。在這個方法裡設定斷點是很重要的。原因有兩點:
1. 抛出異常,通常是程式出現嚴重錯誤的信号。
2. 被抛出的異常通常會被對應的代碼捕獲。如果你不在這裡設定斷點的話,就隻能獲得異常被捕獲之後的資訊,而不知道它到底是在哪裡被抛出的。
如果你設定了斷點,程式就會在異常被抛出的時候停止。這樣你就有機會檢視棧資訊,知道具體是哪裡抛出了異常。
為異常設定斷點的方法也很簡單,因為要抛出的異常是objc_exception_throw方法的唯一一個參數,是以我們可以用上一小節提到的方法來完成它。
線程
現在,多線程代碼随處可見。知道如何調試多線程程式也越來越重要。以下一段代碼啟動了幾個背景運作的線程:
dispatch_apply(3, dispatch_get_global_queue(0, 0), ^(size_t x){
sleep(100);
});
運作debugger,在程式睡眠的時候用Control-C殺掉它:
(gdb) run
Starting program: /Users/mikeash/shell/a.out
Reading symbols for shared libraries .+++........................ done
^C
Program received signal SIGINT, Interrupt.
0x00007fff88c6ff8a in __semwait_signal ()
(gdb) bt
#0 0x00007fff88c6ff8a in __semwait_signal ()
#1 0x00007fff88c6fe19 in nanosleep ()
#2 0x00007fff88cbcdf0 in sleep ()
#3 0x0000000100000ea7 in __main_block_invoke_1 (.block_descriptor=0x1000010a0, x=0) at test.m:12
#4 0x00007fff88cbbbc8 in _dispatch_apply2 ()
#5 0x00007fff88cb31e5 in dispatch_apply_f ()
#6 0x0000000100000e6a in main (argc=1, argv=0x7fff5fbff628) at test.m:11
和我們想的一樣,我們輸出了一個線程的資訊。但是,另外兩個背景運作的線程在哪裡?我們可以用info threads指令擷取所有線程的清單:
(gdb) info threads
3 "com.apple.root.default-priorit" 0x00007fff88c6ff8a in __semwait_signal ()
2 "com.apple.root.default-priorit" 0x00007fff88c6ff8a in __semwait_signal ()
* 1 "com.apple.root.default-priorit" 0x00007fff88c6ff8a in __semwait_signal ()
線程1前面有個星号,這表示它是現在活動中的線程。現在我們切換到線程2:
(gdb) thread 2
[Switching to thread 2 (process 4794), "com.apple.root.default-priority"]
0x00007fff88c6ff8a in __semwait_signal ()
(gdb) bt
#0 0x00007fff88c6ff8a in __semwait_signal ()
#1 0x00007fff88c6fe19 in nanosleep ()
#2 0x00007fff88cbcdf0 in sleep ()
#3 0x0000000100000ea7 in __main_block_invoke_1 (.block_descriptor=0x1000010a0, x=1) at test.m:12
#4 0x00007fff88cbbbc8 in _dispatch_apply2 ()
#5 0x00007fff88c4f7f1 in _dispatch_worker_thread2 ()
#6 0x00007fff88c4f128 in _pthread_wqthread ()
#7 0x00007fff88c4efc5 in start_wqthread ()
現在我們輸出了線程2的資訊。然後時線程3……是不是覺得這種方法效率太低了?我們隻有3個線程,但如果有300個呢?幸好,gdb提供了thread apply all backtrace指令(簡寫為t a a bt),用來列出所有線程的詳細資訊。
Thread 3 (process 4794):
#0 0x00007fff88c6ff8a in __semwait_signal ()
#1 0x00007fff88c6fe19 in nanosleep ()
#2 0x00007fff88cbcdf0 in sleep ()
#3 0x0000000100000ea7 in __main_block_invoke_1 (.block_descriptor=0x1000010a0, x=2) at test.m:12
#4 0x00007fff88cbbbc8 in _dispatch_apply2 ()
#5 0x00007fff88c4f7f1 in _dispatch_worker_thread2 ()
#6 0x00007fff88c4f128 in _pthread_wqthread ()
#7 0x00007fff88c4efc5 in start_wqthread ()
Thread 2 (process 4794):
#0 0x00007fff88c6ff8a in __semwait_signal ()
#1 0x00007fff88c6fe19 in nanosleep ()
#2 0x00007fff88cbcdf0 in sleep ()
#3 0x0000000100000ea7 in __main_block_invoke_1 (.block_descriptor=0x1000010a0, x=1) at test.m:12
#4 0x00007fff88cbbbc8 in _dispatch_apply2 ()
#5 0x00007fff88c4f7f1 in _dispatch_worker_thread2 ()
#6 0x00007fff88c4f128 in _pthread_wqthread ()
#7 0x00007fff88c4efc5 in start_wqthread ()
Thread 1 (process 4794):
#0 0x00007fff88c6ff8a in __semwait_signal ()
#1 0x00007fff88c6fe19 in nanosleep ()
#2 0x00007fff88cbcdf0 in sleep ()
#3 0x0000000100000ea7 in __main_block_invoke_1 (.block_descriptor=0x1000010a0, x=0) at test.m:12
#4 0x00007fff88cbbbc8 in _dispatch_apply2 ()
#5 0x00007fff88cb31e5 in dispatch_apply_f ()
#6 0x0000000100000e6a in main (argc=1, argv=0x7fff5fbff628) at test.m:11
現在我們可以友善地檢視整個程式中的線程了。如果想要更徹底地觀察某個線程,隻需要用thread指令切換到該線程,然後使用各種已經學過的gdb指令。
控制台參數和環境變量
在用gdb調試帶參數的程式時會遇到一個疑惑,即程式的參數究竟怎麼輸入:
$ gdb /bin/echo hello world
Excess command line arguments ignored. (world)
[...]
/Users/mikeash/shell/hello: No such file or directory
如上,把參數直接綴在後面顯然是不對的。因為這樣它們會被解釋成gdb的參數,而不是要調試程式的參數。運作結果也證明了這一點,gdb把hello和world都解釋成了要運作的程式名。
解決方法也很簡單,即,在gdb啟動之後,執行run指令的同時輸入參數:
(gdb) run hello world
Starting program: /bin/echo hello world
Reading symbols for shared libraries +. done
hello world
環境變量可以在啟動gdb之前預先在shell中載入,通常情況下這麼做也沒有問題。但是,如果你操縱的環境變量會對每個程式都造成嚴重影響的話,這就不是一個好主意了。在這種情況下,我們用set env指令,做針對于目标程式的修改:
(gdb) set env DYLD_INSERT_LIBRARIES /gdb/crashes/if/this/is/inserted.dylib