匹夫细说Unity3D(六)——谁偷了我的热更新?
发表于2016-07-03
你也可以在这里读到这篇文章: 谁偷了我的热更新?Mono,JIT,iOS(http://www.cnblogs.com/murongxiaopifu/p/4278947.html)
前言
由于匹夫本人是做游戏开发工作的,所以平时也会加一些玩家的群。而一些困扰玩家的问题,同样也困扰着我们这些手机游戏开发者。这不最近匹夫看自己加的一些群,常常会有人问为啥这个游戏一更新就要重新下载,而不能游戏内更新呢?作为游戏开发者,或者说Unity3d程序猿,我们都清楚Unity3D不支持热更新,甚至于在IOS平台上生成新的代码都会导致游戏报错崩溃(匹夫之所以在此处强调生成新的代码这几个字,就是提醒各位不要混淆Reflection.Emit和反射)。但我们是否和普通的玩家一样,看到的仅仅是“不能”的现象,而不了解“不能”背后的原因呢?那今天小匹夫就抛砖引玉,写写自己对这个问题的想法~~聊聊到底是谁偷了玩家的热更新。
从一个常见的报错说起
不知道各位看官中的u3d程序猿在开发IOS版本的时候是否也曾经碰到过这样的报错:
ExecutionEngineException: Attempting to JIT compile method 'XXXX' while running with --aot-only.
这个报错的意思很明确,说的也很具体,翻译成中文的大意就是在使用--aot-only这个选项的前提下,又试图去使用JIT编译器编译XXX方法。
那么不知道是否会有看官觉得这个问题兴许是程序跑在IOS平台上时,不小心犯了IOS的忌讳,使用了JIT(假设此时我们还不知道为何使用JIT是IOS的忌讳)去动态编译代码导致的IOS的报错呢?
答案是否定的。
又或者更进一步,看到“ExecutionEngineException”,似乎和IOS平台的异常没什么太大的关联,那就把责任定位在Unity3D的引擎上好了。一定是游戏引擎此时不支持JIT编译了。
也不全对,不过离真相很近了。
各位想想,能涉及到编译的被怀疑的对象还能有谁呢?
好了,不卖关子了。这个异常其实是Mono的异常。换言之,Unity3D使用了Mono来编译,所以Unity3D的嫌疑被排除。而IOS并没有因为生成或者运行动态生成的代码而报错,换言之这个异常发生在触发IOS异常之前,所以说Mono在IOS平台上进行JIT编译之前就先一步让程序崩溃了。
说到这里,就绕不过Mono是如何编译代码这个话题了。如果我们去Mono的托管页面看它的源码,就可以简单对它的目录结构做一个简单的分析,匹夫就简单总结一下Mono编译部分的目录结构:
好啦,具体到咱们要聊的JIT编译,我们需要看的就是mono目录下的mini文件夹中的文件了,这个文件夹中的.c文件们实现了JIT编译。
这个目录的结构截个图都截不全,因为文件太多:
不过这里小匹夫想来一个倒叙,也就是先直接定位这个报错“ExecutionEngineException: Attempting to JIT compile method 'XXXX' while running with --aot-only.”的位置,然后再探明它究竟是如何被触发的。
这样,我们就来到了mono的JIT编译器目录mini下的mini.c文件。这里就是JIT的逻辑实现。而那段报错呢?在mini.c文件中是这样处理的:
1 2 3 4 5 6 7 8 | if (mono_aot_only) { char *fullname = mono_method_full_name (method, TRUE); char *msg = g_strdup_printf ( "Attempting to JIT compile method '%s' while running with --aot-only. See [url=http://docs.xamarin.com/ios/about/limitations]http://docs.xamarin.com/ios/about/limitations[/url] for more information.n" , fullname); *jit_ex = mono_get_exception_execution_engine (msg); g_free (fullname); g_free (msg); return NULL; } |
mono_aot_only?没错,只要我们设定mono的编译模式为full-aot(比如打IOS安装包的时候),则在运行时试图使用JIT编译时,mono自身的JIT编译器就会禁止这种行为进而报告这个异常。JIT编译的过程根本还没开始,就被自己扼杀了。
那么JIT究竟是什么洪水猛兽?为何IOS这么忌讳它呢?那就不得不聊聊JIT本尊了。
美丽的JIT
因何美丽
名如其特点,JIT——just in time,即时编译。
什么?这就是匹夫你要告诉大家伙的?这不是人人都知道的嘛?而且网上一搜也全都是JIT=just in time了事。好吧好吧,匹夫知错啦。那就认真的定义一下JIT:
一个程序在它运行的时候创建并且运行了全新的代码,而并非那些最初作为这个程序的一部分保存在硬盘上的固有的代码。就叫JIT。
几个点:
程序需要运行
生成的代码是新的代码,并非作为原始程序的一部分被存在磁盘上的那些代码
不光生成代码,还要运行。
需要提醒的是第三点,也就是JIT不光是生成新的代码,它还会运行新生成的代码。之后我们会就这个话题展开。不过在之前匹夫还是要解释一下,为何称JIT是美丽的。
举个例子:
比如你某一天突然穿越成为了一个优秀的学者(好吧好吧,这个貌似不是必须要穿越),现在要去一个语言不通的 国家做一系列讲座。面对语言不通的窘境,如何才不出丑呢?
匹夫有三条方案:
在家的时候雇人把所有的讲稿全部翻译一遍。这是最省事的做法,但却缺乏灵活性。比如临时有更好的话题或者点子,也只能恨自己没有好好学外语了。
雇一个翻译和你一起出发,你说啥他就翻译成啥。这样就不存在灵活性的问题,因为完全是同步的。不过缺点同样明显,翻译要翻译很多话,包括你重复说的话。所以需要的时间要远远高于方案1。
雇一个翻译和你一起出发,但不是你说啥他就翻译啥,而是记录翻译过的话,遇到曾经翻译过的就不会再翻译了。你自己就可以根据之前的翻译记录和别人交流了。
看完这三条方案,各位看官心中更喜欢哪个呢?
匹夫个人的答案是方案3,因为这便是JIT的道。所以说JIT的美丽,就在于即保留了对代码优化的灵活性,也兼具对热点代码进行重复利用的功能。
模拟一下JIT的过程
JIT这么好,那它是如何实现既生成新代码,又能运行新代码的呢?
编译器如何生成代码很多文章都有涉及,匹夫就不多在此着墨了。下面我就着重和各位聊聊,如何运行新生成的代码。
首先我们要知道生成的所谓机器码到底是神马东西。一行看上去只是处理几个数字的代码,蕴含着的就是机器码。
1 | unsigned char [] macCode = {0x48, 0x8b, 0x07}; |
1 | mov (%rdi),%rax |
其实可以看出机器码就是比特流,所以将它加载进内存并不困难。而问题是应该如何执行。
好啦。下面我们就模拟一下执行新生成的机器码的过程。假设JIT已经为我们编译出了新的机器码,是一个求和函数的机器码:
1 2 3 4 5 6 7 | long add( long num) { return num + 1; } //对应的机器码 0x48, 0x83, 0xc0, 0x01, 0xc3 |
因为我们想要把已经是比特流的“求和函数”在内存中创建出来,同时还要运行它。所以mmap有几个参数需要注意一下。
代表映射区域的保护方式,有下列组合:
PROT_EXEC 映射区域可被执行;
PROT_READ 映射区域可被读取;
PROT_WRITE 映射区域可被写入;
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | #include #include #include #include #include //分配内存 void * create_space( size_t size) { void * ptr = mmap(0, size, PROT_READ | PROT_WRITE | PROT_EXEC, MAP_PRIVATE | MAP_ANON, -1, 0); return ptr; } |
1 2 3 4 5 6 7 8 | //在内存中创建函数 void copy_code_2_space(unsigned char * m) { unsigned char macCode[] = { 0x48, 0x83, 0xc0, 0x01, c3 }; memcpy (m, macCode, sizeof (macCode)); } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 | #include #include #include #include #include //分配内存 void * create_space( size_t size) { void * ptr = mmap(0, size, PROT_READ | PROT_WRITE | PROT_EXEC, MAP_PRIVATE | MAP_ANON, -1, 0); return ptr; } //在内存中创建函数 void copy_code_2_space(unsigned char * addr) { unsigned char macCode[] = { 0x48, 0x83, 0xc0, 0x01, 0xc3 }; memcpy (addr, macCode, sizeof (macCode)); } //main 声明一个函数指针TestFun用来指向我们的求和函数在内存中的地址 int main( int argc, char ** argv) { const size_t SIZE = 1024; typedef long (*TestFun)( long ); void * addr = create_space(SIZE); copy_code_2_space(addr); TestFun test = addr; int result = test(1); printf ( "result = %dn" , result); return 0; } |
1 2 3 4 | //编译 gcc testFun.c //运行 ./a.out 1 |
留给我们的难题
OK,到此为止,一切都很顺利。这个例子模拟了动态代码在内存上的生成,和之后的运行。似乎没有什么问题呀?可不知道各位是否忽略了一个前提?那就是我们为这块区域设置的保护模式可是:可读,可写,可执行的啊! 如果没有内存可读写可执行的权限,我们的实验还能成功吗?
让我们把create_space函数中的“可执行”PROT_EXEC权限去掉,看看结果会是怎样的一番景象。
修改代码,同时将刚才生成的可执行文件a.out删除重新生成运行。
1 2 3 4 | rm a.out vim testFun.c gcc testFun.c ./a.out 1 |
结果。。。报错了!
小结论
所以,IOS并非把JIT禁止了。或者换个句式讲,IOS封了内存(或者堆)的可执行权限,相当于变相的封锁了JIT这种编译方式。原因呢?且听下回分解~~~~~谁偷了我的热更新?IOS和安全漏洞的赌注
你也可以在这里读到这篇文章: http://www.cnblogs.com/murongxiaopifu/p/4278947.html
看了上面的文章 热爱游戏创作的你是不是已经开始热血沸腾了呢?是不是迫不及待的想加入游戏团队成为里面的一员呢?
福利来啦~赶快加入腾讯GAD交流群,人满封群!每天分享游戏开发内部干货、教学视频、福利活动、和有相同梦想的人在一起,更有腾讯游戏专家手把手教你做游戏!