第三章 Dalvik 可执行格式与字节码规范

Dalvik 虚拟机

  • Dalvik 虚拟机(Dalvik Virtual Machine):Android 4.4前
  • ART 虚拟机(Android RunTime):Android 4.4开始

Dalvik 虚拟机特点

  • 体积小,占用内存空间小
  • 专有的 DEX(Dalvik Executable)可执行文件格式,体积小,执行速度快
  • 常量池采用 32 位索引值,对类方法名、字段名、常量的寻址速度快
  • 基于寄存器架构,同时拥有一套完整的指令系统
  • 提供了对象生命周期管理、堆栈管理、线程管理、安全和异常管理及垃圾回收等重要功能
  • 所有的 Android 程序都运行在 Android 系统进程中,每个进程都与一个 Dalvik 虚拟机实例对应

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

运行的字节码不同

  • Java 虚拟机
  • 运行 Java 字节码
  • 程序运行流程:编译 -> 生成 Java 字节码 -> 保存在 class 文件 -> Java 虚拟机解码 class 文件 -> 运行
  • Dalvik 虚拟机
  • 运行 Dalvik 字节码
  • Java 字节码 -> Dalvik 字节码 -> 打包到 Dex 可执行文件 -> Dalvik 虚拟机解释 Dex 文件 -> 运行

Dalvik 可执行文件体积更小

  • Java 字节码转 Dalvik 字节码
  • 由 Android SDK 中的 dx 工具完成
  • dx
  • 重新排列 Java 类文件,消除类文件中所有冗余信息,避免虚拟机初始化时反复加载和解析文件
  • 使用 dx 将 java 文件转为 Dex 的过程
    在这里插入图片描述
  • 如图,基于 dx 对常量池的压缩,相同的字符串和常量在 Dex 文件中只会出现一次(文件体积也就更小)

虚拟机架构不同

  • Java 虚拟机
  • 基于栈结构
  • 程序运行时 Java 虚拟机会频繁对栈进行读写操作,此过程中,不仅会多次进行指令分派与内存访问,且会耗费大量 CPU 时间
  • Dalvik 虚拟机
  • 基于寄存器结构
  • 数据的访问直接在寄存器之间传递(速度快于基于栈的访问方式)

实例

  • 测试代码,保存为 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));
    }
}
  • 执行如下命令编译 Java 源文件
javac -source 1.7 -target 1.7 Hello.java
  • 此处规定源码和目标平台字节码都是 Android 1.7 版本的,对应 JDK 7。若本机安装的 JDK 版本高于 7,则必须加上 -source 1.7 和 -target 1.7。对 JDK 8,Android 官方提供了 Jack(Java Android compiler Kit)编译器来生成 Dex 文件

  • 执行如下命令生成 Dex 文件

dx --dex --output=Hello.dex Hello.class
  • 使用 javap 反编译 Hello.class,查看 foo() 函数的 Java 字节码
javap -c -classpath . Hello
  • 执行上述命令后得到如下代码(节选),即 foo() 函数的 Java 字节码
    在这里插入图片描述
  • 使用 dexdump(位于 Android SDK 的 platform-tools 目录中)查看 foo() 函数的 Dalvik 字节码
dexdump -d Hello.dex
  • 执行上述命令后得到如下代码(节选),即 foo() 函数的 Dalvik 字节码
    在这里插入图片描述

分析上述实例

Java 字节码

在这里插入图片描述

  • foo() 函数共占用 8 字节,代码中每条指令占 1 字节且没有参数
  • 这些指令这样存取数据:
  • Java 虚拟机的指令集称零地址形式的指令集,零地址形式指令的源参数和目标参数都是隐含的,通过 Java 虚拟机提供的数据结构“求值栈”来传递
  • 对 Java 程序来说,每个线程在执行时都有一个 PC 计数器和一个 Java 栈
  • PC 计数器以字节为单位记录当前运行位置与方法开头间的偏移量,作用类似于 ARM 架构 CPU 的 PC 寄存器和 x86 架构 CPU 的 IP 寄存器。与这两种寄存器不同的是,PC 计数器只对当前方法有效,Java 虚拟机通过它的值来取指令并执行
  • Java 栈用于记录 Java 方法调用的活动记录(activation record),以帧(frame)为单位保存线程的运行状态:每调用一个方法,就会分配一个新的栈帧并压入 Java 栈;每从一个方法返回,则弹出并撤销相应的栈帧。每个栈帧包括局部变量区、求值栈(JVM 规范中称操作数栈)及其他信息。局部变量区存储方法的参数和局部变量,参数按照源码中从左到右的顺序保存在局部变量区开头的几个 slot 中。求值栈保存求值的中间结果及调用其他方法的参数等
  • 下图即为 JVM 运行时求值栈的状态。由于每条指令占 1 字节,foo() 函数 Java 字节码左边的偏移量即程序执行每行代码时 PC 计数器的值(Java 虚拟机最多支持 0xff 条指令)
    在这里插入图片描述
  • java 字节码具体分析
  • 第一条指令:iload_1:第一部分为 iload,这是 JVM(Java 虚拟机)指令集 load 系列指令中的一条,i 是指令前缀,表示操作类型为 int;load表示将局部变量存入 Java 栈。类似的指令有 lload、fload 等,表示使 long、float 类型的数据入栈。第二部分为数字 1,表示要操作的是哪个局部变量。索引值从 0 开始计数。如 iload_1 表示使第 2 个 int 类型的局部变量入栈,而这个局部变量即存放在局部变量区 foo() 函数中的第 2 个参数
  • 第二条指令:iload_2:取第 3 个参数
  • 第三条指令:iadd:从栈顶弹出两个 int 类型的值并求和,把结果压回栈顶
  • 第四、五条指令:再次压入第 2 和第 3 个参数
  • 第六条指令:isub:从栈顶弹出两个 int 类型的值并求差,把结果压回栈顶
  • 第七条指令:imul:从栈顶弹出两个 int 类型的值并求积,把结果压回栈顶
  • 第八条指令:ireturn:返回一个 int 类型的值
Dalvik 字节码

在这里插入图片描述

  • 第一条指令:add-int v0, v3, v4:将 v3 和 v4 寄存器的值相加,将结果存到 v0 寄存器。在整个指令操作中使用三个参数,v3 和 v4 代表 foo() 函数的第 1 和第 2 个参数
  • 第二条指令:sub-int v1, v3, v4:将 v3 和 v4 寄存器的值相减,将结果存到 v1 寄存器
  • 第三条指令:mul-int/2addr v0, v1:将 v0 和 v1 寄存器的值相乘,将结果存到 v0 寄存器
  • 第四条指令:return v0:返回 v0 寄存器的值
  • Dalvik 虚拟机运行时也为每个线程维护了一个 PC 计数器和一个调用栈
  • 与 Java 虚拟机不同的是,调用栈维护了一个寄存器列表,寄存器数量在方法结构体的 registers 字段中给出,如下图,Dalvik 虚拟机会根据这个值创建一份虚拟的寄存器列表
    在这里插入图片描述
  • 下图为 Dalvik 虚拟机运行时的状态
    在这里插入图片描述
  • 综上,可发现:与基于栈架构的 Java 虚拟机相比,基于寄存器架构的 Dalvik 虚拟机由于生成的代码更少,程序执行速度更快

虚拟机的执行流程

  • Android 源码根目录下的 Dalvik 目录存放了所有与 Dalvik 相关的代码实现和文档,可通过这些内容深入理解 Dalvik 的运行机制
  • Android 系统由 Linux 内核、函数库、Android 运行时、应用程序框架及应用程序组成,如下图
    在这里插入图片描述
  • Android 系统采用分层思想,优点:各层之间的依赖性降低、便于独立分发、容易收敛问题和错误等
  • Dalvik 虚拟机属于 Android 运行时环境,与一些核心库一起承担 Android 应用程序的运行工作
  • Android 系统启动并加载内核后,会立即执行 init 进程。init 进程先完成设备的初始化工作,再读取 init.rc 文件并启动系统中的重要外部程序 Zygote
  • Zygote 是 Android 系统中所有进程的孵化器进程。Zygote 启动后,会先初始化 Dalvik 虚拟机,再启动 system_server 进程并进入 Zygote 模式,通过 socket 等候命令下达。在执行一个 Android 应用程序时,system_server 进程通过 Binder IPC 方式将命令发送给 Zygote。Zygote 收到命令后,通过 fork 其自身创建一个 Dalvik 虚拟机的实例来执行应用程序的入口函数,从而完成程序的启动过程。如下图
    在这里插入图片描述
  • Zygote 提供了三种创建进程的方法:
  • fork():创建一个 Zygote 进程(这种方式实际不会被调用)
  • forAndSpecialize():创建一个非 Zygote 进程
  • forSystemServer():创建一个系统服务进程
  • Zygote 进程可再分成其他进程,非 Zygote 进程不能再分成其他进程。系统服务进程终止后,其子进程也必须终止
  • 进程 fork 成功后,执行工作将交给 Dalvik 虚拟机来完成
  • Dalvik 虚拟机先通过 loadClassFromDex() 函数装载类。每个类被成功解析后,都会获得运行时环境中的一个 ClassObject 类型的数据结构存储(虚拟机使用 gDvm.loadedClasses 全局散列表来存储和查询所有装载进来的类)。接着,字节码验证器使用 dvmVerifyCodeFlow() 函数对装入的代码进行校验,虚拟机调用 FindClass() 函数查找并装载 main() 方法类。最后,虚拟机调用 dvmInterpret() 函数初始化解释器并执行字节码流

虚拟机的执行方式

  • 即时编译(just-in-time Compilation, JIT):又称动态编译。一种通过在运行时将字节码翻译为机器码使得程序执行速度加快的技术
  • Android 2.2 的 Dalvik 虚拟机中引入
  • 主流 JIT 包括两种字节码编译方式:
  • method 方式:以函数或方法为单位进行编译
  • trace 方式:以 trace 为单位进行编译
  • trace方式
  • 函数中,只有少数代码是顺序执行的,多数代码都有好几条执行路径,其中一些路径在实际运行过程中很少执行,称“冷路径”(执行较频繁的称“热路径”)。传统的 method 方式会编译整个方法的代码,从而在冷路径上耗费很多编译时间及内存,使用 trace 方式编译,能快速获取热路径的代码,从而以更少时间和内存编译代码
  • Dalvik 虚拟机默认采用 trace 方式,同时支持 method 方式

Dalvik 语言基础

  • Dalvik 虚拟机有专门的指令集及指令格式(Dalvik Executable Format)和调用规范
  • 由 Dalvik 指令集组成的代码称 Dalvik 汇编代码,由这种代码表示的语言称 Dalvik 汇编语言
  • Dalvik 汇编语言有专门的机器模型和类似于 C 语言的调用约定,设计准则:
  • 采用基于寄存器的设计。方法在内存中创建后即拥有固定大小的栈帧,栈帧占用的空间取决于方法中指定的寄存器数目。运行时使用的数据和代码都存储在 Dex 文件中
  • 如果整数与浮点数按位表示,可使用 32 位寄存器来存放。相邻的两个寄存器表示 64 位,可用于存放大数据类型。寄存器不需要考虑对齐边界
  • 如果用来保存对象引用,寄存器必须能容纳引用类型
  • 不同的数据类型按位表示
  • 在调用约定上,使用 N 个寄存器表示 N 个参数。对 wide 类型(32 位),使用相邻两个寄存器的组合来传递。对实例方法,第一个参数传递的是 this 指针
  • 指令流以 16 位的无符号整型为存储单位,某些特殊指令会将部分位域置零或忽略。指令不会强行指定它的具体类型。在使用 mov 指令对一个 32 位的寄存器进行操作时,不需要具体说明它是整形还是浮点型。在一些情况下,部分指令可能会改变(或发生格式的改变)。在安装 APK 过程中,当静态链接优化执行 dexopt 时,部分指令会发生改变。另外,在 ART 环境中,部分指令的设计与 Dalvik 有些不同,在 ART 上执行 AOT 编译时也会对 DEX 指令进行一些修改

Dalvik 指令格式

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

位描述

  • 约定:
  • 每 16 位的字用空格分开
  • 每个字母表示 4 位,每个字母按顺序从高字节到低字节排列,每 4 位之间可能使用竖线“|”将不同的内容分开
  • 顺序采用英文大写字母 A ~ Z 表示 4 位的操作码。op 表示 8 位的操作码
  • ∅(空集符号)表示字段所有位的值为 0
  • 示例:
  • A|G|op BBBB F|E|D|C
  • 两个空格将上述指令分成大小均为 16 位的三部分,因此,此指令由三个 16 位的字组成
  • 第一个 16 位部分是“A|G|op”,高 8 位由“A”和“G”组成,低字节由操作码“op”组成
  • 第二个 16 位部分由“BBBB”组成,表示一个 16 位的偏移量
  • 第三个 16 为部分由“F”“E”“D”“C”四个 4 字节组成,分别表示寄存器的参数

指令格式标识

  • 单独使用位标识无法确定一条指令,必须通过指令格式标识指定指令的格式编码,约定如下:
  • 指令格式标识大都由三个字符组成,其中前两个是数字,最后一个是字母
  • 第一个数字表示指令是由多少个 16 位的字组成的
  • 第二个数字表示指令最多使用的寄存器个数。特殊标记 r 用于标识所使用的寄存器范围
  • 第三个字母为类型码,表示指令所使用的额外数据的类型,如下图:
    在这里插入图片描述
  • 一种特殊情况:指令的末尾多出一个字母。如果是字母 s,表示指令采用静态链接;如果是字母 i,表示指令应该被内联处理
  • 示例:
  • 22x
  • 第一个数字 2 表示指令由两个 16 位字组成
  • 第二个数字 2 表示指令使用两个寄存器
  • 第三个字母 x 表示没有使用额外的数据

Dalvik 指令语法说明

  • 每条指令都是从操作码开始,后面紧跟参数。参数的个数不定,参数间用逗号分隔
  • 每条指令的参数都是从指令的第一部分开始。op 位于低 8 位。高 8 位可以是一个 8 位的参数,也可以是两个 4 位的参数,还可以是空。如果指令超过 16 位,则将之后的部分依次作为参数
  • 如果参数用“vX”的形式表示,说明它是一个寄存器,如 v0、v1 等。这里用“v”不用“r”的目的是避免与基于该虚拟机架构本身的寄存器产生命名冲突(如 ARM 架构的寄存器名称以“r”开头)
  • 如果参数用“#+X”的形式表示,说明它是一个常量数字
  • 如果参数用“+X”的形式表示,说明它是一个相对指令的地址偏移量
  • 如果参数用“kind@X”的形式表示,说明它是一个常量池索引值。其中,“kind”表示常量池的类型,可以是 string(字符串常量池索引)、type(类型常量池索引)、field(字段常量池索引)或 meth(方法常量池索引)
  • 示例
  • op vAA, string@BBBB
  • 上述指令使用了一个寄存器参数 vAA,附加了一个字符串常量池索引值 string@BBBB。其实,这种指令格式代表 const-string 指令
  • Android 4.0 源码目录 Dalvik/docs 下的文档 instruction-formats.html 列举了 Dalvik 指令的所有格式

DEX 反汇编工具

  • 主流工具:Android 官方的 dexdump 、第三方的baksmali
  • 使用前述 Hello.java 中的 foo() 函数为例进行对比

使用 dexdump 进行反汇编

  • 命令
dexdump -d Hello.dex
  • 反汇编代码
    在这里插入图片描述

使用 baksmali

java -jar baksmali.jar d Hello.dex
  • 书上的命令貌似已改,执行上述命令后会在当前目录生成 out 文件夹,里面即是 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

二者生成的反汇编代码异同

  • 结构大致相同,方法名、字段类型和代码指令序列一致
  • dexdump 使用的都是以“v”开头的寄存器,baksmali 则同时使用以“v”和“p”开头的寄存器(涉及 Dalvik 寄存器的命名法)。二者的寄存器表示法不同,baksmali 使用 p 命名法,dexdump 使用 v 命名法
  • baksmali 还支持使用 smali 打包反汇编代码来重新生成 DEX 文件(可用于 APK 文件的修改、打补丁、破解等场合)

Dalvik 寄存器

  • Dalvik 虚拟机基于寄存器架构,其代码中使用大量寄存器
  • Dalvik 虚拟机是在特定架构的 CPU 上运行的,设计之初采用了 ARM 架构
  • ARM 架构的 CPU 本身集成了多个寄存器,Dalvik 将部分寄存器映射到 ARM 寄存器上,还有一部分通过调用栈进行模拟
  • Dalvik 使用的寄存器都是 32 位的,支持所有类型。对 64 位类型,可用两个相邻寄存器表示
  • Dalvik 虚拟机支持的虚拟寄存器个数:
  • 查看 Dalvik 指令格式表,可发现形如“∅∅|op AAAA BBBB”的指令,其语法格式为“op vAAAA, vBBBB”,其中每个大写字母代表 4 位。AAAA、BBBB 的最大值为 2^16-1,即 65535。寄存器的初始值为 v0,因此其取值范围为 v0~v65535

Dalvik 虚拟机如何虚拟地使用寄存器

  • Dalvik 虚拟机为每个进程维护一个调用栈,此调用栈的作用之一即“虚拟”寄存器
  • 每个函数都在头部用 .registers 指令指定其所使用的寄存器数目,当虚拟机执行到此函数时,会根据寄存器的数目分配适当的栈空间,这些空间即用来存放寄存器的实际值
  • 虚拟机通过处理字节码对寄存器进行的读写操作,实际都是在对栈空间进行写操作
  • Android SDK 中的 dalvik.bytecode.Opcodes 接口定义了一份完整的 Dalvik 字节码列表(用于处理这些字节码的函数是宏 HANDLE_OPCODE())
  • 这份 Dalvik 字节码列表中的每个字节码的处理过程可在 Android 4.4 源码的 dalvik/vm/mterp/c 目录找到(高版本 Android 源码中 vm 目录已移除)

寄存器命名法

  • v 命名法、p 命名法:Dalvik 字节码的两种寄存器表示法
  • 区别:
  • 假设一个函数使用 M 个寄存器,且有 N 个参数,根据 Dalvik 虚拟机对参数传递方式的规定,参数使用最后的 N 个寄存器,局部变量使用从 v0 开始的 M-N 个寄存器
    在这里插入图片描述
  • 如上图,foo() 函数使用五个寄存器和两个显式的整型参数。该函数是 Hello 类的非静态方法,在被调用时会传入一个隐式的 Hello 对象引用,因此实际传入了三个参数。据传参规则,局部变量将使用前两个寄存器,参数将使用后三个寄存器

v 命名法

  • 以小写字母 v 开头的形式表示函数所使用的局部变量与参数,寄存器命名从 v0 开始递增
  • foo() 函数中,此命名法会使用 v0、v1、v2、v3、v4 五个寄存器,v0、v1 用于表示函数的局部变量寄存器,v2 用于表示被传入的 Hello 对象引用,v3、v4 用于表示两个传入的整型参数

p 命名法

  • 对函数的局部变量寄存器命名无影响,其命名规则:函数中引入的参数命名从 p0 开始递增
  • foo() 函数中,此命名法使用 v0、v1、p0、p1、p2 五个寄存器,v0、v1 同样表示局部变量寄存器,p0 表示被传入的 Hello 对象引用,p1、p2 表示传入的参数
    在这里插入图片描述

Dalvik 字节码

  • Dalvik 字节码有自己的类型、方法及字段表示法,这些内容与 Dalvik 虚拟机指令集一起组成 Dalvik 汇编代码

类型

  • 两种:基本类型、引用类型
  • Dalvik 用这两种类型表示 Java 语言的全部类型
  • 引用类型:对象、数组
  • 基本类型:其他 Java 类型
  • baksmali 严格遵守 DEX 文件格式类型描述符的定义
    在这里插入图片描述
  • 每个 Dalvik 寄存器都是 32 位。对长度小于或等于 32 位的类型,一个寄存器就可存放该类型的值;对 J、D 等 64 位的类型,其值要用相邻的两个寄存器存储,如 v0 与 v1、v3 与 v4
  • L 类型可表示 Java 类型中的任何类。这些类在 Java 代码中以 package.name.ObjectName 的方式被引用,而在 Dalvik 汇编代码中,以“Lpackage/name/ObjectName;”的形式表示(最后有个分号):L 表示后面跟着一个 Java 类,package/name/ 表示对象所在的包,ObjectName 表示对象的名称,分号表示对象名结束,如“Ljava/lang/String:”相当于“java.lang.String”
  • [ 类型可表示所有基本类型的数组。[ 后紧跟基本类型描述符,如“[I”,表示一个整型一维数组,相当于 Java 中的 int[]。多个“[”放一起,可表示多维数组,如“[[I”表示“int[][]”、“[[[I”表示“int[][][]”。多维数组的最大维度为 255
  • L 和 [ 可同时用,表示对象数组,如“[Ljava/lang/String;”,表示 Java 中的字符串数组

方法

  • Dalvik 用方法名、类型参数与返回值详细描述一个方法
  • 有助于 Dalvik 虚拟机在运行时从方法表中快速找到正确的方法
  • Dalvik 虚拟机可用它们进行一些静态分析,如 Dalvik 字节码的验证与优化
  • 格式:
Lpackage/name/ObjectName;->MethodName(III)Z
  • “Lpackage/name/ObjectName;”应被理解为一个类型,MethodName 为具体的方法名,(III)Z 为方法的签名部分,括号里的 III 为方法的参数(此处为三个整型参数),Z 表示方法的返回类型(此处为 boolean 类型)
  • 更复杂的示例:
method(I[[IILjava/lang/String;[Ljava/lang/Object;)Ljava/lang/String;
  • 将其转换为 Java 形式代码:
String method(int, int[][], int, String, Object[])
  • baksmali 生成的方法代码以 .method 指令开始,以 .end method 指令结束,根据方法方法类型的不同,在方法指令前可能会用“#”来添加注释。如“# virtual method”,表示是一个虚方法;“# direct method”,表示是一个直接方法

字段

  • 字段和方法相似,但字段没有方法签名域中的参数和返回值,取代它们的是字段的类型
  • Dalvik 虚拟机在定位字段与进行字节码静态分析时会用到它
  • 格式:
Lpackage/name/ObjectName;->FieldName:Ljava/lang/String;
  • 字段由类型(Lpackage/name/ObjectName;)、字段名(FieldName)与字段类型(Ljava/lang/String;)组成
  • 字段名和字段类型用冒号分隔
  • baksmali 生成的字段代码以 .field 指令开头,根据字段类型的不同,字段指令前可能会用“#”添加注释,如“# instance field”表示是一个实例字段,“# static field”表示是一个静态字段

Dalvik 指令集

  • Android 4.4 前的系统:Android 源码文件 dalvik/libdex/DexOpcodes.h 中可找到完整 Dalvik 指令集
  • Android 4.4 及之后以 ART 为主导的系统:Android 源码文件 art/runtime/dexinstructionlist.h 中可找到
  • 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:目的寄存器,始终在源的前面,取值范围为 v0~v255

  • vBBBB:源寄存器,取值范围为 v0~v65535

  • Dalvik 指令集中的大多数指令都使用寄存器作为目的操作数或源操作数

  • A、B、C、D、E、F、G、H 代表 4 位的数值,可用于表示数值 0~15 或寄存器 v0~v15

  • AA、BB、CC、DD、EE、FF、GG、HH 代表 8 位的数值,可用于表示 0~255 或寄存器 v0~v255

  • AAAA、BBBB、CCCC、DDDD、EEEE、FFFF、GGGG、HHHH 代表 16 位的数值,可用于表示 0~65535 或寄存器 v0~v65535

空操作指令

  • 助记符为 nop,其值为 00
  • nop 指令常用于对齐代码,不进行实际操作

数据操作指令

  • 数据操作指令为 move
  • 原型为 move destination, source
  • 根据字节码大小与类型不同,move 指令有不同的后缀

move 指令后缀

  • move vA, vB
  • 将 vB 寄存器的值赋给 vA 寄存器,源寄存器和目的寄存器都为 4 位
  • move/from16 vAA, vBBBB
  • 将 vBBBB 寄存器的值赋给 vAA 寄存器,源寄存器为 16 位,目的寄存器为 8 位
  • move/16 vAAAA, vBBBB
  • 将 vBBBB 寄存器的值赋给 vAAAA 寄存器,源寄存器和目的寄存器都为 16 位
  • move-wide vA, vB
  • 为 4 位的寄存器对赋值,源寄存器和目的寄存器都为 4 位
  • move-wide/from16 vAA, vBBBB 与 move-wide/16 vAAAA, vBBBB
  • 使 move-wide 相同
  • move-object vA, vB
  • 为对象赋值,源寄存器和目的寄存器都为 4 位
  • move-object/from16 vAA, vBBBB
  • 为对象赋值,源寄存器为 16 位,目的寄存器为 8 位
  • move-object/16 vAAAA, vBBBB
  • 为对象赋值,源寄存器和目的寄存器都为 16 位
  • move-result vAA
  • 将上一个 invoke 类型指令操作的单字非对象结果赋给 vAA 寄存器
  • move-result-wide vAA
  • 将上一个 invoke 类型指令操作的双字非对象结果赋给 vAA 寄存器
  • move-result-object vAA
  • 将上一个 invoke 类型指令操作的对象结果赋给 vAA 寄存器
  • move-exception vAA
  • 将一个在运行时发生的异常保存到 vAA 寄存器中。这条指令必须在异常发生时由异常处理器使用,否则指令无效

返回指令

  • 函数结束时运行的最后一条指令,其基础字节码为 return,有如下四条返回指令:
  • return-void:函数从一个 void 方法返回
  • return vAA:函数返回一个 32 位非对象类型的值,返回值为 8 位寄存器 vAA
  • return-wide vAA:函数返回一个 64 位非对象类型的值,返回值为 8 位寄存器对 vAA
  • return-object vAA:函数返回一个对象类型的值,返回值为 8 位寄存器 vAA

数据定义指令

  • 用于定义程序中用到的常量、字符串、类等数据,其基础字节码为 const
  • const/4 vA, #+B:将数值符号扩展为 32 位后赋给寄存器 vA
  • const/16 vAA, #+BBBB:将数值符号扩展为 32 位后赋给寄存器 vAA
  • const vAA, #+BBBBBBBB:将数值赋给寄存器 vAA
  • const/high16 vAA, #+BBBB0000:将数值右边的 0 扩展为 32 位后赋给寄存器 vAA
  • const-wide/16 vAA, #+BBBB:将数值符号扩展为 64 位后赋给寄存器对 vAA
  • const-wide/32 vAA, #+BBBBBBBB:将数值符号扩展为 64 位后赋给寄存器对 vAA
  • const-wide vAA, #+BBBBBBBBBBBBBBBB:将数值赋给寄存器对 vAA
  • const-wide/high16 vAA, #+BBBB000000000000:将数值右边的 0 扩展为 64 位后赋给寄存器对 vAA
  • const-string vAA, string@BBBB:通过字符串索引构造一个字符串,并将其赋给寄存器 vAA
  • const-string/jumbo vAA, string@BBBBBBBB:通过字符串索引(较大)构造一个字符串,并将其赋给寄存器 vAA
  • const-class vAA, type@BBBB:通过类型索引获取一个类引用,并将其赋给寄存器 vAA
  • const-class/jumbo vAAAA, type@BBBBBBBB:通过给定的类型索引获取一个类引用,并将其赋给寄存器 vAAAA。这条指令占用 2 字节,值为 0x00ff(Android 4.0 新增指令)

锁指令

  • 多用在多线程程序对同一对象的操作中。Dalvik 指令集中有两条:
  • monitor-enter vAA:为指定对象获取锁
  • monitor-exit vAA:释放指定对象的锁

实例操作指令

  • 与实例相关的操作包括实例的类型转换、检查及创建等
  • check-cast vAA, type@BBBB:将 vAA 寄存器中的对象引用转换成指定的类型,若失败会抛出 ClassCastException 异常。若类型 B 指定的是基本类型,则对非基本类型的类型 A 来说,运行将失败
  • instance-of vA, vB, type@CCCC:判断 vB 寄存器中的对象引用是否可以转换成指定的类型,若可以就为 vA 寄存器赋值 1, 否则赋值 0
  • new-instance vAA, type@BBBB:构造一个指定类型对象的新实例,并将对象引用赋给 vAA 寄存器。类型符 type 指定的类型不能是数组类
  • check-cast/jumbo vAAAA, type@BBBBBBBB:功能与上述第一条指令相同,但本条指令的寄存器与指令索引的取值范围更大(Android 4.0 新增指令)
  • instance-of/jumbo vAAAA, vBBBB, type@CCCCCCCC:功能与上述第二条指令相同,但本条指令的寄存器与指令索引的取值范围更大(Android 4.0 新增指令)
  • new-instance/jumbo vAAAA, type@BBBBBBBB:功能与上述第三条指令相同,但本条指令的寄存器与指令索引的取值范围更大(Android 4.0 新增指令)

数组操作指令

  • 包括获取数组长度、新建数组、数组赋值、数组元素取值与赋值等
  • array-length vA, vB:获取给定 vB 寄存器中数组的长度,并将值赋给 vA 寄存器。数组长度值即数组中条目的个数
  • new-array vA, vB, type@CCCC:构造指定类型(type@CCCC)和大小(vB)的数组,并将值赋给 vA 寄存器
  • filled-new-array {vC, vD, vE, vF, vG}, type@BBBB:构造指定类型(type@BBBB)和大小(vA)的数组并填充数组内容。vA 寄存器是隐含使用的,除了指定数组的大小,还指定了参数个数。vC~vG 是所使用的参数寄存器序列
  • filled-new-array/range {vCCCC … vNNNN}, type@BBBB:功能与上一条指令相同,但参数寄存器使用 range 字节码后缀指定取值范围。vC 是第一个参数寄存器,N=A+C-1
  • filled-array-data vAA, +BBBBBBBB:用指定的数据填充数组,vAA 寄存器为数组引用(引用的必须是基础类型的数组),在指令后会紧跟一个数据表
  • new-array/jumbo vAAAA, vBBBB, type@CCCCCCCC:功能与上述第二条指令相同,但本条指令的寄存器与指令索引的取值范围更大(Android 4.0 新增指令)
  • filled-new-array/jumbo{vCCCC … vNNNN}, type@BBBBBBBB:功能与上述第四条指令相同,但本条指令的指令索引的取值范围更大(Android 4.0 新增)
  • arrayop vAA, vBB, vCC:对 vBB 寄存器指定的数组元素进行取值与赋值。vCC 寄存器用于指定数组元素的索引。vAA 寄存器用于存放读取或需要设置的数组元素的值。读取元素时用 aget 类指令,为元素赋值时用 aput 类指令。根据数组中存储的类型指令的不同,在指令后会紧跟不同的指令后缀。指令包括:aget、aget-wide、aget-object、aget-boolean、aget-byte、aget-char、aget-short、aput、aput-wide、aput-object、aput-boolean、aput-byte、aput-char、aput-short

异常指令

  • Dalvik 指令集中有一条用于抛出异常的指令
  • throw vAA:抛出 vAA 寄存器中指定类型的异常

跳转指令

  • 用于从当前地址跳转到指定的偏移处
  • Dalvik 指令集中有三种跳转指令:无条件跳转指令 goto、分支跳转指令 switch、条件跳转指令 if

指令列举

  • goto +AA:无条件跳转到指定偏移处,偏移量 AA 不能为 0
  • goto/16 +AAAA:无条件跳转到指定偏移处,偏移量 AAAA 不能为 0
  • goto/32 +AAAAAAAA:无条件跳转到指定偏移处
  • packed-switch vAA, +BBBBBBBB:分支跳转指令,vAA 寄存器为 switch 分支中需要判断的值,BBBBBBBB 指向一个 packed-switch-payload 格式的偏移表,表中的值是递增的偏移量
  • sparse-switch vAA, +BBBBBBBB:分支跳转指令,vAA 寄存器为 switch 分支中需要判断的值,BBBBBBBB 指向一个 sparse-switch-payload 格式的偏移表,表中的值是无规律的偏移量
  • if-test vA, vB, +CCCC:比较 vA 和 vB 寄存器的值。若比较结果满足,就跳转到 CCCC 指定的偏移处。偏移量 CCCC 不能为 0。if-test 类型的指令:
  • if-eq:若 vA 等于 vB 则跳转,其 Java 语法表示为“if(vA==vB)”
  • if-ne:若 vA 不等于 vB 则跳,Java 语法为“if(vA!=vB)”
  • if-lt:若 vA 小于 vB 则跳,Java 语法为“if(vA<vB)”
  • if-le:若 vA 小于等于 vB 则跳,Java 语法为“if(vA<=vB)”
  • if-gt:若 vA 大于 vB 则跳,Java 语法为“if(vA>vB)”
  • if-ge:若 vA 大于等于 vB 则跳,Java 语法为“if(vA>=vB)”
  • if-testz vAA, +BBBB:将 vAA 寄存器的值与 0 比较。若比较结果满足或值为 0,则跳转到 BBBB 指定的偏移处。偏移量 BBBB 不能为 0。if-testz 类型的指令:
  • if-eqz:若 vAA 为 0 则跳,Java 语法为“if(!vAA)”
  • if-nez:若 vAA 不为 0 则跳,Java 语法为“if(vAA)”
  • if-ltz:若 vAA 小于 0 则跳,Java 语法为“if(vAA<0)”
  • if-lez:若 vAA 小于等于 0 则跳,Java 语法为“if(vAA<=0)”
  • if-gtz:若 vAA 大于 0 则跳,Java 语法为“if(vAA>0)”
  • if-gez:若 vAA 大于等于 0 则跳,Java 语法为“if(vAA>=0)”

比较指令

  • 对两个寄存器的值(浮点型或长整型)比较
  • 格式:
cmpkind vAA, vBB, vCC
  • vBB、vCC:需要比较的两个寄存器或两个寄存器对
  • vAA:存储比较结果的寄存器
  • Dalvik 指令集共有五条比较指令:
  • cmpl-float:比较两个单精度浮点数。若 vBB 寄存器的值大于 vCC 寄存器的值,结果为 -1;若等于,结果为 0;若小于,结果为 1
  • cmpg-float:比较两个单精度浮点数。若 vBB 寄存器的值 大于 vCC,结果为 1;若等于,结果为 0;若小于,结果为 -1
  • cmpl-double:比较两个双精度浮点数。若 vBB 寄存器对的值大于 vCC 寄存器对的值,结果为 -1;若等于,结果为 0;若小于,结果为 1
  • cmpg-double:比较两个双精度浮点数。若 vBB 寄存器对的值大于 vCC 寄存器对的值,结果为 1;若等于,结果为 0;若小于,结果为 -1
  • cmp-long:比较两个长整型数。若 vBB 寄存器的值大于 vCC 寄存器的值,结果为 1;若等于,结果为 0;若小于,结果为 -1

字段操作指令

  • 对对象实例的字段进行读写操作,字段的类型可以是 Java 中有效的数据类型
  • 对普通字段操作与静态字段操作,有两种指令集:
iinstanceop vA, vB, field@CCCC
sstaticop vAA, field@BBBB
  • 普通字段的指令前缀为 i。如,对普通字段,读操作用 iget 指令,写操作用 iput 指令
  • 静态字段的指令前缀为 s。如,对静态字段,读操作用 sget 指令,写操作用 sput 指令
  • 根据访问的字段类型不同,字段操作指令后会紧跟字段类型的后缀。如 iget-byte 表示读取实例字段的值的类型为字节型;iput-short 表示设置实例字段的值的类型为短整型。这两类指令的操作结果一样,只是指令前缀与操作的字段类型不同
  • 普通字段操作指令:iget、iget-wide、iget-object、iget-boolean、iget-byte、iget-char、iget-short、iput、iput-wide、iput-object、iput-boolean、iput-byte、iput-char、iput-short
  • 静态字段操作指令:sget、sget-wide、sget-object、sget-boolean、sget-byte、sget-char、sget-short、sput、sput-wide、sput-object、sput-boolean、sput-byte、sput-char、sput-short
  • Android 4.0 中,Dalvik 指令集新增:
iinstanceop/jumbo vAAAA, vBBBB, field@CCCCCCCC
sstaticop/jumbo vAAAA, field@BBBBBBBB
  • 作用都与上述两类指令相同,而在指令中增加 jumbo 字节码后缀,表示寄存器与指令索引的取值范围更大

方法调用指令

  • 负责调用类实例的方法,基础指令为 invoke
  • 方法调用指令有两类:
invoke-kind {vC, vD, vE, vF, vG}, meth@BBBB
invoke-kind/range {vCCCC ... vNNNN}, meth@BBBB
  • 这两类指令在作用上并无不同,但后者在设置参数寄存器时使用 range 指定寄存器的范围
  • 根据方法类型不同,共有五条方法调用指令:
  • invoke-virtual 或 invoke-virtual/range:调用实例的虚方法
  • invoke-super 或 invoke-super/range:调用实例的父类方法
  • invoke-direct 或 invoke-direct/range:调用实例的直接方法
  • invoke-static 或 invoke-static/range:调用实例的静态方法
  • invoke-interface 或 invoke-interface/range:调用实例的接口方法
  • Android 4.0 中 Dalvik 指令集新增这类指令:
invoke-kind/jumbo {vCCCC ... vNNNN}, meth@BBBBBBBB
  • 这类指令与上述两类指令作用相同,只不过增加了 jumbo 字节码后缀,且寄存器与指令索引的取值范围更大
  • 方法调用指令的返回值必须用 move-result* 指令来获取,示例:
invoke-static {}, Landroid/os/Parcel;->obtain()Landroid/os/Parcel;
move-result-object v0

数据转换指令

  • 将一种类型的数值转换成另一种类型的数值
  • 格式:
unop vA, vB
  • vB 寄存器(或寄存器对)中存放需要转换的数据
  • 转换结果保存在 vA 寄存器(或寄存器对)中
  • 数据转换指令列举:
  • neg-int:对整型数求补
  • not-int:对整型数求反
  • neg-long:对长整型数求补
  • not-long:对长整型数求反
  • neg-float:对单精度浮点型数求补
  • neg-double:对双精度浮点型数求补
  • int-to-long:将整型数转换为长整型数
  • int-to-float:将整型数转换为单精度浮点型数
  • int-to-double:将整型数转换为双精度浮点型数
  • long-to-int:将长整型数转换为整型数
  • long-to-float:将长整型数转换为单精度浮点型数
  • long-to-double:将长整型数转换为双精度浮点型数
  • float-to-int:将单精度浮点型数转换为整型数
  • float-to-long:将单精度浮点型数转换为长整型数
  • float-to-double:将单精度浮点型数转换为双精度浮点型数
  • double-to-int:将双精度浮点型数转换为整型数
  • double-to-long:将双精度浮点型数转换为长整型数
  • double-to-float:将双精度浮点型数转换为单精度浮点型数
  • int-to-byte:将整型数转换为字节型
  • int-to-char:将整型数转换为字符串
  • int-to-short:将整型数转换为短整型

数据运算指令

  • 包括算术运算指令、逻辑运算指令
  • 算术运算指令:进行数值间的加、减、乘、除、模、移位等运算
  • 逻辑运算指令:进行数值间的与、或、非、异或等运算
  • 算术运算指令有四类(由于数据运算可能在寄存器或寄存器对间进行,下面均用寄存器描述):
  • binop vAA, vBB, vCC:将 vBB 寄存器与 vCC 寄存器进行运算,结果保存到 vAA 寄存器
  • binop/2addr vA, vB:将 vA 寄存器与 vB 寄存器进行运算,结果保存到 vA 寄存器
  • binop/lit16 vA, vB, #+CCCC:将 vB 寄存器与常量 CCCC 进行运算,结果保存到 vA 寄存器
  • binop/lit8 vAA, vBB, #+CC:将 vBB 寄存器与常量 CC 进行运算,结果保存到 vAA 寄存器
  • 后三类指令比第一类多出 2addr、lit16、lit8 等指令后缀
  • 这四类指令中,基础字节码相同的指令执行的运算类似
  • 第一类指令中,根据数据类型不同,会在基础字节码后加上不同的后缀,如 -int、-long 分别表示操作的数据类型为整型、长整型。第一类指令规类:
  • add-type:将 vBB 寄存器的值与 vCC 寄存器的值相加(vBB+vCC)
  • sub-type:相减(vBB-vCC)
  • mul-type:相乘(vBB*vCC)
  • div-type:相除(vBB/vCC)
  • rem-type:模运算(vBB%vCC)
  • and-type:与运算(vBB AND vCC)
  • or-type:或运算(vBB OR vCC)
  • xor-type:异或运算(vBB XOR vCC)
  • shl-type:将 vBB 寄存器的值(有符号数)左移 vCC 位(vBB<<vCC)
  • shr-type:将 vBB 的值(有符号数)右移 vCC 位(vBB>>vCC)
  • ushr-type:将 vBB 的值(无符号数)右移 vCC 位(vBB>>vCC)
  • 基础字节码后的 -type 可以是 -int、-long、-float、-double
  • 后三类指令与上面的类似

补充说明

  • Android 4.0 前的系统中,每个指令的字节码只占 1 字节,范围为 0x0~-0x0ff
  • Android 4.0 扩充了一部分指令,这些指令被称为扩展指令,即在指令助记符后添加 jumbo 后缀并扩大寄存器与常量的取值范围

Dalvik 指令练习

编写 smali 文件

  • 使用 smali 语法编写一段 Dalvik 指令集代码
  • 新建文本文件 HelloWorld.smali,代码如下:
# 带 # 号部分为注释
# 定义类名
# 定义一个 public 类,全类名为 HelloWorld
.class public LHelloWorld;
# 定义父类
# 父类为 Object
.super Ljava/lang/Object;
# 声明静态 main()方法
# baksmali 生成的方法以 .method 开始
# public static:公有的静态方法
# 参数为 String 数组
# 返回值为 void
.method public static main([Ljava/lang/String;)V
	# 每个函数都在头部用 .registers 指定使用的寄存器数目
	# 这里程序使用 v0、v1、v2 寄存器和一个参数寄存器
	.registers 4
	# 代码起始指令
	.prologue
	# 空指令
	nop
	nop
	nop
	nop
	# 数据定义指令
	const/16 v0, 0x8
	const/4 v1, 0x5
	const/4 v2, 0x3
	# 数据操作指令
	move v1, v2
	# 数组操作指令
	# 构造一个 int 类型的数组
	new-array v0, v0, [I
	# 获取这个数组的长度
	array-length v1, v0
	# 实例操作指令
	# 构造一个 StringBuilder 类型对象的新实例
	new-instance v1, Ljava/lang/StringBuilder;
	# 方法调用指令
	# 调用实例的直接方法 <init>,无参,返回值为 void
	invoke-direct {v1}, Ljava/lang/StringBuilder;-><init>()V
	# 跳转指令
	# 如果 v0 不为 0 则跳转到 cond_0 处
	if-nez v0, :cond_0
	# 如果不满足上述条件,则无条件跳转到 goto_0 处,
	# 即 return 处
	goto :goto_0
	# 上面的条件跳转指令对应的偏移处
	:cond_0
	# 数据转换指令
	# 将 v2 转换为单精度浮点型数并存入 v2
	int-to-float v2, v2
	# 数据运算指令
	# 将 v2 与 v2 相加并存入 v2
	add-float v2, v2, v2
	# 比较指令
	# 比较 v2 与 v2,此处相等,所以将比较结果 0 存入 v0
	cmpl-float v0, v2, v2
	# 字段操作指令
	# 创建一个 PrintStream 对象并存入 v0
	sget-object v0, Ljava/lang/System;->out:Ljava/io/PrintStream;
	# 构造字符串
	# 构造一个字符串“Hello World”并存入 v1
	const-string v1, "Hello World"
	# 方法调用指令
	# 调用 PrintStream 中的 println 方法,参数为 v1,
	# 即上面的字符串
	invoke-virtual {v0, v1}, Ljava/io/PrintStream;->println(Ljava/lang/String;)V
	# 返回指令
	:goto_0
	# 返回空值
	return-void
# baksmali 生成的方法以 .end method 结束
.end method

编译 smali 文件

java -jar smali.jar a -o HelloWorld.dex HelloWorld.smali
  • 编译成功后会在当前目录生成指定的 HelloWorld.dex 文件
    在这里插入图片描述

测试运行

  • 启动 Android 运行环境(模拟器或 Android 设备),输入如下命令:
adb push HelloWorld.dex /sdcard/

在这里插入图片描述

  • 再次输入如下命令:
adb shell dalvikvm -cp /sdcard/HelloWorld.dex HelloWorld

在这里插入图片描述

  • 如上所示,输出了字符串“Hello World”
发布了7 篇原创文章 · 获赞 5 · 访问量 229

猜你喜欢

转载自blog.csdn.net/zlmm741/article/details/104566842