前言

JNI(Java Native Interface)意为Java本地接口,我们可以通过JNI来实现Java与C/C++之间互调。

前面我们介绍了重构方法的方案,不过重构后的方法仍然是Java方法,因此分析起来相对也比较容易。

这一章我将介绍的是重构到本地方法中,即需要把字节码转换为本地代码。

方案选择

实现方案 优势 劣势
方案一 把字节码直接转换为相应的汇编代码 字节码是基于栈的,而汇编中直接支持栈的操作,因此实现比较容易且运行效率非常高。 汇编代码不跨平台,因而需要针对每个平台来编写对应的汇编代码。
方案二 把字节码转换为相应的C/C++代码 跨平台,实现起来比较简单。 运行效率相对较低。

如果是为了做优化,建议选择方案一,但如果只是为了混淆,那么方案二是个好选择,毕竟编程起来更容易,也更好维护。这里选择的是方案二。

另外,这里选择使用C,因为C编译出来的体积更少。

初步设计

bytecode是基于栈的,如果我们要模拟bytecode的功能,先模拟栈的功能会事半功倍。汇编中自带了栈操作的指令,因此使用汇编实现的话会比较简单。C++的STL库中也有实现好的栈,但不好的地方就是使用C++编译后的文件会比较大。而由于C中没有栈的实现,需要自己实现,相对来说比较麻烦。不过幸运的是,bytecode中栈和本地变量的大小都是固定的,也就是说我们完全可以使用一个固定的数组来实现。

我们知道Java中有8种基本数据类型和引用类型,分别对应于JNI中的jboolean、jbyte、jchar、jshort、jint、jfloat、jlong、jdouble、jobject。除此之外,JNI中还是jarray、jclass等类型,不过这些类型实际上还是jobject类型。因此,为了保证栈可以保存所有类型,我们可以定义一个union类型,里面包括了所有数据类型:

1
2
3
4
5
6
7
8
9
10
11
typedef union {
jboolean Z;
jbyte B;
jchar C;
jshort S;
jint I;
jfloat F;
jlong J;
jdouble D;
jobject O;
}place;

接下来,我们需要根据每个方法中的字节码,生成对应的C函数。由于我们需要自动化生成对应的C代码,也就是需要字符串拼接,而有些地方的设计是通用的,因此我们可以先设计一个模板,然后根据实际情况进行替换。

1
2
3
4
5
6
7
8
9
10
11
@_retType_@ @_functionName_@(JNIEnv* env, jobject obj@_argType_@) {
int stackTop = 0;
place locals[@_maxLocals_@];
place stack[@_maxStack_@];
memset(locals, 0, sizeof(locals));
memset(stack, 0, sizeof(stack));

@_argInit_@

@_code_@
}

其中locals对应于bytecode的本地变量,stack对应于bytecode的栈。

这里我用@xxx@来代表要替换的内容,含义如下:

retType:返回值类型。

functionName:函数名,需要保证不重复。

argType:包含的参数。

maxLocals:本地变量的大小,可通过MethodNode.maxLocals来获取。

maxStack:栈的大小,可通过MethodNode.maxStack来获取。

argInit:如果有参数的话,则初始化参数到相应的locals中。

code:根据bytecode转换出来的C代码。

这样我们只需要把相应的内容替换进模板中,就可以得到一个完整的JNI函数了。接着我们只要把此函数与对应的Java的native方法关联起来就可以了。

代码生成

假设有如下代码:

1
2
3
4
5
6
7
package com.tencent.asm;
public class Record {
int count;
public void clear() {
count = 0;
}
}

对应的字节码为:

1
2
3
4
5
6
ALOAD 0
ICONST_0
PUTFIELD com/tencent/asm/Record.count : I
RETURN
MAXSTACK = 2
MAXLOCALS = 1

现在我们需要把它转换为C语言的实现,最简单的方式当然是每个指令作相应的转换。我们先按照模板填一下除了code以外对应的信息:

1
2
3
4
5
6
7
8
9
10
11
void com_tencent_asm_record_clear_v(JNIEnv* env, jobject obj) {
int stackTop = 0;
place locals[1];
place stack[2];
memset(locals, 0, sizeof(locals));
memset(stack, 0, sizeof(stack));
// 注意下,此时obj为this指针,在bytecode中为第0个本地变量
locals[0] = obj;

@_code_@
}

为了更好的调试,建议名字取得与原方法的名字类似,另外,生成对应的代码也做好注释(后面生成代码时后看到)。

模板生成后了,就差具体代码的实现了。不知大家有没注意到,模板中有个stackTop的变量,这个变量用来代表当前栈顶的位置,这样在生成代码时,我们不需要关心当前使用的是栈的哪个位置,只需对应地更改下stackTop就行。

第一条指令很简单,就是把第0个本地变量的值拷贝到栈中,因此我们可以生成对应的C代码:

1
stack[stackTop++].O = locals[0];

很简单的吧,需要注意的是ALOAD指令加载的是引用,所以用的是place.O(上面定义place时,O为jobject类型,后面我就不再说明)。

第二条指令也很简单,就是把整数0压到栈中,对应的C代码如下:

1
stack[stackTop++].I = 0;

第三条指令就相对复杂一些,我们看下文档,putfield指令要求栈中有两个值,分别是需要设置的对象的引用、需要设置的值。这条指令是用来设置某个类的实例的属性的值,这里我们就需要用到相应的JNI函数了。由于属性的类型为int,因而我们使用的是SetIntField方法,其声明如下:

1
void (*SetIntField)(JNIEnv*, jobject, jfieldID, jint);

其中jfieldID为属性的标识,我们需要先通过FindClass方法获得属性所在类的标识,其声明如下:

1
jclass (*FindClass)(JNIEnv*, const char*);

其中第二个参数为类的全局限定名。接着再通过GetFieldID来获得属性的标识,其声明如下:

1
jfieldID (*GetFieldID)(JNIEnv*, jclass, const char*, const char*);

其中第2、3、4个参数依次为类标识、属性名、属性描述。

我们来看下对应的C代码是怎样的:

1
2
3
4
jclass record_class = (*env)->FindClass(env, "com/tencent/asm/Record");
jfieldID count_field = (*env)->GetFieldID(env, record_class, "count", "I");
(*env)->SetIntField(env, count_field, stack[stackTop-2].O, stack[stackTop-1].I);
stackTop -= 2;

至于return指令就不用解释了吧。汇总一下,就得到最终的转换结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void com_tencent_asm_record_clear_v(JNIEnv* env, jobject obj) {
int stackTop = 0;
place locals[1];
place stack[3];
memset(locals, 0, sizeof(locals));
memset(stack, 0, sizeof(stack));
locals[0].O = obj;

// ALOAD 0
stack[stackTop++].O = locals[0].O;
// ICONST_0
stack[stackTop++].I = 0;
// PUTFIELD com/tencent/asm/Record.count : I
jclass record_class = (*env)->FindClass(env, "com/tencent/asm/Record");
jfieldID count_field = (*env)->GetFieldID(env, record_class, "count", "I");
(*env)->SetIntField(env, count_field, stack[stackTop-2].O, stack[stackTop-1].I);
stackTop -= 2;
// RETURN
return;
}

动态注册

关于如何注册JNI函数,这里使用静态注册与动态注册均可,这里我说一下如何进行动态注册。

由于我们自动化生成代码时,会处理多个类,而注册JNI函数的方法一次只支持一个类,因此我们可以设计一个结构体,来存放每个类的信息,这样只需做个循环就可以全部注册了。设计如下:

1
2
3
4
5
typedef struct {
const char* className;
JNINativeMethod* methods;
int size;
}reg_info;

接着我们加入类似如下的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
// 获取数组中元素个数
#define GET_SIZE(x) (sizeof(x) / sizeof(x[0]))
// 每个类生成一个对应的数组,来保存相应的映射关系
static JNINativeMethod gMethods_Record[] = {
{ "clear", "()V", (void*) com_tencent_asm_record_clear_v },
};
// 保存所有类需要注册的信息
static reg_info gInfos[] = {
{"com/tencent/asm/Record", gMethods_Record, GET_SIZE(gMethods_Record)},
};
static int registerNativeMethods(JNIEnv* env, const char* className,
JNINativeMethod* gMethods, int numMethods) {
jclass clazz = (*env)->FindClass(env, className);
void* retValue = (*env)->ExceptionOccurred(env);
// 类没找到,有些应用神经病地加上了一些android中没有的类来坑人。直接跳过当成已处理就行
if(retValue) {
(*env)->ExceptionClear(env);
return JNI_TRUE;
}
if (clazz == NULL) {
return JNI_FALSE;
}
if ((*env)->RegisterNatives(env, clazz, gMethods, numMethods) < 0) {
(*env)->DeleteLocalRef(env, clazz);
return JNI_FALSE;
}
(*env)->DeleteLocalRef(env, clazz);
return JNI_TRUE;
}
static int registerNatives(JNIEnv* env) {
int i=0;
int size = GET_SIZE(gInfos);
// 批量注册
for(;i<size;++i) {
if(!registerNativeMethods(env, gInfos[i].className, gInfos[i].methods, gInfos[i].size)) {
return JNI_FALSE;
}
}
return JNI_TRUE;
}
// 当Java中调用System.loadLibrary()方法来加载相应的so库时,系统会回调此方法
JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void* reserved) {
JNIEnv* env = NULL;
jint result = -1;

if ((*vm)->GetEnv(vm, (void**) &env, JNI_VERSION_1_4) != JNI_OK) {
return -1;
}
assert(env != NULL);

if (!registerNatives(env)) {
return -1;
}
result = JNI_VERSION_1_4;
return result;
}

至于如何进行编译,不懂的请到网上搜下,这里就不细说了。

整型问题

在bytecode的操作本地变量与栈的指令中,boolean、byte、char、short、int这些类型,都会当然整型来处理,而像数组操作等指令则可以操作这些类型,这样一不小心,就可能会出现类型转换的问题了。我们来看个例子,假设有如下代码:

1
2
3
4
5
6
package com.tencent.asm;
public class EncryptUtil {
public static int count(byte[] src) {
return src[0] + src[1];
}
}

对应的字节码为:

1
2
3
4
5
6
7
8
9
10
ALOAD 0
ICONST_0
BALOAD
ALOAD 0
ICONST_1
BALOAD
IADD
IRETURN
MAXSTACK = 3
MAXLOCALS = 1

接着我把它转换为对应的C代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
void com_tencent_asm_encryptutil_count_bi(JNIEnv* env, jclass obj, jarray src) {
int stackTop = 0;
place locals[1];
place stack[3];
// 增加一个临时变量用来存放返回值
place retValue;
memset(locals, 0, sizeof(locals));
memset(stack, 0, sizeof(stack));
// 由于是静态,所以obj为对应的类标识,第1个本地变量为src
locals[0].O = src;

// ALOAD 0
stack[stackTop++].O = locals[0].O;
// ICONST_0
stack[stackTop++].I = 0;
// BALOAD
(*env)->GetByteArrayRegion(env, stack[stackTop-2].O, stack[stackTop-1].I, 1, &retValue.B);
stack[--stackTop-1].B = retValue.B;
// ALOAD 0
stack[stackTop++].O = locals[0];
// ICONST_1
stack[stackTop++].I = 1;
// BALOAD
(*env)->GetByteArrayRegion(env, stack[stackTop-2].O, stack[stackTop-1].I, 1, &retValue.B);
stack[--stackTop-1].B = retValue.B;
// IADD
stack[stackTop-2].I = stack[stackTop-2].I + stack[stackTop-1].I;
--stackTop;
// IRETURN
return stack[--stackTop].I;
}

不知道大家有没发现上面的写法存在的问题。jbyte对应于Java中的byte,其在JNI中的定义实际是个signed char,占1个字节,我们针对BALOAD的处理时,把栈中的元素当成是jbyte来处理,而栈的元素实际占了8个字节,也就说高7字节并没被更改,而在后面IADD时,我们无法知道其本身是个byte,所以直接当成int来处理。这样问题就来了,此时int的高3个字节是什么内容呢?为了防止这种情况发生,我们可以在使用栈前清空栈中的内容,这样就不用担心旧数据的影响了。

另外还有个问题,假如byte中的值是-1呢?按照上面的写法,int的高3个字节均为0,所以它的值就变为255了,这显然不符合我们需要吧。这问题其实就是自动类型转换的问题,改起来就很简单,我们只需要把下面代码:

1
stack[--stackTop-1].B = retValue.B;

改成:

1
stack[--stackTop-1].I = retValue.B;

如果你把它改成下面的代码,那么就连之前说的清空栈操作也可以省了。

1
stack[--stackTop-1].J = retValue.B;

Android的Bug

俗话说得好,眼见为实,还是惯例,先给段Java代码:

1
2
3
4
5
6
7
8
9
package com.tencent.asm;
public class AndroidBug {
public int debug(Object value) {
if(value instanceof String) {
return 1;
}
return 0;
}
}

对应的字节码如下:

1
2
3
4
5
6
7
8
9
10
    ALOAD 1
INSTANCEOF java/lang/String
IFEQ L1
ICONST_1
IRETURN
L1
ICONST_0
IRETURN
MAXSTACK = 1
MAXLOCALS = 2

对应的C代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
jint com_tencent_asm_androidbug_debug_ov(JNIEnv* env, jobject obj, jobject value) {
int stackTop = 0;
place locals[2];
place stack[1];
memset(locals, 0, sizeof(locals));
memset(stack, 0, sizeof(stack));
locals[0].O = obj;
locals[1].O = value;

// ALOAD 1
stack[stackTop++].O = locals[1].O;
// INSTANCEOF java/lang/String
jclass string_class = (*env)->FindClass(env, "java/lang/String");
stack[stackTop-1].J = (*env)->IsInstanceOf(env, stack[stackTop-1].O, string_class);
// IFEQ L1
if(stack[stackTop-1].I == 0) {
goto L1;
}
// ICONST_1
stack[stackTop++].I = 1;
// IRETURN
return stack[stackTop-1].I;
// L1
// 加入stackTop = stackTop;这行无意义的代码是因为Label后不能跟声明
L1: stackTop = stackTop;
// ICONST_0
stack[stackTop++].I = 0;
// IRETURN
return stack[stackTop-1].I;
}

大家知道问题出现在哪里吗?不知道没关系,我们可以猜一下。大家看我给的代码非常短,比较关键的代码就在于instanceof了,也就是说应该是instanceof出的问题。那么instanceof会出什么问题呢?我们先看下JNI的实现,下面是Jni.cpp中IsInstanceOf的源码:

1
2
3
4
5
6
7
8
9
10
11
12
static jboolean IsInstanceOf(JNIEnv* env, jobject jobj, jclass jclazz) {
ScopedJniThreadState ts(env);

assert(jclazz != NULL);
if (jobj == NULL) {
return true;
}

Object* obj = dvmDecodeIndirectRef(ts.self(), jobj);
ClassObject* clazz = (ClassObject*) dvmDecodeIndirectRef(ts.self(), jclazz);
return dvmInstanceof(obj->clazz, clazz);
}

接下来我们需要看下Dalvik中又是怎么实现的。

Dalvik虚拟机的执行引擎有两个,分别是Fast解释器(用汇编实现)以及Portable解释器(用C语言实现)。为了方便阅读,我们选择Portable解释器来分析。Portable解释器的源码位于dalvik\vm\mterp\out\InterpC-portable.cpp。

我们找到dalvik bytecode中的instance-of指令对应的实现代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
HANDLE_OPCODE(OP_INSTANCE_OF /*vA, vB, class@CCCC*/)
{
ClassObject* clazz;
Object* obj;

vdst = INST_A(inst);
vsrc1 = INST_B(inst); /* object to check */
ref = FETCH(1); /* class to check against */
ILOGV("|instance-of v%d,v%d,class@0x%04x", vdst, vsrc1, ref);

obj = (Object*)GET_REGISTER(vsrc1);
if (obj == NULL) {
SET_REGISTER(vdst, 0);
} else {
#if defined(WITH_EXTRA_OBJECT_VALIDATION)
if (!checkForNullExportPC(obj, fp, pc))
GOTO_exceptionThrown();
#endif
clazz = dvmDexGetResolvedClass(methodClassDex, ref);
if (clazz == NULL) {
EXPORT_PC();
clazz = dvmResolveClass(curMethod->clazz, ref, true);
if (clazz == NULL)
GOTO_exceptionThrown();
}
SET_REGISTER(vdst, dvmInstanceof(obj->clazz, clazz));
}
}
FINISH(2);
OP_END

我们可以看到一大堆宏指令,想深入研究的请自行分析,这里我说明一下关键点。

在Jni.cpp中IsInstanceOf函数里,我们可以看到如下代码:

1
2
3
if (jobj == NULL) {
return true;
}

这表示了如果对象为空,则返回为真,即代表空对象是所有类的实例。

而在InterpC-portable.cpp中,相应的代码为:

1
2
3
if (obj == NULL) {
SET_REGISTER(vdst, 0);
} else {

我们知道,在C++中,true即为1,false即为0,也就是说这行代码代表着所有空对象不是任何类的实例。

显然这两行代码是相矛盾的,而我们的字节码是由dalvik虚拟机执行的,因此需要以dalvik虚拟机为准,这样大家应该知道怎么处理了吧?