字节码文件结构详解

“一次编写,到处运行(Write Once,Run Anywhere)“,这是 Java 诞生之时一个非常著名的口号。在学习 Java 之初,就了解到了我们所写的.java会被编译期编译成.class文件之后被 JVM 加载运行。JVM 全称为 Java Virtual Machine,一直以为 JVM 执行 Java 程序是一件理所当然的事情,但随着工作过程中接触到了越来越多的基于 JVM 实现的语言如Groovy Kotlin Scala等,就深刻的理解到了 JVM 和 Java 的无关性,JVM 运行的不是 Java 程序,而是符合 JVM 规范的.class字节码文件。字节码是各种不同平台的虚拟机与所有平台都统一使用的程序储存格式。是构成Run Anywhere 的基石。因此了解 Class 字节码文件对于我们开发、逆向都是十分有帮助的。

Class 类文件的结构

概述

Class文件是一组以 8位字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑地排列在 Class 文件中,中间没有添加任何分隔符,这使得整个 Class 文件中存储的内容几乎全部是程序运行的必要数据,没有空隙存在。当遇到需要占用 8 位字节以上空间的数据项时,则会按照Big-Endian的方式分割成若干个 8 字节进行存储。Big-Endian具体是指最高位字节在地址最低位、最低位字节在地址最高位的顺序来存储数据。SPARCPowerPC等处理器默认使用Big-Endian字节存储顺序,而x86等处理器则是使用了相反的Little-Endian顺序来存储数据。因此为了Class文件的保证平台无关性,JVM必须对其规范统一。

Class 文件结构

在讲解Class类文件结构之前需要先介绍两个概念:无符号数和表。一种类似 C 语言结构体的伪结构。

  • 无符号数:基本类型数据,一 u1、u2、u4、u8 来分别代表 1 个字节、2 个字节、4 个字节和 8 个字节的无符号数。用来描述数字、索引引用、数量值或者按照UTF-8编码构成字符串值。
  • 表:由多个无符号数或者其他表作为数据项构成的复合数据类型,所有的表都习惯以_info结尾,用于描述有层次关系的复合结构的数据。

当需要描述同一类型但数量不定的多个数据时,经常会使用一个前置的容量计数器加若干个连续的数据项的形式,这时就代表此类型的集合。整个 Class文件本质上就是一张表,其数据项如下伪代码所示:

ClassFile {
  u4              magic;
  u2              minor_version;
  u2              major_version;
  u2              constant_pool_count;
  cp_info         constant_pool[constant_pool_count-1];
  u2              access_flags;
  u2              this_class;
  u2              super_class;
  u2              interfaces_count;
  u2              interfaces[interfaces_count];
  u2              fields_count;
  field_info      fields[fields_count];
  u2              methods_count;
  method_info     methods[methods_count];
  u2              attributes_count;
  attribute_info  attributes[attributes_count];
}	

每项数据项的含义我们可以对照下图参照表:

Class类文件结构

同时我们将根据一个具体的 Java 类来分析 Class 文件结构

public class ByteCode {
    private String username;

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }
}

其.class 文件内容如下:

class文件结构

使用 javap 命令可以得到反汇编代码:

Classfile /Users/chenjianyuan/IdeaProjects/blog/blog-web/target/test-classes/tech/techstack/blog/ByteCode.class
  Last modified 2020-8-8; size 581 bytes
  MD5 checksum 43eb79f48927d9c5bbecfa5507de0f3c
  Compiled from "ByteCode.java"
public class tech.techstack.blog.ByteCode
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #4.#21         // java/lang/Object."<init>":()V
   #2 = Fieldref           #3.#22         // tech/techstack/blog/ByteCode.username:Ljava/lang/String;
   #3 = Class              #23            // tech/techstack/blog/ByteCode
   #4 = Class              #24            // java/lang/Object
   #5 = Utf8               username
   #6 = Utf8               Ljava/lang/String;
   #7 = Utf8               <init>
   #8 = Utf8               ()V
   #9 = Utf8               Code
  #10 = Utf8               LineNumberTable
  #11 = Utf8               LocalVariableTable
  #12 = Utf8               this
  #13 = Utf8               Ltech/techstack/blog/ByteCode;
  #14 = Utf8               getUsername
  #15 = Utf8               ()Ljava/lang/String;
  #16 = Utf8               setUsername
  #17 = Utf8               (Ljava/lang/String;)V
  #18 = Utf8               MethodParameters
  #19 = Utf8               SourceFile
  #20 = Utf8               ByteCode.java
  #21 = NameAndType        #7:#8          // "<init>":()V
  #22 = NameAndType        #5:#6          // username:Ljava/lang/String;
  #23 = Utf8               tech/techstack/blog/ByteCode
  #24 = Utf8               java/lang/Object
{
  public tech.techstack.blog.ByteCode();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 7: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Ltech/techstack/blog/ByteCode;

  public java.lang.String getUsername();
    descriptor: ()Ljava/lang/String;
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: getfield      #2                  // Field username:Ljava/lang/String;
         4: areturn
      LineNumberTable:
        line 11: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Ltech/techstack/blog/ByteCode;

  public void setUsername(java.lang.String);
    descriptor: (Ljava/lang/String;)V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=2, args_size=2
         0: aload_0
         1: aload_1
         2: putfield      #2                  // Field username:Ljava/lang/String;
         5: return
      LineNumberTable:
        line 15: 0
        line 16: 5
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       6     0  this   Ltech/techstack/blog/ByteCode;
            0       6     1 username   Ljava/lang/String;
    MethodParameters:
      Name                           Flags
      username
}
SourceFile: "ByteCode.java"                                   

magic

每个 Class 文件的头 4 个字节0xCAFEBABE称为魔数(Magic Number),用来确定这个文件是否为能被虚拟机接受的 Class 文件格式。

minor_version & major_version

第 5、6 个字节为次版本号(minor_version),第 6、7 个字节是主版本号(major version)上图次版本号 00 00 转换为 10 进制为 0,主版本号 00 34 转换为十进制为 52,代表 JDK 1.8。观察反汇编代码也能得到次版本和主版本信息。高版本的 JDK 向下兼容低版本的 Class 文件,但低版本不能运行高版本的 Class 文件,即使文件格式没有发生任何变化,虚拟机也拒绝执行高于其版本号的 Class 文件。

constant_pool_count & constant_pool[]

后面紧跟着的 2 个字节为常量池个数(constant_pool_count),然后后面紧跟 constant_pool_count 个数的常量。constant_pool_count 是从 1 开始而不是从 0 开始,是为了将 0 项空出来标识后面某些指向常量池的索引值的数据在特定情况下不引用常量池,这种情况下就可以把索引值置为 0 来表示。(除常量池计数外,对于其他类型集合包括接口索引集合、字段表集合、方法表集合等的容量计数都与一般习惯相同,是从0开始的)

常量池(constant_pool)主要存放两大类常量:

  • 字面量
    • 字符串常量
    • final 的常量值
    • 其他类文件的引用
  • 符号引用
    • 类和接口的全限定名
    • 字段的名称和描述符
    • 方法的名称和描述符

常量池中的每一个常量都是一个常量表,常量表开始的第一位是一个u1类型的标志位(tag),来区分常量表的类型。在JDK 1.7之前共有11种结构各不相同的表结构数据,在JDK 1.7中为了更好地支持动态语言调用,又额外增加了3种(CONSTANT_MethodHandle_info、CONSTANT_MethodType_info和CONSTANT_InvokeDynamic_info),14 中常量类型所代表的具体含义如下:

常量池的项目类型

常量池中的 14 种常量项的结构总表

我们对其按照字面量和符号引用类型分类的话可以入下图所示

常量表类型分类

Class文件中的常量池结构通过上例汇编代码可看出:

Constant pool:
   #1 = Methodref          #4.#21         // java/lang/Object."<init>":()V
   #2 = Fieldref           #3.#22         // tech/techstack/blog/ByteCode.username:Ljava/lang/String;
   #3 = Class              #23            // tech/techstack/blog/ByteCode
   #4 = Class              #24            // java/lang/Object
   #5 = Utf8               username
   #6 = Utf8               Ljava/lang/String;
   #7 = Utf8               <init>
   #8 = Utf8               ()V
   #9 = Utf8               Code
  #10 = Utf8               LineNumberTable
  #11 = Utf8               LocalVariableTable
  #12 = Utf8               this
  #13 = Utf8               Ltech/techstack/blog/ByteCode;
  #14 = Utf8               getUsername
  #15 = Utf8               ()Ljava/lang/String;
  #16 = Utf8               setUsername
  #17 = Utf8               (Ljava/lang/String;)V
  #18 = Utf8               MethodParameters
  #19 = Utf8               SourceFile
  #20 = Utf8               ByteCode.java
  #21 = NameAndType        #7:#8          // "<init>":()V
  #22 = NameAndType        #5:#6          // username:Ljava/lang/String;
  #23 = Utf8               tech/techstack/blog/ByteCode
  #24 = Utf8               java/lang/Object

观察上面Class文件00 19表示有 25 个常量,依次往后数 24(25-1)个常量则为常量池中的常量。紧随其后的一个字节为第一个常量表的 tag 位 0A -> 10,通过常量表类型查询可知 10 为 CONSTANT_Methodref_info,表内数据项为u1: tag u2: class_info u2: name_and_type_index,结合Class文件分析,这表示从第一个常量CONSTANT_Methodref_info占用 5 个字节,其中第一个字节0A为标志位,其后两个字节00 04 -> 4 之后两个字节为 class_info,紧随 2 个字节00 15 -> 21为 name_and_type_index。我们通过查询汇编代码常量池中的一个常量表为#1 = Methodref #4.#21得出一个常量表正是方法引用,其数据项索引也是#4#21。剩下的 24 种常量分析也是如此。也是因为这 14 中常量类型各自均有自己的结构,所以说常量池是最繁琐的数据。

小知识:

由于Class文件中方法、字段等都需要引用CONSTANT_Utf8_info型常量来描述名称,所以CONSTANT_Utf8_info型常量的最大长度也就是Java中方法、字段名的最大长度。而这里的最大长度就是length的最大值,既u2类型能表达的最大值65535。所以Java程序中如果定义了超过64KB英文字符的变量或方法名,将会无法编译。

access_flags

在常量池结束之后,紧接着两个字节代表访问标志(access_flag)这个标志用于识别一些类或接口层次的访问信息。具体标志位以及标志的含义见下表:

类的访问权限查询书册

invokeSpecial 指令语义在 JDK1.0.2发生过改变,为了区别这条指令使用哪种语意,在 JDK1.0.2之后编译出来的类的这个标志都必须为真。

分析[Class]文件我们得出 access_flag 为 00 21,但是查询上表确没有查询到对应的标志,这是因为 ByteCode是一个普通的 Java 类,不是接口、枚举或者注解,被public关键字修饰但没有被声明为final和abstract,并且它使用了JDK 1.2之后的编译器进行编译,因此它的ACC_PUBLIC、ACC_SUPER标志应当为真,而其余 6 个标志应当为假,因此它的access_flags的值应为:0x0001|0x0020=0x0021。而我们通过 ByteCode 汇编代码查看得到 flags: ACC_PUBLIC, ACC_SUPER 也证明了的确为上述所言。

this_class & super_class &interfaces_count & interfaces[]

类索引(this_class)、父类索引(super_class)和 接口数量(interface_count)是一个 u2类型的数据,而接口索引集合 interfaces[] 是一组 u2 类型的数据的集合。这四项数据直接确定了这个类的继承关系。Java 不允许多继承但是允许实现多个接口,这就为什么super_class是一个而 interfaces 是一个集合。我们通过分析[Class]文件可以看出 this_class 对应00 03 -> 3 从常量池中查询 #3 对应的常量

#3 = Class              #23            // tech/techstack/blog/ByteCode
#23 = Utf8               tech/techstack/blog/ByteCode

可以看出 #3 对应的就是当前类 tech/techstack/blog/ByteCode。后面同样为占两个字节的 super_class 对应的``00 04 -> 4`从常量池中查询出来对应的常量为

 #4 = Class              #24            // java/lang/Object
 #24 = Utf8               java/lang/Object

所以 super_class 表示的为:java/lang/Object。随后便是 interface_count 对应的 00 00 -> 0 说明 ByteCode 没有实现接口,因此就不存在后面的 interfaces[]。

fields_count & fields[]

字段表(field_info)用于描述接口或者类中声明的变量。字段(field)包括类级变量以及实例级变量,但不包括在方法内部声明的局部变量。fields_count 类中 field_info 的数量。fields[] 则是 field_info 的集合。field_info 的结构如下图所示:

field_info

字段修饰符 access_flag 和类中的 access_flag十分相似:

字段访问标志

在实际情况中,ACC_PUBLIC、ACC_PRIVATE、ACC_PROTECTED三个标志最多只能选择其一,ACC_FINAL、ACC_VOLATILE不能同时选择。接口之中的字段必须有ACC_PUBLIC、ACC_STATIC、ACC_FINAL标志。

继续分析Class文件,00 01 00 02 00 05 00 06 00 00。其中 00 01 -> 1表示 field_count,很显然 ByteCode 类中的字段只有一个 private String username;。 参照上表继续取两个字节00 02 -> 2表示access_flag,查询可知修饰符号为ACC_PRIVATE,继续取两个字节00 05 -> 5表示 name_index,从汇编代码中查询常量池#5为

#5 = Utf8               username	

继续取两个字节00 006 -> 6表示descriptor_index,指向的是常量池 #6 的常量

#6 = Utf8               Ljava/lang/String;

后续的 00 00 -> 0表示attribute_count的个数,此处为 0。

名词释义:

  1. 全限定名和简单名称
    把类名中的.替换成/,连续多个全限定名时,为了不产生混淆,在使用时最后一般都会加入一个;表示全限定名结束。

  2. 方法、字段索引描述

    方法的参数列表(包括数量、类型以及顺序)和返回值。根据描述符规则,基本数据类型(byte、char、double、float、int、long、short、boolean)以及代表无返回值的void类型都用一个大写字符来表示,而对象类型则用字符L加对象的全限定名来表示。

    • 基本数据类型

      B---->byte
      C---->char
      D---->double
      F----->float
      I------>int
      J------>long
      S------>short
      Z------>boolean
      V------->void

    • 对象类型

      String------>Ljava/lang/String;

    • 数组类型:每一个唯独都是用一个前置 [ 来表示

      int[] ------>[ I,

      String [][]------>[[Ljava.lang.String;

  3. 用描述符来描述方法的,先参数列表,后返回值的格式,参数列表按照严格的顺序放在()中
    比如源码 String getUserInfoByIdAndName(int id,String name) 的方法描述符(I,Ljava/lang/String;)Ljava/lang/String;

methods_count & methods[]

Class文件储存格式中对方法的描述与对字段的描述几乎采用了完全一致的方式。方法表的结构如下图所示:

Method_info

因为volatile关键字和transient关键字不能修饰方法,所以方法表的访问标志中没有了ACC_VOLATILE标志和ACC_TRANSIENT标志。与之相对的,synchronized、native、strictfp和abstract关键字可以修饰方法,所以方法表的访问标志中增加了ACC_SYNCHRONIZED、ACC_NATIVE、ACC_STRICTFP和ACC_ABSTRACT标志:

方法访问标志

同样根据Class文件进行分析。00 03表示 method_count 说明ByteCode类的方法有三个,根据Method_info继续取出第一个方法的 8 个字节00 01 00 07 00 08 00 0100 01 -> 0表示的是方法的修饰符 表示的是access_flag 为 acc_public,00 07 -> 7表示的是方法的名称(name_index) 指向常量池中#7常量

  #7 = Utf8               <init>

表示方法为<init>的构造方法。00 08 ->8代表方法的描述符号(descriptor_index),指向常量池 #8 常量

  #8 = Utf8               ()V

表示的是无参无返回值。00 01 -> 1表示有一个方法属性的个数为 1。

Method_info.attribute_info

根据 attribute_info 结构继续从Class文件中取出00 09 00 00 00 2F00 09 -> 9表示方法属性名称(attribute_name_index)指向常量池 #9 常量

  #9 = Utf8               Code

00 00 00 2F ->表示Code属性的长度为 47 个字节。(特别特别需要注意这47个字节从Code属性表中第三个开始也就是max_stack开始,因为此 attribute_info为 Code_attribute 本身,attribute_name_index 和 attribute_length 为 Code 的属性)。

Code_attribute属性表结构如下:

Code_attribute {
    u2 attribute_name_index; // 属性名索引,常量值固定为"Code"
    u4 attribute_length;  //属性值长度,值为整个表的长度减去6个字节(attribute_name_index + attribute_length)
    u2 max_stack; //操作数栈深度最大值
    u2 max_locals; //局部变量表所需的存储空间,单位为"Slot",Slot是虚拟机为局部变量分配内存所使用的最小的单位。
    u4 code_length; // 存储Java源程序编译后生成的字节码指令,每个指令为u1类型的单字节。虚拟机规范中明确限制了一个方法不允许超过65535条字节指令,实际上只用了u2长度。
    u1 code[code_length]; // 方法指向的具体指令码
    u2 exception_table_length; // 异常表的个数
    {   u2 start_pc; // start_pc 和 end_pc 表示在 Code 数组中的[start_pc, end_pc)处指令所抛出的异常由这个表处理。
        u2 end_pc;
        u2 handler_pc; // 异常代码的开始处
        u2 catch_type; // 表示被处理流程的异常类型,指向常量池中具体的某一个异常类,catchType为 0 处理所有的异常
    } exception_table[exception_table_length]; // 异常表结构,用于存放异常信息
    u2 attributes_count; // 属性的个数
    attribute_info attributes[attributes_count]; // 属性的集合
}

第一个 Code 的汇编代码如下:

Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 7: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Ltech/techstack/blog/ByteCode;

Tips: args_size=1是因为在任何实例方法里面,都可以通过"this"关键字访问到此方法所属的对象。这个访问机制对Java程序的编写很重要,而它的实现却非常简单,仅仅是通过Javac编译器编译的时候把对this关键字的访问转变为对一个普通方法参数的访问,然后在虚拟机调用实例方法时自动传入此参数而已。因此在实例方法的局部变量表中至少会存在一个指向当前对象实例的局部变量,局部变量表中也会预留出第一个Slot位来存放对象实例的引用,方法参数值从1开始计算。

回到示例代码,取出 47 位 Code 值:

// _ 是本文自行添加方便表示数据项之间的间隔,Class 文件中是不存在的
00 01  _00 01 _00 00 00 05  _2A B7 00 01 B1 _00 00  _00 02  _00 0A  _00 00 00 06  _00 01  _00 00  _00 06  _00 0B  _00 00 00 0C  _00 01 00 00 00 05 00 0C 00 0D 00 00

00 01 -> 1 表示 操作数栈(max_stack)的最大深度为 1。后面的00 01 -> 1表示局部变量表的长度(max_locals)为 1,正好与 Code 的汇编代码stack=1 locals=1对应。紧接着后面 4 位00 00 00 05 -> 5表示字节码指令长度(code_length)为 5。继续往后数 5 位2A B7 00 01 B1表示 JVM具体的字节码指令。

 0: aload_0
 1: invokespecial #1                  // Method java/lang/Object."<init>":()V
 4: return
  1. 0x2A:对应的字节码注记符是aload_0,作用就是把当前调用方法的栈帧中的局部变量表索引位置为0的局部变量推送到操作数栈的栈顶。

  2. 0xB7:表示是 invokespecial 调用父类的方法 那么后面需要接入二个字节表示调用哪个方法,所以00 01表示的是指向常量池中第一个位置为为如下结构

      1: invokespecial #1                  // Method java/lang/Object."<init>":()V
    
  3. 0xB1:对应的字节码指令值retrun `表示retrun void from method。

00 00表示异常表个数(exception_table_length)为 0,方法没有抛出异常。

00 02 -> 2表示 Code_attribute 结构中属性表的个数为 2 个。00 0A -> 10表示 attribute_name_index 指向常量池 #10 LineNumberTable 常量。继续后面 4 位00 00 00 06 -> 10 表示 attribute_length 即 LineNumberTable 的长度。LineNumberTable 是用来描述Java源码行号与字节码行号(字节码偏移量)之间的对应关系,比如我们平时 debug 某一行代码。其结构如下所示:

LineNumberTable_attribute {
    u2 attribute_name_index;
    u4 attribute_length;
    u2 line_number_table_length;
    {   u2 start_pc;
        u2 line_number;	
    } line_number_table[line_number_table_length];
}

00 01 -> 1表示行号表的个数为 1,即只存在一个行号表。00 00 表示start_pc为字节码行号,00 06 -> 6表示源码行号为第 7(6+1) 行。

00 0B -> 11表示第二个属性表对应常量池 #11 LocalVariableTable 常量。00 00 00 0C -> 12 表示 LocalVariableTable 常量的长度为 12。LocalVariableTable 属性用于描述栈帧中局部变量表中的变量与Java源码中定义的变量之间的关系。其结构如下:

LocalVariableTable_attribute {
    u2 attribute_name_index;
    u4 attribute_length;
    u2 local_variable_table_length;
    {   u2 start_pc;
        u2 length;
        u2 name_index;
        u2 descriptor_index;
        u2 index;
    } local_variable_table[local_variable_table_length];
}

LocalVariableTable也不是运行时必需的属性,但默认会生成到Class文件之中,可以在Javac中分别使用-g:none-g:vars选项来取消或要求生成这项信息。如果没有生成这项属性,最大的影响就是当其他人引用这个方法时,所有的参数名称都将会丢失,IDE将会使用诸如arg0、arg1之类的占位符代替原有的参数名,这对程序运行没有影响,但是会对代码编写带来较大不便,而且在调试期间无法根据参数名称从上下文中获得参数值。

00 01 -> 1表示本地变量表的个数 local_variable_table_length 为 1。00 00表示local_variable_table 的 start_pc 为 0,其含义为这个局部变量的生命周期开始的字节码偏移量。00 05 -> 5 表示 local_variable_table 的 length 为 5,其含义为这个局部变量作用范围覆盖的长度。两者结合起来就是这个局部变量在字节码之中的作用域范围。00 0C 00 0D分别表示 name_index 和 descriptor_index,分别指向常量池中 #12 this 和 #13 Ltech/techstack/blog/ByteCode;常量。分别代表了局部变量的名称以及这个局部变量的描述符。00 00 表示了这个变量在本地变量表中的index 即这个局部变量在栈帧局部变量表中Slot的位置。当这个变量数据类型是64位类型时(double和long),它占用的Slot为index和index+1两个。

attributes_count & attributes[]

属性表(attribute_info)用于描述某些场景专有的信息。在Class文件、字段表、方法表都可以携带自己的属性表集合。所有的属性都具有一下常规格式:

attribute_info { 
    u2 attribute_name_index; 
    u4 attribute_length; 
    u1 info [attribute_length]; 
}

根据The Java® Virtual Machine Specification已经增加到了 23 项。根据其用途可以分为三组:

  1. 五个属性对于classJava虚拟机正确解释文件至关重要 :

  2. 十二个属性对于Java SE平台的类库正确解释class文件至关重要 :

  3. 六个属性对于classJava虚拟机或Java SE平台的类库对文件的正确解释不是至关重要的 ,但对于工具来说非常有用:

属性汇总

按文件位置排序预定义属性

参考:

[1] 周志明.深入理解Java虚拟机:JVM高级特性与最佳实践.北京:机械工业出版社,2013.

[2] Chapter 4. Th class File Format

[3] Chapter 6. The Java Virtual Machine Instruction Set

文章首发于陈建源的博客,欢迎访问。
文章作者:陈建源
文章链接:https://www.techstack.tech/post/zi-jie-ma-wen-jian-jie-gou-xiang-jie/

posted @ 2020-08-14 15:51  41uLove  阅读(975)  评论(2编辑  收藏  举报