Android Dalvik 虚拟机

虽然 Android 平台使用 Java 语言来开发应用程序,但 Android 程序却不是运行在标准 Java 虚拟机上,而是 Dalvik Virtual Machine(Dalvik 虚拟机)。

Dalvik 虚拟机的特点

与 Java 虚拟机的区别

主要区别:

  1. Java 虚拟机运行的是 Java 字节码,Dalvik 虚拟机运行的是 Dalvik 字节码。

    所有的 Dalvik 字节码由 Java 字节码转化而来,并且被打包到一个 DEX(Dalvik Executable)可执行文件中。Dalvik 虚拟机通过解释 DEX 文件来执行这些字节码。

  2. Dalvik 可执行文件体积更小。

    Android SDK 中有一个叫 dx 的工具负责将 Java 字节码转换为 Dalvik 字节码。

  3. Java 虚拟机与 Dalvik 虚拟机架构不同。

    Java 虚拟机基于栈结构。程序在运行时需要频繁地从栈上读取或写入数据。

    Dalvik 虚拟机基于寄存器架构。数据的访问通过寄存器间直接传递。

实例:

  1. 编写 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));
    	}
    }
    
  2. 执行以下命令生成 .class 文件:

    1
    
    $ javac Hello.java
    

    执行以下命令生成 .dex 文件:

    1
    
    $ dx --dex --output=Hello.dex Hello.class
    
  3. 使用 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

  4. 使用 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 应用程序的运行工作。

简述:

  1. Android 系统启动加载完内核之后,第一个执行的是 init 进程

    它首先要做的是设备的初始化工作,然后读取 inic.rc 文件并启动系统中的重要外部程序 Zygote。

  2. Zygote 进程是 Android 所有进程的孵化器进程

    它启动后会首先初始化 Dalvik 虚拟机,然后启动 system_server 并进入 Zygote 模式,通过 socket 等待命令。

  3. 当执行一个 Android 应用程序时,system_server 进程通过 Binder IPC 方式发送命令给 Zygote,Zygote 收到命令后通过 fork 自身创建一个 Dalvik 虚拟机的实例来执行应用程序的入口函数。

  4. 当进程 fork 成功后,执行的工作就交给了 Dalvik 虚拟机。

    Dalvik 虚拟机首先通过 loadClassFromDex() 函数完成类的装载工作,每个类被成功解析后都会拥有一个 ClassObject 类型的数据结构存储在运行时环境中,虚拟机使用 gDvm.loadedClasses 全局哈希表来存储与查询所有装载进来的类。

  5. 随后,字节码验证器使用 dvmVerifyCodeFlow() 函数对装入的代码进行校验。

  6. 接着虚拟机调用 FindClass() 函数查找并装载 main 方法类,随后调用 dvmInterpret() 函数初始化解释器并执行字节码流。

Dalvik 虚拟机 JIT(即时编译)

主流的 JIT 包括两种字节码编译方式:

  • method 方式:以函数或方法为单位进行编译;
  • trace 方式:以 trace 为单位进行编译。编译执行比较频繁的 ”热路径“ 代码。

Dalvik 汇编语言基础

Dalvik 指令格式

一段 Dalvik 汇编代码由一系列 Dalvik 指令组成,指令语法由指令的位描述与指令格式标识来决定。

位描述的约定如下:

  1. 每 16 位的字采用空格分隔开来。
  2. 每个字母表示 4 位,每个字母按顺序从高字节开始,排列到低字节。
  3. 四位的内部可以用 ”|“ 来表示不同的内容。
  4. 顺序采用 A~Z 的单个大写字母表示一个 4 位操作码,op 表示一个 8 位操作码。
  5. ”$$\varnothing$$“ 来表示这个字段的所有位为 0。

例子:”A|G|op BBBB F|E|D|C“

指令格式的约定如下:

  1. 指令格式标识大多由三个字符组成,前两个是数字,最后一个是字母;
  2. 第一个数字是表示指令有多少个 16 位的字组成;
  3. 第二个数字是表示指令最多使用寄存器的个数。特殊标记 ”r“ 标识使用一定范围内的寄存器。
  4. 第三个字母表示类型码,表示指令用到的额外数据的类型;可能值如下表所示:
助记符位大小说明
b88 位有符号立即数
c16, 32常量池索引
f16接口常量(仅对静态链接格式有效)
h16有符号立即数(32 位或 64 位数的高位值,低位为 0)
i32立即数,有符号整数或 32 位浮点数
l64立即数,有符号整数或 64 位双精度浮点数
m16方法常量(仅对静态链接格式有效)
n44 位立即数
s16短整形立即数
t8, 16, 32跳转、分支
x0无额外数据

最新的 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 文件:

1
2
3
$ javac Hello.java

$ dx --dex --output=Hello.dex Hello.class
  1. 使用 baksmali.jar 通过以下的命令反汇编 Hello.dexbakmali 的使用方法与书本描述不一样,详细使用方法可以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 文件。

  2. 使用 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 文件。

  3. 两种反汇编代码的结构组织是一样的,在方法名、字段类型与代码指令序列上它们保持已知,具体表现在一些语法细节上:

    • 前者使用 .registers 指令指定函数用到的寄存器数目,后者则在 .registers 之前加了 limit 前缀;
    • 前者使用 p0this 引用,后者则使用 v2this 引用;
    • 前者使用 .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 虚拟机如何虚拟地使用寄存器呢?

  1. 每个函数在函数头声明其使用的寄存器数量,虚拟机执行到这个函数时,根据其寄存器的数目分配适当的栈空间,用来存放寄存器实际的值;

  2. 虚拟机通过处理字节码,对寄存器进行读写操作就是在写栈空间,Android SDK 中有一个名为 dalvik.bytecode.Opcodes 的接口,它定义了一份完整的 Dalvik 字节码列表

    处理这些字节码的函数为一个宏 HANDLE_OPCODE(),处理过程函数可以在 Android 源代码 dalvik/vm/mterp/c 中找到。

下面以 OP_MOVE.cpp 举例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// vm/mterp/c/OP_MOVE.cpp
HANDLE_OPCODE($opcode /*vA, vB*/)
    vdst = INST_A(inst);
    vsrc1 = INST_B(inst);
    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));
    SET_REGISTER(vdst, GET_REGISTER(vsrc1));
    FINISH(1);
OP_END
  1. 1
    2
    
    vdst = INST_A(inst);
    vsrc1 = INST_B(inst);
    

    INST_A 表示用来获取 vA 寄存器地址的宏,其中 A 表示寄存器的”名称“,可以是其他的字母或长度。在该文件的同目录下的 headers.cpp 文件 300~304 中,INST_AINST_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 位。

  2. 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));
    

    用来输出调试信息。

  3. 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 命名法寄存器含义
v0v0第 1 个局部变量寄存器
v1v1第 2 个局部变量寄存器
。。。
v{M-N}p1第 1 个参数寄存器
。。。
v{M-1}p{N-1}第 N 个寄存器

类型、方法与字段表示方法

  1. 类型。

    Dalvik 字节码只有两种类型,基本类型和引用类型。Dalvik 使用这两种类型来表示 Java 语言的全部类型,除了对象和数组是引用对象类型之外,其他的 Java 类型全都是基本类型。全部的类型列表如下:

    ../dalvik-type-descript.png

    • 对于 32 位的类型来说,一个寄存器就可以存放该类型的值;而像 J、D 这样等 64 位的类型则是用两个响铃的寄存器来存储的,比如 v0 和 v1。
    • L 类型可以表示 Java 中的任何类,这些类在 Java 代码中以 package.name.ObjectName 方式引用,在 Dalvik 汇编代码中,以 Lpackage/name/ObjectName; 形式表示(注意最后有个分号
    • [ 类型表示所有基本类型的数组,[ 后面紧跟基本类型描述符,比如 [I 表示一个整型一位数组、[[I 表示 int[][]
    • [ 和 L 同时使用就可以表示对象数组。
  2. 方法。

    Dalvik 使用方法名、类型参数与返回值来详细描述一个方法。方法格式例子如下:

    1
    
    Lpackage/name/Objectname;->MethodName(III)Z
    
    1. Lpackage/name/Objectname; 表示一个类型;

    2. MethodName 表示方法名;

    3. III 表示方法的参数,在此位三个整形参数;

    4. Z 表示方法的返回类型,Z 为 boolean 类型。

      BakSmali 生成的方法代码以 .method 指令开始,以 .end method 指令结束,根据生成的方法类型不同,在方法指令开始前会用 ”#“ 加以解释。如:# virtual methods 表示这是一个虚方法。

  3. 字段。方法格式例子如下:

    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 位的,描述指令中说的位数表示下标取值范围。

指令

  1. 空指令:助记符为 nop,它的值为 00,无实际用途。

  2. 数据操作指令:数据操作指令为 move。指令原型为 move destination source,会根据字节码的大小与类型不同,后面会跟上不同的后缀。

  3. 返回指令:函数结尾运行的最后一条指令。它的基础字节码为 return,共有以下四条指令:

    1. return-void 表示函数从一个 void 方法返回;
    2. return vAA 表示函数返回一个 32 位非对象类型的值;
    3. return-wide vAA 表示函数返回一个 64 位费对象类型的值;
    4. return-object vAA 表示函数返回一个对象类型的值;
  4. 数据定义指令:用来定义程序中用到的常量、字符串、类等数据。它的基础字节码为 const

    比如:const-wide/16 vAA, #+BBBB 表示将 16 位的数字扩展为 64 位后赋值给寄存器 vAA

  5. 锁指令:Dalvik 中有两条锁指令:monitor-enter vAA 为指定的对象获取锁,monitor-exit vAA 释放指定对象的锁。

  6. 实例操作指令:与实例操作相关的操作包括类型转换、检查与新建等。

    • check-cast vAA, type@BBBB:将 vAA 寄存器中的对象转换为指定的类型,如果失败会抛出 ClassCastException 异常。
    • instance-of vA, vB, type@CCCC:判断 vB 寄存器中的对象是否可以转换为指定的类型,如果可以则将寄存器 vA 赋值为 1,否则赋值为 0。
    • new-instance vAA, type@BBBB:构造一个指定类型对象的新实例,并将对象引用赋值给 vAA 寄存器,类型符 type 指定的类型不能是数组类型。
  7. 数组操作指令:包括获取数组长度、新建数组、数组赋值、数组元素取值与赋值等操作。

  8. 异常指令:Dalvik 中用 throw vAA 指令来抛出 vAA 寄存器中的异常。

  9. 跳转指令:Dalvik 指令集中有三种跳转指令:无条件跳转(goto)、分支跳转(switch)与条件跳转(if

  10. 比较指令:它的格式为 cmpkind vAA, vBB, vCC,其中 vBBvCC 是两个待比较的寄存器对,vAA 是存放比较结果的寄存器。

  11. 字段操作指令:字段操作指令用来对对象实例的字段进行读写操作。字段的类型可以是 Java 中有效的数据类型。

    对普通字段和静态字段有两种指令集:iinstanceop vA,vB, field@CCCCsstaticop vAA, field@BBBB

    普通字段指令的前缀为 i,比如普通字段读操作 iget,写操作 iput

    静态字段指令前缀为 s,比如静态字段读操作 sget,写操作 sput

  12. 方法调用指令:负责调用类实例的方法,它的基础指令为 invoke,方法调用指令有 invoke-kind {vC, vD, vE, vF, vG}, meth@BBBBinvoke-kind/range {vCCCC ... vNNNN}, meth@BBBB 两类。两类指令在作用上并无不同,只是后者使用了 range 来指定寄存器的范围。

  13. 数据转换指令:用于将一种类型的数值转换为另一种类型。它的格式为 unop vA, vBvB 中存放着需要转换的数据,转换后的结果保存在 vA 中。

  14. 数据运算指令:包括算术运算(加、减、乘、除、模、移位等)与逻辑运算(与、或、非、异或等)