前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >JVM字节码学习笔记——class 文件结构

JVM字节码学习笔记——class 文件结构

作者头像
p4nda
发布2023-01-03 14:30:05
2750
发布2023-01-03 14:30:05
举报
文章被收录于专栏:技术猫屋技术猫屋
jvm.jpeg
jvm.jpeg

0x01 前言

本系列学习笔记均来自《深入理解 JVM 字节码》(作者:张亚),本笔记仅用于个人学习知识总结。

对于学习 java 安全、想了解 JVM 字节码的童鞋们强烈建议购买正版书去阅读。

0x02 class 文件结构

java 是跨平台的一门语言,但是 jvm 却不是跨平台的,但是不同平台的 JVM 帮我们屏蔽了差异,通过 JVM 可以把源代码编译成和平台无关的字节码,这样我们的源代码就不用根据不同平台编译成不同二进制是可执行文件了。这也是 java 字节码的意义所在。

class 文件由十部分组成,具体如下:

  • 魔数(magic number)
  • 版本号(minor&major version)
  • 常量池(constant pool)
  • 访问标记(access flag)
  • 类索引(this class)
  • 超类索引(super class)
  • 接口表索引(interface)
  • 字段表(field)
  • 方法表(method)
  • 属性表(attribute)

一句顺口溜可以帮助我们记忆

My Very Cute Animal Truns Savage In full Moon Areas. 我可爱的宠物会在月圆时变得暴躁。

1、魔数(magic number)

魔数主要用于利用文件内容本身来标识文件的类型。class 文件的魔数为0xcafebabe,虚拟机在加载类文件之前会先检验这 4 个字节,如果不是,那么会抛出java.lang.ClassFormatError异常。

java 之父 James Gosling 曾经写过一篇文章,大意是他之前常去的一家饭店里有个乐队经常演出,后来乐队的主唱不幸去世,他们就将那个地方称为”cafedead“。当时 Gosling 正在设计一些文件的编码格式,需要两个魔数,一个用于对象持久化,一个用于 class 文件,这两个魔数有着相同的前缀”cafe“,他选择了 cafedead 作为对象持久化文件的魔数,选择了 cafebabe 作为 class 文件的魔数。

2、版本号(minor&major version)

魔数之后的四个字节分别表示副版本号(Minor Version)和主版本号(Major Version)。

如:CA FE BA BE 00 00 00 34

那么主版本号为:0x34=4x1+3x16=52

3、常量池(constant pool)

常量池是类文件中最复杂的数据结构。

对于 JVM 来说,如果操作数是常用的数值,比如 0,那么就会把这些操作数内嵌到字节码中,而如果是字符串常量或者较大的整数时,class 文件会把这些操作数存储在常量池中,当要使用这些操作数的时候,会根据常量池的索引位置来查找。

数据结构示意如下:

代码语言:javascript
复制
struct{
  u2                    constant_pool_count;
  cp_info            constant_poll[constant_pool_count-1];
}

常量池分为两个部分,一是常量池大小(cp_info_count),意思常量池项(cp_info)集合。

常量池大小(cp_info_count)

常量池大小由两个字节表示。如果常量池大小为 n,那么常量池真正有效的索引是 1~n-1。0 属于保留索引,供特殊情况使用。

常量池项(cp_info)

常量池项最多包含 n-1个元素。因为 long 和 double 类型的常量会占两个字节,也就是说或用两个索引位置,因此如果常量池中包含了这两种类型的变量,那么实际中的常量池的元素个数会比 n-1要少。

常量池项(cp_info)的数据结构示意如下:

代码语言:javascript
复制
cp_info{
  u1 tag;
  u2 info[];
}

每个常量池项的第一个字节表示常量项的类型(tag),接下来的几个字节才表示常量项的具体内容。

在 java 虚拟机中一共定义了 14 种常量项 tag 类型,这些常量名都以 CONSTANT开头,以 info 结尾。

常量类型

描述

CONSTANT_Utf8_info

1

utf-8 编码的字符串

CONSTANT_Integer_info

3

表示 int 类型常量;boolean、byte、short、chart

CONSTANT_Float_info

4

表示 float 类型量

CONSTANT_Long_info

5

长整型字面量

CONSTANT_Double_info

6

双精度型字面量

CONSTANT_Class_info

7

表示类或接口

CONSTANT_String_info

8

java.lang.String 类型的常量对象

CONSTANT_Fieldref_info

9

字段信息表

CONSTANT_Methodref_info

10

方法

CONSTANT_InterfaceMethodref_info

11

接口方法

CONSTANT_NameAndType_info

12

名称和类型表

CONSTANT_MethodHandle_info

15

方法句柄表

CONSTANT_MethodType_info

16

方法类型表

CONSTANT_InvokeDynamic_info

18

动态方法调用点

① CONSTANT_Utf8_info

CONSTANT_Utf8_info存储了 MUTF-8 编码的字符串,结构如下

代码语言:javascript
复制
CONSTANT_Utf8_info {  
   u1 tag;  // 值固定为 1
   u2 length;      // 值为bytes数组的长度
   u1 bytes[length];      // 采用 MUTF-8 编码的长度为 length 的字节数组
} 

值得一提的是,作者在书中解释了MUTF-8和 UTF8 的细微区别,同时也侧面告诉了读者为何字符串在class文件中是以MUTF-8编码而没有用标准的UTF-8编码。

书中提到,MUTF-8编码方式和UTF-8大致相同,但并不兼容。差别有两点:

第一,MUTF-8 里 null 字符(代码点U+0000)会被编码成 2 字节:0xC0、0x80;在标准的 UTF-8 编码中只用一个直接 0x00 表示。我们知道,在其他语言,比如 C 语言中,会把空字符当做字符串结束字符(通常我们所谓的%00 截断等原理就是如此),而采用了 MUTF-8 编码后,这种处理空字符的方式保证了字符串中不会出现空字符,在 C 语言处理的时候就不会发生意外截断。

第二,MUTF-8 只用到了 UTF-8 编码中的单字节、双字节、三字节表示方式,没有用到 4 字节表示方式,对于编码在 U+FFFF 之上的字符,java 使用了”代理对“通过 2 个字符表示,比如 emoji 表情笑哭,其代理对为\ud83d\ude02

第一点比较好理解,第二点要理解起来就必须了解 UTF-8 中的单字节、双字节、三字节、四字节表示方式具体是什么。下面简单说说。

  • 单字节

范围:0x0001 ~ 0x007F,UTF-8 用一个字节来表示:

0000 0001 ~ 0000 007F -> 0xxxxxxx

即,英文字母的 ASCII 编码和 UTF-8 编码的结果一样。

  • 双字节

范围:0x0080 ~ 0x07FF,UTF-8 用两个字节来表示:

0000 0080 ~ 0000 07FF -> 110xxxxx 10xxxxxx

即,把第一字节的110 去除,第二字节的 10 去除,然后把剩下的x组成新的两字节数据。

  • 三字节

范围:0x0800 ~ 0xFFFF,UTF-8 用三个字节表示:

0000 0800 ~ 0000 FFFF -> 1110xxxx 10xxxxxx 10xxxxxx

即,把第一字节的 1110去掉、第二字节的10去掉、第三字节的10去掉,然后把剩下的x组成新的三字节数据。

  • 四字节

范围:0001 0000 ~ 0010 FFFF,UTF-8 用四个字节表示:

0001 0000 ~ 0010 FFFF -> 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

即,把第一字节的 11110去掉,第二字节的10去掉,第三字节的10去掉,第四字节的10去掉,然后把剩下的x组成新的四字节数据。

  • 举例:

的 unicode 编码 为0x673A(0110 0111 0011 1010)

由于0x673A在三字节范围,因此用三字节表示,如下:

1.png
1.png

填入的 x 为:011 001110 0111010

得到UTF-8 编码为0xE69CBA

回到前面,我们知道 emoji 表情笑哭代理对为\ud83d\ude02,即:

D83D DE02,如果我们定义:

代码语言:javascript
复制
public final String y = "\ud83d\ude02";

那么打开编译后的 class 文件可以看到, emoji 表情笑哭表示为:

01 00 06 ED A0 BD ED B8 82

01表示常量项 tag,00 06表示 byte 数组的长度,即后面 6 字节ED A0 BD ED B8 82表示的是emoji 表情笑哭

ED A0 BD对应的二进制为11101101 10100000 10111101,由于是三字节,因此去掉第一字节的 1110、第二字节的10,第三字节的10,剩下的是1101100000111101,换算成十六进制为:0xD83D

同理,ED B8 82经过相同的运算可以得到0xDE02,采用代理对即表示为:\ud83d\ude02

值得一提的是,我查阅资料的时候发现 Java序列化机制使用的也是 MUTF-8 编码。java.io.DataInputjava.io.DataOutput接口分别定义了readUTF()writeUTF()方法,可以用于读写 MUTF-8编码的字符串。

② CONSTANT_Integer_info

表示 int 类型的常量。但是 boolean、byte、short 以及 char 类型的变量,在常量池中也会被当成 Int 来处理。

③ CONSTANT_Float_info

表示 float 类型的常量。

④ CONSTANT_Long_info

表示 long 类型的常量

⑤ CONSTANT_Double_info

表示 double 类型的常量

⑥ CONSTANT_Class_info

表示类或者接口。

⑦ CONSTANT_String_info

表示 java.lang.String类型的常量对象,其与CONSTANT_Utf8_info的区别是CONSTANT_Utf8_info存储了字符串真正的内容,而CONSTANT_String_info不包含字符串的内容,仅仅包含一个常量池中CONSTANT_Utf8_info常量类型的索引。

⑧ CONSTANT_Fieldref_info

指向CONSTANT_Class_info常量池索引值,表示方法所在的类信息。

⑨ CONSTANT_Methodref_info

用来描述一个方法。

⑩ CONSTANT_InterfaceMethodref_info

指向CONSTANT_NameAndType_info常量池索引值,表示方法的方法名、参数和返回类型。

⑪ CONSTANT_NameAndType_info

表示字段或者方法。

⑫ CONSTANT_MethodHandle_info、CONSTANT_MethodType_info、CONSTANT_InvokeDynamic_info

CONSTANT_MethodHandle_info表示方法句柄,比如获取一个类静态字段,实例字段,调用一个方法,构造器等都会转化成一个句柄引用。

CONSTANT_MethodType_info表示一个方法类型。

CONSTANT_InvokeDynamic_info表示动态调用指令引用信息。

作者在书中提到,这三个是从 JDK1.7开始为了更好地支持动态语言调用而新增的常量池类型,经过我的搜索也没发现有什么特别有用的信息,有的博主提到,这新增的三个常量池项只会在极其特别的情况能用到它,在class文件中几乎不会生成;也有博主详细介绍了该类型的结构及值,可以参考:https://juejin.cn/post/6844903950777319432#heading-17

作者在书中主要提到了CONSTANT_InvokeDynamic_info,该类型主要作用是为 invokedynamic指令提供启动引导方法。结构如下:

代码语言:javascript
复制
CONSTANT_InvokeDynmic_info{
  u1 tag;
  u2 bootstrap_method_attr_index;
  u2 name_and_type_index;
}

第一部分tag为固定值 18;第二部分bootstrap_method_attr_index是指向引导方法表bootstrap_method[]数组的索引;第三部分name_and_type_index为指向索引类常量池里的CONSTANT_NameAndType_infod的索引,表示方法描述符。

可以看实际例子来了解这个,参考:https://blog.csdn.net/zxhoo/article/details/38387141

4、访问标记(access flag)

访问标记主要用来标识一个类为 final、abstract、public 等。其由两个字节表示,16 个标记为可供使用,目前使用了其中 8 个标识位,如下图所示。

2.png
2.png

完整的访问标记含义如下表:

标志名

标志值

标志含义

针对的对像

ACC_PUBLIC

0x0001

public类型

所有类型

ACC_FINAL

0x0010

final类型

ACC_SUPER

0x0020

使用新的invokespecial语义

类和接口

ACC_INTERFACE

0x0200

接口类型

接口

ACC_ABSTRACT

0x0400

抽象类型

类和接口

ACC_SYNTHETIC

0x1000

该类不由用户代码生成

所有类型

ACC_ANNOTATION

0x2000

注解类型

注解

ACC_ENUM

0x4000

枚举类型

枚举

值得注意的是,类访问标记是可以组合的,如一个类的访问标记为0x0021(ACC_SUPER|ACC_PUBLIC),表示的是一个 public 类。但组合也是有条件的,像ACC_PUBLIC就不能和ACC_PRIVATE同时设置,ACC_FINALACC_ABSTRACT也不能同时设置,否则就违背了 java 的基本语义。

这方面的源码可以在 javac 源码中的com.sun.tools.javac.comp.Check.java中找到。

5、类索引(this class)&& 超类索引(super class)&& 接口表索引(interface)

这三部分是用来确定类继承关系的文件结构,其中this class表示类索引,super class表示直接父类的索引,interfaces用来描述这个类实现了哪些接口。通常这三部分都是指向常量池的索引,各自代表不同的表示,如类、接口、超类等

6、字段表(field)

字段表(field)用于存储类中定义的字段,包括静态和非静态类。 其结构伪代码表示如下:

代码语言:javascript
复制
{
    u2            fields_count;
    field_info        fields[fileds_count];
}

在上述的结构中,fields_count 用于表示 field 的数量,fields 表示字段集合,共有 fileds_count 个,每一个字段用 field_info结构表示。所以就来看看 filed_info 的结构:

代码语言:javascript
复制
filed_info{
        u2            access_flags;
        u2            name_index;
        u2            descriptor_index;
        u2            attributes_count;
        attribute_info        attributes[attributes_count];
}

如上,可以看到 filed_info 的结构分为四个部分,第一部分是 access_flags,表示字段的访问标记,如可以用该字段去区别某一个字段是否是publicprivateprotectedstatic等类型;第二部分是name_index用来表示字段名,指向常量池中的字符串常量;第三部分descriptor_index表示字段描述符的索引,同样指向常量池中的字符串常量;最后一部分由attributes_countattribute_info组成,分别表示属性的个数和属性的集合。

第一部分的访问标记和类一样,不过与类那块的内容相比,字段的访问标记更加丰富,共有九种

标志名

标志值

标志含义

ACC_PUBLIC

0x0001

public类型

ACC_PRIVATE

0x0002

private 类型

ACC_PROTECTED

0x0004

protected 类型

ACC_STATIC

0x0008

static 类型

ACC_FINAL

0x0010

final类型

ACC_VOLATILE

0x0040

volatile 类型,用于解决内存可见性问题

ACC_TRANSIENT

0x0080

transient 类型,被其修饰的字段默认不会序列化

ACC_SYNTHETIC

0x1000

该类由编译器自动生成,不由用户代码生成

ACC_ENUM

0x4000

枚举类型

比如在类中定义了字段

代码语言:javascript
复制
public static final int DEFAULT_SIZE = 128

编译后 DEFAULT_SIZE 字段在雷文杰中存储的访问标记值为 0x0019

这个值是由 ACC_PUBLIC | ACC_STATIC | ACC_FINAL 组成,表明其是一个 public static final 类型的变量。

一个字段在内存中默认如下:

3.png
3.png

则 public static final 类型为:

4.png
4.png

所以 二进制的 0001 1001 转换为 十六进制为 0x0019 ,也正是该标记值的由来

和类访问标记一样,字段的标记也不是随意组合的,比如 ACC_FINALACC_VOLATILE 不可以同时设置

第三部分的字段描述符也值得具体学习一下。

字段描述符用来表示某个字段的类型,在 JVM 中定义一个 int 类型的字段时,类文件中储存的类型不是字符串 int,而是更精简的字母 I,因此根据字段类型的不同,字段描述符分为三大类:

  • 原始类型:byte、int、char、float 等这些类型使用一个字符来表示,比如 J 对应的long 类型,B 对应的是 byte 类型(如果对序列化熟悉的朋友,一定知道这里其实和序列化中的基础类型字段是相同的)
  • 引用类型使用 L; 的方式来表示,为了防止多个连续的引用类型描述符出现混淆,引用类型描述符最后都加了一个 ; 作为结束,比如字符串类型 String 的描述符为 Ljava/lang/String;
  • JVM 使用一个前置的 [ 来表示数组类型,如 int[] 类型的描述符为 [I ,字符串数组 String[]的描述符为 [Ljava/lang/String;,而多为数组描述符知识多加了几个 [ 而已,比如 Object[][][] 类型的描述符为 [[[Ljava/lang/Object;(这里是不是感到很熟悉,是的,我们曾经在 fastjson 1.2.25-1.2.41版本的利用的就是在类加上L开头;结尾,来达到绕过所有黑名单的目的)
7、方法表(method)

方法表的作用和字段表很类似,类中定义的方法会被存储在这里。方法表也是一个变长结构,如下:

代码语言:javascript
复制
{
    u2        methods_count;
    method_info        methods[methods_count];
}

methods_count 表示方法的数量,methods 表示方法的集合,共有methods_count个,每一个方法用method_info结构表示

method_info 的结构如下:

代码语言:javascript
复制
{
    u2        access_flags;
    u2        name_index;
    u2        descriptor_index;
    u2        attributes_count;
    attribute_info    attributes[attributes_count];
}

method_info 的结构分为四个部分:第一部分 access_flags 表方法的访问标记、name_index表示方法名、

descriptor_index 表示方法描述符的索引值、attributes_count表示方法相关属性的个数、attribute_info表示相关属性的集合,结构示意图如下:

5.png
5.png

方法的访问标记比类和字段的访问标记类型更丰富,一共有 12 种,如下表:

标志名

标志值

标志含义

ACC_PUBLIC

0x0001

public类型

ACC_PRIVATE

0x0002

private 类型

ACC_PROTECTED

0x0004

protected 类型

ACC_STATIC

0x0008

static 类型

ACC_FINAL

0x0010

final类型

ACC_SYNCHRONIZED

0x0020

synchronize 类型

ACC_BRIDGE

0x0040

bridge 方法,由编译器生成

ACC_VARARGS

0x0080

方法包含可变长度参数,比如 String... args

ACC_NATIVE

0x0100

native 类型

ACC_ABSTRACT

0x0400

abstract 类型

ACC_STRICT

0x0800

strictfp 类型,表示使用 IEEE-754 规范的精确浮点数,极少使用

ACC_SYNTHETIC

0x1000

表示这个方法由编译器自动生成,非用户代码编译生成

比如一个方法如下所示 :

代码语言:javascript
复制
private static synchronized void foo(){
}

在生成的类文件中,foo 方法的访问标记值为 0x002a

这个值是由 ACC_PRIVATE | ACC_STATIC | ACC_SYNCHRONIZED 组成,表明这是一个 private static synchronized方法

一个方法在内存中默认如下:

6.png
6.png

则 private static synchronized方法为:

7.png
7.png

所以 二进制的 0010 1010 转换为 十六进制为 0x002a ,也正是该标记值的由来

同前面的字段访问标记一样,不是所有的方法访问标记都可以随意组合设置

最后提一点的是方法描述符,在前面学了字段描述符,方法描述符其实和字段描述符还是很像的,其格式如下:

(参数1类型 参数2类型 参数3类型 ... )返回值类型

比如方法Object foo(int i,double d, Thread t)的描述符为 (IDLjava/lang/Thread;)Ljava/lang/Object; 其中,I 表示第一个参数 i 的参数类型 int ,D 表示第二个参数 d 的类型 double,Ljava/java/Thread; 表示第三个参数 t 的类型 Tread,Ljava/lang/Object; 表示返回值类型为 Object ,如下图所示:

8.png
8.png
8、属性表(attribute)

属性表是 class 文件的最后一部分内容,属性出现的地方比较广泛,除了字段和方法中,在顶层的 class 文件中也会出现。属性表的类型很灵活,不同的虚拟机实现厂商可以自定义属性,属性表的结构如下:

代码语言:javascript
复制
{
    u2        attributes_count;
    attribute_info attributes[attributes_count];
}

和其他结构类似,属性表使用两个直接来表示属性的个数 attributes_count,接下来是若干个属性项的集合,可以看做是一个数组,数组的每一项都是一个属性项 attribute_info,数组的大小为attributes_count,attribute_info结构如下:

代码语言:javascript
复制
{
    u2        attribute_name_index;
    u4        attribute_length;
    u1         info[attribute_length];
}

attribute_name_index 是指向常量池的索引,根据这个索引可以找到 attribute 的名字,接下来的两部分表示 info 数组的长度和具体 byte 数组的内容。

虚拟机里预定义了 20 多种属性,书里介绍了两种属性—— ConstantValue 属性以及 Code 属性。

对于 ConstantValue 属性,书上给出的介绍是其出现在字段 field_info 中,用来表示静态变量的初始值

对于 Code 属性,书上给出的介绍是该属性是类文件中最重要的组成部分,它包含方法的字节码,除 native 和 abstract 方法外,每个 method 都有且仅有一个 Code 属性,并且 Code属性只作用于方法表中,其结构如下:

代码语言:javascript
复制
Code_attribute{
    u2        attribute_name_index;
    u4        attribute_length;
    u2        max_stack;
    u4        code_length;
    u1        code[code_length];
    u2        exception_table_length;
    {
        u2    start_pc;
        u2    end_pc;
        u2    handler_pc;
        u2    catch_type;
    } exception_table[exception_table_length];
    u2        attributes_count;
    attribute_info        attributes[attributes_count];
}

attribute_name_index 表示属性的名字,attribute_length表示属性值的长度,max_stack表示 操作数栈的最大深度,虚拟机运行的时候需要根据这个值来分配栈帧中的操作栈深度。它的计算规则是:有入栈的指令 stack 增加,有出栈的指令 stack 减少,在整个过程中 stack 的最大值就是 max_stack 的值,增加和减少的值一般都是 1,但也有例外,比如 LONG 和 DOUBLE 相关的指令入栈 stack 会增加 2,VOID 相关的指令是 0。

max_locals 表示局部变量表的大学,他的值并不等于方法中所有局部变量的数量值和。当一个局部作用域结束,它内部的局部变量占用的位置就可以被接下来的局部变量复用。

code_length和 code 用来表示字节码相关的信息,code_length 存储了字节码指令的长度,占用 4 个字节,虽然长度是4个字节(表面也就是说字节码指令的长度可以达到2^32-1),但实际上Java虚拟机规定了方法体中的字节码指令最多有65535条。在code属性中存储了Java方法体经过编译后Java的字节码指令,具体的字节码指令可以不用强记,在使用的时候根据字节码去查表就可以,具体可以参考:https://www.cnblogs.com/longjee/p/8675771.html

exception_table_length 和 exception_table 用来表示代码内部的异常表信息,其中start_pc、end_pc、handler_pc都是指向 code 字节数组的索引值,start_pc和end_pc表示异常处理器覆盖的字节码开始和结束的位置,是左闭右开区间[start_pc,end_pc),即包含 start_pc,不包含 end_pc。handler_pc表示异常处理 handler 在 code 字节数组的起始位置,异常被捕获以后该跳转到何处继续执行。

catch_type表示需要处理的 catch 的异常类型是什么,用 2 个字节表示,指向常量池中的类型为 CONSTANT_Class_info 的常量项。如果 catch_type 为0,表示可处理任意异常。

当 JVM 执行到某个方法的[start_pc,end_pc)范围内的字节码发生异常时,如果发生的异常是这个 catch_type 对应的异常类或者它的子类,则跳转到 code 字节数组handler_pc处继续处理。

此外,书上还给出了 code 属性结构,比较直观,有兴趣的朋友可以自行看书。

作者在第一章的最后介绍了 javap 查看类文件的使用技巧,这个互联网上有很多资料,比如:https://blog.csdn.net/jkli52051315/article/details/83943473

0x03 总结

作者第一章主要介绍了 class 文件的内部结构,收获还是挺多的,基础性的知识,学习再多也不为过

后面继续学习这本书并分享自己的学习笔记

本文参与 腾讯云自媒体分享计划,分享自作者个人站点/博客。
原始发表:2021-09-28,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 0x01 前言
  • 0x02 class 文件结构
    • 1、魔数(magic number)
      • 2、版本号(minor&major version)
        • 3、常量池(constant pool)
          • 常量池大小(cp_info_count)
          • 常量池项(cp_info)
        • 4、访问标记(access flag)
          • 5、类索引(this class)&& 超类索引(super class)&& 接口表索引(interface)
            • 6、字段表(field)
              • 7、方法表(method)
                • 8、属性表(attribute)
                • 0x03 总结
                领券
                问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档