1.so庫加載原理
關於so庫的加載原理,在熱修復知識預備 第五章動態鏈接庫修復中,對System.load()
和System.loadLibrary
即native層源碼做了分析,這裏不再贅述。
2. so庫熱部署實時生效的可行性分析
2.1 動態註冊native方法實時生效
前面分析過so庫的加載原理,我們知道動態註冊的native方法調用一次 JNI_OnLoad()
方法都會重新完成一次映射,所以我們是否只要先加載原來的so庫,再加載補丁so庫,就能完成Java層native方法到native層patch後的新方法映射,這樣就完成了動態註冊native方法的patch實時修復,如下圖所示:
實測發現art下這樣是可以做到實時生效的。
但是Dalvik下做不到實時生效,通過代碼測試我們發現,實際上第二次load補丁so庫,執行的仍然是原來so庫的JNI_OnLoad方法,所以Dalvik下做不到實時生效。我們來簡單分析一下,既然拿到的是原來so庫的JNI_OnLoad方法,那麼我們首先懷疑以下兩個函數是否有問題。
dlopen()
返回給我們一個動態鏈接庫的句柄dlsym()
通過一個dlopen得到的動態鏈接庫的句柄,來查找一個symbol
首先來看下Dalvik虛擬機下面dlopen的實現,源碼在 /bionic/linker/dlfcn.cpp
下, 方法調用鏈路: dlopen -> do_dlopen -> find_library -> find_library_internal
。
static soinfo* find_library_internal(LoadTaskList& load_tasks, const char* name,
int rtld_flags, const android_dlextinfo* extinfo) {
soinfo* candidate;
if (find_loaded_library_by_soname(name, &candidate)) { // 1
return candidate;
}
soinfo* si = load_library(load_tasks, name, rtld_flags, extinfo); // 2
if (si == nullptr && candidate != nullptr) {
si = candidate;
}
return si;
}
註釋1:根據name,去找之前有沒有加載過so庫,如果加載過,就返回調用指針
註釋2:如果沒有加載過,就調用 load_library重新加載。
註釋1find_loaded_library_by_soname()
中的name是so庫所在磁盤的完整路徑。在dvm中,它是通過該路徑的basename查找的,比如修復後的so庫的路徑爲 /data/data/com.rikkatheworld.jni/files/libnative-lib.so
,它就會取出 libnative-lib.so
作爲key去查找。我們知道第一次加載原來的so庫 System.loadLibrary("native-lib")
實際上已經在solist
表中存在了這個key,所以Dalvik下面加載修復後的補丁so拿到的還是原來so庫文件的句柄。最後執行的是原so庫的JNI_OnLoad
方法,art下則不存在這種問題,因爲Art下這個方法是name
作爲key去查找而不是basename,所以art下重新加載一遍補丁so庫,拿到的是補丁so庫的句柄,後面就能順利執行 JNI_OnLoad了。
所以如果想要解決Dalvik下面的這個問題,就需要對補丁中的so進行改名,確保這個so庫是全局唯一的(比如拿時間戳命名)按照上面的分析,在solist中查找的key已經是唯一的,所以此時可以做到Davik下面動態註冊的native方法的實時生效。
2.2 靜態註冊native方法的實時生效
上面通過嘗試對補丁so庫進行重命名爲全局唯一的名稱,可以確保在第二次加載補丁時so庫可以做到Dalvik下和Art下動態註冊方法的實時生效,而要實現靜態註冊native方法的實時生效還需要做更多的工作。
前面我們說過靜態註冊native方法的映射是在native方法第一次執行的時候就完成了映射,所以如果native方法在加載補丁so庫之前已經執行過了,那麼是否這種時候這個靜態註冊的native方法一定得不到修復?幸運的是,系統JNI API通過了這個瞭解註冊的接口。
static jint UnregisterNatives(JNIEnv* env, jclass jclazz) {
ClassObject* clazz = (ClassObject*) dvmDecodeIndirectRef(ts.self(), jclazz);
dvmUnregisterJNINativeMethods(clazz);
return JNI_OK;
}
void dvmUnregisterJNINativeMethods(ClassObject* clazz) {
unregisterJNINativeMethods(clazz->directMethods, clazz->directionMethodCount);
unregisterJNINativeMethods(clazz->virtualMethods, clazz->virtualMethodCount)
}
static void unregisterJNINativeMethods(Method* methods, size_t count) {
while(count != 0) {
count--;
Method* meth = &methods[count];
if (!dvmIsNativeMethod(meth)) {
continue;
}
if (dvmIsAbstractMethod(meth)) { /* avoid abstract method stubs */
continue;
}
dvmSetNativeFunc(meth, dvmResolveNativeMethod, NULL); // meth->nativeFunc重新指向dvmResolveNativeMethod
}
}
UnregisterNatives
函數會把jclazz
所在類的所有native方法都重新指向爲dvmResolveNativeMethod
,所以調用UnregisterNatives 之後不管是靜態註冊還是動態註冊native方法、之前是否執行過,在加載補丁so的時候都會重新去做映射。
所以我們只需要調用:
static void patchNativeMethod(JNIEnv *env, jclass clz) {
env->UnregisterNatives(clz);
}
這裏有一個難點,因爲native方法是在so庫,所以補丁工具很難檢測出到底是哪個Java類需要解除native方法的註冊。 這個問題暫且放下。
假設我們現在可以知道哪個具體的Java類需要解除註冊native方法,然後load補丁庫,再次執行該native方法,按照道理來說是可以讓native方法實時生效,但是測試發現,在補丁so庫重命名的前提下,Java層native方法可能映射到原so庫的方法,也可能映射到補丁so庫的修復後的新方法。(即時而生效,時而不生效)
首先,靜態註冊的native方法之前從未執行過的話或者調用了UnregisterJNINativeMethods方法解除註冊,那麼該方法將指向dvmResolveNativeMethod(meth->nativeFunc = dvmesolveNativeMethod)
,那麼真正運行該方法的時候,實際上執行的是dvmResolveNative()
方法。這個函數主要完成Java層的native方法和native層方法的邏輯映射。
void dvmResolveNativeMethod(const u4* args, JValue* pResult, const Method* method, Thread* self) {
void* func = lookupSharedLibMethod(method);
... ...
if (func != NULL) {
// 調用lookupSharedLibMethod方法,拿到so庫文件對應的native方法函數指針。
dvmUseJNIBridage((Method*) method, func);
(*method->nativeFunc)(args, pResult, method, self);
return;
}
... ...
dvmThrowUnstatisfiedLinkError("Native method not found", method);
}
static void* lookupSharedLibMethod(const Method* method) {
return (void*) dvmHashForeach(gDvm.nativeLibs, findMethodInLib, (void*) method);
}
int dvmHashForeach(HashTable* pHashTable, HashForeachFunc func, void* arg) {
int i, val, tableSize;
tableSize = pHashTable->tableSize;
for (i = 0; i < tableSize; i++) {
HashEntry* pEnt = &pHashTable->pEntries[i];
if (pEnt->data != NULL && pEnt->data != HASH_TOMBSTONE) {
val = (*func)(pEnt->data, arg);
if (val != 0) {
return val;
}
}
}
return 0;
}
gDvm.nativeLibs
是一個全局變量,它是一個HashTable,存放着整個虛擬機加載so庫的SharedLib結構指針。然後該變量作爲參數傳遞給dvmHashForeach
函數進行HashTable遍歷。執行findMethodInLib函數看是否找到對應的native函數指針,如果第一個就找到,就直接return。
這個結構很重要,在虛擬機中大量使用到了HashTable這個數據結構,實現源碼在dalvik/vm/Hash.h
和 dalvik/vm/Hash.cpp
文件。
有興趣的可以自行查看源碼,這裏不進行詳細分析,hashtable的遍歷和插入都是在dvmHashTableLookup()
中實現,簡單說下 Java中的HashTable和c中的HashTable的不同點:
- 共同點:兩者實際上都是數組實現,都是對key進行hash計算後跟hashtable的長度進行取模作爲bucket。
- 不同點:Dalvik虛擬機下的HashTable實現要比Java中的實現簡單一些。
Java中的HashTable的put操作要處理hash衝突的情況,一般情況下會在衝突節點上新增一個鏈表處理衝突,然後get實現會遍歷鏈表。Dalvik下的HashTable的put操作只是簡單的把指針下移到下一個空間點。get實現首先根據hash值算出bucket位置,然後比較是否一致,不一致的話,指針下移,HashTable的遍歷實現就是數組遍歷
知道了DVM下HashTable的實現原理,那我們再來看下前面提到的:補丁so庫重命名的前提下,爲什麼Java層的native方法可能映射到原so庫的方法,也可能映射到補丁so庫修復後的新方法,如下圖所示:
由於HashTable的實現方法以及dvmHashForeach的遍歷實現,so註冊位置跟文件命名hash後的bucket值有關,如果順序靠前,那麼生效的永遠是最前面的,而後面一直無法生效。
可見so庫實時生效方案,對於靜態註冊的native方法有一定的侷限性,不能滿足通用性。
2.3 so庫實時生效方案總結
基於上面的分析,so庫的實時生效方案必須滿足下面幾點:
- so庫爲了兼容Dalvik虛擬機下動態註冊native方法的實時生效,必須對so文件進行改名
- 針對so庫靜態註冊native方法的實時生效,首先需要解註冊靜態註冊的native方法,這個也是難點, 因爲我們很難知道so庫中哪幾個靜態註冊的native方法發生了變更。假設就算我們知道如果靜態註冊的native方法需要解註冊,重新加載補丁so庫有可能被修復,也有可能不被修復
- 上面對補丁so進行了第二次加載,那麼可能是多消耗了一次本地內存,如果補丁so庫夠大、夠多,那麼JNI層的OOM也不是沒可能
- 另一方面補丁so庫新增了一個動態註冊的方法而dex中沒有相應方法,直接去加載這個補丁so文件會報
NoSuchMethodError
異常,具體邏輯在dvmRegisterJNIMethod
中。我們知道如果dex新增了一個native方法,那麼就不能熱部署只能冷啓動生效,所以此時so庫就不能第二次加載了。這種情況下so庫的修復驗證依賴於dex的修復方案。
3. so庫冷部署重啓生效實現方案
爲了更好的兼容通用性,我們嘗試通過冷部署重新生效的角度分析下補丁so庫的修復方案。
3.1 接口調用替換方案
SDK提供接口替換System默認加載so庫接口:
SoPatchManager.loadLibrary(String libName) -> 代替 System.loadLibrary(String libName)
SoPatchManager.loadLibrary 接口加載so庫的時候優先嚐試加載 SDK指定目錄下的補丁so,加載策略如下:
- 如果存在則加載補丁so庫
- 如果不存在,那麼調用
System.loadLibrary
加載安裝apk目錄下的so庫
我們可以很清楚的看到這個方案的優缺點:
- 優點:不需要對不同SDK版本進行兼容,因爲所有的SDK版本都有System.loadLibrary這個接口
- 缺點:調用方需要替換掉System默認加載so庫接口爲SDK提供的接口,如果是已經編譯混淆好的第三方庫so需要patch,那麼很難做到接口的替換。
雖然這種方案簡單,但是有一定的侷限性沒法修復三方包的so庫。
3.2 反射注入方案
前面介紹過System.loadLibrary("native-lib")
加載so庫的原理,其實這個so庫最終傳給native方法的參數是 so庫在磁盤中的完整路徑。調用native層的時候參數就會包裝成/data/app-lib/com.rikkatheworld.jni-2/libnative-lib.so
,so庫會在DexPathList.nativeLibraryDirectories/nativeLibraryPathElements
變量所表示的目錄下去遍歷搜索。
Android SDK版本小於23時,DexPathList.findLibrary()
實現如下:
private final File[] nativeLibraryDirectories;
public String findLibrary(String libraryName) {
String fileName = System.mapLibraryName(libraryName);
for (File directory : nativeLibraryDirectories) {
String path = new File(directory, fileName).getPath();
if (IoUtils.canOpenReadOnly(path)) {
return path;
}
}
return null;
}
這裏會發現遍歷 nativeLibraryDirectories
數組,如果找到了 IoUtils.canOpenReadOnly(path)
返回true,那麼就直接返回該path。
它會true的前提肯定是需要path表示so文件存在的。那麼我門可以採取類似類修復反射注入方式,只要把補丁so庫的路徑插入到nativeLibraryDirectories數組的最前面,就能夠使得加載so庫時,加載的是補丁so庫,而不是原來so庫的目錄,從而達到修復的目的。
Android SDK在版本23以上時,DexPathList.findLibrary實現如下:
public String findLibrary(String libraryName) {
String fileName = System.mapLibraryName(libraryName);
for (Element element : nativeLibraryDirectories) {
String path = element.findNativeLibrary(fileName);
if (path != null) {
return path;
}
}
return null;
}
SDK版本再23以上時,findLibrary實現已經發生了變化,如上所示,那麼我們只需要把補丁so庫的完整路徑作爲參數構建一個Element對象,然後再插入到nativeLibraryPathElements數組的最前面就好了。
具體如下圖所示:
- 優點:可以修復第三方的so庫。同時接入方不需要像方案1一樣強制侵入用戶接口調用
- 缺點:需要不斷的對SDK進行適配,如上SDK23爲分界線,findLibrary接口實現已經發生了變化。
我們知道不管是在補丁包還是在APK中,一個so都存在多種CPU架構的so文件。加載肯定是加載其中一個so庫文件的,如何選擇機型對應的so庫文件將是重點所在。
4. 如何正確複製補丁so庫
如果在某個機型上包含多個CPU abi的架構,同時APK裏面也包含了多個abi的so庫,那麼這個App在這個機型上運行時控件會選擇哪個abi的so庫來執行呢?
如下圖所示,簡單介紹一下選擇so的過程:
實際上補丁so庫也存在類似的問題,我們的補丁so庫文件放到補丁包的libs目錄下面,libs目錄和 .dex文件和res資源打包成一個壓縮文件作爲最後的補丁包,libs目錄可能也包含多種abis目錄。所以我們需要選擇手機最合適的primaryCpuAbi
,然後從libs目錄下面選擇這個 primaryCpuAbi子目錄插入到 nativeLibraryDirectories/nativeLibraryPathElements
數組中。所以怎麼選擇primaryCpuAbi是關鍵,來看下具體的實現:
static {
try {
PackageManager pm = mApp.getPackageManager();
if (pm != null) {
ApplicationInfo mAppInfo = pm.getApplicationInfo(mApp.getPackageName(), 0);
if (mAppInfo != null) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { // SDK >= 21
Field thirdField = ApplicationInfo.class.getDeclaredField("primaryCpuAbi");
thirdField.setAccessible(true);
String cpuAbi = (String) thirdField.get(mAppInfo);
primaryCpuAbis = new String[]{cpuAbi};
} else { // SDK <=21
primaryCpuAbis = new String[]{Build.CPU_ABI, Build.CPU_ABI2};
}
}
}
} catch (PackageManager.NameNotFoundException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
} catch (NoSuchFieldException e) {
e.printStackTrace();
}
}
思路是這樣的:
- 當sdk>=21 時,直接反射拿到ApplicationInfo對象的
primaryCpuAbi
即可 - sdk<21時,由於此時不支持64位,所以直接把
Build.CPU_ABI
,Build.CPU_ABI2
作爲 primaryCpuAbi即可。