热修复原理学习(7)so库加载原理,android开发网

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数组的最前面就好了。

具体如下图所示:

在这里插入图片描述
23以上时,findLibrary实现已经发生了变化,如上所示,那么我们只需要把补丁so库的完整路径作为参数构建一个Element对象,然后再插入到nativeLibraryPathElements数组的最前面就好了。

具体如下图所示:

在这里插入图片描述


版权声明:本文为m0_65322636原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接和本声明。