zoukankan      html  css  js  c++  java
  • 动手写IL到Lua的翻译器——准备

    文章里的代码粘过来的时候格式有点问题,原因是一开始文章是在订阅号上写的(gamedev101,文末有二维码),不知道为啥贴过来就没了格式,还要手动删行号,就没搞了。


    介绍下问题背景:

    小说君正在参与的项目,服务端逻辑以C#为主。

    之前的一篇文章,《公式计算机》也有提到,这个项目的服务端需要提供让策划写游戏业务的能力。

    不过跟文章里的方案不同,最后策划用来写业务的语言是C#。

    实践下来,策划写的业务分为两大类:

    1. 战斗相关的流程性质的逻辑。例如技能结算的流程性逻辑。

    2. 各模块中经常变动的运算逻辑。例如面板属性的运算逻辑。

    如图,简单直接,就是程序写的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的构造函数。

    两个特点:

    1. 只翻译一个类型。

    2. 由于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「说给开发游戏的你」,聊聊服务端,聊聊游戏开发。

  • 相关阅读:
    反转链表 16
    CodeForces 701A Cards
    hdu 1087 Super Jumping! Jumping! Jumping!(动态规划)
    hdu 1241 Oil Deposits(水一发,自我的DFS)
    CodeForces 703B(容斥定理)
    poj 1067 取石子游戏(威佐夫博奕(Wythoff Game))
    ACM 马拦过河卒(动态规划)
    hdu 1005 Number Sequence
    51nod 1170 1770 数数字(数学技巧)
    hdu 2160 母猪的故事(睡前随机水一发)(斐波那契数列)
  • 原文地址:https://www.cnblogs.com/fingerpass/p/IL_To_Lua.html
Copyright © 2011-2022 走看看