虽然 Android 平台使用 Java 语言来开发应用程序,但 Android 程序却不是运行在标准 Java 虚拟机上,而是 Dalvik Virtual Machine(Dalvik 虚拟机)。
Dalvik 虚拟机的特点
与 Java 虚拟机的区别
主要区别:
Java 虚拟机运行的是 Java 字节码,Dalvik 虚拟机运行的是 Dalvik 字节码。
所有的 Dalvik 字节码由 Java 字节码转化而来,并且被打包到一个 DEX(Dalvik Executable)可执行文件中。Dalvik 虚拟机通过解释 DEX 文件来执行这些字节码。
Dalvik 可执行文件体积更小。
Android SDK 中有一个叫
dx
的工具负责将 Java 字节码转换为 Dalvik 字节码。Java 虚拟机与 Dalvik 虚拟机架构不同。
Java 虚拟机基于栈结构。程序在运行时需要频繁地从栈上读取或写入数据。
Dalvik 虚拟机基于寄存器架构。数据的访问通过寄存器间直接传递。
实例:
编写
Java
程序如下:1 2 3 4 5 6 7 8 9 10 11
// Hello.java public class Hello { public int foo(int a, int b) { return (a+b) * (a-b); } public static void main(String[] argc) { Hello hello = new Hello(); System.out.println(hello.foo(5,3)); } }
执行以下命令生成
.class
文件:1
$ javac Hello.java
执行以下命令生成
.dex
文件:1
$ dx --dex --output=Hello.dex Hello.class
使用
javap
反编译Hello.class
查看foo()
函数的 Java 字节码:1 2 3 4 5 6 7 8 9 10 11 12 13
$ javap -c -classpath . Hello ... public int foo(int, int); Code: 0: iload_1 1: iload_2 2: iadd 3: iload_1 4: iload_2 5: isub 6: imul 7: ireturn ...
Java 虚拟机的指令集被称为零地址指令集,是指指令集的目标参数和源参数都是隐含的,它通过 Java 虚拟机中一个称作”求值栈“的数据结构传递。
完整的 Java 字节码指令列表可以参考维基百科:https://en.wikipedia.org/wiki/Java_bytecode_instruction_listings
使用
dexdump
可以查看 Dalvik 字节码,执行以下命令:1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
$ dexdump -d Hello.dex ... Virtual methods - #0 : (in LHello;) name : 'foo' type : '(II)I' access : 0x0001 (PUBLIC) code - registers : 5 ins : 3 outs : 0 insns size : 6 16-bit code units 000198: |[000198] Hello.foo:(II)I 0001a8: 9000 0304 |0000: add-int v0, v3, v4 0001ac: 9101 0304 |0002: sub-int v1, v3, v4 0001b0: b210 |0004: mul-int/2addr v0, v1 0001b2: 0f00 |0005: return v0 catches : (none) positions : 0x0000 line=3 locals : 0x0000 - 0x0006 reg=2 this LHello; ...
Dalvik 虚拟机是如何执行程序的
Dalvik 虚拟机属于 Android 运行时环境,它与一些核心库共同承担 Android 应用程序的运行工作。
简述:
Android 系统启动加载完内核之后,第一个执行的是
init
进程它首先要做的是设备的初始化工作,然后读取
inic.rc
文件并启动系统中的重要外部程序 Zygote。Zygote 进程是 Android 所有进程的孵化器进程
它启动后会首先初始化 Dalvik 虚拟机,然后启动 system_server 并进入 Zygote 模式,通过 socket 等待命令。
当执行一个 Android 应用程序时,system_server 进程通过 Binder IPC 方式发送命令给 Zygote,Zygote 收到命令后通过 fork 自身创建一个 Dalvik 虚拟机的实例来执行应用程序的入口函数。
当进程 fork 成功后,执行的工作就交给了 Dalvik 虚拟机。
Dalvik 虚拟机首先通过
loadClassFromDex()
函数完成类的装载工作,每个类被成功解析后都会拥有一个ClassObject
类型的数据结构存储在运行时环境中,虚拟机使用gDvm.loadedClasses
全局哈希表来存储与查询所有装载进来的类。随后,字节码验证器使用
dvmVerifyCodeFlow()
函数对装入的代码进行校验。接着虚拟机调用
FindClass()
函数查找并装载 main 方法类,随后调用dvmInterpret()
函数初始化解释器并执行字节码流。
Dalvik 虚拟机 JIT(即时编译)
主流的 JIT 包括两种字节码编译方式:
method
方式:以函数或方法为单位进行编译;trace
方式:以 trace 为单位进行编译。编译执行比较频繁的 ”热路径“ 代码。
Dalvik 汇编语言基础
Dalvik 指令格式
一段 Dalvik 汇编代码由一系列 Dalvik 指令组成,指令语法由指令的位描述与指令格式标识来决定。
位描述的约定如下:
- 每 16 位的字采用空格分隔开来。
- 每个字母表示 4 位,每个字母按顺序从高字节开始,排列到低字节。
- 四位的内部可以用 ”|“ 来表示不同的内容。
- 顺序采用
A~Z
的单个大写字母表示一个 4 位操作码,op 表示一个 8 位操作码。 - ”$$\varnothing$$“ 来表示这个字段的所有位为 0。
例子:”A|G|op BBBB F|E|D|C“
指令格式的约定如下:
- 指令格式标识大多由三个字符组成,前两个是数字,最后一个是字母;
- 第一个数字是表示指令有多少个 16 位的字组成;
- 第二个数字是表示指令最多使用寄存器的个数。特殊标记 ”r“ 标识使用一定范围内的寄存器。
- 第三个字母表示类型码,表示指令用到的额外数据的类型;可能值如下表所示:
助记符 | 位大小 | 说明 |
---|---|---|
b | 8 | 8 位有符号立即数 |
c | 16, 32 | 常量池索引 |
f | 16 | 接口常量(仅对静态链接格式有效) |
h | 16 | 有符号立即数(32 位或 64 位数的高位值,低位为 0) |
i | 32 | 立即数,有符号整数或 32 位浮点数 |
l | 64 | 立即数,有符号整数或 64 位双精度浮点数 |
m | 16 | 方法常量(仅对静态链接格式有效) |
n | 4 | 4 位立即数 |
s | 16 | 短整形立即数 |
t | 8, 16, 32 | 跳转、分支 |
x | 0 | 无额外数据 |
最新的 Dalvik 字节码 Reference:https://source.android.com/devices/tech/dalvik/dalvik-bytecode
另外,Dalvik 对语法做了一些额外的说明:
每个指令以命名的操作码开始,后面可选择使用一个或多个参数,并且参数之间用逗号分隔。
每条指令的参数从指令的第一部分开始,op 位于低 8 位,高 8 位可以是一个 8 位的参数,也可以是两个 4 位的参数,也可以为空;如果指令超过 16 位,则后面的部分依次作为参数。
命名寄存器的参数形式为“
vX
”,比如:v0, v1
。选择“v
”而不是更常用的“r
”作为前缀,是因为这样可避免与可能会在其上实现 Dalvik 可执行格式的(非虚拟)架构(其寄存器使用“r
”作为前缀)出现冲突。(也就是说,我们可以直截了当地同时讨论虚拟和实际寄存器。)
表示字面量、常数的参数形式为“
#+X
”。有些格式表示高阶位仅为非零位的字面量;对于这种类型的字面量,在语法表示时会明确写出后面的 0,但是在按位表示时这些 0 会被省略。表示相对指令地址偏移量的参数形式为“
+X
”。表示字面量常量池索引的参数形式为“
kind@X
”,其中“kind
”表示正在引用的常量池。每个使用此类格式的操作码明确地表示只允许使用一种常量;请查看操作码参考,找出对应关系。常量池的种类包括“
string
”(字符串池索引)、“type
”(类型池索引)、“field
”(字段池索引) 、“meth
” (方法池索引)和“site
”(调用点索引)。
DEX 文件反汇编工具
目前 DEX 可执行文件主流的反汇编工具有 BakSmali 和 Dedexer。
测试代码使用之前的 Hello.java
,使用下面的命令编译生成 dex 文件:
|
|
使用
baksmali.jar
通过以下的命令反汇编Hello.dex
(bakmali
的使用方法与书本描述不一样,详细使用方法可以hi使用java -jar baksmali.jar help
查看更加详细的使用方法):1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
$ java -jar baksmali.jar dis -o baksmaliout Hello.dex $ cat baksmaliout/Hello.smali ... # virtual methods .method public foo(II)I .registers 5 .prologue .line 3 add-int v0, p1, p2 sub-int v1, p1, p2 mul-int/2addr v0, v1 return v0 .end method
该命令成功执行后,会生成
baksmali/Hello.smali
文件。使用
ddx.jar
通过以下的命令反汇编Hello.dex
:1 2 3 4 5 6 7 8 9 10
$ java -jar ddx.jar -d ddxout Hello.dex $ cat ddxout/Hello.ddx ... .method public foo(II)I add-int v0,v3,v4 sub-int v1,v3,v4 mul-int/2addr v0,v1 return v0 .end method
命令执行成功后,会生成
ddxout/Hello.ddx
文件。两种反汇编代码的结构组织是一样的,在方法名、字段类型与代码指令序列上它们保持已知,具体表现在一些语法细节上:
- 前者使用
.registers
指令指定函数用到的寄存器数目,后者则在.registers
之前加了limit
前缀; - 前者使用
p0
做this
引用,后者则使用v2
做this
引用; - 前者使用
.parameter
指定参数,后者则使用parameter 数组
指定参数; - 前者使用
.prologue
做函数代码的起始位置,后者没有; - 前者使用 p 命名法命名寄存器,后者使用 v 命名法命名寄存器。
- 前者使用
Dalvik 寄存器
ANDROID 源码可以在以下网址查看:https://android.googlesource.com
GITHUB 上有源码的镜像:https://github.com/aosp-mirror
Dalvik 源码可以使用以下的命令下载最新版本(官网总是 Timeout):
1 2 3 4 5 6
$ git clone --depth=1 https://github.com/aosp-mirror/platform_dalvik.git dalvik $ cd dalvik $ git fetch --depth=1 origin gingerbread:gingerbread # 书本上的源码讲的是 gingerbread 这个分支的源码
Dalvik 虚拟机基于寄存器架构,在设计之初采用了 ARM 架构(CPU 本身集成了多个寄存器)。
Dalvik 虚拟机如何虚拟地使用寄存器呢?
每个函数在函数头声明其使用的寄存器数量,虚拟机执行到这个函数时,根据其寄存器的数目分配适当的栈空间,用来存放寄存器实际的值;
虚拟机通过处理字节码,对寄存器进行读写操作就是在写栈空间,Android SDK 中有一个名为
dalvik.bytecode.Opcodes
的接口,它定义了一份完整的 Dalvik 字节码列表处理这些字节码的函数为一个宏
HANDLE_OPCODE()
,处理过程函数可以在 Android 源代码dalvik/vm/mterp/c
中找到。
下面以 OP_MOVE.cpp
举例:
|
|
1 2
vdst = INST_A(inst); vsrc1 = INST_B(inst);
INST_A
表示用来获取vA
寄存器地址的宏,其中A
表示寄存器的”名称“,可以是其他的字母或长度。在该文件的同目录下的headers.cpp
文件300~304
中,INST_A
与INST_B
的声明如下:1 2 3 4 5
/* * Extract the "vA, vB" 4-bit registers from the instruction word (_inst is u2). */ #define INST_A(_inst) (((_inst) >> 8) & 0x0f) #define INST_B(_inst) ((_inst) >> 12)
也就是说,
vdst
获取了_inst
高 8 位的低 4 位的值;vsrc1
获取了_inst
的最高 4 位。1 2 3
ILOGV("|move%s v%d,v%d %s(v%d=0x%08x)", (INST_INST(inst) == OP_MOVE) ? "" : "-object", vdst, vsrc1, kSpacing, vdst, GET_REGISTER(vsrc1));
用来输出调试信息。
1
SET_REGISTER(vdst, GET_REGISTER(vsrc1));
SET_REGISTER
用来设置寄存器的值,GET_REGISTER
用来获取寄存器的值(操作的寄存器可以是其它的大小与类型,比如WIDE
类型相关的宏函数则是GET_REGISTER_WIDE
)。在headers.cpp
文件,声明如下:1 2 3 4 5 6
# define GET_REGISTER(_idx) \ ( (_idx) < curMethod->registersSize ? \ (fp[(_idx)]) : (assert(!"bad reg"),1969) ) # define SET_REGISTER(_idx, _val) \ ( (_idx) < curMethod->registersSize ? \ (fp[(_idx)] = (u4)(_val)) : (assert(!"bad reg"),1969) )
fp
为 ARM 栈帧寄存器,在虚拟机运行到某个函数时指向函数的局部变量区,其中就维护着一份寄存器值的列表。
v
命名法与 p
命名法
假设一个函数有 M 个寄存器和 N 个参数,则寄存器命名法如下表所示:
v 命名法 | p 命名法 | 寄存器含义 |
---|---|---|
v0 | v0 | 第 1 个局部变量寄存器 |
v1 | v1 | 第 2 个局部变量寄存器 |
… | … | 。。。 |
v{M-N} | p1 | 第 1 个参数寄存器 |
… | … | 。。。 |
v{M-1} | p{N-1} | 第 N 个寄存器 |
类型、方法与字段表示方法
类型。
Dalvik 字节码只有两种类型,基本类型和引用类型。Dalvik 使用这两种类型来表示 Java 语言的全部类型,除了对象和数组是引用对象类型之外,其他的 Java 类型全都是基本类型。全部的类型列表如下:
- 对于 32 位的类型来说,一个寄存器就可以存放该类型的值;而像 J、D 这样等 64 位的类型则是用两个响铃的寄存器来存储的,比如 v0 和 v1。
- L 类型可以表示 Java 中的任何类,这些类在 Java 代码中以
package.name.ObjectName
方式引用,在 Dalvik 汇编代码中,以Lpackage/name/ObjectName;
形式表示(注意最后有个分号) - [ 类型表示所有基本类型的数组,[ 后面紧跟基本类型描述符,比如
[I
表示一个整型一位数组、[[I
表示int[][]
。 - [ 和 L 同时使用就可以表示对象数组。
方法。
Dalvik 使用方法名、类型参数与返回值来详细描述一个方法。方法格式例子如下:
1
Lpackage/name/Objectname;->MethodName(III)Z
Lpackage/name/Objectname;
表示一个类型;MethodName
表示方法名;III
表示方法的参数,在此位三个整形参数;Z
表示方法的返回类型,Z
为 boolean 类型。BakSmali
生成的方法代码以.method
指令开始,以.end method
指令结束,根据生成的方法类型不同,在方法指令开始前会用 ”#“ 加以解释。如:# virtual methods
表示这是一个虚方法。
字段。方法格式例子如下:
1
Lpackage/name/ObjectName->FieldName:Ljava/lang/Strng;
字段由类型(
Lpackage/name/ObjectName;
)、字段名(FieldName
)与字段类型(Ljava/lang/String;
)组成。其中后两者用:
隔开。BakSmali
生成的方法代码以.field
指令开头,根据生成的方法类型不同,在方法指令开始前会用 “#” 加以解释。比如:# instance field
表示这是一个实例字段。
Dalvik 指令集
指令特点
Dalvik 指令在调用格式上模仿了 C 语言的调用约定。Dalvik 指令的语法与助记符有以下特点:
参数采用从目标(destination)到源(source)的方式;
根据字节码的大小与类型不同,一些字节码添加了名称后缀以消除歧义:
32 位没有后缀;
64 位常规类型的字节码添加
-wide
后缀;特殊类型的字节码根据具体类型添加后缀。
可能值为:
-boolean
、-byte
、-char
、-short
、-int
、-long
、-float
、-double
、-object
、-string
、-class
、-void
。
根据字节码的布局与选项不同,一些字节码添加了字节码后缀以消除歧义。这些后缀通过在字节码主名称后添加
/
来分隔。在指令集的描述中,宽度值中的每个字母表示宽度为 4 位。
比如这样一个指令:move-wide/from16 vAA vBBBB
move
表示基础字节码(base opcode):标识这是基本操作;wide
为名称后缀(name suffix):标识数据宽度是 64;from16
为字节码后缀(opcode suffix):标识操作源是一个 16 位的寄存器引用常量;vAA
为目的寄存器,始终在源之前,表示 8 位,取值范围是v0~v255
;vBBBB
为源寄存器,表示 16 位,取值范围是v0~v65535
。
注意:
Dalvik 虚拟机中的每个虚拟机都是 32 位的,描述指令中说的位数表示下标取值范围。
指令
空指令:助记符为
nop
,它的值为00
,无实际用途。数据操作指令:数据操作指令为
move
。指令原型为move destination source
,会根据字节码的大小与类型不同,后面会跟上不同的后缀。返回指令:函数结尾运行的最后一条指令。它的基础字节码为
return
,共有以下四条指令:return-void
表示函数从一个void
方法返回;return vAA
表示函数返回一个 32 位非对象类型的值;return-wide vAA
表示函数返回一个 64 位费对象类型的值;return-object vAA
表示函数返回一个对象类型的值;
数据定义指令:用来定义程序中用到的常量、字符串、类等数据。它的基础字节码为
const
。比如:
const-wide/16 vAA, #+BBBB
表示将 16 位的数字扩展为 64 位后赋值给寄存器vAA
。锁指令:Dalvik 中有两条锁指令:
monitor-enter vAA
为指定的对象获取锁,monitor-exit vAA
释放指定对象的锁。实例操作指令:与实例操作相关的操作包括类型转换、检查与新建等。
check-cast vAA, type@BBBB
:将vAA
寄存器中的对象转换为指定的类型,如果失败会抛出ClassCastException
异常。instance-of vA, vB, type@CCCC
:判断vB
寄存器中的对象是否可以转换为指定的类型,如果可以则将寄存器vA
赋值为 1,否则赋值为 0。new-instance vAA, type@BBBB
:构造一个指定类型对象的新实例,并将对象引用赋值给vAA
寄存器,类型符 type 指定的类型不能是数组类型。
数组操作指令:包括获取数组长度、新建数组、数组赋值、数组元素取值与赋值等操作。
异常指令:Dalvik 中用
throw vAA
指令来抛出vAA
寄存器中的异常。跳转指令:Dalvik 指令集中有三种跳转指令:无条件跳转(
goto
)、分支跳转(switch
)与条件跳转(if
)比较指令:它的格式为
cmpkind vAA, vBB, vCC
,其中vBB
与vCC
是两个待比较的寄存器对,vAA
是存放比较结果的寄存器。字段操作指令:字段操作指令用来对对象实例的字段进行读写操作。字段的类型可以是 Java 中有效的数据类型。
对普通字段和静态字段有两种指令集:
iinstanceop vA,vB, field@CCCC
与sstaticop vAA, field@BBBB
;普通字段指令的前缀为
i
,比如普通字段读操作iget
,写操作iput
;静态字段指令前缀为
s
,比如静态字段读操作sget
,写操作sput
。方法调用指令:负责调用类实例的方法,它的基础指令为
invoke
,方法调用指令有invoke-kind {vC, vD, vE, vF, vG}, meth@BBBB
与invoke-kind/range {vCCCC ... vNNNN}, meth@BBBB
两类。两类指令在作用上并无不同,只是后者使用了 range 来指定寄存器的范围。数据转换指令:用于将一种类型的数值转换为另一种类型。它的格式为
unop vA, vB
。vB
中存放着需要转换的数据,转换后的结果保存在vA
中。数据运算指令:包括算术运算(加、减、乘、除、模、移位等)与逻辑运算(与、或、非、异或等)