深入DEX:Dalvik可执行文件的五脏六腑

上一章我们聊了APK的结构,这次我们把目光聚焦到真正的核心——DEX文件。

为什么APK里只有1个(或几个).dex文件,它就能支撑起整个App的代码逻辑?
搞懂DEX,你就搞懂了Android应用的“灵魂”。

来,咱们像剥洋葱一样,一层层看DEX的内部。

DEX 文件整体结构图 DEX 文件头 header_item (magic, checksum, file_size, map_off, ...) 字符串池 string_ids (索引区) string_data (数据区) 每项: string_data_off → data 类型池 type_ids (整型索引) 指向 string_ids 下标 方法区 (method_ids) 每项: class_idx, proto_idx, name_idx 定义方法签名与类归属 code_off → code_item Code Item (代码区) registers_size, ins_size, outs_size tries_size, debug_info_off, insns[] 原型池 (proto_ids) shorty_idx, return_type_idx parameters_off 描述方法参数与返回值 字段池 (field_ids) class_idx, type_idx, name_idx 定义非静态/静态字段 map_list (由 header.map_off 指向) 包含所有 section 的类型、偏移和大小,如同整本书的索引 注解 / 类定义 / 静态值区域 class_defs: 定义每个类的访问标志、父类、接口列表 annotations_off: 运行时可见注解数据

1. DEX文件头 —— 整份文件的“身份证”

每个DEX文件的开头都是一个header_item。它就像一本书的扉页,告诉你这本书用什么语言写的、有多厚、目录在哪里。

关键字段一览:

字段大小说明
magic8字节"dex\n035\0" 或 "dex\n045\0",标识DEX版本
checksum4字节Adler-32校验和,防止篡改
signature20字节SHA-1签名,更可靠的完整性校验
file_size4字节整个DEX文件大小(字节)
map_off4字节指向 map_list 的偏移量,是个很重要的“目录”
string_ids_size/off4+4字符串索引区的大小和偏移
type_ids_size/off4+4类型索引区的大小和偏移
proto_ids_size/off4+4方法原型索引区
field_ids_size/off4+4字段索引区
method_ids_size/off4+4方法索引区
class_defs_size/off4+4类定义区

小贴士:我个人习惯先用 xxd 或 010 Editor 快速查看前8字节的magic。比如我看到 "dex\n035\0",就知道这个DEX是Android 4.4之前常用的格式。新一点的App基本都是045版本了,支持更大的方法数和更宽的指令。

2. 字符串池 —— 所有符号的“仓库”

DEX文件里的类名、方法名、字段名、文件名字符串,最终都会放到字符串池里。

你可能会问:为什么不直接存字符串,而是用索引?
嗯,因为这样可以避免重复存储。比如同一个"Object"可能被几千个类引用,但DEX里只存一份。

字符串池分两部分:

  • string_ids 区: 一个uint32数组,每个元素是string_data_off,指向数据区的实际字符串。
  • string_data 区: 一个接一个的变长结构,包含:
    • uleb128 编码的字符串长度
    • 字符串内容(以'\0'结尾,UTF-16编码,但不以'\0'止)

举个例子,打印字符串池的偏移时,我习惯用 dumpdex.py 或 dextools 看。你发现没有,一些混淆过的App里,字符串池里会全是 "a", "b", "c" 这样的名字。这其实是AndResGuard那种混淆的结果。

3. 类型池 —— 它其实只是字符串池的“索引层”

类型池本质上就是一个type_ids数组。每个元素是一个uint32,指向字符串池中的下标。

比如 type_ids[5] = 13,意思就是“第5号类型”对应的字符串是字符串池里第13个字符串,通常是"Ljava/lang/Object;"这样的全限定名。

注意避坑:我曾经在分析一个64K方法数的包时,发现type_ids的数量异常大。后来排查发现,是因为内部类太多,每个内部类的类型都要注册到type_ids里。所以如果你发现DEX中type_ids > 20000,差不多就该考虑多DEX策略了。

4. 原型池与方法区 —— 方法调用的“桥梁”

先说说原型池 (proto_ids)。它描述了一个方法的返回值和参数类型,不包含方法名。

每个proto_item包括:shorty_idx(短签名字符串,如"II")、return_type_idx、和 parameters_off(指向参数类型列表)。

然后是方法区 (method_ids)。一个method_item包含三个关键字段:

struct method_item {
    uint16_t class_idx;   // 指向type_ids
    uint16_t proto_idx;   // 指向proto_ids
    uint32_t name_idx;    // 指向string_ids
}

说白了,方法在哪个类、叫什么名字、参数列表和返回值长什么样,都通过这几个索引串联起来了。

一个小案例:我调试一个性能问题时,发现某个方法被调用了上百万次。当时我就是通过method_ids反查出它所在的类和方法名,才定位到热点。方法区的索引结构帮了大忙。

5. Code Item —— 真正的字节码“住”在这里

method_ids里的code_off字段会指向一个code_item。这里才是Dalvik虚拟机真正执行的指令。

字段说明
registers_size该方法使用的寄存器总数
ins_size传入参数占用的寄存器数
outs_size调用其他方法时需要的寄存器数
tries_sizetry/catch块的数量
debug_info_off调试信息(行号、局部变量表)的偏移
insns[]真正的字节码指令序列(每指令2字节,按2字节对齐)

嗯,这里要注意一下。debug_info_off是可选字段。Release包经常会把这部分去掉以减小体积。我之前逆向一个App时发现少了行号,就是因为它被strip掉了。

6. 其它重要区域

  • 字段池 (field_ids): 定义每个字段的所属类、字段类型和字段名。静态字段和非静态字段都通过它索引。
  • 类定义区 (class_defs): 每个类的访问标志、父类、接口、注解、静态值、代码位置等信息都汇聚在这里。
  • map_list: 由文件头的map_off指向,列出所有区域的类型、偏移和字节数。我建议你调试时先看看map_list,它像一张DEX的全景地图。

我个人方法:需要快速浏览DEX结构时,直接跳转到map_list,然后按顺序读每个section的偏移和大小。这样比从头解析文件头更直观。比如我常写一个Python脚本,解析完map_list后直接输出所有区间的起始地址列表。

好了,这一章的内容就到这里。下次你看到一个DEX文件,应该能想象它内部是如何组织字符串、类型、方法和代码的了。这对后面分析包体大小、做方法数优化有很大帮助。