Android Linker简介

简单介绍Android linker的基础知识,基于Android 10分支。

linker的作用

考虑简单的HelloWorld程序。

$ tree .
.
|-- jni
|   |-- Android.mk
|   `-- helloworld.c
...

$ cat jni/helloworld.c
#include 

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内部实现影响。查找顺序的规则如下。

  1. 如果环境变量LD_LIBRARY_PATH=/path/to/dir1/:/path/to/dir2/被设置,则首先在环境变量指定的目录中查找;
  2. 如果库文件编译时使用了-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/]
  3. 在linker指定的默认路径中查找。不同的操作系统或者不同的linker实现,有不同的配置。Android 10系统上如果没有配置命名空间规则(实际都会配置,这里只是举个简单例子),则默认的查找路径如下:

     /system/lib64
     /odm/lib64
     /vendor/lib64

Android Linker 命名空间(namespace)

Android linker namespace从Android 7开始引入,到Android 10不断修改完善,主要用来解决两个需求:

  1. 禁止应用程序(apk)访问非公开的NDK库,改善Android碎片化导致的应用兼容问题。Android应用程序可以通过JNI使用native库函数,以前没有限制的时候,很多开发者为了实现各种需求,经常会使用不在NDK中的系统库。而这些库实际属于Android系统的私有库,其API/ABI会随着Android版本不断变化,不保证向后兼容,而Android系统碎片化又非常严重,导致严重的应用兼容性问题;
  2. 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中查找。

Android Linker简介_第1张图片

当运行一个可执行程序的时候,系统根据一个配置文件(/system/etc/ld.config..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官网)。

Android Linker简介_第2张图片

当执行一个可执行程序的时候,linker在可执行程序所属的namespace中开始查找;或者当调用dlopen加载一个库文件的时候,linker在调用函数所属可执行程序或库所在的namespace开始查找。查找顺序如下。

  1. 首先在该namespace中查找,查找顺序如前所述,先在ld_library_paths中查找, 对应LD_LIBRARY_PATH环境变量,然后查找库文件RUN_PATH指定的目录,最后在default_library_paths中查找。如果在RUN_PATH中找到,或者找到的库文件是符号链接,则进一步检查实际的库文件是否在white_listed, ld_library_paths, default_library_paths, permitted_paths这几个目录中,如果不在则不允许加载
  2. 如果1中没有找到,则在关联的namespace中查找,查找顺序同1. 可以指定在关联的namespace中做完整的查找,或者只在一个库文件列表中查找
  3. 如果以上两步都没有找到,则返回失败,即不会递归查找关联namespace的关联namespace。

符号解析

Linker将所有依赖涉及的库文件全部加载到进程的内存地址空间之后,开始解析符号。这个过程就比较直观了,大致过程如下:从可执行程序或者dlopen要加载的库开始,按照BFS顺序遍历每个加载的库文件;对于每个库文件,遍历所有的重定向表,对于每个表项,在依赖的库中查找器符号,将符号地址写入表项指定的地址,完成符号解析工作。

Android Linker简介_第3张图片

代码浏览

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.
  • Google
  • Android Linker Namespace: Security Flaws

你可能感兴趣的:(Android Linker简介)