我们的题目
最近闲着没事想要了解一下 Java .class 文件的结构。然后想要对它进行一下解析。毕竟解析二进制码不是一件特别麻烦的事情,以前也干过,其实是非常有意思的。因此打算按照 JVM 规范对 .class 进行解析。总共用 C++ 写了不到 3k 行代码,应该能够按照 JVM 规范所说的进行完美的解析了。其实一点也不难,按照 JVM 规范的 $4 第四章给定的数据结构和规范进行直接对字节码进行解析即可。
下面说一下详细的方法和实践步骤。
对 .class 文件的解析是非常轻松加愉快的,如果想要看代码的话,请移步 wind2412的github – 对 .class 文件进行解析 我的代码仓库进行查看完整的代码。其实头文件就是 JVM 规范的所有数据结构的集合,#define
的是各种类型的变量结构。当然,为了解析愉快,我在每个数据结构的内部全都塞进去了一个的 friend std::ifstream & operator >> (std::ifstream & f, TYPE & i);
结构进行结构式的从 .class 文件通过输入流进行读取字节码来填充进数据结构的内部。那我就让我们进入愉(wu)快(liao)的 .class 文件解析之旅吧~
本文的定位并不在于一步一步指导要怎么 parse .class file。而是要把踩过的坑都列出来。毕竟网上的菊苣们 hack 这个的也有不少,网上这方面文章还是有很多的,我没有必要重复造轮子(大雾。因此,本篇的主旨在于对想要进行这项工作的童鞋进行一个宏观的 “坑的解说(逃”。
需要之物
- JVM 的规范,最好要 SE 8 版本的规范,因为 SE 7 中有的部分和 8 不一样,改动其实也不太小(。当然为了效率我看的是中文版的,中文版的有不少错误QAQ。最后还是看的英文进行的解析。而且毕竟英文版的放在 oracle 官网上,因此数据结构可以直接进行复制粘贴(雾。
- C++ 基本语法的熟悉即可。当然用 Java 也不是不可以,但是据说有 Java 有内置的一个什么 xxxClassParser 在 sun 包下,可以直接经过人家的 API 进行解析的(逃。Java 毕竟全是引用,在 parse
annotation
那里要好办很多QAQ,用 C++ 必须强行使用指针咯。 - java 环境。你必须要有
javap
和hexdump
两样神器:一个是直接把 .class 文件反汇编,尤其是javap -verbose
命令,你几乎可以查看到非常完整的反汇编代码,解释得非常清楚;另一个是直接暴力查看 .class 文件的字节码,两者强强联合,使用更佳!!而我的代码的定位,就是写一个javap -verbose
工具。力争输出和javap -verbose
一样~
BEGIN!
那么我们就开始吧!
- 预读。首先我们要知道,写 parser 的话,如果想要避免回溯,就一定要采取预读
peek
的策略。Java 官方在 .class 文件的制作上,也是采取了peek
的策略。比如 LL(1) 文法,就是采取预读 1 个token
的方法。而 LR(0) 文法就是预读 0 个token
。不过字节码毕竟是bytecode
,那么这个token
当然就是一个byte
啦!当然随着往下写我们就知道,因为一个 byte 最多也就能表示 256 个数字,因此可能太小了。要表示类别的数量,很有可能需要两个 bytes。所以我在里边写了peek1()
和peek2()
两个方法来进行预读工作。有了 C++ 的流,我们可以非常轻易地进行从流缓冲的读取。还是非常赚的~ - 注意你机器是大端序还是小端序。我的 mac 是小端序的。因此读入的时候,保存在变量中是反向存放的。所以,这样就会引发 “读的是反的” 的情况。因此,在 *nix 环境下,我们可以引用 POSIX 规范的
#include <arpa/inet.h>
头文件,使用其中的htons()
和htonl()
函数进行比特的逆转。当然,这两个函数其实真正是用在网络编程当中的。在read2()
和read4()
中,我用到了这两个函数。 - 关于 unicode。因为 Java 字节码全都是使用 Java 改进的 UTF8 编码进行存放的。如果我们要保存的话,就一定要将其转为 Unicode。Java 的 String 本身就支持 Unicode,自然不必多说,但是 C++ 的 std::string 不行啊……因为它某种意义上讲根本就不是一个 string……顶多算是一个 char[] 数组。但是我们有 std::wstring,它是按照 Unicode 进行存放的。其实 Java 毕竟是 Unicode,我们可以使用中文编程的,比如
class 蛤蛤 { public static int 膜 = 4; }
,这类的情况也需要我们进行考虑。当然,用通用的 Unicode 准没错就是了~
数据结构
JVM 规范的第四章把数据结构全用伪代码列给我们了。当然,这其中有很多坑。我会一一列举出来。
- 在 Java 的规范中,有非常多的继承关系。而如何对这些继承的结构进行识别,我们就需要用到
peek
来进行distribute
。也就是,使用 “前看一个或者两个字符” 来进行选择到底该选择哪个类进行使用。举个例子,比如常量池constant_pool
,我们就可以看代码:constant_pool 的继承关系,这个cp_info 结构体
就是一个基类。预先读入peek
一个一字节的tag
之后,我们就会按照tag
的大小,按照这几个#define
的变量,见 constant_pool 的 tags,来进行选择子类的类别,并且按照子类内部的成员向内进行填充 bytecode。这之中比较坑的是long
和double
,因为他们在常量池当中要占据两个位置……而正常的变量都占据一个位置……我还没有实现过简单的 JVM,并不知道这么做的深意……而且常量池的索引是从 1 开始的,而不是从 0 开始,我也并不明白这个的深意……不敢妄测不敢妄测。不过这个是比较坑的部分,必须小心谨慎,否则可能会一调试调试一个晚上(QAQ。 - 如果遇到类内部的数组(而且不定长度,是按照类内部的另一个成员变量的数值来当做长度的),这样,编译过程中长度不确定的数组是不被允许的。必须要等到运行时才能进行。因此,必须要使用
new
来在堆上在运行时分配。其实这里是比较好考虑到的。但是,由于 C++ 的 RAII 特性,我们就要在这样的类中写析构函数…… 这样不断的申请释放不断 copy 代码实在是累死了QAQ…… - 常量池 parse 完了之后,我们可以说是完成了 1/4 的工作吧。不过如果常量池 parse 完了,后边的工作难度就大大降低了。我们就可以 parse field and methods and interfaces。这三者其实都差不多,只不过 method 是最难的。因为内部含有大量的运行时字节码。这样的字节码将会是非常麻烦的,因为
java -verbose
的输出非常麻烦……其实截止到现在,我还并没有写完,因为实在是想要和它官方的 output 一模一样的话,实在是工作量奇大,说不定要到 4k 行去……而且要非常明确各个字节码的意义才行。等以后写一个 simple jvm 的时候再说吧。那么,比较坑的地方其实并不在于别的,而是在于annotation(注解)
的解析。因为如果你仔细观察过annotation
的数据结构实现,我们就会发现……其实element_value
结构体的内部有一个value_t*
的指针,当然这个value_t
是继承体系中的根类。它可以变成annotation
子类。而annotation
结构体的内部又有着element_value
的对象……你没看错这个其实是循环的。要读入必须要递归。读者看到这里,可能会以为,“没啥打不了的反正就是递归啊~”,会有这种想法不奇怪……毕竟我说得比较简单(逃,其实 java 中还有这种用法,即像这种一样,我还是列在下边吧:123456"ha")(name ="ok", a = , b = { (name = "a"), (name = "b"), ()})(name =public void haha () {}
Java 的 annotation
赋值 annotation
的猥琐用法……. oh 如果有的童鞋说 “我已经知道了”,那么请忽略我说的话 QAQ,我也是在写程序的过程中才发现这个奇技淫巧的用法的……QAQ,尤其是这货在 javap -verbose
下所产生的反汇编字符串是:
对照着上边的源代码看,我们可以知道:IA, IB, IC
这三个 RuntimeInvisibleAnnotations
其实在反汇编码中代表着 #10, #11 和 #14
。而后边跟随着的括号即是构造函数。注释我已经写在了后边,当然,最后一个我故意没有写,还请看官自行考虑~ 这部分的代码在这里,可以看到用了一个函数内部套着一个内部的 lambda 表达式。lambda 表达式内部有多个分支,其中如果是把 annotation 赋给 annotation 递归的情况,就会由内部的 inner lambda 呼叫外部的函数;如果要是最后一种情况,即是把数组赋给 annotation 的话,由于 .class 文件的数据结构内部表示问题,我们会让 inner lambda 递归呼叫自身。这个问题就留给读者吧~
尾声
搞定了上边这些,估计也到了尾声了。放一张图来表示内心的鸡冻(逃:
那么这篇文章也要结束了~接下来两个月,我打算写一个 simple STL,然后仔细研读虚拟机规范,实现一个简单的 JVM。就这样吧。