魔数之咖啡宝贝

Java的class文件以十六进制打开,前八位是cafe babe(也被叫做咖啡宝贝)。
在Java中0xCAFEBABE这个标识被称为魔数。那么到底什么是魔数?文件里的内容在磁盘中是如何存储的?
文件扩展名被修改后怎么识别一个文件的类型呢?

标识文件类型的魔数

一般情况下,我们人类是通过文件扩展名来识别一个文件类型的,比如我们看到一个.txt类型的文件就知道他是一个纯文本文件。但是扩展名是可以修改的,一旦扩展名被修改过,要如何识别一个文件的类型呢?

很多类型的文件,起始的几个字节都是固定的,这几个字节的内容也被称为魔数(magic number)。计算机系统中不同的处理程序根据这几个字节的内容就可以确定文件类型。有了这些魔法数字,就可以方便的区别不同类型的文件。

zip压缩文件中的PK魔数

所有的zip文件都是以PK开头的,PK是PKZIP算法的发明者菲尔·卡茨(Phil Katz)名字首字母的缩写。

使用压缩工具创建一个压缩文件,比如我使用的是MacOS操作系统,直接使用内置的zip命令将一个文件夹压缩成zip文件

1
zip -r someDir.zip ./someDir

压缩后会得到一个someDir.zip的压缩文件。

接下来使用文本查看工具,以16进制格式打开查看, MacOS系统下可以使用hexdump或者xxd命令行工具以指定格式查看文件内容。

1
xxd someDir.zip

可以看到zip文件的最前面的字节是PK这个魔数,压缩文件可以通过这个字节来分辨出这是一个zip格式的压缩文件。

class文件中的0xCAFEBABE魔数

使用文本编辑器,创建一个App.java的文件

1
vim App.java

手敲一个程序员入门程序Hello World

1
2
3
4
5
class App {
public static void main(String[] args) {
System.out.println("Hello,World!");
}
}

通过JDK的编译工具javacApp.java 编译成Java字节码文件 App.class

1
javac App.java

使用java命令执行App.class文件,输出Hello World!

1
2
lanshiqin@lanshiqindembp Desktop % java App
Hello,World!

那么,这个App.class文件在磁盘上存储的到底是什么内容呢?

1
xxd App.class

使用xxd 以十六进制打开文件,内容如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
lanshiqin@lanshiqindembp Desktop % xxd App.class 
00000000: cafe babe 0000 0034 001d 0a00 0600 0f09 .......4........
00000010: 0010 0011 0800 120a 0013 0014 0700 1507 ................
00000020: 0016 0100 063c 696e 6974 3e01 0003 2829 .....<init>...()
00000030: 5601 0004 436f 6465 0100 0f4c 696e 654e V...Code...LineN
00000040: 756d 6265 7254 6162 6c65 0100 046d 6169 umberTable...mai
00000050: 6e01 0016 285b 4c6a 6176 612f 6c61 6e67 n...([Ljava/lang
00000060: 2f53 7472 696e 673b 2956 0100 0a53 6f75 /String;)V...Sou
00000070: 7263 6546 696c 6501 0008 4170 702e 6a61 rceFile...App.ja
00000080: 7661 0c00 0700 0807 0017 0c00 1800 1901 va..............
00000090: 000c 4865 6c6c 6f2c 576f 726c 6421 0700 ..Hello,World!..
000000a0: 1a0c 001b 001c 0100 0341 7070 0100 106a .........App...j
000000b0: 6176 612f 6c61 6e67 2f4f 626a 6563 7401 ava/lang/Object.
000000c0: 0010 6a61 7661 2f6c 616e 672f 5379 7374 ..java/lang/Syst
000000d0: 656d 0100 036f 7574 0100 154c 6a61 7661 em...out...Ljava
000000e0: 2f69 6f2f 5072 696e 7453 7472 6561 6d3b /io/PrintStream;
000000f0: 0100 136a 6176 612f 696f 2f50 7269 6e74 ...java/io/Print
00000100: 5374 7265 616d 0100 0770 7269 6e74 6c6e Stream...println
00000110: 0100 1528 4c6a 6176 612f 6c61 6e67 2f53 ...(Ljava/lang/S
00000120: 7472 696e 673b 2956 0020 0005 0006 0000 tring;)V. ......
00000130: 0000 0002 0000 0007 0008 0001 0009 0000 ................
00000140: 001d 0001 0001 0000 0005 2ab7 0001 b100 ..........*.....
00000150: 0000 0100 0a00 0000 0600 0100 0000 0100 ................
00000160: 0900 0b00 0c00 0100 0900 0000 2500 0200 ............%...
00000170: 0100 0000 09b2 0002 1203 b600 04b1 0000 ................
00000180: 0001 000a 0000 000a 0002 0000 0003 0008 ................
00000190: 0004 0001 000d 0000 0002 000e ............

class文件是一组以8位字节为基础单位的二进制流(当遇到需要8位字节以上空间的数据项时,则会按照高位在前的方式分隔成若干个8位字节进行存储)。

前八位 cafe babe为class字节码类型标识的魔数,紧跟后面的八位0000 0034 为编译这个class文件所使用的jdk版本号。

minor_version 为0000,表示次版本号为0
major_version为 0034,对应十进制数为52,表示主版本为52,对应的jdk版本为1.8。
再往后的001d,对应的十进制是29,可知常量池的大小为28
每个字节码的含义,都可以在官方网站中找到对应的文档描述,不需要死记硬背,因为class文件是通过工具生成的规则,生成的规则必须要有对应的文档描述让他人能够参照。

为了使人更加易读,java提供了javap命令进行class文件查看

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
lanshiqin@lanshiqindembp Desktop % javap App.class 
Compiled from "App.java"
class App {
App();
public static void main(java.lang.String[]);
}
lanshiqin@lanshiqindembp Desktop %
lanshiqin@lanshiqindembp Desktop % javap -v App.class
Classfile /Users/lanshiqin/Desktop/App.class
Last modified 2021-6-14; size 412 bytes
MD5 checksum 5c5d130a8ce6adcad2b6418abb7cc3d0
Compiled from "App.java"
class App
minor version: 0
major version: 52
flags: ACC_SUPER
Constant pool:
#1 = Methodref #6.#15 // java/lang/Object."<init>":()V
#2 = Fieldref #16.#17 // java/lang/System.out:Ljava/io/PrintStream;
#3 = String #18 // Hello,World!
#4 = Methodref #19.#20 // java/io/PrintStream.println:(Ljava/lang/String;)V
#5 = Class #21 // App
#6 = Class #22 // java/lang/Object
#7 = Utf8 <init>
#8 = Utf8 ()V
#9 = Utf8 Code
#10 = Utf8 LineNumberTable
#11 = Utf8 main
#12 = Utf8 ([Ljava/lang/String;)V
#13 = Utf8 SourceFile
#14 = Utf8 App.java
#15 = NameAndType #7:#8 // "<init>":()V
#16 = Class #23 // java/lang/System
#17 = NameAndType #24:#25 // out:Ljava/io/PrintStream;
#18 = Utf8 Hello,World!
#19 = Class #26 // java/io/PrintStream
#20 = NameAndType #27:#28 // println:(Ljava/lang/String;)V
#21 = Utf8 App
#22 = Utf8 java/lang/Object
#23 = Utf8 java/lang/System
#24 = Utf8 out
#25 = Utf8 Ljava/io/PrintStream;
#26 = Utf8 java/io/PrintStream
#27 = Utf8 println
#28 = Utf8 (Ljava/lang/String;)V
{
App();
descriptor: ()V
flags:
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 1: 0

public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=1, args_size=1
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #3 // String Hello,World!
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 3: 0
line 4: 8
}
SourceFile: "App.java"

通过javap 命令查看的class文件中可以验证主次版本号和常量池大小与前面的16进制分析的结果是一样的。
其他部分不是本文的重点,所以其他的操作符本文就不分析了,这里推荐周志明所著《深入理解java虚拟机》,从底层角度很系统的讲述了JVM的各方面细节。

文件里的内容在磁盘中是如何存储的

无论是十进制还是十六进制的方式进行查看。不同程序不同的操作格式,主要区别在于由多少个0和1的排列组合进行数据转译。在目前主流的计算机中,底层所有的数据操作的都是0和1的序列组合。
磁盘数据的存储原理此次就不展开讲了,因为这是大学基础课程中的知识,有需要的话可以重新复习一下《计算机组成原理》。

新建一个文件 text.txt,并输入以下内容。

1
ABCDEFG123

保存后,使用 xxd -b 的方式以二进制的方式查看。

1
2
3
lanshiqin@lanshiqindembp Desktop % xxd -b text.txt 
00000000: 01000001 01000010 01000011 01000100 01000101 01000110 ABCDEF
00000006: 01000111 00110001 00110010 00110011 00001010 G123.

可以看到这个文本文件在磁盘上存储的二进制信息,txt类型的文件没有魔数,存储的就是文本内容本身。
txt文本文件直接使用ASCII 进行编号(American Standard Code for Information Interchange)美国信息交换标准代码的简写。
使用8位二进制数组合来表示128或256种可能的字符。
01000001 二进制对应的十进制是65,对应的ASCII码表的字符就是A
01000010 二进制对应的十进制是66,对应的ASCII码表的字符就是B
以此类推,每个二进制转换成十进制后得到的值都可以在ASCII码表上找到对应都值。

那么,上文中的java的class文件的存储方式也是如此码?
使用 xxd -b 的方式以二进制的方式查看 App.class文件,篇幅限制,这里只截图了部分输出结果:

可以看到这个class文件在磁盘中是由很多个0和1的排列组合进行存储的。与上文中的十六进制是一一对应的。
class文件的魔数信息也被存储在磁盘上。

文件扩展名被修改后怎么识别一个文件的类型呢

回到开头的问题,一个文件的扩展名被修改后,如何识别一个文件的类型。

使用java 命令运行App.class文件时,命令省略后面的.class。因为java虚拟机的规则会根据名字去找对应名字.class的文件。如果改了文件扩展名,会直接报错错误: 找不到或无法加载主类 App

那么,如果把一个其他任意的txt文件扩展名改成.class后缀,使用java命令执行会怎么样呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
lanshiqin@lanshiqindembp Desktop % java text       
Error: A JNI error has occurred, please check your installation and try again
Exception in thread "main" java.lang.ClassFormatError: Incompatible magic value 1094861636 in class file text
at java.lang.ClassLoader.defineClass1(Native Method)
at java.lang.ClassLoader.defineClass(ClassLoader.java:756)
at java.security.SecureClassLoader.defineClass(SecureClassLoader.java:142)
at java.net.URLClassLoader.defineClass(URLClassLoader.java:468)
at java.net.URLClassLoader.access$100(URLClassLoader.java:74)
at java.net.URLClassLoader$1.run(URLClassLoader.java:369)
at java.net.URLClassLoader$1.run(URLClassLoader.java:363)
at java.security.AccessController.doPrivileged(Native Method)
at java.net.URLClassLoader.findClass(URLClassLoader.java:362)
at java.lang.ClassLoader.loadClass(ClassLoader.java:418)
at sun.misc.Launcher$AppClassLoader.loadClass(Launcher.java:355)
at java.lang.ClassLoader.loadClass(ClassLoader.java:351)
at sun.launcher.LauncherHelper.checkAndLoadMain(LauncherHelper.java:601)

可以看到ClassLoader将类文件加载到虚拟机后,经过文件验证,抛出了一个ClassFormatError到错误,表示class文件格式错误。
因为java虚拟机会验证魔数和版本号,如果魔数不是cafe babe,则说明不是class文件,不再进行后续的验证解析步骤。
Java虚拟机为了能够鉴别文件是否为自己所能接受的类型,所以在编译文件时会把魔数信息写入到文件里。

如果我们将一个正常的class字节码内容中的版本号数据改成更高版本,会怎么样呢?
除了魔数,编译器还会把版本号写入到class文件中,比如高版本jdk编译的class文件在低版本的jvm中被加载时,会报无法运行。

以二进制打开文件

1
vim -b App.class

转换成16进制进行编辑

1
:%!xxd

把文件内容
cafe babe 0000 0034
改成
cafe babe 0000 0035
然后转换回二进制模式
:%!xxd -r
保存。
此时使用java命令执行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
lanshiqin@lanshiqindembp Desktop % java App 
Error: A JNI error has occurred, please check your installation and try again
Exception in thread "main" java.lang.UnsupportedClassVersionError: App has been compiled by a more recent version of the Java Runtime (class file version 53.0), this version of the Java Runtime only recognizes class file versions up to 52.0
at java.lang.ClassLoader.defineClass1(Native Method)
at java.lang.ClassLoader.defineClass(ClassLoader.java:756)
at java.security.SecureClassLoader.defineClass(SecureClassLoader.java:142)
at java.net.URLClassLoader.defineClass(URLClassLoader.java:468)
at java.net.URLClassLoader.access$100(URLClassLoader.java:74)
at java.net.URLClassLoader$1.run(URLClassLoader.java:369)
at java.net.URLClassLoader$1.run(URLClassLoader.java:363)
at java.security.AccessController.doPrivileged(Native Method)
at java.net.URLClassLoader.findClass(URLClassLoader.java:362)
at java.lang.ClassLoader.loadClass(ClassLoader.java:418)
at sun.misc.Launcher$AppClassLoader.loadClass(Launcher.java:355)
at java.lang.ClassLoader.loadClass(ClassLoader.java:351)
at sun.launcher.LauncherHelper.checkAndLoadMain(LauncherHelper.java:601)

可以看到报了一个错误UnsupportedClassVersionError, 报错内容显示class文件是由版本号53编译的(其实是我们手动该的版本号),而虚拟机只支持52及以下版本号所编译的文件。 高版本的Java虚拟机具有向下兼容的特性,低版本虚拟机无法运行高版本所编译的class文件,比如高版本的一些新特性在低版本虚拟机中无法支持,所以在文件加载验证阶段就要校验版本

如果是一个txt文件,因为没有魔术,所以无论改成什么扩展名,都可以使用文本编辑器打开,默认以ASCII码表进行解析。
如果是一个zip文件,魔数为PK,所以解压缩文件会验证这个魔数PK,如果不是PK就直接报错,比如压缩包损坏错误。

图片,或者其他格式的文件,都会有对应的魔数标识,如果没有魔数标识,在一般程序中会以该扩展名默认的格式被加载,比如txt文件默认以ASCII编码的方式进行打开。

一个文件里面的内容到底是啥?用惯了Windows的人肯定是看后缀。但是后缀这个东西说改就改,不可靠。
比较可靠的是把文件类型信息写到文件里面,通常来说,也就是写到文件开头的那几个字节。
文件扩展名被修改后,在需要魔数验证的程序加载文件时,就会对文件的魔数进行验证,验证不通过就报文件损坏等错误提示。

0%