2.3 Unity 3D为何能跨平台?聊聊CIL
在日常的工作中,笔者发现很多从事Unity 3D开发的朋友,一直对Unity 3D的跨平台能力很好奇。那么到底是什么原理使Unity 3D可以跨平台呢?带着这个问题,让我们来看看Mono的贡献,然后再进一步了解CIL(Common Intermediate Language,通用中间语言,也叫MSIL微软中间语言)的作用。
2.3.1 Unity 3D为何能跨平台
如果笔者或者读者来做,应该怎么实现一套代码对应多种平台呢?其实原理想想也简单,生活中也有很多可以参考的例子。现实生活中“跨平台”的例子,如图2-8所示。
图2-8 现实生活中“跨平台”的例子
像这样一根连接(传输)线,无论目标设备是安卓手机还是苹果手机,都能为手机充电。所以从这个意义上来说,这根连接(传输)线也实现了跨平台。那么我们能从它身上获得什么灵感呢?那就是从一样的能(电)源到不同的平台(iOS、安卓)之间需要一个中间层过渡转换一下。
那么再回到Unity 3D为何能跨平台的问题上,简而言之,其实原理在于使用了叫CIL(Common Intermediate Language,通用中间语言,也叫MSIL微软中间语言)的一种代码指令集。CIL可以在任何支持CLI(Common Language Infrastructure,通用语言基础结构)的环境中运行,就像.NET是微软对这一标准的实现,Mono则是对CIL的又一实现。由于CIL能运行在所有支持CIL的环境中,例如刚刚提到的.NET运行时以及Mono运行时,也就是说和具体的平台或者CPU无关。这样就无须根据平台的不同而部署不同的内容了。所以,原来在使用Unity 3D开发游戏的过程中,代码的编译只需要分为两部分就可以了:第一部分是从代码本身到CIL的编译(其实之后CIL还会被编译成一种位元码,生成一个CLI assembly);第二部分是运行时从CIL(其实是CLI assembly,不过为了直观理解,此处不必纠结这种细节)到本地指令的即时编译(这就引出了为何Unity 3D官方没有提供热更新的原因:在iOS平台中Mono无法使用JIT引擎,而是以Full AOT模式运行的,所以此处说的即时编译不包括iOS平台)。
2.3.2 CIL是什么
CIL是指令集,但是不是太模糊了呢?不妨先通过工具来看看CIL。而这个工具就是——ildasm。下面就通过编译一个简单的C#文件来看看生成的CIL代码。
C#部分的代码如下。
与其对应的CIL代码如下。
代码虽然简单,但是也能说明足够多的问题。那么和CIL的第一次接触,能给我们留下什么直观的印象呢?
• 以“.”(一个点号)开头的,例如.class、.method,称为CIL指令(directive),用于描述.NET程序集总体结构的标记。为什么需要它呢?因为你总得告诉编译器你处理的是什么。
• 在CIL代码中还看到了private、public,暂时称为CIL特性(attribute)。它的作用也很好理解,通过CIL指令并不能完全说明.NET成员和类,针对CIL指令进行补充说明成员或者类的特性的,常见的还有extends、implements等。
• 每一行CIL代码基本都有的就是CIL操作码。
对CIL有了直观的印象,但是要弄明白Unity 3D为何能跨平台,还需要进一步的学习。
参照CIL的操作码表,可以总结出一份更易读的表格,如图2-9所示。希望读者朋友们可以认真读表。
图2-9 CIL操作码表
基于堆栈
笔者的第一感觉就是基本每一条描述中都包含一个“栈”。CIL是基于堆栈的,也就是说CIL的VM(Mono运行时)是一个栈式机。这就意味着数据是推入堆栈,通过堆栈来操作的,而非通过CPU的寄存器来操作的,这更加验证了其和具体的CPU架构没有关系。为了说明这一点,举个例子。
上大学学单片机时,使用汇编语言做加法的代码如下。
其中的eax是什么——寄存器。所以,如果CIL处理数据要通过CPU的寄存器,那也就不可能和CPU的架构无关了。
当然,CIL之所以是基于堆栈而非CPU的另一个原因,是相较于CPU的寄存器,操作堆栈实在太简单了。大学时学单片机,在学的时候需要记得各种寄存器、各种标志位、各种操作,而堆栈只需要简单地压栈和弹出,因此对于虚拟机的实现来说是再合适不过了。所以想要更具体地了解CIL基于堆栈这一点,读者可以自学一下堆栈方面的内容,这里就不再赘述。
• 面向对象。
表中有new对象的语句,CIL同样是面向对象的。
这意味着什么呢?那就是在CIL中你可以创建对象、调用对象的方法、访问对象的成员。而这里需要注意的就是对方法的调用。
图2-9的右上角就是对参数的操作部分。静态方法和实例方法是不同的。
• 静态方法:ldarg.0没有被占用,所以参数从ldarg.0开始。
• 实例方法:ldarg.0是被this占用的,也就是说实际上的参数是从ldarg.1开始的。
举个例子,假设有一个叫Murong的类中有一个静态方法Add(int32 a,int32 b),实现的内容是使两个数相加,所以需要两个参数。另一个实例方法TellName(string name),这个方法会告诉你传入的名字。
Murong的代码如下。
分别来看看CIL语言对静态方法和实例方法的不同处理。
• 静态方法的处理。
静态方法Add的CIL代码如下。
调用这个静态函数的代码如下。
对应的CIL代码如下。
可见CIL直接调用了Murong的Add方法,而不需要一个Murong的实例。
• 实例方法的处理。
Murong类中的实例方法TellName()的CIL代码如下。
第一个参数对应的是ldarg.1中的参数1,而不是静态方法中的参数0。因为此时参数0相当于this,this是不用参与参数传递的。
再看看调用实例方法的C#代码和对应的CIL代码,对应的C#代码如下。
对应的CIL代码如下。
由于篇幅限制,CIL是什么的问题大概介绍到这里。下面将会介绍Unity 3D是如何通过CIL来实现跨平台的。
2.3.3 Unity 3D如何使用CIL跨平台
Q:知道了Unity 3D能跨平台是因为存在着一个中间语言CIL,这也是所谓跨平台的前提,但是为什么CIL能“通吃”各大平台呢?当然可以说CIL基于堆栈,与CPU怎么架构没关系,但是感觉过于理论化、学术化,那还有没有通俗化、工程化的说法呢?
A:原因就是前面提到过的.NET运行时和Mono运行时。也就是说CIL语言其实是运行在虚拟机中的,具体到Unity 3D游戏引擎也就是Mono的运行时了,换言之Mono运行的其实是CIL语言,CIL也并非真正在本地运行,而是在Mono运行时中运行,运行在本地的是被编译后生成的原生代码。
因此这里为了“实现跨平台式的演示”,使用OS X系统做个测试,代码如下。
在OSX系统上通过最简单的文本编辑器,输入上述代码并保存为.cs文件。这里使用Test.cs作为这个示例的名字。这样就有一个最基本的C#文件,如果在OSX系统上直接使用Mono来运行这个文件会发生什么结果呢?使用Mono直接运行.cs文件,如图2-10所示。
图2-10 使用Mono直接运行.cs文件
文件没有包含一个CIL映像。可见Mono是不能直接运行.cs文件的。假如把它编译成CIL呢?那么用Mono带的mcs来编译Test.cs文件,代码如下。
生成的内容如图2-11所示。
图2-11 使用Mono的mcs编译器在Mac上生成的.exe文件
没有.IL文件生成,反而多了一个.exe文件。可是OS X系统不能运行.exe文件,但是为什么生成了.exe文件呢?真相其实就是这个.exe文件并不是直接让OS X系统来运行的,而是留给Mono运行时来运行的。换言之,这个文件的可执行代码形式是CIL的位元码形态。这样就完成了从C#到CIL的过程。接下来就运行下刚刚的成果,代码如下。
再次使用Mono,只不过这次的目标换成了Test.exe文件,如图2-12所示。结果是输出了一个“Hi”。
图2-12 在OS X系统上运行.exe文件
• 从CIL到Native Code
为什么C#写的代码能在Mac上运行呢?这就不得不提从CIL如何到本机原生代码的过程了。Mono提供了两种编译方式,就是经常能看到的JIT(Just-in-time compilation,即时编译)和AOT(Ahead-of-time,提前编译或静态编译)。这两种编译方式都是将CIL进一步编译成平台的原生代码。这也是实现跨平台的最后一步。
• JIT即时编译
即时编译,或者称为动态编译,是在程序执行时才编译代码,解释一条语句执行一条语句,即将一条中间的托管语句翻译成一条机器语句,然后执行这条机器语句。但同时也会将编译过的代码进行缓存,而不是每一次都进行编译。所以可以说它是静态编译和解释器的结合体。不过机器既要处理代码的逻辑,同时还要进行编译的工作,所以其运行时的效率肯定是受到影响的。因此,Mono会有一部分代码通过AOT静态编译,以解决在程序运行时JIT动态编译在效率上的问题。
不过一向严苛的iOS平台是不允许这种动态的编译方式的,这也是Unity官方无法给出热更新方案的一个原因。而Android平台恰恰相反,Dalvik虚拟机使用的就是JIT方案。
• AOT静态编译
其实Mono的AOT静态编译和JIT并非对立的。AOT同样使用了JIT编译器来进行编译,只不过被AOT编译的代码在程序运行之前就已经编译好了。当然,还有一部分代码会通过JIT来进行动态编译。下面就手动操作一下Mono,让它进行一次AOT静态编译,代码如下。
这条命令运行的结果,如图2-13所示。
图2-13 Mono的AOT静态编译运行结果
从图2-13可以看到JIT time:39 ms,也就是说Mono的AOT模式其实会使用到JIT编译器,同时可以看到生成了一个适应Mac的动态库Test.exe.dylib,而在Linux中生成则是.so(共享库)。
AOT静态编译出来的库,除了包括代码之外,还有被缓存的元数据(Metodata,是一种二进制信息,用以对存储在公共语言运行库可移值可执行文件(PE文件)或存储在内存中的程序进行描述)信息,所以甚至可以只编译元数据信息而不编译代码。例如如下所示代码。
再次运行的结果,如图2-14所示。可见代码没有被包括进来。
图2-14 只编译元数据的运行结果
简单总结一下AOT的过程。
(1)收集要被编译的方法。
(2)使用JIT编译器进行编译。
(3)发射(Emitting)经JIT编译过的代码和其他信息。
(4)直接生成文件,或者调用本地汇编器或连接器进行处理后生成文件(例如图2-14中使用了本地的gcc)。
• Full AOT
iOS平台是禁止使用JIT的,但是Mono的AOT模式仍然会保留一部分代码在程序运行时动态编译。所以为了解决这个问题,Mono提供了一个被称为Full AOT的模式。即预先对程序集中的所有CIL代码进行AOT编译生成一个本地代码映像,然后在运行时直接加载这个映像,而不再使用JIT引擎。目前由于技术或实现上的原因,在使用Full AOT时有一些限制,所以这里不再赘述。
• 总结
本节的主要内容总结有以下几点。
(1)CIL(Common Intermediate Language,通用中间语言)是CLI(Common Language Infrastructure,通用语言基础结构)标准定义的一种可读性较低的语言。
(2)以.NET或Mono等实现CLI标准的运行环境为目标的语言要先编译成CIL,然后CIL会被编译,并且以位元码的形式存在(源代码→中间语言的过程)。
(3)这种位元码运行在虚拟机中(.NET Mono的运行时)。
(4)这种位元码可以被进一步编译成不同平台的原生代码(中间语言→原生代码的过程)。
(5)面向对象。
(6)基于堆栈。