[Java] 用GUI展示class文件顶层的 access_flags

68 阅读8分钟

背景

The Java® Virtual Machine Specification 中的 4.1. The ClassFile Structure 这一小节有对 access_flags 的介绍 ⬇️

image.png

image.png

我们可以借助 trae 来生成一个 Python 脚本,这个 Python 提供图形化界面,让用户选择 class 文件,然后展示这个 class 文件顶层的 access_flags。示例效果如下图所示 ⬇️

image.png

局限性说明

本文的 python 代码仅用于学习 class 文件基础结构,不是生产级解析库。仅适用于解析 javac 编译得到的简单 class 文件,不保证可用于生产、恶意字节码检测场景。

要点

1. class 文件格式

class 文件的格式如下表所示 ⬇️ (原始内容来自 4.1. The ClassFile Structure)

类型字段
u4magic
u2minor_version
u2major_version
u2constant_pool_count
cp_infoconstant_pool[constant_pool_count-1]
u2access_flags
u2this_class
u2super_class
u2interfaces_count
u2interfaces[interfaces_count]
u2fields_count
field_infofields[fields_count]
u2methods_count
method_infomethods[methods_count]
u2attributes_count
attribute_infoattributes[attributes_count]

本文只关心 access_flags,上表中将它用粗体展示了。

2. 如何借助 javap 命令查看 class 文件顶层的 access_flags

如果我们想查看 Main.class 文件顶层的 access_flags,可以执行下方的命令 ⬇️

javap -v Main.class

正文

生成代码的过程

4.1. The ClassFile Structure 中介绍了 class 文件的结构,要想获取 access_flags,我们需要跳过下图红色框所示的这些内容 ⬇️

image.png

其中

  • magic 长度固定为 4 字节
  • minor_version 长度固定为 2 字节
  • major_version 长度固定为 2 字节
  • constant_pool_count 长度固定为 2 字节
  • constant_pool 长度 不固定

所以难点在于如何跳过 constant_pool。根据 4.4. The Constant Pool 中的描述可知,constant_pool 共支持 17 种 tag ⬇️

image.png

trae 帮我汇总的常量池中各个 tag 的长度信息

(这一小节的内容由 trae 生成,我做了些调整)

来源: JVMS §4.4, Table 4.4-A (Java SE 27)

每个常量池条目以 1 字节的 tag 开头, tag 之后跟着若干字节的附加信息, 格式由 tag 决定。 下表按 tag 值升序排列, "字节数"指 tag 之后 的附加信息长度, 各结构的字段布局来自 §4.4.1–§4.4.12。

Tag常量类型结构 (tag 之后)字节数位置
1CONSTANT_Utf8u2 length; u1 bytes[length]变长 (2 + length)§4.4.7
3CONSTANT_Integeru4 bytes4§4.4.4
4CONSTANT_Floatu4 bytes4§4.4.4
5CONSTANT_Longu4 high_bytes; u4 low_bytes8§4.4.5
6CONSTANT_Doubleu4 high_bytes; u4 low_bytes8§4.4.5
7CONSTANT_Classu2 name_index2§4.4.1
8CONSTANT_Stringu2 string_index2§4.4.3
9CONSTANT_Fieldrefu2 class_index; u2 name_and_type_index4§4.4.2
10CONSTANT_Methodrefu2 class_index; u2 name_and_type_index4§4.4.2
11CONSTANT_InterfaceMethodrefu2 class_index; u2 name_and_type_index4§4.4.2
12CONSTANT_NameAndTypeu2 name_index; u2 descriptor_index4§4.4.6
15CONSTANT_MethodHandleu1 reference_kind; u2 reference_index3§4.4.8
16CONSTANT_MethodTypeu2 descriptor_index2§4.4.9
17CONSTANT_Dynamicu2 bootstrap_method_attr_index; u2 name_and_type_index4§4.4.10
18CONSTANT_InvokeDynamicu2 bootstrap_method_attr_index; u2 name_and_type_index4§4.4.10
19CONSTANT_Moduleu2 name_index2§4.4.11
20CONSTANT_Packageu2 name_index2§4.4.12
备注
  • u1 = 无符号 1 字节, u2 = 无符号 2 字节(大端序), u4 = 无符号 4 字节(大端序)。
  • CONSTANT_Utf8 是唯一变长的条目, 总长度 = 1(tag) + 2(length) + length。
  • CONSTANT_Long(5) 和 CONSTANT_Double(6) 虽然只占 8 字节, 但在常量池中占用 2 个 slot, 即常量池索引会跳过一个位置 — 遍历常量池时必须特殊处理。
  • 同一小节内的常量类型结构完全相同, 仅 tag 值不同:
    • §4.4.2: Fieldref / Methodref / InterfaceMethodref
    • §4.4.4: Integer / Float
    • §4.4.5: Long / Double
    • §4.4.10: Dynamic / InvokeDynamic

完整的代码

经过与 trae 和 豆包 的若干轮互动,最终得到了如下的 Python 代码

import struct
import tkinter as tk
from tkinter import filedialog, messagebox, ttk

# class 文件魔数: 0xCAFEBABE
MAGIC_NUMBER = 0xCAFEBABE

# 常量池 tag -> (类型名称, 后续字节数)
# 注: CONSTANT_Long(5) 和 CONSTANT_Double(6) 占用 2 个 slot
CONSTANT_POOL_TAGS = {
    1: ("CONSTANT_Utf8", None),            # 变长: 2字节 length + length 字节
    3: ("CONSTANT_Integer", 4),
    4: ("CONSTANT_Float", 4),
    5: ("CONSTANT_Long", 8),              # 占用 2 个 slot
    6: ("CONSTANT_Double", 8),            # 占用 2 个 slot
    7: ("CONSTANT_Class", 2),
    8: ("CONSTANT_String", 2),
    9: ("CONSTANT_Fieldref", 4),
    10: ("CONSTANT_Methodref", 4),
    11: ("CONSTANT_InterfaceMethodref", 4),
    12: ("CONSTANT_NameAndType", 4),
    15: ("CONSTANT_MethodHandle", 3),
    16: ("CONSTANT_MethodType", 2),
    17: ("CONSTANT_Dynamic", 4),
    18: ("CONSTANT_InvokeDynamic", 4),
    19: ("CONSTANT_Module", 2),
    20: ("CONSTANT_Package", 2),
}

# access_flags 位掩码 (JVMS §4.1 Table 4.1-B)
ACCESS_FLAGS = [
    ("ACC_PUBLIC", 0x0001,
     "Declared public; may be accessed from outside its package."),
    ("ACC_FINAL", 0x0010, "Declared final; no subclasses allowed."),
    ("ACC_SUPER", 0x0020,
     "Treat superclass methods specially when invoked by the "
     "invokespecial instruction."),
    ("ACC_INTERFACE", 0x0200, "Is an interface, not a class."),
    ("ACC_ABSTRACT", 0x0400, "Declared abstract; must not be instantiated."),
    ("ACC_SYNTHETIC", 0x1000,
     "Declared synthetic; not present in the source code."),
    ("ACC_ANNOTATION", 0x2000, "Declared as an annotation interface."),
    ("ACC_ENUM", 0x4000, "Declared as an enum class."),
    ("ACC_MODULE", 0x8000, "Is a module, not a class or interface."),
]


def skip_constant_pool(data, offset, count):
    """跳过常量池, 返回 access_flags 的偏移量。
    常量池索引从 1 到 count-1。
    """
    index = 1
    while index < count:
        if offset >= len(data):
            raise ValueError("常量池数据不完整, 文件可能被截断")
        tag = data[offset]
        offset += 1
        if tag not in CONSTANT_POOL_TAGS:
            raise ValueError(f"未知的常量池 tag: {tag} (索引 {index})")
        name, size = CONSTANT_POOL_TAGS[tag]
        if name == "CONSTANT_Utf8":
            if offset + 2 > len(data):
                raise ValueError(
                    "CONSTANT_Utf8 长度字段不完整, 文件可能被截断")
            (length,) = struct.unpack_from(">H", data, offset)
            offset += 2
            if offset + length > len(data):
                raise ValueError(
                    "CONSTANT_Utf8 内容超出文件末尾, 文件可能被截断")
            offset += length
        else:
            if offset + size > len(data):
                raise ValueError(f"{name} 数据不完整, 文件可能被截断")
            offset += size
        # CONSTANT_Long 和 CONSTANT_Double 占 2 个 slot
        if name in ("CONSTANT_Long", "CONSTANT_Double"):
            index += 2
        else:
            index += 1
    return offset


def parse_access_flags(file_path):
    """解析 class 文件, 返回 (minor, major, constant_pool_count, access_flags)。"""
    with open(file_path, "rb") as f:
        data = f.read()

    if len(data) < 10:
        raise ValueError("文件太小, 不是有效的 class 文件")

    # 格式字符串 ">IHHH" 含义:
    #   >  — 大端序(网络字节序), class 文件采用大端序
    #   I  — 无符号 4 字节整数, 读取 magic (魔数)
    #   H  — 无符号 2 字节整数, 读取 minor_version (次版本号)
    #   H  — 无符号 2 字节整数, 读取 major_version (主版本号)
    #   H  — 无符号 2 字节整数, 读取 constant_pool_count (常量池条目数)
    magic, minor, major, cp_count = struct.unpack_from(">IHHH", data, 0)
    if magic != MAGIC_NUMBER:
        raise ValueError(f"魔数不匹配, 期望 0xCAFEBABE, 实际为 0x{magic:08X}")

    # constant_pool_count 等于常量池条目数 + 1, 因此最小为 1
    if cp_count < 1:
        raise ValueError(f"constant_pool_count 非法: {cp_count} (必须 >= 1)")

    offset = 10  # magic(4) + minor(2) + major(2) + constant_pool_count(2)
    offset = skip_constant_pool(data, offset, cp_count)

    if offset + 2 > len(data):
        raise ValueError("access_flags 字段不完整, 文件可能被截断")
    (access_flags,) = struct.unpack_from(">H", data, offset)
    return minor, major, cp_count, access_flags


class AccessFlagsViewer:
    def __init__(self, root):
        self.root = root
        self.root.title("Class 文件 access_flags 查看器")
        self.root.resizable(False, False)

        self.file_path = tk.StringVar(value="尚未选择文件")
        self.minor_version = tk.StringVar(value="-")
        self.major_version = tk.StringVar(value="-")
        self.cp_count = tk.StringVar(value="-")
        self.access_flags_hex = tk.StringVar(value="-")

        self._build_ui()

        # 宽度固定 880, 高度取内容自然高度(明细表恰好 9 行)
        self.root.update_idletasks()
        self.root.geometry(f"880x{self.root.winfo_reqheight()}")

    def _build_ui(self):
        pad = {"padx": 12, "pady": 8}

        # 文件选择行
        file_frame = ttk.Frame(self.root)
        file_frame.pack(fill="x", **pad)

        ttk.Button(file_frame, text="选择 class 文件",
                   command=self._choose_file).pack(side="left")
        path_label = ttk.Label(file_frame, textvariable=self.file_path,
                               foreground="gray", wraplength=600)
        path_label.pack(side="left", padx=(8, 0))

        # 基础信息区
        info_frame = ttk.LabelFrame(self.root, text="基础信息")
        info_frame.pack(fill="x", **pad)

        rows = [
            ("次版本号 (minor_version):", self.minor_version),
            ("主版本号 (major_version):", self.major_version),
            ("常量池条目数 (constant_pool_count):", self.cp_count),
            ("access_flags (十六进制):", self.access_flags_hex),
        ]
        for i, (label_text, value_var) in enumerate(rows):
            ttk.Label(info_frame, text=label_text).grid(
                row=i, column=0, sticky="w", padx=8, pady=4)
            value_label = ttk.Label(info_frame, textvariable=value_var,
                                    font=("Menlo", 11, "bold"))
            value_label.grid(row=i, column=1, sticky="w", padx=8, pady=4)

        info_frame.columnconfigure(1, weight=1)

        # flags 明细表
        flags_frame = ttk.LabelFrame(self.root, text="access_flags 位明细")
        flags_frame.pack(fill="x", **pad)

        columns = ("flag", "value", "set", "interpretation")
        self.tree = ttk.Treeview(flags_frame, columns=columns,
                                 show="headings", height=9)
        self.tree.heading("flag", text="Flag Name")
        self.tree.heading("value", text="Value")
        self.tree.heading("set", text="Set?")
        self.tree.heading("interpretation", text="Interpretation")
        self.tree.column("flag", width=140)
        self.tree.column("value", width=80)
        self.tree.column("set", width=60)
        self.tree.column("interpretation", width=560)
        # 配置高亮 tag: 用于 "Set?" 为 Yes 的行
        self.tree.tag_configure("set", background="#FFF3B0",
                                foreground="#B8860B")
        self.tree.pack(fill="x")

    def _choose_file(self):
        path = filedialog.askopenfilename(
            title="选择 class 文件",
            filetypes=[("Java class 文件", "*.class"), ("所有文件", "*.*")],
        )
        if not path:
            return

        self.file_path.set(path)
        try:
            minor, major, cp_count, flags = parse_access_flags(path)
        except (ValueError, OSError) as e:
            messagebox.showerror("错误", str(e))
            self._reset_results()
            return

        self.minor_version.set(str(minor))
        self.major_version.set(str(major))
        self.cp_count.set(str(cp_count))
        self.access_flags_hex.set(f"0x{flags:04X}")
        self._update_flags_table(flags)

    def _update_flags_table(self, flags):
        self.tree.delete(*self.tree.get_children())
        for name, value, desc in ACCESS_FLAGS:
            is_set = "Yes" if flags & value else "No"
            tags = ("set",) if flags & value else ()
            self.tree.insert("", "end",
                             values=(name, f"0x{value:04X}", is_set, desc),
                             tags=tags)

    def _reset_results(self):
        for var in (self.minor_version, self.major_version, self.cp_count,
                    self.access_flags_hex):
            var.set("-")
        self.tree.delete(*self.tree.get_children())


def main():
    root = tk.Tk()
    AccessFlagsViewer(root)
    root.mainloop()


if __name__ == "__main__":
    main()

运行结果

请将以上代码保存为 access_flags_viewer.py,使用如下命令可以运行它

python3 access_flags_viewer.py

初始的效果如下图所示 ⬇️ (此时尚未选择 class 文件)

image.png

选择一个 class 文件后,就会展示这个 class 文件顶层的 access_flags。示例效果如下 ⬇️

image.png

补充说明

借助 javap 命令来查看 access_flags

可以借助 javap 命令来查看 class 文件的 access_flags。示例如下(假设我们已经有一个名为 Main.class 的文件了)

javap -v Main.class

示例运行结果如下图所示 ⬇️

image.png

运行环境提示

  1. 运行脚本需要本机有 Python
  2. 如果您没有安装 Tkinter 的话,也需要安装一下才能运行本文提供的 Python 脚本

参考资料