ASM简介(四)

函数

我们在使用ASM相关API对函数进行操作之前,我们需要了解函数在字节码的存储格式及其执行模型。

执行模型

我们需要简单了解Java虚拟机的执行模型。Java代码是在线程中执行的,每个Java程序可能包含多个执行线程,而每个线程都有一个执行栈。这些执行栈是由许多frame(帧)组成的,每一个frame都代表一个函数调用。每当一个函数被调用的时候,一个frame就被推到执行线程的栈顶。当函数执行完毕(无论是正常还是异常返回)这个frame就会弹栈,然后执行下一个frame.
每一个frame都包含两个部分,一个本地的符号表和一个操作栈。符号表存储了可以被自由取值的变量,操作栈主要用于在执行时存储字节码指令。本地符号表大小及操作栈大小是由函数本身大小决定的。因此二者是在编译期由编译器计算并存储在字节码中的。
当每个frame创建的时候,其操作栈是空的,符号表由this对象和函数参数组成。符号表和操作栈中每一个item都可以防止任意的除long和double以外的Java对象。这是因为long和double需要两个item才能存下。

字节码指令

字节码指令是由一个opcode和几个固定的参数组成
opcode 是无符号byte类型。因此一般用助记符代替,例如一般使用NOP代替0.参数一般是静态的值,用于说明opcode的操作对象。参数是静态存储在class文件里的。opcode只有运行时才知道。
总体来说opcode主要分为两大类,其中一小部分主要用于将数据由符号表推送到操作栈或者相反。其余的指令只是操作操作栈上的对象。他们可以从操作栈弹出几个对象,根据这些对象计算出一些值,然后将结果再送回操作栈。
ILOAD LLOAD FLOAD DLOAD ALOAD 指令用于将符号表中的变量推动到操作栈上。他们接收变量在符号表的索引值。ILOAD用于操作boolean byte char short int. LOAD ,FLOAD DLOAD用于long,float,double的操作。ALOD主要用于非基本数据类型的操作。对应 ISTORE,LSTORE,FSTORE,DSTORE ASTORE 从操作站弹出并将其存储在符号表。字节码指令基本都是类型相关的。字节码指令一般可以分为:

  • Stack(栈操作相关) 主要用于操作操作栈上的数据:POP,DUP(push复制栈顶的数据)SWAP(交换栈顶的两个元素)
  • Constants(常量相关) 将一个常量push到栈顶ACONST_NULL(pushes null), ICONST_0(push 0) FCONST_0(push 0f) DCONST_0 BIPUSH b(push byte 类型的 b)SIPUSH s(push short 类型 s) LDC(push 任意类型 int float long double String 或者是class 常量等)
  • Arithmentic and logic(逻辑运算相关) 弹出栈顶几个元素进行运算将结果push到栈顶。xADD xSUB xDIV xREM 分别代表 + - * / % 运算。x可以是‘I’ ‘L’ 'F' 'D' 类似还有和<< >> >>> | ^ & 等相对应的指令。
  • Casts(类型转换相关) 这些指令将栈顶元素弹出,转换类型后入栈。它和java中类型转换相对应。I2F F2D L2D 等 CHECKCAST t将一个引用类型的对象转换为t类型
  • Objects(对象相关)这些指令用于创建对象,锁定对象,检查类型等 NEW type 会将一个type类型的对象入栈。
  • Fields(取值赋值相关) GETFIELD owner name desc 弹出对象引用,将其name对应的变量的值入栈。PUTFIELD将对象弹栈将其值存储在name对应的存储区。这两个指令中弹出的对象必须是owner类型,field的类型必须是desc类型。GETSTATIC和PUTSTATIC是类似的。不过其用于操作静态变量。
  • Methods(方法相关) 这些指令可以调用一个函数或者构造函数。他们会弹出函数参数的个数加上1(调用者对象)个对象,将函数结果入栈。INVOKEVIRTUAL owner name desc 会调用定义在owner类中函数签名为desc名称为name的函数。INVOKESTATIC 用于调用静态方法,INVOKESPECIAL 用于private及构造函数。INVOKEINTERFACE 用于调用定义在接口中的方法。对于java7而言INVOKEDYNAMIC 是用于动态方法调用。
  • ARRAYS(数组相关) 这些指令主要用于读写数组。xALOAD指令会弹出索引和数组,并且将数组中索引对应的值入栈。xSTORE会弹出一个值,索引和数组,将值存储在数组的索引位置。x可以是I L F D A B C S
  • Jumps(跳转指令) 这些指令会在指定的条件为true或者false的时候跳转到任意指定的指令接着执行。他们对应于高级语言的if for do while break continue 等流程控制语句。IFEQ label会弹出int值,如果该值为0就跳转到label指定的指令。其他的跳转指令也类似:IFNE IFGE ... TABLESWITCH LOOKUPSWITCH 对应于java语言中switch语句。
  • Return(返回)xRETURN 和 RETURN指令被用于终止函数的执行,返回相关值给函数调用者。RETURN对应于 return void, xRETURN用于返回其他值。

对于以下简单的函数,其对应的指令如下:

package pkg;
public class Bean {
    private int f;
    public int getF() {
        return this.f;
    }
    public void setF(int f) {
        this.f = f;
    }
}


 // getF函数对应的指令如下
 ALOAD 0   //将this入栈 
 GETFIELD pkg/Bean f I //this弹出,将this.f入栈
         IRETURN // 返回 this.f
 // setF函数对应的指令      
 ALOAD 0  // this 入栈
 ILOAD 1  // 将索引为1(类型为int)的变量入栈
 PUTFIELD pkg/Bean f I // 弹出两个值,并且将栈顶的元素及this弹出,并将其值赋给this.f
         RETURN  // RETURN

Bean对象会有默认的构造函数,其对应的指令为:ALOAD 0 INVOKESPECIAL java/lang/Object <init> ()V RETURN
构造函数在字节码中的名称为<init>
一个复杂的例子如下:

public void checkAndSetF(int f) {
    if (f >= 0) {
        this.f = f;
    } else {
        throw new IllegalArgumentException();
    }
}
// 其对应的字节码指令
ILOAD 1 // f入栈
IFLT label // f弹栈,如果小于0,跳转到label
ALOAD 0 // this 入栈
ILOAD 1 // f入栈
PUTFIELD pkg/Bean f I // this.f = f
GOTO end //跳转到end
label:
NEW java/lang/IllegalArgumentException //生成IllegalArgumentException对象
        DUP // 赋值对象
INVOKESPECIAL java/lang/IllegalArgumentException <init> ()V // 弹栈并调用初始化方法
        ATHROW // 抛出栈底异常,指令执行结束
end:
RETURN

异常处理

并没有catch对应的字节码指令,不过函数会和一系列exception handler(异常处理代码)相关联。当抛出指定的异常时对应的handler代码就会执行。因此exception handler就和try catch代码块类似。

public static void sleep(long d) {
    try {
        Thread.sleep(d);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}

//对应的指令如下

TRYCATCHBLOCK try catch catch java/lang/InterruptedException // 声明handler,如果try: 和 catch: 之间代码发生异常,则生成InterruptedException实例,跳转到catch:
try:
LLOAD 0
INVOKESTATIC java/lang/Thread sleep (J)V
RETURN
catch:
INVOKEVIRTUAL java/lang/InterruptedException printStackTrace ()V
RETURN

Frames(帧)
Java6及以上版本编译的class文件,包含一系列stack map frames来加速jvm对class的校验。它们甚至在运行前就能告知jvm某个frame的符号表及操作栈的详细信息。为此,我们可以为frame中的每一个指令创建一个frame来查看其运行时的状态

//运行前的state frame     对应的指令
[pkg/Bean] []           ALOAD 0
[pkg/Bean] [pkg/Bean]   GETFIELD
[pkg/Bean] [I]          IRETURN

//对于 throw new IllegalArgumentException的代码:
[pkg/Bean I] []                                             NEW
[pkg/Bean I] [Uninitialized(label)]                         DUP
[pkg/Bean I] [Uninitialized(label) Uninitialized(label)]    INVOKESPECIAL
[pkg/Bean I] [java/lang/IllegalArgumentException]           ATHROW

上述的Uninitialized只存在于stack map frame中,代表内存分配完毕但是构造函数还没调用。UNINITIALIZED_THIS 代表被初始化为0 TOP代表未定义类型 NULL代表null
对于编译后的class为了节省空间,实际上并不是每一个指令都对应一个state frame,而是只有跳转指令和异常处理handler 和无条件跳转后面的第一个指令包含state frame. 而其他指令可以从已有的state frames推断出来。为了进一步节省空间,每一个frame只有在和上一个frame不同的时候才会被存储。初始帧由于可以很容易从函数参数中推断,因此不会存储,而后续的帧如果和初始帧相同只需存储 F_SAME即可

ILOAD 1
IFLT label
ALOAD 0
ILOAD 1
PUTFIELD pkg/Bean f I
GOTO end
label:
F_SAME
NEW java/lang/IllegalArgumentException
        DUP
INVOKESPECIAL java/lang/IllegalArgumentException <init> ()V
        ATHROW
end:
F_SAME
        RETURN

接口及组件

函数的生成改写需要使用MethodVisitor(在ClassVisitor的visitMethod函数中返回),生成函数时各个部分的调用顺序也是固定的:

visitAnnotationDefault?
        ( visitAnnotation | visitParameterAnnotation | visitAttribute )*
        ( visitCode
        ( visitTryCatchBlock | visitLabel | visitFrame | visitXxxInsn |
                visitLocalVariable | visitLineNumber )*
visitMaxs )?
visitEnd

注解和参数需要首先被生成,然后是方法体,最后需要调用visitMax。 visitCode和visitMax可以看做函数体的开始和结束。最后需要调用visitEnd代表事件结束。

ClassVisitor cv = ...;
cv.visit(...);
MethodVisitor mv1 = cv.visitMethod(..., "m1", ...);
mv1.visitCode();
mv1.visitInsn(...);
...
        mv1.visitMaxs(...);
mv1.visitEnd();
MethodVisitor mv2 = cv.visitMethod(..., "m2", ...);
mv2.visitCode();
mv2.visitInsn(...);
...
        mv2.visitMaxs(...);
mv2.visitEnd();
cv.visitEnd();

于我们而言计算某个方法的stack frame绝非易事。幸好ASM可以帮我们计算,当你声明一个ClassWriter的时候你可以声明让ASM自动计算这些值。例如:new ClassWriter(ClassWriter.COMPUTE_MAX) 符号表及操作栈的大小会自动帮你计算。但你仍然需要调用visitMax,此时你传什么值都可以(它们会被忽略),但你仍要自己计算frames。而new ClassWriter(ClassWriter.COMPUTE_FRAMES) 所有的都会自动被计算,但仍要调用visitMax.但是COMPUTE_MAX会使得ClassWriter慢10%左右,而COMPUTE_FRAMES会慢一倍。

//生成getF的代码如下
mv.visitCode();
mv.visitVarInsn(ALOAD, 0);
mv.visitFieldInsn(GETFIELD, "pkg/Bean", "f", "I");
mv.visitInsn(IRETURN);
mv.visitMaxs(1, 1);
mv.visitEnd();

//
mv.visitCode();
mv.visitVarInsn(ILOAD, 1);
Label label = new Label();
mv.visitJumpInsn(IFLT, label);
mv.visitVarInsn(ALOAD, 0);
mv.visitVarInsn(ILOAD, 1);
mv.visitFieldInsn(PUTFIELD, "pkg/Bean", "f", "I");
Label end = new Label(); // 这里又创建一个label,虽然不创建直接用前一个label也合法,不过最好和字节码保持一致,这样更清晰
mv.visitJumpInsn(GOTO, end);
mv.visitLabel(label);
mv.visitFrame(F_SAME, 0, null, 0, null);
mv.visitTypeInsn(NEW, "java/lang/IllegalArgumentException");
mv.visitInsn(DUP);
mv.visitMethodInsn(INVOKESPECIAL,
        "java/lang/IllegalArgumentException", "<init>", "()V");
mv.visitInsn(ATHROW);
mv.visitLabel(end);
mv.visitFrame(F_SAME, 0, null, 0, null);
mv.visitInsn(RETURN);
mv.visitMaxs(2, 2);
mv.visitEnd();

函数也可以像类那样被修改,我们可以直接使用MethodVisitor即可。而MethodVisitor可以包含多分枝:

public MethodVisitor visitMethod(int access, String name,
                                 String desc, String signature, String[] exceptions) {
    MethodVisitor mv1, mv2;
    mv1 = cv.visitMethod(access, name, desc, signature, exceptions);
    mv2 = cv.visitMethod(access, "_" + name, desc, signature, exceptions);
    return new MultiMethodAdapter(mv1, mv2);
}  

测量类中所有方法的执行时间

public class C {
    public static long timer;
    public void m() throws Exception {
        timer -= System.currentTimeMillis();
        Thread.sleep(100);
        timer += System.currentTimeMillis();
    }
}

我们可以使用TraceClassVisitor来查看该类生成的字节码:使用Textifier或者使用ASMifier

GETSTATIC C.timer : J
INVOKESTATIC java/lang/System.currentTimeMillis()J
        LSUB
PUTSTATIC C.timer : J
LDC 100
INVOKESTATIC java/lang/Thread.sleep(J)V
GETSTATIC C.timer : J
INVOKESTATIC java/lang/System.currentTimeMillis()J
        LADD
PUTSTATIC C.timer : J
        RETURN
MAXSTACK = 4
MAXLOCALS = 1

我们看到我们需要在函数开始的时候插入4行代码结束的时候插入另外4行代码。我们还需要更新最大操作栈的大小,因此我们在visitCode中添加如下代码

public void visitCode() {
    mv.visitCode();
    mv.visitFieldInsn(GETSTATIC, owner, "timer", "J");
    mv.visitMethodInsn(INVOKESTATIC, "java/lang/System",
            "currentTimeMillis", "()J");
    mv.visitInsn(LSUB);
    mv.visitFieldInsn(PUTSTATIC, owner, "timer", "J");
}

我们需要添加另外4条指令,在return或者xRETURN、ATHROW之前。这些指令都没有参数,都是使用visitInsn方法访问的

  public void visitInsn(int opcode) {
    if ((opcode >= IRETURN && opcode <= RETURN) || opcode == ATHROW) {
        mv.visitFieldInsn(GETSTATIC, owner, "timer", "J");
        mv.visitMethodInsn(INVOKESTATIC, "java/lang/System",
                "currentTimeMillis", "()J");
        mv.visitInsn(LADD);
        mv.visitFieldInsn(PUTSTATIC, owner, "timer", "J");
    }
    mv.visitInsn(opcode);
}

最后我们需要更新最大操作栈的大小。由于我们向操作栈添加了两个long型变量,因此最坏情况maxsize+4

public void visitMaxs(int maxStack, int maxLocals) {
    mv.visitMaxs(maxStack + 4, maxLocals);
}

当然我们也可以依赖COMPUTE_MAX来计算。
我们的ClassVisitor可以这样来写:

public class AddTimerAdapter extends ClassVisitor {
    private String owner;
    private boolean isInterface;

    public AddTimerAdapter(ClassVisitor cv) {
        super(ASM4, cv);
    }

    @Override
    public void visit(int version, int access, String name,
                      String signature, String superName, String[] interfaces) {
        cv.visit(version, access, name, signature, superName, interfaces);
        owner = name;
        isInterface = (access & ACC_INTERFACE) != 0;
    }

    @Override
    public MethodVisitor visitMethod(int access, String name,
                                     String desc, String signature, String[] exceptions) {
        MethodVisitor mv = cv.visitMethod(access, name, desc, signature,
                exceptions);
        if (!isInterface && mv != null && !name.equals("<init>")) {
            mv = new AddTimerMethodAdapter(mv);
        }
        return mv;
    }

    @Override
    public void visitEnd() {
        if (!isInterface) {
            FieldVisitor fv = cv.visitField(ACC_PUBLIC + ACC_STATIC, "timer",
                    "J", null, null);
            if (fv != null) {
                fv.visitEnd();
            }
        }
        cv.visitEnd();
    }
}

对于label和frames而言,我们知道visitLabel的调用是在其关联代码前的。如果代码跳转到ICONST_0 IADD ,当我们把这两个指令删除后跳转后直接执行后续的代码,这正是我们期望的。而如果是跳转到IADD 我们就不能直接删除相关指令了,不过这样的话ICONST_0和IADD之前肯定有一个label.如果在两个指令之间我们访问了stack map frame, 我们也不能删除这些指令。这些case都可以通过把label和frame当做需要match的指令(我们要记录指令出现的位置及后续指令的状态)来处理。编译后的class文件同时包含了其对应源代码中的行号信息用于异常栈的处理。
很多时候,我们需要记录某一指令调用时所处的状态才能识别固定的pattern,例如如果我们想找出class中自赋值语句(f=f this.f = this.f)等就需要记录ALOAD 0的状态

class RemoveGetFieldPutFieldAdapter extends PatternMethodAdapter {
    private final static int SEEN_ALOAD_0 = 1;
    private final static int SEEN_ALOAD_0ALOAD_0 = 2;
    private final static int SEEN_ALOAD_0ALOAD_0GETFIELD = 3;
    private String fieldOwner;
    private String fieldName;
    private String fieldDesc;

    public RemoveGetFieldPutFieldAdapter(MethodVisitor mv) {
        super(mv);
    }

    @Override
    public void visitVarInsn(int opcode, int var) {
        switch (state) {
            case SEEN_NOTHING: // S0 -> S1
                if (opcode == ALOAD && var == 0) {
                    state = SEEN_ALOAD_0;
                    return;
                }
                break;
            case SEEN_ALOAD_0: // S1 -> S2
                if (opcode == ALOAD && var == 0) {
                    state = SEEN_ALOAD_0ALOAD_0;
                    return;
                }
                break;
            case SEEN_ALOAD_0ALOAD_0: // S2 -> S2
                if (opcode == ALOAD && var == 0) {
                    mv.visitVarInsn(ALOAD, 0);
                    return;
                }
                break;
        }
        visitInsn();
        mv.visitVarInsn(opcode, var);
    }

    @Override
    public void visitFieldInsn(int opcode, String owner, String name,
                               String desc) {
        switch (state) {
            case SEEN_ALOAD_0ALOAD_0: // S2 -> S3
                if (opcode == GETFIELD) {
                    state = SEEN_ALOAD_0ALOAD_0GETFIELD;
                    fieldOwner = owner;
                    fieldName = name;
                    fieldDesc = desc;
                    return;
                }
                break;
            case SEEN_ALOAD_0ALOAD_0GETFIELD: // S3 -> S0
                if (opcode == PUTFIELD && name.equals(fieldName)) {
                    state = SEEN_NOTHING;
                    return;
                }
                break;
        }
        visitInsn();
        mv.visitFieldInsn(opcode, owner, name, desc);
    }

    @Override
    protected void visitInsn() {
        switch (state) {
            case SEEN_ALOAD_0: // S1 -> S0
                mv.visitVarInsn(ALOAD, 0);
                break;
            case SEEN_ALOAD_0ALOAD_0: // S2 -> S0
                mv.visitVarInsn(ALOAD, 0);
                mv.visitVarInsn(ALOAD, 0);
                break;
            case SEEN_ALOAD_0ALOAD_0GETFIELD: // S3 -> S0
                mv.visitVarInsn(ALOAD, 0);
                mv.visitVarInsn(ALOAD, 0);
                mv.visitFieldInsn(GETFIELD, fieldOwner, fieldName, fieldDesc);
                break;
        }
        state = SEEN_NOTHING;
    }
}

工具类

ClassVisitor中介绍的工具类在这里仍然是适用的。

  • Type xLOAD xADD xRETURN 均是一些类型相关的指令,而Type提供了getOpcode可以用于获取适当的指令。t.getOpcode(IMUL)会返回FMUL如果t是Type.FLOAT_TYPE

  • TraceClassVisitor 和之前用法一样,不过如若你指向打印某个方法的指令,你可以使用TraceMethodVisitor

    public MethodVisitor visitMethod(int access, String name,
                                   String desc, String signature, String[] exceptions) {
      MethodVisitor mv = cv.visitMethod(access, name, desc, signature,
              exceptions);
      if (debug && mv != null && ...) { // if this method must be traced
          Printer p = new Textifier(ASM4) {
              @Override public void visitMethodEnd() {
                  print(aPrintWriter); // print it after it has been visited
              }
          };
          mv = new TraceMethodVisitor(mv, p);
      }
      return new MyMethodAdapter(mv);
    }
    
  • CheckClassAdapter,同样你也可以选择CheckMethodAdapter

  • ASMifier 可以用于生成产生某个类的ASM代码

  • AnalyzerAdapter 这个adapter主要用于计算stack map frames,其基于visitFrame.它帮助我们做frame的压缩,包括删除可快速推断出来的及重复的。

  • LocalVariablesSorter 这个adapter会重新计算本地符号表的大小。

  • AdviceAdapter 当需要在函数开始或者结束的时候插入代码可以使用这个Adapter。它会自动处理构造函数构造函数开始到调用完super是不允许插入代码的。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,064评论 5 466
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,606评论 2 376
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,011评论 0 328
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,550评论 1 269
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,465评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 47,919评论 1 275
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,428评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,075评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,208评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,185评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,191评论 1 328
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,914评论 3 316
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,482评论 3 302
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,585评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,825评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,194评论 2 344
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,703评论 2 339

推荐阅读更多精彩内容