65.9K
CodeProject 正在变化。 阅读更多。
Home

Dex 文件格式

emptyStarIconemptyStarIconemptyStarIconemptyStarIconemptyStarIcon

0/5 (0投票)

2018年2月5日

CPOL
viewsIcon

11220

本文深入探讨 Dalvik 可执行文件格式,并提供一个最小 Dex 文件结构的实际示例。

你有没有想过,当你的 Android 应用程序代码被编译并打包成 APK 时,会发生什么?本文深入探讨了 Dalvik 可执行文件格式,并提供了一个最小 Dex 文件结构的实际示例。

什么是 Dex 文件?

Dex 文件包含最终由 Android 运行时执行的代码。每个 APK 都有一个单独的 classes.dex 文件,该文件引用应用程序中使用的所有类或方法。本质上,你代码库中使用的任何 ActivityObjectFragment 都将转换为 Dex 文件中的字节,可以作为 Android 应用程序运行。

了解 Dex 文件的结构会很有用,因为所有这些引用都会占用应用程序的很多空间。使用许多第三方库会使你的 APK 大小增加数兆字节,甚至更糟,导致臭名昭著的 64k 方法大小限制。当然,总有一天,Dex 文件知识会帮助你追查到 应用程序中意外的行为

Dexing 过程

Android 项目中的所有 Java 源文件首先被编译成 .class 文件,这些文件由字节码指令组成。在传统的 Java 应用程序中,这些指令将在 JVM 上执行。然而,Android 应用程序在 Android 运行时上执行,它使用不兼容的操作码,因此需要额外的 Dexing 步骤,将 .class 文件转换为单个 .dex 文件。

由于大多数移动设备在内存、处理能力和电池寿命方面都受到严重限制,ART 提供优于 JVM 的性能。实现这一目标的一个关键特性是 ART 执行提前编译 (AOT) 和即时编译 (JIT)。这避免了 JIT 的一些运行时开销,同时随着应用程序的分析,仍然允许 性能随时间提高

如何创建 Dex 文件

Dex 文件的实际示例使其更容易理解。让我们创建一个只包含一个 Application 类的 最小 APK,因为这允许我们理解文件格式而不会被典型应用程序中存在的数千种方法所淹没。

我们将使用 Hexfiend 以十六进制查看我们的 Dex 文件,因为 Dex 使用一些 不寻常的数据类型 来节省空间。我们已隐藏所有空字节,因此上面屏幕截图中的空白区域实际上表示 00

Dex 文件的结构

我们 480 字节 Dex 文件的 完整结构 以十六进制和 UTF-8 显示在下面。当解释为 UTF-8 时,某些部分可以立即识别,例如我们在源代码中定义的单个 BugsnagApp 类,而其他部分则不那么明显。

6465780A 30333800 7A44CBBB FB4AE841 0286C06A 8DF19000
3C5DE024 D07326A2 E0010000 70000000 78563412 00000000
00000000 64010000 05000000 70000000 03000000 84000000
01000000 90000000 00000000 00000000 02000000 9C000000
01000000 AC000000 14010000 CC000000 E4000000 EC000000
07010000 2C010000 2F010000 01000000 02000000 03000000
03000000 02000000 00000000 00000000 00000000 01000000
00000000 01000000 01000000 00000000 00000000 FFFFFFFF
00000000 57010000 00000000 01000100 01000000 00000000
04000000 70100000 00000E00 063C696E 69743E00 194C616E
64726F69 642F6170 702F4170 706C6963 6174696F 6E3B0023
4C636F6D 2F627567 736E6167 2F646578 6578616D 706C652F
42756773 6E616741 70703B00 01560026 7E7E4438 7B226D69
6E2D6170 69223A32 362C2276 65727369 6F6E223A 2276302E
312E3134 227D0000 00010001 818004CC 01000000 0A000000
00000000 01000000 00000000 01000000 05000000 70000000
02000000 03000000 84000000 03000000 01000000 90000000
05000000 02000000 9C000000 06000000 01000000 AC000000
01200000 01000000 CC000000 02200000 05000000 E4000000
00200000 01000000 57010000 00100000 01000000 64010000

dex
038zDÀª˚JËAÜ¿jçÒê<]‡$–s&¢‡pxV4dpÑêú¨Ã‰Ï ,/ˇˇˇˇWp<init>Landroid/app/Application;
#Lcom/bugsnag/dexexample/BugsnagApp;
V&~~D8{"min-api":26,"version":"v0.1.14"}ÅÄÃ
pÑêú¨ Ã ‰ Wd

解释 Dex 文件头

从非常高的层面来看,Dex 文件可以被认为是两个独立的部分。一个包含元数据的 文件头,以及一个包含大部分数据的主体。文件头结构的图示如下。

让我们按顺序遍历标题中的每个项。

Dex 文件魔数

许多文件格式都以固定的字节序列开头,这些字节序列 唯一地标识用于操作它们的应用,Dex 也不例外。

6465780A 30333800
dex
038

我们可以看到前 8 个字节必须包含“dex”和版本号——当前为 38,当我们的 targetSdkVersion 为 API 26 时。

你可能还注意到第 4 个字节编码了一个换行符,第 8 个字节为空。这些由 Android 框架验证以检查文件损坏——如果不存在这个确切的序列,APK 将拒绝安装。

校验和

7A44CBBB

下一个值是 校验和,通过对整个文件内容(不包括校验和之前的任何字节)应用函数计算。如果在下载或磁盘存储过程中文件中的某个字节损坏,则计算出的校验和将不匹配,Android 框架将拒绝安装 APK。

SHA1 签名

FB4AE841 0286C06A 8DF19000 3C5DE024 D07326A2

头文件还包括文件的 SHA-1 哈希值(不包括任何之前的字节)。这用于唯一标识 Dex 文件,这在 Multidex 等场景中可能很有用。

文件大小

E0010000
480

这与以字节为单位的文件大小匹配,也可以在读取 Dex 文件时用于验证。

头文件大小

70000000
112

头文件大小应为 112 字节。

因此,我们现在可以突出显示 header_item 中所有剩余的字段。

字节序常量

78563412

Dex 文件支持大端和小端编码。此值等于 REVERSE_ENDIAN_CONSTANT,表示此 Dex 文件以小端编码,这是默认行为。

ID 和偏移量

文件头中的剩余值定义了其他数据结构的大小和位置,这些数据结构保存着方法、字符串和其他项的标识符。

00000000 00000000 64010000 05000000
70000000 03000000 84000000 01000000
90000000 00000000 00000000 02000000
9C000000 01000000 AC000000 14010000
CC000000

这些值汇总在下表中,其中大小等于数组长度,偏移量是从文件开头到可以找到此信息的字节数。

类型 大小 偏移量
link_size 0 0
map_off N/A 356
string_ids 5 112
type_ids 3 132
proto_ids 1 144
field_ids 0 0
method_ids 2 156
class_defs 1 172
data 276 204

值得注意的是,link_sizefield_ids 都为 0,因为我们的应用程序没有静态链接任何库,也没有包含任何字段。data 部分中的 map_off 结构在很大程度上复制了此信息,以一种更易于 Dex 文件解析的格式。

例如,我们可以看到我们的 Dex 文件中有 5 个字符串 ID,编码在字节 112-132 之间。此位置的每个 ID 还指向 data 部分中的一个偏移量,该偏移量编码字符串的实际值。

映射列表

map_list 是数据主体中的一个部分,它包含与文件头类似的信息。

有了这些知识,我们可以使用偏移量来解析实际信息,并确定我们的 Dex 文件编码了什么。

字符串

话不多说,让我们看一些具体的东西。让我们找出 string_ids 结构指向什么。

E4000000 EC000000 07010000 2C010000 2F010000
228,     236,     263,     300,     303

该数组编码了 5 个整数偏移量,这些偏移量指向数据部分。

<init>
Landroid/app/Application;
Lcom/bugsnag/dexexample/BugsnagApp;
V
~~D8{"min-api":26,"version":"v0.1.14"}

如果我们以 UTF-8 格式检索这些值,我们会看到一些 Java 符号,这些符号对于使用过 JNI 的人来说会很熟悉,还有一些 JSON,这表明 D8 创建了 Dex 文件。此时,所有这些关于 ID、偏移量和多个头文件的事情可能看起来有点没用。为什么不直接在头文件中编码字符串值呢?

这背后的一些原因是,这些字符串在 Dex 文件中的多个点被引用。为每个字符串提供一个 ID 可以防止信息重复并减小整体文件大小,简化解析,因为 ID 将始终是固定长度,并且意味着只有在需要时才访问值。

Types

01000000 02000000 03000000
1, 2, 3

我们的 Dex 文件定义了 3 种 Java 类型。这里的每个值都是指向前面 string_id 数组的索引——因此我们可以确定文件中的类型如下:

Landroid/app/Application;
Lcom/bugsnag/dexexample/BugsnagApp;
V

TypeDescriptor 语法可能看起来有些陌生,但 L 只是指完整的类名,而 Vvoid 类型。我们的类型包括自定义的 BugsnagApp 类和 Android 框架中的 Application 类。

协议(Prototypes)

03000000 02000000 00000000
3,       2,       0
"V",     V

方法原型包含方法的返回类型和它接受的参数数量的信息。proto_id 部分使用索引检索类型信息,以及一个偏移量,在这种情况下,由于我们的方法不接受任何参数,因此该偏移量不具有功能性。

方法

方法部分也使用索引。每个方法都会从字符串表中查找它定义的类 ID、方法原型和方法的名称。

00000000 00000000 01000000 00000000
0,  0,   0,       1,  0,   0

Landroid/app/Application "V" <init>
Lcom/bugsnag/dexexample/BugsnagApp; "V" <init>

我们 Dex 文件中唯一的方法与 BugsnagApp 的构造函数有关——这正是我们所期望的。

类定义

此部分包含类型、继承层次结构、访问元数据以及其他类元数据,例如注解和源文件索引。

01000000 01000000 00000000 00000000 FFFFFFFF 00000000 57010000 00000000
1,       1,       0,       0,       NO_INDEX,0,       343,     0

这表示一个 publicLcom/bugsnag/dexexample/BugsnagApp,它继承自 Landroid/app/Application,其类数据从字节 343 开始存储。public 访问修饰符由一个 位字段 确定。让我们查看类数据。

类数据

我们 BugsnagApp 类数据的前 4 个字节定义了静态和实例字段的数量,以及任何直接或虚拟方法。

00 00 01 00
0, 0, 1, 0,

01 81 80 04 CC 01 00 00 00
1,          460

这个类中只定义了一个直接方法。它的 ID 为 1,对应于 Lcom/bugsnag/dexexample/BugsnagApp; "V" ,代码数据偏移量为 460。如果我们的方法是 abstractnative,则不会有代码数据偏移量。

如果我们的类定义了字段和其他信息,则此部分中将编码更多数据。顺便说一句,如果方法 ID 的值大于 65,536,我们将遇到 臭名昭著的 64k 方法限制

代码结构

我们现在正在分析我们类中定义的构造方法,它在偏移量 460 处具有 以下结构

0100 0000 5701 0000 0010, 0000 01000000 64010000
1,   0,   343, 0,   16,   0    1,       64,1

这对应于寄存器大小为 1,0 个传入参数,343 个传出参数,以及一个存储调试信息的偏移量 16。

然而,最重要的部分是最后几个字节。我们有一个指令列表大小为 1,这意味着我们的方法编译成一个操作码:64010000

Dalvik 字节码 表明 64 对应于寄存器上的 sget-byte 操作,使用字段引用索引 1。这似乎与我们期望为应用程序创建一个单例 BugsnagApp 字段相匹配——但深入研究 Dalvik 是另一天的故事!

新的 Android 编译器 - D8

我们没有过多讨论编译过程,但我们的最小 Dex 文件是使用 D8 创建的,D8 是 Android Studio 3.1 中将默认推出的 新编译器。它在整体文件大小和构建速度方面提供了性能优势,因此让我们测试这些说法。

D8 性能基准测试

让我们使用 Android Studio 3.0.1 创建一个新应用。我们将添加 Kotlin 支持和导航抽屉,但其他所有选项都保留为默认值,生成一个签名 APK,并使用 APK 分析器 查看它。

我们可以通过使用 unzip app-release.apk -d app 解压 APK,然后使用 stat -f%z app/classes.dex 测量文件大小(以字节为单位)来从 APK 中检索 classes.dex

更好更快更小更强

指标 DX D8
未压缩文件大小 (Mb) 4.23 3.73
类计数 2790 2790
方法计数 22038 22038
总方法引用 28653 28651

使用 D8 编译时,我们的 Dex 文件大约是其以前大小的 88%。由于这是一个非常简单的示例项目,因此你的情况可能会有所不同。另外一个有趣的事情是,使用 D8,我们似乎丢失了以下两个方法 引用

android.view.View#isInEditMode
java.lang.Class#desiredAssertionStatus

这些似乎未在运行时使用,因此可能是一种优化。如果您知道这些为什么丢失了,请与我们联系!

为什么代码混淆会带来更好的应用

启用代码混淆和模糊处理是你可以为你的应用所做的最重要的事情,既然你已经是 Dex 格式的专家,你大概可以想到一些原因了。

首先,使用 Proguard 剥离未使用的 Java 类将减小 APK 的大小,因为生成的 Dex 文件将不包含未使用的类定义及其所有相关数据,这些数据会占用空间。

混淆也将减小 Dex 文件大小,因为除非你是那种将类命名为 a.a.Az.z.Z 的开发者,否则每个符号所需的字符会更少,这将整体节省空间。存在用于 映射混淆堆栈跟踪 的解决方案,可以让你轻松诊断应用程序中的崩溃。

最后,更小的 Dex 文件带来更小的 APK,这意味着用户在移动数据上的花费更少,并且放弃下载的可能性也更小。如果你提供 即时应用,那么 4Mb 的硬限制意味着保持 APK 大小较低是一个重要的考虑因素。

想了解更多吗?

希望这能帮助你理解 Dex 文件,随着 D8 的出现,它们将会变得更小。如果你有任何问题或反馈,请随时 联系我们

Bugsnag 自动监控你的应用程序是否存在有害错误并向你发出警报,让你了解软件的稳定性。你可以将我们视为软件质量的指挥中心。

试试 Bugsnag 的 Android 崩溃报告

© . All rights reserved.