概要
SO文件是Linux下共享庫文件,它的文件格式被稱為ELF文件格式。由于Android操作系統的底層基于Linux系統,所以SO文件可以運行在Android平臺上。Android系統也同樣開放了C/C++接口供開發者開發Native程序。由于基于虛擬機的編程語言JAVA更容易被人反編譯,因此越來越多的應用將其中的核心代碼以C/C++為編程語言,并且以SO文件的形式供
上層JAVA代碼調用,以保證安全性。本文以SO文件格式為脈絡,梳理SO文件格式中每一部分的作用與其背后所隱含的技術(注:本文對SO文件的格式分析基于Android平臺ARM-V7架構)。
SO文件格式綜述
SO文件格式即ELF文件格式,它是Linux下可執行文件,共享庫文件和目標文件的統一格式。根據看待ELF文件的不同方式,ELF文件可以分為鏈接視圖和裝載視圖。鏈接視圖是鏈接器從鏈接的角度看待靜態的ELF文件。從鏈接視圖看ELF文件,ELF文件由多個section組成,不同的section擁有不同的名稱,權限。而裝載視圖是操作系統從加載ELF文件到內存的角度看待動態的ELF文件。從裝載視圖看ELF文件,ELF文件由多個segment,每一個segment都擁有不同的權限,名稱。實際上如上圖所示,一個segment是對多個具有相同權限的section的集合。
ELF頭表
ELF頭表記錄了ELF文件的基本信息,包括魔數,目標文件類型(可執行文件,共享庫文件或者目標文件),文件的目標體系結構,程序入口地址(共享庫文件為此值為0),然后是section表大小和數目,程序頭表的大小和數目,分別對應的是鏈接視圖和裝載視圖。
Section表
Section表記錄了每一個Section的基本信息,名稱,類型,字節數,虛擬地址偏移和文件偏移。文件偏移指的是在ELF文件中,Section距離ELF文件起始位置的字節數,而虛擬地址偏移指的是當此section被加載到內存中后,該Section距離ELF起始位置的字節數。由于有些section只存在于文件中,而不會被系統加載到內存中,因此虛擬地址偏移可能為0.
程序頭表
程序頭表是裝載視圖下,系統進行segment解析的入口,它給出了每一個segment的類型,文件偏移,虛擬地址偏移和對齊等。通過對程序頭表的遍歷,我們可以得到ELF文件所有的segment。
字符串表 .strtab
字符串表記錄了ELF文件中的每一個常量字符串值,以"\0"標識字符串結尾。
靜態鏈接
在我們的程序中,經常會在一個文件中引用其他文件中的函數和變量,當鏈接器將這多個文件組合成一個可執行文件時,就需要對這些引用進行重定位,否則,系統就無法判斷出這些引用的地址,程序也就無法正常運行。
舉個栗子:
Android NDK開發中,我們經常使用動態注冊函數的方式,將JAVA中調用的native函數與JNI中某一函數進行關聯,一般的源代碼如下(節選):
#define LOG_TAG "JNITEST_NATIVE"
#define LOGD(fmt, args...) ;__android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, fmt, ##args)
static JNINativeMethod methods[] = {
{"getStringFromNative", "()Ljava/lang/String;", (void*)native_hello}
};
int jniRegisterNativeMethods(JNIEnv* env,
const char* className,
const JNINativeMethod* gMethods,
int numMethods)
{
jclass clazz;
int tmp;
LOGD("Registering %s natives\n", className);
clazz = (*env)->FindClass(env, className);
if (clazz == NULL) {
LOGD("Native registration unable to find class '%s'\n", className);
return -1;
}
if ((tmp= (*env)->RegisterNatives(env, clazz, gMethods, numMethods)) < 0) {
LOGD("RegisterNatives failed for '%s', %d\n", className, tmp);
return -1;
}
return 0;
}
我們在Android.mk中將其編譯成共享庫SO:
LOCAL_PATH:= $(call my-dir)
include $(CLEAR_VARS)
LOCAL_SRC_FILES:= m.c
LOCAL_MODULE := hello
LOCAL_LDLIBS := -llog
LOCAL_CFLAGS := -DDEBUG -O0
include $(BUILD_SHARED_LIBRARY)
使用NDK提供的工具鏈對生成的.o文件代碼段進行反編譯
arm-linux-androideabi-objdumop.exe -d m.o
產生的部分代碼如下所示:
……
1a: a902 add r1, sp, #8
1c: 4a10 ldr r2, [pc, #64] ; (60 <JNI\_OnLoad+0x60>)
1e: 4798 blx r3
20: 1c03 adds r3, r0, #0
22: 2b00 cmp r3, #0
……
3e: 4b08 ldr r3, [pc, #32] ; (60 <JNI\_OnLoad+0x60>)
40: 9303 str r3, [sp, #12]
42: 4b08 ldr r3, [pc, #32] ; (64 <JNI\_OnLoad+0x64>)
44: 447b add r3, pc
46: 1c19 adds r1, r3, #0
48: 4b07 ldr r3, [pc, #28] ; (68 <JNI\_OnLoad+0x68>)
4a: 447b add r3, pc
4c: 1c1a adds r2, r3, #0
4e: 9b03 ldr r3, [sp, #12]
50: 2003 movs r0, #3
52: f7ff fffe bl 0 <__android_log_print>
……
可以看到,在目標文件中,對于目標文件之外的函數,例如__android_log_print函數的調用地址為0,很明顯,這是一個非法地址。這表明在目標中,對文件外符號的引用是非法的,而重定位就是要解決非法引用的問題。
靜態鏈接指的是在程序加載到內存之前,在鏈接成可執行文件時就對這些引用進行重定位。為了符號符號讓鏈接器能夠判斷哪些符號需要被重定位以及在代碼段和數據段中的具體位置,例如上面最終生成的目標文件中重定位表中必然有一項,記錄了__android_log_print符號,并且記錄了它需要被重定位的地方為代碼段JNI_OnLoad函數中的52字節處。ELF文件用符號表和重定位表分別記錄了這些數據。在Android NDK開發中,可以在Android,mk中添加命令 include $(BUILD_STATIC_LIBRARY)
將源文件編譯成靜態庫。
符號表 .symtab
符號表是ELF文件中的非常重要的表,因為符號可以認為是鏈接多個模塊的粘合劑,通過對符號的引用解析,系統才能將多個目標文件組成一個完整的可執行文件。符號表主要記錄了全局符號(定義在本目標文件中的全局符號,可以被其他目標文件引用)和外部符號(本目標文件中引用的全局符號,卻沒有定義在本目標文件中)。符號表的結構是一個Elf32_Sym類型的數組。Elf32_Sym結構體保存了符號名,符號值,符號大小,符號類型和綁定信息和符號所在的段。
重定位表
靜態鏈接下需要對符號的引用進行重定位,并且ELF文件中對符號的引用可能出現在代碼段,也可能出現在數據段,因此重定位表分為了代碼段重定位表和數據段重定位表,分別記錄引用的符號名和所在的偏移地址。因此,重定位表的格式就是記錄符號名和重定位地址的數組。
動態鏈接
靜態鏈接解決重定位問題實在程序被載入內存之前,而動態鏈接解決這一問題的時機是在程序被載入內存之后。這與靜態鏈接相比,有幾個明顯的優勢:首先是節省內存空間。如果一個程序采用靜態鏈接的方式解決重定位問題,如果程序用到了庫函數,那么每一個程序的虛擬內存空間都擁有一份自己私有的靜態庫。那么在某一時刻,計算機的物理內存中就會存在內容完全相同的多份靜態庫,這無疑浪費內存空間。其次,靜態鏈接的程序一旦有更新,甚至是微小的更新,都需要重新編譯打包鏈接,用戶也需要進行全量的更新。最后,動態鏈接的方式,將程序拆分成了多個模塊,各個模塊之間可以獨立開發,降低了程序各模塊之間的耦合度。
為了適應動態鏈接的需要,ELF文件中增加了多個段。
.dynamic段
typedef struct {
Elf32 _Sword d_tag;
union {
Elf32_Word d_val;
Elf32_Addr d_ptr;
} d_un;
} Elf32_Dyn;
d_val 此 Elf32_Word 對象表示一個整數值,可以有多種解釋。
d_ptr 此 Elf32_Addr 對象代表程序的虛擬地址。
對象表示一個整數值,可以有多種解釋。d_ptr 此 Elf32_Addr 對象代表程序的虛擬地址
.dynamic段是動態鏈接中最重要的段,它記錄了和動態鏈接有關的段的類型,地址或者數值。
.interp段
Android下的動態鏈接器本質上就是一個SO文件,我們編寫的SO文件被系統加載之后,為了完成動態鏈接,系統還會把動態鏈接器也加載到內存中。所以在.interp段中就記錄了動態鏈接器的路徑。
.got段
如上文所說,動態鏈接可以節省空間,因為整個內存中只需要保存一份運行庫即可。但是,這有一個問題。雖然運行庫可以在內存中只保留一份,但是對于每一個獨立的進程,其運行庫在進程虛擬空間的位置可能不一樣,這樣就導致了代碼段或數據段對庫以外的符號的引用不能以絕對地址的形式寫死,否則的話,整個內存只存一份庫是不可能做到的。
因此,ELF文件提出了"位置無關代碼"的概念,將ELF代碼段中對庫外函數的引用全部移到.got段中,使得代碼段不包含對函數地址的絕對引用,以此保證代碼段可以被重復使用,而每一個進程都保有庫數據段的副本。
.got的格式就是存放地址的數組。那么鏈接器是如何找到相應符號在.got表中的位置的呢?這需要.rel.plt段的支持。
.plt段
在介紹.rel.plt段之前,對比靜態鏈接與動態鏈接我們需要知道.plt段的作用。我們會發現,動態鏈接將所有的重定位操作延遲到加載時處理,那么就難以避免的會降低程序執行的效率,試想,如果有1000個對外部模塊的函數引用,動態鏈接器就需要先解決這1000個函數引用,然后才開始執行程序。為此,鏈接器為了提升效率,采取了這樣一種策略:僅當函數被調用時,才會喚醒動態鏈接器解決重定位問題。
.plt段就是為了實現這種策略增加的段。增加了.plt段之后,代碼段中的地址就不再指向.got段而是指向了.plt段,再由.plt段指向.got段,具體過程如下:
.plt段是包含了若干數目的代碼片段組成的段,代碼段中的地址指向對應函數的.plt代碼段,代碼片段的第一行代碼就是間接調用.got表中對應函數地址,但是此時,.got表中的地址指向的是.plt中代碼片段的第二行代碼,而第二行以后的代碼作用就是調用動態鏈接器處理.got表中的地址。當再次調用此函數時,.plt中代碼段第一行代碼就可以正確的跳入函數地址執行相應的函數了。
但是,在Android平臺下,由于兼容性的限制,并沒有采用這種"延遲加載"的特性,所以一開始加載,.got表中的地址就是真實的函數調用地址。但是,Android下的ELF文件仍然保留了.plt表這種結構。
.rel.plt段
.rel.plt段也是重定位表,因此它的格式和重定位表一樣,記錄符號表索引和重定位地址。對函數調用的重定位。
.rel.dyn段
與.rel.plt,類似,.rel.dyn對數據引用進行重定位。
.dynsym段
動態鏈接符號表,專門存放動態里娜姐相關的符號。
.hash段
為了提高符號檢索的效率增加的段。它的結構如何所示:
bucket 數組包含 nbucket 個項目, chain 數組包含 nchain 個項目,下標都是從 0 開始。 bucket 和 chain 中都保存符號表索引。 Chain 表項和符號表存在對應。符號表項的數目應該和 nchain 相等,所以符號表的索引也可用來選取 chain 表項。哈希函數能夠接受符號名并且返回一個可以用來計算bucket的索引。
因此,如果哈希函數針對某個名字返回了數值 X ,則 bucket[X%nbucket] 給出了一個索引 y ,該索引可用于符號表,也可用于 chain 表。如果符號表項不是所需要的,那么 chain[y] 則給出了具有相同哈希值的下一個符號表項。我們可以沿著 chain 鏈一直搜索,直到所選中的符號表項包含了所需要的符號,或者 chain 項中包含值 STN_UNDEF。
哈希函數如下:
unsigned long
elf_hash (const unsigned char *name) {
unsigned long h = 0, g;
while (*name) {
h = (h << 4) + *name++;
if (g = h & 0xf0000000)
h ^= g >> 24;
h &= -g;
}
return h;
}
其他段
.init/.init_array
動態鏈接器在執行程序main函數之前會首先執行這兩個段中的代碼。先執行.init段中的代碼,再執行.init_array中函數指針指向的代碼。在Android NDK中,可以通過添加編譯器注釋 __attribute(constructor)
將某一函數寫入這兩個段中。
.fini/.fini_array
類似的,動態鏈接器最后會執行這兩個段中的代碼。在Android NDK中,通過添加編譯器注釋__attribute(destructor)
將某一函數寫入這兩個段。
參考:
《ELF文件格式分析》滕啟明 2003年5月
POC一期文檔資料