簡單介紹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