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方法有一定的局限性,不能满足通用性。
基于上面的分析,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的修复方案。
===================================================================================
为了更好的兼容通用性,我们尝试通过冷部署重新生效的角度分析下补丁so库的修复方案。
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库。
前面介绍过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数组的最前面就好了。
具体如下图所示: