深入DEX:Dalvik可执行文件的五脏六腑
上一章我们聊了APK的结构,这次我们把目光聚焦到真正的核心——DEX文件。
为什么APK里只有1个(或几个).dex文件,它就能支撑起整个App的代码逻辑?
搞懂DEX,你就搞懂了Android应用的“灵魂”。
来,咱们像剥洋葱一样,一层层看DEX的内部。
1. DEX文件头 —— 整份文件的“身份证”
每个DEX文件的开头都是一个header_item。它就像一本书的扉页,告诉你这本书用什么语言写的、有多厚、目录在哪里。
关键字段一览:
| 字段 | 大小 | 说明 |
|---|---|---|
| magic | 8字节 | "dex\n035\0" 或 "dex\n045\0",标识DEX版本 |
| checksum | 4字节 | Adler-32校验和,防止篡改 |
| signature | 20字节 | SHA-1签名,更可靠的完整性校验 |
| file_size | 4字节 | 整个DEX文件大小(字节) |
| map_off | 4字节 | 指向 map_list 的偏移量,是个很重要的“目录” |
| string_ids_size/off | 4+4 | 字符串索引区的大小和偏移 |
| type_ids_size/off | 4+4 | 类型索引区的大小和偏移 |
| proto_ids_size/off | 4+4 | 方法原型索引区 |
| field_ids_size/off | 4+4 | 字段索引区 |
| method_ids_size/off | 4+4 | 方法索引区 |
| class_defs_size/off | 4+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_size | try/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文件,应该能想象它内部是如何组织字符串、类型、方法和代码的了。这对后面分析包体大小、做方法数优化有很大帮助。