热更原理
C# 之 CLI
C# 的编译过程
- 代码通过C#编译器变异成通用中间件语言(CIL)
- CIL通过虚拟机(运行时),编译成成对应系统的机器码,并执行
很明显,C#是一种解释型语言,与直接编译成机器码的C/C++这类编译型语言不同,C#的底层平台是运行时,而C/C++的底层平台直接是操作系统
CLI的定义
CLI指的是一种标准,就比如USB设计规范,只要满足这种规范的USB设备,那都可以进行连接,而CLI的规范则包括
- 虚拟执行系统(VES,运行时)
- 公共中间语言(CIL)
- 公共类型系统(CTS)
- 公共语言规范(CLS)
- 元数据(metadata)
- 框架(Framework)
CLI并非只是为C#的服务的,倒不如说,C#是满足了CLI规范的一种设计,其他语言自然也可以去按照这套规则来设计
CLI的实现
目前有很多种实现,比较出名的有.NET和Mono
- .NET是微软所用的C#编译器,只能用与windows操作系统
- Mono是一个开源项目,提供了C#编译器的多平台版本
Unity跨平台
程序集
Unity提供程序集功能,允许我们自己划分我们的代码结构,不属于任何程序集的代码则会打包进我们的默认程序集,最终是一个DLL文件,我们可以在项目中找到这些DLL
而这些DLL存放的并非是C/CPP代码,是我们上文提到的公共中间语言(CIL) 我们可以编写段个简单的C#代码来看看,左边是代码,右边是编译后的DLL
在.NET平台下,所有的C#代码都会被编译成类似的CIL,然后运行在虚拟机上,由虚拟机把CIL转译成不同平台的原生代码
Mono
mono就是虚拟机的一种实现,他是基于CLR的一个开源项目,他可以把CIL再转译成对应平台的代码,目前mono支持非常多的平台,因此可以实现跨平台
而monoy运行时有三种形式
- JIT (Just In Time) 即时模式,编译时把C#编译成CIL,运行时逐条读入CIL,并转译成目标平台的代码
- AOT (Ahead Of Time) 提前模式,编译时把C#编译成CIL,然后再处理一部分CIL,编译成平台原生代码
- Full AOT 完全提前模式,同AOT,但会处理所有的CIL代码
在安卓平台时区别可能不大,但是在IOS平台时,因为IOS禁止运行时访问内存,所以不允许动态的创建新的函数,所以再IOS平台采用的Full AOT模式
JIT
JIT的具体过程
比如程序运行时,需要调用函数A:
- 从加载的Dll托管模块的元数据表中查找被调用的函数,找到函数对应的IL指令集,并返回给CLR
- 将该函数的IL指令集,经过本机代码编译器,编译成本机CPU指令(机器码)
- 申请一块可执行的内存,将编译成的本机机器码复制到内存中,并返回该内存的地址
JIT优点
- 增加启动速度,减少加载代码量。
- 提高性能:因为编译过程的最后一部分是在运行时进行的,JIT编译器确切地知道程序运行在什么类型的处理器上,可以利用该处理器提供的任何特性或特定的机器代码指令来优化最后的可执行代码。
为什么IOS禁止JIT
JIT的实现需要底层系统支持动态代码生成
对IOS来说,这意味着要支持动态分配带有“可写可执行”权限的内存
当一个应用程序拥有请求分配可写可执行内存的权限时,它会比较容易受到攻击从而允许任意代码动态生成并执行,这样就让恶意代码更容易有机可乘
因此,IOS在App运行过程中新申请的内存,读写和执行权限,只能二选一。能读写,就不能执行,能执行就不能读写
JIT的实现过程是会先将机器码写进内存,这是需要【读写】权限的。然后【执行】,这相当于一块新申请的内存同时需要读写和执行两个权限,是不被IOS所允许的。所以,IOS并不支持JIT
例子
- 先定义一个函数,比如函数:long add(long num) { return num + 1;},通过iMac终端的命令来获取该函数在本机CPU架构下的机器码
- 动态的申请分配一块内存空间,并将函数对应的机器码映射到内存空间中。并声明一个函数指针,来指向这块内存空间的首地址,方便后续模拟函数调用。这里我们使用c语言做实验,利用mmap函数来实现这一点
注意mmap的权限设置
- PROT_EXEC 映射区域可被执行
- PROT_READ 映射区域可被读取
- PROT_WRITE 映射区域可被写入
-
编译运行
-
此时,如果我们删掉内存的可执行权限PROT_EXEC,再次编译运行,则会报错
Lua
Lua热更解决方案的核心是Lua虚拟机。虚拟机可以看成是处于系统和APP之间的中间层。如果系统和App之间没有中间层,就像绝大部分编程语言一样,那么支持热更新就是系统的责任。所以苹果封了系统对动态申请内存的可执行权限后,就无法进行热更新了。
但Lua虚拟机,处于系统和APP之间,做到了代替系统进行热更新的责任 。
首先,Lua定义了各种对系统的操作函数,这些操作函数内嵌在Lua之中,在游戏发布时随应用程序一起打包。在程序初始化运行时,其所有可执行的操作均已经申请好内存了。当然就不存在在程序运行时去动态申请内存和权限二选一的问题了。而我们编写的Lua代码只是相当于传递给Lua的参数,指导Lua如何调用其内部已经存在的操作函数。
本质上,我们可以将Lua虚拟机视为一个函数,Lua文件视为函数的参数。我们更新Lua文件,实际上就是更新了Lua需要执行的“命令参数”字符串而已。这些“命令参数”经过Lua解释后得到对应的操作函数,然后由Lua去运行这些操作函数,这样Lua就完成了热更。
ILRuntime
ILRunTime实现热更的原理是:在游戏主工程之外,建立一个热更工程,如HotFix工程。在热更时,只需要将HotFix工程编译成DLL,上面我们提到了,由于IOS不支持JIT,所以不能直接让CLR加载热更DLL并执行它。那么ILRunTime是怎么使用这个热更DLL的呢?主要分为以下几个步骤:
- ILRuntime借助Mono.Cecil库来读取热更DLL的PE信息,以及当中类型的所有信息,最终得到方法的IL指令。(注意:这里只是读取,并未执行,所以IOS上也是允许的)
-
拿到上面的IL指令后,ILRuntime通过内置的IL解释器来模拟CLR,执行热更DLL中代码对应的IL指令。
从上面可以看出,其获得热更DLL的IL指令后,并不是通过JIT来执行的。而且交给了ILRunTime虚拟机,去模拟CLR对CPU的各种基本操作,进行辅助解释。ILRunTime的核心源码内部有一个很大的switch/case结构,就是针对基本上每一条IL指令码进行解释执行,这点跟Lua解释器是类似的。每一条指令解释执行出的操作都是ILRunTime源码在程序初始化时注册好的操作。所以在这个过程中,并不存在程序运行时动态申请内存的问题。这就是ILRunTime的实现热更的原理。
总结
执行效率
对比Lua和ILRuntime的执行效率:
- 在进行数值运算上,Lua的效率要明显好于ILRuntime。
- 在操作变量引用上,ILRuntime的效率要高于Lua。
Lua解释器会把包括数值运算在内的所有操作转换为操作码,然后由每个操作码对应的C语言函数来执行。由于是转换成C函数来执行,所以Lua执行数值运算的效率是非常高的。对于一些我们自定义类型,比如Unity中的Vector2类型,如果直接调用C#侧Unity的Vector2运算,频繁的让Lua与C#通信,那么效率会很差
在获取变量引用的效率上,ILRuntime的效率要明显好于Lua的。主要是因为ILRuntime热更代码在解释器中执行时,可以直接调用主工程的变量类型。因为都是在C#环境下的,所以无需进行类型转换。Lua侧与C#侧在进行相互变量引用时,必须通过Lua栈进行传递。首先有个知识点,C#会在Lua虚拟机初始化时,专门构建一个对象池,用来保存Lua侧引用的C#对象。当Lua侧与C#侧传递的是引用类型对象时,本质上传递的是对象在C#侧的对象池里的ID。如果是值类型的变量,传入Lua栈时,虚拟机会先通过预定义的Pack方法将值类型变量封装成Lua的userdata(本质上是一个C语言的内存块)。在从Lua栈中取出时,则会通过预先定义的UnPack方法来将userdata转换回值类型变量。到这里细心的同学可能会问,上面4.1.1中不是提到了值类型不需要通过Lua栈进行传递,只需要本地覆盖实现就可以了吗?其实这两种方式都可行,tolua中采用的是本地覆盖实现的方式去解决,而xlua则采用了本节中提到的Pack成userdata的方式去实现。