Class 檔案格式詳解
Write once, run everywhere!
,我們都知道這是 Java
著名的宣傳口號。不同的作業系統,不同的 CPU 具有不同的指令集,如何做到平臺無關性,依靠的就是 Java 虛擬機器。計算機永遠只能識別 0
和 1
組成的二進位制檔案,虛擬機器就是我們編寫的程式碼和計算機之間的橋樑。虛擬機器將我們編寫的 .java
源程式檔案編譯為 位元組碼
格式的 .class
檔案,位元組碼是各種虛擬機器與所有平臺統一使用的程式儲存格式,這是平臺無關性的本質,虛擬機器在作業系統的應用層實現了平臺無關。實際上不僅僅是平臺無關,JVM 也是 語言無關
的。常見的 JVM 語言,如 Scala
,Groovy
,再到最近的 Android 官方開發語言 Kotlin
,經過各自的語言編譯器最終都會編譯為 .class
檔案。適當的瞭解 Class 檔案格式,對我們開發,逆向都是大有裨益的。
Class 檔案結構
class 檔案的結構很清晰,如下所示:
ClassFile {
u4 magic;
u2 minor_version;
u2 major_version;
u2 constant_pool_count;
cp_info constant_pool[constant_pool_count-1];
u2 access_flags;
u2 this_class;
u2 super_class;
u2 interfaces_count;
u2 interfaces[interfaces_count];
u2 fields_count;
field_info fields[fields_count];
u2 methods_count;
method_info methods[methods_count];
u2 attributes_count;
attribute_info attributes[attributes_count];
}
複製程式碼
其中的 u2
u4
分別代表 2 和 4 位元組的無符號數。另外需要注意的是 classs 檔案的多位元組資料是按照大端表示法(big-endian)進行儲存的,在解析的時候需要注意。
瞭解一種檔案結構最好的方法就是去解析它,包括之後的 AndroidManifest.xml
、dex
等等,都會通過程式碼直接解析來學習它們的檔案結構。下面就以最簡單的 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
檔案。這裡推薦一個利器 010Editor
,檢視分析各種二進位制檔案結構十分方便,相比 Winhex
或者 Ghex
更加智慧。下面是通過 010Editor
開啟 Hello.class
檔案的截圖:
檔案結構一目瞭然。點選各個結構也會自動標記處上半部分檔案內容中對應的十六進位制資料,相當方便。下面就對照著結構目錄逐項解析。
magic
class 檔案的魔數很有意思, 0xCAFEBABE
,也許 Java 創始人真的很熱衷於咖啡吧,包括 Java 的圖示也是一杯咖啡。
minor_version && major_version
minor_version
是次版本號,major_version
是主版本號。每個版本的 JDK 都有自己特定的版本號。高版本的 JDK 向下相容低版本的 Class 檔案,但低版本不能執行高版本的 Class 檔案,即使檔案格式沒有發生任何變化,虛擬機器也拒絕執行高於其版本號的 Class 檔案。上面圖中主版本號為 52,代表 JDK 1.8,在 JDK 1.8 以下的版本是無法執行的。
constant_pool
常量池是 Class 檔案中的重中之重,存放著各種資料型別,與其他專案關聯甚多。在解析的時候,我們可以把常量池看成一個陣列或者集合,既然是陣列或者集合,就要先確定它的長度。首先看一下 Hello.class
檔案的常量池部分的截圖:
常量池部分以一個 u2 型別開頭,代表常量池中的容量,上例中為 34
。需要注意的是,常量池的下標是從 1
開始的,也就代表該 Class 檔案具有 33 個常量。那麼,為什麼下標要從 1 開始呢?目的是為了表示在特定情況下 不引用任何一個常量池項
,這時候下標就用 0
表示。
下表是常量池的一些常見資料型別:
類 型 | 標 志 | 描 述 |
---|---|---|
CONSTANT_Utf8_info | 1 | UTF-8 編碼的字串 |
CONSTANT_Integer_info | 3 | 整型字面量 |
CONSTANT_Float_info | 4 | 浮點型字面量 |
CONSTANT_Long_info | 5 | 長整型字面量 |
CONSTANT_Double_info | 6 | 雙精度浮點型字面量 |
CONSTANT_Class_info | 7 | 類或介面的符號引用 |
CONSTANT_String_info | 8 | 字串型別字面量 |
CONSTANT_Fieldref_info | 9 | 欄位的符號引用 |
CONSTANT_Methodref_info | 10 | 類中方法的符號引用 |
CONSTANT_InterfaceMethodref_info | 11 | 介面中方法的符號引用 |
CONSTANT_NameAndType_info | 12 | 欄位或方法的部分符號引用 |
CONSTANT_MethodHandle_info | 15 | 表示方法控制程式碼 |
CONSTANT_MethodType_info | 16 | 標識方法型別 |
CONSTANT_InvokeDynamic_info | 18 | 表示一個動態方法呼叫點 |
常量池的資料型別有十幾種,各自都有自己的資料結構,但是他們都有一個共有屬性 tag
。tag
是標誌位,標記是哪一種資料結構。我們在這裡不針對每種資料結構進行分析,就按照 Hello.class
檔案的常量池結構粗略分析一下。
首先看 Hello.class 檔案常量池的第一項:
這是一個 CONSTANT_Methodref_info
, 表示類中方法的一些資訊,它的資料結構是 tag
class_index
name_and_type_index
。tag
標識為 10。class_index
的值是 7,這是一個常量池索引,指向常量池中的某一項資料。注意,常量池的索引是從 1 開始的,所以這裡指向的其實是第 6 個資料項:
CONSTANT_Methodref_info
的 class_index
指向的資料項永遠是 CONSTANT_Class_info
,tag
標識為 7,代表的是類或者介面,它的 name_index
也是常量池索引,上圖中可以看到是第 26 項:
這是一個 CONSTANT_Utf8_info
,從名稱就可以看出來這是一個字串,length
屬性標識長度,後面的 byte[]
代表字串內容。從 010Editor 解析內容可以看到這個字串是 java/lang/Object
,表示類的全限定名。
接著回到常量池第一項 CONSTANT_Methodref_info
,剛才看了 name_index
屬性,另一個屬性是 name_and_type_index
,它永遠指向 CONSTANT_NameAndType_info
,表示欄位或者方法,它的值為 19,我們來看一下常量池的第 18 項:
CONSTANT_NameAndType_info
的 tag 標識為 12,具有兩個屬性, name_index
和 descriptor_index
,它們指向的均是 CONSTANT_Utf8_info
。name_index
表示欄位或者方法的非限定名,這裡的值是 <init>
。descriptor_index
表示欄位描述符或者方法描述符,這裡的值是 ()V
。
到這裡,常量池的第一個資料項就分析完了,後面的每一個資料項都可以按照這樣分析。到這裡就可以看到常量池的重要性了,包含了 Class 檔案的大部分資訊。
接著繼續分析常量池之後的檔案結構,先總體瀏覽一下:
access_flags
訪問表示,表示類或介面的訪問許可權和屬性,下圖為一些訪問標誌的取值和含義:
標誌名稱 | 標 志 值 | 含 義 |
---|---|---|
ACC_PUBIC | 0x0001 | 是否為 public 型別 |
ACC_FINAL | 0x0010 | 是否宣告為 final |
ACC_SUPER | 0x0020 | JDK1.0.2 之後編譯出來的類這個標誌都必須為真 |
ACC_INTERFACE | 0x0200 | 是否為介面 |
ACC_ABSTRACT | 0x0400 | 是否為 abstract 型別 |
ACC_SYNTHETIC | 0x1000 | 標記這個類並非由使用者程式碼產生 |
ACC_ANNOTATION | 0x2000 | 是否為註解 |
ACC_ENUM | 0x4000 | 是否為列舉型別 |
Hello.class
檔案的訪問標記為十進位制 33,Hello.java
就是一個普通的類,由 public
修飾,所以應該具有 ACC_PUBIC
和 ACC_SUPER
這兩個標記, 0x0001 + 0x0010
正好為十進位制 33。
this_class && super_class && interfaces_count && interfaces[]
為什麼把這幾項放在同一節進行解釋?因為這幾項資料共同確定了該類的繼承關係。 this_class
代表類索引,用於確定該類的全限定名,圖中可以看到索引值為 6 ,指向常量池中第 5 個資料項,這個資料項必須是 CONSTANT_Class_info
,查詢常量池可以看到類名為 Hello
,代表當前類的名字。super_class
表示父類索引, 同樣也是指向 CONSTANT_Class_info
,值為 java/lang/Object
。我們都知道,Object 是 java 中唯一一個沒有父類的類,因此它的父類索引為 0 。
super_class
之後緊接的兩個字元是 interfaces_count
,表示的是該類實現的介面數量。由於 Hello.java
未實現任何介面,所以該值為 0。如果實現了若干介面,這些介面資訊將儲存在之後的 interfaces[]
之中。
fields_count && field_info
欄位表集合,表示該類中宣告的變數。fields_count
指明變數的個數,fields[]
儲存變數的資訊。注意,這裡的變數指的是成員變數,並不包括方法中的區域性變數。再回憶一下 Hello.java
檔案,僅有一個變數:
private static String HELLO_WORLD = "Hello World!";
複製程式碼
上面這行變數宣告告訴我們,有一個叫 HELLO_WORLD
的 String
型別變數,且是 private static
修飾的。所以 fields[]
所需儲存的也正是這些資訊。先來看下 filed_info
的結構:
access_flags
是訪問標誌,表示欄位的訪問許可權和基本屬性,和之前分析過的類的訪問標誌是很相似的。下表是一些常見的訪問標誌的名稱和含義:
標誌名稱 | 標 志 值 | 含 義 |
---|---|---|
ACC_PUBIC | 0x0001 | 是否為 public |
ACC_PRIVATE | 0x0002 | 是否為 private |
ACC_PROTECTED | 0x0004 | 是否為 protected |
ACC_STATIC | 0x0008 | 是否為 static |
ACC_FINAL | 0x0010 | 是否為 final |
ACC_VOLATILE | 0x0040 | 是否為 volatile |
ACC_TRANSIENT | 0x0080 | 是否為 transient |
ACC_SYNTHETIC | 0x1000 | 是否由編譯器自動生成 |
ACC_ENUM | 0x4000 | 是否為 enum |
private static
即為 0x0002 + 0x0008
,等於十進位制的 10。
name_index
為常量池索引,表示欄位的名稱,檢視常量池第 7 項,是一個 CONSTANT_Utf8_info
,值為 HELLO_WORLD
。
descriptor_index
也是常量池索引,表示欄位的描述,檢視常量池第 8 項,是一個 CONSTANT_Utf8_info
,值為 Ljava/lang/String;
。
這樣便得到了這個欄位的完整資訊。在圖中還可以看到,descriptor_index
後面還跟著 attributes_count
,這裡的值為 0,否則後面還會跟著 attributes[]
。關於屬性表後面還會專門分析到,這裡先不做分析。
methods_count && method_info
緊接著欄位表集合的是方法表集合,表示類中的方法。方法表集合和欄位表集合的結構很相似,如下圖所示:
access_flags
表示訪問標誌,其標誌值和欄位表略有不同,如下所示:
標誌名稱 | 標 志 值 | 含 義 |
---|---|---|
ACC_PUBIC | 0x0001 | 是否為 public |
ACC_PRIVATE | 0x0002 | 是否為 private |
ACC_PROTECTED | 0x0004 | 是否為 protected |
ACC_STATIC | 0x0008 | 是否為 static |
ACC_FINAL | 0x0010 | 是否為 final |
ACC_SYNCHRONIZED | 0x0020 | 是否為 sychronized |
ACC_BRIDGE | 0x0040 | 是否由編譯器產生的橋接方法 |
ACC_VARARGS | 0x0080 | 是否接受不定引數 |
ACC_NATIVE | 0x0100 | 是否為 native |
ACC_ABSTRACT | 0x0400 | 是否為 abstract |
ACC_STRICTFP | 0x0800 | 是否為 strictfp |
ACC_SYNTHETIC | 0x1000 | 是否由編譯器自動產生 |
name_index
和 descriptor_index
與欄位表一樣,分別表示方法的名稱和方法的描述,指向常量池中的 CONSTANT_Utf8_info
項。方法中的具體程式碼儲存在之後的屬性表中,經編譯器編譯為位元組碼格式儲存。屬性表在下一節進行具體分析。
attributes_count && attribute_info
屬性表在之前已經出現過好幾次,包括欄位表,方法表都包含了屬性表。屬性表的種類很多,可以表示原始檔名稱,編譯生成的位元組碼指令,final 定義的常量值,方法丟擲的異常等等。在 《Java虛擬機器規範(Java SE 7)》中已經預定義了 21 項屬性。這裡僅對 Hello.class
檔案中出現的屬性進行分析。
首先來看下 Hello.class
檔案中緊跟在方法表之後的最後兩項。
attributes_count
宣告後面的屬性表長度,這裡為 1,後面跟了一個屬性。由上圖該屬性結構可知,這是一個定長的屬性,但是大部分屬性型別其實是不定長的。
attribute_name_index
是屬性名稱索引,指向常量池中的 CONSTANT_Utf8_info
,代表的是屬性的型別,該屬性為 17,所以指向常量池的第 16 項,查閱常量池,其值為 SourceFile
,表示這是一個 SourceFile
屬性,其屬性值為原始檔的名稱。
attribute_length
是屬性的長度,但不包含 attribute_name_index
和其本身,所以整個屬性的長度應該是 attribute_length + 6
。
sourcefile_index
是原始檔名稱索引,指向常量池中的 CONSTANT_Utf8_info
,索引值為 18,即指向第 17 項,不難猜測,該項表示的字串就是原始檔名稱 Hello.java
。
這個屬性比較簡單,下面來看 main
方法表中的屬性表,該屬性表所代表的是 main
方法中的程式碼經過編譯編譯生成的位元組碼:
可以看到 main
方法的方法表中包含了一個屬性,其結構還是比較複雜的,下面進行逐項分析。
attribute_name_index
指向常量池第 11 項,其字串為 Code
,表示這是一個 Code
屬性。Code
屬性是 Class 檔案中最重要的屬性,它儲存的是 Java 程式碼編譯生成的位元組碼。
attribute_length
為 38,表示後面 38 個位元組都是該屬性的內容。
max_stack
代表了運算元棧深度的最大值。在方法執行的任意時刻,運算元棧都不會超過這個深度。虛擬機器執行的時候需要根據這個值來分配棧幀中的操作棧深度。
max_locals
代表了區域性變數表所需的儲存空間,以 slot
為單位。Slot
是虛擬機器為區域性變數分配記憶體所使用的最小單位。
code_length
指的是編譯生成的位元組碼的長度, 緊接著的 code
就是用來儲存位元組碼的。上圖中可以看到這裡的位元組碼長度是 10 個位元組,我們來看一下這 10 個位元組:
B2 00 02 B2 00 03 B6 00 04 B1
複製程式碼
位元組碼指令是由操作碼(Opcode)以及跟隨其後的所需引數構成的。操作碼是單位元組,代表某種特定操作,引數個數可能為 0。關於位元組碼指令集的指令描述,在 《Java 虛擬機器規範》 一書中有詳細介紹。
這裡我們接著分析上述位元組碼。首個操作符為 0xb2
,查表可得代表的操作是 getstatic
,獲取類的靜態欄位。其後跟著兩位元組的索引值,指向常量池中的第 2 項資料,這是一個 CONSTANT_Fieldref_info
,表示的是一個欄位的符號引用。按照上面的分析方式分析一下這個欄位,它的類名是 java/lang/System
,名稱是 out
,描述符是 Ljava/io/PrintStream;
。由此可知,0xb20002
這段位元組碼的含義是獲取類 System
中型別為 Ljava/io/PrintStream
的靜態欄位 out
。
接著看下一個操作符,仍舊是 0xb2
,同上,分析可得這個欄位是類 Hello
中型別為 Ljava/lang/String
的靜態欄位 HELLO_WORLD
。
第三個操作符是 0xb6
,查表其代表的操作為 invokevirtual
,其含義是呼叫例項方法。後面緊跟兩個位元組,指向常量池中的 CONSTANT_Methodref_info
。檢視常量池中的第 4 項資料,分析可得其類名為 java/io/PrintStream
,方法名為 println
,方法描述符為 (Ljava/lang/String;)V
。這三個位元組的位元組碼執行的操作就是我們的列印語句了。
最後一個操作符是 0xb1
,代表的操作為 return
,表示方法返回 void
。到這裡,該方法就執行完畢了。
到這裡, Hello.class
的檔案結構就基本分析完了。我們再回顧一下 Class 檔案的基本結構:
魔數 | 副版本號 | 主版本號 | 常量池數量 | 常量 | 訪問標誌 | 類索引 | 父類索引 | 介面數量 | 介面表 | 欄位數量 | 欄位表 | 方法數量 | 方法表 | 屬性數量 | 屬性表
各個專案嚴格按照順序緊湊地排列在 Class 檔案之中,中間沒有任何分隔符。
另外我們也可以通過 javap
命令快速檢視 Class 檔案內容:
javap -verbose Hello.class
結果如下圖所示:
程式碼解析
Class 檔案格式的程式碼解析相對比較簡單,讀到檔案流逐項解析即可。
魔數和主副版本解析:
private void parseHeader() {
try {
String magic = reader.readHexString(4);
log("magic: %s", magic);
int minor_version = reader.readUnsignedShort();
log("minor_version: %d", minor_version);
int major_version = reader.readUnsignedShort();
log("major_version: %d", major_version);
} catch (IOException e) {
log("Parser header error:%s", e.getMessage());
}
}
複製程式碼
常量池解析:
private void parseConstantPool() {
try {
int constant_pool_count = reader.readUnsignedShort();
log("constant_pool_count: %d", constant_pool_count);
for (int i = 0; i < constant_pool_count - 1; i++) {
int tag = reader.readUnsignedByte();
switch (tag) {
case ConstantTag.METHOD_REF:
ConstantMethodref methodRef = new ConstantMethodref();
methodRef.read(reader);
log("%s", methodRef.toString());
break;
case ConstantTag.FIELD_REF:
ConstantFieldRef fieldRef = new ConstantFieldRef();
fieldRef.read(reader);
log("%s", fieldRef.toString());
break;
case ConstantTag.STRING:
ConstantString string = new ConstantString();
string.read(reader);
log("%s", string.toString());
break;
case ConstantTag.CLASS:
ConstantClass clazz = new ConstantClass();
clazz.read(reader);
log("%s", clazz.toString());
break;
case ConstantTag.UTF8:
ConstantUtf8 utf8 = new ConstantUtf8();
utf8.read(reader);
log("%s", utf8.toString());
break;
case ConstantTag.NAME_AND_TYPE:
ConstantNameAndType nameAndType = new ConstantNameAndType();
nameAndType.read(reader);
log("%s", nameAndType.toString());
break;
}
}
} catch (IOException e) {
log("Parser constant pool error:%s", e.getMessage());
}
}
複製程式碼
剩餘資訊解析:
private void parseOther() {
try {
int access_flags = reader.readUnsignedShort();
log("access_flags: %d", access_flags);
int this_class = reader.readUnsignedShort();
log("this_class: %d", this_class);
int super_class = reader.readUnsignedShort();
log("super_class: %d", super_class);
int interfaces_count = reader.readUnsignedShort();
log("interfaces_count: %d", interfaces_count);
// TODO parse interfaces[]
int fields_count = reader.readUnsignedShort();
log("fields_count: %d", fields_count);
List<Field> fieldList=new ArrayList<>();
for (int i = 0; i < fields_count; i++) {
Field field=new Field();
field.read(reader);
fieldList.add(field);
log(field.toString());
}
int method_count=reader.readUnsignedShort();
log("method_count: %d", method_count);
List<Method> methodList=new ArrayList<>();
for (int i=0;i<method_count;i++){
Method method=new Method();
method.read(reader);
methodList.add(method);
log(method.toString());
}
int attribute_count=reader.readUnsignedShort();
log("attribute_count: %d", attribute_count);
List<Attribute> attributeList = new ArrayList<>();
for (int i = 0; i < attribute_count; i++) {
Attribute attribute=new Attribute();
attribute.read(reader);
attributeList.add(attribute);
log(attribute.toString());
}
} catch (IOException e) {
e.printStackTrace();
}
}
複製程式碼
由於屬性種類眾多,這裡未對屬性就行詳細解析,僅為了加深對 Class 檔案結構的瞭解,相當於一個低配版的 javap 。
Class 檔案結構的基本瞭解就到這裡,文中相關檔案和 Class 檔案解析工程原始碼都在這裡, android-reverse。
下一篇開始學習 smali
語言,Smali 語法解析——Hello World
文章同步更新於微信公眾號:
秉心說
, 專注 Java 、 Android 原創知識分享,LeetCode 題解,歡迎關注!