无限循环似乎应该很容易避免。但时不时就会遇到它的变种。曾经有次因为错误的随机函数返回了1.000001这个值导致无限循环。还有就是衰退的网格恰好给没有做输入检测的while(1) { d += 1.0; if(d>10.0) break; /* .. */ } 循环发送了NaN这个数据。再后来还有坏掉的数据结构中贯穿了一个规则假定了current = current.next;并认为这个规则一定会有个终点。
如果在Unity中遇到过无限循环,就会知道这很不爽。Unity无法响应,并且需要强行关闭整个编辑器来结束无限循环。如果幸运的在游戏中附加了调试组件,那就可以中断应用。但通常只能靠猜测在合适的位置设置断点。
到最近我才明白这个小技巧起作用的根本原因,以及如何利用这个小技巧找到一种合适的方法来中断Unity脚本。在新功能开发完成并发布之前,都可使用该技巧。或者享受反汇编JIT(即时编译)代码的乐趣,反正这样也不会出啥错,何乐而不为呢?
实际项目别这么做!
作为一个受过良好训练的专家,都知道练习的重要性,因此正式将其用于项目前,先在测试项目中试一下这个小技巧。打开Unity并新建一个空的项目,在空的场景中添加一个盒子对象再创建一个C#脚本,命名为 “流沙”并附加在盒子对象上。脚本代码如下:
using UnityEngine;
class Quicksand : public Monobehaviour
{
public OnMouseDown()
{
while(true)
{
// "Mind you, you'll keep sinking forever!!", -- My mom
}
}
}
现在点击运行后单击盒子对象。可以看到Unity已经卡住了,不要惊慌,这只是个测试不是实际项目。
现在脚本已经卡住了Unity似乎也宕掉了。让我们再开一个Visual Studio。
为了保证这个方法奏效(说实话我没确认)需要在安装Visual Studio时勾选C++编程语言。在Debug菜单下选择 Attach to Process(注意:这个选项并不是通常选用的Attaching to Unity)。找到Unity进程并绑定。
把调试器附在卡住的Unity进程上后,依次点击“Debug > Break all”然后找到disassembly视图,这里显示了主线程正在执行的代码。操作步骤见下图。可能还需要点击“show disassembly”或者一些其它按钮,这取决于Visual Studio的相关设置。(在我的测试机上,需要点击F10做一次单步调试来打开disassembly视图).
众所周知,为了执行效率更高,编写好的代码往往被编译成机器语言来执行。这也称为jit-compiling(即时编译)。执行的结果可以在disassembly窗口中查看。如下:
在这个例子中出现了无限循环(参考上图的红色尖头)。这里有一个mov,一个cmp和很多nop然后 jmp 循环回了开始的位置。没有任何出路。在实际情况中,C#的代码要更复杂,也更难判断到底发生了什么,但开发者并不需要理解这些,因为技巧就是:不停点击F10(只需一步)直到看到“cmp dword ptr [r11], 0″这条指令。它们应该不受限的分散在代码的各个位置,因为它们是调试的基础。再执行几步之后,看到这样的提示就可以结束了:
幸运的话这里会出现“Autos”窗口(如果没有,依次点击Debug > Windows > Auto打开)。窗口里面展示了当前正在执行的寄存器中的值:
现在只需将R11的值设为0,如下:
如果现在执行cmp指令,它会尝试读取内存地址为0的数据,这将导致异常。这也正是我们想要的,所以接下来按F5键让程序继续执行,并在弹出对话框中点击“Continue”继续:
如果一切顺利,此时Unity控制台会显示(Mono)异常信息,循环已被终止且Unity恢复正常。这时可以先保存工程再看看控制台显示是哪里的脚本代码导致的问题。
这样就中断了死循环!有个忠告:走到这里基本上是打入Unity很底层干了些坏事,所以比较保险的做法是先保存项目并重启编辑器。此例中一切正常,但还是小心点为好。
为什么可以这么做呢?
之所以可以这么做的原因可以归结于Mono有内建的脚本调试系统。它的工作原理是穿插一些即时编译的代码(实际上是每句C#代码一次)到读取指定内存地址的过程中。也就是上面的“cmp dword ptr [r11], 0”指令。当在调试模式下对代码进行单步调试时,系统会将持有该内存地址的页设为只读,这将导致每句C#代码产生一次异常。Mono框架可以从JIT代码外部捕获异常并暂停代码执行。
我们在上面用到的技巧就是将注册器r11设为0,由于内存地址0是不可读的,如此就不会再产生同类的异常。此时调试器会认为正在进行类似单步调试的行为,但实际上这里并未进行调试,所以这里会抛出NullReferenceException的异常,我们也会看到很有用的堆栈信息。非常方便!
该技术对于编译出来的可执行程序同样适用。将Unity连接到游戏.exe,全部中断,找到JIT代码,强制内存读取失败即可。只是这里需要在log文件中查看堆栈信息。
极端案例
上面只是为了演示说明而展示的简单示例。现实情况远比示例复杂,可能会遇到各种异常。即使拆包可能访问的也不是“纯”JIT的代码。如果C#代码调用了任意API,这段程序可能会跑进Unity核心代码部分。如下:
这里的代码调用了GetPosition。当Call Stack顶部包含真正的函数名称而非一些天书般的内存地址时,这就表示已经脱离Mono或JIT代码了。这时要点几次Shift+F11跳出当前步骤直至回到纯JIT代码(大量的nop指令也是纯JIT代码的象征)。
有时你可以设法在某个主线程不活跃的位置中断Unity。最简单的解决方式是点击继续(或按F5)然后中断所有直至主线程激活即可。可能还有更多怪异的情况,但这只是调试,尽情发挥吧!
32位是什么情况
在32位下也能使用该方式。只是JIT代码看起来有些区别,如下:
这里表示从0xB10000的位置读取数据。为了引发系统页出错,就需要实际更改代码,因为这里的地址是硬编码到指令中的,不像64位系统那样位于注册器中。打开内存视图(依次点击Debug > Windows > Memory > Memory1)找到指令地址(上图黄箭头的地址)0x65163DC。显示如下:
可以找到该地址,然后将从头开始第四个字节“b1”改为“00”后点击继续。这会有些作用,但与64位系统不同,这里每次跑到这个位置都会导致中断。如果是非调试模式呢?
如果实在不幸,发生了只有在没有勾选脚本调试的情况下进行编译才会出现的Bug,这就真的要即兴发挥了。你可以看看代码然后找到某种方法引起读取出错,可能会有新的进展,但这可能不太容易。最后的绝招是通过手动注入代码,类似cmp eax, dword ptr ds:[0x0]指令,这样就能像上面那样知道地址是3b 05 00 00 00 00。可以试试看上面的脚本。先中断:
最坏的情况出现了,编译器优化导致只有jmp指令在自循环。这样就没有空间加入cmp了(与jmp相关,最多占2个字节)。这里不要多想了直接通过内存读取来破坏代码。在内存视图找到地址4D34446,不管是什么内容都往最上方填充3b 05 00 00 00 00。然后点击继续。此例中(单机游戏)成功了,可以在log文件中查看堆栈信息:
这时应该立即关掉游戏,因为你已经毁掉了脚本的一部分JIT生成的代码,可能游戏无法正常运行了。但至少可以知道是哪里出了问题。
有时可以在中断位置附近发现一些读取指令。这时可以右击指令并选择“Set next statement”然后将注册器设为0,通过这种方式就可以正确产生异常。
结论
通过一点投机取巧就能中断看起来无法中断的死循环。赶紧来试试看吧,这样你就可以跟小伙伴们说“想当年我也是玩过反汇编的人!”。后续我们还会推出更好的解决方案,敬请期待哦!
原文作者:PETER ANDREASEN