Smali 語法解析——Hello World

秉心說TM發表於2018-12-06

通過上一篇文章的學習,我們都知道了 Java 程式碼是如何執行的。Java 編譯器將 .java 原始檔編譯為 .class 位元組碼檔案,JVM(Java虛擬機器)將位元組碼解釋為機器程式碼最終在目標機器上執行。而在 Android 中,程式碼是如何執行的呢 ?首先看下面這張圖:

JVM VS DVM

這裡的 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。

dexsmali 使用的工具是 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 , initmain 方法。main 方法是我們自己編寫的,而 clinitinit 方法則是 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; : 字首 ssputsget 指令用於靜態欄位的讀寫操作。將暫存器 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…。在閱讀過程中遇到不熟悉的指令,都可以在這個頁面進行查詢。

最後再介紹一個 javasmali 的快捷方式,在 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 題解,歡迎關注!

Smali 語法解析——Hello World