如何理解ByteCode、IL、彙編等底層語言與上層語言的對應關係?

從java.lang.NullPointerException為什麼不設計成顯示null對象的名字或類型? - RednaxelaFX 的回答的討論而來。

我想請教,在擁有編譯出的低層語言和原始的高級語言的情況下,如何理解這些低層語言與上層語言的對應關係?


蟹腰。寫了一段之後覺得這種東西應該發去看雪,發知乎有多少人會看呢orz

長文警告:一不小心把前面介紹背景的部分寫太長了,廢話太多,大傢伙兒湊合著看吧?

這個問題的答案相當大程度取決於:最終的目標文件里保存了多少符號信息、編譯器做了多少優化、目標文件是否經過混淆或隨機化(randomize)。

===================================================================

Java篇

原先的問題討論的是Java,這裡先以Java為例來討論。同時假設我們既有Java的源碼也有對應的Java Class文件,只是想了解兩者之間的映射關係。

從Java源碼到Java位元組碼

Java的位元組碼本質上是Java AST通過後序遍歷的線性序列化形式。幾乎每個Java位元組碼opcode都有對應的Java語法結構。

只要熟悉Java的語法,能夠在看到Java源碼時想像出其解除語法糖之後的樣子,然後對應的AST的樣子,然後對這個AST後序遍歷就能得到Java位元組碼。我在這裡給過一個動畫例子:虛擬機隨談(一):解釋器,樹遍歷解釋器,基於棧與基於寄存器,大雜燴

&<- 請前讀完這篇再繼續向下讀本回答。Java位元組碼就是一種「零地址指令」。

Java最主流的源碼編譯器,javac,基本上不對代碼做優化,只會做少量由Java語言規範要求或推薦的優化;也不做任何混淆,包括名字混淆或控制流混淆這些都不做。這使得javac生成的代碼能很好的維持與原本的源碼/AST之間的對應關係。換句話說就是javac生成的代碼容易反編譯。

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規範的記法一樣,左邊是棧底右邊是棧頂,省略號表示不關心除棧頂附近幾個值之外操作數棧上的值。

讀取一個局部變數用&load系指令。

local_var_0

// // ... -&>
// iload_0 // ..., value0

&是類型前綴,有

  • b: byte
  • s: short
  • c: char
  • i: int
  • l: long
  • f: float
  • d: double
  • a: 引用類型

&load後面跟的參數是局部變數所在的位置(slot number)。其中對0到3的slot有特化的簡短指令,例如iload_0。4和以上就用通用的load指令,例如iload 4。

存儲一個局部變數用&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 // ...

&const_&、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.&()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 & -c -s -p -l -verbose &

例如,要看java.lang.Object的Class文件的內容,可以執行:

javap -c -s -p -l -verbose java.lang.Object

提取其中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
StackMapTable: number_of_entries = 2
frame_type = 9 /* same */
frame_type = 64 /* same_locals_1_stack_item */
stack = [ int ]

(為了演示方便我刪除了一些重複輸出的屬性表)

可以看到這裡不但有Java位元組碼,還有豐富的元數據(metadata)描述這段代碼。

讓我們先從Java位元組碼的部分看起。在Class文件里,Java位元組碼位於方法的Code屬性表裡。

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。

讓我們試著用這個記法來跟蹤一下Object.equals(Object)的程序狀態。

根據上文提到的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;
}

而這跟Object.equals(Object)boolean真正的源碼幾乎一樣了:

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

跟前面的例子一樣,我們先根據方法的signature創建出初始的棧幀狀態,然後再一條條指令抽象解釋下去。

這是個靜態方法,沒有隱含參數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

上一例也提到過,我們要盡量消除掉新添加的tmp臨時變數,因為它們不是原本源碼里存在的局部變數。修正後得到:

public static Object add3(int arg0, int arg1, int arg2) {
int loc3 = arg0 + arg1;
loc3 = loc3 + arg2;
return Integer.valueOf(loc3);
}

留意:包裝類型的valueOf()方法可能是源碼里顯式調用的,也可能是編譯器給自動裝箱(autoboxing)生成代碼時生成的。所以遇到Integer.valueOf(loc3)的話,反編譯出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 ]

Class文件里每個方法可以有許多元數據,裡面可以包含豐富的符號信息。

其中有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插件默認配置&true&,實際動作就是傳-g參數給javac,如果想維持可調試性的話請不要把它配置為false。這些調試符號信息消耗不了多少空間,不會影響運行時性能,不要白不要——除非您的目的是想阻撓別人調試?

這個例子不是泛型方法所以沒有LocalVariableTypeTable,只有LineNumberTable和LocalVariableTable。

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&;

從LineNumberTable可以知道,源碼第302行對應到bci [44, 57)的半開區間。

從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

可以很明顯的看到這行代碼有3處解引用,分別位於bci 46、50、51。當然,Java的實例方法的語義保證了此處this不會是null,所以能拋NPE的只能是bci 50和51兩處。

消除掉臨時變數恢復出源碼,這行代碼是:

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這倆主流編譯器生成的,或者是經過了後期處理(各種優化和混淆過),那就沒那麼方便了,必須用更強力的辦法來抵消掉一些優化或混淆帶來的問題。

===================================================================

.NET篇

題主所說的IL多半說的是MSIL,而不是泛指「中間語言」吧?

.NET的純託管程序里的MSIL(或稱為CIL)的分析方法跟前面Java篇所說的類似。

不同的是,Java世界裡大家主要用javac編譯出Class文件,程序怎麼被優化;而C#程序的發布版通常會用csc /o優化編譯,代碼與原本的源碼的對應關係可能會受到影響,所以一般.NET反編譯出來的源碼相對原本的源碼的差距,可能會比一般Java程序反編譯出來的大一些。這只是一般論。

關於MSIL的知識,去看ECMA-335規範自然好,另外也有不少現成的書可讀:

Inside Microsoft .NET IL Assembler

Expert .NET 2.0 IL Assembler

微軟.NET程序的加密與解密 &<- 看雪論壇的大大們寫的書。開頭有一章是介紹MSIL的。

研究MSIL的工具方面,ildasm(IL disassembler) 與 ilasm(IL assembler) 的組合完爆JDK的javap。前者能實現彙編-反彙編-彙編的roundtrip,使得實現學習MSIL非常順手;而後者只能反彙編,不能再彙編成Class文件。

公平的說,Java也有許多第三方工具/庫可以手寫位元組碼。比較老的有例如Jasmin,比較新的有例如bitescript或jitescript,但它們有些很老了跟不上時代的步伐,而且全部都只能彙編而不能反彙編,無法達成roundtrip,總之就是略麻煩。

更新:2016年的現在OpenJDK項目里有jasm/jdis工具,終於可以跟.NET的iladm/ildasm一樣roundtrip了,簡直贊!請跳傳送門:是否有工具能夠直接使用JVM位元組碼編寫程序? - RednaxelaFX 的回答

反編譯器方面.NET也有若干選擇。以前很長一段時間.NET Reflector都是大家的不二之選,但自從它徹底商業化不再免費之後,大家又要尋找新的選擇了。ILSpy似乎是新的主流選擇,免費開源;JetBrains dotPeek免費不開源。

===================================================================

待續?


語言的抽象層次和其作用以及適用的環境/對象是密切相關的。

那麼要理解這些東西與上層語言之間的關係,就要先理解他們存在是為了幹什麼。

首先,ByteCode是VM直接操縱的對象,無論是JIT還是直接解釋執行,都是直接跟ByteCode相關。而上層語言的源代碼則是用於開發者和compiler之間交流的中介,而且更傾向於適應開發者的需要。

如R大 @RednaxelaFX 所說,Java的位元組碼本質上是Java AST通過後序遍歷的線性序列化形式。

但反過來呢,Java源碼相對於ByteCode呢?

MetaInfo而已。

為什麼這麼說,很重要的一點就是ByteCode是去符號化的。因為VM runtime的任務是執行指令處理數據,多出來的東西對它來說都將是累贅。所以除了保存一些跟reflection等特性相關的基本內容在class文件裡面,那些本應該被compiler解析和處理的符號都被丟掉了。

而這些被丟掉的信息都可以在源碼裡面找到,用於輔助調試。否則的話JVM突然拋出來一個NullPointerException,至少要debug半天的ByteCode才能發現是哪兒的問題。

對於機器碼和PE文件(*.exe)雖然沒像class保存那麼多內容,也還是有專門保存符號的symbol file這種東西存在。像 @vczh 就利用pdb文件實現了C++ reflection這種黑魔法。

IL只是一個相對性的概念,只要不是最頂層和最底層,都可以稱為Intermediate Language。

IL的存在是為了分離編譯器的前端和後端,同時增強不同語言間的互操作性,我們也可以把Java ByteCode看成一種IL,所有基於JVM的語言都統一編譯為ByteCode,再由JVM的JIT編譯器進行後期的編譯和優化。

那麼彙編語言也就可以看作在機器碼之上引入了符號並且增強了可讀性的一種IL。因為所有的變數、過程和計算都變成了對應到stack / register / memory等之上的操作,所以彙編語言裡面也就只有那些預定義符號、寄存器名和標號,再進一步翻譯成機器碼的時候也就變成了數據 / 地址、opcode和偏移量。

也許有用的參考:

http://yosefk.com/blog/c-as-an-intermediate-language.html

以上。


其實就IL而言,其對應關係是非常明確的。

說白了在ByteCode和MSIL裡面有完整的類、方法、調用、實例化和繼承等等現成的語法,所以Java和C#語言在很大程度上來說就是好大一坨糖。

或者用 @RednaxelaFX 大大的話來說就是:

Java的位元組碼本質上是Java AST通過後序遍歷的線性序列化形式

這比起C++編譯器的魔改來說簡直是小兒科。

我覺得對於IL這種東西,理解的最好辦法就是多看,很多反編譯器(如ILSpy)都可以把DLL反編譯成多種語言,對照著IL和C#版本多看幾次,自然就能掌握裡面的對應關係和套路。

當然,C#有些語法糖在編譯後也是非常頭疼的,譬如說yield和yield的衍生(async/await),套路上倒是大同小異,但是編譯出來的輔助類型的名字通常是自動生成的,看起來和混淆了一樣。

作為一個普通的程序員,手譯沒有yield和閉包的C#/Java代碼變為IL是很容易掌握的一項裝逼技能。


IL 位元組碼哪一點算底層語言了?不是說一般沒人看的就是底層語言,底層語言主要還是彙編。IL和位元組碼還是要進一步編譯成彙編才能運行的


推薦閱讀:

如何學寫一個編譯器後端?
《編譯器設計》第二章第5節的疑惑?
請教,像C/Go這種靜態語言,編譯可執行文件的過程,是否是先生成彙編然後由彙編生成機器碼?
為什麼不能把so文件靜態鏈接到可執行文件中?
關於這段C代碼為什麼會輸出這種結果?

TAG:Java | 反編譯 | 編譯 | 軟體調試 | Net開發 |