标签:UI oss href compile 描述 result slide build method
Java Class文件含有丰富的符号信息。而且javac默认的编译参数会让编译器生成行号表,这些都有助于了解对应关系。
关于Java语法结构如何对应到Java字节码,在JVM规范里有相当好的例子:Chapter 3. Compiling for the Java Virtual Machine
好好读完这章基本上就能手码字节码了。
记住一个要点就好:“运算”全部都在“操作数栈”(operand stack)上进行,每个运算的输入参数全部都在“操作数栈”上,运算完的结果也放到“操作数栈”顶。在多数Java语句之间“操作数栈”为空。
从Java源码对应到Java字节码的例子
题主之前说“从来不觉得阅读底层语言很容易,无论是汇编还是ByteCode还是IL”。我是觉得只要能耐心读点资料,Charles Nutter的JVM Bytecodes for Dummies,然后配合The Java Virtual Machine Instruction Set,要理解Java字节码真的挺容易的。
口说无凭,举些简单的例子吧。把这些简单的例子组装起来,就可以得到完整方法的字节码了。
每个例子前半是Java代码,后面的注释是对应的Java字节码,每行一条指令。每条指令后面我还加了注释来表示执行完该指令后操作数栈的状态,就像JVM规范的记法一样,左边是栈底右边是栈顶,省略号表示不关心除栈顶附近几个值之外操作数栈上的值。
读取一个局部变量用<type>load系指令。local_var_0
// // ... ->
// iload_0 // ..., value0
local_var_0 = ...
// // ..., value0 ->
// istore_0 // ...
local_var_1 = local_var_0;
// // ... ->
// iload_0 // ..., value0 ->
// istore_1 // ...
... + ...
// // ..., value1, value2 ->
// iadd // ..., sum
local_var_0 + local_var_1
// // ... ->
// iload_0 // ..., value0 ->
// iload_1 // ..., value0, value1 ->
// iadd // ..., sum
local_var_2 = local_var_0 + local_var_1;
// // ... ->
// iload_0 // ..., value0 ->
// iload_1 // ..., value0, value1 ->
// iadd // ..., sum ->
// istore_2 // ...
local_var_3 = local_var_0 + local_var_1 + local_var_2
// // ... ->
// iload_0 // ..., value0 ->
// iload_1 // ..., value0, value1 ->
// iadd // ..., sum1 ->
// iload_2 // ..., sum1, value2 ->
// iadd // ..., sum2 ->
// istore_3 // ...
return ...;
// // ..., value ->
// ireturn // ...
return local_var_0;
// // ... ->
// iload_0 // ..., value0 ->
// ireturn // ...
return local_var_0 + local_var_0
// // ... ->
// iload_0 // ..., value0 ->
// dup // ..., value0, value0 ->
// iadd // ..., sum ->
// ireturn // ...
1 // iconst_1
true // iconst_1 // JVM的类型系统里,整型比int窄的类型都统一带符号扩展到int来表示
127 // bipush 127 // 能用一个字节表示的带符号整数常量
1234 // sipush 1234 // 能用两个字节表示的带符号整数常量
12.5 // ldc 12.5 // 较大的整型常量、float、double、字符串常量用ldc
new Object()
// // ... ->
// new java/lang/Object // ..., ref ->
// dup // ..., ref, ref ->
// invokespecial java/lang/Object.<init>()V // ..., ref
关键点在于:new指令只复制分配内存与默认初始化,包括设置对象的类型,将对象的Java字段都初始化到默认值;调用构造器来完成用户层面的初始化是后面跟着的一条invokespecial完成的。
使用this:this
// // ... ->
// aload_0 // ..., this
这涉及到Java字节码层面的“方法调用约定”(calling convention):参数从哪里传出和传入,通过哪里返回。读读这里和这里就好了。
静态方法,方法参数会从局部变量区的第0~(n-1)个slot从左到右传入,假如有n个参数;
实例方法,方法参数会从局部变量区的第1~n个slot从左到右传入,假如有n个显式参数,第0个slot传入this的引用。所以在Java源码里使用this,到字节码里就是aload_0。
在被调用方看有传入的东西,必然都是在调用方显式传出的。传出的办法就是在invoke指令之前把参数压到操作数栈上。当然,“this”的引用也是这样传递的。
方法真正的局部变量分配在参数之后的slot里。常见的不做啥优化的Java编译器会按照源码里局部变量出现的顺序来分配slot;如果有局部变量的作用域仅在某些语句块里,那么在它离开作用域后后面新出现的局部变量可以复用前面离开了作用域的局部变量的slot。
这方面可以参考我以前写的一个演示稿的第82页:Java 程序的编译,加载 和 执行
int local_var_2 = Math.max(local_var_0, local_var_1);
// // ... ->
// iload_0 // ..., value0 ->
// iload_1 // ..., value0, value1 ->
// invokestatic java/lang/Math.max(II)I // ..., result ->
// istore_2 // ...
local_var_0.equals(local_var_1)
// aload_0 // 压入对象引用,作为被调用方法的“this”传递过去
// aload_1 // 压入参数
// invokevirtual java/lang/Object.equals(Ljava/lang/Object;)Z
Java字节码的方法调用使用“符号引用”(symbolic reference)来指定目标,非常容易理解,而不像native binary code那样用函数地址。
读取一个字段:this.x // 假设this是mydemo.Point类型,x字段是int类型
// // ... ->
// aload_0 // ..., ref ->
// getfield mydemo.Point.x:I // ..., value
this.x = local_var_1 // 假设this是mydemo.Point类型,x字段是int类型
// // ... ->
// aload_0 // ..., ref ->
// iload_1 // ..., ref, value ->
// putfield mydemo.Point.x:I // ...
循环的代码生成例子,我在对C语义的for循环的基本代码生成模式发过一个。这里就不写了。
其它控制流,例如条件分支与无条件分支,感觉都没啥特别需要说的…
异常处理…有人问到再说吧。
从Java字节码到Java源码
上面说的是从Java源码->Java字节码方向的对应关系,那么反过来呢?两本书里前一本靠谱一些,后一本过于简单不过入门读读可能还行。
论文是日文的不过写得还挺有趣,可读。它的特点是通过dominator tree来恢复出Java层面的控制流结构。
它的背景是当时有个用Java写的研究性Java JIT编译器叫OpenJIT,先把Java字节码反编译为Java AST,然后再对AST应用传统的编译技术编译到机器码。
这种做法在90年代末的JIT挺常见,JRockit最初的JIT编译器也是用这个思路实现。但很快大家就发现干嘛一定要费力气先反编译Java字节码到AST再编译到机器码呢,直接把Java字节码转换为基于图的、有显式控制流和基本块的IR不就好了么。所以比较新的Java JIT编译器都不再做“反编译”这一步了。
这些比较老的资料从现在的角度看最大的问题是对JDK 1.4.2之后的javac对try...catch...finally生成的代码的处理不完善。由于较新的javac会把finally块复制到每个catch块的末尾,生成了冗余代码,在复原源码时需要识别出重复的代码并对做tail deduplication(尾去重)才行。以前老的编译方式则是用jsr/ret,应对方式不一样。
从Java字节码对应到Java源码的例子
首先,我们要掌握一些工具,帮助我们把二进制的Class文件转换(“反汇编”)为比较好读的文本形式。最常用的是JDK自带的javap。要获取最详细的信息的话,用以下命令:javap -cp <your classpath> -c -s -p -l -verbose <full class name>
javap -c -s -p -l -verbose java.lang.Object
public boolean equals(java.lang.Object);
Signature: (Ljava/lang/Object;)Z
flags: ACC_PUBLIC
Code:
stack=2, locals=2, args_size=2
0: aload_0
1: aload_1
2: if_acmpne 9
5: iconst_1
6: goto 10
9: iconst_0
10: ireturn
LineNumberTable:
line 150: 0
StackMapTable: number_of_entries = 2
frame_type = 9 /* same */
frame_type = 64 /* same_locals_1_stack_item */
stack = [ int ]
(为了演示方便我删除了一些重复输出的属性表)
可以看到这里不但有Java字节码,还有丰富的元数据(metadata)描述这段代码。
0: aload_0
javap的这个显示格式,开头的数字就是bci(bytecode index,字节码偏移量)。bci是从该方法的字节码起始位置开始算的偏移量。后面跟的是字节码指令,以及可选的字节码参数。
如何把字节码转换回成Java代码呢?有些不错的算法可以机械地复原出Java AST。这个例子我们先用比较简单的思路人肉走一遍流程。[ 0: this, 1: x, 2: undefined | this, null ]
这个记法用方括号括住一个Java栈帧?的状态。中间竖线是分隔符,左边是局部变量区,右边是操作数栈。局部变量区每个slot有标号,也就是slot number,这块可以随机访问;操作数栈的slot则没有标号,通常只能访问栈顶或栈顶附近的slot。
跟之前用的记法类似,操作数栈也是靠左边是栈底,靠右边是栈顶。
局部变量区里如果有slot尚未赋初始值的话,则标记为undefined。
// [ 0: this, 1: arg0 | ]
0: aload_0 // [ 0: this, 1: arg0 | this ]
1: aload_1 // [ 0: this, 1: arg0 | this, arg0 ]
2: if_acmpne 9 // [ 0: this, 1: arg0 | ] // if (this != arg0) goto bci_9
5: iconst_1 // [ 0: this, 1: arg0 | 1 ]
6: goto 10 // [ 0: this, 1: arg0 | 1 ] // goto bci_10
9: iconst_0 // [ 0: this, 1: arg0 | 0 ]
10: ireturn // [ 0: this, 1: arg0 | phi(0, 1) ] // return phi(0, 1)
其实上述过程就是一种“抽象解释”(abstract interpretation):我们实际上对字节码做了解释执行,只不过不以“运算出最终结果”为目的,而是以“提取出代码的某些特点”为目的。
之前有另外一个问题:如何理解抽象解释(abstract interpretation)? - 编程语言,这就是抽象解释的一个应用例子。
Wikipedia的Decompiler词条也值得一读,了解一下大背景。
if (this == arg0) {
tmp0 = 1;
} else {
// bci_9:
tmp0 = 0;
}
// bci_10:
return tmp0;
public boolean equals(Object arg0) {
return this == arg0;
}
public boolean equals(Object obj) {
return (this == obj);
}
如何?小试牛刀感觉还不错?
我们可以再试一个简单的算术运算例子。假如有下述字节码(及signature): public static java.lang.Object add3(int, int, int);
Code:
stack=2, locals=4, args_size=3
0: iload_0
1: iload_1
2: iadd
3: istore_3
4: iload_3
5: iload_2
6: iadd
7: istore_3
8: iload_3
9: invokestatic #2 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
12: areturn
// [ 0: arg0, 1: arg1, 2: arg2, 3: undefined | ]
0: iload_0 // [ 0: arg0, 1: arg1, 2: arg2, 3: undefined | arg0 ]
1: iload_1 // [ 0: arg0, 1: arg1, 2: arg2, 3: undefined | arg0, arg1 ]
2: iadd // [ 0: arg0, 1: arg1, 2: arg2, 3: undefined | tmp0 ] // tmp0 = arg0 + arg1
3: istore_3 // [ 0: arg0, 1: arg1, 2: arg2, 3: loc3 | ] // int loc3 = tmp0
4: iload_3 // [ 0: arg0, 1: arg1, 2: arg2, 3: loc3 | loc3 ]
5: iload_2 // [ 0: arg0, 1: arg1, 2: arg2, 3: loc3 | loc3, arg2 ]
6: iadd // [ 0: arg0, 1: arg1, 2: arg2, 3: loc3 | tmp1 ] // tmp1 = loc3 + arg2
7: istore_3 // [ 0: arg0, 1: arg1, 2: arg2, 3: loc3 | ] // loc3 = tmp1
8: iload_3 // [ 0: arg0, 1: arg1, 2: arg2, 3: loc3 | loc3 ]
9: invokestatic #2 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
// [ 0: arg0, 1: arg1, 2: arg2, 3: loc3 | tmp2 ] // tmp2 = Integer.valueOf(loc3)
12: areturn // [ 0: arg0, 1: arg1, 2: arg2, 3: loc3 | ] // return tmp2
tmp0 = arg0 + arg1
int loc3 = tmp0
tmp1 = loc3 + arg2
loc3 = tmp1
tmp2 = Integer.valueOf(loc3)
return tmp2
public static Object add3(int arg0, int arg1, int arg2) {
int loc3 = arg0 + arg1;
loc3 = loc3 + arg2;
return Integer.valueOf(loc3);
}
public static Object add3(int x, int y, int z) {
int result = x + y;
result = result + z;
return result;
}
就差参数/局部变量名和行号了。
其次,我们要充分利用Java Class文件里包含的符号信息。
如果我们用的是debug build的JDK,那么javap得到的信息会更多。还是以java.lang.Object.equals(Object)为例, public boolean equals(java.lang.Object);
Signature: (Ljava/lang/Object;)Z
flags: ACC_PUBLIC
Code:
stack=2, locals=2, args_size=2
0: aload_0
1: aload_1
2: if_acmpne 9
5: iconst_1
6: goto 10
9: iconst_0
10: ireturn
LineNumberTable:
line 150: 0
LocalVariableTable:
Start Length Slot Name Signature
0 11 0 this Ljava/lang/Object;
0 11 1 obj Ljava/lang/Object;
StackMapTable: number_of_entries = 2
frame_type = 9 /* same */
frame_type = 64 /* same_locals_1_stack_item */
stack = [ int ]
这三个属性表通常被称为“调试符号信息”。事实上,Java的调试器就是通过它们来在某行下断点、读取局部变量的值并映射到源码的变量的。放几个传送门:
为什么有时候调试代码的时候看不到变量的值。
LocalVariableTable有点迷糊
LocalVariableTable属性、LineNumberTable属性
换句话说,如果没有LocalVariableTable,调试器就无法显示参数/局部变量的值(因为不知道某个名字的局部变量对应到第几个slot);如果没有LineNumberTable,调试器就无法在某行上下断点(因为不知道行号与bci的对应关系)。
Oracle/Sun JDK的product build里,rt.jar里的Class文件都只有LineNumberTable而没有LocalVariableTable,所以只能下断点调试却不能显示参数/局部变量的值。
我是推荐用javac编译Java源码时总是传-g参数,保证所有调试符号信息都生成出来,以备不时之需。像Maven的Java compiler插件默认配置<debug>true</debug>,实际动作就是传-g参数给javac,如果想维持可调试性的话请不要把它配置为false。这些调试符号信息消耗不了多少空间,不会影响运行时性能,不要白不要——除非您的目的是想阻挠别人调试?
public boolean equals(Object arg0) {
return this == arg0;
}
public boolean equals(Object obj) {
return this == obj; // line 150
}
与原本的源码完美吻合。
终于铺垫了足够背景知识来回过头讲讲题主原本在java.lang.NullPointerException为什么不设计成显示null对象的名字或类型? - RednaxelaFX 的回答下的疑问了。
假如一行源码有多个地方要解引用(dereference),每个地方都有可能抛出NullPointerException,但由此得到的stack trace的行号都是一样的,无法区分到底是哪个解引用出了问题。假如stack trace带上bci,问题就可以得到完美解决——前提是用户得能看懂bci对应到源码的什么位置。
44: aload_1
45: aload_0
46: getfield #12 // Field elementData:[Ljava/lang/Object;
49: iload_2
50: aaload
51: invokevirtual #31 // Method java/lang/Object.equals:(Ljava/lang/Object;)Z
54: ifeq 59
LineNumberTable:
line 302: 44
line 303: 57
LocalVariableTable:
Start Length Slot Name Signature
36 29 2 i I
0 67 0 this Ljava/util/ArrayList;
0 67 1 o Ljava/lang/Object;
LocalVariableTypeTable:
Start Length Slot Name Signature
0 67 0 this Ljava/util/ArrayList<TE;>;
// [ 0: this, 1: o, 2: i | ... ]
44: aload_1 // [ 0: this, 1: o, 2: i | ..., o ]
45: aload_0 // [ 0: this, 1: o, 2: i | ..., o, this ]
46: getfield #12 // Field elementData:[Ljava/lang/Object;
// [ 0: this, 1: o, 2: i | ..., o, tmp0 ] // tmp0 = this.elementData
49: iload_2 // [ 0: this, 1: o, 2: i | ..., o, tmp0, i ]
50: aaload // [ 0: this, 1: o, 2: i | ..., o, tmp1 ] // tmp1 = tmp0[i]
51: invokevirtual #31 // Method java/lang/Object.equals:(Ljava/lang/Object;)Z
// [ 0: this, 1: o, 2: i | ..., tmp2 ] // tmp2 = o.equals(tmp1)
54: ifeq 59
// [ 0: this, 1: o, 2: i | ... ] // if (tmp2) goto bci_59
tmp0 = this.elementData // bci 46
tmp1 = tmp0[i] // bci 50
tmp2 = o.equals(tmp1) // bci 51
if (tmp2) goto bci_59 // bci 54
if (o.equals(this.elementData[i])) { // ...
实际源码在此:http://hg.openjdk.java.net/jdk8/jdk8/jdk/file/tip/src/share/classes/java/util/ArrayList.java#l302 是 java.util.ArrayList.indexOf(Object)int 的其中一行。
假如有NullPointerException的stack trace带有bci,显示:java.lang.NullPointerException
at java.util.ArrayList.indexOf(ArrayList.java:line 302, bci 51)
...
那么我们很容易就知道这里o是null,而不是elementData是null。
通常大家会写在一行上的代码都不会很多,很少会有复杂的控制流所以通常可以不管它,用这种简单的人肉分析法以及足以应付分析抛NPE时bci到源码的对应关系。
爽不?
实际的Java Decompiler是怎么做的,可以参考开源的Procyon的实现。
上面的讨论都是基于“要分析的字节码来自javac编译的Java源码”。如果不是javac或者ecj这俩主流编译器生成的,或者是经过了后期处理(各种优化和混淆过),那就没那么方便了,必须用更强力的办法来抵消掉一些优化或混淆带来的问题。
标签:UI oss href compile 描述 result slide build method
原文地址:http://www.cnblogs.com/qlqwjy/p/7598933.html