反编译器(或者解码器),简而言之,就是将目标程序码反转成源代码。但是其中的过程却比较复杂,也很有意思——Java源码是结构化的,字节码却不是。而且,转换不是一一对应的:两段完全不同的Java程序也可能生成完全相同的字节码,有时需要一些试探才能更加接近源码。

(一段简短的)字节码教程

为了更好的理解反编译器如何工作,现在有必要理解一下字节码基础。如果你对此非常熟悉,可以略过此处直接跳到下一部分。

(不同于基于寄存器 register-based 的方式)JVM运行基于栈。这就意味着指令会在 evaluation stack(计算堆栈)上执行。操作对象可能先出栈,进行一些操作,然后再把结果入栈来进行接下来的操作。考虑如下场景:




1


2


3


4




​public​​​​static​​ ​​int​​ ​​plus(​​​​int​​​​a, ​​​​int​​​​b) {​


​int​​​​c = a + b;​


​return​​​​c;​


​}​



注:本文所有的相关的字节码都是由 ​​javap​​ 产生,例如执行命令 ​​javap -c -p MyClass​ ​。




1


2


3


4


5


6


7


8


9




​public​​​​static​​ ​​int​​ ​​plus(​​​​int​​​​,​​​​int​​​​);​


​Code:​


​stack=​​​​2​​​​, locals=​​​​3​​​​, arguments=​​​​2​


​0​​​​: iload_0    ​​​​// load ‘x’ from slot 0, push onto stack​


​1​​​​: iload_1    ​​​​// load ‘y’ from slot 1, push onto stack​


​2​​​​: iadd       ​​​​// pop 2 integers, add them together, and push the result​


​3​​​​: istore_2   ​​​​// pop the result, store as ‘sum’ in slot 2​


​4​​​​: iload_2    ​​​​// load ‘sum’ from slot 2, push onto stack​


​5​​​​: ireturn    ​​​​// return the integer at the top of the stack​



方法中的本地变量(包括方法声明)被寄存在所谓的JVM本地变量数组中。为了简单起见,在这里我们将一个存放在本地变量数组位置 ​​#x​​ 处的变量称为 ​​slot#x​​ (参见​​JVM规范3.6.1​​)。

对于示例方法,​​slot#0​​ 的值一般是 ​​this​​ 指针。然后从左到右依次是方法中的各个变量,接下来是方法中声明的本地变量。在上面的示例中,由于方法是静态的,所以没有 ​​this​​ 指针。相应的 ​​slot#0​​ 存放的是参数 ​​x​​,​​slot#y​​ 存放的是参数 ​​y​​,本地变量 ​​sum​​ 存放在 ​​slot#2​​ 中。

有意思的是,每个方法的栈大小和本地变量存储空间都有最大值的限制。二者都是在编译时决定。

目前为止,所有内容都是非常直白的,仅有一点没有达到你的预期:编译器一直没有尝试去优化这些代码。事实上,​​javac​​ 几乎从未支持字节码优化。这样有很多好处,比如几乎可以在任何地方设置断点:一旦移除 load/store 操作,就会失去这种特性。所以,大部分压力都转移到了运行时JIT编译器(just-in-time compiler)。

反编译

那么,怎样才能将一个非结构化、基于栈的字节码转换为结构化的Java代码呢?通常,第一步要先摈弃操作对象栈。可以通过映射栈的值成变量,并插入合适的 ​​load/store​​ 操作来实现这个步骤。

如果一个“栈变量”仅仅分配并使用一次,你会发现这将产生非常多的重复变量——而且接下来会生成的重复变量会更多!反编译器会将这些字节码缩减成更简单的指令集。这里对此不作深究。

我们使用 ​​s0​​ 代表栈变量, ​​v0​​ 代表原始的字节码在本地的真实引用(存在 slot 上)。

 

字节码

栈变量

复制传播

0

1

2

3

4

5

iload_0

iload_1

iadd

istore_2

iload_2

ireturn

s0 = v0

s1 = v1

s2 = s0 + s1

v2 = s2

s3 = v2

return s3

v2 = v0 + v1


return v2


通过为 ​​push​​ 或 ​​pop​​ 的每个值分配一个标识符,可以将字节码转换为本地变量。比如 ​​iadd​​ 是将两个操作数出栈并、相加,并将结果入栈。

然后,使用一种复制传播(copy propagation)的技术,可以消除一些重复变量。复制传播是内联的一种形式,可以将变量简单替换为指定值,前提是这种转换是有效的。

如何定义”有效性“?这里包含了一些重要准则。考虑下面这种情况:




1


2


3




​0​​​​: s0 = v1​


​1​​​​: v1 = s4​


​2​​​​: v2 = s0 <-- s0 cannot be replaced with v1​



在这里,如果将 ​​s0​​ 替换为 ​​v1​​ 结果将大不相同。因为 ​​v1​​ 的值在 ​​s0​​ 被指定之后改变了,虽然此时 ​​v1​​ 的值却还没有被使用(译注:原文这里是​​V0​​,根据注释可以确认为笔误)。为了避开这种复杂的情形,这里复制传播只考虑仅被赋值一次的内联变量(inline variable)。

译注:一个简单的(C语言)内联变量手动解析示例,​​ ​




1


2


3


4


5


6




​int​​​​pred(​​​​int​​​​x) {​


​if​​​​(x == 0)​


​return​​​​0;​


​else​


​return​​​​x - 1;​


​}​



进行 inline 操作前:




1


2


3




​int​​​​f(​​​​int​​​​y) {​


​return​​​​pred(y) + pred(0) + pred(y+1);​


​}​



进行 inline 操作以后:




1


2


3


4


5


6


7




​int​​​​f(​​​​int​​​​y) {​


​int​​​​temp;​


​if​​​​(y   == 0) temp  = 0; ​​​​else​​​​temp  = y       - 1; ​​​​/* (1) */​


​if​​​​(0   == 0) temp += 0; ​​​​else​​​​temp += 0       - 1; ​​​​/* (2) */​


​if​​​​(y+1 == 0) temp += 0; ​​​​else​​​​temp += (y + 1) - 1; ​​​​/* (3) */​


​return​​​​temp;​


​}​



一种改进的方案——跟踪所有非栈变量的存储空间。比如,我们知道 ​​v1​​ 在 ​​#0​​ 赋值给 ​​v1​​0,同时在 ​​#2​​ 被赋值给 ​​v1​​1。当对 ​​v1​​ 赋值超过一次,则不能进行复制传播。

不过我们最初的那个例子没有这么复杂,因而我们得到如下的优美精确的结果:




1


2




​v2 = v0 + v1​


​return​​​​v2​



画外音:存储变量名

如果变量在字节码中被简化为 slot 的引用,那么接下来怎样才能知道原来对象的名称呢?很有可能无法知道。为了改变这情况,改进调试的用户体验,每个方法的字节码都包含有一个特殊的部分——本地变量表。这个表中记录了原代码中每个变量的名称、slot 编号和变量名对应的字节码。通过 ​​javap​​ 的 ​​-v​​ 选项可以把本地变量表(以及其他有用的元数据)包含到反汇编代码。对于上面示例中的 ​​plus()​​ 方法,它的本地变量表看起来像下面这样:




1


2


3


4




​Start  Length  Slot  Name   Signature​


​0      6       0     a      I​


​0      6       1     b      I​


​4      2       2     c      I​



可以看到 ​​v2​​ 是 ​​int​​ 类型的变量,原来变量名为 ​​c​​,偏移位于字节码 ​​#4-5​​。

如果编译的类没有包含本地变量表(也可能被混淆器删掉),必须自己生成变量名。处理这种情况有很多办法:聪明的方法会根据变量的使用情况定义合适的名字。

栈分析

前面的示例中,在任何时刻都可以确保栈顶的变量,因此可以依次命名为 ​​s0​​、​​s1​​等。

目前为止,在处理变量的时候都是比较直接的,因为我们仅仅采用一种代码路径来探索方法。在真实的应用环境里,多数的方法都不是那么”善解人意“。每当为方法增加一个循环或者判断,就会增加了很多可能的调用情况。让我们来看一下改进版的示例:




1


2


3


4




​public​​​​static​​ ​​int​​ ​​plus(​​​​boolean​​​​t, ​​​​int​​​​a, ​​​​int​​​​b) {​


​int​​​​c = t ? a : b;​


​return​​​​c;​


​}​



现在情况更加复杂,如果按照之前的分配方式操作,将会遇到很大的问题。

 

字节码

栈变量

0

1

4

5

8

9

10

11

iload_0

ifeq 8

iload_1

goto 9

iload_2

istore_3

iload_3

ireturn

s0 = v0

if (s0 == 0) goto #8

s1 = v1

goto #9

s2 = v2

v3 = {s1,s2}

s4 = v3

return s4

我们需要对如何用栈标识符赋值更加谨慎。由于可能有多个路径能够到达,因此仅考虑每个指令自身是不够的,需要对给定的位置查看整个栈的情况。

在我们检查 ​​#9​​ 的时候,看到 ​​istore_3​​ 出栈了一个值。但是这个值可能有两个来源,可能来自于 ​​#5​​ 或者 ​​#8​​。栈顶 ​​#9​​ 的值可能是 ​​s1​​ 也可能是 ​​s2​​,这取决于它是来自于 ​​#5​​ 还是 ​​#8​​。因此,我们认为这可能是同一个变量——因此我们将其合并,所有引用 ​​s1​​ 或者 ​​s2​​ 的地方都指向这个无歧义的变量 ​​s{1,2}​​。”重新标记“(relabeling)后,可以安全地进行复制传播。

 

重新标记后

复制传播后

0

1

4

5

8

9

10

11

s0 = v0

if (s0 == 0) goto #8

s{1,2} = v1

goto #9

s{1,2} = :v2

v3 = s{1,2}

s4 = v3

return s4


if (v0 == 0) goto #8

s{1,2} = v1

goto #9

s{1,2} = v2

v3 = s{1,2}return v3

值得注意的是:在 ​​#1​​ 处的条件分支:如果 ​​s0​​ 的值是0,就跳到 ​​else​​ 块;否则,继续当前的路径。有趣的是,与原始代码相比,这里测试条件是取反的。

接下来将我们进行更深入的研究……


在​​上一篇文章​​中,我们介绍了翻译器的功能、简单的字节码知识回顾、反编译和栈分析。本文将继续讨论反编译器中对条件表达式、变量类型分析、短路运算符和方法调用在反编译器中的处理。

条件表达式

在这里可以决定我们的代码是否使用了三元运算符(​​?:​​):有一个判断条件,条件的每个分支都对同一个栈变量 ​​s{1,2}​​ 进行一次赋值,赋值后两条路径会进行合并。

一旦确定了这个模式,就可直接使用三元表达式。

 

复制传播后

合并三元表达式

0

1

4

5

8

9

10

11



if (v0 == 0) goto #8

s{1,2} = v1

goto 9

s{1,2} = v2

v3 = s{1,2}return v3



v3 = v0 != 0 ? v1 : v2


return v3

值得注意的是,作为转换的一部分,我们对 ​​#9​​ 处的条件进行了取反。可以看出 ​​javac​​ 生成的代码对判断条件取反这一行为是有规律的。因此,如果将转换后的条件取反,就可以更加接近原来的代码。

画外音:类型是什么?

当处理栈值时,JVM使用了一个比 ​​Java​​ 代码更为简单的类型系统。特别是 ​​boolean​​、​​char​​ 和​​short​​ 的值都被作为 ​​int​​ 值使用同一指令处理。因此, ​​v0! = 0​ ​ 可以翻译成:




1




​v0 != ​​​​false​​​​? v1 : v2​



或者




1




​v0 != ​​​​0​​​​? v1 : v2​



甚至还可以翻译为




1




​v0 != ​​​​false​​​​? v1 == ​​​​true​​​​: v2 == ​​​​true​



……还有很多其它的翻译结果!

在这个例子中,我们很幸运地知道 ​​v0​​ 的精确类型,这个类型包含在​​方法描述​​中:




1


2




​descriptor: (ZII)I​


​flags: ACC_PUBLIC, ACC_STATIC​



方法签名由此可以知形式如下:




1




​public​​​​static​​ ​​int​​ ​​plus(​​​​boolean​​​​,​​​​int​​​​,​​​​int​​​​)​



通过签名还可以知道,​​v3​​ 是 ​​int​​ 型(而不是 ​​boolean​​ 型)。因为它是返回值,通过描述符已经知道了返回值类型。接下来,还需要翻译:




1


2




​v3 =  v0 ? v1 : v2​


​return​​​​v3​



另外,如果 ​​v0​​ 是一个本地变量(不是形参),可能无法知道其类型是 ​​boolean​​ 而不是 ​​int​​。还记得我们之前提到的本地变量表,就是包含了原始本地变量名的那个表吗?除了变量名,它还记录了有变量的类型。因此,如果编译时带有debug信息,就可以从本地变量表中知道变量的类型。此外,还有一张 ​​LocalVariableTypeTable​​ 表,此表也包含类似的信息。两者的主要区别在于 ​​LocalVariableTypeTable​​ 包含了泛型信息。然而,由于​​LocalVariableTypeTable​​ 中的信息是未经验证的元数据,因此不能完全依赖这些数据。一些非常规的混淆器(obfuscator)会在这些表中填入假信息,但是修改后的字节码却依然可以执行!所以请自行决定如何使用这些表。

短路运算符(​​‘&&’​​ 和 ​​’||’​​)




1


2


3




​public​​​​static​​ ​​boolean​​ ​​fn(​​​​boolean​​​​a, ​​​​boolean​​​​b, ​​​​boolean​​​​c){​


​return​​​​a || b && c;​


​}​



怎么能更简单呢?不幸的是,关于字节码的理解总是有一点痛苦……

 

字节码

栈变量

复制传播后

0

1

4

5

8

9

12

13

16

17

iload_0

ifne #12

iload_1

ifeq #16

iload_2

ifeq #16

iconst_1

goto #17

iconst_0

ireturn

s0 = v0

if (s0 != 0) goto #12

s1 = v1

if (s1 == 0) goto #16

s2 = v2

if (s2 == 0) goto #16

s3 = 1

goto 17

s4 = 0

return s{3,4}


if (v0 != 0) goto #12


if (v1 == 0) goto #16


if (v2 == 0) goto #16

s{3,4} = 1

goto 17

s{3,4} = 0

return s{3,4}



根据选择的路径不同,位于 ​​#17​​ 位置的 ​​ireturn​​ 指令可能返回 ​​s3​​ 或者 ​​s4​​。我们为其分别命名,然后使用复制传播来消除 ​​s0​​、​​s1​​ 和 ​​s2​​。

接下来,在 ​​#1​​、​​#5​​ 和 ​​#7​​ 位置有三个连续的条件。如之前提到的那样,条件分支要么跳转,要么接着执行下一条指令。

上面的字节码包含了一组遵循特定的使用模式,这些模式非常实用:

条件与(&&)

条件或(||)

T1: 

if (c1) goto L1

if (c2) goto L2

L1:

变成了



if (!c1 && c2) goto L2

L1:

T1:

if (c1) goto L2

if (c2) goto L2

L1:

变成了



if (c1 || c2) goto L2

L1:

如果考虑上面表中的临近条件组,​​#1​​ … ​​#5​​ 不遵循上面任何一种模式,但 ​​#5​​ … ​​#9​​ 却是一个条件或(||),因此可以进行如下转换:




1


2


3


4


5


6




​1​​​​: ​​​​if​​​​(v0 != ​​​​0​​​​)​​​​goto​​​​#​​​​12​


​5​​​​: ​​​​if​​​​(v1 == ​​​​0​​​​|| v2 == ​​​​0​​​​)​​​​goto​​​​#​​​​16​


​12​​​​:  s{​​​​3​​​​,​​​​4​​​​} = ​​​​1​


​13​​​​: ​​​​goto​​​​#​​​​17​


​16​​​​:  s{​​​​3​​​​,​​​​4​​​​} = ​​​​0​


​17​​​​: ​​​​return​​​​s{​​​​3​​​​,​​​​4​​​​}​



注意:每次转换都可能引入新的转换。这种情况下,可以应用 ​​||​​ 对条件进行重组。现在可以对 ​​#1...#5​​ 应用 ​​&&​​ 模式!通过将这些代码合并为单个条件分支可以进一步简化方法:




1


2


3


4


5




​1​​​​: ​​​​if​​​​(v0 == ​​​​0​​​​&& (v1 == ​​​​0​​​​|| v2 == ​​​​0​​​​))​​​​goto​​​​#​​​​16​


​12​​​​:  s{​​​​3​​​​,​​​​4​​​​} = ​​​​1​


​13​​​​: ​​​​goto​​​​#​​​​17​


​16​​​​:  s{​​​​3​​​​,​​​​4​​​​} = ​​​​0​


​17​​​​: ​​​​return​​​​s{​​​​3​​​​,​​​​4​​​​}​



这是不是看起来和其他地方很类似?是的,现在这个字节码就符合之前的三元操作符(​​? :​​)规则了。我们可以将 ​​#1...#16​​ 缩减为一个独立的表达式,再使用复制传播将 ​​s{3,4}​​ 内联到为 ​​#17​​ 的 ​​return​​ 语句。




1




​return​​​​(v0 == ​​​​0​​​​&& (v1 == ​​​​0​​​​|| v2 == ​​​​0​​​​)) ? ​​​​0​​​​: ​​​​1​​​​;​



利用方法描述符和本地变量类型表可以推断变量类型,这样缩减后的表达式如下:




1




​return​​​​(v0 == ​​​​false​​​​&& (v1 == ​​​​false​​​​|| v2 == ​​​​false​​​​)) ? ​​​​false​​​​: ​​​​true​​​​;​



好吧,现在的结果比反编译的内容更加精炼了,但是仍然不够美观。让我们看看可以做点什么。首先,折叠比较运算符,比如把 ​​x==true​​ 和 ​​x==false​​ 简写为 ​​x​​ 和 ​​!x​​。还可以消除三元操作符,比如把 ​​x ? false:true​ ​ 简写为 ​​!x​​。




1




​return​​​​!(!v0 && (!v1 || !v2));​



如果你还记得你高中的离散数学,那么根据德摩根定理,更进一步可以缩写为:




1


2




​!(a || b) --> (!a) && (!b)​


​!(a && b) --> (!a) || (!b)​



因此,




1




​return​​​​! ( !v0 && ( !v1 || !v2 ) )​



可以变为,




1




​return​​​​! ( !v0 && ( !v1 || !v2 ) )​



接着变成,




1




​return​​​​( v0 || !(!v1 || !v2 ) )​



……最终会变成:




1




​return​​​​( v0 || (v1 && v2) )​



万岁!

处理方法调用

我们已经了解调用方法的流程:先将参数“存入”本地数组;要进行方法调用,必须将参数推到栈上,并且紧跟一个指向实例方法的 ​​this​​ 指针。方法调用的字节码正如你预想的那样:




1


2


3




​push arg_0​


​push arg_1 ​


​invokevirtual METHODREF​



在上面的代码中可以看到 ​​invokevirtual​​,该指令可以用来调用大多数的实例方法。JVM有一组方法调用的指令,每个指令都有特定的功能:

  1. invokeinterface
    :调用接口方法。
  2. invokevirtual
    :调用使用 virtual
     语义的实例方法,比如调用的方法在运行时根据重载分派到不同的实例方法。
  3. invokespecial
    :调用一个具体的实例方法(非 virtual
     语义)。该指令常用来调用构造器(constructor),但也可以调用类似 super.method()
     这样的方法。
  4. invokestatic
    :调用静态方法。
  5. invokedynamic
    :使用“引导方法”(bootstrap)启动自定义调用点,该命令(在Java中)很少使用。引入该命令是为了支持动态语言,在Java8中被用来实现lambda表达式。

反编译器有一个重要细节,class的常量池中包含了所有方法调用的信息,包括参数的数量、类型和返回值类型。调用的类会记录这些信息,运行时会确保该方法在调用时已存在,并对方法签名进行检查。如果调用的是第三方代码的函数,并且函数的签名发生了改变,任何试图对旧版本的调用都会抛出错误(而不是产生不可预知的行为)。

回到上面的例子,从 ​​invokevirtual​​ 操作码可以得知目标方法是一种实例方法。因此,需要将 ​​this​​ 指针作为隐含的第一参数。常量池中的 METHODREF 告诉我们该这个方法有一个形参,所以除了实例方法的指针还需要从栈上弹出一个参数。接下来代码可以重写为:




1




​arg_0.METHODREF(arg_1)​



当然,不是所有的字节码看起来都如此“友好”。栈中的参数并不要求一个接一个排列整齐。假如参数中有一个三元表达式,那么中间就会有加载、存储和分支指令,这些都需要单独转换。混淆器可能会将方法重写成为一种特别复杂的指令序列。优秀的反编译器需要足够灵活,才能处理很多有趣的边界情形。这些已经超出了本文的讨论内容。

下一篇我们会继续探讨反编译器的更多细节和流程控制。

更多细节

目前为止,我们的分析仅限于一个单独的代码序列——以一个简单指令列表开始,经过一系列转换产生更高级别的指令。如果你认为这些都太过简化,你的看法是对的。因为Java是一种高度结构化的编程语言,包含的概念比如范围(scope)、块(block),以及更加复杂的控制流。为了处理一些更加复杂的指令,比如 ​​if/else​​ 块和循环(loop),我们需要对代码进行更加深入的分析,关注各种可能被选取的代码路径。这就是所谓的控制流分析。

我们首先将代码分解成连续的块,确保这些代码块会从头至尾依次执行。这些分解后的代码称作基本块(basic block)。通过在指令跳转的地方将指令列表进行分割,由此划分这些基本块。指令跳转可以是跳转到别的块,也可以是跳转到块本身。

通过在块之间连上边,就可以得到一个代表所有可能分支的​​控制流图​​(CFG,control flow graph)。应该注意的是,这些边界可能并不十分明确,如果块中包含的指令抛出异常,那么控制流就会转到对应的异常处理程序。虽然我们不会在这里详细讨论如何构建CFG,但是为了帮助理解如何利用这些图解析类似循环这种代码结构,需要理解一些比较高层的概念。

控制流图实例

我们对控制流图最感兴趣的角度是支配关系(domination relationship):

  • 若所有通向节点N的路径都经过D,那么称节点D支配了节点N。所有节点都支配自身;如果D和N是不同的节点,那么D被称为严格支配了节点N。
  • 如果D严格支配了N,但严格支配节点N的其它节点不受D的严格支配,那么D可以称作直接支配N。
  • 支配树(dominator tree)上的节点有这样的特性,所有子节点都是受该树节点直接支配。
  • D的支配边界(dominance frontier)是一组类型N的节点集合。D直接支配类型N的前一节点,但不是完全支配N。换言之,到该集合为止节点D的支配关系结束。

译注:关于此处的概念,可以参考​​Wikipedia: Dominator (graph theory)​ ​。

基本的循环和控制流

考虑如下Java方法:




1


2


3


4


5




​public​​​​static​​ ​​void​​ ​​fn(​​​​int​​​​n) {​


​for​​​​(​​​​int​​​​i = ​​​​0​​​​; i < n; ++i) {​


​System.out.println(i);​


​}​


​}​



反汇编结果如下:




1


2


3


4


5


6


7


8


9


10


11




​0​​​​:  iconst_0​


​1​​​​:  istore_1​


​2​​​​:  iload_1​


​3​​​​:  iload_0​


​4​​​​:  if_icmpge ​​​​20​


​7​​​​:  getstatic #​​​​2​​     ​​// System.out:PrintStream​


​10​​​​:  iload_1​


​11​​​​:  invokevirtual #​​​​3​​ ​​// PrintStream.println:(I)V​


​14​​​​:  iinc ​​​​1​​​​,​​​​1​


​17​​​​: ​​​​goto​​​​2​


​20​​​​: ​​​​return​



接下来,我们应用先前讨论的内容将其转为更加可读的形式。首先引入栈变量,然后执行复制传播。

 

字节码

栈变量

复制传播后

 0

1

2

3

4

7

10

11

14

17

20

iconst_0

istore_1

iload_1

iload_0

if_icmpge 20

getstatic #2

iload_1

invokevirtual #3

iinc 1, 1

goto 2

return

s0 = 0

v1 = s0

s2 = v1

s3 = v0

if (s2 >= s3) goto 20

s4 = System.out

s5 = v1

s4.println(s5)

v1 = v1 + 1

goto 2

return


v1 = 0 


if (v1 >= v0) goto 20


System.out.println(v1)



v1 = v1 + 1

goto 4

return

我们注意到 ​​#4​​ 的条件分支和 ​​#17​​ 的 ​​goto​​ 创建了一个逻辑循环。从控制流图上可以更容易发现这个循环:

在上图中,从 ​​goto​​ 语句跳转回条件判断形成了一个循环。在这个例子中,条件分支作为循环入口(loop header),可定义为循环边的支配者。循环入口支配了循环体内所有节点。

通过寻找形成循环的边,我们可以确定一个条件分支是不是循环入口。但是要如何才能做到这一点?一个简单的办法是,判断测试条件是否在其自身的控制边界内。一旦确定了循环入口,我们需要找出哪些节点应当放在循环体内。通过找出入口支配的所有节点可以达到这个目的。算法的伪代码如下:




1


2


3


4


5


6


7


8


9


10


11


12


13


14


15


16




​findDominatedNodes(header)​


​q := ​​​​new​​​​Queue()​


​r := ​​​​new​​​​Set()​


 


​q.enqueue(header)​


 


​while​​​​(not q.empty())​


​n := q.dequeue()​


 


​if​​​​(header.dominates(n))​


​r.add(n)​


 


​for​​​​(s in n.successors())​


​q.enqueue(n)​


 


​return​​​​r​



一旦确定了循环体,就可以将代码转换成循环了。请记住,循环入口也许是一个判断跳出循环条件语句。这种情况下需要对这个条件取反。




1


2


3


4


5


6




​v1 = ​​​​0​


​while​​​​(v1 < v0) {​


​System.out.println(v1)​


​v1 = v1 + ​​​​1​


​}​


​return​



瞧,现在我们得到了一个前置条件循环!包括​​while​​、​​for​​ 以及​​for-each​​ 的大部分循环,编译后都遵循一种基本模式,这里我们都将其作为简单的 ​​while​​ 循环。一般来讲,我们很难完全确定原来的程序到底写的是哪一种循环。但是,​​for​​ 循环和 ​​foreach​​ 循环都遵循着一种非常特殊的模式。这里我们不对细节进行追究。但如果对比一下上面的 ​​while​​ 循环,就可以发现原来的 ​​for​​ 循环是如何在循环开始之前对循环条件进行初始化的 ​​(v1 = 0)​ ​ ,也可以了解迭代器 ​​(v1 = v1 + 1)​​ 如何被加到循环体的结尾。这个就当做把 ​​while​​ 转换为 ​​for​​ 和 ​​foreach​​ 的一个练习吧。还有一个很有意思的问题,如果要把循环改为后置条件循环 (​​do​​ / ​​while​​) 又该怎么做呢?

我们可以使用类似的技术对 ​​if/else​​ 语句进行反编译。​​if/else​​ 的字节码非常直观:




1


2


3


4


5


6


7


8


9


10


11


12




​begin:​


​iftrue(!condition)​​​​goto​​​​#​​​​else​


​// `if` block begins here​


​...​


​goto​​​​#end​


 


​else​​​​:​


​// `else` block begins here​


​...​


 


​end:​


​// end of `if/else`​



上面的代码中,我们使用 ​​iftrue​​ 伪指令取代条件分支:测试条件,如果通过则进入分支;否则,继续测试。我们知道, ​​if​​ 后面紧跟着条件,​​else​​ 开始跳转。找出 ‘if/else’ 块的内容与找出起始点的支配节点一样简单,执行之前的算法即可达成。

现在完成了基本的流控制机制介绍,当然还有些其他内容(比如错误处理和子程序等等),这些已经超出了本文的讨论范围。

总结

写一个反编译器不是一件简单的工作,涉及内容足以写一本甚至是一个系列的书!很明显,在一篇博客中不能覆盖所有的内容。而且即使我们这么做,也许你都不愿意读。我们希望,通过一些最普通的构造——逻辑运算、条件判断以及基本的流控制,能让你对反编译器的开发有一点有趣的了解。

  • Lee Benfield:Java反编译器​​CFR​​的作者。
  • Mike Strobel:Java反编译器和元编程框架​​Procyon​​的作者。

现在,不如开始动手写一个自己的Java反编译器吧 :)