写点什么

浅谈 Android Dex 文件

  • 2020-03-11
  • 本文字数:5559 字

    阅读完需:约 18 分钟

浅谈 Android Dex 文件

概述

为什么要了解 Dex 文件

了解了 Dex 文件以后,对日常开发中遇到一些问题能有更深的理解。如:APK 的瘦身、热修复、插件化、应用加固、Android 逆向工程、64K 方法数限制。

什么是 Dex 文件

在明白什么是 Dex 文件之前,要先了解一下 JVM,Dalvik 和 ART。JVM 是 JAVA 虚拟机,用来运行 JAVA 字节码程序。Dalvik 是 Google 设计的用于 Android 平台的运行时环境,适合移动环境下内存和处理器速度有限的系统。ART 即 Android Runtime,是 Google 为了替换 Dalvik 设计的新 Android 运行时环境,在 Android4.4 推出。ART 比 Dalvik 的性能更好。Android 程序一般使用 Java 语言开发,但是 Dalvik 虚拟机并不支持直接执行 JAVA 字节码,所以会对编译生成的 .class 文件进行翻译、重构、解释、压缩等处理,这个处理过程是由 dx 进行处理,处理完成后生成的产物会以 .dex 结尾,称为 Dex 文件。Dex 文件格式是专为 Dalvik 设计的一种压缩格式。所以可以简单的理解为:Dex 文件是很多 .class 文件处理后的产物,最终可以在 Android 运行时环境执行。

Dex 文件是怎么生成的

Java 代码转化为 Dex 文件的流程如图所示,当然真的处理流程不会这么简单,这里只是一个形象的显示:



注:图片来源于网络


现在来通过一个简单的例子实现 Java 代码到 Dex 文件的转化。

从.java 到.class

先来创建一个 Hello.java 文件,为了便于分析,这里写一些简单的代码。代码如下:


public class Hello {  private String helloString = "hello! youzan";
public static void main(String[] args) { Hello hello = new Hello(); hello.fun(hello.helloString); }
public void fun(String a) { System.out.println(a); }}
复制代码


在该文件的同级目录下面使用 JDK 的 javac 编译这个 java 文件。


javac Hello
复制代码


javac 命令执行后会在当前目录生成 Hello.class 文件,Hello.class 文件已经可以直接在 JVM 虚拟机上直接执行。这里使用使用命令执行该文件。


java Hello
复制代码


执行后应该会在控制台打印出“hello! youzan”


这里也可以对 Hello.class 文件执行 javap 命令,进行反汇编。


javap -c Hello
复制代码


执行结果如下:


public class Hello { public Hello();  Code:    0: aload_0    1: invokespecial #1         // Method java/lang/Object."<init>":()V    4: aload_0    5: ldc      #2         // String hello! youzan    7: putfield   #3         // Field helloString:Ljava/lang/String;   10: return
public static void main(java.lang.String[]); Code: 0: new #4 // class Hello 3: dup 4: invokespecial #5 // Method "<init>":()V 7: astore_1 8: aload_1 9: aload_1 10: getfield #3 // Field helloString:Ljava/lang/String; 13: invokevirtual #6 // Method fun:(Ljava/lang/String;)V 16: return
public void fun(java.lang.String); Code: 0: getstatic #7 // Field java/lang/System.out:Ljava/io/PrintStream; 3: aload_1 4: invokevirtual #8 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 7: return}
复制代码


其中 Code 之后都是具体的指令,供 JVM 虚拟机执行。指令的具体含义可以参考 JAVA 官方文档。

从.class 到.dex

上面生成的 .class 文件虽然已经可以在 JVM 环境中运行,但是如果要在 Android 运行时环境中执行还需要特殊的处理,那就是 dx 处理,它会对 .class 文件进行翻译、重构、解释、压缩等操作。


dx 处理会使用到一个工具 dx.jar,这个文件位于 SDK 中,具体的目录大致为 你的 SDK 根目录/build-tools/任意版本 里面。使用 dx 工具处理上面生成的 Hello.class 文件,在 Hello.class 的目录下使用下面的命令:


dx --dex --output=Hello.dex Hello.class
复制代码


执行完成后,会在当前目录下生成一个 Hello.dex 文件。这个 .dex 文件就可以直接在 Android 运行时环境执行,一般可以通过 PathClassLoader 去加载 dex 文件。现在在当前目录下执行 dexdump 命名来反编译:


dexdump -d Hello.dex
复制代码


执行结果如下(部分区域的含义已经在下面描述):


Processing 'Hello.dex'...Opened 'Hello.dex', DEX version '035'
------ 这里是编写的Hello.java的类的信息 ------Class #0 - Class descriptor : 'LHello;' Access flags : 0x0001 (PUBLIC) Superclass : 'Ljava/lang/Object;' Interfaces - Static fields - Instance fields - #0 : (in LHello;) name : 'helloString' type : 'Ljava/lang/String;' access : 0x0002 (PRIVATE)
------ 下面区域描述的是构造方法的信息。7010 0400 0100 1a00 0b00 之类的数字就是方法中的代码翻译成的指令。Dalvik使用的是16位代码单元,所以这里就是4个数字为一组,每个数字是16进制。invoke-direct 这些是前面指令对应的助记符,也代表着这些指令的真正操作。如果对这些指令转化感兴趣可以去https://source.android.com/devices/tech/dalvik/instruction-formats 查看 ------ Direct methods - #0 : (in LHello;) name : '<init>' --- 方法名称:这个很明显就是构造方法 --- type : '()V' --- 方法原型,()里面表示入参,()后面表示返回值,V代表void--- access : 0x10001 (PUBLIC CONSTRUCTOR) --- 方法访问类型 --- code - registers : 2 --- 方法使用的寄存器数量 --- ins : 1 --- 方法入参,方法除了我们定义的参数以外,系统还会默认带一个特殊参数 --- outs : 1 insns size : 8 16-bit code units --- 指令大小 ---000148: |[000148] Hello.<init>:()V000158: 7010 0400 0100 |0000: invoke-direct {v1}, Ljava/lang/Object;.<init>:()V // method@000400015e: 1a00 0b00 |0003: const-string v0, "hello! youzan" // string@000b000162: 5b10 0000 |0005: iput-object v0, v1, LHello;.helloString:Ljava/lang/String; // field@0000000166: 0e00 |0007: return-void catches : (none) positions : 0x0000 line=1 0x0003 line=2 locals : 0x0000 - 0x0008 reg=1 this LHello;
#1 : (in LHello;) name : 'main' type : '([Ljava/lang/String;)V' access : 0x0009 (PUBLIC STATIC) code - registers : 3 ins : 1 outs : 2 insns size : 11 16-bit code units000168: |[000168] Hello.main:([Ljava/lang/String;)V000178: 2200 0000 |0000: new-instance v0, LHello; // type@000000017c: 7010 0000 0000 |0002: invoke-direct {v0}, LHello;.<init>:()V // method@0000000182: 5401 0000 |0005: iget-object v1, v0, LHello;.helloString:Ljava/lang/String; // field@0000000186: 6e20 0100 1000 |0007: invoke-virtual {v0, v1}, LHello;.fun:(Ljava/lang/String;)V // method@000100018c: 0e00 |000a: return-void catches : (none) positions : 0x0000 line=5 0x0005 line=6 0x000a line=7 locals :
Virtual methods - #0 : (in LHello;) name : 'fun' type : '(Ljava/lang/String;)V' access : 0x0001 (PUBLIC) code - registers : 3 ins : 2 outs : 2 insns size : 6 16-bit code units000190: |[000190] Hello.fun:(Ljava/lang/String;)V0001a0: 6200 0100 |0000: sget-object v0, Ljava/lang/System;.out:Ljava/io/PrintStream; // field@00010001a4: 6e20 0300 2000 |0002: invoke-virtual {v0, v2}, Ljava/io/PrintStream;.println:(Ljava/lang/String;)V // method@00030001aa: 0e00 |0005: return-void catches : (none) positions : 0x0000 line=10 0x0005 line=11 locals : 0x0000 - 0x0006 reg=1 this LHello;
source_file_idx : 1 (Hello.java)
复制代码


到此为止,已经完成了将 Java 代码转变成 Dalvik 可执行的文件,即 dex。

Dex 文件的具体格式

现在来分析一下 Dex 文件的具体格式,就像 MP3,MP4,JPG,PNG 文件一样,Dex 文件也有它自己的格式,只有遵守了这些格式,才能被 Android 运行时环境正确识别。


Dex 文件整体布局如下图所示:



这些区域的数据互相关联,互相引用。由于篇幅原因,这里只是显示部分区域的关联,完整的请去官网自行查看相关数据整理。下图中的各字段都在后面的各区域的详细介绍中有具体介绍。



下面将分别对文件头、索引区、类定义区域进行简单的介绍。其它区域可以去 Android 官网了解。

文件头

文件头区域决定了该怎样来读取这个文件。具体的格式如下表(在文件中排列的顺序就是下面表格中的顺序):


id 区

id 区存储着字符串,type,prototype,field, method 资源的真正数据在文件中的偏移量,我们可以根据 id 区的偏移量去找到该 id 对应的真实数据。

字符串 id 区域

这个区块是一个偏移量列表,每个偏移量对应了一个真正的字符串资源,每个偏移量占 32 位。我们可以通过偏移量找到对应的实际字符串数据。具体格式如下:



最终这个偏移的位置应该是落在数据区的。找到这个偏移量的位置后,根据下面的格式就可以读取出这个字符串资源的具体数据:


类型 id 区

这个区块是一个索引列表,索引的值对应字符串 id 区域偏移量列表中的某一项。数据格式如下:



如果我们要找到某个类型的值,需要先根据类型 id 列表中的索引值去字符串 id 列表中找到对应的项,这一项存储的偏移量对应的字符串资源就是这个类型的字符串描述。

方法原型 id 区

这个区块是一个方法原型 id 列表,数据格式为:


成员 id 区

这个区块存储着原型 id 列表,数据格式为:


方法 id 区

这个区块存储着方法 id 列表,数据格式为: 这个区块存储着原型 id 列表,数据格式为:


类定义区

这个区域存储的是类定义的列表,具体的数据结构如下:


解析 dex 文件的工具

这里推荐一个可以解析 dex 文件的工具 010 Editor。它可以通过预置的模板让我们更清晰的了解 dex 文件的格式。


Dex 文件在 Android Tinker 热修复中的应用

在目前的主流的 Android 热修复方案中,Tinker 有免费、开源、用户量大等优点,因此在有赞也是基于 Tinker 搭建 Android 热修复服务。Tinker 热修复的主要原理就是通过对比旧 APK 的 dex 文件与新 APK 的 dex 文件,生成补丁包,然后在 APP 中通过补丁包与旧 APK 的 dex 文件合成新的 dex 文件。流程如下图所示:



注:图片来源于 Tinker 官网

补丁包的生成

Tinker 官方使用自研一套合成方案,就是 DexDiff。它基于 Dex 文件格式的特性,具有补丁包小,消耗内存小等优点。在 DexDiff 算法中,会根据 Dex 文件的格式,将 Dex 文件划分为不同的区块类,如下图:



这些区块有一个统一的数据结构,主要的数据有区块对应的实际数据类型及在文件中的偏移量。如下图:



有了区块数据中的实际数据类型与偏移量,再根据实际数据类型对应的数据结构就可以从文件中读出这个区块包含的实际数据。这里以 header 区域为例,读取代码如下(删除了部分无关代码,代码可以参照上面的 Dex 文件格式的文件头的介绍):


private void readHeader(Dex.Section headerIn) throws UnsupportedEncodingException { byte[] magic = headerIn.readByteArray(8);  int apiTarget = DexFormat.magicToApi(magic); checksum = headerIn.readInt();  signature = headerIn.readByteArray(20); fileSize = headerIn.readInt(); int headerSize = headerIn.readInt(); int endianTag = headerIn.readInt(); linkSize = headerIn.readInt(); linkOff = headerIn.readInt(); mapList.off = headerIn.readInt(); stringIds.size = headerIn.readInt(); stringIds.off = headerIn.readInt(); typeIds.size = headerIn.readInt(); typeIds.off = headerIn.readInt(); protoIds.size = headerIn.readInt(); protoIds.off = headerIn.readInt(); fieldIds.size = headerIn.readInt(); fieldIds.off = headerIn.readInt(); methodIds.size = headerIn.readInt(); methodIds.off = headerIn.readInt(); classDefs.size = headerIn.readInt(); classDefs.off = headerIn.readInt(); dataSize = headerIn.readInt(); dataOff = headerIn.readInt();}
复制代码


从文件中读取到新旧 Dex 文件各区块的具体的数据后,就可以进行对比生成补丁包了。因为各区块的数据结构不一致,因此各区块有着相应的 diff 算法来处理各区块补丁生成与合成。算法列表如图:



这些算法会对比新旧 Dex 文件转化成数据结构以后数据的差异,然后生成相关的操作指令,存储到补丁文件,下发到客户端。

补丁的合成

客户端收到补丁文件后,会使用相同的读取方式,将旧 Dex 文件转换为相关的数据结构,然后使用补丁包中的操作指令,对旧 Dex 数据进行修改,生成新 Dex 数据,最后数据写入文件,生成新 Dex 文件,这样就完成了补丁的合成。

写在最后

本文并没有写什么特别深入的东西,对 dex 的文件格式也没有完全描述完全。主要是给大家分享一个 dex 文件的大致结构,还有一些在实际中的应用。让大家在以后遇到相关问题的时候,可以有一些方向去了解 dex 文件,然后解决问题。最后,如果大家有任何的建议或意见,欢迎反馈。

参考资源


Dalvik 和 Java 字节码的对比(http://www.importnew.com/596.html


2020-03-11 22:192234

评论

发布
暂无评论
发现更多内容

系统稳定性建设之我见(64/100)

hackstoic

质量管理 系统稳定性

打工人逃不开「单人单岗」

Java 架构 程序人生 职场

nvim 配置c++环境

linux大本营

vim C++

扎最深的寨,打最持久的仗——一知智能AI商业化攻略访谈录

B Impact

概述产品设计通用七原则

阿泽🧸

产品设计 三周年连更

dpdk中,如何建立portid/queue的配置和逻辑核心的关系

linux大本营

队列 DPDK DPDK开发

2022-04-26:给定一个数组componets,长度为A, componets[i] = j,代表i类型的任务需要耗时j 给定一个二维数组orders,长度为M, orders[i][0]代表i

福大大架构师每日一题

golang 算法

minikube 初体验环境搭建

IT蜗壳-Tango

三周年连更

Java - 泛型

乌龟哥哥

三周年连更

Go并发编程的秘密武器:内存模型和同步原语

Jack

重载++运算符分别实现i++和++i

linux大本营

运算符 数据结构与算法

一文带你了解实战常用JavaScript API

程序员海军

JavaScript 三周年连更

6G 通信技术和 5G 通信技术的区别

汪子熙

通讯协议 通讯 三周年连更

数据存储与访问——文件存储读写

芯动大师

application 三周年连更 SharedPreference

Django笔记十七之group by 分组用法总结

Hunter熊

Python django count 分组查询 sum

linux dbus代码举例

linux大本营

Linux C++

如何建设IT运维流程与体系

穿过生命散发芬芳

运维体系 三周年连更

京韵、京城、京味:从一台服务器看数字北京

脑极体

算力

dpdk l2fwd如何初始化每个逻辑核的port/queue的

linux大本营

队列 DPDK DPDK开发

CefSharp自定义缓存实现

沙漠尽头的狼

LuckyDraw发布啦

进基的小张

开源项目 Github'

写一个完整的SHOW TABLE STATUS 语句返回的所有表的状态信息对应的结构体

linux大本营

数据库 存储 结构体 C++

linux dbus客户端和服务器示例代码

linux大本营

c++ Linux dbus

一键生成通用的微服务(gRPC)项目代码,让你的开发效率翻倍提升

vison

Go 微服务 gRPC 代码自动生成

Golang new 和 make 函数

宇宙之一粟

Go make new 三周年连更

基于Flutter实现Windows平台离线大模型对话应用实战

轻口味

flutter AI windows 跨平台 三周年连更

推荐一些好用的ChatGPT扩展工具

石云升

ChatGPT 三周年连更

解析下rte_pktmbuf_pool_create参数含义

linux大本营

DPDK DPDK开发

UDP报头是通过结构体位段实现的吗

linux大本营

网络协议 udp UDP协议

共话数字化新技术、新趋势 华为云开发者日东莞站成功举办

极客天地

openbmc 中如何使用D-bus

linux大本营

dbus openBMC

浅谈 Android Dex 文件_文化 & 方法_有赞技术_InfoQ精选文章