概述
在了解 Mono 前,首先要知道一个 .NET 语言(如C#)在编译成本地代码的过程中发生了什么:
阶段一(编译期):翻译成CIL
我们知道 C++ 经过预编译、编译、汇编、连接等步骤后会直接生成包含处理器的Native Code(本地代码/机器代码),并可以被CPU直接执行。而不同于 C++,C#、VB.net、J#、UnityScript、Boo 等这些 .NET 下的语言经过 C# Complier、VB.NET Complier 等编译器编译后,会生成一种统一标准格式的中间语言:CIL(Common Intermediate Language,公共中间语言,以前也叫MSIL)。
CIL 是不基于特定平台或处理器的目标代码,它可以用于描述程序集,但它不能被 CPU 直接执行,需要进一步翻译成 Native Code。
阶段二:PE Loader 启动 CLR 服务后
在了解 CLR 之前,我们需要先知道两个概念:
- 托管代码(Managed Code):由 .NET CLR管理,CIL 就是一种托管代码;
- 非托管代码(Unmanaged Code):不需要 CLR 管理;
然后再来看 CLR(Common Language Runtime)公共语言运行时,它为托管代码提供各种服务,如跨语言集成、代码安全性访问、对象生存期管理、调试和分析支持。它主要有以下作用:
- 加载类型,并将其缓存起来,下一次不用再缓存起来;
- 负责垃圾回收;
- 验证元数据的类型是否是正确的;
- 将托管代码交给它内部的编译器 JIT(Just In Time Complier)即时进行编译;
阶段三(运行期):JIT 将 CIL 翻译成 Native Code
JIT 负责在运行时对托管代码进行编译,并将编译后的代码交还给 CLR 进行管理。但是它并不是每一次都会对方法进行编译。
JIT 只在第一次调用方法时进行编译。在第一次对某个方法进行编译时,JIT 会为该方法生成一个 Stub(存根)。Stub 本质上存的是该方法的相对虚拟地址,JIT 会将 Stub 交给 CLR 进行存储。下一次调用该方法时,JIT 会从 CLR 中拿到这个虚拟地址进行调用,而不会重新编译。
可见,相对于读一段编译一段的完全动态编译的编译器来说,JIT 很好的弥补了同一个方法多次编译的缺点。
最后可以得到这样一张顶层 .NET 语言到本地代码的流程图:
.NET
不止 C# 能够编译为 CIL , 一些其他的语言也可以,例如F#、VB.NET 之类的。CIL 程序需要由一个 .NET Runtime 来运行。
- 微软一开始提供了仅支持 Windows 的 .NET Framework;
- .NET Framework 推出后,Ximian 开始提供一款名为 Mono 的 .NET Runtime,它是开源的,并且可以跨平台运行;
- 微软后来也开始开发跨平台 .NET Runtime , 即 .NET Core,还把维护 Mono 的 Xamarin 公司收购了;
- 当下,微软已经把 .NET Framework / .NET Core / Mono 全部收入囊中,并整合进新的 .NET Runtime 中了,就变成了 .NET 5;
.NET Runtime 可以看做一种能够运行 CIL 程序的更基础的程序,它能将硬件平台的复杂性与程序隔离开,使得一份 CIL 程序可以在多个不同的硬件上运行,而不是针对每个硬件单独编译一次;
Mono 提供了跨平台的支持,使得 CIL 得以运行在非 Windows 的设备上,所以在 Unity 创立之初(2004年)选择了 Mono 作为 C# 的运行时而不是 .NET Framework;
.NET Runtime 因为有多个实现,所以在它之上其实还有一套标准在约束着它们,即 .NET Standard。.NET Standard 是一套 API 的集合,任何 .NET Runtime 都要提供这些 API 的实现;
.Net 的发展非常快,在很多地方已经优于 Mono,Unity 已经打算抛弃 Mono 了,Unity 可能很快就会发布基于 .NET 5+ 的游戏引擎;
C# 的版本决定了 C# 能够提供哪些语言特性,不过具体是否能够提供取决于 .NET Runtime 的支持,Unity 受限于 Mono,经常只提供部分的新特性支持;
Unity 其实也搞了一个名为 IL2Cpp(Intermediate Language To C++)的编译工具,由于 C# 编译后的 CIL 运行在 .NET Runtime 中,性能与安全性不如C++,所以 Unity 将 CIL 进一步转化为 C++ 代码,再转化为硬件支持的机器语言程序,使得性能与安全性进一步提升;
在编辑器模式下 Unity 会采用 Mono 作为后端,以 JIT(Just-In-Time)模式运行,以便快速预览修改,这也是为什么修改代码后,能那么快就在编辑器中运行起 Unity 的原因;
在构建成可执行程序时,则使用 AOT(Ahead-Of-Time)模式,即提前将所有代码编译好,以可以避免运行时损耗。Mono 支持 AOT 模式,但是一般会使用 IL2Cpp 来生成性能更好的 C++ 代码;
Mono
Mono 是一个移植了 .NET CLR 的一个开源项目,本质上是一个虚拟机(CLR的重新实现)。Mono 的出现很好地解决了 .NET 无法跨平台的缺陷。
Mono 是如何跨平台的
由于已经知道了 .NET 代码从编译到运行的过程,我们可以看到 CIL 是一个很好的跨平台语言,但它需要 CLR 支持才能运行,而 CLR 在以前只能运行在 .NET 平台上。Mono 作为一个虚拟机,其制作者将 CLR 在所有平台上重新实现了一遍(实现了 IL 编译和运行时)。
但是这种做法也使得 Mono 本身的维护成本很高,迭代速度也跟不上 .NET 版本更新的速度。另外由于Mono版本授权受限,Unity自己又专门维护了一个Mono版本,所以有些同一个C#版本的特性(比如异步流)在Unity上用不了。
在 Unity 脚本编译成 IL 后,和其他第三方 DLL 一起被 Mono 虚拟机执行:
Mono 编译 CIL 的手段
除了 CLR 中的 JIT。Mono还提供了以下编译手段
- AOT:静态编译,将部分代码提前编译好,部分代码交由 JIT 在运行时编译;
- Full AOT:在编译成 CIL 后,直接将所有代码编译成本地代码;
根据需求我们可以在不同情况下使用哪种编译:对代码要求安全性高,或不支持 JIT 的平台(诸如 IOS)时使用 Full AOT。需要热更代码时使用 JIT 或 AOT。
动态编译执行 / 动态解释执行
iOS平台下由于数据执行保护(DEP)的缘故,不允许Page被同时赋予执行和写这两种权限(Intel 中也称为W^X或XD,在ARM中也称为XN,即要么写入要么执行)。所以将代码动态写入内存后就不能再拥有执行权限,而应用程序在一开始加载后的代码之所以可以被执行,是因为编译后载入的内存是预先从硬盘中载入的,并不算是写入的内存。因此 JIT 在 iOS 上被完全禁用,只能使用 Mono-Full AOT 模式或直接编译成 IL2CPP。
这里的DLL被动态分配的内存执行指的是 JIT 将编译后的代码写入内存,然后由CPU统一调度。这就是动态编译执行IL代码,其本质上是 CPU 直接调用机器码。
我们在 iOS 上能做的只有执行程序被加载时所被分配内存的代码。于是“虚拟机”出现了,它的本质上是用已经被载入内存的代码来动态解释执行时加载的 dll (模拟CPU调用)。此时运行时加载的 dll 仅相当于资源文件,它的解释权不由 CPU 直接提供,而是解释工具提供(CPU 执行编译后的虚拟机,虚拟机解释执行 DLL)简单来说,对虚拟机而言,数据是数据,指令也是数据。
目前大多热更新方案都采用了 Mono.Cecil 来实现虚拟机,比如 ILRuntime、xLua。
另外简单说一下 Huatuo(HybridCLR),它和上面两种热更方式最大区别是,只解释需要CPU执行的代码(新增的变化代码),其他代码全部用AOT方式执行(通过魔改IL2CPP的方式)。热更层可以直接访问原生层数据(只解释需要解释的IL代码)。而上面两种热更方式在热更层不管是什么代码都要解释执行,因此要访问原生层对象时都要进行“跨域访问”,意思是明明是同一个数据,热更解释出来的数据却是另外一个内存里的东西,因此还需要用一个适配器来访问原生层数据。一个在AOT层就做了适配,一个只能在热更层做适配,格调一下子就拉开了。
IL2CPP
IL2CPP 是为了代替 Mono 在多平台上移植困难而采用的方案。对比 Mono,IL2CPP 还需要将 IL 代码转译成 C++ 代码,再由各平台的 C++ 编译器编译成汇编代码。而 IL2CPP 虚拟机则负责部分 CLR 的工作(比如内存管理、线程创建等),以此保留部分 C# 语言特性。
优缺点
优点:
- 能利用现有平台的C++编译器对其进行优化,缩小最终包体体积;
- 执行效率要优于 Mono;
- 避免了堆内存只涨不降;
缺点:
- IL2CPP 由于最终采用 C++ 这样的静态语言,导致其丧失了 JIT 的全部功能;
- 由于需要额外的一次转译,导致编译时间变长;
Assembly
过去,编程人员在编译Windows C++或Visual Basic应用程序时,编译结果是以 .exe 或 .dll 为扩展名的文件。在.NET中这一点仍然不变,但这些结果文件被赋予了一个新的名称:程序集(Assembly)。当然差别并不仅仅于此。在.NET中,这些文件的内部格式与以前相比有很大区别。.NET之前,DLL和EXE文件是包含了有平台特征的代码,而所有的.NET程序集包含了称为通用中间语言(Common Intermediate Language)的跨平台代码。
通常,一个程序集正好由一个DLL或EXE文件构成,然而程序集也可由多个DLL或EXE组成。用户无需考虑其内部物理构成,就可以使用程序集中定义的类型。因此,从逻辑的角度来看,可以把程序集简单地看着一个类、结构、枚举等的类型集合体。程序集又是通过清单(Manifest)巧妙地实现了这一点。
程序集清单包含了有关程序集的重要信息。以多文件程序集为例,清单列出了构成程序集的全部DLL和EXE,还包括版本号、区域信息和类型参考信息等。如果一个程序集依赖于其他程序集,清单中还要列出依赖关系。由于清单信息用于描述程序集,在通用术语中被称为元数据(metadata)。当然,清单并非存放元数据的惟一地方,在程序集的核心,每个实现类型均有与之对应的元数据。其概要是,每个程序集都有完整的自身描述。
其他
C# 反射开销
Mono 和 IL2CPP 会在内部缓存所有 C# 反射(System.Reflection)对象,并且按照设计,Unity 不会对它们进行垃圾收集。此行为的结果是垃圾回收器在应用程序生命周期内持续扫描缓存的 C# 反射对象,这会导致不必要和潜在的大量垃圾回收器开销。
要最大程度减少垃圾回收器开销,请在应用程序中避免使用诸如 Assembly.GetTypes 和 Type.GetMethods() 等方法,这些方法会在运行时创建许多 C# 反射对象。而是应该在编辑器中扫描程序集以获取所需数据,并进行序列化或代码生成以在运行时使用。
参考
https://docs.unity3d.com/cn/2021.1/Manual/overview-of-dot-net-in-unity.html