随笔博文

听说这样学JNI,效果不是一般的好

2022-12-26 15:46:11 michael007js 60

/JNI定义/


JNI全称:Java Native Interface。它是Java本身的一种特性,用来在Java里面调用C/C++代码的。看下面的一个程序:


public static void main(String[] args) throws Exception {
 PrintStream ps = new PrintStream(new FileOutputStream("work"));
 System.setOut(ps);
 System.out.println("Hello World!");
}


程序比较简单,就是将输出流重定向了一下。但是,我们看一下System这个类的out字段:


public static final PrintStream out = null;


这是一个final字段,那么setOut方法是如何改变这个out的值的呢?


是因为它调用了一个native方法:


private static native void setOut0(PrintStream out);


在native层就可以绕过限制,改变一个final字段的值。


/Hello World/


下面,看一个native版的hello world的例子。先写一个java类,里面调用一个native方法:


class HelloWorld {
 private native void print();

 public static void main(String[] args) {
     new HelloWorld().print();
 }

 static {
     // String path = System.getProperty("java.library.path");
     // System.out.println(path);
     System.loadLibrary("HelloWorld");
 }
}


print是一个native方法,它会输出hello world。接下来我们实现这个native类。


/* DO NOT EDIT THIS FILE - it is machine generated */
#include <jni.h>
/* Header for class HelloWorld */

#ifndef _Included_HelloWorld
#define _Included_HelloWorld
#ifdef __cplusplus
extern "C" {
#endif
/*
* Class:     HelloWorld
* Method:  print
* Signature: ()V
*/
JNIEXPORT void JNICALL Java_HelloWorld_print
(JNIEnv *, jobject);

#ifdef __cplusplus
}
#endif
#endif


这个文件是使用javah -jni生成的。头文件里面定义了一个方法,只不过这个方法的名称有点奇特,其实就是由对应的 java 方法的包名+方法名组成。不过如果涉及到特殊字符或者重载,还会有更多变化,具体的可以看JNI规范文档。


先忽略JNIEXPORT和JNICALL宏。你可能有注意到本地方法的C实现接受两个参数。但是HelloWorld.java中定义的 print 方法却没有接受任何参数。


每一个本地方法实现的第一个参数是一个JNIEnv接口指针。第二个参数是HelloWorld对象本身,类似于this指针。如果是一个静态方法,那么第二个参数是类对象本身,类似 Class 对象。


extern “C” {} 的主要作用就是为了能够正确实现C++代码调用其他C语言代码。加上extern “C”后,会指示编译器这部分代码按c语言的进行编译,而不是C++的。由于C++支持函数重载,因此编译器编译函数的过程中会将函数的参数类型也加到编译后的代码中,而不仅仅是函数名;而C语言并不支持函数重载,因此编译C语言代码的函数时不会带上函数的参数类型,一般只包括函数名。


JNIECXPORT的作用是将函数导出,可以被其他库调用。


JNICALL的作用暂时不清楚,linux平台似乎是空定义。


#define JNIEXPORT__attribute__ ((visibility ("default")))
#define JNICALL


接下来,我们来实现上面的这个方法:


#include <jni.h>
#include <stdio.h>
#include "HelloWorld.h"


// mac 上 loadLibary 方法会找以 .dylib 或者 .jnilib 结尾的库
// 编译命令cc -I$JAVA_HOME/include -I$JAVA_HOME/include/darwin -I. -fPIC -shared HelloWorld.c -o libHelloWorld.dylib
JNIEXPORT void JNICALL Java_HelloWorld_print
(JNIEnv *env, jobject obj)
{
 printf("Hello World!\n");
 return ;
}


实现非常的简单,就是调用了一下printf方法,输出了hello world。


再下面,就是生成so文件,不过需要注意的是,不同的平台System.loadLibrary方法加载的库文件的规则不一样。在mac上默认加载以.dylib或者.jnilib结尾的库,所以如果你生成了一个libHelloWorld.so的文件,是加载不了的,除非使用 load 方法加载绝对路径。


编译命令:


cc -I$JAVA_HOME/include -I$JAVA_HOME/include/darwin -I. -fPIC -shared HelloWorld.c -o libHelloWorld.dylib


然后使用 javac 生成 class 文件,最后运行 class 文件,就能输出 hello world 了!!!


/JavaVM/JNIEnv/


一个JNIEnv指针仅在其相关联的线程中有效。不能将这个指针从一个线程中传递给另一个线程,或者在多线程中缓存和使用它。


Java虚拟机在同一个线程传递给本地方法相同的JNIEnv指针,但是从不同线程中调用本地方法时传递的是不同的JNIEnv指针。但是这些JNIEnv 指针的方法都是同一份。


获取JNIEnv有几种方式:


JNIEnv *env;
(*jvm)->AttachCurrentThread(jvm, (void **)&env, NULL);


(*jvm)->GetEnv((void **) env, JNI_VERSION_1_6);


写到这一节的时候,多少有点随意,因为这一节的知识大家都知道,但是突然想了一下,为啥JNIEnv无法被线程共享呢?暂时没有找到阐述的很清楚的文章,翻了源码也没有思路,猜测一下,可能JNIEnv的某些变量就是放在线程相关的缓冲区中的。不深究了。


/NDK/


上面花了一节来写一个demo,其实主要是想说JNI是java的东西,跟android关系不大,刚开始接触android的时候,我一直是将JNI当作android的特性来理解的,而且我看了一些java相关的书籍中,似乎都没有介绍JNI相关的。


我理解的NDK是google官方实现了很多API,提供给开发者来使用,只不过是通过JNI来实现的。


/字符串的处理/


JNIEXPORT jstring JNICALL
Java_Prompt_getLine(JNIEnv *env, jobject obj, jstring prompt) {
 /* ERROR: incorrect use of jstring as a char* pointer */
 printf("%s", prompt);
 ...
}


当从java层传递一个字符串过来之后,它的类型是jstring。如果,我们想打印一下这个字符串,那么可以使用GetStringUTFChars这个函数,它会返回一个const char *类型(编码是MUTF类型,具体看参考文档JNI TIPS),我们就可以使用printf来打印它了。为啥要是const的呢?是因为java中的string是不可变的,不允许更改。


还有一个GetStringChars函数,它返回jchar*类型,我们不能直接打印。Java中的字符都是Unicode编码,显然这个jchar *指向的是unicode编码的字符串。有一个需要注意的地方:


bool x ;
const char *chars = env->GetStringUTFChars(hello, &x);

if(x)


我们经常会用到这个方法,但是第二个参数经常会让我疑惑,查了一些文档后,终于弄明白了。


这个方法会返回字符串的utf形式,由于虚拟机内部以及本地内存的一些原因,在调用这个方法的时候,有可能会将字符串拷贝一遍。如果返回的字符串是原来的java.lang.String的一份拷贝, 在方法返回之后,isCopy指向的内存地址将会被设置为JNI_TRUE。而如果返回的字符串指针直接指向原来的java.lang.String对象,则该地址会被设置为JNI_FALSE。如果返回了JNI_FALSE, 则原生代码将不能改变返回的字符串,因为改变了这个字符串,原来的java字符串也会被修改,这违背了java.lang.String实例不可改变的原则。通常你可以直接传递NULL给isCopy来告诉Java虚拟机你不在乎返回的字符串是否拷贝了。


同样的,基于上面的原因,在调用完这个方法之后,就需要调用一个对应的ReleaseStringUTFChars 方法,如下:


str = (*env)->GetStringUTFChars(env, prompt, NULL);
(*env)->ReleaseStringUTFChars(env, prompt, str);


否则会造成内存泄漏。


ReleaseStringUTFChars的实现比较有意思。


static void ReleaseStringUTFChars(JNIEnv*, jstring, const char* chars) {
 delete[] chars;
}


再看GetStringUTFChars的实现:


static const char* GetStringUTFChars(JNIEnv* env, jstring java_string, jboolean* is_copy) {
  if (java_string == nullptr) {
    return nullptr;
  }
  if (is_copy != nullptr) {
    *is_copy = JNI_TRUE;
  }

  ScopedObjectAccess soa(env);
  ObjPtr<mirror::String> s = soa.Decode<mirror::String>(java_string);
  size_t length = s->GetLength();
  size_t byte_count =
      s->IsCompressed() ? length : GetUncompressedStringUTFLength(s->GetValue(), length);
  char* bytes = new char[byte_count + 1];
  CHECK(bytes != nullptr);// bionic aborts anyway.
  if (s->IsCompressed()) {
    const uint8_t* src = s->GetValueCompressed();
    for (size_t i = 0; i < byte_count; ++i) {
      bytes[i] = src[i];
    }
  } else {
    char* end = GetUncompressedStringUTFChars(s->GetValue(), length, bytes);
    DCHECK_EQ(byte_count, static_cast<size_t>(end - bytes));
  }
  bytes[byte_count] = '\0';
  return bytes;
}


看第6行,是必定会执行拷贝的,所以JNI在ART里面的实现,是一定会拷贝。


对于字符串的创建还有一对函数:NewStringUTF/NewString ,这个是类似的,不说了,看文档吧。


还有一点要提一下,Get/ReleaseStringCritical这对函数我没有用过,但是有了Get/ReleaseStringChars为啥还要这对玩意呢?而且这对函数使用起来条件比较苛刻,他们之间的本地代码不能调用会引起阻塞的方法以及创建新对象,否则虚拟机可能会引起死锁。


/JNI访问Java字段与方法/


JNIEnv提供了一个FindClass方法来寻找某一个类,这个里面的逻辑其实就是ClassLoader的逻辑。这一套方法有点像Java里面的反射的使用方式。


上面拿到的是jclass对象,按照反射的逻辑,我们接下来就该获取对应的字段或者方法了:


jmethodID mid;
jclass runnableIntf =(*env)->FindClass(env, "java/lang/Runnable");
mid = (*env)->GetMethodID(env, runnableIntf, "run", "()V");


GetMethodID 的第3个参数需要解释一下,看例子:


“(I)V”表明该方法有一个类型为int的参数并且返回类型为void。“()D”表明该方法不需要参数并且返回一个double值。调用静态方法,使用GetStaticMethod ID。访问字段/构造方法也是类似的,一看就懂。


那么这里就有一个问题,既然,能使用构造方法,来为啥不直接用String的构造方法来创建字符串,而是要为String创建专门的使用函数呢?


答案还是因为性能问题,字符串是编程语言中使用最频繁的了,必须得特别支持。由于,FindClass/GetMethodID/GetFieldID这些都会有性能问题,所以有一个小技巧就是将这些结果缓存起来:


jmethodID MID_InstanceMethodCall_callback; 

JNIEXPORT void JNICALL Java_InstanceMethodCall_initIDs(JNIEnv *env, jclass cls) {
 MID_InstanceMethodCall_callback = (*env)->GetMethodID(env, cls, "callback", "()V");
}


后面,直接使用缓存的字段就好了。使用JNI访问字段和调用方法的性能特性如何?


一个典型的虚拟机实现执行Java/native调用比执行Java/Java调用大概慢两到三倍。有了 method id 与 field id 就可以尝试访问方法与字段了,常用的方法:


CallVoidMethod
CallVoidMethodV
CallVoidMethodA


那这3个方法有啥区别呢?


从文档上看不出来什么东西,但是从源码里面可以看出来,前面两个内部调用的是:


InvokeVirtualOrInterfaceWithVarArgs


后面一个内部调用的是:


InvokeVirtualOrInterfaceWithJValues


那这两个内部又有啥区别呢?


这要说到两种类型,一种是va_list,一种是jvalue。va_list就是可变长参数,我们使用va_arg(变长参数, 类型),就可以取出参数的值。jvalue是一个联合类型,它里面储存的是真正的参数值。


typedef union jvalue {
 jboolean  z;
 jbyte       b;
 jchar       c;
 jshort    s;
 jint      i;
 jlong       j;
 jfloat    f;
 jdouble     d;
 jobject     l;
} jvalue;


其实,InvokeVirtualOrInterfaceWithVarArgs这个方法内部是将可变参数一个一个的取出来,然后转为jvalue类型,再处理,可以理解为上面的两个方法殊途同归。所以,最终的参数都是jvalue类型。但是使用起来还是CallVoidMethod最方便,它只需要我们传递java对应的类型就好了,比如需要一个String,我们就传递一个jstring。


/引用方式/


上面说到,将计算出来的class/method/field缓存起来可以提高运行性能,但是有一点需要注意。FindClass返回的是一个局部引用,而局部引用在函数返回后会被JVM自动回收。查了一些资料以及经过自己的测试,发现FindClass返回的是局部引用,GetMethodID/GetFieldID返回的是弱全局引用。


我写了这样的一个Demo:


jclass cache_class;

extern "C"
JNIEXPORT void JNICALL
Java_com_aprz_mytestdemo_jni_RefActivity_cache(JNIEnv *env, jobject thiz) {
 if (cache_class == nullptr) {
     cache_class = env->FindClass("com/aprz/mytestdemo/jni/RefActivity");
 }
 LOGE("cache_class = %p", cache_class);
 jmethodID just_print = env->GetMethodID(cache_class, "justPrint", "()V");
 if (just_print) {
     env->CallVoidMethodA(thiz, just_print, {});
 }
}


上面的代码想缓存一个RefActivity的引用以便后续使用,但是却有一个逻辑问题,cache_class它是一个指针,第一次被赋值后,指向RefActivity的class的一个局部引用,这个引用在方法结束之后会被回收,但是cache_class的值仍然指向那个局部引用的地址(地址里面没有东西了),所以导致cache_class成了一个野指针。


该方法第一次执行时没有问题,第二次执行时就会报错:


2021-12-21 10:59:36.466 6683-6683/? A/DEBUG: signal 6 (SIGABRT), code -1 (SI_QUEUE), fault addr --------
2021-12-21 10:59:36.466 6683-6683/? A/DEBUG: Abort message: 'JNI DETECTED ERROR IN APPLICATION: use of deleted local reference 0x75
     from void com.aprz.mytestdemo.jni.RefActivity.cache()'


同样的方式缓存method id :


jmethodID cache_method_id;

extern "C"
JNIEXPORT void JNICALL
Java_com_aprz_mytestdemo_jni_RefActivity_cacheMethod(JNIEnv *env, jobject thiz) {
 if (cache_method_id == nullptr) {
     cache_method_id = env->GetMethodID(env->GetObjectClass(thiz), "justPrint", "()V");
 }
 if (cache_method_id) {
     env->CallVoidMethodA(thiz, cache_method_id, {});
 }
}


由于GetMethodID返回的是弱全局引用,那么只要有别的地方引用它,它就不会被回收,所以这样使用没有问题。多次运行是ok的。


还有一个问题,JNIEnv提供了一个DeleteLocalRef方法用来删除局部引用,那么就有人要问了,既然局部引用在方法执行完之后会被回收,为啥还要提供这个方法,让它自己会被回收不香吗?


是基于两个原因,第一个是在某些版本的android里面,JNI的局部引用表是有限制的,比如512。既然有限制,那么就有可能超过这个限制,比如,你方法里面有循环,又比如,你写了一个调用链巨长的方法。所以,我们应该在以下这些情况主动删除局部引用:


  1. 本地代码遍历一个特别大的字符串数组,每遍历一个元素,都会创建一个局部引用,当对使用完这个元素的局部引用时,就应该马上手动释放它。

  2. 局部引用会阻止所引用的对象被GC回收。比如你写的一个本地函数中刚开始需要访问一个大对象,因此一开始就创建了一个对这个对象的引用,但在函数返回前会有一个大量的非常复杂的计算过程,而在这个计算过程当中是不需要前面创建的那个大对象的引用的。但是,在计算的过程当中,如果这个大对象的引用还没有被释放的话,会阻止GC回收这个对象,内存一直占用着,造成资源的浪费。所以这种情况下,在进行复杂计算之前就应该把引用给释放了,以免不必要的资源浪费。

  3. 编写工具函数时,要当心不要在函数的调用轨迹上遗漏任何的局部引用,因为工具函数被调用的场合和次数是不确定的,一量被大量调用,就很有可能造成内存溢出。


反正,核心就是,能够删除的就立即删除。比如,我写了一个工具方法:


void tools(JNIEnv *env, jobject thiz) {
 jmethodID x = env->GetMethodID(env->GetObjectClass(thiz), "justPrint", "()V");
}


它泄漏了一个局部引用,当我在一个while(true)里面调用这个方法的时候,就会报错:


2021-12-21 11:42:13.373 10249-10249/com.aprz.mytestdemo A/aprz.mytestdem: jni_env_ext-inl.h:32] JNI ERROR (app bug): local reference table overflow (max=8388608)
2021-12-21 11:42:13.373 10249-10249/com.aprz.mytestdemo A/aprz.mytestdem: jni_env_ext-inl.h:32] local reference table dump:
2021-12-21 11:42:13.373 10249-10249/com.aprz.mytestdemo A/aprz.mytestdem: jni_env_ext-inl.h:32]   Last 10 entries (of 8388608):
2021-12-21 11:42:13.373 10249-10249/com.aprz.mytestdemo A/aprz.mytestdem: jni_env_ext-inl.h:32]     8388607: 0x1387b518 java.lang.Class<com.aprz.mytestdemo.jni.RefActivity>
2021-12-21 11:42:13.373 10249-10249/com.aprz.mytestdemo A/aprz.mytestdem: jni_env_ext-inl.h:32]     8388606: 0x1387b518 java.lang.Class<com.aprz.mytestdemo.jni.RefActivity>
2021-12-21 11:42:13.373 10249-10249/com.aprz.mytestdemo A/aprz.mytestdem: jni_env_ext-inl.h:32]     8388605: 0x1387b518 java.lang.Class<com.aprz.mytestdemo.jni.RefActivity>


错误提示很明显,是局部引用表溢出了,800多万个,几秒就耗光了!!下面有表里面储存的对象的dump,全是RefActivity的class对象,看我们上面的代码,是env->GetObjectClass(thiz)这行代码有问题,它返回了一个局部引用,每次调用这个方法,都会累加一次。所以造成溢出,应该将它删除。


void tools(JNIEnv *env, jobject thiz) {
 jclass tmp_class = env->GetObjectClass(thiz);
 jmethodID tmp_method = env->GetMethodID(tmp_class, "justPrint", "()V");
 env->DeleteLocalRef(tmp_class);
}


这样写就没问题了,注意,tmp_method不能delete,会报错,突然发现好像也没有一个文档来说明那些应该删除,那些不能删除。


上面的这个例子有一点没能想通,就是GetObjectClass返回的是一个局部引用,循环体里面的方法执行完之后就释放了才对,为啥还会导致引用溢出,猜测与编译器优化,变量分配有关。


看了一下android 11的源码,local table的大小为512,上面测试的手机是我刷的android10的镜像,不知道为啥这么大。看注释可知,是故意设置这么小的,强制开发者注意随时释放。


// Number of local references in the indirect reference table. 
// The value is arbitrary but low enough that it forces sanity checks.
static constexpr size_t kLocalsInitial = 512;


同样的,全局引用与弱全局引用也有对应的删除方法:


env->DeleteGlobalRef()
env->DeleteWeakGlobalRef()


还有一个知识点是引用的比较:给定两个引用(不管是全局、局部还是弱全局引用),我们只需要调用IsSameObject来判断它们两个是否指向相同的对象。


例如:(*env)->IsSameObject(env, obj1, obj2)


如果obj1和obj2指向相同的对象,则返回JNI_TRUE(或者1),否则返回JNI_FALSE(或者 0)。这个方法还可以用于空指针的比较。如果obj是一个局部或全局引用,可以用来判断obj是否指向一个null对象。但需要注意的是,如果比较的是弱全局引用,那么true表示的是该引用被回收了。


/异常处理/


当JNI调用java方法时,java方法有可能会出现异常,那么我们需要做对应的处理,因为出了异常之后,不像在java里面,后面的流程会中断,而是会继续执行,看下面的一个例子:


extern "C"
JNIEXPORT void JNICALL
Java_com_aprz_mytestdemo_jni_ExceptionActivity_toNative(JNIEnv *env, jobject thiz) {
 jclass exception_activity_class = env->GetObjectClass(thiz);
 jmethodID do_something_method_id = env->GetMethodID(exception_activity_class, "doSomething",
                                                     "()V");
 if (do_something_method_id) {
     // 这里出了异常
     env->CallVoidMethod(thiz, do_something_method_id);
 }

 LOGE("i will print even exception occurred!!!!");

 env->DeleteLocalRef(exception_activity_class);
}


doSomething是一个java方法,它会抛出一个空指针异常。按照我们对java的理解,LOGE这行代码应该执行不到才对,但是实际上它打印出来了。所以我们需要一种手段来检测异常是否出现,从而做对应的处理。


exc = (*env)->ExceptionOccurred(env); 
if (exc) {
 (*env)->ExceptionDescribe(env);
 (*env)->ExceptionClear(env);
}


ExceptionOccurred会检测到这个异常。ExceptionDescribe会输出一个关于这个异常的描述性信息。ExceptionClear方法清除这个异常。


还有一个ExceptionCheck方法,它的功能与ExceptionOccurred类似,但是它不会产生一个局部引用,所以使用起来比较方便。异常检查的代码写起来很麻烦,但是确实是必要的,特别是你申请了资源的时候,出了异常必须要释放才行。


/JNI_Onload/


当Sytem.loadlibrary加载一个so的时候,虚拟机会找到该so中的JNI_OnLoad方法,并调用它。简单的追一下调用链:


java.lang.System#loadLibrary
java.lang.Runtime#nativeLoad(java.lang.String, java.lang.ClassLoader, java.lang.Class<?>)
// 下面的是 android-11.0.0—r3 代码
Runtime_nativeLoad->
JVM_NativeLoad ->
JavaVMExt::LoadNativeLibrary


LoadNativeLibrary 这个方法里面做了两件非常重要的事情:


  1. void* handle = android::OpenNativeLibrary

    1. 将 so 加载进来,使用的是android_dlopen_ext方法。

    2. 创建一个SharedLibrary对象:new SharedLibrary(…, handle, …)

  2. 找到JNI_OnLoad函数的地址,执行这个函数


void* sym = library->FindSymbol("JNI_OnLoad", nullptr);
...
using JNI_OnLoadFn = int(*)(JavaVM*, void*);
JNI_OnLoadFn jni_on_load = reinterpret_cast<JNI_OnLoadFn>(sym);
int version = (*jni_on_load)(this, nullptr);


从这里,我们可以看出,该方法的第二个参数没什么卵用,是用来兼容以后的版本的。JNI 提供了注册函数的两种方式,一种是静态注册,就是我们第一个例子使用的方式,第二种是动态注册(RegisterNatives),动态注册的时机通常就是在 JNI_OnLoad 中,动态注册相比较于静态注册的好处就是可以批量注册,而且还不会暴露出注册的函数。


看了一下源码流程,其实静态注册也是走的动态注册的流程。当一个类被加载的时候,会去设置


/C、C++使用JNIEnv的不同/


c中的JNIEnv:


typedef const struct JNINativeInterface* JNIEnv;


env是一个JNINativeInterface** c++中的JNIEnv:


typedef _JNIEnv JNIEnv;


_JNIEnv又是const struct JNINativeInterface* functions,所以env是一个JNINativeInterface** 类型都是一样的,但是使用方式不一样,是因为JNINativeInterface 对C++做了封装。看下面一段。


struct _JNIEnv {

const struct JNINativeInterface* functions;

#if defined(__cplusplus)

jint GetVersion()
 { return functions->GetVersion(this); }


_JNIEnv除了直接引用了JNINativeInterface*之外,还将里面的接口重新定义了一遍,然后将this参数替我们传递了。所以在cpp文件里面,使用env非常简单,而在c文件里面使用env比较麻烦。


/关键方法的内部逻辑/


首先说FindClass的逻辑,这是看的Android运行时ART加载类和方法的过程分析 这篇文章,是分析的4.4的代码,很老了,但是大致逻辑应该差不过,理解了这个,可以再去看最新的代码。


上面提到过,FindClass走的也是classLoader加载类的流程,我们知道,每个classLoader都会指定path用来加载dex文件,那么有了类的描述符之后,就会挨个的去找,从而获取对应的DexFile文件。如果classLoader为null的话,就会去系统启动路径去找。


找到DexFile之后,会创建一个kclass对象,就是native层的Class。然后设置各种信息:类索引号,静态成员变量和实例成员变量,函数索引号等。


创建class的时候,会加载类的各个成员,比如类方法,这个时候,就会创建ArtField与ArtMethod等。对于ArtMethod,会去LinkCode,这个顾名思义,就是链接该方法对应的指令了。如果这个方法是一个native方法,就会去注册这个方法,就像动态注册一样,其实就是设置了entry_point_from_jni_的值,只不过它设置的是一个stub,等到真正调用的时候才会去dlsym查询so的函数地址,然后替换成真的,这种模式很常用。


所以,FindClass其实就是加载了一个类,创建了对应Class对象,以及为每个类方法创建了“Stub”对象,方法真正被调用的时候才会创建真的。


那么GetMethodID的逻辑就更简单了,该方法需要一个jclass参数,而这个参数其实就是Class对象的一个指针,所以我们只需要拿到Class对象里面ArtMethod数组,一个一个对比就好了,不过有点麻烦的是,要考虑接口,父类等问题。


CallVoidMethod的逻辑其实也不难,但是需要对ArtMethod的结构有一定的了解,我们看下面:


class ArtMethod {
…………
protect:
HeapReference declaring_class_;
HeapReference> dex_cache_resolved_methods_;
HeapReference> dex_cache_resolved_types_;
uint32_t access_flags_;
uint32_t dex_code_item_offset_;
uint32_t dex_method_index_;
uint32_t method_index_;
struct PACKED(4) PtrSizedFields {
void* entry_point_from_interpreter_;
void* entry_point_from_jni_;
void* entry_point_from_quick_compiled_code_;
#if defined(ART_USE_PORTABLE_COMPILER)
void* entry_point_from_portable_compiled_code_;
#endif
} ptr_sized_fields_;
static GcRoot java_lang_reflect_ArtMethod_;

……


这是 5.0 的代码,这个时候,还存在entry_point_from_jni,entry_point_from_interpreter这些字段,这些字段其实就是方法的指令入口地址:


entry_point_from_jni_:如果该方法是一个native方法,那么这个地址就是加载进来的so中的方法的地址。


entry_point_from_interpreter_:这个是使用解释器执行时,指令的地址。


entry_point_from_quick_compiled_code_:这个是非解释器执行时的指令地址(就是 aot 模式)。


还有一个entry_point_from_portable_compiled_code,这个似乎没有打开过。它与entry_point_from_quick_compiled_code 类似,只不过是这两种模式生成的oat文件不一样,通过Portable后端和Quick后端生成的oat文件的本质区别在于,前者使用标准的动态链接器加载,而后者使用自定义的加载器加载。


标准动态链接器在加载SO文件(这里是oat文件)的时候,会自动处理重定位问题。也就是说,在生成的本地机器指令中,如果有依赖其它的SO导出的函数,那么标准动态链接器就会将被依赖的SO也加载进来,并且从里面找到被引用的函数的地址,用来重定位引用了该函数的符号。


自定义加载器的做法就不一样了。它在加载oat文件时,并不需要做上述的重定位操作。因为Quick后端生成的本地机器指令需要调用一些外部库提供的函数时,是通过一个函数跳转表来实现的。由于在加载过程中不需要执行重定位,因此加载过程就会更快,Quick的名字就是这样得来的。


了解了上面的知识,我们再来看 CallVoidMethod,有了method id,就相当于有了ArtMethod,而ArtMethod里面又有方法对应的指令入口,直接跳转过去执行就好了,当然不像我说的这么简单,还要处理参数,栈帧等。


/Hook JNI调用/


上面说到,java方法对应的jni函数的地址在entry_point_from_jni这个里面,那么我们只需要替换这个值为我们写的方法的地址,就可以监控方法的执行了。实现这个功能的难点在于,如何找到ArtMethod里面的entry_point_from_jni这个字段。


因为每个版本,每个rom都可能不一样,所以有一种取巧的方式是写两个相邻的native method:


external fun x1()
external fun x2()


然后,获取到他们的jmethodid:


jclass entry_point_activity = env->GetObjectClass(thiz);

jmethodID x1 = env->GetMethodID(entry_point_activity, "x1", "()V");
jmethodID x2 = env->GetMethodID(entry_point_activity, "x2", "()V");


因为,jmethodID实际上就是ArtMethod的指针,所以,我们就获取到了这两个对象的地址。


对象里面储存的是各个字段,我们只需要从对象的地址开始往后遍历,总能走到entry_point_from_jni_这个字段,然后跟我们声明的JNI函数比较一下即可:


intptr_t offsetToFind =
     reinterpret_cast<intptr_t >(x1) - reinterpret_cast<intptr_t >(x2);


if (offsetToFind < 0) {
 offsetToFind = -offsetToFind;
}
if (offsetToFind > 128) {
 offsetToFind = 128;
}


uintptr_t jni_entry_point_offset = 0;
for (uintptr_t curOffset = 0; curOffset < offsetToFind; curOffset += sizeof(void *)) {
 uintptr_t curAddr = reinterpret_cast<uintptr_t >(x1) + curOffset;
 if ((*reinterpret_cast<void **>(curAddr)) ==
     Java_com_aprz_mytestdemo_jni_EntryPointsActivity_x1) {
     jni_entry_point_offset = curOffset;
     break;
 }
}


这里是限制了最大往后遍历128位。其中有一点不太明白,就是为什么步长是 sizeof(void *),因为我们不确定rom会放什么字段。


经过测试发现,对象中的字段会对齐,按照字段占用最大值对齐,比如有一个 char,一个 void *,会将结构的占用的内存,扩展为最大值的倍数,于是char 扩展成 4/8 字节。


计算出了地址,hook就只需要将你创建的替换函数的地址替换上去就好了,记得签名一致。


需要注意的是,这种ArtMethod计算方法只在11以下管用,11及以上ArtMethod的获取需要使用其他的方式,这个可以去翻翻源码来解决!


我看了5.x里面的逻辑,ArtMethod的entry_point_from_jni字段,默认会设置为Stub(art_jni_dlsym_lookup_stub)的地址,在Stub执行的时候,会在so里面找真实的方法地址,找到了之后entry_point_from_jni才会替换为真实的!!那如果这个方法之前没有被调用过不是找不到地址了吗?


所以想要找到entry_point_from_jni_的地址,必须要先调用一下这两个native方法,才能正确计算。计算完成之后,想要调用原来的方法,就需要去符号表里面寻找原函数并调用了。


首页
关于博主
我的博客
搜索