Lab1

清华实验lab1

本markdown遵循markdown plus与简书与Typora编辑器规则

若需要使用目录,请使用markdown plus或Typora

markdwon plus 在线编辑器

[TOC]

练习1

理解通过 make 生成执行文件的过程。(要求在报告中写出对下述问题的回答)
在此练习中,大家需要通过阅读代码来了解:

  1. 操作系统镜像文件 ucore.img 是如何一步一步生成的?(需要比较详细地解释 Makefile 中
    每一条相关命令和命令参数的含义,以及说明命令导致的结果)
  2. 一个被系统认为是符合规范的硬盘主引导扇区的特征是什么?

练习1.1

1. 生成ucore.img

查看makefile源码,在178行处有注释:create ucore.img
以下是此部分代码

UCOREIMG    := $(call totarget,ucore.img)

$(UCOREIMG): $(kernel) $(bootblock)
    $(V)dd if=/dev/zero of=$@ count=10000
    $(V)dd if=$(bootblock) of=$@ conv=notrunc
    $(V)dd if=$(kernel) of=$@ seek=1 conv=notrunc

$(call create_target,ucore.img)

此处参考dd命令参数 " Linux 下的dd命令使用详解 "可知道,创建了一块10000字节的块,并且将bootblock复制过去。并且把kernel接在之后的位置。(有seek = 1可知 复制的时候从文件开始跳过一个块,再存放。但此处bootblock为什么只占了一个块,会不会发生文件覆写 存疑)。

2. 生成kernel

可以看到上文的依赖中有kernel,搜索全文得到

# create kernel target
kernel = $(call totarget,kernel)

$(kernel): tools/kernel.ld

$(kernel): $(KOBJS)
    @echo + ld $@
    $(V)$(LD) $(LDFLAGS) -T tools/kernel.ld -o $@ $(KOBJS)
    @$(OBJDUMP) -S $@ > $(call asmfile,kernel)
    @$(OBJDUMP) -t $@ | $(SED) '1,/SYMBOL TABLE/d; s/ .* / /; /^$$/d' > $(call symfile,kernel)

$(call create_target,kernel)

此处涉及到tools/kernel.ld文件,可运行查看make指令的实际样子

+ ld bin/kernel
ld -m    elf_i386 -nostdlib -T tools/kernel.ld -o bin/kernel  obj/kern/init/init.o obj/kern/libs/readline.o obj/kern/libs/stdio.o obj/kern/debug/kdebug.o obj/kern/debug/kmonitor.o obj/kern/debug/panic.o obj/kern/driver/clock.o obj/kern/driver/console.o obj/kern/driver/intr.o obj/kern/driver/picirq.o obj/kern/trap/trap.o obj/kern/trap/trapentry.o obj/kern/trap/vectors.o obj/kern/mm/pmm.o  obj/libs/printfmt.o obj/libs/string.o

可以得出结论,此处链接的是如上的文件,也就是kern目录下的全部文件编译而成的文件。

gcc -Ikern/init/ -fno-builtin -Wall -ggdb -m32 -gstabs -nostdinc -fno-stack-protector
此处的全部文件使用类如上格式编译 相关的编译参数有

  • 不适用c语言的内建函数,解决函数名冲突的情况
  • 打开警告开关
  • 生成gdb调试信息
  • 生成32位环境代码
  • 生成stabs格式的调试信息
  • 不使用标准库,内核代码不需要标准io
  • 禁用堆栈保护,这一条的作用如下 from stackoverflow ,并没有读的很懂
    (In the standard/stock GCC, stack protector is off by default. However, some Linux distributions have patched GCC to turn it on by default. In my opinion, this is rather harmful, as it breaks the ability to compile anything that's not linked against the standard userspace libraries unless the Makefile specifically disables stack protector. It would even break the Linux kernel build except that the distributions with this hack added additional hacks to GCC to detect that the kernel is being built and disable it.)

3.生成bootblock

bootfiles = $(call listf_cc,boot)
$(foreach f,$(bootfiles),$(call cc_compile,$(f),$(CC),$(CFLAGS) -Os -nostdinc))

bootblock = $(call totarget,bootblock)

$(bootblock): $(call toobj,$(bootfiles)) | $(call totarget,sign)
    @echo + ld $@
    $(V)$(LD) $(LDFLAGS) -N -e start -Ttext 0x7C00 $^ -o $(call toobj,bootblock)
    @$(OBJDUMP) -S $(call objfile,bootblock) > $(call asmfile,bootblock)
    @$(OBJCOPY) -S -O binary $(call objfile,bootblock) $(call outfile,bootblock)
    @$(call totarget,sign) $(call outfile,bootblock) $(bootblock)

$(call create_target,bootblock)

代码如上,一样通过运行来查看实际的运行情况

ld -m    elf_i386 -nostdlib -N -e start -Ttext 0x7C00 obj/boot/bootasm.o obj/boot/bootmain.o -o obj/bootblock.o

此处有一个用sign规范bootblock.o到bin/block.o的操作

'obj/bootblock.out' size: 472 bytes
build 512 bytes boot sector: 'bin/bootblock' success!
dd if=/dev/zero of=bin/ucore.img count=10000
10000+0 records in
10000+0 records out
5120000 bytes (5.1 MB) copied, 0.0237835 s, 215 MB/s

猜想是规范为 "符合规范的硬盘主引导扇区文件"

以上就是makefile所做的事情,下面加上一张图以示

makefile编译流程.jpg


练习1.2

根据刚刚的猜想,阅读sign.c文件

char buf[512];
memset(buf, 0, sizeof(buf));    
···
buf[510] = 0x55;
buf[511] = 0xAA;

这三行代码表明了 主引导扇区

  • 大小为512字节
  • 最后的两位为AA55(小端机,低位在前)
  • 初始化为全零

练习2,练习3

  1. 输入make debug进入调试界面
  2. 输入 b *0x7c00设置断点
  3. continue运行
  4. 查看接下的指令,与boot/bootasm.S和bootblock.asm里的内容十分类似
start:
.code16                                             # Assemble for 16-bit mode
    cli                                             # Disable interrupts
    cld                                             # String operations increment

    # Set up the important data segment registers (DS, ES, SS).
    xorw %ax, %ax                                   # Segment number zero
    movw %ax, %ds                                   # -> Data Segment
    movw %ax, %es                                   # -> Extra Segment
    movw %ax, %ss                                   # -> Stack Segment

    # Enable A20:
    #  For backwards compatibility with the earliest PCs, physical
    #  address line 20 is tied low, so that addresses higher than
    #  1MB wrap around to zero by default. This code undoes this.
seta20.1:
    inb $0x64, %al                                  # Wait for not busy(8042 input buffer empty).
    testb $0x2, %al
    jnz seta20.1

    movb $0xd1, %al                                 # 0xd1 -> port 0x64
    outb %al, $0x64                                 # 0xd1 means: write data to 8042's P2 port

seta20.2:
    inb $0x64, %al                                  # Wait for not busy(8042 input buffer empty).
    testb $0x2, %al
    jnz seta20.2

    movb $0xdf, %al                                 # 0xdf -> port 0x60
    outb %al, $0x60                                 # 0xdf = 11011111, means set P2's A20 bit(the 1 bit) to 1

    # Switch from real to protected mode, using a bootstrap GDT
    # and segment translation that makes virtual addresses
    # identical to physical addresses, so that the
    # effective memory map does not change during the switch.
    lgdt gdtdesc
    movl %cr0, %eax
    orl $CR0_PE_ON, %eax
    movl %eax, %cr0

    # Jump to next instruction, but in 32-bit code segment.
    # Switches processor into 32-bit mode.
    ljmp $PROT_MODE_CSEG, $protcseg

.code32                                             # Assemble for 32-bit mode
protcseg:
    # Set up the protected-mode data segment registers
    movw $PROT_MODE_DSEG, %ax                       # Our data segment selector
    movw %ax, %ds                                   # -> DS: Data Segment
    movw %ax, %es                                   # -> ES: Extra Segment
    movw %ax, %fs                                   # -> FS
    movw %ax, %gs                                   # -> GS
    movw %ax, %ss                                   # -> SS: Stack Segment

    # Set up the stack pointer and call into C. The stack region is from 0--start(0x7c00)
    movl $0x0, %ebp
    movl $start, %esp
    call bootmain

    # If bootmain returns (it shouldn't), loop.
spin:
    jmp spin

# Bootstrap GDT
.p2align 2                                          # force 4 byte alignment
gdt:
    SEG_NULLASM                                     # null seg
    SEG_ASM(STA_X|STA_R, 0x0, 0xffffffff)           # code seg for bootloader and kernel
    SEG_ASM(STA_W, 0x0, 0xffffffff)                 # data seg for bootloader and kernel

gdtdesc:
    .word 0x17                                      # sizeof(gdt) - 1
    .long gdt                                       # address gdt

代码后面基本上都带有注释,在此处总结一下流程

  1. 禁止中断
  2. 复位标志寄存器方向标志位
  3. 初始化ds,es, ss三个段(设置为0)
  4. 使能A20(扩大寻址空间从1M)
  5. 跳转到gdtdes中,加载GDT(全局描述符表)
  6. 使能cr0,切换到保护模式
  7. 切换到32位模式
  8. 修改保护模式下各个寄存器的值(0x10)
  9. 设置堆栈以调用c语言
  10. 调用bootmain.c

按照练习3中的要求具体分析切换保护模式

1. 为何开启A20

A20的历史可以通过阅读wiki/A20_line得知:
大概是8086的时候使用段加偏移访问的时候,1M以上的空间必须要第21根线来寻址,所以才有了A20。
发展到后来的80286也沿用8086的方式,80386出来了保护模式。此时地址线已经是32根,如果不使能A20的话,A20将保持低电平,访问的空间减少了一半(第21位恒为零)。

2. 如何初始化GDT表

gdt:
    SEG_NULLASM                                     # null seg
    SEG_ASM(STA_X|STA_R, 0x0, 0xffffffff)           # code seg for bootloader and kernel
    SEG_ASM(STA_W, 0x0, 0xffffffff)                 # data seg for bootloader and kernel

gdtdesc:
    .word 0x17                                      # sizeof(gdt) - 1
    .long gdt                                       # address gdt

3.如何使能和进入保护模式

使能cr0的wp位为1,进入保护模式。


练习4

使用source insight可以很方便的查看各种数据类型和结构体的定义

void
bootmain(void) {
    // read the 1st page off disk
    readseg((uintptr_t)ELFHDR, SECTSIZE * 8, 0);

    // is this a valid ELF?
    if (ELFHDR->e_magic != ELF_MAGIC) {
        goto bad;
    }

    struct proghdr *ph, *eph;

    // load each program segment (ignores ph flags)
    ph = (struct proghdr *)((uintptr_t)ELFHDR + ELFHDR->e_phoff);
    eph = ph + ELFHDR->e_phnum;
    for (; ph < eph; ph ++) {
        readseg(ph->p_va & 0xFFFFFF, ph->p_memsz, ph->p_offset);
    }

    // call the entry point from the ELF header
    // note: does not return
    ((void (*)(void))(ELFHDR->e_entry & 0xFFFFFF))();

bad:
    outw(0x8A00, 0x8A00);
    outw(0x8A00, 0x8E00);

    /* do nothing */
    while (1);
}

先通过readseg函数读取扇区,readseg函数则是循环调用readsect函数来读取每一个扇区的内容。

static void
readsect(void *dst, uint32_t secno) {
    // wait for disk to be ready
    waitdisk();

    outb(0x1F2, 1);                         // count = 1
    outb(0x1F3, secno & 0xFF);
    outb(0x1F4, (secno >> 8) & 0xFF);
    outb(0x1F5, (secno >> 16) & 0xFF);
    outb(0x1F6, ((secno >> 24) & 0xF) | 0xE0);
    outb(0x1F7, 0x20);                      // cmd 0x20 - read sectors

    // wait for disk to be ready
    waitdisk();

    // read a sector
    insl(0x1F0, dst, SECTSIZE / 4);
}

此处代码具体的读取方式存疑,猜想是通过outb:I/O 上写入 8 位数据 ( 1 字节 )的方法往不同的io端口写入指令来实现不同的功能。

扇区内容读完以后,进入判断函数判断是否为ELF格式的文件“ Loading ELF Binaries ” 配合相关ELF加载方法文档,将ELF Header的信息读如ph中。
按照ph里的信息,将ELF文件载入内存,然后再通过过函数入口信息,加载内核的入口。


练习5

栈相关的寄存器两个,ebp(基址寄存器)和esp(栈指针寄存器),栈的增长方向是由高到低
eip是程序指令指针,当前程序运行的指令

此时ebp是sum函数栈的基址,然后eip里面是sum函数中的第一条指令
sum函数执行完之后,sum函数栈的内容全部出栈,
eip=((uint_t)ebp+1),就是sum函数之后的指令的地址,然后函数参数出栈
然后ebp重新变成main函数的函数栈基址,ebp=((uint_t)ebp)

我们通过ebp获得当前函数栈的基址,eip获得程序当前运行的位置。
然后打印两个值,由指导方案的示意图与注释输出参数表

函数调用栈结构

然后在调用print_debuginfo输出其他的信息。
再通过结构示意图,将ebp+1的内容(返回地址)给eip
eip = *((uint32_t*)ebp + 1);
再吧ebp的内容给ebp回到上层调用函数继续输出
ebp = *((uint32_t*)ebp);

原代码没有判断ebp是否为0,输出了很多为零的ebp信息,在循环条件处判断ebp是否为零就可以了

最后一行的信息由 print_debuginfo函数输出

void
print_debuginfo(uintptr_t eip) {
    struct eipdebuginfo info;
    if (debuginfo_eip(eip, &info) != 0) {
        cprintf("    <unknow>: -- 0x%08x --\n", eip);
    }
    else {
        char fnname[256];
        int j;
        for (j = 0; j < info.eip_fn_namelen; j ++) {
            fnname[j] = info.eip_fn_name[j];
        }
        fnname[j] = '\0';
        cprintf("    %s:%d: %s+%d\n", info.eip_file, info.eip_line,
                fnname, eip - info.eip_fn_addr);
    }
}

输出的信息分别是:源码所在文件,源码所在行数,函数名,当前位置与函数指针的差(即函数源码的长度)

至于
最深层ebp

这里的被ebp为什么是7bf8。
其实很好理解,因为每个函数体之前在编译的时候都会被插入

pushl %ebp
movl %esp,%ebp

所以7c00-0008 = 7bf8


练习6

1.中断描述符表(也可简称为保护模式下的中断向量表)中一个表项占多少字节?其中哪几位代表中断处理代码的入口?

image.png

由指导方案的上图可知,一个表项占32*2位,8个字节。0到15位和48到63位为偏移量的低位和高位。16到31位是段选择子。 通过这几个数据来找到中断处理代码的入口。

2.补全idt_init函数
void idt_init(void)
{
    /* LAB1 YOUR CODE : STEP 2 */
    /* (1) Where are the entry addrs of each Interrupt Service Routine (ISR)?
      *     All ISR's entry addrs are stored in __vectors. where is uintptr_t __vectors[] ?
      *     __vectors[] is in kern/trap/vector.S which is produced by tools/vector.c
      *     (try "make" command in lab1, then you will find vector.S in kern/trap DIR)
      *     You can use  "extern uintptr_t __vectors[];" to define this extern variable which will be used later.
      * (2) Now you should setup the entries of ISR in Interrupt Description Table (IDT).
      *     Can you see idt[256] in this file? Yes, it's IDT! you can use SETGATE macro to setup each item of IDT
      * (3) After setup the contents of IDT, you will let CPU know where is the IDT by using 'lidt' instruction.
      *     You don't know the meaning of this instruction? just google it! and check the libs/x86.h to know more.
      *     Notice: the argument of lidt is idt_pd. try to find it!
      */
    int i = 0;
    extern uintptr_t __vectors[];
    for(i = 0; i < 255; ++i)
    {
        SETGATE(idt[i], 0, GD_KTEXT, __vectors[i], 0);
    }
    SETGATE(idt[T_SWITCH_TOK], 1, GD_KTEXT, __vectors[T_SWITCH_TOK], 3);
    lidt(&idt_pd);
}
  • 根据上面的注释,先申明外部变量__vectors[],也就是偏移量。
  • 然后使用SETGATE宏来设置idt表。第二个参数按照要求设为零(for 中断)
    第三个参数按照实验手册上说的通常设置为内核代码段,猜想应该折折以一个有关内存管理的宏定义,查看mm文件下的.h文件,发现在memlayout.h里定义了相关的宏
/* This file contains the definitions for memory management in our OS. */

/* global segment number */
#define SEG_KTEXT    1
#define SEG_KDATA    2
#define SEG_UTEXT    3
#define SEG_UDATA    4
#define SEG_TSS        5

/* global descriptor numbers */
#define GD_KTEXT    ((SEG_KTEXT) << 3)        // kernel text
#define GD_KDATA    ((SEG_KDATA) << 3)        // kernel data
#define GD_UTEXT    ((SEG_UTEXT) << 3)        // user text
#define GD_UDATA    ((SEG_UDATA) << 3)        // user data
#define GD_TSS        ((SEG_TSS) << 3)        // task segment selector
  • 第四个参数设置为偏移量__vector[i]
  • 第五个参数按照指导手册上所写,除了系统调用使用特权级3以外,均使用特权级0
  • 然后是专门设置系统调用中断,即用户态切换到内核态,使用陷阱门描述符,并且特权级为3
  • 最后使用lidt加载中断描述符表。此处虽然注释上写参数是idt_pd,这个变量类型是struct gatedesc 类型。但在写的时候发现lidt的申明是lidt(struct pseudodesc *pd) 所以将idt_pd取地址传入。

练习ex 1

扩展proj4,增加syscall功能,即增加一用户态函数(可执行一特定系统调用:获得时钟计数值),当内核初始完毕后,可从内核态返回到用户态的函数,而用户态的函数又通过系统调用得到内核态的服务

此处参考《操作系统设计与实现》中的代码

case T_SWITCH_TOU:
        if (tf->tf_cs != USER_CS) {
            switchk2u = *tf;
            switchk2u.tf_cs = USER_CS;
            switchk2u.tf_ds = USER_DS;
            switchk2u.tf_es = USER_DS;
            switchk2u.tf_ss = USER_DS;
            switchk2u.tf_esp = (uint32_t)tf + sizeof(struct trapframe) - 8;
        
            //设置EFLAG的i/o特权位,在用户态也能使用i/o指令
            switchk2u.tf_eflags |= (3<<12);
        
            //设置临时栈,以便cpu用switchk2u中恢复数据
            *((uint32_t *)tf - 1) = (uint32_t)&switchk2u;
        }
        break;  
    case T_SWITCH_TOK:
        if (tf->tf_cs != KERNEL_CS) {
            tf->tf_cs = KERNEL_CS;
            tf->tf_ds = tf->tf_es = KERNEL_DS;

            //关闭用户态使用io的特权位
            tf->tf_eflags &= ~(3<<12);
            switchu2k = (struct trapframe *)(tf->tf_esp - (sizeof(struct trapframe) - 8));
            memmove(switchu2k, tf, sizeof(struct trapframe) - 8);
            *((uint32_t *)tf - 1) = (uint32_t)switchu2k;
        }
        break;
栈情况K2U.png

栈情况恢复寄存器.png

图片来源网络,权侵删

根据操作系统设计与实现里的指导,先申明临时变量
struct trapframe switchk2u, *switchu2k;

然后在trap_disptach函数中,设置寄存器为用户/内核。
最后根据上面的两张图保存栈里的值。

在trap函数运行结束后,在trapentry.s里的iret的值返回以后,以用户态/内核态继续执行。

在init.c中启动测试函数,

static void
lab1_switch_to_user(void) {
    //LAB1 CHALLENGE 1 : TODO
    asm volatile (
        "sub $0x8, %%esp \n"      //从中断返回的时候会多pop两位用来更新ss和sp,所以先把栈压两位
        "int %0 \n"
        "movl %%ebp, %%esp"    //修复esp
        : 
        : "i"(T_SWITCH_TOU)
    );
}

static void
lab1_switch_to_kernel(void) {
    //LAB1 CHALLENGE 1 :  TODO
    asm volatile (
        "int %0 \n"
        "movl %%ebp, %%esp \n"
        : 
        : "i"(T_SWITCH_TOK)
    );
}

两段内联汇编的作用与含义依然存在疑问。


练习ex 2

(未成功实现
尝试在trap.c里重写switch_to_u/k函数,然后通过键盘中断调用系统调用中断,使用中断嵌套的方式实现

 case IRQ_OFFSET + IRQ_KBD:
        c = cons_getc();
        cprintf("kbd [%03d] %c\n", c, c);
        if('0' == c && tf->tf_cs != KERNEL_CS){
            print_trapframe(tf);

            lab1_switch_to_kernel();
            cprintf("switch to kernel : \n");
            print_trapframe(tf);
            
        }
        else if('3' == c && tf->tf_cs != USER_CS){
            print_trapframe(tf);

            lab1_switch_to_userl();
            cprintf("switch to user : \n");
            print_trapframe(tf);

        }else
            
            break;

实际运行的时候,首先是无法观察到寄存器的变化。理论在调用switch函数的时候,会有新的中断产生,会再次调用trap函数,在trap函数返回的时候,.s文件里的iret会执行,寄存器的值被写入应该会有不同的输出。

然后就是内核态在调用键盘中断时转入用户态是成功了的,但是之后的用户态转入内核态一直失败,原因也仍在探索。

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

推荐阅读更多精彩内容