操作系统实验:Lab5 用户进程管理

清华大学操作系统Lab5实验报告
课程主页:http://os.cs.tsinghua.edu.cn/oscourse/OS2018spring
实验指导书:https://chyyuu.gitbooks.io/ucore_os_docs/content/
github:https://github.com/chyyuu/ucore_os_lab

实验目的

  • 了解第一个用户进程创建过程
  • 了解系统调用框架的实现机制
  • 了解ucore如何实现系统调用sys_fork/sys_exec/sys_exit/sys_wait来进行进程管理

实验内容

实验4完成了内核线程,但到目前为止,所有的运行都在内核态执行。实验5将创建用户进程,让用户进程在用户态执行,且在需要ucore支持时,可通过系统调用来让ucore提供服务。为此需要构造出第一个用户进程,并通过系统调用sys_fork/sys_exec/sys_exit/sys_wait来支持运行不同的应用程序,完成对用户进程的执行过程的基本管理。相关原理介绍可看附录B。

练习0:填写已有实验

除了将原有Lab中的代码转移到Lab5之外,还需要做一些修改。

alloc_proc中,初始化proc_struct中新加入的几个成员变量。

        //LAB5 2015011346 : (update LAB4 steps)
            /*
             * below fields(add in LAB5) in proc_struct need to be initialized
             *       uint32_t wait_state;                        // waiting state
             *       struct proc_struct *cptr, *yptr, *optr;     // relations between processes
             */
        proc -> wait_state = 0x0;
        proc -> cptr = proc -> yptr = proc -> optr = NULL;

do_fork中,加入与进程控制有关的信息。

    //LAB5 2015011346 : (update LAB4 steps)
   /* Some Functions
    *    set_links:  set the relation links of process.  ALSO SEE: remove_links:  lean the relation links of process 
    *    -------------------
    *    update step 1: set child proc's parent to current process, make sure current process's wait_state is 0
    *    update step 5: insert proc_struct into hash_list && proc_list, set the relation links of process
    */
    proc = alloc_proc();
    if (proc == NULL) {
        goto fork_out;
    }
    assert(proc -> wait_state == 0x0);
    proc -> parent = current;
    int kstack_success = setup_kstack(proc);
    if (kstack_success != 0) {
        goto bad_fork_cleanup_proc;
    }
    int copy_success = copy_mm(clone_flags, proc);
    if (copy_success != 0) {
        goto bad_fork_cleanup_kstack;
    }
    copy_thread(proc, stack, tf);

    bool intr_flag;
    local_intr_save(intr_flag);
    proc -> pid = get_pid();
    hash_proc(proc);
    set_links(proc);
    local_intr_restore(intr_flag);
    wakeup_proc(proc);
    ret = proc -> pid;

trap_dispatch中,修改timer interrupt,在中断时将当前正在运行的进程设置为可调度的,以便在下一个时间片重新选择进程。

    case IRQ_OFFSET + IRQ_TIMER:
        /* LAB5 2015011346 */
        /* you should upate you lab1 code (just add ONE or TWO lines of code):
         *    Every TICK_NUM cycle, you should set current process's current->need_resched = 1
         */
        ticks++;
        if (ticks == TICK_NUM) {
            ticks = 0;
            current -> need_resched = 1;
        }

练习1:加载应用程序并执行

load_icode函数中,在特权级为0的内核栈中创建新的中断帧,通过弹出该中断帧可以赚到特权级为3的用户程序处执行。

    /* LAB5:EXERCISE1 2015011346
     * should set tf_cs,tf_ds,tf_es,tf_ss,tf_esp,tf_eip,tf_eflags
     * NOTICE: If we set trapframe correctly, then the user level process can return to USER MODE from kernel. So
     *          tf_cs should be USER_CS segment (see memlayout.h)
     *          tf_ds=tf_es=tf_ss should be USER_DS segment
     *          tf_esp should be the top addr of user stack (USTACKTOP)
     *          tf_eip should be the entry point of this binary program (elf->e_entry)
     *          tf_eflags should be set to enable computer to produce Interrupt
     */
    tf -> tf_cs = USER_CS;
    tf -> tf_ds = tf -> tf_es = tf -> tf_ss = USER_DS;
    tf -> tf_esp = USTACKTOP;
    tf -> tf_eip = elf -> e_entry;
    tf -> tf_eflags = FL_IF;
请在实验报告中描述当创建一个用户态进程并加载了应用程序后,CPU是如何让这个应用程序最终在用户态执行起来的。即这个用户态进程被ucore选择占用CPU执行( RUNNING态)到具体执行应用程序第一条指令的整个经过。

这部分主要在load_icode函数中实现。

  • 为内存管理的数据结构mm分配空间并初始化,代码如下:
    //(1) create a new mm for current process
    if ((mm = mm_create()) == NULL) {
        goto bad_mm;
    }
  • 通过setup_pgdir为用户空间创建页目录,并将内存管理数据结构mmpgdir设置为页目录的虚地址。
    //(2) create a new PDT, and mm->pgdir= kernel virtual addr of PDT
    if (setup_pgdir(mm) != 0) {
        goto bad_pgdir_cleanup_mm;
    }
  • 接下来将解析已经被载入内存的ELF格式的用户代码。解析ELF header,找到用户程序中program section headers。随后通过调用mm_map将不同段的起始地址和长度记录到虚拟内存空间管理的数据结构vma中去。接下来根据program section的header中的信息,找到每个program section,并将其中的内容拷贝到用户进程的内存中(包括BSS section和TEXT/DATA section)。
    //(3) copy TEXT/DATA section, build BSS parts in binary to memory space of process
    struct Page *page;
    //(3.1) get the file header of the bianry program (ELF format)
    struct elfhdr *elf = (struct elfhdr *)binary;
    //(3.2) get the entry of the program section headers of the bianry program (ELF format)
    struct proghdr *ph = (struct proghdr *)(binary + elf->e_phoff);
    //(3.3) This program is valid?
    if (elf->e_magic != ELF_MAGIC) {
        ret = -E_INVAL_ELF;
        goto bad_elf_cleanup_pgdir;
    }

    uint32_t vm_flags, perm;
    struct proghdr *ph_end = ph + elf->e_phnum;
    for (; ph < ph_end; ph ++) {
    //(3.4) find every program section headers
        if (ph->p_type != ELF_PT_LOAD) {
            continue ;
        }
        if (ph->p_filesz > ph->p_memsz) {
            ret = -E_INVAL_ELF;
            goto bad_cleanup_mmap;
        }
        if (ph->p_filesz == 0) {
            continue ;
        }
    //(3.5) call mm_map fun to setup the new vma ( ph->p_va, ph->p_memsz)
        vm_flags = 0, perm = PTE_U;
        if (ph->p_flags & ELF_PF_X) vm_flags |= VM_EXEC;
        if (ph->p_flags & ELF_PF_W) vm_flags |= VM_WRITE;
        if (ph->p_flags & ELF_PF_R) vm_flags |= VM_READ;
        if (vm_flags & VM_WRITE) perm |= PTE_W;
        if ((ret = mm_map(mm, ph->p_va, ph->p_memsz, vm_flags, NULL)) != 0) {
            goto bad_cleanup_mmap;
        }
        unsigned char *from = binary + ph->p_offset;
        size_t off, size;
        uintptr_t start = ph->p_va, end, la = ROUNDDOWN(start, PGSIZE);

        ret = -E_NO_MEM;

     //(3.6) alloc memory, and  copy the contents of every program section (from, from+end) to process's memory (la, la+end)
        end = ph->p_va + ph->p_filesz;
     //(3.6.1) copy TEXT/DATA section of bianry program
        while (start < end) {
            if ((page = pgdir_alloc_page(mm->pgdir, la, perm)) == NULL) {
                goto bad_cleanup_mmap;
            }
            off = start - la, size = PGSIZE - off, la += PGSIZE;
            if (end < la) {
                size -= la - end;
            }
            memcpy(page2kva(page) + off, from, size);
            start += size, from += size;
        }

      //(3.6.2) build BSS section of binary program
        end = ph->p_va + ph->p_memsz;
        if (start < la) {
            /* ph->p_memsz == ph->p_filesz */
            if (start == end) {
                continue ;
            }
            off = start + PGSIZE - la, size = PGSIZE - off;
            if (end < la) {
                size -= la - end;
            }
            memset(page2kva(page) + off, 0, size);
            start += size;
            assert((end < la && start == end) || (end >= la && start == la));
        }
        while (start < end) {
            if ((page = pgdir_alloc_page(mm->pgdir, la, perm)) == NULL) {
                goto bad_cleanup_mmap;
            }
            off = start - la, size = PGSIZE - off, la += PGSIZE;
            if (end < la) {
                size -= la - end;
            }
            memset(page2kva(page) + off, 0, size);
            start += size;
        }
    }
  • 接下来通过调用mm_map函数为用户进程的user stack分配空间:
    //(4) build user stack memory
    vm_flags = VM_READ | VM_WRITE | VM_STACK;
    if ((ret = mm_map(mm, USTACKTOP - USTACKSIZE, USTACKSIZE, vm_flags, NULL)) != 0) {
        goto bad_cleanup_mmap;
    }
    assert(pgdir_alloc_page(mm->pgdir, USTACKTOP-PGSIZE , PTE_USER) != NULL);
    assert(pgdir_alloc_page(mm->pgdir, USTACKTOP-2*PGSIZE , PTE_USER) != NULL);
    assert(pgdir_alloc_page(mm->pgdir, USTACKTOP-3*PGSIZE , PTE_USER) != NULL);
    assert(pgdir_alloc_page(mm->pgdir, USTACKTOP-4*PGSIZE , PTE_USER) != NULL);
  • 建立用户进程的内存管理数据结构mm中的内容,并在进程控制块中记录下用户进程的页目录地址,将用户进程的页目录地址赋给CR3寄存器。
    //(5) set current process's mm, sr3, and set CR3 reg = physical addr of Page Directory
    mm_count_inc(mm);
    current->mm = mm;
    current->cr3 = PADDR(mm->pgdir);
    lcr3(PADDR(mm->pgdir));
  • 最后清空原来的中断帧,建立新的中断帧,代码就是填入的那段代码。通过iret指令从内核栈中弹出中断帧恢复各种段寄存器的值。这时段寄存器已经指向特权级为3的段,也就说完成了到用户进程的切换。

练习2:父进程复制自己的内存空间给子进程

copy_range函数中完成内存资源的复制。

/* LAB5:EXERCISE2 2015011346
         * replicate content of page to npage, build the map of phy addr of nage with the linear addr start
         *
         * Some Useful MACROs and DEFINEs, you can use them in below implementation.
         * MACROs or Functions:
         *    page2kva(struct Page *page): return the kernel vritual addr of memory which page managed (SEE pmm.h)
         *    page_insert: build the map of phy addr of an Page with the linear addr la
         *    memcpy: typical memory copy function
         *
         * (1) find src_kvaddr: the kernel virtual address of page
         * (2) find dst_kvaddr: the kernel virtual address of npage
         * (3) memory copy from src_kvaddr to dst_kvaddr, size is PGSIZE
         * (4) build the map of phy addr of  nage with the linear addr start
         */
// (1) find src_kvaddr: the kernel virtual address of page
        uint32_t src_kvaddr = page2kva(page);
// (2) find dst_kvaddr: the kernel virtual address of npage
        uint32_t dst_kvaddr = page2kva(npage);
// (3) memory copy from src_kvaddr to dst_kvaddr, size is PGSIZE
        memcpy(dst_kvaddr, src_kvaddr, PGSIZE);
// (4) build the map of phy addr of  nage with the linear addr start
        ret = page_insert(to, npage, start, perm);
        assert(ret == 0);
请在实验报告中简要说明如何设计实现”Copy on Write 机制“,给出概要设计,鼓励给出详细设计。

“Copy on Write”是指在fork一个进程时不立刻将父进程的数据段/代码段等复制到子进程的内存空间,而是当父进程或子进程中对相关内存做出修改时,才进行复制操作。

实现时,在fork一个进程时,可以省去load_icode中创建新页目录的操作,而是直接将父进程页目录的地址赋给子进程,为了防止误操作以及辨别是否需要复制,应该将尚未完成复制的部分的访问权限设为只读。

当执行读操作,父进程和子进程均不受影响。但当执行写操作时,会发生权限错误(因为此时的访问权限为只读)。这时候会进入到page fault的处理中去,在page fault的处理中,如果发现错误原因读/写权限问题,而访问的段的段描述符权限为可写,便可以知道是由于使用COW机制而导致的,这时再将父进程的数据段、代码段等复制到子进程内存空间上即可。

练习3:阅读分析源代码,理解进程执行 fork/exec/wait/exit 的实现,以及系统调用的实现。

系统调用共用一个中断号(即代码中的T_SYSCALL)。当发生冲段或异常后,会进入到中断服务例程中去,最终在trap_dispatch函数中调用syscall函数,并通过系统调用号选择应该执行函数sys_fork/exec/wait/exit中的一个,这些函数会解析系统调用时传入的参数,并将参数传递给do_fork/execv/wait/exit执行具体操作。

请分析fork/exec/wait/exit在实现中是如何影响进程的执行状态的?

根据上面的系统调用处理过程的分析,我们只需了解do_fork/execve/wait/exit中的实现。

  • do_fork:sys_fork的相关函数。在该函数中,首先要为子进程创建进程控制块,设置好进程控制块中的上下文的中断帧等信息,为子进程创建用户栈、内核栈等。随后通过wakeup_proc函数将子进程设置为RUNNABLE。之后该函数给父进程返回子进程的pid,给子进程返回0。随后在ucore循环执行进程调度schedule时,就会将子进程考虑进去。详细说明见代码注释。
int
do_fork(uint32_t clone_flags, uintptr_t stack, struct trapframe *tf) {
    int ret = -E_NO_FREE_PROC;
    struct proc_struct *proc;
    if (nr_process >= MAX_PROCESS) {
        goto fork_out;
    }
    ret = -E_NO_MEM;
  
    //    1. call alloc_proc to allocate a proc_struct
    //    2. call setup_kstack to allocate a kernel stack for child process
    //    3. call copy_mm to dup OR share mm according clone_flag
    //    4. call copy_thread to setup tf & context in proc_struct
    //    5. insert proc_struct into hash_list && proc_list
    //    6. call wakeup_proc to make the new child process RUNNABLE
    //    7. set ret vaule using child proc's pid
    proc = alloc_proc();
    if (proc == NULL) {
        goto fork_out;
    }
    assert(proc -> wait_state == 0x0);
    proc -> parent = current;
    int kstack_success = setup_kstack(proc);
    if (kstack_success != 0) {
        goto bad_fork_cleanup_proc;
    }
    int copy_success = copy_mm(clone_flags, proc);
    if (copy_success != 0) {
        goto bad_fork_cleanup_kstack;
    }
    copy_thread(proc, stack, tf);

    bool intr_flag;
    local_intr_save(intr_flag);
    proc -> pid = get_pid();
    hash_proc(proc);
    set_links(proc);
    local_intr_restore(intr_flag);
    wakeup_proc(proc);
    ret = proc -> pid;
    
fork_out:
    return ret;

bad_fork_cleanup_kstack:
    put_kstack(proc);
bad_fork_cleanup_proc:
    kfree(proc);
    goto fork_out;
}
  • do_execve:sys_exec的相关函数。sys_exec不创建新进程,而是用新的内容覆盖原来的进程内存空间。在do_execve中,首先使用exit_mmapput_pgdirmm_destroy来删除并释放掉当前进程内存空间的页表信息、内存管理信息。随后通过load_icode将新的用户程序从ELF文件中加载进来执行。如果加载失败,则调用do_exit退出当前进程。执行sys_exec后,当前进程的状态保持不变。详细说明见代码注释。
int
do_execve(const char *name, size_t len, unsigned char *binary, size_t size) {
    struct mm_struct *mm = current->mm;
    if (!user_mem_check(mm, (uintptr_t)name, len, 0)) {
        return -E_INVAL;
    }
    if (len > PROC_NAME_LEN) {
        len = PROC_NAME_LEN;
    }

    char local_name[PROC_NAME_LEN + 1];
    memset(local_name, 0, sizeof(local_name));
    memcpy(local_name, name, len);

// 删除当前进程的内存空间里的内容
    if (mm != NULL) {
        lcr3(boot_cr3);
        if (mm_count_dec(mm) == 0) {
// 取消vma中记录的合法内存块
            exit_mmap(mm);
// 删除页表
            put_pgdir(mm);
// 删除mm记录的信息和占用的空间
            mm_destroy(mm);
        }
        current->mm = NULL;
    }
    int ret;
// 调用load_icode加载新的进程内容
    if ((ret = load_icode(binary, size)) != 0) {
        goto execve_exit;
    }
    set_proc_name(current, local_name);
    return 0;

// 如果exec执行不成功,则退出进程
execve_exit:
    do_exit(ret);
    panic("already exit: %e.\n", ret);
}
  • do_wait:sys_wait的相关函数。在该函数中,循环查看子进程的状态,直到一个正在等待的子进程的状态变成Zombie状态,这时完成这个子进程的剩余资源回收工作,释放子进程的空间。详细说明见代码注释。
int
do_wait(int pid, int *code_store) {
    struct mm_struct *mm = current->mm;
    if (code_store != NULL) {
        if (!user_mem_check(mm, (uintptr_t)code_store, sizeof(int), 1)) {
            return -E_INVAL;
        }
    }

    struct proc_struct *proc;
    bool intr_flag, haskid;
// 循环询问正在等待的子进程的状态,直到有子进程状态变为ZOMBIE。
repeat:
    haskid = 0;
    if (pid != 0) {
        proc = find_proc(pid);
        if (proc != NULL && proc->parent == current) {
            haskid = 1;
            if (proc->state == PROC_ZOMBIE) {
                goto found;
            }
        }
    }
    else {
        proc = current->cptr;
        for (; proc != NULL; proc = proc->optr) {
            haskid = 1;
            if (proc->state == PROC_ZOMBIE) {
                goto found;
            }
        }
    }
    if (haskid) {
        current->state = PROC_SLEEPING;
        current->wait_state = WT_CHILD;
        schedule();
        if (current->flags & PF_EXITING) {
            do_exit(-E_KILLED);
        }
        goto repeat;
    }
    return -E_BAD_PROC;

// 如果发现一个子进程变成了ZOMBIE,则释放该子进程剩余的资源。
found:
    if (proc == idleproc || proc == initproc) {
        panic("wait idleproc or initproc.\n");
    }
    if (code_store != NULL) {
        *code_store = proc->exit_code;
    }
    local_intr_save(intr_flag);
    {
        unhash_proc(proc);
        remove_links(proc);
    }
    local_intr_restore(intr_flag);
    put_kstack(proc);
    kfree(proc);
    return 0;
}
  • do_exit:sys_exit的相关函数。退出时,首先释放掉该进程占用的一部分内存(还有一部分可能由父进程释放)。然后将该进程标记为僵尸进程。如果它的父进程处于等待子进程退出的状态,则唤醒父进程,将自己的子进程交给initproc处理,并进行的进程调度。详细说明见代码注释。
int
do_exit(int error_code) {
    if (current == idleproc) {
        panic("idleproc exit.\n");
    }
    if (current == initproc) {
        panic("initproc exit.\n");
    }
    
// 释放该进程的空间
    struct mm_struct *mm = current->mm;
    if (mm != NULL) {
// 加载当前进程的页目录地址
        lcr3(boot_cr3);
        if (mm_count_dec(mm) == 0) {
// 释放由vma记录的内存地址块
            exit_mmap(mm);
// 删除页表
            put_pgdir(mm);
// 删除内存管理结构mm占用的内存
            mm_destroy(mm);
        }
        current->mm = NULL;
    }
// 记录当前进程的退出编码,并标记为僵尸进程
    current->state = PROC_ZOMBIE;
    current->exit_code = error_code;
    
    bool intr_flag;
    struct proc_struct *proc;
    local_intr_save(intr_flag);
    {
// 如果当前进程的父进程处于等待子进程退出状态,则将父进程设置为RUNNABLE
        proc = current->parent;
        if (proc->wait_state == WT_CHILD) {
            wakeup_proc(proc);
        }
// 如果当前进程有子进程,则将子进程设置为initproc的子进程,并完成子进程中处于僵尸状态的进程的最后的回收工作
        while (current->cptr != NULL) {
            proc = current->cptr;
            current->cptr = proc->optr;
    
            proc->yptr = NULL;
            if ((proc->optr = initproc->cptr) != NULL) {
                initproc->cptr->yptr = proc;
            }
            proc->parent = initproc;
            initproc->cptr = proc;
            if (proc->state == PROC_ZOMBIE) {
                if (initproc->wait_state == WT_CHILD) {
                    wakeup_proc(initproc);
                }
            }
        }
    }
    local_intr_restore(intr_flag);
// 执行进程调度    
    schedule();
    panic("do_exit will not return!! %d.\n", current->pid);
}
请给出ucore中一个用户态进程的执行状态生命周期图(包执行状态,执行状态之间的变换关系,以及产生变换的事件或函数调用) 。
状态图

覆盖的知识点

  • 进程切换的全过程
  • 在父进程执行fork时的行为
  • 子进程执行exit后的行为

与参考答案的区别

  • 练习1:自己完成。
  • 练习2:自己完成。

总结

感觉这次实验比之前的容易一点,官方说法应该是由于认真看了mooc和实验指导书,但实际也可能是因为压着DDL写的比较有动力。
同时很怀疑思考题的表述是否准确,如果不准确希望助教指正。(当然不扣分最好了,我还是认真写了的)。

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

推荐阅读更多精彩内容