本文由風鳴_Jan(微博)翻譯自BigNerdRanch,原文:A Lurking Horror in Debugging
通過深入調查這難以言說的恐怖——一個詭異到幾乎動搖了可憐的人類根基的bug,我克服了一種之前不熟悉的恐懼乃至近似恐怖的情緒。
上個禮拜我在我們的一個高級iOS訓練營教一門課程。教授這樣的課程的樂趣之一,就是有很多機會動態調試,因為我們的學生常常會遇到一些非常有趣的問題。在大多數情況下,調試過程會演變成兩個課程:“如何解決問題”和 “如何定位到問題” (也許是更重要的)。
個別bug初看起來是無害的。它是這樣開始的,“我遇到了一個程序crash,我不知道為什麼。”這個crash是100%可重現的:“啟動app,做一個兩指捏合(pinch)的手勢,然後它就掛了”。這類問題通常都比較簡單,八成是:“你在調用數據源委托前忘了……”。
當一個學生重現了那個問題,我們擠到了一個電腦前面。這是調試器裡的證據:
啊。
我的腦海裡立即跳出了兩件事。第一件,crash是發生在編譯器合成的property setter方法裡,對應的屬性是:
@property (strong, nonatomic) id interactiveTransition;
如果你回想一下我在 Thoughts on Debugging裡列的潛在批評層次,你應該記得編譯器是在我的批評列表的最底部。 但現在這個crash是編譯器產生的代碼導致的。要麼編譯器出了問題,要麼這個屬性值相關代碼出了問題。
然後調試器給出了傳給方法的地址:1. 等同於0x1或者0x00000001。這是個奇怪的地址。它不是nil,因為nil是全0. 它也肯定不是一個合法的地址——合法地址的值不但比它大得多,而且一定是16的倍數,因為對象是以16字節對齊的。也許這是個迷路的枚舉值或者其它什麼的。
我們來做個假設:-setInteractiveTransition 被傳進了一個假的值。那麼這個值的類型是什麼呢?也許有個模式可以導致0x0000001產生。一個驗證的簡單辦法是把編譯器產生的setter替換成原始的調試方法:
- (void) setInteractiveTransition: (id) transition { NSLog (@"Got set a transition of %p", transition); _interactiveTransition = transition; }
這段代碼打出了兩個nil:
2014-07-29 19:05:12.225 FieldTech[22852:60b] Got set a transition of 0x0 2014-07-29 19:05:20.588 FieldTech[22852:60b] Got set a transition of 0x0
然後在進入函數之前崩潰了:
尼瑪, 真奇怪。在工作了一段時間後,在打log之前,程序卻crash了,這讓我陷入了迷霧之中。為了安全,ARC在進入函數之前會retain傳進來的指針。在對crash附近的代碼快速反匯編之後我們發現,內存管理是工作的:
(lldb) disassemble ... 0x446a: movl %ecx, 0x4(%esp) 0x446e: movl %eax, -0x18(%ebp) 0x4471: calll 0x5a8e ; symbol stub for: objc_storeStrong -> 0x4476: movl -0x18(%ebp), %eax 0x4479: leal 0x4c8f(%eax), %ecx
這些反編譯的數據是否有用呢?好像不是。不管是編譯器生成的setter,還是我自己的代碼,看起來ARC都崩潰在了一個瘋狂的地址裡。
那麼這是怎麼發生的呢?堆棧跟蹤顯示調用來自-[UINavicationController _startCustomTransition:]。也許從這兒開始看應該比較靠譜。但是我們沒有可用的UIKit代碼,所以只能靠反匯編了。我使用的是Hopper Disassembler(http://hopperapp.com/),它能生成偽代碼。
這是個非常大的函數,但是它的構建過程很有趣:
它檢查了寄存器r5,r5是在調用_interactionController的時候初始化的。如果它的值是非0,那麼就把寄存器r2設置成0x1,然後調用setInteractiveTransition,並把r2傳給它。
所以0x1不是一個錯誤地址。它看起來像是個boolean值!為什麼會把一個boolean值傳給我們的方法呢?更奇怪的是,為什麼它會首先調用我們的這個方法呢?聽起來甚至像UINavigationController有它自己的interactiveTransitions屬性。
是時候讓Class-dump登場了!我們把UINavigationController dump出來:
@interface UINavigationController : UIViewController { UIView *_containerView; ... BOOL _interactiveTransition; } ... @property(nonatomic, getter=isInteractiveTransition) BOOL interactiveTransition;
尼瑪,猜到了開頭,猜不到結尾啊。一個沒有文檔說明的名叫interactiveTransition的屬性(property)潛伏在類的腹地,而且它的類型是BOOL。(這個名字看起來一點兒都不像BOOL型)這就是這個問題的原因。
編譯器不知道已經存在一個BOOL interactiveTransition的事,所以它無法告訴我們:“嘿,你在覆蓋一個不同類型的方法。你真的要這樣做麼?”然後Clang很歡地為這個Objective-C指針生成了適當的代碼,包括ARC的內存管理在內。
另外UINavigationController,也很歡地把BOOL值傳了進去。看到setInteractiveTransition打印出來的nil值了?實際上NO.nil和NO的值都是0,他們在運行時是沒法區分的。這是徒勞無功的事。
把那個property重命名一下就解決了這個問題。
干貨大派送
Leveling Up一文中提到的工具非常強大,它的用處不僅僅局限於破解系統。他們能給你提供信息。在調試的時候,信息就是王道。尤其是在Hopper Disassembler給了我們那個“HuH?“的驚喜的時候,其實就是在啟發我們用class-dump探查探查發生了什麼。
這個bug也給我們展示了Objective-C在某些情況下是多麼危險。編譯器有時根本無法知道有些錯誤發生了,所以它也沒辦法警告我們。作為C的衍生品,這個語言假設我們知道我們在干什麼,所以BOOL值傳給了指針,傳的很歡。
那麼Swift會有這個問題麼?
在修了這個bug之後,我在我們內部的一個iOS討論渠道裡發了個帖子。一個哥們Nerd冒出來說:”我相信Swift裡的private能解決這個問題。如果你有父/子類(在不同文件中)都定義了private func foo(),他們可以都存在而且彼此看不見對方。你不能在子類中調用super;在父類中的調用肯定會指向父類的版本,在子類中的調用會指向子類的版本。”Swift再得一分。