本文由风鸣_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值传给了指针,传的很欢。
在修了这个bug之后,我在我们内部的一个iOS讨论渠道里发了个帖子。一个哥们Nerd冒出来说:”我相信Swift里的private能解决这个问题。如果你有父/子类(在不同文件中)都定义了private func foo(),他们可以都存在而且彼此看不见对方。你不能在子类中调用super;在父类中的调用肯定会指向父类的版本,在子类中的调用会指向子类的版本。”Swift再得一分。