Java反編譯器剖析
反編譯器(或者解碼器),簡而言之,就是將目標程式碼反轉成原始碼。但是其中的過程卻比較複雜,也很有意思——Java原始碼是結構化的,位元組碼卻不是。而且,轉換不是一一對應的:兩段完全不同的Java程式也可能生成完全相同的位元組碼,有時需要一些試探才能更加接近原始碼。
(一段簡短的)位元組碼教程
為了更好的理解反編譯器如何工作,現在有必要理解一下位元組碼基礎。如果你對此非常熟悉,可以略過此處直接跳到下一部分。
(不同於基於暫存器 register-based 的方式)JVM執行基於棧。這就意味著指令會在 evaluation stack(計算堆疊)上執行。操作物件可能先出棧,進行一些操作,然後再把結果入棧來進行接下來的操作。考慮如下場景:
public static int plus(int a, int b) { int c = a + b; return c; }
注:本文所有的相關的位元組碼都是由 javap
產生,例如執行命令 javap -c -p MyClass
。
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)的技術,可以消除一些重複變數。複製傳播是內聯的一種形式,可以將變數簡單替換為指定值,前提是這種轉換是有效的。
如何定義”有效性“?這裡包含了一些重要準則。考慮下面這種情況:
0: s0 = v1 1: v1 = s4 2: v2 = s0 <-- s0 cannot be replaced with v1
在這裡,如果將 s0
替換為 v1
結果將大不相同。因為 v1
的值在 s0
被指定之後改變了,雖然此時 v1
的值卻還沒有被使用(譯註:原文這裡是V0
,根據註釋可以確認為筆誤)。為了避開這種複雜的情形,這裡複製傳播只考慮僅被賦值一次的內聯變數(inline variable)。
譯註:一個簡單的(C語言)內聯變數手動解析示例,來自Wikipedia:inline expansion
int pred(int x) { if (x == 0) return 0; else return x - 1; }
進行 inline 操作前:
int f(int y) { return pred(y) + pred(0) + pred(y+1); }
進行 inline 操作以後:
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
賦值超過一次,則不能進行復制傳播。
不過我們最初的那個例子沒有這麼複雜,因而我們得到如下的優美精確的結果:
v2 = v0 + v1 return v2
畫外音:儲存變數名
如果變數在位元組碼中被簡化為 slot 的引用,那麼接下來怎樣才能知道原來物件的名稱呢?很有可能無法知道。為了改變這情況,改進除錯的使用者體驗,每個方法的位元組碼都包含有一個特殊的部分——本地變數表。這個表中記錄了原始碼中每個變數的名稱、slot 編號和變數名對應的位元組碼。通過 javap
的 -v
選項可以把本地變數表(以及其他有用的後設資料)包含到反彙編程式碼。對於上面示例中的 plus()
方法,它的本地變數表看起來像下面這樣:
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
等。
目前為止,在處理變數的時候都是比較直接的,因為我們僅僅採用一種程式碼路徑來探索方法。在真實的應用環境裡,多數的方法都不是那麼”善解人意“。每當為方法增加一個迴圈或者判斷,就會增加了很多可能的呼叫情況。讓我們來看一下改進版的示例:
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
塊;否則,繼續當前的路徑。有趣的是,與原始程式碼相比,這裡測試條件是取反的。
接下來將我們進行更深入的研究……
相關文章
- Java編譯與反編譯Java編譯
- 深入剖析Java即時編譯器(上)Java編譯
- java反編譯工具Java編譯
- Java 反彙編、反編譯、volitale解讀Java編譯
- [java]javap命令列反編譯Java命令列編譯
- JAVA反編譯技術研究心得Java編譯
- Java程式碼的編譯與反編譯那些事兒Java編譯
- Java反編譯工具使用對比,最好用的Java反編譯工具 --- JD-GUI、XJadJava編譯GUI
- 如何保護Java程式 防止Java反編譯Java編譯
- Clojure 執行原理之編譯器剖析編譯
- Android反編譯:反編譯工具和方法Android編譯
- 7 款開源 Java 反編譯工具Java編譯
- 7款開源Java反編譯工具Java編譯
- 反編譯apk編譯APK
- Java Jar原始碼反編譯工具那家強JavaJAR原始碼編譯
- Android Apk反編譯得到Java原始碼AndroidAPK編譯Java原始碼
- Android Apk反編譯系列教程(一)如何反編譯APKAndroidAPK編譯
- 反編譯 iOS APP編譯iOSAPP
- android 反編譯Android編譯
- Android反編譯和微信機器人初探Android編譯機器人
- 反編譯系列教程(上)編譯
- 反編譯系列教程(中)編譯
- Android 反編譯指南Android編譯
- Eclipse配置反編譯Eclipse編譯
- 小程式反編譯教程編譯
- .net反編譯工具ILSpy編譯
- 安卓反編譯詳解安卓編譯
- jive論壇反編譯編譯
- 反編譯技術探究編譯
- c#程式反編譯C#編譯
- Java反編譯程式碼左側註釋批量清除Java編譯
- Mac平臺反編譯Unity編譯的安卓apkMac編譯Unity安卓APK
- IDEA報錯java: 編譯失敗: 內部 java 編譯器錯誤IdeaJava編譯
- 一些防止java程式碼被反編譯的方法Java編譯
- 通過反編譯深入理解Java String及intern編譯Java
- 如何反編譯微信小程式?編譯微信小程式
- 安卓apk檔案反編譯安卓APK編譯
- gcc 編譯器與 clang 編譯器GC編譯