本文转载自: http://blog.csdn.net/innost/article/details/50377905
一、背景
这个选题很大,但并不是一开始就有这么高大上的追求。最初之时,只是源于对Xposed的好奇。Xposed几乎是定制ROM的神器软件技术架构或者说方法了。它到底是怎么实现呢?我本意就是想搞明白Xposed的实现原理,但随着代码研究的深入,我发现如果不了解虚拟机的实现,而仅简单停留在Xposed的调用流程之上,那真是对Xposed最大的不敬了。另外,歪果仁为什么能写出Xposed?Android上的Java虚拟机对他们来说应该也是相对陌生的,何以他们能做而我们没有人搞出这样的东西?
所以,在研究Xposed之后,我决定把虚拟机方面的东西也来研究一番。诚如我在很多场合中提到的关于Android学习的三个终极问题(其实对其他各科学习也适用):学什么?怎么学?学到什么程度为止?关于这三个问题,以本次研究的情况来看,回答如下:
- 学习目标是:按顺序是dalvik虚拟机,然后是Xposed针对dalvik的实现,然后是art虚拟机。
- 学习方法:VM原理配合具体实现,以代码为主。Java VM有一套规范,各公司具体的VM实现必须遵守此规范。所以对VM学习而言,规范很重要,它是不变的,而代码实现只不过是该规范的一种实现罢了。这里也直接体现了我提出的关于专业知识学习的一句警语“基于Android,高于Android”。对VM而言,先掌握规范才是最最重要和核心的事情。
- 学到什么程度为止:对于dalvik虚拟机,我们以学会一段Java程序从代码,到字节码,最后到如何被VM加载并运行它为止。关于dalvik的内存管理我们不会介绍。对于XPosed,基于dalvik+selinux环境的代码我们会全部分析。对于ART,由于它是Google未来较长一段时期的重点,所以我们也会围绕它做更多的分析,诸如内存管理怕是肯定会加上的。
除了这三个问题,其实还有一个隐含的疑问,学完之后有什么用呢?
- 这个问题的答案要看各位的需求了。从本人角度来看,我就是想知道Xposed是怎么实现的。另外,如果了解虚拟机实现的话,我还想定制它,使得它在智能POS领域变得更安全一点。
- 当然,我自己有一个比较高大上的梦想,就是我一直想写Linux Kernel方面的书,而且我自认为已经找到了一个绝妙的学习它的入手点(我在魅族做分享的时候介绍过。到今天为止一年多过去了,不知道当初的有心人是否借此脱引而出,如果有的话也请和大家分享下你的学习经历)。Anyway,从目前的工作环境和需求来看,VM是当前更好的学习目标。
言归正传,现在开始正式介绍dalvik,请牢记关于它的学习目标和学习程度。
你也可以下载本专题对应的demo代码用于学习。
二、Class、dex、odex文件结构
2.1 Class文件结构总览
Class文件是理解Vm实现的关键。关于Class文件的结构,这里介绍的内容直接参考JVM规范,因为它是最权威的资料。
Oracle的JVM SE7官方规范:https://docs.oracle.com/javase/specs/jvms/se7/html/
还算很有良心,纯网页版的,也可以下载PDF版。另外,周志明老师曾经翻译过中文版的JVM规范,网上可搜索到。
作为分析Class文件的入口,我在Demo示例中提供了一个特别简单的例子,代码如图1所示:
TestMain类的代码简单到不行,此处也不拟多说,因为没有特殊之处。
当我们用eclipse编译这个类后,将得到bin/com/test/TestMain.class。这个TestMain.class就是我们要分析的Class文件了。
Class文件到底是什么东西?我觉得一种通俗易懂的解释就是:
- *.java文件是人编写的,给人看的。
- *.class是通过工具处理*.java文件后的产物,它是给VM看的,给VM操作的
在某种哲学意义上看,java源文件和处理得到的class文件是同一种东西......
那么,这个给VM使用的class文件,其内部结构是怎样的呢?Jvm规范很聪明,它通过一个C的数据结构表达了class文件结构。这个数据结构如图2所示:
请大家务必驻足停留片刻,因为搞清楚图2的内容对后续的学习非常关键。图2的ClassFile这个数据结构真得是太容易理解了。相比那些native的二进制程序而言,ClassFile的组织结构和Java源码的组织结构匹配度非常高,以致于我第一眼看到这个结构体时,我觉得自己差不多就理解了它:
- 比如,类的是public的还是final的,还是interface,就由access_flags来表示。其具体取值我觉得都不用管,代码中用得是名字诸如ACC_XXX这样得的标志位来表示,一看知道是啥玩意儿。
- Java类中定义的域(成员变量),方法等都有对应的数据结构来表达,而且还是个数组。
- 唯一有点特别之处的是常量池。什么东西会放在常量池呢?最容易想到的就是字符串了。对头,这个Java源码中的类名,方法名,变量名,居然都是以字符串形式存储在常量池中。所以,图2中的this_class和super_class分别指向两个字符串,代表本类的名字和基类的名字。这两个字符串存储在常量池中,所以this_class和super_class的类型都是u2(索引,代表长度为2个字节)。
Class文件用javap工具可以很好得解析成图2那样的格式,我这里替大家解析了一把,结果如图3所示(先显示部分内容):
注意,解析方法为:javap -verbose xxxx.class
先来看看常量池。
2.1.1 常量池介绍
常量池看起来陌生,其实简单得要死。注意,count_pool_count是常量池数组长度+1。比如,假设某个Class文件常量池只有4个元素,那么count_pool_count=5)。
javap解析class文件的时候,常量池的索引从1算起,0默认是给VM自己用得,一般不显示0这一项。这也是为什么图3中常量池第一个元素以#1开头。所以,如果count_pool_count=5的话,真正有用的元素是从count_pool[1]到count_pool[4]。
常量池数组的元素类型由下面的代码表示:
cp_info { //特别注意,这是介绍的cp_info是相关元素类型的通用表达。
u1 tag; //tag为1个字节长。不论cp_info具体是哪种,第一个字节一定代表tag
u1 info[]; //其他信息,长度随tag不同而不同
}
//tag取值,先列几个简单的:
tag=7 <==info代表这个cp_info是CONSTANT_Class_info结构体
tag=9<==info代表CONSTANT_Fieldrefs_info结构体
tag=10<==info代表CONSTANT_Methodrefs_info结构体
tag=8<==info代表CONSTANT_String_info结构体
tag=1<==info代表CONSTANT_Utf8_info结构体
在JVM规范中,真正代表字符串的数据结构是CONSTANT_Utf8_info结构体,它的结构如下代码所示:
CONSTANT_Utf8_info {
u1 tag;
u2 length; //下面就是存储UTF8字符串的地方了
u1 bytes[length];
}
大家看图3中常量池的内容,比如#2=Utf8 com/test/TestMain 这行表示:
数组第二个元素的类型是CONSTANT_Utf8_info,字符串为“com/test/TestMain”
下面我们看几个常用的常量池元素类型
(1) CONSTANT_Class_info
这个类型是用于描述类信息的,此处的类信息很简单,就是类名(也就是代表类名的字符串)
CONSTANT_Class_info {
u1 tag; //tag取值为7,代表CONSTANT_Class_info
u2 name_index; //name_index表示代表自己类名的字符串信息位于于常量池数组中哪一个,也就是索引
}
唉,够懒的,name_index对应的那个常量池元素必须是CONSTANT_Utf8_info,也就是字符串。图3中的例子,咱们再看看:
#1 = Class #2 //com/test/TestMain
#2 = Utf8 com/test/TestMain
这说明:
- 常量池第一个元素类型为Class_info,它对应的name_index取值为2,表示使用第2个元素
- 常量池第二个元素类型为Utf8 内容为“com/test/TestMain”
- #1最后的//表示注释,它把第二行的字符串内容直接搬过来,方便我们查看
(2) CONSTANT_NameAndType_Info
这个结构也是常量池数据结构中中比较重要的一个,干什么用得呢?恩,它用来描述方法/成员名以及类型信息的。有点JNI基础的童鞋相信不难明白,在JNI中,一个类的成员函数或成员变量都可以由这个类名字符串+函数名字符串+参数类型字符串+返回值类型来确定(如果是成员变量,就是类名字符串+变量名字符串+类型字符串)来表达。既然是字符串,那么NameAndType_Info也就是存储了对应字符串在常量池数组中的索引:
CONSTANT_NameAndType_info {
u1 tag;
u2 name_index; //方法名或域名对应的字符串索引
u2 descriptor_index; //方法信息(参数+返回值),或者成员变量的信息(类型)对应的字符串索引
}
//还是来看图3中的例子吧
#13 = Utf8 ()V
#15 = NameAnType #16.#13 //合起来就是test.()V 函数名是test,参数和返回值是()V
#16=Utf8 test
太简单了,都不惜得说...,请大家自行解析#25这个常量池元素的内容,一定要做喔!
注意,对于构造函数和类初始化函数来说,JVM要求函数名必须是<init>和<cinit>。当然,这两个函数是编译器生成的。
(3) CONSTANT_MethodrefInfo三兄弟
Methodref_Info还有两个兄弟,分别是Fieldref_Info,InterfaceMethodref_Info,他们三用于描述方法、成员变量和接口信息。刚才的NameAndType_Info其实已经描述了方法和成员变量信息的一部分,唯一还缺的就是没有地方描述它们属于哪个类。而咱这三兄弟就补全了这些信息。他们三的数据结构如图4所示:
如此直白简单,不解释了。不放心的童鞋们请对照图3的例子自行玩耍!
常量池先介绍到这,它还有一些有用的信息,不过要等到后面我们碰到具体问题时再分析
2.1.2 Field和Method描述
刚才在常量池介绍中有提到Methodref_Info和Fieldref_Info,不过这两个Info无非是描述了函数或成员变量的名字,参数,类型等信息。但是真正的方法、成员变量信息还包括比如访问权限,注解,源代码位置等。对于方法来说,更重要的还包括其函数功能(即这个函数对应的字节码)。
在JavaVM中,方法和成员变量的完整描述由如图5所示的数据结构来表达的:
- access_flags:描述诸如final,static,public这样的访问标志
- name_index:方法或成员变量名在常量池中对应的索引,类型是Utf8_Info
- attribute_info:是域或方法中很重要的信息。我们单独用一节来介绍它。
2.1.3 attribute_info介绍
attribute_info结构体很简单,如下代码所示:
attribute_info {//特别注意,这里描述的attribute_info结构体也是具体属性数据结构的通用表达
u2 attribute_name_index; //attribute_info的描述,指向常量池的字符串
u4 attribute_length; //具体的内容由info数组描述
u1 info[attribute_length];
}
Java VM规范中,attribute类型比较多,我们重点介绍几个,先来看代表一个函数实际内容的Code属性。
(1) Code属性
代表Code属性的数据结构如图6所示:
- 前2个成员变量就不多说了。属于attribute的头6个字节,分别指向代表属性名字符串的常量池元素以及后续属性数据的长度。注意,Code属性的attribute_name_index所指向的那个Utf8常量池元素对应的字符串内容就是“Code”,大家可参考图3的#9。
- max_stack和max_locals:虚拟机在执行一个函数的时候,会为它建立一个操作数栈。执行过程中的参数啊,一些计算值啊等都会压入栈中。max_stack就表示该函数执行时,这个栈的最大深度。这是编译时就能确定的。max_locals用于描述这个方法最大的栈数和最大的本地变量个数。本地变量个数包括传入的参数。
- code_length和code:这个函数编译成Java字节码后对应的字节码长度和内容。
- exception_table_length:用来描述该方法对应异常处理的信息。这块我不打算讲了,其实也蛮简单,就是用start_pc表示异常处理时候从此方法对应字节码(由code[]数组表示)哪个地方开始执行。
- Code属性本身还能包含一些属性,这是由attributes_count和attributes数组决定的。
来看个实际例子吧,如图7所示(接着图3的例子):
图7中:
- stack=2,locals=2,args_size=1。结合代码,main函数确实有一个参数,而且还有一个本地变量。注意,main函数是static的。如果对于类的非static函数,那么locals的第0个元素代表this。
- stack后面接下来的就是code数组,也就是这个函数对应的执行代码。0表示code[]的索引位置。0:new:代表这个操作是new操作,此操作对应的字节码长度为3,所以下一个操作对应的字节码从索引3开始。
- LineNumberTable也是属性的一种,用于调试,它将源码和字节码匹配了起来。比如line 7: 0这句话代表该函数字节码0那一个操作对应代码的第7行。
- LocalVariableTable:它也是属性一种,用于调试,它用于描述函数执行时的变量信息。比如图7中的Start = 0:表示从code[]第0个字节开始,Length = 13表示到从start=0到start+13个字节(不包含第13个字节,因为code数组一共就12个字节)这段范围内,这个变量都有效(也就是这个变量的作用域),Slot=0表示这个变量在本地变量表中第一个元素,还记得前面提到的locals吗?,name为“args”,表示这个参数的名字叫args,类型(由Signature表示)就是String数组了。
请大家自行解析图7中最后一行,看看能搞明白LocalVariableTable的含义不...
另外,Android SDK build Tools中的dx工具dump class文件得到的信息更全,大家可以试试。
使用方法是:dx --dump --debug xxx.class。
Class文件先介绍到这,下面我们来看看Android平台上的dex文件。
2.2 Dex文件结构和Odex
2.2.1 dex文件结构简介
Android平台中没有直接使用Class文件格式,因为早期的Anrdroid手机内存,存储都比较小,而Class文件显然有很多可以优化的地方,比如每个Class文件都有一个常量池,里边存储了一些字符串。一串内容完全相同的字符串很有可能在不同的Class文件的常量池中存在,这就是一个可以优化的地方。当然,Dex文件结构和Class文件结构差异的地方还很多,但是从携带的信息上来看,Dex和Class文件是一致的。所以,你了解了Class文件(作为Java VM官方Spec的标准),Dex文件结构只不过是一个变种罢了(从学习到什么程度为止的问题来看,如果不是要自己来解析Dex文件,或者反编译/修改dex文件,我觉得大致了解下Dex文件结构的情况就可以了)。图8所示为Dex文件结构的概貌:
有一点需要说明:传统Class文件是一个Java源码文件会生成一个.Class文件,而Android是把所有Class文件进行合并,优化,然后生成一个最终的class.dex,如此,多个Class文件里如果有重复的字符串,当把它们都放到一个dex文件的时候,只要一份就可以了嘛。
dex头部信息中的magic取值为“dex\n035\0”
proto_ids:描述函数原型信息,包括返回值,参数信息。比如“test:()V”
methods_ids:函数信息,包括所属类及对应的proto信息。比如
"Lcom.test.TestMain. test:()V",.前面是类信息,后面属于proto信息
下面我们将示例TestMain.class转换成dex文件,然后再用dexdump工具看看它的结果,如图9所示:
具体方法:
- 先将.class文件转换成dex文件,工具是sdk build-tools下的dx命令。dx --dex --debug --verbose-dump --output=test.dex com/test/TestMain.class,生成test.dex文件。
- 同样,利用build-tools下的dexdump命令查看,dexdump -d -l plain test.dex,得到图9的结果
图9中的dexdump结果其实比图3还要清晰易懂。我们重点关注code段的内容(图中红框的部分):
- registers:Dalvik最初目标是运行在以ARM做CPU的机器上的,ARM芯片的一个主要特点是寄存器多。寄存器多的话有好处,就是可以把操作数放在寄存器里,而不是像传统VM一样放在栈中。自然,操作寄存器是比操作内存(栈嘛,其实就是一块内存区域)快。registers变量表示该方法运行过程中会使用多少个寄存器。
- ins:输入参数对应的个数,outs:此函数内部调用其他函数,需要的参数个数。
- insns:size:以4字节为单位,代表该函数字节码的长度(类似Class文件的code[]数组)
Android官方文档:https://source.android.com/devices/tech/dalvik/dex-format.html
说实话,写完这一小节的时候,我又反复看了官方文档还有其他一些参考文档。很痛苦,主要是东西太多,而我们目前又没有实际的问题,所以基本上是一边看一边忘!
恩。至少在这个阶段,先了解到这个程度就好。后面会随着学习的深入,有更多的深入知识,到时候根据需求再加进来。
2.2.2 odex介绍
再来看odex。odex是Optimized dex的简写,也就是优化后的dex文件。为什么要优化呢?主要还是为了提高Dalvik虚拟机的运行速度。但是odex不是简单的、通用的优化,而是在其优化过程中,依赖系统已经编译好的其他模块,简单点说:
- 从Class文件到dex文件是针对Android平台的一种优化,是一种通用的优化。优化过程中,唯一的输入是Class文件。
- odex文件就是dex文件具体在某个系统(不同手机,不同手机的OS,不同版本的OS等)上的优化。odex文件的优化依赖系统上的几个核心模块(由BOOTCLASSPATH环境变量给出,一般是/system/framework/下的jar包,尤其是core.jar)。我个人感觉odex的优化就好像是把中那些本来需要在执行过程中做的类校验、调用其他类函数时的解析等工作给提前处理了。
图10给出了图1所示示例代码得到的test.dex,然后利用dexopt得到test.odex,接着利用dexdump得到其内容,最后利用Beyond Compare比较这两个文件的差异。
图10中,绿色框中是test.dex的内容,红色框中是test.odex的内容,这也是两个文件的差异内容:
- test.dex中,TestMain类仅仅是PUBLIC的,但test.odex则增加了VERIFIED和OPTIMIZED两项。VERIFIED是表示该类被校验过了,至于校验什么东西,以后再说。
- 然后就是一些方法的不同了。优化后的odex文件,一些字节码指令变成了xxx-quick。比如图中最后一句代码对于的字节码中,未优化前invoke-virtual指令表示从method table指定项(图中是0002)里找到目标函数,而优化后的odex使用了invoke-virtual-quick表示从vtable中找到目标函数(图中是000b)。
vtable是虚表的意思,一般在OOP实现中用得很多。vtable一定比methodtable快么?那倒是有可能。我个人猜测:
- method表应该是每个dex文件独有的,即它是基于dex文件的。
- 根据odex文件的生成方法(后面会讲),我觉得vtable恐怕是把dex文件及依赖的类(比如Java基础类,如Object类等)放一起进行了处理,最终得到一张大的vtable。这个odex文件依赖的一些函数都放在vtable中。运行时直接调用指定位置的函数就好,不需要再解析了。以上仅是我的猜测。
1 http://mylifewithandroid.blogspot.com/2009/05/about-quick-method-invocation.html介绍了vtable的生成,大家可以看看
2 http://pallergabor.uw.hu/androidblog/dalvik_opcodes.html 详细描述了dex/odex指令的格式,大家有兴趣可以做参考。
(1) odex文件的生成
前面曾经提到过,odex文件的生成依赖于BOOTCLASSPATH提供的系统核心库。以我们这个简单的例子而言,core.jar是必须的(java基础类大部分封装在core.jar中)。另外,core.jar对应的core.odex文件也需要。所有这些文件我都已经上传到示例代码仓库的javavmtest/odex-test目录下。然后执行dextest.sh脚本。此脚本内容如下:
#!/bin/sh
#在根目录下建立/data/dalvik-cache目录,这是因为odex往往是在机器上生成的,所有这些目录都是
#设备上才有。我们模拟一下罢了
sudo mkdir -p /data/dalvik-cache/
#core.dex文件名:这也是模拟了机器上的情况。系统将dex文件的绝对路径名换成了@来唯一标示
#一个dex文件。由于我在制作core.dex的时候,该core.jar包放在了/home/innost/workspace/my-projects/
#javavmtest/odex-test下,生成的core.dex就应该命名为[email protected]@[email protected]@[email protected]@[email protected]
CORE_TARGET_DEX="[email protected]@[email protected]@[email protected]@core.jar@"
CURRENT_PATH=`pwd`
#为了减少麻烦,我这里做了一个链接,将需要的dex文件链接到此目录下的core.dex
sudo ln -sf ${CURRENT_PATH}/core.dex /data/dalvik-cache/${CORE_TARGET_DEX}classes.dex
rm test.odex
#设置BOOTCLASSPATH变量
export BOOTCLASSPATH=${CURRENT_PATH}/core.jar
/home/innost/workspace/android-4.4.4/out/host/linux-x86/bin/dexopt --preopt ${CURRENT_PATH}/test.jar test.odex "m=y u=n"
#删掉/data目录
sudo rm -rf /data
odex文件由dexopt生成,这个工具在SDK里没有,只能由源码生成。odex文件的生成有三种方式:
- preopt:即OEM厂商(比如手机厂商),在制作镜像的时候,就把那些需要放到镜像文件里的jar包,APK等预先生成对应的odex文件,然后再把classes.dex文件从jar包和APK中去掉以节省文件体积。
- installd:当一个apk安装的时候,PackageManagerService会调用installd的服务,将apk中的class.dex进行处理。当然,这种情况下,APK中的class.dex不会被剔除。
- dalvik VM:preopt是厂商的行为,可做可不做。如果没有做的话,dalvik VM在加载一个dex文件的时候,会先生成odex。所以,dalvik VM实际上用得是odex文件。以后我们研究dalvik VM的时候会看到这部分内容。
实际上dex转odex是利用了dalvik vm,里边也会运行dalvik vm的相关方法。
2.3 小结
本节主要介绍了Class文件,以及在Android平台上的变种dex和odex文件。以标准角度来看,Class文件是由Java VM规范定义的,所以通用性更广。dex或者是odex只不过是规范在Android平台上的一种具体实现罢了,而且dex/odex在很多地方也需要遵守规范。因为dex文件的来源其实还是Class文件。
对于初学者而言,我建议了解Class文件的结构为主。另外,关于dex/odex的文件结构,除非有明确需求(比如要自己修改字节码等),否则以了解原理就可以。而且,将来我们看到dalvik vm的实际代码后,你会发现dex的文件内容还是会转换成代码里的那些你很熟悉的类型,数据结构。比如dex存储字符串是一种优化后的方法,但是到vm代码中,还不是只能用字符串来表示吗?
另外,你还会发现,Class、dex还是odex文件都存储了很多源码中的信息,比如类名、函数名、参数信息、成员变量信息等,而且直接用得是字符串。这和Native的二进制比起来,就容易看懂多了。
三、字节码的执行
下面我们来讲讲字节码的执行。很多人对Java字节码到底是怎么运行的比较好奇。Java字节码的运行和操作系统上(比如Linux)一个进程是如何执行其代码,从理论上说是一致的。只不过Java字节码的执行是JVM,而操作系统上一个进程其代码的执行是由CPU来完成。当然,现在JVM也可以把Java字节码直接转成机器码,然后交给CPU来执行。这样可以显著提高运行速度。
本节我们将介绍Android平台上Java字节码的执行。当然,我并不会具体分析每一行代码都是怎么执行的(比如函数参数的入栈,寄存器的使用),而只是想向大家介绍大体的流程,满足大家的好奇心。如果有更深次的学习需求,你就可以在本节基础上自行开展了!
下面所讲内容的源码全部位于AOSP源码/dalvik/vm/mterp/out目录下
mterp/out目录下有好些个源码文件,如图11所示:
这个目录中的文件就是不同平台上,Java字节码处理的代码。每一个平台包含一个汇编文件和一个C文件。
- 前面讲过,Java字节码可以完全由JVM自己来执行,比如碰到一个new instance的字节码,就对应去调用内存分配函数。这种完全由JVM执行的情况,其对应代码位于InterpC-portable.cpp中。待会我们先分析它。
- 对于ARM平台,则有InterpAsm-armXXX.S和对应的InterpC-armXXX.cpp。其中.S文件是汇编文件,而.CPP文件是对应的C++文件。二者要结合起来使用。
- x86和mips平台与ARM平台类似。
- 当CPU类型不属于ARM、x86或mips(也不采用纯解释方法),则通过InterpAsm-allstubs.S和interpAsm-allsubts.cpp来处理。
下面我们看对于new操作,portable、arm平台的处理。
3.1 portable的纯解释执行
在InterpC-portable.cpp中,有几处关键代码,先来看图12:
在这段代码中:
- H(_op):这个宏定义了&&op_##_op这样的东西。op_#_op其实是一个标号(Label,和goto中的label是一个意思),而&&代表这个Label的地址[4]。
- HANDLE_OPCODE(_op):这个宏定义了一个标号op_##_op。
- 在FINISH宏中,有一个goto *handleTable,这是portable模式下JVM执行Java字节码的关键。简单点说,portable模式下,每一种Java操作码(OPCode)都对应有一个处理逻辑(是一段代码,但不一定是函数),FINISH宏就是取出当前的操作码,然后跳转(goto)到对应的处理逻辑去处理它。
那么,handlerTable是怎么定义的呢?来看图13:
图13中:
- dvmInterpretPortable是porttable模式下Java字节码的执行入口。也就是当执行Java字节码的时候(比如TestMain.class中的main函数时),都会调用这个函数。这里要强调一点,JVM执行的时候,除了Java字节码外,还有很多JVM自己的处理逻辑。比如分配内存时候对堆栈size的检查,看看是不是超标。
- DEFINE_GOTO_TABLE则定义了操作码的标记。
那么,new操作符对应的goto label在哪里呢?来看图14:
你看,portable.cpp中通过HANDLE_OPCODE(OP_NEW_INSTANCE)定义了new操作符的处理逻辑。这段逻辑中,真正分配内存的操作是由红框的dvmAllocObject来处理的。
看到这里,你会发现JVM执行Java字节码还是比较容易理解的。其实对于arm等平台也是这样。
3.2 ARM平台上的执行
和portable下dvmInterpretPortable函数(Java字节码执行的入口函数)相对应的,其他模式下的入口函数是dvmMterpStd,其代码如图15所示:
dvmMterpStd中最重要的是dvmMterpStdRun,这个函数是由各平台对应的xxx.S汇编文件定义的。InterpAsm-armv7-a-neon.S对应的dvmMterpStdRun函数以及对new的处理逻辑如图16所示:
图16中:
- dvmMterpStdRun也是通过GOTO_OPCODE调整到不同操作码处理逻辑的地方去执行。
- new操作符对应的OP_NEW_INSTANCE处理也会调用dvmAllocObject来分配内存喔。
3.3 小结
这一节我们介绍了JVM是怎么执行Java字节码的,主要以揭秘性质为主,大家也以掌握原理为首要任务。其中,portable模式下,操作码是一条一条解释执行的。而具体CPU平台上,则是由相关汇编代码来处理。二者实际上大同小异。但是由CPU来执行,显然处理要快,比如对于+这种操作,用portable的解释执行当然比直接转换成机器指令来执行要慢很多。
到此,我们了解了Class文件结构,以及Java字节码到底是怎么执行的。下一步,我们就开始正式分析Dalvik虚拟机了。
四、Dalvik虚拟机启动
4.1 dalvik的启动
Android平台中,第一个虚拟机是通过app_process进程启动的,这个进程也就是大名鼎鼎的Zygote(含义是受精卵)。Zygote的启动我在《深入理解Android卷I》第四章深入理解Zygote中有详细分析,这里我们简单回顾下。图17所示为zygote启动的触发机制:
上述代码是位于init.rc中,当Linux天字号第一进程init启动后,将执行init.rc中的内容。此处的zygote的一个Service,对应的进程是/system/bin/app_process,后面的--zygote...等是该进程的参数。
zygote,也就是app_process,其源码位于frameworks/base/cmds/app_process里,源码比较少,主要是一个App_main.cpp。其main函数如下:
int main(int argc, char* const argv[])
{
.......
AppRuntime runtime; //AppRuntime是关键数据结构
const char* argv0 = argv[0];
int i = runtime.addVmArguments(argc, argv);//添加参数,不重要
// Parse runtime arguments. Stop at first unrecognized option.
.......
if (zygote) {//我是zygote
runtime.start("com.android.internal.os.ZygoteInit",
startSystemServer ? "start-system-server" : "");
} ......
}
runtime是核心对象,其类型是AppRuntime,是定义在app_process中的一个Class,它从AndroidRuntime派生。start函数就是AndroidRuntime中的,用于启动VM的入口。
4.1.1 AndroidRuntime start之一
start函数我们分两部分讲,第一部分如图18所示:
第一部分包含三个主要函数:
- jni_invocation.Init:初始化JNI相关的几个重要函数。
- startVm:注意,它传入了一个JNIEnv* env对象进去,当这个函数返回时,我们在JNI中天天见的JNIEnv对象就是这个东西。startVm是Dalvik VM的核心,该函数返回后,VM就基本就绪了。
- startReg:注册Android平台中一些特有的JNI函数。
(1) JniInvocation Init
该函数内容如图19所示:
该函数:
- 通过dlopen加载libdvm.so。看来每个Java进程都会有这个东西。这可是dalvik vm的核心库。这个库有很多API,我个人觉得如果了解libdvm.so的话,应该能干很多事情。我们后续分析xposed就会看到。
- 从libdvm.so中找到JNI_GetDefaultJavaVMInitArgs、JNI_CreateVM和JNI_GetCreateJavaVMs这三个函数指针。
所以,以后调用比如JNI_CreateVM_函数的时候,我们知道它的真实实现其实是位于libdvm.so中的JNI_CreateVM就好。
比较简单,Nothing more....
4.2 startVM之旅
startVM属于Android Runtime start函数的第一部分,不过该函数内容比较多,我们单独搞一大节来讲它!
startVM此函数前面一大段都是参数处理,所以对本文有意义的内容其实只有图20所示的部分:
核心内容还是在libdvm.so中的JNI_CreateVM函数中,这个函数定义在dalvik/vm/jni.cpp中。来看它!
4.2.1 JNI_CreateJavaVM
(1) gDvm、JavaVMExt和JNIEnvExt
图21所示为此函数的主要代码:
图21中,首先扑面而来的就是Dalvik VM中的几个重量级数据结构:
- gDvm,全局变量,数据类型为结构体DvmGlobals,该结构体是Dalvik的核心数据结构,几乎所有的重要成员,控制参数(比如堆栈大小,状态、已经加载的类信息)等都通过gDvm来管理。
- JavaVMExt:JavaVM在JNI编程中代表虚拟机本身。在Dalvik中,这个虚拟机本身真正的数据类型是此处的JavaVMExt。由于JNI支持C和C++两种语言调用(对C而言,就是直接调用函数,对于C++而言,就是调用一个类的成员函数),所以JavaVM这个数据结构在C++里是一个类(如果定义了__cplusplus宏,就是_JavaVM类),在C里则是JNIInvokeInterface数据结构。
- 同样,对于JNIEnvExt而言,当使用C++编译时候,它就是__JNIEnv类,使用C编译时就是JNINativeInterface。
图22所示为JavaVMExt和JNIEnvExt的内容:
图22中可知:
- JavaVMExt有一个envList链表,该链表管理这一个Java进程中所有JNIEnv环境实体。JNIEnv环境和线程有关,什么样的线程会需要JNIEnv环境呢?所有从Java层调用JNI的线程以及从Native线程往调用Java函数的线程都需要创建一个JNIEnv。说白了,JNIEnv环境是Java和Native世界的桥梁。
- JNIEnvExt提供的跨Java和Native的桥梁主要就是JNIEnv定义的那些函数,它们统一保存在JNINativeInterface数据结构体中,比如图中右下角红框中的NewGlobalRef、NewLocalRef等。
- 注意,gDvm的funcTable变量指向了全局对象gInvokeInterface。该变量定义在dalvik/vm/jni.cpp中。
再来看gDvm的内容,它自己其实就是一大仓库,里边有很多成员变量,每个成员变量都有各自的用途。其内部如图23所示:
图23中:
- gDvm的数据类型是DvmGlobals,里边存储了整个Dalvik虚拟机中相关的参数,成员变量。其中loadedClasses代表虚拟机加载的所有类信息。
- classJavaLangClass指向一个类型为ClassObject的对象。ClassObject是Class信息在代码中的表示,其主要内容见图右上角,它包括类名信息、成员变量、函数(函数的代码表示是Method)等。classJavaLangClass代表的就是Java中最基础的java.lang.Class类。
- ClassObject从Object类派生(C++中,struct其实就是class)
这里要特别说明虚拟机中对类唯一性的确定方法:
1 对我们而言,类的唯一性由包名+类名表示,比如java.lang.Class这个类,就是唯一的。但实际上,根据Java VM规范,类的唯一性由全路径类名+定义它的ClassLoader两者唯一确定。
2 对一个类的加载而言,ClassLoader有两种情况。一种是直接创建目标类,这种loader叫Define Loader(定义加载器)。另外一种情况是一个ClassLoader创建了Class,但它可以自己直接创建,也可以是委托给比如父加载器创建的,这种Loader叫Initiating Loader(初始加载器)。
3 类的唯一性是由全路径类名+定义加载器唯一决定。
下面来看JNIEnvExt的创建,这是由图21中的dvmCreateJNIEnv函数完成的。
(2) dvmCreateJNIEnv
图21中的调用方法如下:
JNIEnvExt* pEnv = (JNIEnvExt*) dvmCreateJNIEnv(NULL);
该函数的相关代码如图24所示:
图24中,Dalvik虚拟机里JNI的所有函数都封装在gNativeInterface中。这个结构体包含了JNI定义的所有函数。注意,在使用sourceInsight的时候会有一些函数无法被解析。因为这些函数使用了类似图右下角的CALL_VIRTUAL宏方式定义。
我确认了下,应该所有函数的定义其实都在jni.cpp这一个文件里。
到此,我们为主线程创建和初始化了gDvm和JNI环境。下面来看dvmStartup。
4.2.2 dvmStartup:虚拟机创建的核心
去掉dvmStartup函数中一些判断代码后,该函数整个执行流程可由图25表示:
图25中,dvmStartup的执行从左到右。由于本章我只是想讨论dalvik是怎么执行的Java代码的,所以这里有一些函数(比如GC相关的,就不拟讨论)。
dvmStartup首先是解析参数,这些参数信息可能会传给gDvm相关的成员变量。解析参数是由setCommandLineDefaults和processOptions来完成的。具体代码就不看了,最终设置的几个重要的参数是:
- gDvm.executionMode = kExecutionModeJit:如果定义的WITH_JIT宏,则执行模式是JIT模式。
- gDvm.bootClassPathStr:由BOOTCLASSPATH环境变量提供。Nexus7 WiFi版4.4.4的值如图26所示。
- gDvm.mainThreadStackSize = kDefaultStackSize。kDefaultStackSize值为16K,代表主线程的堆栈大小
- gDvm.dexOptMode = OPTIMIZE_MODE_VERIFIED,用于控制odex操作,该参数表示只对verified的类进行odex。
图26为Nexus 7 Wi-Fi版4.4.4的BOOTCLASSPATH值:
图26可知,system/framework下几乎所有的jar包都被放在了BOOT CLASSPATH里。这意味这zygote进程加载了所有framework的包,这进一步意味着App也加载了所有framework的包.....。
下面来分析几个和本章目标相关的函数:
(1) dvmThreadStartup
图27所示为dvmThreadStartup的一些关键代码和解释:
Thread是Dalvik中代表和管理一个线程的重要结构。注意,这里的Thread不简单是我们在Java层中的线程。在那里,我们只需要在线程里执行要干得活就可以了。而这里的Thread几乎模拟了一个CPU(或者说CPU上的一个核)是怎么执行代码的。比如Thread中为函数调用要设置和维护一个栈,还要要有一个变量指向当前正在执行的指令(大名鼎鼎的PC)。这一块我不想浪费时间介绍,有兴趣的童鞋们可以此为契机进行深入研究。
(2) dvmInlineNativeStartup
dvmInlineNativeStartup主要是将一些常用的函数搞成inline似的。这里的inline,其实就是将某些Java函数搞成JNI。比如String类的charAt、compareTo函数等。相关代码如图28所示:
注意,在上面函数中,gDvm.inlineMethods只不过是分配了一个内存空间,该空间大小和gDvmInlineOpsTable一样。而gDvm.inlineMethods数组元素并未和gDvmInlineOpsTable挂上钩。当然,最终是会挂上的,但是不在这里。此处暂且不表。
(3) dvmClassStartup
下面我们跳到dvmClassStartup,这个函数很重要。图29是其代码:
图29中:
- 创建了一个Hash表,用来存储已经加载的类。
- 创建了代表java.lang.Class和所有基础数据类型的Class信息。
下面来看processClassPath这个函数,它要加载所有的Boot Class,由于它涉及到类的加载,所以它也是本文的重点内容。先来看图30:
processClassPath主要是处理BOOTCLASSPATH,也就是图26中的那些位于system/framework/下的jar包。图31展示了prepareCpe的代码,该函数处理一个一个的文件:
prepareCpe倒是很简单:
- 对于.jar/.zip/.apk结尾的文件,则调用dvmJarFileOpen进行处理。
- 对于.dex结尾的文件则调用dvmRawDexFileOpen进行处理。
- 处理成功后,则设置ClassPathEntry的kind为KCpeJar或者是KCpeDex,代表文件的类型是Jar还是Dex。并且设置cpe->ptr指针为对应的文件(jar文件则是JarFile,Dex文件这是RawDexFile)。存储它们的原因是因为后续要从这些文件中解析里边包含的信息。
这里我们看dvmJarFileOpen函数,如图32所示:
图32介绍了dvmJarFileOpen的主要内容,其中:
- 打开jar中的classes.dex文件,然后判断有没有对应的odex文件。如果没有,就调用dexopt生成一个odex文件。文件后缀还是.dex,但是路径位于/data/dalvik-cache下。
到此dvmClassStartup就介绍完了。下面来看一个重要函数,dvmFindRequiredClassesAndMembers。
(4) dvmFindRequiredClassesAndMembers
dvmFindRequiredClassesAndMembers初始化一些重要类和函数。其代码如图33所示:
dvmFindRequiredClassesAndMembers就是初始化一些类,函数,虚函数等等。我们重点关注它是怎么初始化的。一共有三个重要函数:
- findClassNoInit:和Java层的findClass有关,涉及到JVM中如何加载一个Class。
- dvmFindDirectMethodByDescriptor和dvmFindVirtualMethodByDescriptor:涉及到JVM中如何定位到一个方法。
重点是findClassNoInit,代码如图34所示:
图34中,有几个关键点:
- dvmLookupClass:这是从gDvm的已加载Class Hash表里搜索,看看目标Class是否已经加载了。注意搜索时的匹配条件:前面也曾经说到过,除了类名要相同之外,该类的类加载器也必须一样。另外,当待搜索类的类加载器位于clazz的初始化加载类列表中的时候,即使两个类的定义ClassLoader不一样,也可以满足搜索条件。关于初始类加载器来确定唯一性,我没有在JVM规范中找到明确的说明。
- loadClassFromDex:该函数将解析odex文件中的类信息。下面重点介绍它。
- dvmAddClasstoHash:把这个新解析得到的Class加到Class Hash表里。
- dvmLinkClass:解析这个Class的一些信息。比如,Class的基类是谁,该class实现了哪些接口。请大家回过头去看2.1节的图2 Class文件内部结构。一个Class的基类以及它实现的接口类信息都是通过对应的索引来间接指向基类Class以及接口类Class的。而dvmLinkClass处理完后,这些索引将由实际的ClassObject对象来替代。另外,dvmLinkClass将做一些校验,比如此Class的基类是final的话,那么这个Class就应该存在。
注意:我们在编写代码的时候,对于类的唯一性往往只知道全路径类名,很少关注ClassLoader的重要性。实际上,我之前曾经碰到过一个问题:通过两个不同ClassLoader加载的相同的Class居然不相等。当时很不明白为什么要这么设计, 直到我碰到一个真实事情:有一天我在等车,听见一个路人大声叫着“李志刚,李志刚”。我回头一看,以为他是在找人,结果发现他的宠物狗跑了出来。原来他的 宠物狗就叫李志刚。这就说明,两个具有相同名字的东西,实际上很能是完全不同的事物。所以,简单得以两个类是否同名来判断唯一性肯定是不行得了。
下面来看最重要的loadClassFromDex,这个函数其实就是把odex文件中的信息转换成ClassObject。我们来看它:loadClassFromDex代码如图34所示:
其中主要的加载函数是loadClassFromDex0,其代码如图35所示:
以上是loadClassFromDex0的第一部分内容,这这一块比较简单,也就是设置一些东西。下面看图36
图36中:
- newClazz的基类和它所实现的接口类,在loadClassFromDex0中还只是一索引来标识。最后这些索引会在dvmLinkClass里转换并指向成真正的ClassObject。
- 然后调用loadSFieldFromDex来解析类的静态成员信息。成员信息由数据结构DexFieldId表示,其实包含的那些信息
其实loadClassFromDex0后面的工作也类似,比如解析成员函数信息,成员变量信息等。我们直接看相关函数吧:
图37展示了解析成员变量和解析函数用的两个函数。
注意native函数的处理,此处是先用dvmResolveNativeMethod顶着。我们以后分析JNI的时候再来讨论它。
上面的findClassNoInit是用于搜索Class的,下面我们来看dvmFindDirectMethodByDescriptor函数,它是用来搜索方法的,代码如图38所示:
对compareMethodHelper好奇的读者,我在图40里展示了如何从dex文件中获取一个函数的返回值信息。
好像感觉我们一直和字符串在玩耍。
4.3 小结
说实话,讲到现在,其实虚拟机启动的流程差不多就完了。当然,本节所说的这个流程是很粗犷的,主要内容还是集中在Class的加载上,然后浮光掠影看了下一些重要的数据结构。Anyway,上述流程,我建议读者结合代码反复走几个来回。下面我们将开始介绍一些细节性的内容:
- 第五章介绍类的初始化和加载。
- 第六章介绍Java中的函数调用到底是怎么实现的。
- 第七章介绍JNI的内容。
五、Class的加载和初始化
JVM中,一个Class首先被使用的时候会调用它的<clinit>函数。<clinit>函数是一个由编译器生成的函数,当类有static成员变量或者static语句块的时候,编译器就会为它生成这个函数。那么,我们要搞清楚这个函数在什么时候被调用,以什么样的方式被调用。
先来看一段示例代码,如图41所示:
示例代码中:
- TestMain有一个静态成员变量another,其类型是TestAnother。初始值是NULL。
- main函数中,构造了这个TestAnother对象。
- TestAnother有一个静态成员变量testCLinit和static语句。
- 最后一个图是执行结果。从其输出来看,main函数的“00000”先执行,然后执行的是TestAnother的static语句,最后是TestAnother的构造函数。
问题来了:TestAnother的<clinit>什么时候被调用?我一开始思考这个问题的时候:这个函数是编译器自动生成的,那么调用它的地方是不是也由编译器控制呢?
要确认这一点,只需要看dexdump的结果,如图42所示:
图42中:
- 上图:由于TestMain也有静态成员变量,所以编译器为它生成了<clinit>函数。在它的<clinit>中,由于another变量赋值为null,所以没有触发another类的加载(不过,这个结论不是由图42得到的,而是由图41日志输出的顺序得到的)。
- 下图:是TestMain的main函数。我们来看another对象的创建,首先是通过new-instance指令创建,然后通过invoke-direct调用了TestAnother的<init>函数。是的,你没看错,TestAnother的构造函数(也就是<init>)是明确被调用的,但是TestAnother的<clinit>调用之处却毫无踪迹。
当然,根据图41的日志输出,我们知道<clinit>是在TestAnother的构造函数之前调用的,那唯一有可能的地方会不会是new-instance呢?
5.1 new-instance
我们在3.1节portable的纯解释执行一节中提到过new-instance,下面我们将以portable为主要讲解对象来介绍。
其实,不管是portable还是arm、x86方式,最终都会变成机器指令来执行。相对arm、x86的汇编代码,portable是以C语言实现的Java字节码解释器,非常方便我们理解。
图43为new-instance指令对应的代码:
第六节会介绍portable模式下Java函数是如何执行的,所以这里大家先不用管HANDLE_OPCODE这样的宏是干什么用的。图43中:
- 先调用dvmDexGetResolvedClass,看看目标类TestAnother是不是已经被解析过了。前面曾经提到说,一个类在初始化的时候可能会解析它所使用到的其他类。
- 假设被引用的类没有解析过,则调用dvmResolveClass来加载目标类。
- 目标类加载成功后,如果该类没有初始化过,则调用dvmInitClass进行初始化。
我们重点介绍dvmResolveClass和dvmInitClass。
5.1.1 dvmResolveClass分析
图44是dvmResolveClass的代码:
图44中:
- 上图是dvmResolveClass的代码,其主要逻辑就是先得到目标类名(Lcom/test/TestAnother;)然后调用dvmFindClassNoInit来加载目标类。
- 下图是dmvFindClassNoInit的代码,由于referrer的ClassLoader(也就是使用TestAnother类的TestMain类的ClassLoader)不为空,代码逻辑将走到findClassFromLoaderNoInit。注意,dvmFindSystemClassNoInit我们在4.2.2.4节将bootclass类解析的时候讲过。
图45是findClassFromLoaderNoInit的代码,出奇的简单:
代码真是简洁啊,居然调用java/lang/ClassLoader的loadClass函数来加载类。当然,dalvik中调用Java函数是通过dvmCallMethod来实现的。这个函数我们下一节再介绍。然后,我们把loader存储到目标clazz的初始加载loader链表中。初始加载链表在决定类唯一性的时候很有帮助(不记得初始加载器和定义加载器的同学们,请回顾图23后的说明和图33)。
Anyway,到此,目标类就算加载成功了。类加载成功到底意味这什么?前面讲过loadClassFromDex等函数,类加载成功意味着dalvik虚拟机从dex字节码文件中成功得到了一个代表该类的ClassObject对象,里边该填的信息在这里都填好了!
加载成功,下一步工作是初始化,来看下一节:
5.1.2 dvmInitClass分析
图46为dvmInitClass的代码:
终于,在dvmInitClass中,我们看到了<clinit>的执行。其他感觉没什么特别需要说的了。
再次强调,本章是整个虚拟机旅程中一次浮光掠影般的介绍,先让大家,包括我自己看看虚拟机是个什么样子,有一个粗略的认识即可。后续有打算搞一个完整的,严谨的,基于ART的虚拟机分析系列。
六、Java函数是怎么run起来的
JVM规范定义了JVM应该怎么执行一个函数,东西较碎,但和其他语言一样,无非是如下几个要点:
- JVM在执行一个函数之前,它会首先分配一个栈帧(JVM中叫Frame),这个Frame其实就是一块内存,里边存储了参数,还预留了空间用来存储返回值,还有其他一些东西。
- 函数执行时,从当前栈帧(每一个函数执行之前,JVM都会为它分配一个栈帧)获取参数等信息,然后执行,然后将返回值存储到当前栈帧。当前正在执行的函数叫current Method(当前方法)
- 函数返回后,JVM回收当前栈帧。
函数执行肯定是在一个线程里来做的,栈帧则理所当然就会和某个线程相关联。我们先来看dalvik是怎么创建线程及对应栈的。
6.1 allocThread分析
Dalvik中,allocThread用于创建代表一个线程的线程对象,其代码如图47所示:
图47是dalvik虚拟机为一个线程创建代表对象的处理代码,其中,它为每个线程都创建了一个线程栈。线程栈大小默认为16KB,并设置了相关的栈顶和栈底指针,如图中右下角所示:
- interpStackStart为栈顶,位于内存高位值。
- interpStackEnd为栈底,位于内存地位。
- 整个栈的内存起始位置为stackBottom。stackBottom和interpStackEnd还有一个768字节的保护区域。如果栈内容下压到这块区域,就认为出错了。
每个线程都分配16KB,会不会耗费内存呢?不会,这是因为mmap只是在内核里建立了一个内存映射项,这个项覆盖16KB内存。注意,它只是告诉kernel,这块区域最大能覆盖16KB内存。如果一直没有使用这块内存的话,那么内存并不会真正分配。所以,只有我们真正操作了这块内存,系统才会为它分配内存。
6.2 dvmCallMethod
dalvik中,如果需要调用某个函数,则会调用dvmCallMethod(嗯嗯?不对吧,Java字节码里的invoke-direct指令难道也是调用这个么?别急,待会再说invoke-direct的实现。)
dvmCallMethod第一步主要是调用callPrep准备栈帧,这是函数调用的关键一步,马上来看:
6.2.1 dvmPushInterpFrame
当调用一个Java函数时,JVM需要为它搞一个新的栈帧,图49展示了dvmPushInterpFrame的代码
图49中:
- 一个栈帧的大小包括两个StackSaveArea和输入参数及函数内部本地变量(大小为method->registersSize*4)所需的空间。但是,在计算栈是否overflow的时候,会额外加上该函数内部调用其他函数时所传参数所占空间(大小为method->outsSize*4)
- 这两个StackSaveArea,一个叫BreakSaveBlock,另外一个叫SaveBlock。其分布如图49中右下角位置所示。这两个SSA的作用,我们后面将看到。
- self->interpSave.curFrame指向saveBlock的高地址。紧接其上的就是参数空间
1 注意:registersSize包括函数输入参数和函数内部本地变量的个数
2 dvmPushJNIFrame,这个函数是当Java要调用JNI函数时的压栈处理,该函数和dvmPushInterpFrame几乎一样,只是在计算所需栈空间时,没有加上outsSize*4,因为native函数所需栈是由Native自己控制的。此函数代码很简单,请童鞋们自己学习
好了,栈已经准备好了,我们看看函数到底怎么执行。
6.2.2 参数入栈
图48中dvmCallMethodV调用callPrep之后,有一段代码我们还没来得及展示,如图50所示:
参数入栈,您看明白了吗?
6.2.3 调用函数
接着看dvmCallMethodV调用函数部分,如图51所示
对于java函数,其处理逻辑由dvmInterpret完成,对于Native函数,则由对应的nativeFunc完成。JNI我们放到后面讲,先来处理dvmInterpret。如图52所示:
图52中:
- self->interpSave.pc指向要指向函数的指令部分(method->insns)
下面我们来看dvmInterpretPortable的处理:
(1) dvmInterpretPortable
dvmInterpretPortable位于dalvik/vm/mterp/out/InterpC-portable.cpp里,这个InterpC-portable.cpp是用工具生成的,将分散在其他地方的函数合并到最终这一个文件里。我们先来看该函数的第一段内容,如图53所示:
第一部分中,我们发现dvmInterpretPortable通过DEFINE_GOTO_TABLE定义了一个handlerTable[kNumPackedOpcodes]数组,这个数组里的元素通过H宏定义。H宏使用了&&操作符来获取某个goto label的位置。比如图中的H(OP_RETURN_VOID),展开这个宏后得到&&op_OP_RETURN_VOID,这表示op_OP_RETURN_VOID的位置。
那么,这个op_OP_RETURN_VOID标签是谁定义的呢?恩,图中的HANDLE_OPCODE宏定义的,展开后得到op_OP_RETURN_VOID:。
最后:
- pc=self->interpSave.pc:将pc指向self->interpSave.pc,它是什么?回顾图52,原来这就是method->insns。也就是这个方法的第一个字节码指令。
- fp=self->interpSave.curFrame:参看图50右边的示意图。
来看portable模式下Java字节码的处理,这也是最精妙的一部分,如图54所示:
请先认真看图54的内容,然后再看下面的总结,portable模式下:
- FINISH(0):移动PC,然后获取对应指令的操作码到ins。根据ins获取该指令的操作码(注意,一条指令包含操作码和操作数),然后goto到该操作码对应的处理label处。
- 在对应label处理逻辑处:从指令中提取参数,比如INST_A或INST_B。然后处理,然后再次调整PC,使得它能处理下一条指令。
好了,portable模式下dalvik如何运行java指令就是这样的,就是这么任性,就是这么简单。下面,我们来看Invoke-direct指令又是如何被解析然后执行的。
(2) invoke-direct指令是如何被执行的
刚才你看到了portable模式下指令的执行,就是解析指令的操作码然后跳转到对应的label。假设我们现在碰到了invoke-direct指令,这是用来调用函数的。我们看看dvmInterpretPortable怎么处理它。一个图就可以了,如图55所示:
就是跳来跳去麻烦点,其实和dvmCallMethod一样一样。
(3) 函数返回
一切尽在图56。
函数返回后,还需要pop栈帧,代码在stack.cpp的dvmPopFrame中。此处略过不讨论了。
6.3 小结
这一节你真得要好好思考,函数调用,不论是Java、C/C++,Python等等,都有这类似的处理:
- 建立栈帧,参数入栈。
- 跳转到对应函数的位置,native就是函数地址指针,Java这是goto label,转换成汇编还是地址指针。
- 函数返回,pop栈帧。
这好像是程序设计的基础知识,这回你真正明白了吗?
七、JNI相关
关于JNI,我打算介绍下面几个内容:
- Java层加载so库,so库中一般会注册相关JNI函数。
- Java层调用native函数。
native库中,如果某个线程需要调用java函数,它会先创建一个JNIEnv环境,然后callXXMethod来调用Java层函数。这部分内容请大家自行研究吧....
把这几个步骤讲清楚的话,JNI内容就差不多了。
7.1 so加载和JNI函数注册
7.1.1 so文件搜索路径和so加载
APP中,如果要使用JNI的话,native函数必须封装在动态库里,Windows平台叫DLL,Linux平台叫so。然后,我们要在APP中通过System.loadLibrary方法把这个so加载进来。所以,入口是System的loadLibrary函数。相关代码如图57所示:
图57是System.loadLibrary的相关代码。这里主要介绍了so加载路径的问题:
- 我们在应用里调用loadLibrary的时候系统默认会传入调用类的ClassLoader。如果有ClassLoader,则so必须由它加载。原因其实很简单,就是APP只能加载自己的so,而不能加载别的APP的so。这种做法和传统的linux平台上把so的搜索路径设置到LD_LIBRARY_PATH环境变量中有冲突,所以Android想出了这种办法。
- 如果没有ClassLoader,则还是使用传统的LD_LIBRARY_PATH来搜索相关目录以加载so。
这里再明确解释下,loadLibrary只是指定了so文件的名字,而没有指定绝对路径。所以虚拟机得知道去哪个目录搜索这个文件。传统做法是搜索LD_LIBRARY_PATH环境变量所表明的文件夹(AOSP默认是/vendor/lib和/system/lib)这两个目录。但是我刚才讲,如果使用传统方法,APP A有so要加载的话,得把自己的路径加到LD_LIBRARY_PATH里去。比如LD_LIBRARY_PATH=/vendor/lib:/system/lib:/data/data/pkg-of-app-A/libs,这种方法将导致任何APP都可以加载A的so。
真正的加载由doLoad函数完成。这个函数相关的代码如图58所示:
没什么太多可说的,无非就是dlopen对应的so,然后调用JNI_OnLoad(如果该so定义了这个函数的话)。另外,dalvik虚拟机会保存自己加载的so项。
注意,图58里左边有两个笑脸,当然是很“阴险”的笑脸。什么意思呢?请童鞋们看看nativeLoad和它对应的Dalvik_java_lang_Runtime_nativeLoad函数。你会发现Runtime_nativeLoad的函数参数声明好奇怪,完全不符合JNI规范。并且,Runtime_nativeLoad的函数返回是void,但是Java中的nativeLoad却是有返回值的。怎么回事???此处不表,下文接着说。
7.1.2 JNI 函数主动注册和被动注册
(1) 调用RegisterNatives主动注册JNI函数
我们在JNI里,往往会自行注册java中native函数和native层对应函数的关系。这样,Java层调用native函数时候就会转到native层对应函数来执行。注册,是通过JNIEnv的RegisterNatives函数来完成的。我们来看看它的实现。如图59所示:
RegisterNatives里有几个比较重要的点:
- 如果签名信息以!开头,则采用fastjni模式。这个玩意具体是什么,我们后面会讲。
- Method的nativeFunc指向dvmCallJNIMethod,当java层调用native函数的时候会进入这个函数。而真正的native函数指针则存储在Method->insns中。我们知道insns代表一个函数的字节码.....。
(2) 被动注册
被动注册,也就是JNI里不调用RegisterNatives函数,而是让虚拟机根据一定规则来查找native函数的实现。一般的JNI教科书都是介绍被动注册,不过我从《深入理解Android卷1》开始就建议直接上主动注册方法。
dalvik中,当最开始加载类并解析其中的函数时,如果标记为native函数,则会把Method->nativeFunc设置为dvmResolveNativeMethod(请回头看图37)。我们来看这个函数的内容,如图60所示:
被动注册的方式是在该native函数第一次调用的时候被处理。童鞋们主要注意native函数的匹配规则。Anyway,不建议使用被动注册的方法,因为native层设置的函数名太长,搞起来很不方便。
7.2 调用Java native函数
6.2节专门讲过如何调用java函数,故事还得从dvmCallMethodV说起,如图61所示:
整个流程如下:
- dvmCallMethodV发现目标函数是native的时候,就直接调用method->nativeFunc。当native函数已经解析过的时候,一般情况下该函数都指向dvmCallJNIMethod。如果这个native函数之前没有解析,则它指向dvmResolveNativeMethod。
- dvmCallJNIMethod进行参数处理,然后调用dvmPlatformInvoke,这个函数一般由不同平台的汇编代码提供,大致工作流程也就是解析参数,压栈,然后调用method->insns指向的native层函数。
图62是X86平台上关于dvmPlatformInvoke注释:
也就是解析参数嘛,不多说了。和前面讲的Java准备栈帧类似,无非是用汇编写得罢了。
(1) 神秘得fastJni
fastJni,唉,可惜代码里有这个,但是好像没地方用。干啥的呢?还记得我们前面图58里的两个笑脸吗?
实话告诉大家,fastJni如果真正实现的话,可以加快JNI层函数的调用。为什么?我先给你看个东西,如图63所示:
图63需要好好解释下:
- 首先,我们有两种类型的函数,一个是DalvikBridgeFunc,这个函数有四个参数。一个是DalvikNativeFunc,这个函数有两个参数。
- dvmResolveNativeMethod或者是dvmCallJNIMethod都属于DalvikBridgeFunc类型。
- 不过,如果是dalvik内部注册的native函数时候,比如Dalvik_java_lang_Runtime_nativeLoad这样的,它就属于dalvik内部注册的native函数,这个函数的类型就是DalvikNativeFunc。参考图61右上角。也就是说,Android为java.lang.Runtime.nativeLoad这个java层的native函数设置了一个native层的实现,这个实现就是Dalvik_java_lang_Runtime_nativeLoad。
- 接着,这个函数被强制转换成DalvikBridgeFunc类型,并且设置到了Method->nativeFunc上。
这种做法会造成什么后果呢?
- dvmCallMethodV发现自己调用的是native函数时候,直接调用Method->nativeFunc,也就是说,要么调用到dvmCallJNIMethod(或者是dvmResolveNativeMethod,姑且不论它)要么就直接调用到Dalvik_java_lang_Runtime_nativeLoad上了。
注意喔,这两个函数的参数一个是四个参数,一个是两个参数。不过注释中说了,给一个只有两个参数的函数传4个参数没有问题.....
等等,这么做的好处是什么?
- 原来,dvmCallJNIMethod干了好多杂事,比如参数解析,参数入栈,然后才是通过dvmPlatformInvoke来调用真正的native层函数。而且还要对返回值进行处理。
- fastJni模式下,直接调用对应的函数(比如Dalvik_java_lang_Runtime_nativeLoad),这样就没必要做什么参数入栈之类,也不用借助dvmPlatformInvoke再跳转了,肯定比dvmCallMethod省了不少时间。
当然,fastJni模式是有要求的,比如是静态,而且非synchronized函数。Anyway,目前这么高级的功能还是只有虚拟机自己用,没放开给应用层。
八 dalvik虚拟机小结
本篇是我第一次细致观察Android上Java虚拟机的实现,起因是想知道xposed的原理。我们下一篇会分析xposed的原理,其实蛮简单。因为xposed只涉及到了函数调用,hook之类的东西,没有虚拟机里什么内存管理,线程管理之类的。所以,我们这两篇文章都不会涉及内存管理,线程管理之类的高级玩意儿。
简单点说,本章介绍得和dalvik相关的内容还是比较好理解。希望各位先看看,有个感性认识,为将来我们搞更深入的研究而打点基础。
- 参考文档。
- 很详细的关于dex文件的中文介绍。
- dex/odex指令集可参考这里。
- 解释器中对标号的使用。
- 深入理解Android卷1和卷2的电子版已经全部公开,卷1第四章内容请参考这里。