聊聊.Net反编译器ILSpy

2,005 阅读13分钟

内容接上篇

进入IL到Lua的翻译器正题之前,小说君这篇文章打算先写写ILSpy。

由于内容比较独立,标题就单独起了。


把IL翻译到Lua,比较核心的部分其实是把集合形式的IL Instructions转换为结构化的语法树。

ILSpy虽然看起来用起来都只是一个用来反编译IL Assembly的GUI工具,实际上代码结构非常清晰,拿掉GUI的部分,就是一个完整的、易于定制的IL反编译库。

我们可以在这个库的基础上做任何想做的事情。

小说君在一开始构思IL转Lua的翻译器的时候,首先想到的就是基于ILSpy扩展。其他语言的一些IL翻译器也是这么做的,比如有一个JSIL,就是一个基于ILSpy实现的JS翻译器。


回顾上篇文章,我们看看目前已经做到的:

我们借助Mono.Cecil从IL Assembly中拿到了所有类型定义,每个类型的字段、属性、方法等的定义,以及各元素的元信息(比如名称,类型,Modifier,Attribute等等)。

其中,只有属性和方法的具体实现是体现为一组IL Instructions。除了这部分之外的信息,都不涉及到反编译,只需要“提取”出来。

需要反编译的就是一个具体方法块的IL Instructions。

IL是一种基于操作栈的虚拟机语言。指令间借助栈传递数据。

还是这个add指令的例子,从栈上pop两个值,加完以后把结果push回栈。

举个稍微复杂的例子:

Console.WriteLine(condition ? "true" : "false");

这样一段代码,生成的IL是这样:

 1IL_0001: ldarg.1 2IL_0002: brtrue.s IL_000b 3 4IL_0004: ldstr "false" 5IL_0009: br.s IL_0010 6 7IL_000b: ldstr "true" 8 9IL_0010: call void [mscorlib]System.Console::WriteLine(string)10IL_0016: ret

简单解释下代码中涉及的几条IL指令的含义:

IL 解释
ldarg.1 把第一个参数push到操作栈
brtrue.s IL_000b 有条件跳转,从栈上pop一个值,true/非零/非空的话跳转
ldstr 把一个字符串的引用push到操作栈
br.s IL_0010 无条件跳转
call 方法调用,会从栈上pop所需参数
ret return,并且从callee的栈上pop一个值,push到caller的栈上

很容易理解,而且实现一个stack-based machine也很容易。

我们的目标是反编译,而在一般的高级语言中,没有操作栈这样的概念。

C#和Lua中,我们都用变量和环境来表达数据之间的传输。

不过IL本身既有操作栈,也有局部变量。一组普通的IL Instructions经过变换,就能完全去掉操作栈的概念。

我们可以把一个连续的操作栈看作离散的一个个变量S0,S1,S2等等。

  • 某个指令push,就相当于给Si赋值。

  • 某个指令pop,就相当于从Si取值。

因此,ILSpy首先对IL指令的一步处理就是做了这样的转换:

 1IL_0001: stloc S_0(condition) 2IL_0002: if (ldloc S_0) br IL_000b 3 4IL_0004: stloc S_1(ldstr "false") 5IL_0009: br IL_0010 6 7IL_000b: stloc S_1(ldstr "true") 8 9IL_0010: call WriteLine(ldloc S_1)10IL_0016: ret

其中,stloc指令表示的是从操作栈上pop一个值,赋给某个local变量;ldloc指令表示的是把某个local变量push到操作栈。

IL指令本身是带有类型信息的。

理论上,不管经过任何路径,执行到某一条指令前,栈上的元素数量和各自的类型都是固定的。

这也是ILSpy做数据流分析的基础。

例如:

在IL_0004语句执行前,操作栈一定是空的;执行后,操作栈上一定只有一个string类型的值。

ILSpy在具体实现上,就是过一遍语句,为每条语句维护一个当前栈状态。

根据遇到的指令大体上有三类操作:

  • 遇到push变量到操作栈的指令,比如IL_0001。

    处理逻辑是构造一个stloc和一个IL variable,包一下这条指令。同时把这个IL variable push到代码模拟的一个栈里。

  • 遇到从操作栈pop变量的指令,比如IL_0010。

    处理逻辑是构造一个ldloc,同时从代码模拟的栈里pop一个IL variable,作为这条指令的子指令。

  • 遇到跳转指令。

    首先会做指令自身对栈的操作(比如brtrue会执行一次pop,br不会)。然后更新下当前语句和跳转目标语句的栈状态。例如IL_0009,此时栈状态是一个string,同时会把当前栈状态copy一份,设置到跳转目标IL_0010的栈状态。

这样处理了之后,还要确定pop的栈变量与push的栈变量的对应关系。

三种情况:

1. 最简单的,先stloc,再ldloc。转换完的语句类似于s0 = xx,yy = s0。

2. 然后是一处stloc,根据不同分支多处ldloc。转换完的语句类似于s0 = xx,(分支1)yy1 = s0, (分支2)yy2 = s0。

这两种情况比较简单,ldloc的时候可以从代码中模拟的栈直接拿到同一份实例。

3. 最后一种情况是不同分支中的多处stloc,最后一处ldloc。

就像代码中的IL_0004 / IL_000b / IL_0010。

如果还像前两种一样处理,IL_0004和IL_000b两条指令store的就是不同的IL variable实例,无法确定两条指令实际push的都是S1。

解决方案还是栈分析。

  • IL_0009跳到IL_0010的时候,栈状态是s0。

  • IL_000b跳到IL_0010的时候,栈状态是s1。

  • s0有一个string variable。

  • s1有一个string variable。

  • 在转语句IL_0010之前,做一次栈合并。

ILSpy的合并逻辑,用了并查集实现。

这样,转换完,过一遍所有栈模拟语句中的variable的时候,所有的stloc会查并查集,找root节点。IL_0009和IL_000b两句的stloc就查到了同一个variable。


接下来,ILSpy对IL Instructions以跳转指令为界限,划分了基本的block。block间构成树形结构。

比如之前的例子,中间没有空行相隔的就同属一个block,大概像这样:

 1BlockContainer { 2    Block IL_0000 (incoming: 1) { 3        nop 4        stloc S_0(ldloc condition) 5        if (ldloc S_0) br IL_000b 6        br IL_0004 7    } 8 9    Block IL_0004 (incoming: 1) {10        stloc S_1(ldstr "false")11        br IL_001012    }1314    Block IL_000b (incoming: 1) {15        stloc S_1(ldstr "true")16        br IL_001017    }1819    Block IL_0010 (incoming: 2) {20        call WriteLine(ldloc S_1)21        nop22        leave IL_0000 (nop)23    }24}

一连串相关的IL Instructions组成一个block。

这样就拿到了结构化的IL block,可以看做是IL的AST。

画个树形图:

之后ILSpy会基于这个IL AST做各种变换。

每次变换其实就是构建个visitor,遍历IL AST,操作、修改,再进行下一次变换。

ILSpy在实现中,变换有很多,简单列出几种:

new ControlFlowSimplification(),new SplitVariables(),new ILInlining(),new YieldReturnDecompiler(), new AsyncAwaitDecompiler(),  new DetectCatchWhenConditionBlocks(), new DetectExitPoints(canIntroduceExitForReturn: false),new RemoveDeadVariableInit(),new BlockILTransform { // per-block transforms    PostOrderTransforms = {        new LoopDetection()    }},new BlockILTransform { // per-block transforms    PostOrderTransforms = {        new ConditionDetection(),        new CopyPropagation(),    }},new ProxyCallReplacer(),new DelegateConstruction(),new HighLevelLoopTransform(),new AssignVariableNames(),

命名上都比较自描述,小说君接下来就挑几个感觉比较关键的介绍下。


在之前的流程中,ILSpy不仅加进了一些dummy变量来模拟栈,还加了不少跳转指令来描述结构化的block。

因此,变换的一开始先是简化掉无意义的跳转(ControlFlowSimplification)。做的事情主要有:

  • 删掉nop指令。

  • 删掉入度为零的block。

  • 简化掉a->b->c,但是b只有一条跳转指令的跳转。

之后是inlining优化(ILInlining)。用来干掉不必要的局部变量。

干掉局部变量的流程很直接。

像消除操作栈时引入的变量这样,如果仅声明了一次仅使用了一次,并且唯一的一次使用紧跟着声明的话,就可以直接inline掉。

ILInlining,实现逻辑比较简单,就是遍历整棵树,找stloc,检查下相关IL variable只赋值一次读一次的话,如果下一个节点的ldloc语句关联的variable也是这个,就做一次变换。

举例,IL_0001和IL_0002开始是这样:

stloc S_0(ldloc condition)if (ldloc S_0) br IL_000b

inline完之后是这样:

if (ldloc condition) br IL_000b

然后比较重要的是控制流分析(LoopDetection和ConditionDetection)。

换一下示例代码,加进去条件分支和循环结构。

 1private void Test11(bool condition) 2{ 3    int a = 10; 4 5    while (condition) 6    { 7        a += 10; 8 9        if (a > 100)10            condition = false;11    }12}

我们直接看化简后的IL AST:

 1BlockContainer { 2    Block IL_0000 (incoming: 1) { 3        stloc a(ldc.i4 10) 4        br IL_0019 5    } 6 7    Block IL_0019 (incoming: 3) { 8        if (ldloc condition) br IL_0006 9        leave IL_0000 (nop)10    }1112    Block IL_0006 (incoming: 1) {13        stloc a(binary.add.i4(ldloc a, ldc.i4 10))14        if (logic.not(comp.signed(ldloc a > ldc.i4 100))) br IL_001915        br IL_001516    }1718    Block IL_0015 (incoming: 1) {19        stloc condition(ldc.i4 0)20        br IL_001921    }22}

画个简单的跳转关系图:

控制流分析的核心是两个变换,LoopDetection和ConditionDetection。

这部分代码比较复杂,不过相关理论比较成熟,主要是控制流图Control flow graph相关内容。

这篇文章不会涉及太多控制流图相关的知识,涉及多了小说君也不懂。所以这里就可以简单理解为block之间的跳转关系图。

LoopDetection和ConditionDetection都是BlockTransform。

BlockTransform会先根据各block之间的跳转关系构建Control flow graph,做了这样几件事:

  • 指一下节点间的前后继关系。

  • 标记下entry point和exit point。

  • 计算下每个节点的dominators和immediate dominator。

备注一下:

从entry point到节点n的所有路径如果都要经过节点d,那d就是n的dominator(简记dom)。

节点n的dominator中,如果某个节点d不是n的其他dominator的dominator,那d就是n的immediate dominator(简记idom)。

比如前面的跳转图中,Block IL_0000是其他block的dom。Block IL_0019的idom是Block IL_0000。

这两个概念可以用来找循环结构的入口block。

接下来BlockTransform会对各block做后根遍历,这样可以处理嵌套的循环结构,先找出子节点的循环或条件分支结构,再找父节点的。

然后就是针对每个block的具体Transform。篇幅所限,这里就只简单介绍下LoopDetection的流程。

由于在做具体的LoopDetection前,已经构建好了节点间的dominator关系,所以只需要遍历block,查到如果某个block节点dominate某个先继,这个block节点就是loop head。然后根据先继关系,找出这整个loop涉及的节点。

最后根据这些节点构建出来对应的结构。

变换完之后,代码结构就变成了这样,构建了一个注记为while的BlockContainer,子节点是之前平铺的几个block。

 1BlockContainer { 2    Block IL_0000 (incoming: 1) { 3        stloc a(ldc.i4 10) 4        br IL_0019 5    } 6    Block IL_0019 (incoming: 1) { 7        BlockContainer (while-true) { 8            Block IL_0019 (incoming: 3) { 9                if (ldloc condition) br IL_000610                leave IL_0000 (nop)11            }12            Block IL_0006 (incoming: 1) {13                stloc a(binary.add.i4(ldloc a, ldc.i4 10))14                if (logic.not(comp.signed(ldloc a > ldc.i4 100))) br IL_001915                br IL_001516            }17            Block IL_0015 (incoming: 1) {18                stloc condition(ldc.i4 0)19                br IL_001920            }21        }22    }23}

对应的跳转关系图:

全部变换做完之后,结构变成了这样:

 1ILFunction Test11 { 2    local a : System.Int32(Index=0, LoadCount=2, AddressCount=0, StoreCount=2) 3    param condition : System.Boolean(Index=0, LoadCount=1, AddressCount=0, StoreCount=2) 4 5    BlockContainer { 6        Block IL_0000 (incoming: 1) { 7            stloc a(ldc.i4 10) 8            BlockContainer (while) { 9                Block IL_0019 (incoming: 2) {10                    if (ldloc condition) br IL_0006 else leave IL_0019 (nop)11                }12                Block IL_0006 (incoming: 1) {13                    stloc a(binary.add.i4(ldloc a, ldc.i4 10))14                    if (comp.signed(ldloc a > ldc.i4 100)) Block IL_0015 {15                        stloc condition(ldc.i4 0)16                    }17                    br IL_001918                }19            }20            leave IL_0000 (nop)21        }22    }23}

表达形式已经比较高级。


ILSpy处理得到这样一颗AST之后,对各节点逐个直译,就转换为了基本C#表达的语法树。

得到基本语法树之后,ILSpy还会做各种基于C# AST的Transform,表达更高阶的C#语法节点。

不过这部分不是重点,因为C#的高阶语法,有很多是Lua表达不了的。我们做IL到Lua的翻译工具时也不需要。

后面的实现中,就是选择性的打开一些Transform,根据最后拿到的语法树,再做次处理,就得到了Lua的语法树。

当然,只是理论上是这样,实际实现起来就直接在访问C#语法树的时候做代码生成了。

下篇文章就重点讲讲导出Lua代码的一些具体case,以及相应的运行模型。


个人订阅号:gamedev101「说给开发游戏的你」,聊聊服务端,聊聊游戏开发。