概要
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一期文档资料