关于猎杀不常见的大象

RENÉ DAMM / UNITY TECHNOLOGIESContributor
Apr 22, 2014|5 Min
关于猎杀不常见的大象
为方便起见,此网页已进行机器翻译。我们无法保证翻译内容的准确性或可靠性。如果您对翻译内容的准确性有疑问,请参阅此网页的官方英文版本。

捕虫很有趣时不时地,你还能带着一个故事活着离开,让你的孙子们听得津津有味("在我的年代,我们还在用木棍和石头捕虫 "等等)。

GDC 2014 为我们准备了另一场堪称战利品的狩猎之旅。还有五天,我们就要向全世界展示 Unity 5 了,这时我们 "发现 "了一个丑陋的小毛病(嗯,这还真有点难以错过):我们闪亮的新 64 位编辑器在 OSX 上会随机崩溃,以至于完全无法使用。没有什么比在台上每隔几分钟就展示一下你的虫虫记者有多厉害了。

于是,李维、乔纳森和我放下手头所有的工作(更多我们想让孙子们厌烦的故事),去跟踪了。我们当时只知道它在 Mono 运行时生成的本地代码的某个地方崩溃了。

每个程序员都知道,当你遇到一个不明显的错误时,你只需从收集证据开始。一旦你对虫子的行为模式有了足够的了解,你最终会有机会对付它。随着时间的流逝,我们已经准备好射击任何东西。

但我们被难住了。对于一头大象来说,这只虫子出奇地敏捷和狡猾。

这似乎只发生在 OSX 10.9 操作系统上,尽管 Kim 在 Windows 系统上使用他的重型内存调试器分支看到了明显类似的情况。如果你在早期版本的 OSX 上启用 Guard Malloc,也会得到类似的结果。然而,由于它是在调用层次结构中任意深度的随机脚本代码中崩溃的,因此很难确定哪些是相同的崩溃,哪些不是。连续十次的碰撞可能是一致的,但接下来的五次却完全不同。

因此,当我和 Kim 用膝盖啃内存、用大腿啃汇编代码的时候,Levi 对 Mono 的所有秘密和非秘密活动进行了大量跟踪,生成了一个千兆字节的日志和一个以我奶奶的速度运行的编辑器。这产生了第一个有趣的发现:显然,我们总是在事情变得糟糕之前编译我们崩溃的方法。

是什么让它崩溃了呢?直接原因是我们试图从一个无效 Address 执行代码。我们是如何到达那里的?Mono 信号处理中存在无法正常恢复的错误?Mono 的 JIT 编译器存在无法正常跳回已编译代码的错误?另一个线程破坏了主线程的堆栈内存?仙女和鬼怪?(有一点,后者似乎最有可能)。

经过两天的狩猎,大象依然活蹦乱跳。

因此,周六晚上我给自己准备了一本笔记本、四支不同颜色的笔,还从我们家标志性的 Unity 冰箱里拿出了充足的啤酒(小心翼翼地确保我不会碰那些还粘在冰箱缝隙里的难喝的圣诞罐装啤酒)。然后,我启动 Unity 实例,直到调试器中冻结了四种不同的崩溃情况,给它们分别贴上 "红色崩溃"、"蓝色崩溃"、"绿色崩溃 "和 "黑色崩溃 "的标签,然后用我的彩色笔分别记录下来,并把我发现的一切画成一些不太漂亮的图。

这是我的 "蓝色撞击 "笔记:

就在那时,我有了第一个发现:在每种情况下,堆栈都比应有的大 16 个字节!

这导致了下一个发现:对于所有崩溃,查看这额外的 16 个字节会发现返回地址,回到我们崩溃的函数中。从跟踪中可以清楚地看到,在所有情况下,我们都已经执行了来自同一方法的一些调用,起初我以为地址来自我们跟踪的最后一次调用。但仔细观察后发现,这实际上是一个方法尚未编译的调用的返回地址!

这让我困惑了一会儿,因为在某些情况下,在最后一个跟踪方法和这次调用之间有几次调用也尚未编译。然而,仔细一看,才发现我们总是在它们周围跳来跳去。

于是,我看了看我们显然应该从那个功能返回......

现在我们看到了(用蓝色标出):我们跳错了方向!

Mono 在这里所做的就是创建一些小的 "蹦床 "函数,这些函数只包含对 JIT 编译器的调用,以及调用后编码到指令流中的一些数据(JIT 编译器用来知道要编译哪个方法)。一旦 JIT 编译器完成工作,它就会删除这些蹦床,并抹去所有与方法调用挂钩的痕迹。

不过,你在这里看到的调用指令被称为 "近调用",它使用带符号的 32 位偏移来跳转到下一条指令。

由于带符号的 32 位数字上下只能达到 2GB,而我们在这里运行的是 64 位,因此我们突然明白了为什么堆内存布局在重现错误时起着如此关键的作用:一旦 Mono 的蹦床距离 JIT 编译器超过 2GB,偏移量就不再适合 32 位,并会在发出调用指令时被截断。

这时,乔纳森很快找到了正确的修复方法,当他的周日结束时,我们已经为 GDC 准备好了一个稳定的工作版本。

你们都知道那里的历史。我们在 2014 年的 GDC 上成功演示了 Unity 5,获得好评如潮,推出后迅速成为最受喜爱的软件。哦,等等,这部分还没完呢...

在推出之前,还有很多黑色和蓝色的崩溃需要修复:)。