聊聊反编译器ILSpy
Posted 说给开发游戏的你
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了聊聊反编译器ILSpy相关的知识,希望对你有一定的参考价值。
内容接。
进入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_0010
12 }
13
14 Block IL_000b (incoming: 1) {
15 stloc S_1(ldstr "true")
16 br IL_0010
17 }
18
19 Block IL_0010 (incoming: 2) {
20 call WriteLine(ldloc S_1)
21 nop
22 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 }
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_0019
15 br IL_0015
16 }
17
18 Block IL_0015 (incoming: 1) {
19 stloc condition(ldc.i4 0)
20 br IL_0019
21 }
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_0006
10 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_0019
15 br IL_0015
16 }
17 Block IL_0015 (incoming: 1) {
18 stloc condition(ldc.i4 0)
19 br IL_0019
20 }
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_0019
18 }
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「说给开发游戏的你」,聊聊服务端,聊聊游戏开发。
以上是关于聊聊反编译器ILSpy的主要内容,如果未能解决你的问题,请参考以下文章
求c# 通过Debug 文件反编译出其中的代码,能看见编译的代码就可以了,求这样的软件工具???