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
- b: byte
- s: short
- c: char
- i: int
- l: long
- f: float
- d: double
- a: 引用類型
存儲一個局部變量用<type>store系指令。
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 // ...
<type>const_<val>、bipush、sipush、ldc這些指令都用於向操作數棧壓入常量。例如:
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字節碼方向的對應關系,那么反過來呢?反過來的過程也就是“反編譯”。反編譯Java既有現成的反編譯器( Procyon、 JD、 JAD之類, 這里有更完整的列表),也有些現成的資料描述其做法,例如:
- 書:Covert Java: Techniques for Decompiling, Patching, and Reverse Engineering: Alex Kalinovsky
- 書:Decompiling Java: Godfrey Nolan
- 老論文:Java バイトコードをデコンパイルするための効果的なアルゴリズム(An Effective Decompilation Algorithm for Java Bytecodes)
兩本書里前一本靠譜一些,后一本過於簡單不過入門讀讀可能還行。
論文是日文的不過寫得還挺有趣,可讀。它的特點是通過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。這個例子我們先用比較簡單的思路人肉走一遍流程。下面用一種新的記法來跟蹤Java程序的局部變量與表達式臨時值的狀態,例如:
[ 0: this, 1: x, 2: undefined | this, null ]
這個記法用方括號括住一個Java棧幀的狀態。中間豎線是分隔符,左邊是局部變量區,右邊是操作數棧。局部變量區每個slot有標號,也就是slot number,這塊可以隨機訪問;操作數棧的slot則沒有標號,通常只能訪問棧頂或棧頂附近的slot。
跟之前用的記法類似,操作數棧也是靠左邊是棧底,靠右邊是棧頂。
局部變量區里如果有slot尚未賦初始值的話,則標記為undefined。
根據上文提到的Java calling convention,從該方法的signature(方法參數列表類型和返回值類型。 Method Signature是Java層面的叫法;在JVM層面叫做 Method Descriptor)——(Object)boolean,或者用JVM內部表現方式 (Ljava/lang/Object;)Z——我們可以知道在進入該方法的時候局部變量區的頭兩個slot已經填充上了參數——實例方法的slot 0是this,slot 1是第一個顯式參數。
局部變量區有多少個slot是傳入的參數可以看javap輸出的“args_size”屬性,此例為2;局部變量區總共有多少個slot可以看“locals”屬性,此例為2,跟args_size一樣說明這個方法沒有聲明任何具名的局部變量;操作數棧最高的高度可以看“stack“屬性,此例為2。
我們先不管具體的參數名,后面再說;先用arg0來指代“第一個參數”。
// [ 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)
- 當指令使值從局部變量壓到操作數棧的時候,我們只是記下棧的變化,其它什么都不用做。
- 當指令從操作數棧彈出值並且進行運算的時候,我們記下棧的變化並且記下運算的內容。
- 當指令是控制流(跳轉)時,記錄下跳轉動作。
- 當指令是控制流交匯處(例如這里的bci 10的位置,既可以來自bci 6也可以來自bci 9),用“phi”函數來合並棧幀中對應位置的值的狀態。這里例子里,phi(0, 1)表示這個slot既可能是0也可能是1,取決於前面來自哪條指令。
- 正統的做法應該把基本塊(basic block)划分好並且構建出控制流圖(CFG,control flow graph)。這個例子非常簡單所以先偷個懶硬上。
其實上述過程就是一種“抽象解釋”(abstract interpretation):我們實際上對字節碼做了解釋執行,只不過不以“運算出最終結果”為目的,而是以“提取出代碼的某些特點”為目的。
之前有另外一個問題:如何理解抽象解釋(abstract interpretation)? - 編程語言,這就是抽象解釋的一個應用例子。
Wikipedia的Decompiler詞條也值得一讀,了解一下大背景。
if (this == arg0) { tmp0 = 1; } else { // bci_9: tmp0 = 0; } // bci_10: return tmp0;
- 把if的判斷條件“反過來”,跳轉目標也“反過來。這是因為javac在為條件分支生成代碼時,通常把then分支生成為fall through(直接執行下一條指令而不跳轉),而把else分支生成為顯式跳轉。這樣跳轉的條件就正好跟源碼相反。既然我們要從字節碼恢復出源碼,這里就得再反回去。
- 把操作數棧上出現了phi函數的slot在恢復出的源碼里用臨時變量tmp來代替。這樣就可以知道到底哪個分支里應該取哪個值。
- 通過方法的signature,我們知道Object.equals(Object)boolean返回值是boolean類型的。前面提到了JVM字節碼層面的類型系統boolean是提升到int來表示的,所以這里的1和0其實是true和false。
- if (compare) { true } else { false },其實就是compare本身。只不過JVM字節碼指令集沒有返回boolean結果的比較指令,而只有帶跳轉的比較指令,所以生成出的代碼略繁瑣略奇葩。這樣可以化簡出tmp0 = this == arg0;
- 所有在我們的整理過程中添加的tmp變量在原本的源碼里肯定不是有名字的局部變量,而是沒有名字的臨時值。在恢復源碼時要盡量想辦法消除掉。例如說return tmp0;就應該盡量替換成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
這是個靜態方法,沒有隱含參數this。根據args_size=3可知slot 0-2是傳入的參數,locals=4所以有一個顯式聲明的局部變量,stack=2所以操作數棧最高高度為2。
// [ 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
只有兩點新做法值得留意:
- 顯式聲明的局部變量,在還沒有進入作用域之前還沒有值,記為undefined。當抽象解釋到某個局部變量slot首次被賦值,也就是從undefined變為有意義的值的時候,把記錄下的代碼寫成局部變量聲明,類型就用賦值進來的值的類型。后面我們會看到局部變量的聲明的類型有可能還要受后面代碼的影響而需要調整,現在可以先不管。
- 每當從操作數棧彈出值,進行運算后要把結果壓回到操作數棧上。為了方便記錄,我們把運算用臨時變量記着,並把臨時變量壓回到棧上。這樣就不用把棧里的狀態寫得那么麻煩。
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 ]
其中有3個屬性表含有非常重要的符號信息:
- LineNumberTable:行號表。顧名思義,它記錄了 源碼里的行號 -> 該行的代碼的起始bci 的映射關系。javac默認會生成該屬性表,也可以顯式通過-g:lines參數指定生成。
- LocalVariableTable:局部變量表。它記錄了 源碼里的變量名和類型 -> 局部變量區的slot number以及作用域在什么bci范圍內。javac默認不會生成該屬性表,需要通過-g:vars或-g參數來指定生成。該屬性表記錄的類型是“擦除泛型”之后的類型。
- LocalVariableTypeTable:局部變量類型表。這是泛型方法才會有的屬性表,用於記錄擦除泛型前源碼里聲明的類型。javac默認也不會生成該屬性表,跟上一個表一樣要用參數指定。
這三個屬性表通常被稱為“調試符號信息”。事實上,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。這些調試符號信息消耗不了多少空間,不會影響運行時性能,不要白不要——除非您的目的是想阻撓別人調試⋯
LineNumberTable只有一項,說明這個方法只有一行有效的源碼,第150行映射到bci [0, 11)這個半開區間。
LocalVariableTable有兩項,正好描述的都是參數。它們的作用域都是bci [0, 11)這個半開區間;start和length描述的是 [start, start+length) 范圍。它們的類型都是引用類型java.lang.Object。它們的名字,slot 0 -> this,slot 1 -> obj。
應用上這些符號信息,我們就可以把前面例子中反編譯得到的:
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;>;
從LocalVariableTable可以知道,在這段字節碼的范圍內每個slot到局部變量名的映射關系。
僅憑以上信息無法知道當前操作數棧的高度,不過這種上下文里通常我們可以不關心它的初始高度,暫時忽略就好。
然后讓我們來抽象解釋一下這段字節碼:
// [ 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這倆主流編譯器生成的,或者是經過了后期處理(各種優化和混淆過),那就沒那么方便了,必須用更強力的辦法來抵消掉一些優化或混淆帶來的問題。