实现一个完整的 Dalvik 虚拟机,说到底就是三件事同时动手:把 DEX 文件按规范解析成内存结构、写一个寄存器式的字节码解释器主循环、再补上对象模型和垃圾回收。 剩下的一切工作,都是在这三根柱子上加砖。
很多人一上来就想着复刻 AOSP 的 dalvik/ 目录,结果被几万行 C++ 劝退,更现实的路子是:先做一个只能跑 System.out.println 的迷你版本,再一层层往上堆。
先划清边界:你要做的是解释器、运行时,还是兼容层
Dalvik虚拟机和ART区别决定了你的实现路线
Dalvik 是解释执行加 JIT 即时编译,Android 4.4 引入 ART 作为可选运行时,从 5.0 起 ART 完全取代 Dalvik 成为默认,采用 AOT 提前编译,这个差异直接决定工作量:如果你只求”能跑字节码”,写个解释器就够了;如果要模拟 Dalvik 的 JIT 行为,还得实现热点计数和代码缓存,多数人练手的目标是前者。
一个”能跑起来”的版本至少要有这些模块
- DEX 解析器:读出字符串、类型、方法、类定义和
code_item - 指令解码与执行:取指、译码、派发
- 寄存器帧管理:每个方法调用对应一个帧
- 类加载与链接:解析引用、初始化静态字段
- 基础对象模型:对象头、字段布局、数组
- 垃圾回收:先做最简单的标记-清除
- JNI 桥:让
native方法能落到宿主系统调用上
业内专家指出,从零写解释器的难点不在”写”,而在”对齐语义”寄存器编号、异常传播、宽字段处理,任何一处和规范偏差都会让程序跑飞。
从 DEX 文件解析开始:把二进制变成结构体
DEX 头部与索引表的关键偏移
DEX 是小端格式,文件开头 8 字节 magic 形如 dexn035 (后续还有 037、038、039 等版本),头部固定 0x70 字节,之后紧跟一串索引表,偏移量都在头部里给出:
| 区域 | 作用 |
|---|---|
| string_ids | 所有字符串,含 MUTF-8 编码 |
| type_ids | 类型描述符,如 Ljava/lang/String; |
| proto_ids | 方法签名(返回类型 + 参数) |
| field_ids | 字段引用 |
| method_ids | 方法引用 |
| class_defs | 类定义,指向 class_data_item |
校验时先读 file_size 和 map_off,再核对 Adler-32 checksum 与 SHA-1 签名,能挡掉大部分损坏文件。
解析顺序不能乱
按 string_ids → type_ids → proto_ids → field_ids → method_ids → class_defs → class_data_item → code_item 的顺序走。class_data_item 是变长编码(ULEB128),字段和方法按”直接/虚”分组,每组引用差值是增量编码,这点最容易读错。
手边备两个工具会轻松很多:
# Android SDK build-tools 自带 dexdump -d classes.dex | head -80 baksmali d classes.dex -o out/
dexdump -d 输出的就是反汇编视图,baksmali 生成 .smali,对照着看,指令语义一目了然。
写解释器主循环:从寄存器机模型讲起
Dalvik虚拟机指令集详解与编码格式
Dalvik 指令定长 16 位,低 8 位是 opcode,高 8 位是操作数,常见的格式有 10x、12x、22x、35c、3rc。invoke-virtual {v0, v1}, method@0012,35c 格式里包含了方法索引和寄存器列表。
寄存器命名有个约定:v 是局部寄存器,p 是参数寄存器,方法头里的 registers_size 是总寄存器数,ins_size 是参数占用数,参数永远排在最后,p0 等价于 v(registers_size - ins_size)。
用 switch 搭出取指-译码-执行的骨架
while (1) {
u2 inst = pc++;
int op = inst & 0xff;
switch (op) {
case 0x0e: / return-void /
goto method_return;
case 0x1a: { / const-string /
u2 idx = read_u2(&pc);
set_reg(vA, make_string(idx));
break;
}
}
}
派发方式的选择直接决定性能,可以按阶段升级:
- switch 派发:最好写,便于调试,性能一般
- 计算 goto:用 GCC/Clang 的
&&label扩展做标签数组,省掉边界检查和跳转表 - 汇编模板拼接:每条指令一段机器码,运行期拼成线程代码,接近 JIT 效果
前两种足够把功能跑通,第三种在嵌入式设备上要谨慎,工具链未必支持。
对象模型、堆和垃圾回收怎么落地
对象布局与类元数据
对象头放一个指向 ClassObject 的指针,后面按字段类型紧凑排布,数组对象额外带一个长度字段,字段排布要和 DEX 里的 field_ids 顺序一致,否则 iget/iput 的偏移量对不上。
GC 策略可以先做最简单的
Dalvik 历史上主要使用标记-清除的变体,后期引入并发标记以缩短停顿,自己实现时,第一版做 stop-the-world 的标记-清除就够了,根集包括:
- 所有活动帧里的寄存器值(需要区分引用和整数,靠指令类型或影子栈判断)
- 类的静态字段
- JNI 全局引用与局部引用
想再进一步,可以加个简单的空闲链表分配器,把对象按大小分桶,减少碎片。
类加载、链接与 JNI 桥接
类加载要处理”解析”和”初始化”两个阶段。new-instance 触发解析,sget-object 之类可能触发 <clinit>,类初始化必须加锁,避免多线程重复执行。
JNI 部分,native 方法在 DEX 里没有 code_item,实现思路是:
- 实现一套
JNIEnv函数表,至少覆盖FindClass、GetMethodID、NewStringUTF、CallVoidMethod System.loadLibrary映射到宿主系统的dlopen- 调用本地方法前把寄存器里的参数打包成 JNI 参数,返回后把结果写回
本地方法执行完写回寄存器这一步经常被漏掉,表现就是调用链返回后变量莫名变零。
零基础如何读懂Dalvik字节码并跑通第一个Hello World
别急着写完整虚拟机,先用命令行把链路打通:
- 准备一个 Java 文件:
public class Hello { public static void main(String[] args) { System.out.println("hi"); } } - 编译并转 DEX:
javac Hello.java d8 --output . Hello.class
- 反汇编观察:
dexdump -d classes.dex
你会看到
sget-object、invoke-virtual、return-void这几条关键指令。 - 在自己的虚拟机里优先实现
const-string、sget-object、invoke-virtual、new-instance、return-void。 System.out.println实际是 native 调用,先在宿主侧写个桩函数把字符串打印出来即可。
这一步跑通,说明你的解析、帧管理和调用约定都没大问题,后面的工作量是线性叠加而非推倒重来。
把Dalvik虚拟机移植到嵌入式设备上要踩哪些坑
嵌入式设备资源和桌面完全不是一个量级,常见问题集中在几处:
- 内存:堆可能只有几十 MB,DEX 文件不适合整体 mmap,需要按需读取或先拷贝到 RAM,注意 4 字节对齐
- 字节序:DEX 是小端,跑在大端 MCU 上要全部做转换,推荐解析阶段统一转成宿主字节序
- 工具链:ARMCC 之类编译器不支持计算 goto,退回 switch 派发或手写汇编 handler
- 无文件系统:DEX 固化在 Flash 里,类加载路径要改成基于内存缓冲区的接口
- 启动时间:类预加载会影响冷启动,建议把核心类做成静态表
行业共识认为,移植阶段最大的时间消耗不在指令解释,而在调试工具缺失没有 dexdump、没有日志,只能靠点灯和串口打印,务必先移植一个最小日志模块。
实现一个完整的Dalvik虚拟机大概要多久、要花多少钱
按目标分层看工作量:
| 目标 | 业余投入 | 产出 |
|---|---|---|
| 跑通 Hello World | 2-4 周 | 解析器 + 30 条左右指令 |
| 支持常见类库调用 | 3-6 个月 | 完整指令集 + 基础 GC + JNI |
| 接近 AOSP 兼容度 | 1 年以上 | 验证器、异常、JIT、优化 DEX |
如果是团队外包,通常按人月计价,国内资深系统工程师的月成本在两万到四万区间浮动,具体看城市和资历,一线城市明显更高,自学的话,主要成本是时间和调试耐心,钱反而花不了多少。
收束
写虚拟机的过程,本质是把”规范文档”翻译成”能跑的机器”,先让最小闭环跑起来,再逐条补指令、补 GC、补 JNI,比一开始追求大而全有效得多。只要你的解释器能正确执行 invoke- 和 return-,剩下的都是体力活。
Q&A:关于实现一个完整的Dalvik虚拟机的常见疑问
Q1:可以直接复用 AOSP 里的 dalvik 目录源码吗?
可以,Android 5.0 之前的 dalvik/ 目录包含 libdex、vm、dexopt 等完整实现,许可以 Apache 2.0 为主,但每个文件顶部的许可声明需要逐个核对,部分文件可能带有其他条款,作为学习参考完全没问题。
Q2:能用 .class 文件直接练手,绕过 DEX 解析吗?
不行,两者格式毫无关系,.class 是基于栈的 JVM 字节码,DEX 是基于寄存器的,必须先经过 d8(旧版本是 dx)转换成 classes.dex,解析器才有东西可读。
Q3:解释器太慢怎么办?
按代价从低到高依次尝试:换成计算 goto 派发、把高频指令的操作数预先解码、把 pc 和帧指针放进局部变量而非结构体成员、减少每次执行的边界检查,这些做完通常能有明显改善,仍不够快,就只能上 JIT 或 AOT,那是另一个量级的工作。
首发原创文章,作者:王坚,如若转载,请注明出处:https://idctop.com/article/762695.html





