原文链接:Unity 游戏用XLua的HotFix实现热更原理揭秘-CSDN博客
本文通过对XLua的HoxFix使用原理的研究揭示出来这样的一套方法。这个方法的
- 第一步:通过对C#的类与函数设置Hotfix标签。来标识需要支持热更的类和函数。
- 第二步:生成函数连接器来连接LUA脚本与C#函数。
- 第三步:在C#脚本编译结束后,使用Mono提供的一套C#的API函数,对已经编译过的.Net体系生成的DLL文件进行修改。
- 第四步,通过LUA脚本修改C#带有标签的类中静态变量,把代码的执行路径修改到LUA脚本中。通过这套方案可以实现对已经标识的C#代码进行动态更新。
基础准备
知识准备
CIL: 通用中间语言(Common Intermediate Language,简称CIL), 是一种属于通用语言架构和 .NET 框架的低阶(lowest-level)的人类可读的编程语言。目标为 .NET 框架的语言被编译成CIL(基于.NET框架下的伪汇编语言,原:MSIL),这是一组可以有效地转换为本机代码且独立于 CPU 的指令。CIL类似一个面向对象的汇编语言,并且它是完全基于堆栈的。它运行在CLR上(类似于JVM),其主要支持的语言有C#、VisualBasic .NET、C++/CLI以及 J#(集成这些语言向CIL的编译功能)。
在编译.NET编程语言时,源代码被翻译成CIL码,而不是基于特定平台或处理器的目标代码。CIL是一种独立于具体CPU和平台的指令集,它可以在任何支持.NET framework的环境下运行。CIL码在运行时被检查并提供比二进制代码更好的安全性和可靠性。在Unity3D中,是用过Mono虚拟机来实现运行这些中间语言指令的。
之前写一篇介绍过一篇使用微软的API函数,利用中间语言生成或注入.NET支持下的DLL。这里就不在赘述,需要了解的请参考《使用MSIL采用Emit方式实现C#的代码生成与注入》。
IL2CPP: 直接理解把IL中间语言转换成CPP文件。根据官方的实验数据,换成IL2CPP以后,程序的运行效率有了1.5-2.0倍的提升。引用地址:Unity将来时:IL2CPP是什么?有了Mono为什么还需要IL2CPP?_unity windows il2cpp什么时候用-CSDN博客
使用Mono的时候,脚本的编译运行如下图所示:
简单的来说,3大脚本被编译成IL,在游戏运行的时候,IL和项目里其他第三方兼容的DLL一起,放入Mono VM虚拟机,由虚拟机解析成机器码,并且执行
IL2CPP做的改变由下图红色部分标明:
在得到中间语言IL后,使用IL2CPP将他们重新变回C++代码,然后再由各个平台的C++编译器直接编译成能执行的原生汇编代码。
一,Lua基础之热更新
首先,什么是热更新
- 字面意思就是对lua的一些代码进行更新,在介绍热更新之前,我想要和大家分享一下lua的require的机制
- 我们知道lua加载一个文件的方式可以有:dofile,loadfile以及 require。其中loadfile是只编译不执行,dofile和require是同时编译和执行。而dofile和require的区别是dofile同一个文件每次都要加载,也就是说,dofile两次返回来的是两个不同的地址。而require同一个文件,不管多少次都是都返回同一个地址,其原因是lua的地址缓存在了package.load()中。所以效率比dofile要高许多,因而现在一般都是用require加载文件。
- 那么问题来了,如果我在lua文件中改变了一些数值(产生了新的地址),结果你却用之前的地址,那岂不是白给了吗?
于是热更新机制应运而生。其实现方式有两种:
(1)简单版但是有缺陷
package.load(“modelname”) = nil
-- 修改modelname.lua的数据
require(“modelname”)
- 既然你有缓存,我直接置为空不就好了吗?然后重新require一次把修改好的加进来。这样子做的话第二次require的数据可能是正确的,但是之前require过一次的数值却仍然错误,所以说程序除非在之前没有加载过这个文件,否则得到的结果不完善。
(2)复杂版但是很有用
function reload_module(module_name)
local old_module = package.loaded[module_name] or {}
package.loaded[module_name] = nil
require (module_name)
local new_module = package.loaded[module_name]
for k, v in pairs(new_module) do
old_module[k] = v
end
package.loaded[module_name] = old_module
return old_module
end
-
简单来说就是使用一个全局表存储了新修改后的所有数值,然后循环赋值给旧的值,这样就可以确保同一个旧地址也可以得到正确的数据。
要点分析
Lua 语言
-
再热更新功能开发过程中,我们需要用到一款新的语言:Lua语言。
-
Lua和C#对比:C#是编译型语言,Lua是解析型语言
-
Lua语言不可以单独完成一个项目的开发,Lua语言出现的目的是“嵌入式”,为其他语言开发出来的项目进行功能的扩展和补丁的更新。
2.Lua语言与C#语言交互
- Unity项目是使用C#开发的,后续热更新的功能需要使用Lua语言实现。而我们在最开始使用C#开发项目的时候,需要预留和Lua代码的“交互接口”,这就涉及到两门语言的代码相互调用访问。
3.AssetBundle
- AssetBundle是Unity内资源的一种打包格式,和电脑上的rar、zip压缩包比较类似,客户端热更新过程中,从服务器上下载下来的资源,都是AssetBundle打包过的资源。
4.ULua和XLua热更新框架
- ULua和XLua是两个热更新框架,专门用于Unity客户端项目热更新开发。其实就是两个“资源包”,导入到我们的项目中,在框架的基础之上,完成我们项目需要的热更新逻辑。
3.Lua热更新的实现
- 1.将模块中旧的函数替换成新的函数,这个新的函数可以放到一个lua文件中,或者以字符串的形式给出。
- 2.将模块中旧的函数,当前用到的所有上值,(什么是上值,后面有讲到)保存到起来,用于新函数引用,保证新函数作为模块中的一部分能够正确运行。
下面以一个demo为例,这也是抽取 snax 模块中热更新部分:
./main.lua 调用 test.lua,做为运行文件,显示最终运行效果
./test.lua 一个简单模块文件,用于提供热更新的来源
./test_hot.lua 用于更新替换 test 模块中的某些函数,更新文件
./hotfix.lua 实现热更新机制
通过这幅关系图,可以了解到,test 模块和 test_hot 之间的关系,test_hot 负责更新 test 模块中的某些函数,但更新后的这些函数依然属于 test 模块中的一部分,并没有脱离 test 模块的掌控,而独立出来。
- 现在我们看看 test.lua 包含了哪些内容,分别有 一个局部变量 index,两个函数 print_index,show ,函数体分别是圆圈1和2,两个函数都引用到了这个局部变量 index。
- 假设当前,我们想更新替换掉 print_index 函数,让其 index 加1 操作,并打印 index 值,那么我们可以在 test_hot.lua 文件中这么写,见下图黄色框部分:
- 我们希望在 print_index 更新后, index 加 1 后,show 函数获取到的 index 值是 1,即把更新函数也看作是 test.lua 模块中的一部分。而不应该是 index 加 1 后,show 函数获取到的还是原值 0。
- 假设我们希望更新 print_index 后,再一次更新,把 index 值直接设置为 100,那么它又应该是这样子的,见下图最左侧黄色部分:
4._ENV 环境变量
- 在 lua 程序设计一书中有过这样的解释,lua 语言并没有全局变量,所谓的全局变量都是通过某种手段模拟出来的。
Lua 语言是在一个名为 _ENV 的预定义上值(一个外部的局部变量,upvalue)存在的情况下编译所有的代码段的。因此,所有的变量要么绑定到一个名称的局部变量,要么是 _ENV 中的一个字段,而 _ENV 本身是一个局部变量。
例如:
local z = 10
x = 0
y = 1
x = y + z
等价于
local z = 10
_ENV.x = 0
_ENV.y = 1
_ENV.x = _ENV.y + z
- x,y 都是不用 local 声明,z 是 local 声明。
- 所以,我们用到的全局变量其实是保存到 _ENV 变量中。lua 语言在内部维护了一个表来作用全局环境(_G),通常,我们在 load 一个代码段,一个模块时,lua 会用这个表(_G)来初始化 _ENV。如果上面的几行代码是写在一个文件中,那么当 load 调用它时,又会等价于:
-- xxx.lua 文件
local _ENV = the global environment (全局环境)
return function(...)
local z = 10
_ENV.x = 0
_ENV.y = 1
_ENV.x = _ENV.y +z
end
5.上值 upvalue
从这里开始不是很懂,之后再复盘吧 ------------------------------------------------------------------------------
当一个局部变量被内层的函数中使用的时候, 它被内层函数称作上值,或是外部局部变量。引用 Lua 5.3 参考手册
例如:
local x = 10
function hello(a, b)
local c = a + b + x
print(c)
end
那么在这段代码中,hello 函数的上值有 变量 x,_ENV,而我们刚刚讲到,print 没有经过声明,就可以直接使用,那么它肯定是保存于 _ENV 表中,print(c) 等价于 _ENV.print(c),而变量 a、b、c 都是做为 hello 函数的局部变量。
6.热更新函数Lua的require函数
- Lua的require(modelname)把一个lua文件加载存放到package.loaded[modelname]中,重复require同一个模块实际还是沿用第一次加载的chunk。因此,很容易想到,第一个版本的热更新模块可以写成这样:
--强制重新载入module
function require_ex( _mname )
log( string.format("require_ex = %s", _mname) )
if package.loaded[_mname] then
log( string.format("require_ex module[%s] reload", _mname))
end
package.loaded[_mname] = nil
require( _mname )
end
- 可以看到,强制地require新的模块来更新新的代码,非常简单暴力。但是,显然问题很多,旧的引用住的模块无法得到更新,全局变量需要用"a = a or 0"这种约定来保留等等。这种程度的热更新显然不能满足现在的游戏开发需求。
7.热更新函数Lua的setenv函数
setenv是Lua 5.1中可以改变作用域的函数,或者可以给函数的执行设置一个环境表,如果不调用setenv的话,一段lua chunk的环境表就是_G,即Lua State的全局表,print,pair,require这些函数实际上都存储在全局表里面。那么这个setenv有什么用呢?我们知道loadstring一段lua代码以后,会经过语法解析返回一个Proto,Lua加载任何代码chunk或function都会返回一个Proto,执行这个Proto就可以初始化我们的lua chunk。为了让更新的时候不污染_G的数据,我们可以给这个Proto设置一个空的环境表。同时,我们可以保留旧的环境表来保证之前的引用有效。
local Old = package.loaded[PathFile]
local func, err = loadfile(PathFile)
--先缓存原来的旧内容
local OldCache = {}
for k,v in pairs(Old) do
OldCache[k] = v
Old[k] = nil
end
--使用原来的module作为fenv,可以保证之前的引用可以更新到
setfenv(func, Old)()
8.热更新函数Lua的debug库函数
Lua的函数是带有词法定界的first-class value,即Lua的函数与其他值(数值、字符串)一样,可以作为变量、存放在表中、作为传参或返回。通过这样实现闭包的功能,内嵌的函数可以访问外部的局部变量。这一特性给Lua带来强大的编程能力同时,其函数也不再是单一无状态的函数,而是连同外部局部变量形成包含各种状态的闭包。如果热更新缺少了对这种闭包的更新,那么可用性就大打折扣。
下面讲一下热更新如何处理旧的数据,还有闭包的upvalue的有效性问题怎么解决。这时候强大的Lua debug api上场了,调用debug库的getlocal函数可以访问任何活动状态的局部变量,getupvalue函数可以访问Lua函数的upvalues,还有相对应的修改函数。
例如,这是查询和修改函数局部变量写的debug函数:
-- 查找函数的local变量
function get_local( func, name )
local i=1
local v_name, value
while true do
v_name, value = debug.getlocal(func,i)
if not v_name or v_name == name then
break
end
i = i+1
end
if v_name and v_name == name then
return value
end
return nil
end
-- 修改函数的local变量
function set_local( func, name, value )
local i=1
local v_name
while true do
v_name, _ = debug.getlocal(func,i)
if not v_name or v_name == name then
break
end
i = i+1
end
if not v_name then
return false
end
debug.setlocal(func,i,value)
return true
end
一个函数的局部变量的位置实际上在语法解析阶段就已经能确定下来了,这时候生成的opcode就是通过寄存器的索引来找到局部变量的,了解这一点应该很容易理解上面的代码。
9.深度递归替换所有的upvalue
- 接下来要做的事情很清晰了,递归所有的upvalue,根据一定的替换规则替换就可以,注意新的upvalue需要设置回原来的环境表。
function UpdateUpvalue(OldFunction, NewFunction, Name, Deepth)
local OldUpvalueMap = {}
local OldExistName = {}
-- 记录旧的upvalue表
for i = 1, math.huge do
local name, value = debug.getupvalue(OldFunction, i)
if not name then break end
OldUpvalueMap[name] = value
OldExistName[name] = true
end
-- 新的upvalue表进行替换
for i = 1, math.huge do
local name, value = debug.getupvalue(NewFunction, i)
if not name then break end
if OldExistName[name] then
local OldValue = OldUpvalueMap[name]
if type(OldValue) ~= type(value) then -- 新的upvalue类型不一致时,用旧的upvalue
debug.setupvalue(NewFunction, i, OldValue)
elseif type(OldValue) == "function" then -- 替换单个函数
UpdateOneFunction(OldValue, value, name, nil, Deepth.." ")
elseif type(OldValue) == "table" then -- 对table里面的函数继续递归替换
UpdateAllFunction(OldValue, value, name, Deepth.." ")
debug.setupvalue(NewFunction, i, OldValue)
else
debug.setupvalue(NewFunction, i, OldValue) -- 其他类型数据有改变,也要用旧的
end
else
ResetENV(value, name, "UpdateUpvalue", Deepth.." ") -- 对新添加的upvalue设置正确的环境表
end
end
end
10.实例分析
- 下面就来看下具体 demo 的实现。
-- main.lua
local hotfix = require "hotfix"
local test = require "test"
local test_hot = require "test_hot"
print("before hotfix")
for i = 1, 5 do
test.print_index() -- 热更前,调用 print_index,打印 index 的值
end
hotfix.update(test.print_index, test_hot) -- 收集旧函数的上值,用于新函数的引用,这个对应之前说的归纳第2小点
test.print_index = test_hot -- 新函数替换旧的函数,对应之前说的归纳第1小点
print("after hotfix")
for i = 1, 5 do
test.print_index() -- 打印更新后的 index 值
end
test.show() -- show 函数没有被热更,但它获取到的 index 值应该是 最新的,即 index = 5。
- 接下来看看 test.lua 模块内容:
-- test.lua
local test = {}
local index = 0
function test.print_index()
print(index)
end
function test.show( )
print("show:", index)
end
return test
- 再看看 热更文件 test_hot.lua 内容:
-- test_hot.lua
local index -- 这个 index 必须声明,不用赋值,才能够引用到 test 模块中的局部变量 index
return function () -- 返回一个闭包函数,这个就是要更新替换后的原型
index = index + 1
print(index)
end
- 最后,再看看 hotfix.lua:
-- hotfix.lua
local hotfix = {}
local function collect_uv(f, uv)
local i = 1
while true do
local name, value = debug.getupvalue(f, i)
if name == nil then -- 当所有上值收集完时,跳出循环
break
end
if not uv[name] then
uv[name] = { func = f, index = i } -- 这里就会收集到旧函数 print_index 所有的上值,包括变量 index
if type(value) == "function" then
collect_uv(value, uv)
end
end
i = i + 1
end
end
local function update_func(f, uv)
local i = 1
while true do
local name, value = debug.getupvalue(f, i)
if name == nil then -- 当所有上值收集完时,跳出循环
break
end
-- value 值为空,并且这个 name 在 旧的函数中存在
if not value and uv[name] then
local desc = uv[name]
-- 将新函数 f 的第 i 个上值引用旧模块 func 的第 index 个上值
debug.upvaluejoin(f, i, desc.func, desc.index)
end
-- 只对 function 类型进行递归更新,对基本数据类型(number、boolean、string) 不管
if type(value) == "function" then
update_func(value, uv)
end
i = i + 1
end
end
function hotfix.update(old, new)
local uv = {}
collect_uv(old, uv)
update_func(new, uv)
end
return hotfix
- 这个用到了 lua 的两个 api 函数,在 Lua 5.3 参考手册 中有介绍。
debug.getupvalue (f, up)
此函数返回函数 f 的第 up 个上值的名字和值。 如果该函数没有那个上值,返回 nil 。
debug.upvaluejoin (f1, n1, f2, n2)
让 Lua 闭包 f1 的第 n1 个上值 引用 Lua 闭包 f2 的第 n2 个上值。
- 我们可以看到, hotfix.lua 做的事也是比较简单的,主要是收集 旧函数的所有上值,更新到新函数中。最后一步替换旧函数是在 main.lua 中完成。
- 最后看看运行结果:
[root@instance test]# lua main.lua
before hotfix
0
0
0
0
0
after hotfix
1
2
3
4
5
-------------
show: 5
四、Lua脚本热更新方案
-
热更新,通俗点说就是补丁,玩家那边知道重启客户端就可以更新到了的,不用卸载重新安装app,相对于单机游戏,这也是网络游戏用得比较多的一个东西吧。
-
首先,大概流程如下:
- luaFileList.json文件内容一般是lua文件的键值对,key为lua文件路径+文件名,value为MD5值:
五、lua热更新
1.什么是热更新
- 热更新也叫不停机更新,是在游戏服务器运行期间对游戏进行更新。实现不停机修正bug、修改游戏数据等操作
2.热更新原理第一种:
- lua中的require会阻止多次加载相同的模块。所以当需要更新系统的时候,要卸载掉响应的模块。(把package.loaded里对应模块名下设置为nil,以保证下次require重新加载)并把全局表中的对应的模块表置 nil 。同时把数据记录在专用的全局表下,并用 local 去引用它。初始化这些数据的时候,首先应该检查他们是否被初始化过了。这样来保证数据不被更新过程重置。
原文链接:Unity将来时:IL2CPP是什么? - 知乎 (zhihu.com)
IL
啰 嗦完了C#,.Net Framework和Mono,引出了我们很重要的一个概念”IL“。IL的全称是 Intermediate Language,很多时候还会看到CIL(Common Intermediate Language,特指在.Net平台下的IL标准)。在Unity博客和本文中,IL和CIL表示的是同一个东西:翻译过来就是中间语言。它是一种属于 通用语言架构和.NET框架的低阶(lowest-level)的人类可读的编程语言。目标为.NET框架的语言被编译成CIL,然后汇编成字节码。 CIL类似一个面向对象的汇编语言,并且它是完全基于堆栈的,它运行在虚拟机上(.Net Framework, Mono VM)的语言。
具体过程是:C#或者VB这样遵循CLI规范的高级语言,被先被各自的编译器编译成中间语言:IL(CIL),等到需要真正执行的时候,这些IL会被加载到运行时库,也就是VM中,由VM动态的编译成汇编代码(JIT)然后在执行。
正是由于引入了VM,才使得很多动态代码特性得以实现。通过VM我们甚至可以由代码在运行时生成新代码并执行。这个是静态编译语言所无法做到的。回到上一 节我说的Boo和Unity Script,有了IL和VM的概念我们就不难发现,这两者并没有对应的VM虚拟机,Unity中VM只有一个:Mono VM,也就是说Boo和Unity Script是被各自的编译器编译成遵循CLI规范的IL,然后再由Mono VM解释执行的。这也是Unity Script和JavaScript的根本区别。JavaScript是最终在浏览器的JS解析器中运行的(例如大名鼎鼎的Google Chrome V8引擎),而Unity Script是在Mono VM中运行的。本质上说,到了IL这一层级,它是由哪门高级语言创建的也不是那么重要了,你可以用C#,VB,Boo,Unity Script甚至C++,只要有相应的编译器能够将其编译成IL都行!
IL2CPP, IL2CPP VM
- 1.Mono VM在各个平台移植,维护非常耗时,有时甚至不可能完成
- 2.Mono版本授权受限
- 3.提高运行效率
几点注意:
1.将IL变回CPP的目的除了CPP的执行效率快以外,另一个很重要的原因是可以利用现成的在各个平台的C++编译器对代码执行编译期优化,这样可以进一步减小最终游戏的尺寸并提高游戏运行速度。
2. 由于动态语言的特性,他们多半无需程序员太多关心内存管理,所有的内存分配和回收都由一个叫做GC(Garbage Collector)的组件完成。虽然通过IL2CPP以后代码变成了静态的C++,但是内存管理这块还是遵循C#的方式,这也是为什么最后还要有一个 IL2CPP VM的原因:它负责提供诸如GC管理,线程创建这类的服务性工作。但是由于去除了IL加载和动态解析的工作,使得IL2CPP VM可以做的很小,并且使得游戏载入时间缩短。
3.由于C++是一门静态语言,这就意味着我们不能使用动态语言的那些酷炫特性。运行时生 成代码并执行肯定是不可能了。这就是Unity里面提到的所谓AOT(Ahead Of Time)编译而非JIT(Just In Time)编译。其实很多平台出于安全的考虑是不允许JIT的,大家最熟悉的有iOS平台,在Console游戏机上,不管是微软的Xbox360, XboxOne,还是Sony的PS3,PS4,PSV,没有一个是允许JIT的。使用了IL2CPP,就完全是AOT方式了,如果原来使用了动态特性的 代码肯定会编译失败。这些代码在编译iOS平台的时候天生也会失败,所以如果你是为iOS开发的游戏代码,就不用担心了。因此就这点而言,我们开发上几乎 不会感到什么问题。
原文链接:【Unity游戏开发】Mono和IL2CPP的区别 - 知乎 (zhihu.com)
二、Mono介绍
Mono是一个由 Xamarin公司所主持的自由开放源码项目。
Mono的目标是在尽可能多的平台上使.net标准的东西能正常运行的一套工具,核心在于“跨平台的让.net代码能运行起来“。
Mono组成组件:C# 编译器,CLI虚拟机,以及核心类别程序库。
Mono的编译器 负责生成符合公共语言规范的映射代码,即公共中间语言(Common Intermediate Language, CIL),我的理解就是工厂方法实现不同解析。
IL科普
IL的全称是 Intermediate Language,很多时候还会看到 CIL(特指在.Net平台下的IL标准)。翻译过来就是中间语言。
它是一种属于通用语言架构和.NET框架的低阶的人类可读的编程语言。
CIL类似一个面向对象的汇编语言,并且它是完全基于堆栈的,它运行在虚拟机上(.Net Framework, Mono VM)的语言。
2.1 工作流程
- 通过C#编译器mcs,将C#编译为IL(中间语言,byte code)
- 通过Mono运行时中的编译器将IL编译成对应平台的原生码
2.2 知识点
2.2.1. 编译器
C#编译器mcs:将C#编译为 IL
Mono Runtime编译器:将IL转移为 原生码。
2.2.2. 三种转译方式
即时编译(Just in time,JIT):程序运行过程中,将CIL的byte code转译为目标平台的原生码。
提前编译(Ahead of time,AOT):程序运行之前,将.exe或.dll文件中的CIL的byte code部分转译为目标平台的原生码并且存储,程序运行中仍有部分CIL的byte code需要JIT编译。
完全静态编译(Full ahead of time,Full-AOT):程序运行前,将所有源码编译成目标平台的原生码。
2.2.3 Unity跨平台的原理
Mono运行时编译器支持将IL代码转为对应平台原生码
IL可以在任何支持CLI,通用语言环境结构)中运行,IL的运行是依托于Mono运行时。
2.2.4 IOS不支持jit编译原因
机器码被禁止映射到内存,即封存了内存的可执行权限,变相的封锁了jit编译方式. 详情见
2.2.5 JIT编译
将IL代码转为对应平台原生码并且将原生码映射到虚拟内存中执行。JIT编译的时候IL是在依托Mono运行时,转为对应的原生码后在依托本地运行。
2.3 优点
- 构建应用非常快
- 由于Mono的JIT(Just In Time compilation ) 机制, 所以支持更多托管类库
- 支持运行时代码执行
三、IL2CPP【AOT编译】
IL2CPP分为两个独立的部分:
1. AOT(静态编译)编译器:把IL中间语言转换成CPP文件
2. 运行时库:例如 垃圾回收、线程/文件获取(独立于平台,与平台无关)、内部调用直接修改托管数据结构的原生代码的服务与抽象
3.1 AOT编译器
IL2CPP AOT编译器名为il2cpp.exe。
在Windows上,您可以在Editor \ Data \ il2cpp
目录中找到它。
在OSX上,它位于Unity安装的Contents / Frameworks / il2cpp / build
目录中
il2cpp.exe 是由C#编写的受托管的可执行程序,它接受我们在Unity中通过Mono编译器生成的托管程序集,并生成指定平台下的C++代码。
IL2CPP工具链: