Dex文件是什么
在明白什么是 Dex 文件之前,要先了解一下 JVM,Dalvik 和 ART。JVM 是 JAVA 虚拟机,用来运行 JAVA 字节码程序。Dalvik 是 Google 设计的用于 Android平台的运行时环境,适合移动环境下内存和处理器速度有限的系统。ART 即 Android Runtime,是 Google 为了替换 Dalvik 设计的新 Android 运行时环境,在Android 4.4推出。ART 比 Dalvik 的性能更好。Android 程序一般使用 Java 语言开发,但是 Dalvik 虚拟机并不支持直接执行 JAVA 字节码,所以会对编译生成的 .class 文件进行翻译、重构、解释、压缩等处理,这个处理过程是由 dx 进行处理,处理完成后生成的产物会以 .dex 结尾,称为 Dex 文件。Dex 文件格式是专为 Dalvik 设计的一种压缩格式。所以可以简单的理解为:Dex 文件是很多 .class 文件处理后的产物,最终可以在 Android 运行时环境执行。
构造Dex文件
Java代码转化为dex文件的流程如下:
可以形象理解为Java源代码编译成.class文件,然后通过dx工具生成dex文件。
从.java到.class
先建一个文件Hello.java,只是简单的打印一下Hello, world:
1 | public class Hello { |
进入该文件所在的目录,使用javac编译这个java文件。
1 | javac Hello.java |
javac 命令执行后会在当前目录生成 Hello.class 文件。
从.class到.dex
上面生成的 .class 文件虽然已经可以在 JVM 环境中运行,但是如果要在 Android 运行时环境中执行还需要特殊的处理,那就是 dx 处理,它会对 .class 文件进行翻译、重构、解释、压缩等操作。
dx 处理会使用到一个工具 dx.jar,这个文件位于 SDK 中,具体的目录大致为 你的SDK根目录/build-tools/任意版本 里面。使用 dx 工具处理上面生成的Hello.class 文件,在 Hello.class 的目录下使用下面的命令:
1 | dx --dex --output=Hello.dex Hello.class |
执行完成后,会在当前目录下生成一个 Hello.dex 文件。这个 .dex 文件就可以直接在 Android 运行时环境执行。
Dex格式详解
先看下dex文件的整体布局:
然后用xxd命令打开上边生成的dex文件。
1 | xxd Hello.dex |
因为数据不长,这里直接贴出来整个Hello.dex的内容:
1 | 00000000: 6465 780a 3033 3500 555d 340e 86e9 521c dex.035.U]4...R. |
接下来对照这个dex文件的内容一步步解析整个dex文件的格式。
header
在android源码中对dex文件的格式,有了详细的定义:
1 | struct DexHeader { |
其中,u标识无符号整数,u1表示8位无符号整数即一个字节,u4表示32位无符号整数即四个字节。
下面用一个表格对照Hello.dex对每一个成员含义做一个简单的说明,后边会针对某些字段有一个详细的说明。
| 成员名称 | 成员长度(字节) | 含义 |
|---|---|---|
| magic | 8 | 魔数,必须出现在文件开头,标识其文件格式,用8个1字节的无符号数来表示,它可以分解为:文件标识 dex + 换行符 + DEX 版本 + 0, 这里是64 65 78 0a 30 33 35 00,表示dex的版本是35 |
| checksum | 4 | checksum 是对去除 magic 、 checksum 以外的文件部分作 adler32 算法得到的校验值,用于判断 DEX 文件是否被篡改。这里的值是0e34 5d55(为啥不是555d 340e?因为是小端存储,关于大小端参见 大端还是小端) |
| signature | 20 | SHA1签名,除magic,checksum和它本身,作为文件的唯一标识。这里的值是86 e9 52 1c b1 0d 57 08 b4 48 3f b8 fe ba cd 1c 22 d3 0f 65 |
| fileSize | 4 | dex文件的大小,包括头文件,这里是0000 02e0 |
| headerSize | 4 | dex头文件的大小,这里是0000 0070 |
| endianTag | 4 | 端存储标记,主要是用来判断大端存储还是小端存储。默认值是1234 5678,即小端存储,这里是1234 5678 |
| linkSize | 4 | 文件链接段大小,为0则表示静态链接,这里是0000 0000 |
| linkOff | 4 | 文件链接段的偏移位置,如果链接段大小为0,则偏移位置也为0,这里是0000 0000 |
| mapOff | 4 | DexMapList的文件偏移,这里是0000 0240,也即DexMapList的基址是0000 0240 |
| stringIdsSize | 4 | dex文件包含的字符串数量,这里是0000 000e |
| stringIdsOff | 4 | dex文件字符串偏移位置,这里是0000 0070 |
| typeIdsSize | 4 | dex文件类型信息的数量,这里是0000 0007 |
| typeIdsOff | 4 | dex文件类型信息的偏移位置,这里是0000 00a8 |
| protoIdsSize | 4 | dex文件方法声明的数量,这里是0000 0003 |
| protoIdsOff | 4 | dex文件方法声明偏移位置,这里是0000 00c4 |
| fieldIdsSize | 4 | dex文件字段信息的数量,这里是0000 0001 |
| fieldIdsOff | 4 | dex文件字段信息的偏移位置,这里是0000 00e8 |
| methodIdsSize | 4 | dex文件方法的数量,这里是0000 0004 |
| methodIdsOff | 4 | dex文件方法的偏移位置,这里是0000 00f0 |
| classDefsSize | 4 | dex文件类的数量,这里是0000 0001 |
| classDefsOff | 4 | dex文件类信息的偏移位置,这里是0000 0110 |
| dataSize | 4 | dex文件数据区的大小,这里是0000 01b0 |
| dataOff | 4 | dex文件数据区的偏移位置,这里是0000 0130 |
下面针对部分字段,进一步理解:
1. 验证checksum
通过前面表格,了解到checksum 是对去除 magic 、 checksum 以外的文件部分作 alder32 算法得到的校验值,这里我们先备份一下Hello.dex文件,然后用UE(UltraEdit)打开Hello.dex,删除magic,checksum信息,如下:
保存之后,执行如下Python代码:
1 | import zlib |
输出结果是238312789,转为十六进制为0e34 5d55,正好是该文件的checksum。
2. 验证signature
类似于在上面验证checksum文件,进一步删除signature,如图:
保存之后,执行如下Python代码:
1 | import hashlib |
输出结果是86e9521cb10d5708b4483fb8febacd1c22d30f65,正好是删除的signature。
3. 验证fileSize
从备份的Hello.dex还原dex文件,然后前面表格得出整个dex文件的大小是2e0即736个字节,我们用ll命令验证下:
4. 验证headerSize
headerSize占0000 0070也即112个字节。我们通过DexHeader这个结构体可以算出来:
magic(8个字节)+checksum(4个字节)+signature(20个字节)+fileSize(4个字节)+ … + dataOff(4个字节)=112字节。
string_ids
字符串id区域,这个区域是一个偏移量列表,每个偏移量对应一个真正的字符串资源,每个偏移量占32位,即4个字节。我们可以通过偏移量找到对应的实际字符串数据。
从DexFile.h中我们可以找到DexStringId的定义:
1 | struct DexStringId { |
通过注释可以看到,这个区域存的并不是真正的字符串,只是存储了真正字符串的偏移位置,stringIdsSize为0000 000e即14,stringIdsOff为0000 0070。我们找到地址0000 0070h,然后取出后边的4*14个字节:
1 | 00000070: 7601 0000 7e01 0000 8d01 0000 9901 0000 v...~........... |
这里以第一个偏移为例,解释具体每个字符串偏移背后代表的真正字符串的,取出前4个字节,即0000 0176,然后找到0000 0176h这个地址:
dex中的字符串采用了一种叫做MUTF-8这样的编码,它是经过传统的UTF-8编码修改的。在MTUF-8中,它的头部存放的是由uleb128编码的字符的个数。所以第一个字节06表示的含义是字符串的字节数是6个,然后我们往后推6个字节,即3C 69 6E 69 74 3E,对照ASCII码表含义如下:
| 字符 | 3C | 69 | 6E | 69 | 74 | 3E |
|---|---|---|---|---|---|---|
| 对应ASCII码 | < | i | n | i | t | > |
即<init>。
其余的13个字符串可以按照这个步骤,依次分析出来,这里就不展开了,给出一个表格如下:
| 索引 | 偏移值 | 字符个数(十六进制) | 字符串十六进制内容 | 对应ASCII内容 |
|---|---|---|---|---|
| 0 | 0000 0176 | 06 | 3C 69 6E 69 74 3E | <init> |
| 1 | 0000 017e | 0D | 48 65 6C 6C 6F 2C 20 77 6F 72 6C 64 21 | Hello, world! |
| 2 | 0000 018d | 0A | 48 65 6C 6C 6F 2E 6A 61 76 61 | Hello.java |
| 3 | 0000 0199 | 07 | 4C 48 65 6C 6C 6F 3B | LHello; |
| 4 | 0000 01a2 | 15 | 4C 6A 61 76 61 2F 69 6F 2F 50 72 69 6E 74 53 74 72 65 61 6D 3B | Ljava/io/PrintStream; |
| 5 | 0000 01b9 | 12 | 4C 6A 61 76 61 2F 6C 61 6E 67 2F 4F 62 6A 65 63 74 3B | Ljava/lang/Object; |
| 6 | 0000 01cd | 12 | 4C 6A 61 76 61 2F 6C 61 6E 67 2F 53 74 72 69 7E 67 3B | Ljava/lang/String; |
| 7 | 0000 01e1 | 12 | 4C 6A 61 76 61 2F 6C 61 6E 67 2F 53 79 73 74 65 6D 3B | Ljava/lang/System; |
| 8 | 0000 01f5 | 01 | 56 | V |
| 9 | 0000 01f8 | 02 | 56 4C | VL |
| a | 0000 01fc | 13 | 5B 4C 6A 61 76 61 2F 6C 61 6E 67 2F 53 74 72 69 7E 67 3B | [Ljava/lang/String; |
| b | 0000 0211 | 04 | 6D 61 69 6E | main |
| c | 0000 0217 | 03 | 6F 75 74 | out |
| d | 0000 021c | 07 | 70 72 69 6E 74 6C 6E | println |
type_ids
类型id区域,索引的值对应字符串id区域偏移量列表中的某一项,每一个人偏移也是占4个字节。
从DexFile.h中我们可以找到DexTypeId的定义:
1 | /* |
从注释可以看到如果我们要找到某个类型的值,需要先根据类型id列表中的索引值去字符串id列表中找到对应的项,这一项存储的偏移量对应的字符串资源就是这个类型的字符串描述。
typeIdsSize为0000 0007,typeIdsOff为0000 00a8,我们找到地址为0000 00a8然后取出后面7*4个字节。
1 | 0300 0000 0400 0000 0500 0000 0600 0000 0700 0000 0800 0000 0a00 0000 |
以第一个偏移0000 0003为例,即索引为3,查上面的string_ids得到的字符串列表,即为LHello;。类似地,可以分析出来其余6个:
| 索引 | 对应string_idx的索引 | 类型 |
|---|---|---|
| 0 | 0000 0003 | LHello; |
| 1 | 0000 0004 | Ljava/io/PrintStream; |
| 2 | 0000 0005 | Ljava/lang/Object; |
| 3 | 0000 0006 | Ljava/lang/String; |
| 4 | 0000 0007 | Ljava/lang/System; |
| 5 | 0000 0008 | V |
| 6 | 0000 000a | [Ljava/lang/String; |
proto_ids
方法原型id区,这个区块是一个方法原型 id 列表。
从DexFile.h中可以找到其定义:
1 | /* |
各个字段的解释如下:
可以看到,这个数据结构由三个变量组成。第一个shortyIdx它指向的是我们上面分析的DexStringId列表的索引,代表的是方法声明字符串。第二个returnTypeIdx它指向的是 我们上边分析的DexTypeId列表的索引,代表的是方法返回类型字符串。第三个parametersOff指向的是DexTypeList的位置索引,这又是一个新的数据结构了,先说一下这里面 存储的是方法的参数列表。可以看到这三个参数,有方法声明字符串,有返回类型,有方法的参数列表,这基本上就确定了我们一个方法的大体内容。
parametersOff指向DexTypeList,我们看下DexTypeList的数据结构:
1 | struct DexTypeList { |
包含2个字段,第一个是大小说的是DexTypeItem的个数。
那DexTypeItem又是什么呢?我们再看下其数据结构:
1 | struct DexTypeItem { |
包含一个指向DexTypeId列表的索引,也就是代表参数列表中某一个具体的参数的位置。
protoIdsSize为0000 0003,protoIdsOff为0000 00c4,找到地址为0000 00c4然后取出后边3*12个字节(为啥是12,因为每一个proto_id数据结构占12个字节):
1 | 0800 0000 0500 0000 0000 0000 |
先对第一个方法原型进行分析,前四个字节0000 0008为shorty_ids,对应string_ids的索引,查上面string_ids字符串的表格知其为V,即方法描述短格式为void()。中间四个字节0000 0005为return_type_idx,对应type_ids的索引,查上面的type_ids类型区域表格知其为V,即返回值类型为void。最后四个字节为0000 0000,即代表方法无参数。
在看第二个方法原型。前四个字节0000 0009为short_ids,对应string_ids的索引,查上面string_ids字符串的表格知其为VL。中间四个字节为0000 0005为return_type_idx,对应type_idx的索引,查上面的type_ids类型区域表格知其为V,即返回值类型为void。最后四个字节为0000 0168,找到168h这个地址如下:
这里DexTypeList数据结构,我们先看前4个字节,代表DexTypeItem的个数,0000 0001也就是1,说明只有一个DexTypeItem,每一个占2个字节,就是00 03,查看type_ids表格,找到索引为3的,即Ljava/lang/String;,说明有一个String类型的参数。第三个方法原型类似,就不展开了。整理三个方法原型如下表格:
| 索引 | 方法描述短格式 | 返回值类型 | 参数类型 | 方法原型 |
|---|---|---|---|---|
| 0 | V | V | 无参数 | void() |
| 1 | VL | V | Ljava/lang/String; | void(java.lang.String) |
| 2 | VL | V | [Ljava/lang/String; | void(java.lang.String[]) |
field_ids
成员id区。这个区域是一个类成员id区域列表。定义如下:
1 | /* |
各字段的解释如下:
这里我们Hello.dex的fieldIdsSize为0000 0001,说明只存在一个DexField,fieldIdOff为0000 00e8,找到地址为e8h然后取出后边8个字节,即04 00 01 00 0c 00 00 00,其中前2个字节0004,表示class_idx,表示成员所在的类在类型区域的索引,查表得Ljava/lang/System;。中间2个字节0001,表示type_idx,表示该成员自身的类型在类型区域的索引,查表得Ljava/io/PrintStream;。最后4个字节0000 000c,表示name_idx,表示该成员的名字在字符串区域的索引,查表得out。所以Hello.dex中仅包含一个成员为java.io.PrintStream java.lang.System.out。
method_ids
方法id区,这个方法是存储方法id的列表。数据格式为:
1 | /* |
解释如下:
methodIdsSize和methodIdsOff分表为0000 0004和0000 00f0,即该dex文件一共包含4个方法,方法id区的偏移地址为f0h,我们找到这个地址,然后取出后边4*(2+2+4)个字节,如下:
1 | 0000 0000 0000 0000 |
然后根据DexMethodId数据结构,查上边的type_ids表格,proto_ids表格以及string_ids表格,这里就不一一展开了,结果整理如下:
| 序号 | class_idx | proto_idx | name_idx | 方法 |
|---|---|---|---|---|
| 0 | LHello; | void() | <init> | void Hello.<init>() |
| 1 | LHello; | void(java.lang.String[]) | main | void Hello.main(java.lang.String[]) |
| 2 | Ljava/io/PrintStream; | void(java.lang.String) | println | void java.io.PrintStream.println(java.lang.String) |
| 3 | Ljava/lang/Object; | void() | <init> | void java.lang.Object.<init>() |
class_def
类定义区。这个区域存储的是类定义的列表,具体的数据结构如下:
1 | /* |
各字段的含义如下:
在开始分析这个类的结构之前,先看DexFile.h中定义的一组枚举值:
1 | enum { |
classDefsSize和classDefsOff分别为0000 0001和0000 0110。即只有一个类定义,其偏移地址为110h,我们找到该地址,并取出32个字节:
1 | 0000 0000 0100 0000 0200 0000 0000 0000 0200 0000 0000 0000 3102 0000 0000 0000 |
前4个字节代表类的类型,为0000 0000查表知为LHello;。接下来4个字节为类的访问权限,为0000 0001,记得我们上边刚提到的那个枚举值定义吗,1代表ACC_PUBLIC即Public访问权限。接下来4个字节0000 0002为父类对应的类型,查表知为Ljava/lang/Object;。然后四个字节0000 0000为这个类实现的接口在dex文件中的偏移,因为我们这个类没有实现接口,所以这里为0000 0000。紧接着的四个字节0000 0002查表知为Hello.java为该类类源码所在的文件。然后4个字节为该类的注解在文件中的偏移,很显然这个类没有注解,所以为0000 0000。接下来的4个字节则是表示该类的具体数据在文件中的偏移,这里先不讨论,后边会针对类数据区专门讨论。最后4个字节表示静态成员初始值列表在文件中的偏移,很显然我们这个类没有静态成员。整理一下如下:
| 类的类型 | 类的权限 | 父类的类型 | 实现的接口 | 类定义所在的文件 | 类注解 | 类具体数据 | 静态成员初始值列表 |
|---|---|---|---|---|---|---|---|
| Hello | ACC_PUBLIC | java.lang.Object | 无 | Hello.java | 无 | 偏移值0000 0231 |
无 |
总结
本文对dex文件结构进行了一个简单的剖析,让我们对dex文件结构有了一个基本的认识。最后附上一个dex文件结构图以及思维图帮助我们记忆dex文件结构。
dex文件层次结构图:
dex文件结构思维导图: