SONAME缺失
前幾天遇到了個比較詭異的鏈接問題,分析下來感覺挺有意思的。
背景是我們導入了供應商給的幾個so,編譯成功之后在機器上運行出現鏈接報錯:
06-26 08:10:01.940 25976 25976 E AndroidRuntime: java.lang.UnsatisfiedLinkError: dlopen failed: library "/Users/linjw/workspace/Demo/app/src/main/cpp/../../../libs/arm64-v8a/libcjson.so" not found: needed by /data/app/~~Y8XCESOaI01yUY_5GwBPeg==/me.linjw.demo-HHKb0jSb43YhjfSIfuxutw==/lib/arm64/libDemo.so in namespace classloader-namespace
libcjson.so
的確是其中一個so,但可以看到它的運行報錯居然是去找我的開發電腦上的這個路徑:/Users/linjw/workspace/Demo/app/src/main/cpp/../../../libs/arm64-v8a/libcjson.so
。
這樣的問題首先我們可以在adb shell
里面用readelf
命令或者在開發電腦里的ndk目錄下找到對應abi的readelf
工具看看libDemo.so
的信息:
# readelf -d /data/app/~~Y8XCESOaI01yUY_5GwBPeg==/me.linjw.demo-HHKb0jSb43YhjfSIfuxutw==/lib/arm64/libDemo.so
Dynamic section at offset 0x3f6c8 contains 38 entries:
Tag Type Name/Value
0x0000000000000001 (NEEDED) Shared library: [/Users/linjw/workspace/Demo/app/src/main/cpp/../../../libs/arm64-v8a/libcjson.so]
0x0000000000000001 (NEEDED) Shared library: [libcurl.so.4]
0x0000000000000001 (NEEDED) Shared library: [libcrypto.so.1.1]
...
可以看到的確有一個NEEDED
配置的是/Users/linjw/workspace/Demo/app/src/main/cpp/../../../libs/arm64-v8a/libcjson.so
,但是可以看到其他的像libcurl.so.4
和libcrypto.so.1.1
也是供應商提供的,他們就沒有帶開發電腦的路徑。從CMake配置上看他們的配置方式是一樣的:
set(lib_path ${CMAKE_SOURCE_DIR}/../../../libs)
add_library(cjson SHARED IMPORTED)
set_target_properties(cjson PROPERTIES IMPORTED_LOCATION ${lib_path}/${ANDROID_ABI}/libcjson.so)
add_library(curl SHARED IMPORTED)
set_target_properties(curl PROPERTIES IMPORTED_LOCATION ${lib_path}/${ANDROID_ABI}/libcurl.so)
add_library(crypto SHARED IMPORTED)
set_target_properties(crypto PROPERTIES IMPORTED_LOCATION ${lib_path}/${ANDROID_ABI}/libcrypto.so)
那么問題就只能出現在他們的so本身,我們繼續用readelf
去對比看看這幾個so的區別:
可以看到libcrypto.so
和libcurl.so
都是帶有SONAME
的,但是libcjson.so
沒有攜帶。我之前在其他的問題里面遇到過SONAME
配錯了導致找不到符號的問題。看起鏈接器在鏈接的時候是使用so的SONAME
字段而不是文件名去寫入target的NEEDED字段所以造成了這個問題。
so的幾個名字
這里我們再回顧下so幾個name的作用:
realname
realname實際上就是so的文件名,一般格式為lib${name}.so.${major}.${minor}.${revision}
例如libcurl.so.4.5.0,我們可以在編譯的時候用-o參數指定:
gcc -shared -o $(realname) …
linkname
linkname是在鏈接時使用的,用-l參數指定例如下面的foo就是linkname。我們在這里不需要填so文件的名字,gcc會自動為linkname補上lib和.so,去鏈接lib$(name).so
gcc main.c -L. -lfoo
另外我們在java里面加載so填的也是linkname:
System.loadLibrary("Demo");
soname
soname顧名思義就是so的名字,它可以在編譯的時候用?Wl,?soname,${soname}
指定,-Wl,表示后面的參數將傳給link程序ld:
gcc -shared -fPIC -Wl,-soname,libfoo.so.0 -o libfoo.so.0.0.0 foo.c
如前面所見,soname會被記錄在so的二進制數據中。在鏈接目標程序的時候也會將soname填入目標程序的NEEDED字段記錄依賴,如果so里面沒有SONAME字段則將文件路徑打入目標程序的NEEDED字段。在加載目標程序的時候則是根據這個NEEDED去相應目錄加載${NEEDED}
這個文件。
patchelf
如果我們有源碼,當然可以修改編譯配置把SONAME加入到libcjson.so
,但是這個so是供應商提供的。我們可以先用patchelf工具嘗試給它加上SONAME驗證看看。下載patchelf-0.18.0-aarch64.tar.gz解壓出patchelf直接adb push到安卓機器上去運行:
patchelf --set-soname libcjson.so libcjson.so
然后再把修改后的libcjson.so用adb pull回來重新編譯app。運行之后可以發現前面的報錯的確沒有了,證明的確是SONAME缺失導致的。
so的版本號問題
但是卻出現了其他的報錯:
06-26 08:46:47.737 30092 30092 E AndroidRuntime: java.lang.UnsatisfiedLinkError: dlopen failed: library "libcrypto.so.1.1" not found: needed by /data/app/~~Y8XCESOaI01yUY_5GwBPeg==/me.linjw.demo-HHKb0jSb43YhjfSIfuxutw==/lib/arm64/libDemo.so in namespace classloader-namespace
這是由于libcrypto.so
的SONAME字段是libcrypto.so.1.1
,所以libDemo.so在鏈接它之后NEEDED字段填入的也是libcrypto.so.1.1
:
# readelf -d /data/app/~~Y8XCESOaI01yUY_5GwBPeg==/me.linjw.demo-HHKb0jSb43YhjfSIfuxutw==/lib/arm64/libDemo.so
Dynamic section at offset 0x3f6c8 contains 38 entries:
Tag Type Name/Value
0x0000000000000001 (NEEDED) Shared library: [/Users/linjw/workspace/Demo/app/src/main/cpp/../../../libs/arm64-v8a/libcjson.so]
0x0000000000000001 (NEEDED) Shared library: [libcurl.so.4]
0x0000000000000001 (NEEDED) Shared library: [libcrypto.so.1.1]
...
但我們導入apk的so名字是libcrypto.so
,在安裝目錄只有libcrypto.so
找不到libcrypto.so.1.1
這個名字的so:
# ls /data/app/~~Y8XCESOaI01yUY_5GwBPeg==/me.linjw.demo-HHKb0jSb43YhjfSIfuxutw==/lib/arm64/ | grep libcrypto
libcrypto.so
所以比較容易想到的是把libcrypto.so
文件名改成libcrypto.so.1.1
,在adb shell里面用mv
命令修改名字運行時可以的。但代碼工程里面修改so名字再去編譯,實際編譯出來之后仍然報錯。這個時候在安裝目錄甚至都找不到libcrypto.so.1.1
。
原因就是雖然安卓系統是支持這種加載帶版本后綴的so,但是gradle在編譯apk的時候確是只會將.so
后綴的文件打包到apk,所以安裝之后就缺失了這個so。
在Android上庫不是在系統范圍內安裝的它們總是應用程序包的一部分,所以so的版本標記是不必要的,谷歌就把這塊在打包的時候去掉了,但這樣的差異造成了在安卓上使用c/c++庫方面需要對so的版本號進行額外的處理。例如在編譯ffmpeg的時候編譯參數添加--target-os=android
最終鏈接的時候就會添加-shared -Wl,-soname,$(SLIBNAME)
參數指定soname
為不帶版本后綴的SLIBNAME
:
# ffmpeg-4.4.2 configure
...
SLIBPREF="lib"
SLIBSUF=".so"
SLIBNAME='$(SLIBPREF)$(FULLNAME)$(SLIBSUF)'
...
# OS specific
case $target_os in
...
android)
disable symver
enable section_data_rel_ro
add_cflags -fPIE
add_ldexeflags -fPIE -pie
SLIB_INSTALL_NAME='$(SLIBNAME)'
SLIB_INSTALL_LINKS=
SHFLAGS='-shared -Wl,-soname,$(SLIBNAME)'
;;
...
解決這個問題除了修改編譯配置重新編譯之外,如果沒有源代碼同樣可以用patchelf
把libcrypto.so
的SONAME改成libcrypto.so
,不過由于蠻多第三方庫交叉編譯之后都會出現帶版本后綴so文件名和soname的情況,這里我再提供兩個思路。
so的搜索路徑
一個是可以用rpath
或者runpath
去解決。
安卓默認會按照優先級搜索下面的路徑:
- so文件的RPATH字段指的的目錄
- LD_LIBRARY_PATH環境變量指定的目錄
- so文件的RUNPATH字段指的的目錄
- 應用的安裝目錄如上面的(/data/app/~~Y8XCESOaI01yUY_5GwBPeg==/me.linjw.demo-HHKb0jSb43YhjfSIfuxutw==/lib/arm64/)
- 系統目錄如/system/lib64/、/vendor/lib64/、/system/apex/com.android.i18n/lib64/等
所以我們可以在CMakeLists.txt對libDemo.so添加如下link參數指定rpath
到應用的內部私有目錄:
project("Demo")
...
set_target_properties(${CMAKE_PROJECT_NAME} PROPERTIES LINK_FLAGS "-Wl,-rpath,/data/data/me.linjw.demo/cache")
然后在第一次運行的時候將帶有版本后綴的so拷貝到這個目錄下。然后加載libDemo.so的時候就會先去到這個rpath
指的的目錄去搜索NEEDED so。
如果有多個目錄需要指定rpath
可以用冒號分割,例如"-Wl,-rpath,/data/data/me.linjw.demo/cache:/data/data/me.linjw.demo/files"
另外從前面的搜索目錄來看,Linux并不會在可執行程序的當前目錄下去搜索so。而rpath
還有個$ORIGIN
變量它指定的是可執行程序的位置,例如我們寫的一個可執行程序依賴了某個so,可以將rpath
指定為$ORIGIN
,那么只要so和可執行程序在同一個目錄就能搜索到。
so緩存
另外一個是我們在load libDemo.so
之前手動調用System.loadLibrary("crypto")
去load libcrypto.so
,然后load的時候讀取到SONAME是libcrypto.so.1.1
放到緩存里,然后再load libDemo.so
查找依賴的時候在緩存里面就能找libcrypto.so.1.1