熱修復原理學習(7)so庫加載原理

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.hdalvik/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,加載策略如下:

  1. 如果存在則加載補丁so庫
  2. 如果不存在,那麼調用 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即可。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章