前言:
其实小匹夫在 U3D 的开发中一直对 U3D 的跨平台能力很好奇。到底是什么原理使得 U3D 可以跨平台呢?后来发现了 Mono 的作用,并进一步了解到了 CIL 的存在。所以,作为一个对 Unity3D 跨平台能力感兴趣的 U3D 程序猿,小匹夫如何能不关注 CIL 这个话题呢?那么下面各位看官就拾起语文老师教导我们的作文口诀(Why、What、How),和小匹夫一起走进 CIL 的世界吧~
Why?
回到本文的题目,U3D 或者说 Mono 的跨平台是如何做到的?
如果换做小匹夫或者看官你来做,应该怎么实现一套代码对应多种平台呢?
其实原理想想也简单,生活中也有很多可以参考的例子,比如下图(谁让小匹夫是做移动端开发的呢,只能物尽其用从自己身边找例子了 T.T):
像这样一根线,管你是安卓还是 ios 都能充电。所以从这个意义上,这货也实现了跨平台。那么我们能从它身上学到什么呢?对的,那就是从一样的能源(电)到不同的平台(ios,安卓)之间需要一个中间层过度转换一下。
那么来到 U3D 为何能跨平台,简而言之,其实现原理在于使用了叫 CIL(Common Intermediate Language 通用中间语言,也叫做 MSIL 微软中间语言)的一种代码指令集,CIL 可以在任何支持 CLI(Common Language Infrastructure,通用语言基础结构)的环境中运行,就像.NET 是微软对这一标准的实现,Mono 则是对 CLI 的又一实现。由于 CIL 能运行在所有支持 CLI 的环境中,例如刚刚提到的.NET 运行时以及 Mono 运行时,也就是说和具体的平台或者 CPU 无关。这样就无需根据平台的不同而部署不同的内容了。所以到这里,各位也应该恍然大了。代码的编译只需要分为两部分就好了嘛:
- 从代码本身到 CIL 的编译(其实之后 CIL 还会被编译成一种位元码,生成一个 CLI assembly)
- 运行时从 CIL(其实是 CLI assembly,不过为了直观理解,不必纠结这种细节)到本地指令的即时编译(这就引出了为何 U3D 官方没有提供热更新的原因:在 iOS 平台中 Mono 无法使用 JIT 引擎,而是以 Full AOT 模式运行的,所以此处说的即时编译不包括 IOS)
What?
上文也说了 CIL 是指令集,但是不是还是太模糊了呢?所以语文老师教导我们,描述一个东西时肯定要先从外貌写起。遵循老师的教导,我们不妨先通过工具来看看 CIL 到底长什么样。
工具就是 ildasm 了。下面小匹夫写一个简单的.cs 看看生成的 CIL 代码长什么样。
C#代码:
class Class1 { public static void Main(string[] args) { System.Console.WriteLine("hi"); } }
CIL 代码:
.class private auto ansi beforefieldinit Class1 extends [mscorlib]System.Object { .method public hidebysig static void Main(string[] args) cil managed { .entrypoint // 代码大小 13 (0xd) .maxstack 8 IL_0000: nop IL_0001: ldstr "hi" IL_0006: call void [mscorlib]System.Console::WriteLine(string) IL_000b: nop IL_000c: ret } // end of method Class1::Main .method public hidebysig specialname rtspecialname instance void .ctor() cil managed { // 代码大小 7 (0x7) .maxstack 8 IL_0000: ldarg.0 IL_0001: call instance void [mscorlib]System.Object::.ctor() IL_0006: ret } // end of method Class1::.ctor } // end of class Class1
好啦。代码虽然简单,但是也能说明足够多的问题。那么和 CIL 的第一次亲密接触,能给我们留下什么直观的印象呢?
- 以“.”一个点号开头的,例如上面这份代码中的:.class、.method 。我们称之为CIL 指令(directive),用于描述.NET 程序集总体结构的标记。为啥需要它呢?因为你总得告诉编译器你处理的是啥吧。
- 貌似 CIL 代码中还看到了 private、public 这样的身影。姑且称之为CIL 特性(attribute)。它的作用也很好理解,通过 CIL 指令并不能完全说明.NET 成员和类,针对 CIL 指令进行补充说明成员或者类的特性的。市面上常见的还有:extends,implements 等等。
- 每一行 CIL 代码基本都有的,对,那就是CIL 操作码咯。小匹夫从网上找了一份汉化的操作码表放在附录部分,当然英文版的你的 vs 就有。
直观的印象有了,但是离我们的短期目标,说清楚(或者说介绍个大概)CIL 是 What,甚至是终极目标,搞明白 Mono 为何能跨平台还有 2 万 4 千 9 百里的距离。
好啦,话不多说,继续乱侃。
参照附录中的操作码表,对照可以总结出一份更易读的表格。那就是如下的表啦。
查看完整大图:
在此,小匹夫想请各位认真读表,然后心中默数 3 个数,最后看看都能发现些什么。
基于堆栈
如果是小匹夫的话,第一感觉就是基本每一条描述中都包含一个“栈”。不错,CIL 是基于堆栈的,也就是说 CIL 的 VM(mono 运行时)是一个栈式机。这就意味着数据是推入堆栈,通过堆栈来操作的,而非通过 CPU 的寄存器来操作,这更加验证了其和具体的 CPU 架构没有关系。为了说明这一点,小匹夫举个例子好啦。
大学时候学单片机的时候记得做加法大概是这样的:
add eax,-2
其中的 eax 是啥?寄存器。所以如果 CIL 处理数据要通过 cpu 的寄存器的话,那也就不可能和 cpu 的架构无关了。
当然,CIL 之所以是基于堆栈而非 CPU 的另一个原因是相比较于 cpu 的寄存器,操作堆栈实在太简单了。回到刚才小匹夫说的大学时候曾经学过的单片机那门课程上,当时记得各种寄存器,各种标志位,各种。。。,而堆栈只需要简单的压栈和弹出,因此对于虚拟机的实现来说是再合适不过了。所以想要更具体的了解 CIL 基于堆栈这一点,各位可以去看一下堆栈方面的内容。这里小匹夫就不拓展了。
面向对象
那么第二感觉呢?貌似附录的表中有 new 对象的语句呀。嗯,的确,CIL 同样是面向对象的。
这意味着什么呢?那就是在 CIL 中你可以创建对象,调用对象的方法,访问对象的成员。而这里需要注意的就是对方法的调用。
回到上表中的右上角。对,就是对参数的操作部分。静态方法和实例方法是不同的哦~
- 静态方法:ldarg.0 没有被占用,所以参数从 ldarg.0 开始。
- 实例方法:ldarg.0 是被 this 占用的,也就是说实际上的参数是从 ldarg.1 开始的。
举个例子:假设你有一个类 Murong 中有一个静态方法 Add(int32 a, int32 b),实现的内容就如同它的名字一样使两个数相加,所以需要 2 个参数。和一个实例方法 TellName(string name),这个方法会告诉你传入的名字。
class Murong { public void TellName(string name) { System.Console.WriteLine(name); } public static int Add(int a, int b) { return a + b; } }
静态方法的处理:
那么其中的静态方法 Add 的 CIL 代码如下:
// 小匹夫注释一下。 .method public hidebysig static int32 Add(int32 a, int32 b) cil managed { // 代码大小 9 (0x9) .maxstack 2 .locals init ([0] int32 CS$1$0000) // 初始化局部变量列表。因为我们只返回了一个 int 型。所以这里声明了一个 int32 类型。索引为 0 IL_0000: nop IL_0001: ldarg.0 // 将索引为 0 的参数加载到计算堆栈上。 IL_0002: ldarg.1 // 将索引为 1 的参数加载到计算堆栈上。 IL_0003: add // 计算 IL_0004: stloc.0 // 从计算堆栈的顶部弹出当前值并将其存储到索引 0 处的局部变量列表中。 IL_0005: br.s IL_0007 IL_0007: ldloc.0 // 将索引 0 处的局部变量加载到计算堆栈上。 IL_0008: ret // 返回该值 } // end of method Murong::Add
那么我们调用这个静态函数应该就是这样咯。
Murong.Add(1, 2);
对应的 CIL 代码为:
IL_0001: ldc.i4.1 // 将整数 1 压入栈中 IL_0002: ldc.i4.2 // 将整数 2 压入栈中 IL_0003: call int32 Murong::Add(int32, int32) // 调用静态方法
可见 CIL 直接 call 了 Murong 的 Add 方法,而不需要一个 Murong 的实例。
实例方法的处理:
Murong 类中的实例方法 TellName() 的 CIL 代码如下:
.method public hidebysig instance void TellName(string name) cil managed { // 代码大小 9 (0x9) .maxstack 8 IL_0000: nop IL_0001: ldarg.1 // 看到和静态方法的区别了吗? IL_0002: call void [mscorlib]System.Console::WriteLine(string) IL_0007: nop IL_0008: ret } // end of method Murong::TellName
看到和静态方法的区别了吗?对,第一个参数对应的是 ldarg.1 中的参数 1,而不是静态方法中的 0。因为此时参数 0 相当于 this,this 是不用参与参数传递的。
那么我们再看看调用实例方法的 C#代码和对应的 CIL 代码是如何的。
C#:
//C# Murong murong = new Murong(); murong.TellName("chenjiadong");
CIL:
.locals init ([0] class Murong murong) // 因为 C#代码中定义了一个 Murong 类型的变量,所以局部变量列表的索引 0 为该类型的引用。 //.... IL_0009: newobj instance void Murong::.ctor() // 相比上面的静态方法的调用,此处 new 一个新对象,出现了 instance 方法。 IL_000e: stloc.0 IL_000f: ldloc.0 IL_0010: ldstr "chenjiadong" // 小匹夫的名字入栈 IL_0015: callvirt instance void Murong::TellName(string) // 实例方法的调用也有 instance
到此,受制于篇幅所限(小匹夫不想写那么多字啊啊啊!)CIL 是 What 的问题大致介绍一下。当然没有再拓展,以后小匹夫可能会再详细写一下这块。
How?
记得语文老师说过,写作文最重要的一点是要首尾呼应。既然咱们开篇就提出了 U3D 为何能跨平台的问题,那么接近文章的结尾咱们就再来
提问:
Q:上面的 Why 部分,咱们知道了 U3D 能跨平台是因为存在着一个能通吃的中间语言 CIL,这也是所谓跨平台的前提,但是为啥 CIL 能通吃各大平台呢?当然可以说 CIL 基于堆栈,跟你 CPU 怎么架构的没啥关系,但是感觉过于理论化、学术化,那还有没有通俗化、工程化的说法呢?
A:原因就是前面小匹夫提到过的,.Net 运行时和 Mono 运行时。也就是说 CIL 语言其实是运行在虚拟机中的,具体到咱们的 U3D 也就是 mono 的运行时了,换言之 mono 运行的其实 CIL 语言,CIL 也并非真正的在本地运行,而是在 mono 运行时中运行的,运行在本地的是被编译后生成的原生代码。当然看官博的文章,他们似乎也在开发自己的“mono”,也就是被称为脚本的未来的 IL2Cpp,这种类似运行时的功能是将 IL 再编译成 c++,再由 c++ 编译成原生代码,据说效率提升很可观,小匹夫也是蛮期待的。
这里为了“实现跨平台式的演示”,小匹夫用 mac 给各位做个测试好啦:
从 C#到 CIL
新建一个 cs 文件,然后使用 mono 来运行。这个 cs 文件内容如下:
然后咱们直接在命令行中运行这个 cs 文件试试~
说的很清楚,文件没有包含一个 CIL 映像。可见 mono 是不能直接运行 cs 文件的。假如我们把它编译成 CIL 呢?那么我们用 mono 带的 mcs 来编译小匹夫的 Test.cs 文件。
mcs Test.cs
生成了什么呢?如图:
好像没见有叫.IL 的文件生成啊?反而好像多了一个.exe 文件?可是没听说 Mac 能运行 exe 文件呀?可为啥又生成了.exe 呢?各位看官可能要说,小匹夫你是不是拿 windows 截图 P 的啊?嘿嘿,小匹夫可不敢。辣么真相其实就是这个 exe 并不是让 Mac 来运行的,而是留给 mono 运行时来运行的,换言之这个文件的可执行代码形式是 CIL 的位元码形态。到此,我们完成了从 C#到 CIL 的过程。接下来就让我们运行下刚刚的成果好啦。
mono Test.exe
结果是输出了一个大大的“Hi”。这里,就引出了下一个部分。
从 CIL 到 Native Code
这个“HI”可是在小匹夫的 MAC 终端上出现的呀,那么就证明这个 C#写的代码在 MAC 上运行的还挺“嗨”。
为啥呢?为啥 C#写的代码能跑在 MAC 上呢?这就不得不提从 CIL 如何到本机原生代码的过程了。Mono 提供了两种编译方式,就是我们经常能看到的:JIT(Just-in-Time compilation,即时编译)和 AOT(Ahead-of-Time,提前编译或静态编译)。这两种方式都是将 CIL 进一步编译成平台的原生代码。这也是实现跨平台的最后一步。下面就分头介绍一下。
JIT 即时编译:
从名字就能看的出来,即时编译,或者称之为动态编译,是在程序执行时才编译代码,解释一条语句执行一条语句,即将一条中间的托管的语句翻译成一条机器语句,然后执行这条机器语句。但同时也会将编译过的代码进行缓存,而不是每一次都进行编译。所以可以说它是静态编译和解释器的结合体。不过你想想机器既要处理代码的逻辑,同时还要进行编译的工作,所以其运行时的效率肯定是受到影响的。因此,Mono 会有一部分代码通过 AOT 静态编译,以降低在程序运行时 JIT 动态编译在效率上的问题。
不过一向严苛的 IOS 平台是不允许这种动态的编译方式的,这也是 U3D 官方无法给出热更新方案的一个原因。而 Android 平台恰恰相反,Dalvik 虚拟机使用的就是 JIT 方案。
AOT 静态编译:
其实 Mono 的 AOT 静态编译和 JIT 并非对立的。AOT 同样使用了 JIT 来进行编译,只不过是被 AOT 编译的代码在程序运行之前就已经编译好了。当然还有一部分代码会通过 JIT 来进行动态编译。下面小匹夫就手动操作一下 mono,让它进行一次 AOT 编译。
// 在命令行输入 mono --aot Test.exe
结果:
查看完整大图:
从图中可以看到JIT time: 39 ms,也就是说Mono 的AOT 模式其实会使用到JIT,同时我们看到了生成了一个适应小匹夫的MAC 的动态库Test.exe.dylib,而在Linux 生成就是.so(共享库)。
AOT 编译出来的库,除了包括我们的代码之外,还有被缓存的元数据信息。所以我们甚至可以只编译元数据信息而不编译代码。例如这样:
// 只包含元数据的信息 mono --aot=metadata-only Test.exe
查看完整大图:
可见代码没有被包括进来。
那么简单总结一下 AOT 的过程:
- 收集要被编译的方法
- 使用 JIT 进行编译
- 发射(Emitting)经 JIT 编译过的代码和其他信息
- 直接生成文件或者调用本地汇编器或连接器进行处理之后生成文件。(例如上图中使用了小匹夫本地的 gcc)
Full AOT
当然上文也说了,IOS 平台是禁止使用 JIT 的,可看样子 Mono 的 AOT 模式仍然会保留一部分代码会在程序运行时动态编译。所以为了破解这个问题,Mono 提供了一个被称为 Full AOT 的模式。即预先对程序集中的所有 CIL 代码进行 AOT 编译生成一个本地代码映像,然后在运行时直接加载这个映像而不再使用 JIT 引擎。目前由于技术或实现上的原因在使用 Full AOT 时有一些限制,不过这里不再多说了。以后也还会更细的分析下 AOT。
总结
好啦,写到现在也已经到了凌晨 3:04 分了。感觉写的内容也差不多了。那么对本文的主题 U3D 为何能跨平台以及 CIL 做个最终的总结陈词:
- CIL 是 CLI 标准定义的一种可读性较低的语言。
- 以.NET 或 mono 等实现 CLI 标准的运行环境为目标的语言要先编译成 CIL,之后 CIL 会被编译,并且以位元码的形式存在(源代码—> 中间语言的过程)。
- 这种位元码运行在虚拟机中 (.net mono 的运行时)。
- 这种位元码可以被进一步编译成不同平台的原生代码(中间语言—> 原生代码的过程)。
- 面向对象
- 基于堆栈
感谢郭蕾对本文的审校。
给InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家通过新浪微博( @InfoQ , @丁晓昀),微信(微信号: InfoQChina )关注我们,并与我们的编辑和其他读者朋友交流。
评论