avatar

Catalog
Dalvik虚拟机

前言

想要去逆向分析一个程序,那就得了解它所在平台底层的知识,学习如何分析Android程序的之前,了解Dalvik虚拟机是必不可少的环节。本篇就Android运行依赖的Dalvik虚拟机(尽管现在都是ART虚拟机了,但Dalvik虚拟机仍有学习的价值,就像学习Windows内核,不都是从xp开始的嘛)作简单的介绍,下一篇会介绍Dalvik语言基础,为之后学习Dalvik指令打下基础。

关于Dalvik虚拟机

Android系统的应用层是采用Java开发的(Java是主流,现Google全面推行Kotlin),由于Java语言的跨平台特性,所以Java的代码必须运行在虚拟机中。正因为这个特性,Android系统也实现了自己的一个类似JVM但是更适合嵌入式平台的虚拟机——Dalvik。Dalvik的功能等同于JVM,为Android平台上的Java代码提供了运行环境。

Dalvik虚拟机的特点

  • 体积小,占用内存空间少(与JVM相比)
  • 专有的DEX(Dalvik Executable)可执行文件格式,体积小,执行速度快(与.class相比)
  • 基于寄存器架构,同时拥有一套完整的指令系统(JVM基于栈架构)
  • 所有的Android程序都运行在Android系统进程中,每个进程都与一个Dalvik虚拟机实例对应。(基于Zygote)

Dalvik虚拟机与Java虚拟机的区别

DVM与传统的JVM有诸多显著区别,具体如下(二者亦不兼容)

运行的字节码不同

JVM:运行Java字节码(.java文件 -> .class文件(保存Java字节码)

DVM:运行Dalvik字节码(.class -> .dex(保存Dalvik字节码)

Dalvik可执行文件的体积更小

一般来说,一个Java的应用程序会打包成jar的形式发布在相应平台。一个jar会包含若干个.class文件;Android的应用程序则会把一个.dex文件打包成.apk文件,并且一个.apk文件中仅包含一个.dex文件。而.dex文件又都是由.class文件转换而来的(由Android SDK提供的”dx”工具完成转换的工作)。从描述上看,似乎.dex文件比.class文件更紧凑。下面来看一张图:

这张图充分说明了为什么Dalvik可执行文件的体积更小。Java类文件中通常有多个方法签名,如果其它类文件引用了该类文件中的方法,相应的方法签名也会被复制到其它类文件中。也就是说,如果多个不同的类同时包含相同的方法签名,则大量的字符串常量会被多个类文件重复使用。这些冗余信息造成了文件体积增大,严重影响了虚拟机解析文件的效率。

相反,dx在对Java文件转换为DEX文件时,对所有的Java类文件中的常量池进行了分解,并重新组合成一个常量池,让所有类文件共享这个常量池,这也使得DEX文件体积要小很多。

虚拟机架构不同

在前面说到特点时,就提到DVM是基于寄存器架构的,而JVM是基于栈架构的。仅从架构上来看,DVM就已经优势无限大,栈是位于内存中的,这就意味着当Java程序运行时,不断对栈的读写操作会进行大量内存访问,会耗费大量CPU时间。这对于资源有限的设备(手机)来说,无疑是笔巨大的开销。相比之下,寄存器的读取要快许多。

书中用一个程序举例,对比分析了Java字节码与Dalvik字节码的差异,由于我在本机上实验时,dx工具不能够支持,换了旧版本的build-tools同样不得行(找不出原因了)。所以这里就不给出文件转换的教程了,具体的文件转换步骤可以参考该文章。下面来看程序。

Java程序

java
1
2
3
4
5
6
7
8
9
10
public class Hello {
public int foo(int a, int b){
return (a+b) * (a-b);
}

public static void main(String[] agrc){
Hello hello = new Hello();
System.out.println(hello.foo(5, 3));
}
}

这个代码很好理解,定义了一个简单的函数并调用了一次。而我们关注的重点在于它经过编译生成后的字节码。

Java字节码的foo()函数部分

Code
1
2
3
4
5
6
7
8
9
10
11
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
...

这部分截取了.class文件中foo()函数的部分,想要理解这部分指令的含义,得先了解JVM的工作机制。

对Java程序来说,每个线程在执行时都有一个PC计数器和一个Java栈。PC计数器和x86架构CPU的IP(EIP)寄存器作用差不多,可以类比的理解。不同的在于PC计数器只对当前方法有效,Java虚拟机通过它的值来取指令执行。

Java栈和C语言调用函数的栈类似,在JVM中,每调用一个方法,就会分配一个新的栈帧并压入Java栈;每从一个方法返回,则弹出并撤销相应的栈帧。每个栈帧包括局部变量区求值栈(JVM规范称作操作数栈),局部变量区用于存储方法的参数和局部变量求值栈用于保存求值的中间结果及调用其它方法的参数等。

对JVM的一些机制稍作了解后,我们来看一下指令,先看第一个iload_1。这个指令要分为3部分来看:

  • i:指令前缀,表示操作类型为int
  • load:表示将局部变量存入Java栈
  • _1:索引,从0开始计数,表示要操作的是哪个局部变量,这里指第2个

拆分完后,就可以理解,iload_1表示使第2个int类型的局部变量入栈。

参考上图,这是JVM运行上面那段Java字节码时,栈帧的状态,可以看到,此时正准备执行iadd指令,在这之前,先取了第2个和第3个int参数入局部变量区,在执行iadd指令前,又从栈顶弹出两个int类型的值进入求值栈,并准备求它们的和。接下来的部分,也可以根据指令推断,首先在求和完了后会将结果压入求值栈的栈顶(求值栈用于保存求值的中间结果),第4条和第5条指令会再次将两个参数入栈,第6条指令进行求差,并把结果压回栈顶。此时求值栈中已经有两个int类型的值了。第7条指令imul用于从栈顶弹出两个int类型的值并求它们的积,把结果压回栈顶。第8条指令ireturn用于返回一个int类型的值。

这下是弄明白了Java字节码这部分指令的含义,关于更多JVM指令的含义,可以参考此篇文章。现在可以看到,Java字节码在执行行,会进行多次的压栈,出栈操作,这样会大量访问内存,耗费大量的CPU时间。所以,才有了Dalvik字节码的诞生。

Dalvik字节码的foo()函数部分

Code
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
...
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字节码这部分看上去很复杂,但实际上,很大一部分都是对函数的描述性的字段(这部分会在下一篇语言基础中讲到)。真正在实现foo()函数上,Dalvik字节码只使用了4条指令(Java字节码需要8条)。下面来看看它门的含义。

第1条指令add-int将v3和v4寄存器的值相加,结果存到v0寄存器中;第2条指令sub-int将v3和v4寄存器的值相减,将结果保存到v1寄存器中;第3条指令mul-int/2addr将v0和v1寄存器的值相乘,结果保存到v0寄存器中。第4条指令用于返回v0寄存器的值。

DVM运行时也为每个线程维护了一个PC计数器和一个调用栈。与JVM不同的是,这个调用栈维护了一个寄存器列表,寄存器的数量在方法结构体的registers字段中给出。DVM会根据这个值来创建一份虚拟的寄存器列表。DVM运行时的状态,可以参考下图:

通过比较JVM和DVM的运行状态以及实现同一功能的指令,可以发现与基于栈架构的JVM相比,基于寄存器架构的DVM生成的代码指令明显减少了,加上原本寄存器较内存更快的访问速度,使得DVM更适合资源有限的设备。

Dalvik虚拟机的执行流程

Dalvik虚拟机的执行流程很复杂,如果展开讲,仅一个Zygote进程的启动流程,就够写两篇博客了。这里不拓展太多,结合书中内容介绍主体部分,了解个大概,等到一周目通关后,可再去研究Android进程启动等细节。

在了解虚拟机执行流程前,先来看张图,这是一张经典的Android系统组成的图。

通过这张图我们可以看到两点:Android系统基于Linux内核DVM属于Android运行时环境,它与一些核心库一起承担了Android应用程序的运行工作。

Android系统启动并加载内核后,会立即执行init进程(Linux系统中,所有进程都是init进程的子孙进程,均由init进程fork出来,包括Zygote)。init进程先完成设备的初始化工作,再读取init.re文件并启动系统中的重要外部程序Zygote。

Zygote是Android系统中所有进程的孵化器进程。Zygote启动后,会先初始化Dalvik虚拟机(在AndroidRuntime的start函数中调用startVM启动虚拟机),再启动system_server进程(system_server进程负责启动系统的关键服务,如包管理服务PackageManagerService和应用程序组件管理服务ActivityManagerService)并进入Zygote模式,通过socket等候命令的下达(ZygoteInit的main函数中会调用runSelectLoopMode函数进入一个无限循环,在创建的socket接口等待ActivityManagerService发送的创建新的应用程序进程的请求)。这里提到的关于Zygote启动过程的细节,可以参考罗老师的Android系统进程Zygote启动过程的源代码分析,文章中讲的非常详细。也可以参考另一篇LooperJing的文章。建议安卓逆向一周目通关后把这两篇都看了。

在执行一个Android应用程序时,system_server进程通过Binder IPC方式将命令发送给Zygote(此时socket处已在等候命令),Zygote收到命令后,通过fork其自身创建一个Dalvik虚拟机的实例来执行应用程序的入口函数,从而完成应用程序的启动过程。过程可参考下图。详细细节可以参考罗老师的这篇文章

Zygote提供了三种创建进程的方法:

  • fork():创建一个Zygote进程。
  • forkAndSpecialize():创建一个非Zygote进程。
  • forkSystemServer():创建一个系统服务进程。

Zygote进程可以再分成其它进程,非Zygote进程则不能再分成其它进程。系统服务进程终止后,其子进程也必须终止。

进程fork成功后,执行工作将交给DVM完成。DVM先通过loadClassFromDex()函数来装载类。每个类被成功解析后,都会获得运行时环境(DVM所属位置)中的一个ClassObject类型的数据结构存储(虚拟机使用gDvm.loadedClasses全局散列表来存储和查询所有装载进来的类)。接下来,字节码验证器使用dvmVerifyCodeFlow()函数对装入的代码进行校验,虚拟机调用FindClass()函数查找并装载main()方法类。最后,虚拟机调用dvmInterpret()函数来初始化解释器并执行字节码流。

以上就是DVM的执行流程,这里仅作了解即可,等一周目后再来仔细研究。

Dalvik虚拟机的执行方式

即时编译(Just-in-time Compilation,JIT),又称动态编译,是一种通过在运行时将字节码翻译为机器码使得程序执行速度加快的技术。主流的JIT包括两种字节码编译方式:

  • method方式:以函数或方法为单位进行编译。
  • trace方式:以trace为单位进行编译(trace方式即按照执行路径编译,比起method方式编译整个方法,trace更节省时间和内存)。

DVM默认采用trace方式编译,同时支持method方式。

参考资料

参考书籍:《Android软件安全权威指南》—— 丰生强

参考链接:

  1. https://www.jianshu.com/p/6bdbbab73705 (简书文章-关于Dalvik虚拟机)
  2. https://blog.csdn.net/cy524563/article/details/41550915 (CSDN文章-什么是Dalvik虚拟机)
  3. https://stackoverflow.com/questions/33533370/difference-between-aar-jar-dex-apk-in-android (stackoverflow解答-jar,dex,apk,aar的区别)
  4. https://juejin.im/post/5bf22bb5e51d454cdc56cbd5 (掘金文章-浅谈Android Dex文件)
  5. https://blog.csdn.net/zzc901205/article/details/77676330 (CSDN文章-java,class,dex转换过程)
  6. https://blog.csdn.net/hudashi/article/details/7062675 (CSDN文章-JVM指令详解(上))
  7. https://www.jianshu.com/p/ab9b83a77af6 (简书文章-Zygote进程的启动流程)
  8. https://blog.csdn.net/luoshengyang/article/details/6768304 (CSDN文章-Zygote启动过程的源代码分析)
  9. https://blog.csdn.net/Luoshengyang/article/details/6747696 (CSDN文章-Android应用程序进程启动过程的源代码分析)
Author: cataLoc
Link: http://cataloc.gitee.io/blog/2020/06/09/Dalvik%E8%99%9A%E6%8B%9F%E6%9C%BA/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.
Donate
  • 微信
    微信
  • 支付寶
    支付寶