简单介绍Android linker的基础知识,基于Android 10分支。
linker的作用
考虑简单的HelloWorld程序。
$ tree .
.
|-- jni
| |-- Android.mk
| `-- helloworld.c
...
$ cat jni/helloworld.c
#include <stdio.h>
int main() {
puts("hello, world\n");
return 0;
}
$ ndk-build
install : helloworld => libs/arm64-v8a/helloworld
我们只需要调用puts
库函数来打印字符串到标准输出,不需要自己实现打印的功能。工具链(比如Android ndk,包括编译器和链接编辑器等)将源文件编译成动态可执行程序。puts
的代码在libc库中实现,不会编译到我们的HelloWorld程序当中,所以当运行HelloWorld程序的时候,libc库需要同时被加载到进程地址空间,这样main
函数才能调用puts
函数,这个工作由linker完成。现代操作系统大多默认配置ASLR,程序每次执行,libc库在内存地址空间中的加载地址是不固定的,即puts
函数的实际地址也是不固定的,所以编译器编译main
函数时不能直接引用puts
函数的地址,只能通过重定向机制来间接引用,可以简单理解成,main
函数通过一个指针来间接调用puts
函数,而linker负责在运行时查找puts
的实际加载地址,修改这个指针,使其指向正确的地址。
所以linker主要作用:加载可执行程序依赖的库;查找修改被引用的符号(称为符号解析或者重定向)。
实际上动态链接涉及非常多的细节,linker需要处理这些细节,比如调用每个库的初始化函数,处理符号的版本,库内部符号的解析等等,这里不做讨论。
Android linker程序
64位系统上,Android linker程序位于/system/bin/linker64
路径。其本身是一个动态可执行程序,能够直接运行。
$ file linker64
linker64: ELF 64-bit LSB shared object, ARM aarch64, version 1 (SYSV), dynamically linked, BuildID[md5/uuid]=22c1b90f715b68a629bd2c0113c02dae, not stripped
$ adb shell linker64
Usage: linker64 program [arguments...]
linker64 path.zip!/program [arguments...]
A helper program for linking dynamic executables. Typically, the kernel loads
this program because it's the PT_INTERP of a dynamic executable.
This program can also be run directly to load and run a dynamic executable. The
executable can be inside a zip file if it's stored uncompressed and at a
page-aligned offset.
如上描述,一般linker不是作为独立可执行程序运行,而是由kernel在运行其他可执行程序时调用。Android 可执行程序为ELF格式,ELF可执行程序有一个INTERP
类型的program header,指定linker程序的路径。当在命令行中运行一个ELF可执行程序的时候,比如我们在命令行shell中执行helloworld程序时adb shell /data/local/tmp/helloworld
,内核同时将helloworld和linker程序加载到内存,然后跳转到linker程序的入口函数执行,由linker负责完成动态连接过程:加载helloworld依赖的库libc等,查找puts
等函数的实际地址,修改main
函数对puts
的引用(重定向)。最后linker程序跳转到helloworld程序的入口处开始执行。看上去就像helloworld程序直接运行一样。
$ aarch64-linux-android-readelf -l libs/arm64-v8a/helloworld
...
Program Headers:
Type Offset VirtAddr PhysAddr
FileSiz MemSiz Flags Align
...
INTERP 0x0000000000000238 0x0000000000000238 0x0000000000000238
0x0000000000000015 0x0000000000000015 R 1
[Requesting program interpreter: /system/bin/linker64]
...
除了用于链接可执行程序,Android linker还提供了dlopen
系列函数的实现。Android系统上libdl.so中的dlopen
函数只是一个wrapper,实际功能实现在linker程序中。
// bionic/libdl/libdl.cpp, libdl中的wrapper函数
__attribute__((__weak__))
void* dlopen(const char* filename, int flag) {
const void* caller_addr = __builtin_return_address(0);
return __loader_dlopen(filename, flag, caller_addr);
}
// bionic/linker/dlfcn.cpp,linker中的实现
void* __loader_dlopen(const char* filename, int flags, const void* caller_addr) {
return dlopen_ext(filename, flags, nullptr, caller_addr);
}
查找并加载库
可执行程序依赖的库文件记录在ELF文件动态段中类型为NEEDED
的表项中,如下图。
$ aarch64-linux-android-readelf -d libs/arm64-v8a/helloworld
Dynamic section at offset 0xd88 contains 30 entries:
Tag Type Name/Value
0x0000000000000001 (NEEDED) Shared library: [libc.so]
0x0000000000000001 (NEEDED) Shared library: [libm.so]
0x0000000000000001 (NEEDED) Shared library: [libdl.so]
这里helloworld程序依赖三个库文件,分别是libc.so, libm.so, libdl.so。
被依赖的库文件,也可能依赖其他的库文件,Linker首先按照BFS顺序,加载这些库文件到进程的内存地址空间。但是这里NEEDED
表项记录的是文件名,没有包含完整路径,那么在哪里找到这些文件呢?另外,dlopen
函数参数指定要加载的库文件可以是绝对路径,也可以是不带路径的文件名,后者如何查找呢?Linker按照一定的顺序查找一些指定的目录,在这些目录中寻找库文件。Android linker在Android N版本上引入了一个命名空间的概念,使库文件的查找变得稍微复杂一下,但是基本的查找原则是一致的。这里先介绍引入命名空间之前的查找规则,然后讨论命名空间的概念,引入的原因,以及完整的查找规则。
Linker按照顺序在指定的一些目录中查找依赖的库文件,这个顺序受运行时的环境变量、编译时的参数,以及linker内部实现影响。查找顺序的规则如下。
如果环境变量
LD_LIBRARY_PATH=/path/to/dir1/:/path/to/dir2/
被设置,则首先在环境变量指定的目录中查找;如果库文件编译时使用了
-rpath=/path/to/dir1:/path/to/dir2
, 则在rpath参数指定的目录中查找。rpath指定的路径保存在ELF文件的动态段中的RUNTPATH
表项:$ cat jni/Android.mk
include $(CLEAR_VARS)
LOCAL_MODULE := test
LOCAL_SRC_FILES := testlib.c
LOCAL_LDFLAGS := -Wl,-rpath=/data/local/tmp/:/data/
include $(BUILD_SHARED_LIBRARY) $ ndk-build
...
$ aarch64-linux-android-readelf -d libs/arm64-v8a/libtest.so
Dynamic section at offset 0xdd8 contains 27 entries:
Tag Type Name/Value
...
0x000000000000001d (RUNPATH) Library runpath: [/data/local/tmp/:/data/]
在linker指定的默认路径中查找。不同的操作系统或者不同的linker实现,有不同的配置。Android 10系统上如果没有配置命名空间规则(实际都会配置,这里只是举个简单例子),则默认的查找路径如下:
/system/lib64
/odm/lib64
/vendor/lib64
Android Linker 命名空间(namespace)
Android linker namespace从Android 7开始引入,到Android 10不断修改完善,主要用来解决两个需求:
- 禁止应用程序(apk)访问非公开的NDK库,改善Android碎片化导致的应用兼容问题。Android应用程序可以通过JNI使用native库函数,以前没有限制的时候,很多开发者为了实现各种需求,经常会使用不在NDK中的系统库。而这些库实际属于Android系统的私有库,其API/ABI会随着Android版本不断变化,不保证向后兼容,而Android系统碎片化又非常严重,导致严重的应用兼容性问题;
- system与vendor分区的解耦,减少Android系统的碎片化。Android 8引入treble架构,将system分区与vendor分区解耦,这样在Android版本升级时,可以单独升级system分区,而不需要重新适配vendor分区,减少OEM厂商在Android大版本升级时的适配工作,加快Android大版本的升级速度。
一个namespace定义了一个范围,每个可执行程序或者库文件都属于一个namespace,linker查找依赖的库文件时,只在被依赖的可执行程序或库文件所属的namespace(及其直接关联的namespace)中查找。下图是namespace数据结构的一部分,ld_library_paths
对应前面所述的LD_LIBRARY_PATH
环境变量,default_library_path
对应前面所述linker默认路径。Linker在namespace中的查找顺序同之前我们介绍的顺序一致,即先在ld_library_paths
中查找,然后在RUN_PATH
指定的目录中查找,最后在default_library_paths
中查找。
当运行一个可执行程序的时候,系统根据一个配置文件(/system/etc/ld.config.<vndk_version>.txt
),为该程序创建对应的namespace。该配置文件分别定义了/system/bin/、/vendor/bin/等目录下可执行程序在运行时进程内的namespace配置。例如运行/system/bin/目录下的程序时,可执行程序所在的namespace的default_library_path
被设置为/system/lib64/
, /product/lib64
,即先从这两个目录开始查找依赖的库;而运行/vendor/bin/目录下的程序时,可执行程序所在的namespace的default_library_path
被设置为/odm/lib64
, /vendor/lib64
,即先从这两个目录查找依赖的库。
一个namespace可以关联多个其他namespace,当在这个namespace中找不到库文件的时候,可以在其直接关联的namespace中查找,如果仍然找不到,则不再继续。如果一个库文件在其调用者的namespace中找到,则该库也属于调用者的namespace,如果一个库文件在其调用者namespace的关联的某个namespace中找到,则该库属于关联的namespace。
system分区和vendor分区可执行程序运行时的namespace配置如下图所示(来源于Android官网)。
当执行一个可执行程序的时候,linker在可执行程序所属的namespace中开始查找;或者当调用dlopen
加载一个库文件的时候,linker在调用函数所属可执行程序或库所在的namespace开始查找。查找顺序如下。
- 首先在该namespace中查找,查找顺序如前所述,先在
ld_library_paths
中查找, 对应LD_LIBRARY_PATH
环境变量,然后查找库文件RUN_PATH
指定的目录,最后在default_library_paths
中查找。如果在RUN_PATH
中找到,或者找到的库文件是符号链接,则进一步检查实际的库文件是否在white_listed
,ld_library_paths
,default_library_paths
,permitted_paths
这几个目录中,如果不在则不允许加载 - 如果1中没有找到,则在关联的namespace中查找,查找顺序同1. 可以指定在关联的namespace中做完整的查找,或者只在一个库文件列表中查找
- 如果以上两步都没有找到,则返回失败,即不会递归查找关联namespace的关联namespace。
符号解析
Linker将所有依赖涉及的库文件全部加载到进程的内存地址空间之后,开始解析符号。这个过程就比较直观了,大致过程如下:从可执行程序或者dlopen
要加载的库开始,按照BFS顺序遍历每个加载的库文件;对于每个库文件,遍历所有的重定向表,对于每个表项,在依赖的库中查找器符号,将符号地址写入表项指定的地址,完成符号解析工作。
代码浏览
Android linker代码实现位于Android源码的bionic/linker目录。推荐Google最近发布的代码浏览工具:cs.android.com
libdl, namespace等相关代码主要在 bionic/libdl, art/libnativeloader(master分支)等工程目录下。
64位arm平台上,Linker入口函数在bionic/linker/arch/arm64/begin.S
find_libraries函数实现了linker加载库函数,解析符号的主要过程,是linker中极为重要的一个函数,也是理解linker运行原理的关键之一。
init_default_namespaces, CreateClassLoaderNamespace是创建linker namespace的代码逻辑。
Resources
阅读以下文档和代码,可以对Android linker有一个更好的理解。
- ELF
- cs.android.com
- vndk linker namespace
- man page of tools: readelf, gcc, ld, android-ndk, etc.
- Android Linker Namespace: Security Flaws