【ArchSummit】如何通过AIOps推动可量化的业务价值增长和效率提升?>>> 了解详情
写点什么

Mono 为何能跨平台?聊聊 CIL

  • 2015-03-30
  • 本文字数:6124 字

    阅读完需:约 20 分钟

前言:

其实小匹夫在 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 无关。这样就无需根据平台的不同而部署不同的内容了。所以到这里,各位也应该恍然大了。代码的编译只需要分为两部分就好了嘛:

  1. 从代码本身到 CIL 的编译(其实之后 CIL 还会被编译成一种位元码,生成一个 CLI assembly)
  2. 运行时从 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 的第一次亲密接触,能给我们留下什么直观的印象呢?

  1. 以“.”一个点号开头的,例如上面这份代码中的:.class、.method 。我们称之为CIL 指令(directive),用于描述.NET 程序集总体结构的标记。为啥需要它呢?因为你总得告诉编译器你处理的是啥吧。
  2. 貌似 CIL 代码中还看到了 private、public 这样的身影。姑且称之为CIL 特性(attribute)。它的作用也很好理解,通过 CIL 指令并不能完全说明.NET 成员和类,针对 CIL 指令进行补充说明成员或者类的特性的。市面上常见的还有:extends,implements 等等。
  3. 每一行 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 中你可以创建对象,调用对象的方法,访问对象的成员。而这里需要注意的就是对方法的调用。

回到上表中的右上角。对,就是对参数的操作部分。静态方法和实例方法是不同的哦~

  1. 静态方法:ldarg.0 没有被占用,所以参数从 ldarg.0 开始。
  2. 实例方法: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 的过程:

  1. 收集要被编译的方法
  2. 使用 JIT 进行编译
  3. 发射(Emitting)经 JIT 编译过的代码和其他信息
  4. 直接生成文件或者调用本地汇编器或连接器进行处理之后生成文件。(例如上图中使用了小匹夫本地的 gcc)

Full AOT

当然上文也说了,IOS 平台是禁止使用 JIT 的,可看样子 Mono 的 AOT 模式仍然会保留一部分代码会在程序运行时动态编译。所以为了破解这个问题,Mono 提供了一个被称为 Full AOT 的模式。即预先对程序集中的所有 CIL 代码进行 AOT 编译生成一个本地代码映像,然后在运行时直接加载这个映像而不再使用 JIT 引擎。目前由于技术或实现上的原因在使用 Full AOT 时有一些限制,不过这里不再多说了。以后也还会更细的分析下 AOT。

总结

好啦,写到现在也已经到了凌晨 3:04 分了。感觉写的内容也差不多了。那么对本文的主题 U3D 为何能跨平台以及 CIL 做个最终的总结陈词:

  1. CIL 是 CLI 标准定义的一种可读性较低的语言。
  2. 以.NET 或 mono 等实现 CLI 标准的运行环境为目标的语言要先编译成 CIL,之后 CIL 会被编译,并且以位元码的形式存在(源代码—> 中间语言的过程)。
  3. 这种位元码运行在虚拟机中 (.net mono 的运行时)。
  4. 这种位元码可以被进一步编译成不同平台的原生代码(中间语言—> 原生代码的过程)。
  5. 面向对象
  6. 基于堆栈

感谢郭蕾对本文的审校。

给InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家通过新浪微博( @InfoQ @丁晓昀),微信(微信号: InfoQChina )关注我们,并与我们的编辑和其他读者朋友交流。

2015-03-30 03:586227

评论

发布
暂无评论
发现更多内容

Docker商业版受限,胖容器是个选择

BoCloud博云

Docker 容器 博云

年薪80万技术专家,面试通过后,被发现简历造假!合并8年前多段工作,惨遭警告和淘汰!

程序员生活志

程序员 面试 职场

week11 小结

Geek_196d0f

在木莲庄酒店和孩子一起体验“团队作战”的乐趣!

InfoQ_967a83c6d0d7

云原生技术采用增加,全球60%后端开发人员都在使用容器

BoCloud博云

Kubernetes 容器 云原生 CaaS 博云

架构师训练营 第11周

大丁💸💵💴💶🚀🐟

升级的华为云“GaussDB”还能战否?

华为云开发者联盟

MySQL 数据库 开源 Elastic Stack GaussDB

代理,一文入魂

cxuan

Java 后端 代理

大数据技术思想入门(五):分布式计算特点

cristal

Java 大数据 hadoop 分布式

week11 作业

Geek_196d0f

oeasy教您玩转linux010105详细手册man

o

“DNAT+云链接+CDN”加速方案,助力出海企业落地生长

华为云开发者联盟

CDN 网络 华为云 企业出海 网络加速

架构师训练营第十一周作业

Hanson

一个用户秘密加密验证功能

elfkingw

开源流数据公司 StreamNative 推出 Pulsar 云服务,推进企业“流优先”进程

Apache Pulsar

Apache Pulsar 消息系统 消息中间件

薪水真的不是工作的全部

escray

学习 面试

性能相关,进程调度

Linuxer

架构师训练营第十一周总结

Hanson

原创 | 使用JPA实现DDD持久化-O/R阻抗失配(1/2)

编程道与术

Java hibernate DDD JDBC jpa

上手Elasticsearch

北漂码农有话说

安全系列之——主流Hash散列算法介绍和使用

诸葛小猿

hash 散列函数 md5 sha1 murmurhash

易实战Spring Boot 2 资源汇总 从入门到精通 内含实战github代码 毫无保留分享

John(易筋)

redis Spring Boot 2 RestTemplate thymeleaf HikariCP

ARTS挑战打卡的100天,我学到了这些

老胡爱分享

ARTS 打卡计划

如何在面试中表现你所没有的能力

escray

学习 面试

Flink状态管理-8

小知识点

大数据 flink scal

满足消费者仪式感要求,木莲庄酒店做得很到位

InfoQ_967a83c6d0d7

分手快乐 祝你快乐 你可以找到更好的

escray

学习 面试

用户注册密码保存与校验(golang版)

2流程序员

让这家有12万名员工、1.7万种产品的钢铁厂平滑上云的黑科技是什么?

华为云开发者联盟

大数据 云服务 华为云 非对称加密 KYON

《精益创业》续

孙苏勇

随笔杂谈 精益创业

计算机网络基础(二十一)---传输层-TCP连接的四次挥手

书旅

TCP 四次挥手 TCP/IP 协议族

Mono为何能跨平台?聊聊CIL_.NET_陈嘉栋_InfoQ精选文章