动手写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「说给开发游戏的你」,聊聊服务端,聊聊游戏开发。

 

posted @ 2018-04-03 12:23 fingerpass 阅读(3656) 评论(3) 推荐(6) 编辑

21分钟学会写编译器

摘要: 知乎上有一种说法是「编译器、图形学、操作系统是程序员的三大浪漫」。   先不管这个说法是对是错,我们假设一个程序员在国内互联网公司写代码,业余时间不看相关书籍。那么三年之后,他的这些知识会比在校时损耗多少? 很显然,损耗的比例肯定非常高,毕竟国内互联网公司日常开发工作中,程序员基本很少接触这三块知识。大部分程序员工作几年后对编译原理相关的概念只能生理上起反应,脑海里很难再串联起相关概念了。 阅读全文
posted @ 2016-09-04 10:36 fingerpass 阅读(14809) 评论(3) 推荐(7) 编辑

跟Unity3D学代码优化

摘要: 今天我们来聊聊如何跟Unity学代码优化,准确地说,是通过学习Unity的IL2CPP技术的优化策略,应用到我们的日常逻辑开发中。 阅读全文
posted @ 2016-09-02 10:26 fingerpass 阅读(2717) 评论(3) 推荐(0) 编辑

游戏服务端究竟解决了什么问题?

摘要: 既然是游戏服务端程序员,那博客里至少还是得有一篇跟游戏服务端有关的文章,今天文章主题就关于游戏服务端。 阅读全文
posted @ 2016-02-01 16:55 fingerpass 阅读(42820) 评论(87) 推荐(79) 编辑

漫谈游戏中的人工智能

摘要: 今天我们来谈一下游戏中的人工智能。当然,内容可能不仅仅限于游戏人工智能,还会扩展一些其他的话题。 阅读全文
posted @ 2015-09-05 23:12 fingerpass 阅读(14521) 评论(7) 推荐(6) 编辑

游戏中的定时器

摘要: 游戏中处处都有定时器,基本上每个逻辑部分我们都能看到定时器的影子。如果翻看一下以前网上流传的一些MMO的代码,比如mangos的,比如大唐的,比如天龙的,我们都可以看到形形色色的定时器实现。 在以前,很多程序员用起来C++还都是在用C with Object,以前的C++写callback也好异步也好总是感觉哪里不对劲的样子,所以网上流传的那种线上服务器的代码,一般都是往主循环里硬塞定时器逻辑。 定时器在很多能参考到的代码里都是逻辑和底层不做区分的,这样就会导致一些问题。 一方面,底层的需求是通用性。要通用性的话就必须得在主循环中轮询timeout,而不是借助一些更高层级的抽象; 另一方面,上层的需求是易用性。要易用性的话就必须得用起来方便,而且最好是能原生嵌入在一些常规的异步编程模型中的。最不济的,需要我很方便的挂callback。再高级点,我需要能yield。最上层的,能让我在描述一次lasy evaluation的计算中描述WaitForTime语义,做future什么的当然更好了。 阅读全文
posted @ 2015-05-10 19:18 fingerpass 阅读(6284) 评论(0) 推荐(5) 编辑

面向组合子设计Coder

摘要: 面向组合子(Combanitor-Oriented),是最近帮我打开新世界大门的一种pattern。缘起haskell,又见monad与ParseC,终于ajoo前辈的几篇文章。 自去年9月起正式回归C#以来,我又逐渐接受了不少新的paradigm(虽然主要原因还是在学校用C#的方法太山寨),其中对我影响比较深刻的就是codegen。此codegen非compiler中的codegen,可能更像是meta-programming中的codegen。抽象来说,就是作为一个嵌入于构建流程中的某一步骤,拿到一些元描述信息,来生成代码。 阅读全文
posted @ 2015-03-23 15:51 fingerpass 阅读(2849) 评论(2) 推荐(2) 编辑

用半年的时间面试自己

摘要: 把之前发在is-programmer的面经文章也贴过来了,是我从13年大三下学期的腾讯实习生面试起,到大四上学期参加2014届校招面阿里、人人、网易游戏雷火的面试经历梳理。 标题源自当时在企鹅实习的时候偶然看到的一篇刘未鹏大大的让人醍醐灌顶的文章,之所以换成了半年,一是因为距离我开始决定第一份工作的应聘方向到最后面完网易游戏,恰好半年多一两个月的时间;二是我一直都没有认真经营过一个github主页,之前弄过一个也只是小打小闹。 距写这篇面经已经过去一年有余,我也已经入职雷火半年了,不少当时的观点看来十分幼稚,下面贴上正文: 阅读全文
posted @ 2015-01-04 11:15 fingerpass 阅读(8157) 评论(11) 推荐(16) 编辑

浅谈消息队列

摘要: 1.写在前面 本来一年前的时候还打算以那篇面经为契机,开始自己写博客的习惯,结果后来一拖再拖,虽然evernote里面积攒了不少东西,但是发现想整理成博客真的是太累了,毕设的时候觉得累没整理,刚到公司做mini项目觉得累没整理,后来刚进工作室熟悉环境觉得累没整理,不知不觉就一年没写博客了,囧。 为什么想起来写这样一篇文章呢?其实主要还是两周前有一个知乎问题突然火起来了,传说中的水货程序员之问。这篇知乎问题真是一个大宝库,刚一出来,海量平时在知乎上各种高调的大神(棍)和海量低调的真大神都出来冒泡,让我们这些弱菜开了眼界。水货程序员之问是个好问题,问题不是说好在水货程序员这篇文章到底黑的对不对准不准,而是印发的讨论像钓鱼一样钓出了一水儿神棍。 那怎么区别神棍和大神呢?方法很简单,我们一个一个浏览答案,从高票到低票,哪个答案或者跟着的评论里屁代码没有,还说的煞有介事的,一般都是神棍;哪个答案全程干货,各种分析,那就是大神。 举个比较简单的例子,某高票答案「妄议」了下云风,说后者解决问题的路子太野大家学不来,并且推荐了一票路子「不野」的大 阅读全文
posted @ 2015-01-01 00:19 fingerpass 阅读(6185) 评论(5) 推荐(4) 编辑

ruby中的class_eval

摘要: 最近几天在做一个ruby作业的时候遇到了一些问题,题目是这样要求的 阅读全文
posted @ 2012-03-05 17:19 fingerpass 阅读(806) 评论(0) 推荐(1) 编辑
< 2025年2月 >
26 27 28 29 30 31 1
2 3 4 5 6 7 8
9 10 11 12 13 14 15
16 17 18 19 20 21 22
23 24 25 26 27 28 1
2 3 4 5 6 7 8

统计

点击右上角即可分享
微信分享提示