入门Android上动态链接器的实现机制
背景知识
编译过程
一个c/c++源文件经过预处理,编译和汇编处理,就会生成一份目标文件(object file)。
静态链接器根据配置把多个目标文件(和库文件)“合并”打包后就会生成一个可执行文件或库文件。
库文件
库文件也分为静态库和动态库。
其中静态库文件在linux上一般都以.a结尾,就是多个目标文件的集合(ar archive)。静态库不能被进程加载执行,只可以在编译时作为一个依赖被别的模块使用,它里面的代码和数据就会被复制一份并编译进依赖它的那个模块的产物里。
一个静态库被不同的模块使用时,编译器会将静态库中的代码和数据复制一份打包到各个模块的编译产物中,这样多个模块产物都包含了相同的代码和数据,浪费磁盘空间。而如果修改了静态库里的一个函数的代码实现,就不得不重新编译其他所有依赖这个函数的模块,降低工程效率。
动态库文件,则是可以在运行时被加载并执行的,它在linux上的文件名一般以.so(shared object)结尾。
在编译一个模块时,如果它声明了依赖其他动态库文件,那么编译器不会将被依赖的动态库文件里的代码和数据打包到生成的程序文件中,而只需在程序文件头中加上对所依赖的动态库文件的名称即可。当一个程序被系统加载后,系统的动态链接器会解析程序依赖的所有其他的动态库文件,并将它们也加载到进程空间内以供主程序使用。于是,一个动态库程序可以被多个程序共享使用,却无需包含重复代码,并且当动态库文件里的代码改变后,也无需再重新编译依赖这个动态库的其他程序,即节省磁盘空间,也提高了工程效率。
ELF
linux上的程序一般都是以ELF格式存储的,在分析linker实现机制前,先看一下ELF文件的格式
elf文件比较常见的有下面几个类型:
- 可执行文件,executable file
- 动态链接库,shared object file
- 重定位文件
.cpp/.c文件编译后的中间产物 - core dump文件
保存进程crash前的内存快照,可以使用gdb加载,帮助诊断进程崩溃的原因
注:静态库在linux上不是elf格式的。
下图是elf的大致格式
我们重点关注其中用于实现动态链接的部分
GOT
GOT用于实现将程序对外部函数的依赖转换成对本程序内数据段中某个变量的依赖(相对位置偏移),保证了代码区中没有需要重定位的元素,系统就可以将代码区所在页(page)映射成只读保护的, 并可以在多个进程间共享一份物理内存。
PLT
PLT用于实现延迟加载 (Lazy Binding)特性,即对外部函数的重定位不必发生在加载一个动态链接库时,而是可以延迟到真正调用对一个外部函数时才发生,于是提高加载动态链接库的速度。
不过android并没有支持延迟加载,而是会在加载一个共享库时,完成所有的重定位工作。
Dynamic section
.dynamic段包含了用于动态链接需要的所有信息,例如上面重定位表和符号表的位置,当前elf文件依赖的其他动态连接库等等
重定位
总的来说重定位可以分为2类:
静态重定位
我们知道在一个cpp/c文件里,只要添加正确的头文件,就可以调用其他cpp/c源文件里声明/定义的函数。
编译器处理cpp/c源文件时,如果遇到不是在当前源文件里定义的函数(简称外部函数),生成的汇编指令里被调用外部函数的地址就是留空的,并会在当前源文件的目标文件里保存所依赖的外部函数的符号信息(符号表),以及当前目标文件里调用了这个外部函数的汇编代码位置(重定位表)。
到了编译最后的阶段,静态链接器在整合多个目标文件时,根据前面的重定位表里的保存的符号,搜索其他一起参与链接的目标文件或静态库文件的符号表,找到目标函数或变量的实际地址,然后填写到之前留空的地方,生成一个可执行程序。运行时重定位
而如果被依赖的外部函数是定义在其他的动态库里的话,那么就不能将该函数的地址填写到留空的地方,因为链接器不知道动态库在运行时会被加载到什么位置,所以这类函数的重定位工作只能推迟到运行时由操作系统的动态链接器来完成。
运行时重定位也可以分为2类,一类是涉及代码段的重定位,这种重定位会直接修改程序加载的elf文件中指令,所以程序代码占用的文件页mmap后不能在多个进程间共享,这种方式比较浪费物理内存,所以从Android 5.0开始,就不再支持了;
另一类是支持PIC的程序,当这个程序被加载后,动态链接器在给它进行重定位时,只会修改它的数据段,所以不同进程间可以共享程序文件的代码段,节省内存,也能加快新进程的启动速度。延伸阅读:Eli Bendersky的Linkers and Loaders系列博客
Global Symbol Interpose
另外,由于ELF格式的动态链接程序支持Global Symbol Interpose机制,就是在2+个不同的共享库文件里,可以存在相同名称的全局变量或函数, 当这2个共享库文件被同一个进程加载后,先被加载的那个共享库里的全局变量或函数会覆盖后面被加载的共享库里的同名全局函数或变量。
所以为了支持这样Global Symbol Interpose,编译器在生成代码时,就不会直接使用变量或函的地址,而是先将函数或变量的地址集中放到一张表里,因为这个表里实际存的是函数位置相对elf文件头的偏移(offset),所以就叫它Global Offset Table(GOT),最后在生成调用函数的指令时,就先从GOT里取出需要函数的地址(offset),再进行跳转,于是在运行时,系统的动态链接器就可以根据实际情况去改写GOT表里函数的实际地址,实现全局变量或函数的覆盖功能。
延伸阅读:
- Linker and Libraries Guide-Relocation Processing
- RFC: Add an “interposible” linkage type (and implement -fsemantic-interposition
- Protected Symbols
程序入口
在编码时,我们一般都以main()函数作为我们程序的入口,但其实main只是我们程序逻辑上的入口,而程序真实的入口函数被定义在elf文件的头信息中。
1 | $ man elf |
elf文件头中的e_entry就表示了程序的入口,它里面存储的时需要被执行的第一条指令在elf文件中的位置。
不过,当elf文件是一个动态连结库时,一般地,在elf文件中的program header中还会出现PT_INTERP
,也就是Program Interpreter,它里面实际存储的是另一个程序的文件名,通常也就是动态链接器的路径。
当系统加载一个elf文件后,发现PT_INTERP存在时,就不会立即跳转e_entry所指向的程序入口,而是会先加载并执行Program Interpreter所指的linker程序,由linker程序完成一些必要的初始化工作后,再将控制权归还给程序本身,即跳到e_entry所指程序入口处,继续执行。
可以使用readelf
工作查看程序的入口和interpreter信息
1 | $ prebuilts/gcc/linux-x86/aarch64/aarch64-linux-android-4.9/aarch64-linux-android/bin/readelf -lh \ |
Andorid linker入口
1 | $ prebuilts/gcc/linux-x86/aarch64/aarch64-linux-android-4.9/aarch64-linux-android/bin/readelf -h \ |
使用objdump
命令可以查看linker64程序的入口函数的汇编代码
1 | $ prebuilts/gcc/linux-x86/aarch64/aarch64-linux-android-4.9/aarch64-linux-android/bin/objdump -d \ |
__dl__start
定义在 bionic/linker/arch/arm64/begin.S
1 | #include <private/bionic_asm.h> |
机器代码里函数名与源文件不完全一致,是因为linker的编译规则了以下配置
1 | // bionic/linker/Android.bp |
所以linker项目里定义的函数在编译时都会自动被加__dl_
这样的前缀,所以使用objdump看到的就是__dl__start
。
找到了linker的入口函数,下面就进入正文,分析linker的实现。
Android linker初始化基本流程
_start()
-> linker_init() // 主要是linker自己的一些初始化工作:
-> prelink_image() // 1. 解析linker文件中dynamic段的各项,例如重定位表,符号表
-> link_image() // 2. 对全局变量,外部函数等地址进行重定位
-> return linker_init_post_relocation() // 初始化即将被执行的程序,返回该程序的入口地址
// 跳转到程序的入口,继续执行
1 | extern "C" ElfW(Addr) __linker_init(void* raw_args) { |
load_bias的计算逻辑看起来可以可能会比较“绕”,不过一般
prelink_image()
1 | bool soinfo::prelink_image() { |
prelink_image()的工具就是解析重定位表,字符串表、符号表等项目的地址以及它们各自的大小
不过,要让prelink_image()能正常工作,得首先知道dynamic段的地址
1 | // bionic/linker/linker_phdr.cpp |
link_image()
1 | bool soinfo::link_image(const soinfo_list_t& global_group, const soinfo_list_t& local_group, |
link_image()是个通用函数,它负责处理各类运行时重定位。 不过,从Android从5.0开始,为了增强系统安全性,aosp就全局启用了PIC编译选项,并机制加载未使用PIC编译的动态链接程序,所以现在linker代码里也就没有了对代码段的重定位表的处理逻辑。
linker作为第一个被kernel加载的程序,不能依赖其他动态链接库程序,所以linker的elf文件不包含plt段,也就没有plt.rela段。
relr段是google的工程师在2017年新提出的一个段类型,主要是解决开启PIC编译后,生成的程序文件的rela段增大的情况的。
为方便理解上面代码里对rela和relr段的处理逻辑,我们先看一段代码,当开启PIC选项后,编译器生成的访问全局函数或变量的基本流程:
1 | // test_global.cc |
使用gcc
编译test_global.cc
1 | $ gcc -shared -fPIC test_pic.cc -o libpic.so |
使用objdump
反编译libpic.so的指令
1 |
从上面的汇编代码,可以看到启用PIC编译后,对全局变量的访问流程:
- 从rela表取出一条保存了目标全局变量地址的entry地址
- 接着加载entry保存的数据,在这个例子中,也就是variable_a的地址,
最后才从读取到的variable_a地址加载variable_a里存储的数值100
注:访问非static变量需要经过rela表就是因为要支持global variable interpose机制!
作为对比,我们看一下关闭PIC时生成的汇编代码:
1
$ gcc -shared -fNO-PIC -mmodel=large test_pic.cc -o libpic.so
使用objdump
反编译libpic.so的指令
1 |
与PIC模式不同,关闭PIC后,对全局变量的访问流程十分简单,只要一条moveabs指令就完成了所有的工作。
这时候就可以发现,开启PIC后,每一处对全局变量的访问需要3条指令,而关闭PIC后,则只需要一条指令,所以就不难看出,启用PIC编译后的程序会变大不少。
那怎么降低启用PIC后生成的程序文件的大小呢?
我们知道当一个全局变量上同时声明了static关键字时,就表示这个全局变量只能在声明了该全局变量的源文件里使用,那么接下来看一个访问static全局变量的汇编代码流程:
1 | // test_static_global.cc |
使用gcc
编译test_static_global.cc
1 | $ gcc -shared -fPIC test_pic.cc -o libpic.so |
使用objdump
反编译libpic.so的指令
1 |
这时,访问staitc全局变量variable_b就不再经过rela表,利用相对寻址指令定位到variable_b变量在elf文件中的位置,直接从该地址读取出数值101。此时因为我们代码里明确了这个变量b是私有的,那么编译器生成更加优化的指令,只要2条汇编指令来就能完成读取变量b的操作,另外也同时去掉了rela里的一条记录。
另外我们还可以通过在全局变量或函数上添加attribute_visibility(hidden)来实现跟static相似的功能。
延伸阅读:Proposal for a new section type SHT_RELR
__linker_init_post_relocation: 主程序初始化
linker完成了自身的初始化后,就会跳转到程序代码的的真正入口
1 | $ prebuilts/gcc/linux-x86/aarch64/aarch64-linux-android-4.9/aarch64-linux-android/bin/readelf -h \ |
通过objdump -d
可以看到0xb000对应_start
函数:
1 | $ prebuilts/gcc/linux-x86/aarch64/aarch64-linux-android-4.9/aarch64-linux-android/bin/objdump -d \ |
_start()
里调用了_start_main()
函数,最终进入libc_init()
开始初始化libc