动手写IL到Lua的翻译器——准备
Posted 说给开发游戏的你
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了动手写IL到Lua的翻译器——准备相关的知识,希望对你有一定的参考价值。
一年没写文章了,今天开始恢复更新。
从本篇开始,小说君打算改下文章风格。
17年和16年的文章,尤其是17年的几篇,字数太多,导致了几个问题:
1.阅读难度高。技术文章对阅读连续性的要求本来就很高,在电梯里面打开看一点,吃完饭再看的时候就比较难接上了。
2.不够聚焦文章主题。没了字数限制,就会出现想到什么写什么的情况。
然后两点是对于小说君本人的:
3.写作难度高。去年其实还有几次打算分享一些东西,但是大概列了下提纲发现能写的字数似乎跟之前的文章相比差太多,就作罢了。
4.难坚持。每次写长文要消耗的精力太大,而且往往正反馈也比较少。
本篇文章就开始尝试控制字数,预期5000字以内。
介绍下问题背景:
小说君正在参与的项目,服务端逻辑以C#为主。
之前的一篇文章,也有提到,这个项目的服务端需要提供让策划写游戏业务的能力。
不过跟文章里的方案不同,最后策划用来写业务的语言是C#。
实践下来,策划写的业务分为两大类:
战斗相关的流程性质的逻辑。例如技能结算的流程性逻辑。
各模块中经常变动的运算逻辑。例如面板属性的运算逻辑。
如图,简单直接,就是程序写的Foo调用策划写的Formula。
这些逻辑如果只放在服务端,那就什么问题也没有。
第一类逻辑,由于游戏类型的原因(MMO),基本上只有服务端会用,服务端想怎么更新就怎么更新。
第二类逻辑,面板属性运算,不仅服务端需要计算完推给客户端做显示用,客户端自己也需要做属性预览。
要解决这个问题,一般的做法要么是客户端每次问服务端计算下数据,显示出来;要么是客户端也维护一份相关逻辑的定义。
第一种做法,是项目实在改不动了才不得不用。
第二种做法,如果客户端大部分面板逻辑跑在C#上,那还好办,策划的逻辑打成Assembly,客户端服务端两边共用。
但是现在Lua普及程度已经这么高了,很少还有面板主要靠C#的手游。
这样,如标题所说,如果我们有个工具可以把C#转成Lua,平时策划用C#写业务,持续集成流程自动把客户端服务端共用的业务逻辑转成Lua,客户端用自动生成的对应的Lua函数做一些面板预览计算逻辑,就解决了上面说的所有问题。
策划用C#写Formula,强类型,减少犯错。
工具把C#版本的Formula转成Formula.lua。
客户端的xxx.lua直接require Formula,调用。
把C#翻译成Lua的方法有很多种。
比如可以直接给Roslyn写插件,集成在编译流程里,取到C#的语法树,然后做自动生成。
再比如可以读C#的编译后程序集,反编译,拿到语法树,然后做代码生成。
两种方法相比较,小说君更倾向于后者。原因也很简单:
C#是一种多范式编程语言,语法特性多而杂。而且C#版本越新,语法糖越多,Lua很难覆盖。前者拿到的就是源代码对应的语法树,要变换的东西太多。
C#编译出的IL就简单多了,由于抽象层次介于底层语言和高级语言之间,基本上不用做任何变换就可以用任一门高级语言完整表达。
最关键的是,对于IL来说,有强大的ILSpy工具,可以读取IL,可以选择性地做反编译变换,方便生成适用于目标语言的语法树。
接下来进一段背景知识,用过C#的同学都知道,C#源代码会被编译为IL Assembly。然后由具体的runtime加载Assembly,编译为native code并执行,这也是现在几乎所有虚拟机语言的执行流程。
.Net Core/Mono是两个比较常见的加载执行Assembly的backend。既可以运行时JIT编译为native code直接执行,也可以编译期AOT。
IL2CPP与上面两个稍微不同,但是本质属于一种AOT。Assembly被翻译成CPP代码集合,与支持库编译、link为目标文件。
Assembly的信息除了一些元信息比如模块、类定义之外,主要存的是每个方法的IL指令集合。
IL是一种基于操作栈的虚拟机语言,所有的IL指令要么是把参数或返回值push到操作栈,要么是从操作栈pop值。
围绕IL称呼的名词比较多,不过由于这次的系列主题不会太深入,所以就简单统称为IL了。有兴趣的同学可以查阅ECMA335深入学习下IL。
C#中的200+100,翻译为IL后,就是依次push 200、push 100,然后调用add指令,从操作栈pop两个值,相加把结果push回操作栈。
介绍完IL,我们继续看把IL翻译成Lua的方案。
先看下参考方案,Unity的IL2CPP。
Unity在4.x开始引入了IL2CPP,用来在一些平台上替代mono这个逻辑脚本的backend。
IL2CPP整套工具链除了支持工具以外,主要分为两块:
把CIL Assembly翻译成CPP的工具集。
支撑翻译后的CPP正常运行在各个目标平台上的Native库。
总的来说,IL2CPP做的事情就是把IL Assembly翻译成C++文件集合,然后提供一些库函数,保证原来的IL能怎么在Mono上跑起来,现在的so就也能直接跑起来。
相比之下,由于我们的需求比较简单,所以ILToLua要做的事情就简单很多了。比如IL2CPP需要提供gc相关的库支持,lua就不用考虑这个问题。
再比如IL2CPP需要自己搞一套异常处理机制在C++中支持IL中的try-catch-finally语义,我们就可以有限支持。
先订个小目标:我们实现一个工具,可以解析IL Assembly,将其中特定类型的定义转为一个Lua module。
比如这样一个简单的类定义:
1public class Test
2{
3 private Random r = new Random();
4
5 public void Foo(Custom a, Custom b, Context ctx)
6 {
7 if ((a.Count - b.Count) > 0)
8 {
9 b.Rate = Modify(b.Rate, 0.003f * (b.Count - a.Count) * (b.Count - a.Count));
10
11 var t = Math.Min(1 - b.Rate, a.Rate);
12
13 a.Rate = Modify(a.Rate, t - a.Rate);
14 }
15 }
16
17 private float Modify(float old, float diff)
18 {
19 var newVal = old + diff;
20
21 if (newVal < 0f)
22 {
23 newVal = 0f;
24 }
25 return newVal;
26 }
27}
里面的逻辑也比较简单,刚入门的策划写起来完全没问题。
我们需要的大概的翻译效果:
1local Prelude = require("LX6/Base/Prelude")
2local Math = require("LX6/Base/Math")
3
4local Random = System.Random
5
6local Formula = {}
7
8Formula.r = Random.New()
9
10function Formula:Foo(a, b, ctx)
11 if a.Count - b.Count > 0 then
12 b.Rate = self:Modify(b.Rate, 0.003 * (b.Count - a.Count) * (b.Count - a.Count))
13 local t = Math.Min(1 - b.Rate, a.Rate)
14 a.Rate = self:Modify(a.Rate, t - a.Rate)
15 end
16end
17
18function Formula:Modify(old, diff)
19 local newVal = old + diff
20 if newVal < 0 then
21 newVal = 0
22 end
23 return newVal
24end
25
26return Formula
把这个类翻译为Lua中的一个table。
简化起见,这里就略去了table的构造函数。
两个特点:
只翻译一个类型。
由于lua本身的特性,函数用到的所有复杂参数都是鸭子类型(具体为table或udata)。
这两点跟IL2CPP很不一样,我们只需要把一个类型翻译成Lua,不需要递归地去翻译这个类型引用的其他类型。比如例子中的Custom和Context。
外面想调用的时候传一个有Count和Rate成员的table也可以,传一个真的符合类型的udata也可以。
接下来就开始进入正题了。不过由于这次文章的主题关联的内容比较多,小说君打算分成几篇短文来写。每篇聚焦的内容稍微少一点。
大概的安排是:
本篇剩下的篇幅介绍下Mono.Cecil,然后初步认识下ILSpy。
接下来介绍ILSpy的一些原理性质的东西,以及相应的实现细节。
然后开始进入ILToLua的主题,跟大家分享下实现细节。
IL2CPP把IL Assembly翻译成CPP的部分,就是靠Mono.Cecil做的。
Mono.Cecil,官方解释
Cecil is a library written by Jb Evain to generate and inspect programs and libraries in the ECMA CIL format.
简单来说,就是Mono.Cecil是符合ECMA335规范的。我们借助这个库,可以结构化地读Assembly,用起来跟.Net带的反射库差不多,只不过Mono.Cecil有自己的类型定义。可以修改Assembly。可以运行时Emit代码。
Mono.Cecil可以用来写编译器,写反编译器,以及各种东西。
Unity用到的大量工具集都用了这个库,比如用来裁剪未引用的字节码的工具,用来在Editor热更新脚本的工具等等。
Mono.Cecil wiki上介绍了现在用到这个库的一些工具。基本上编译、反编译、混淆、AOP相关的工具都有用到。
IL本身是一种抽象层次比较高的语言,用Mono.Cecil可以比较容易地拿到Assembly中定义的全部类型,以及每个类型包含方法的IL集合。
还是之前的代码示例,抠出来一个简单函数:
1private float Modify(float old, float diff)
2{
3 var newVal = old + diff;
4
5 if (newVal < 0f)
6 {
7 newVal = 0f;
8 }
9 return newVal;
10}
用ILSpy看到的IL是这样的:
1.method private hidebysig
2 instance float32 Modify (
3 float32 old,
4 float32 diff
5 ) cil managed
6{
7 // Method begins at RVA 0x2110
8 // Code size 20 (0x14)
9 .maxstack 2
10 .locals init (
11 [0] float32
12 )
13
14 // float newVal = old + diff;
15 IL_0000: ldarg.1
16 IL_0001: ldarg.2
17 IL_0002: add
18 IL_0003: stloc.0
19 // if (newVal < 0f)
20 IL_0004: ldloc.0
21 IL_0005: ldc.r4 0.0
22 IL_000a: bge.un.s IL_0012
23
24 // newVal = 0f;
25 IL_000c: ldc.r4 0.0
26 IL_0011: stloc.0
27
28 // return newVal;
29 IL_0012: ldloc.0
30 // (no C# code)
31 IL_0013: ret
32} // end of method Test::Modify
33
IL2CPP翻译成这样:
1// System.Single ConsoleApplication13.Test::Modify(System.Single,System.Single)
2extern "C" float Test_Modify_m3633460209 (Test_t2103423000 * __this, float ___old0, float ___diff1, const RuntimeMethod* method)
3{
4 float V_0 = 0.0f;
5 {
6 float L_0 = ___old0;
7 float L_1 = ___diff1;
8 V_0 = ((float)((float)L_0+(float)L_1));
9 float L_2 = V_0;
10 if ((!(((float)L_2) < ((float)(0.0f)))))
11 {
12 goto IL_0012;
13 }
14 }
15 {
16 V_0 = (0.0f);
17 }
18
19IL_0012:
20 {
21 float L_3 = V_0;
22 return L_3;
23 }
24}
比较直接。只做了比较简单的块划分,和数据流分析,没做Inlining,也没做控制流分析。
我们在ILSpy中看到的信息,如果不反编译的话,大部分都是借助Mono.Cecil读出来的。比如Assembly依赖的其他Assembly,Assembly里面的命名空间和类型定义,具体到每个类型定义的Method、Field、Property等定义,以及最关键的,每个Method的IL Instruction。
Mono.Cecil拿到的Assembly元信息层次关系图:
然后是BCL反射库拿到的:
除了叫法有区别,其他能拿到的信息都是差不多的。
最大的区别就是Mono.Cecil可以直接拿到带类型的IL Instruction,比较方便。当然,修改,回写的接口就不用说了,BCL反射库是没有的。
ILSpy反编译的流程,就是根据Mono.Cecil,拿到具体类型,拿到类型定义的方法,以及各自的MethodBody。
然后对MethodBody中的IL Instructions做数据流分析,控制流分析,最后转为AST,再输出为C#代码。
这篇就到这里。
下篇小说君重点介绍下ILSpy的数据流分析和控制流分析过程和具体实现细节。
个人订阅号:gamedev101「说给开发游戏的你」,聊聊服务端,聊聊游戏开发。
以上是关于动手写IL到Lua的翻译器——准备的主要内容,如果未能解决你的问题,请参考以下文章