NVIDIA 初创加速计划,免费加速您的创业启动 了解详情
写点什么

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:586221

评论

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

AI版女网红“半藏森林”上线,服务项目让人意想不到

引迈信息

人工智能 AI 低代码 JNPF

【零售电商系列】走进电商

小诚信驿站

6 月 优质更文活动

Taro框架应用优势下的移动App开发创新模式

FinFish

taro 跨端开发 小程序容器 跨端框架 小程序容器技术

小程序容器技术助力数字门户拓展多样化服务

FinFish

跨端开发 小程序容器 小程序容器技术 数字门户 移动门户

软件测试|Pytest必会技巧(二)

霍格沃兹测试开发学社

三分钟知识点 - 如何选择编程语言

雨中山

龙蜥社区 5 月度运营大事件回顾

OpenAnolis小助手

开源 总结 生态 龙蜥社区 运营月报

软件测试|Pytest的必会技巧(一)

霍格沃兹测试开发学社

软件测试|Pytest必会技巧(三)

霍格沃兹测试开发学社

架构实战营模块 1 第 4 课 - 如何做好架构设计

净意

你说的是哪一种 IDP:内部开发者门户 OR 内部开发者平台?

杨振涛

DevOps 平台工程 内部开发者平台 内部开发者门户 IDP,

三分钟知识点 - 重构平滑升级

雨中山

SeaTunnel StarRocks 连接器的使用及原理介绍

StarRocks

数据库 大数据 OLAP 湖仓一体 大数据 开源

面试官问:kafka为什么如此之快?

JAVA旭阳

kafka

朱珠代言Moto razr40登618手机榜首,小折叠成新摩登主义造风者

科技之家

ChatGPT与软件架构(3) - 软件架构提示工程

俞凡

人工智能 架构 ChatGPT

ChatGPT与软件架构(4) - 架构师提示工程指南

俞凡

人工智能 架构 ChatGPT

软件测试|教你用skip灵活跳过用例

霍格沃兹测试开发学社

Golden Gate (GGX) ZK 预编译: 彻底改变游戏玩法,成本降低千倍

股市老人

大模型时代下的企业系统架构变革

蔡超

架构 AI 大模型 GPT ChatGPT

ChatGPT与软件架构(2) - 基于Obsidian和GPT实现解决方案架构自动化

俞凡

人工智能 架构 ChatGPT

软件测试|手把手教你用Python来模拟绘制自由落体运动过程中的抛物线

霍格沃兹测试开发学社

软件测试|不会Python RPC,一篇文章教你入门

霍格沃兹测试开发学社

软件测试|简单易学的性能监控体系prometheus+grafana搭建教程

霍格沃兹测试开发学社

软件测试|Pytest必会技巧(四)使用autouse实现自动传参

霍格沃兹测试开发学社

inBuilder低代码平台特性推荐系列-第三期

inBuilder低代码平台

精选一线企业最佳生产实践,《Apache Doris 用户案例集》重磅发布!

SelectDB

数据库 大数据 数据分析 实时数仓 Doris

软件测试|一步到位教会你Python字典操作(一)

霍格沃兹测试开发学社

软件测试|PC端应用自动化最佳解决方案——Pywinauto

霍格沃兹测试开发学社

Zora测试链空投交互与测试币ETH领取教程

加密先生

Goerli 测试币 空投

硬核干货!一文掌握 binlog 、redo log、undo log

架构精进之路

MySQL 数据库 后端 6 月 优质更文活动

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