通過上一篇文章的學習,我們都知道了 Java 程式碼是如何執行的。Java 編譯器將 .java
原始檔編譯為 .class
位元組碼檔案,JVM
(Java虛擬機器)將位元組碼解釋為機器程式碼最終在目標機器上執行。而在 Android 中,程式碼是如何執行的呢 ?首先看下面這張圖:
這裡的 DVM
指的是 DalviK VM
。在 Android 中,Java 類被打包生成固定格式的 DEX
位元組碼檔案,DEX
位元組碼經過 Dalvik
或者 ART
轉換為原生機器碼,進而執行。DEX
位元組碼是獨立於裝置架構的。
Dalvik 是一個基於 JIT(即時)的編譯引擎。使用 Dalvik 是有缺點的,因此從 Android4.4(kitkat)開始引入了 ART 作為執行時,從 Android5.0(Lollopop)開始就完全替代了 Dalvik。Android7.0 增加了一個即時型編譯器,給 Android 執行時(ART)提供了程式碼分析,提升了 Android app執行時的表現。關於 Dalvik 和 Art 的具體分析,可以閱讀我之前的一篇譯文 走近 Android 執行時: DVM VS ART 。
上圖中還可以看到,JVM 的執行是 Stack-based
, 基於棧幀的,而 Dalvik 虛擬機器是 Register-based
,基於暫存器的。這點需要記住,在後面的 Smali 語法分析中很重要。說到 Smali,那麼什麼是 Smali呢?用過 apktool
的朋友肯定都不陌生,apktool d xxx.apk
反編譯 apk 之後,生成的資料夾之中會有 smali 資料夾,裡面就包含了該 apk 的所有程式碼,均以 .smali
檔案形式儲存。關於 Smali ,在 Android 官網中並無相關介紹,它應該出自 JesusFreke
的開源專案 smali,在 README 中是這樣介紹的:
smali/baksmali is an assembler/disassembler for the dex format used by dalvik, Android's Java VM implementation.
The syntax is loosely based on Jasmin's/dedexer's syntax, and supports the full functionality of the dex format (annotations, debug info, line info, etc.)
複製程式碼
大致翻譯一下, smali/baksmali
是針對 dalvik
使用的 dex
格式的彙編/反彙編器。它的語法基於 Jasmin's/dedexer
,支援 dex
格式的所有功能(註釋,除錯資訊,行資訊等等)。因此我們可以認為 smali 和 Dalvik 位元組碼檔案是等價的。事實上,Apktool
也正是呼叫這個工程生成的 jar 包來進行反編譯生成 smali 程式碼的。對生成的 smali 程式碼進行修改之後再重打包,就可以修改 apk 中的邏輯了。因此,能閱讀 smali 程式碼對我們進行 android 逆向十分重要。
Smali 檔案生成
下面仍然以之前的 Hello.java
為例:
public class Hello {
private static String HELLO_WORLD = "Hello World!";
public static void main(String[] args) {
System.out.println(HELLO_WORLD);
}
}
複製程式碼
javac
生成 Hello.class
檔案,然後通過 Sdk 自帶的 dx
工具生成 Hello.dex
檔案,命令如下:
dx --dex --output=Hello.dex Hello.class
複製程式碼
dx
工具位於 Sdk 的 build-tools
目錄下,可新增至環境變數方便呼叫。dx
也支援多 Class 檔案生成 dex。
dex
轉 smali
使用的工具是 baksmali.jar
,最新版本是 2.2.5
,點選下載,使用命令如下:
java -jar baksmali-2.2.5.jar d hello.dex
複製程式碼
執行完成後,會在當前目錄生成 out
資料夾,資料夾內包含生成的 smali
檔案。
Smali 詳細解析
我們首先看一下生成的 Hello.smali
檔案內容:
.class public LHello;
.super Ljava/lang/Object;
.source "Hello.java"
# static fields
.field private static HELLO_WORLD:Ljava/lang/String;
# direct methods
.method static constructor <clinit>()V
.registers 1
.prologue
.line 3
const-string v0, "Hello World!"
sput-object v0, LHello;->HELLO_WORLD:Ljava/lang/String;
return-void
.end method
.method public constructor <init>()V
.registers 1
.prologue
.line 1
invoke-direct {p0}, Ljava/lang/Object;-><init>()V
return-void
.end method
.method public static main([Ljava/lang/String;)V
.registers 3
.prologue
.line 6
sget-object v0, Ljava/lang/System;->out:Ljava/io/PrintStream;
sget-object v1, LHello;->HELLO_WORLD:Ljava/lang/String;
invoke-virtual {v0, v1}, Ljava/io/PrintStream;->println(Ljava/lang/String;)V
.line 7
return-void
.end method
複製程式碼
檔案頭
首先看一下檔案頭部分:
.class public LHello; // 類名
.super Ljava/lang/Object; // 父類名
.source "Hello.java" // 原始檔名稱
複製程式碼
.class
後面是 訪問修飾符和當前類,這裡類名用 LHello
表示。那麼這個 L
代表什麼呢?其實之前的 Class 檔案中也出現過這種表示方法,JVM 的位元組碼指令和 Dalvik 的位元組碼指令有很多地方都是類似的。Java 中分為基本型別和引用型別,DalviK 對這兩種型別分別有不同的描述方法。對於基本型別和 Void 型別,都是用一個大寫字母表示。對於引用型別,使用字母 L
加上物件型別的全限定名來表示。具體規則如下表所示:
Java 型別 | 型別描述符 |
---|---|
char | C |
byte | B |
short | S |
int | I |
long | J |
float | F |
double | D |
boolean | Z |
void | V |
物件 | L |
陣列 | [ |
基本型別的表示很簡單,int 用 I
表示即可。物件的表示,如上圖中父類 Object 的表示方法 Ljava/lang/Object;
,再比如 String 型別,就用 Ljava/lang/String
表示。
對於陣列,DalviK 有特殊的表示方法 [
後面跟上陣列元素的型別。int[]
的表示方式就是 [I
, String[]
的表示方法是 [Ljava/lang/String;
。二維陣列用 [[
表示,[[Ljava/lang/String
就是指 String[][]
,以此類推。
欄位表示
# static fields
.field private static HELLO_WORLD:Ljava/lang/String;
複製程式碼
smali 中的欄位以 .field
開頭,並有 # static field(靜態欄位)
或者 # instance field(例項欄位)
的註釋。.field
之後分別是 訪問修飾符,欄位名稱,冒號以及欄位型別描述符。這句 smali 就宣告瞭一個 String
型別名稱為 HELLO_WORLD
的私有靜態欄位。
方法表示
smali 中的方法以 .method
開頭。Hello.smali
中包含了三個方法,clinit
, init
和 main
方法。main
方法是我們自己編寫的,而 clinit
和 init
方法則是 javac 編譯時生成的。下面進行逐一分析:
clinit
.method static constructor <clinit>()V
.registers 1
.prologue
.line 3
const-string v0, "Hello World!"
sput-object v0, LHello;->HELLO_WORLD:Ljava/lang/String;
return-void
.end method
複製程式碼
clinit
方法會進行靜態變數的初始化,靜態程式碼塊的執行等操作,該方法在類被載入的時候呼叫。逐行分析該方法的執行邏輯:
-
.registers 1 :
該方法需要使用的暫存器數量。之前已經提到,DalviK VM 是基於暫存器的,位元組碼可以使用的虛擬暫存器個數可達 65536 個,每個暫存器 32 位,64 位的資料使用相鄰兩個暫存器表示。最終,所有的虛擬暫存器都會被對映到真實的物理暫存器上。一般情況下,我們使用字母v
表示區域性變數使用的暫存器,使用字母p
表示引數所使用的暫存器,且區域性變數使用的暫存器排列在前,引數使用的暫存器排列在後。這裡就表示clinit
方法僅使用了一個暫存器。 -
.prologue :
表示邏輯程式碼的開始處 -
.line 3 :
表示 java 原始檔中的行數 -
const-string v0, "Hello World!"
: 將字串Hello World!
的引用移到暫存器v0
中。 -
sput-object v0, LHello;->HELLO_WORLD:Ljava/lang/String;
: 字首s
的sput
和sget
指令用於靜態欄位的讀寫操作。將暫存器v0
儲存的字串引用賦值給HELLO_WORLD
欄位,結合上一句位元組碼,這裡完成了靜態變數HELLO_WORLD
的賦值工作,也驗證了clinit
方法的確進行了靜態變數的初始化。 -
return-void
: 表示該方法無返回值 -
.end method
: 表示方法執行結束
到這裡,clinit
方法就執行結束了。下面分析 init
方法。
init
.method public constructor <init>()V
.registers 1
.prologue
.line 1
invoke-direct {p0}, Ljava/lang/Object;-><init>()V
return-void
.end method
複製程式碼
其餘各項與 clinit
方法相同,我們直接看執行的程式碼邏輯:
invoke-direct {p0}, Ljava/lang/Object;-><init>()V
複製程式碼
invoke-direct 用於呼叫非 static 直接方法(也就是說,本質上不可覆蓋的例項方法,即 private 例項方法或建構函式)。顯然,這裡呼叫的是預設建構函式。
main
.method public static main([Ljava/lang/String;)V
.registers 3
.prologue
.line 6
sget-object v0, Ljava/lang/System;->out:Ljava/io/PrintStream;
sget-object v1, LHello;->HELLO_WORLD:Ljava/lang/String;
invoke-virtual {v0, v1}, Ljava/io/PrintStream;->println(Ljava/lang/String;)V
.line 7
return-void
.end method
複製程式碼
最後是 main
方法,從上述 smali 程式碼我們可以看到 main
方法使用了 3 個暫存器,無返回值(那是肯定的),執行的具體程式碼是下面三行:
sget-object v0, Ljava/lang/System;->out:Ljava/io/PrintStream;
sget-object v1, LHello;->HELLO_WORLD:Ljava/lang/String;
invoke-virtual {v0, v1}, Ljava/io/PrintStream;->println(Ljava/lang/String;)V
複製程式碼
sget
的用法在 clinit
方法中解釋過,表示靜態欄位的讀取。第一句程式碼,獲取類 System
的靜態欄位 out
,其型別是 Ljava/io/PrintStream
,並將其引用賦給暫存器 v0
。第二句程式碼獲取在 clinit
方法中已經初始化的靜態欄位 HELLO_WORLD
,並將其引用賦給暫存器 v1
。第三句中使用了 invoke-virtual
指令,invoke-virtual
呼叫正常的虛方法(該方法不是 private、static 或 final,也不是建構函式),之後通常會跟上 {}
,{}
之中的第一個暫存器通常是指向當前例項物件,如 v0
就是指向 System.out
物件,後面的內容才是該方法真正的引數,如 v1
。{},
之後就是要執行的方法的描述,如 Ljava/io/PrintStream;->println(Ljava/lang/String;)V
,指的就是 PrintStream
物件的 println
方法。綜上,這三句位元組碼執行的就是 System.out.println(HELLO_WORLD);
。
到這裡,Hello.smali
檔案就解析完了。當然,我們在反編譯過程中遇到的任何一個 smali 檔案肯定都要比這個複雜的多。Android 官網也對 Dalvik 位元組碼的指令集進行了歸納,地址是 source.android.google.cn/devices/tec…。在閱讀過程中遇到不熟悉的指令,都可以在這個頁面進行查詢。
最後再介紹一個 java
轉 smali
的快捷方式,在 IDEA
或者 Android Studo
中安裝外掛 java2smali
,在 Build
選單欄下會出現 Compile to smali
選項,可以迅速將 java 程式碼轉化成 smali 程式碼。在我們學習 smali 的過程中,碰到不確定的內容,可以先寫好 java 程式碼,再轉成 smali 程式碼進行對照學習。
最後貼一個完整的帶註釋的 Hello.smali
檔案:
.class public LHello; // 類名
.super Ljava/lang/Object; // 父類名
.source "Hello.java" // 原始檔名稱
# static fields // 表示靜態欄位 private static String HELLO_WORLD
.field private static HELLO_WORLD:Ljava/lang/String;
# direct methods
.method static constructor <clinit>()V // clinit 方法
.registers 1 // 使用一個暫存器 v0
.prologue // 方法開始
.line 3 // 原始碼行數
const-string v0, "Hello World!" // 將 "Hello World!"放入暫存器 v0
// 靜態欄位賦值,將暫存器v0儲存的值賦給 HELLO_WORLD
sput-object v0, LHello;->HELLO_WORLD:Ljava/lang/String;
return-void // 無返回值
.end method // 方法結束
.method public constructor <init>()V // init 方法
.registers 1 // 使用一個暫存器
.prologue // 方法開始
.line 1
invoke-direct {p0}, Ljava/lang/Object;-><init>()V // 呼叫構造方法
return-void // 無返回值
.end method // 方法結束
.method public static main([Ljava/lang/String;)V // main 方法
.registers 3 // 使用 3 個暫存器
.prologue // 方法開始
.line 6
// 獲取靜態物件,System.out,其型別為 java.io.PrintStream,賦給 v0
sget-object v0, Ljava/lang/System;->out:Ljava/io/PrintStream;
// 獲取靜態物件, HELLO_WORLD,其型別為 java.lang.String,賦給 v1
sget-object v1, LHello;->HELLO_WORLD:Ljava/lang/String;
// 執行 v0 所儲存的物件的 println() 方法,v1儲存的是方法的引數
invoke-virtual {v0, v1}, Ljava/io/PrintStream;->println(Ljava/lang/String;)V
.line 7
return-void // 無返回值
.end method // 方法結束
複製程式碼
下一篇 簡單學習一些常見語法的 smali 表示,比如數學運算,if-else,迴圈等等。
文章同步更新於微信公眾號:
秉心說
, 專注 Java 、 Android 原創知識分享,LeetCode 題解,歡迎關注!