Java 字节码 .class 文件解析:写一个 javap 工具

我们的题目

最近闲着没事想要了解一下 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 这个的也有不少,网上这方面文章还是有很多的,我没有必要重复造轮子(大雾。因此,本篇的主旨在于对想要进行这项工作的童鞋进行一个宏观的 “坑的解说(逃”。

需要之物

  1. JVM 的规范,最好要 SE 8 版本的规范,因为 SE 7 中有的部分和 8 不一样,改动其实也不太小(。当然为了效率我看的是中文版的,中文版的有不少错误QAQ。最后还是看的英文进行的解析。而且毕竟英文版的放在 oracle 官网上,因此数据结构可以直接进行复制粘贴(雾。
  2. C++ 基本语法的熟悉即可。当然用 Java 也不是不可以,但是据说有 Java 有内置的一个什么 xxxClassParser 在 sun 包下,可以直接经过人家的 API 进行解析的(逃。Java 毕竟全是引用,在 parse annotation 那里要好办很多QAQ,用 C++ 必须强行使用指针咯。
  3. java 环境。你必须要有 javaphexdump 两样神器:一个是直接把 .class 文件反汇编,尤其是 javap -verbose 命令,你几乎可以查看到非常完整的反汇编代码,解释得非常清楚;另一个是直接暴力查看 .class 文件的字节码,两者强强联合,使用更佳!!而我的代码的定位,就是写一个 javap -verbose 工具。力争输出和 javap -verbose 一样~

BEGIN!

那么我们就开始吧!

  1. 预读。首先我们要知道,写 parser 的话,如果想要避免回溯,就一定要采取预读 peek 的策略。Java 官方在 .class 文件的制作上,也是采取了 peek 的策略。比如 LL(1) 文法,就是采取预读 1 个 token 的方法。而 LR(0) 文法就是预读 0 个 token。不过字节码毕竟是 bytecode,那么这个 token 当然就是一个 byte 啦!当然随着往下写我们就知道,因为一个 byte 最多也就能表示 256 个数字,因此可能太小了。要表示类别的数量,很有可能需要两个 bytes。所以我在里边写了 peek1()peek2() 两个方法来进行预读工作。有了 C++ 的流,我们可以非常轻易地进行从流缓冲的读取。还是非常赚的~
  2. 注意你机器是大端序还是小端序。我的 mac 是小端序的。因此读入的时候,保存在变量中是反向存放的。所以,这样就会引发 “读的是反的” 的情况。因此,在 *nix 环境下,我们可以引用 POSIX 规范的 #include <arpa/inet.h> 头文件,使用其中的 htons()htonl() 函数进行比特的逆转。当然,这两个函数其实真正是用在网络编程当中的。在 read2()read4() 中,我用到了这两个函数。
  3. 关于 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 规范的第四章把数据结构全用伪代码列给我们了。当然,这其中有很多坑。我会一一列举出来。

  1. 在 Java 的规范中,有非常多的继承关系。而如何对这些继承的结构进行识别,我们就需要用到 peek 来进行 distribute。也就是,使用 “前看一个或者两个字符” 来进行选择到底该选择哪个类进行使用。举个例子,比如常量池 constant_pool,我们就可以看代码:constant_pool 的继承关系,这个 cp_info 结构体 就是一个基类。预先读入 peek 一个一字节的 tag 之后,我们就会按照 tag 的大小,按照这几个 #define 的变量,见 constant_pool 的 tags,来进行选择子类的类别,并且按照子类内部的成员向内进行填充 bytecode。这之中比较坑的是 longdouble,因为他们在常量池当中要占据两个位置……而正常的变量都占据一个位置……我还没有实现过简单的 JVM,并不知道这么做的深意……而且常量池的索引是从 1 开始的,而不是从 0 开始,我也并不明白这个的深意……不敢妄测不敢妄测。不过这个是比较坑的部分,必须小心谨慎,否则可能会一调试调试一个晚上(QAQ。
  2. 如果遇到类内部的数组(而且不定长度,是按照类内部的另一个成员变量的数值来当做长度的),这样,编译过程中长度不确定的数组是不被允许的。必须要等到运行时才能进行。因此,必须要使用 new 来在堆上在运行时分配。其实这里是比较好考虑到的。但是,由于 C++ 的 RAII 特性,我们就要在这样的类中写析构函数…… 这样不断的申请释放不断 copy 代码实在是累死了QAQ……
  3. 常量池 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 中还有这种用法,即像这种一样,我还是列在下边吧:
    1
    2
    3
    4
    5
    6
    @IA
    @IB(name = "ha")
    @IC(name = "ok", a = @IA, b = { @IB(name = "a"), @IB(name = "b"), @IB()})
    public void haha () {
    }

Java 的 annotation 赋值 annotation 的猥琐用法……. oh 如果有的童鞋说 “我已经知道了”,那么请忽略我说的话 QAQ,我也是在写程序的过程中才发现这个奇技淫巧的用法的……QAQ,尤其是这货在 javap -verbose 下所产生的反汇编字符串是:

1
2
3
4
RuntimeInvisibleAnnotations:
0: #10() // IA,就是 #10 (是常量池编号) 的构造函数式空构造函数。
1: #11(#12=s#13) // IB,就是 #11 的构造函数是 #12,也就是 name,它的构造函数的参数是 #13 "ha",当然,"s" 代表的语义是 #13 是一个 Unicode 的 String。如下文一般的 "@" 表示参数是一个 annotation(递归的)。这些都可以在文档中查到。
2: #14(#12=s#15,#16=@#10(),#17=[@#11(#12=s#16),@#11(#12=s#17),@#11()])

对照着上边的源代码看,我们可以知道:IA, IB, IC 这三个 RuntimeInvisibleAnnotations 其实在反汇编码中代表着 #10, #11 和 #14。而后边跟随着的括号即是构造函数。注释我已经写在了后边,当然,最后一个我故意没有写,还请看官自行考虑~ 这部分的代码在这里,可以看到用了一个函数内部套着一个内部的 lambda 表达式。lambda 表达式内部有多个分支,其中如果是把 annotation 赋给 annotation 递归的情况,就会由内部的 inner lambda 呼叫外部的函数;如果要是最后一种情况,即是把数组赋给 annotation 的话,由于 .class 文件的数据结构内部表示问题,我们会让 inner lambda 递归呼叫自身。这个问题就留给读者吧~

尾声

搞定了上边这些,估计也到了尾声了。放一张图来表示内心的鸡冻(逃:
IMG_0966.JPG
那么这篇文章也要结束了~接下来两个月,我打算写一个 simple STL,然后仔细研读虚拟机规范,实现一个简单的 JVM。就这样吧。