胡琪

为今天工作,为明天投资,为未来孵化一些东西

【native层方法替换热修复原理】之native方法在dalvik虚拟机中的执行流程

在前面【native层方法替换热修复原理】之虚拟机运行时数据结构ClassObject/Class和Method/ArtMethod 中我们讲到了2个重要的数据结构ClassObject和Mehod,也提到了这2个数据结构中的一些重要成员,这一节就来看看native方法在dalvik虚拟机中是如何执行的。也就是dalvik虚拟机中JNI模块的实现原理。

我们已经知道jni中native方法包括2种情况:一种是静态注册,一种是动态注册。不清楚的同学可以查看专栏:NDK开发从入门到精通

这2种方式注册的native函数在dalvik虚拟机中的执行流程是不一样,先来看下动态注册方式的native函数的执行流程。

使用RegisterNatives动态注册的native函数

安卓NDK开发之JNI函数动态注册中我们讲过native方法的动态注册是通过RegisterNatives这个函数来完成的,因此我们以该函数为切入点来分析下native函数的注册和执行流程。该函数位于安卓源码的dalvik/vm/Jni.cpp。先来看下其定义:

可以看到该函数最重要的逻辑就是调用dvmRegisterJNIMethod函数来完成JNI方法的注册,跟进该函数的实现:

可以看到该函数处理逻辑于流程是这样的:

  1. 根据方法的签名在ClassObject对象所代表的类clazz的direct方法区中查找该方法
  2. 如果在direct方法区中没找到,则在virtual方法区中查找该方法,如果2者都没找到抛出NoSuchMethodError异常
  3. 如果找到先判断该方法是否是native方法,如果不是打警告日志:不能注册非native方法,然后直接返回false,也就是注册方法失败
  4. 如果是native方法,使用JNI桥函数dvmUseJNIBridge将该方法所代表的Method数据结构与传入的fnPtr指针指向的c/c++代码中的本地函数建立映射关系(也就是注册)

所以重点在于dvmUseJNIBridge这个函数是如何做到将java层方法对应的Method数据结构与fnPtr指针指向的c/c++代码中的本地函数建立映射关系的?看下该函数的实现:

从注释上可以看到该函数的作用就是将method->nativeFunc指向JNI桥函数,将method->insns指向实际的函数(point at the actual function)。而完成这个操作的函数是dvmSetNativeFunc,所以我们重点看下该函数是如何实现的(该函数位于安卓源码的dalvik/vm/oo/Class.cpp):

从注释上可以看到该函数实际上就是用新的值替换nativeFunc和insns字段(Replace method->nativeFunc and method->insns with new values),从代码实现来看分为2种情况:

  1. insns不为空,那么2者都更新
  2. insns为空,仅仅更新 nativeFunc

另外从代码注释上来看insns和nativeFunc的状态转换包括3种情况(There are three basic states):
(1) (initial) nativeFunc = dvmResolveNativeMethod, insns = NULL
(2) (internal native) nativeFunc = <impl>, insns = NULL
(3) (JNI) nativeFunc = JNI call bridge, insns = <impl>

第一种是初始状态,nativeFunc字段指向初始值dvmResolveNativeMethod,insns为空

第二种是内部native方法状态:nativeFunc字段指向某个具体的实现,insns为空

第三种是JNI方法状态(也就是开发者在java代码中声明的native方法):nativeFunc字段指向 JNI call bridge,比如通过前面的分析我们已经知道动态注册的native函数在注册过程中会将该字段指向JNI桥函数(dvmCheckCallJNIMethod或dvmCallJNIMethod),insns指向函数的具体实现,也就是注册时传入的fnPtr指针

不管哪种状态,只要该方法是native方法,其nativeFunc字段一定不为NULL。

而JNI桥函数(dvmCheckCallJNIMethod或dvmCallJNIMethod)的作用就是根据传入的Method结构体中保存的参数以及具体实现代码(insns指向的内容)做平台相关的实现。以dvmCallJNIMethod为例,如下一段代码是dvmCallJNIMethod函数中一段重要的调用流程:

其中的dvmPlatformInvoke就是根据不同平台来实现。

到这里通过动态注册的方式注册的native函数的执行流程就分析完了,先小结下:

  1. RegisterNatives注册native函数是通过dvmRegisterJNIMethod完成的,该函数会根据注册时传入的函数签名依次从direct,virtual方法区中查找该方法对应的Method对象,查找到之后判断该方法是否为native方法,如果是则将该Method对象和注册时传入的fnPrt指针指向的c/c++层函数建立映射关系
  2. 将java方法对应的Method对象和c/c++层函数建立映射关系的本质就是用新的值替换nativeFunc和insns字段
  3. 整个函数调用链是这样的:RegisterNatives->dvmRegisterJNIMethod->dvmUseJNIBridge->dvmSetNativeFunc。这条调用链走完之后就已经将java层声明的native函数和我们在JNI代码中定义的c/c++函数建立起了映射关系,此时Method数据结构中nativeFunc字段指向的值是JNI桥函数(dvmCheckCallJNIMethod或dvmCallJNIMethod),insns指向的值是对应的c/c++中定义的函数的函数地址。
  4. 等到执行该native函数的时候,本质上是执行该函数的JNI桥函数,在该桥函数中会根据Method数据结构中保存的参数和insns指向的c/c++函数的地址进行执行。这个过程是通过dvmPlatformInvoke函数完成的,该函数是一个和平台相关的调用(实际上从函数名就可以看出来,所以该函数的具体实现应该使用到了类似汇编代码)。

思考:从上面的流程我们能得到一些什么启示?

从上面的流程我们已经知道了native函数在dalvik中的注册流程,也知道了注册的本质就是修改Method数据结构的nativeFunc字段和insns字段,那么知道这些有什么用呢?再进一步想想我们为何要注册native函数?不就是为了在执行一个java层声明的函数时去执行另外一个(c/c++层的)函数吗?调用一个函数实际上去执行另外一个函数不就是劫持或者hook吗?试想下如果我们能够在运行时随意修改一个Mehod对象对应的这2个字段的内容,那么执行该函数时是不是就会去调用我们指定的函数呢?比如将nativeFunc字段指向一个我们自己定义的函数(当然这个函数定义必须满足以下形式)

,在这个函数中我们可以实现一些自己的操作,然后再去调用原函数,这样不就达到类似hook的行为了吗?实际上在后面带领大家分析native层方法替换框架,如Xposed和DexPosed等框架时你会发现他们就是这么做的,换而言之他们借鉴或参考了dalvik虚拟机JNI实现原理的这一套逻辑。

通过命令规则静态注册的native函数

前面分析了通过动态注册方式注册的native函数,接下来分析下静态注册方式注册的native函数的执行流程?在分析之前,我们先来思考一个问题?静态注册和动态注册的native函数的区别在哪里?是代码实现不一样吗?很明显实现是一样的,区别仅仅在于函数的命名是不一样的,同时缺少了动态注册的过程。静态注册函数需要符合命名规则。那么命名会影响什么呢?很显然是函数的查找,所以我在分析之前先预测静态注册函数执行流程的区别和动态注册相比在于函数查找逻辑不一样,换而言之就是得到Method数据结构方式不一样。后面大家可以看我预测的对不对。

对于静态注册方式,因为没有动态注册过程,所以我们需要找另外一个切入点分析,这个切入点就是类加载了之后方法是如何加载及调用的?

通过前面的一系列热修复文章的学习,我们已经知道类加载机制的最终目标就是为目标类生成一个ClassObject数据结构,并将其存储在运行时环境中,为解释器的执行提供相应类方法的字节码。同时在dex中类的加载流程和unexpected DEX异常已经讲过类的加载过程中ClassObject对象的生成是在findClassNoInit方法中。同时也知道该方法生成ClassObject实际上调用的是loadClassFromDex方法(还不清楚的同学可以先了解下专栏中的热修复相关专题),所以我们以该方法为入口进行分析。该方法位于安卓源码的dalvik\vm\oo\Class.cpp中。因为该方法实际上是调用了loadClassFromDex0方法,所以我们直接分析loadClassFromDex0方法,这个方法非常长,做了很多事,因为我们主要是希望弄明白静态注册的native方法的执行流程。所以省略其他和方法加载无关的代码:

从上面可以看到和动态注册类似也分为direct方法和virtual的加载,不管哪种类型最终都是调用loadMethodFromDex方法,跟进该方法:

这个方法做的事也比较多,因为我们是分析native方法的静态注册,所以重点关注上述代码我注释的地方,可以看到,如果是native方法,那么会将Method对象的nativeFunc成员的值赋为dvmResolveNativeMethod,这也是为何前面提到动态注册native函数时Method对象的nativeFunc成员的初始值是dvmResolveNativeMethod的原因。

有了前面分析动态注册时的经验,我们就知道直接分析该函数即可(该函数位于安卓源码dalvik\vm\Native.cpp目录下)。

从上面的注释可以看到该函数的作用就是解析一个native函数,然后调用它。如果解析成功
method->insns将被替换(if the resolution succeeds, method->insns is replaced)。从执行流程上来看包括以下几个重要步骤:

  1. 首先通过dvmLookupInternalNativeMethod函数来查找,对于dalvik虚拟机内部定义的native函数而言,是可以找到的,比如安卓源码dalvik\vm\native路径下定义的各个类中的native方法,
  2. 对于开发者在java层定义的方法通过lookupSharedLibMethod函数来查找,找到之后返回指向该函数的指针
  3. 接下来和动态注册时调用流程就差不多了,调用dvmUseJNIBridge将该函数代表的Method对象和前面在动态库中查找到的函数的地址建立映射关系,注意映射关系建立完成之后,Method->insns字段的值为从动态库中查找到的native函数的地址func,Method->nativeFunc的值为JNI桥函数。所以(*method->nativeFunc)(args, pResult, method, self);这一句实际调用的是JNI桥函数。

分析完了之后现在回过头来看我前面的预测,是不是很正确?区别仅仅在于方法的查找逻辑不同,在已经知道java层函数对应的Method对象和native层函数的地址之后,后面建立映射关系的逻辑几乎一模一样。而且可以看到这2种方式查找方法时的侧重点是不同的,静态注册时侧重点在于如何找到该方法对应的native方法的地址,而动态注册查找方法时侧重点在于如何通过注册时传入的函数签名查找到该函数对应的Method对象。原因很简单,因为动态注册是在so加载时注册,此时java层方法调用还未执行,仅仅只是建立java层方法和native层方法的映射关系而已,而且会传入该函数在c/c++层对应的指针,所以native层函数地址是已知条件,而java层函数对应的Method对象是未知条件。而静态注册的执行是在运行时从java层跳到native层时,此时已经发生函数调用,Java层函数对应的Method结构是已知条件,而对应的native层函数是未知条件。

Tips

  • 从上面分析可以看到整个过程涉及到了ClassObject和Method数据结构的很多成员,这也是为何前面要对这2个数据结构进行分析的原因,同时也可以感受到数据结构在代码编写中的重要性,函数本质上就是对各种数据结构进行操作和更新。同时也可以看到源码中很多函数都使用了委托调用的形式,即将一个功能很大的函数拆分为n个小功能点的短函数。这也是代码规范的一个技巧。
  • 在分析源码时强烈推荐大家用源码阅读工具Source Insight,方便分析流程跳转。

总结

  1. 不管是静态注册还是动态注册,其目的都是建立起java层方法和native层c/c++方法的映射关系,本质上都是用新的值替换Method数据结构中的nativeFunc和insns字段的值。
  2. 任何一个native方法,其Method数据结构对应的nativeFunc值一定不为空,在运行过程中这个值一定会指向一个JNI桥函数(dvmCallJNIMethod或者dvmCheckCallJNIMethod),JNI方法的执行就是在该桥函数中被调用执行的

 

打赏

点赞

发表评论

电子邮件地址不会被公开。 必填项已用*标注