一、背景
这个选题非常大,但并非一開始就有这么高大上的追求。
最初之时,仅仅是源于对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这个数据结构真得是太easy理解了。相比那些native的二进制程序而言,ClassFile的组织结构和Java源代码的组织结构匹配度非常高,以致于我第一眼看到这个结构体时,我认为自己差点儿相同就理解了它:
- 比方。类的是public的还是final的。还是interface,就由access_flags来表示。其详细取值我认为都不用管,代码中用得是名字诸如ACC_XXX这样得的标志位来表示,一看知道是啥玩意儿。
- Java类中定义的域(成员变量),方法等都有相应的数据结构来表达。并且还是个数组。
- 唯一有点特别之处的是常量池。
什么东西会放在常量池呢?最easy想到的就是字符串了。对头,这个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
这个类型是用于描写叙述类信息的。此处的类信息非常easy,就是类名(也就是代表类名的字符串)
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无非是描写叙述了函数或成员变量的名字,參数。类型等信息。可是真正的方法、成员变量信息还包含比方訪问权限,注解。源代码位置等。对于方法来说,更重要的还包含其函数功能(即这个函数相应的字节码)。
在Java VM中。方法和成员变量的完整描写叙述由如图5所看到的的数据结构来表达的:
- access_flags:描写叙述诸如final,static,public这样的訪问标志
- name_index:方法或成员变量名在常量池中相应的索引,类型是Utf8_Info
- attribute_info:是域或方法中非常重要的信息。我们单独用一节来介绍它。
2.1.3 attribute_info介绍
attribute_info结构体非常easy,例如以下代码所看到的:
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 035 ”
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就应该命名为home@innost@workspace@my-projects@javavmtest@odex-test@core.jar@classes.dex
CORE_TARGET_DEX="home@innost@workspace@my-projects@javavmtest@odex-test@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的二进制比起来,就easy看懂多了。
三、字节码的运行
以下我们来讲讲字节码的运行。非常多人对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字节码还是比較easy理解的。事实上对于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倒是非常easy:
- 对于.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自己控制的。此函数代码非常easy,请童鞋们自己学习
好了,栈已经准备好了,我们看看函数究竟怎么运行。
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必须由它载入。原因事实上非常easy,就是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第四章内容请參考这里。