MIT6.828 lab3 Part A:User Environments and Exception Handling

环境

ubuntu 20.04 64位系统
之前有些实验是在xv6的源码上操作的,发现20.04无法运行。后来发现lab还是可以在20.04正常做的,就把环境切换到本机上了。

lab地址:mit 6.828 lab3

废话

这次实验感觉难做了不少,参考了别人不少的代码。毕竟代码是别人写的,要花心思去理解别人的思路。注释里面的提示我也觉得有点模棱两可。做起来比较困难,还存在一些问题都没有很好理解。完成本lab最好阅读一下xv6 book的trap和page table这两节。

正文

先说在前面,本次lab里面提到的environment就是进程(process),不知道为什么要起这样奇奇怪怪的名字。
新引入的文件inc/env.h包括了JOS中envrionment的基本定义(当成进程来就完事儿了)。kernel使用Env这个struct来操作每一个用户进程。在本次lab当中你只需要创建一个进程,但是你需要让JOS能够支持多进程;lab4将会是用这个特性通过使用fork来创建一个新的用户进程。
几个关键的变量(kern/env.c):

struct Env *envs = NULL;        // All environments
struct Env *curenv = NULL;      // The current env
static struct Env *env_free_list;   // Free environment list

注释里面写的很明白了,envs指向所有的进程,curenv指向当前正在运行的进程,env_free_list是一个空闲进程的链表。当JOS启动的时候,envs指向了系统所有的进程。在JOS中,所有的同时可运行的进程数量为NENV个((NENV在inc/env.h当中)),1<<LOG2NENV=1024,这也就是说JOS最多可以同时运行1024个进程。

当我们需要创建一个新的进程的时候,只需要从env_free_list中拿一个出来,然后放到envs当中就行。都知道链表对于添加和删除是非常方便的。
下面是进程的定义:

struct Env {
    struct Trapframe env_tf;    // Saved registers
    struct Env *env_link;       // Next free Env
    envid_t env_id;         // Unique environment identifier
    envid_t env_parent_id;      // env_id of this env's parent
    enum EnvType env_type;      // Indicates special system environments
    unsigned env_status;        // Status of the environment
    uint32_t env_runs;      // Number of times environment has run

    // Address space
    pde_t *env_pgdir;       // Kernel virtual address of page dir
};
  • env_tf: 这个进程所属的寄存器的值,当发生进程切换到时候,我们需要保存当前正在运行的进程的寄存器的值。
  • env_link:这个就是相当于链表中的next,指向下一个节点,这样才能够把所有env串起来
  • env_id:进程的ID
  • env_parent_id:附进程的ID
  • env_type: 进程的类型,虽然大部分都进程都是ENV_TYPE_USER.()用户进程
  • env_status: ENV_FREE表示这个Env结构是空闲的,也就说它还在env_free_list当中,可以被分配使用。ENV_RUNNABLE表示这个进程可运行。比如说我们从硬盘中加载这个进程的代码到内存当中,初始化它的寄存器后,那么此时这个进程就是runnable了。ENV_RUNNING表示这个进程正在占用CPU。ENV_NOT_RUNNABLE这个就是进程被挂起了,这个进程此时激活的(inactive)的,但是还没准备好运行,比如说等待硬盘或者网络的IO结束。ENV_DYINGzombie进程,它此时还在内存中没有被释放,当下一次时钟中断发生的时候且轮到它执行,kernel发现他是zombie就会将他释放了(本次lab没有涉及到这个)
  • env_pgdir:这个进程的page directory的虚拟地址,注意看是虚拟地址。
    JOS中的environment和xv6中的process是差不多一个东西,就是JOS中的environemnt没有进程自己的栈,所以JOS使用的栈是内核栈

Allocating the Environments Array
在lab2当中,我们为pages[]这个数组分配了内存。现在我们需要为envs这个数组分配内存。envs应该放在UENVS开始的地方并且要让用户程序也可以访问。
这个和lab2当中对pages的操作差不多,在 kern/pmap.c 中加入下面的代码:

 //为envs分配内存
envs = (struct Env*) boot_alloc(NENV * sizeof(struct Env));
memset(envs,0, NENV* sizeof (struct Env));
//将envs映射到UENVS开始的地方,注意PTE_U,因为用户程序也要访问

boot_map_region(kern_pgdir,UENVS,PTSIZE,PADDR(envs),PTE_U | PTE_P);

Creating and Running Environments
接下来我们要写一些代码来运行进程,因为我们此时还没有文件系统,所以我们在内核当中嵌入了一些进程镜像,这些镜像也是ELF格式的。
Exercise 2

env_init():初始化所有的Env结构,并且放到env_free_list当中
env_setup_vm():为进程分配page directory,并且在页表中分配kernel所属的内存。
region_alloc():为进程分配物理内存
load_icode():将ELF文件加载为进程(毕竟ELF文件是无法直接运行的)
env_create():调用env_alloc(),env_alloc()初始化好进程所需要的东西,然后还需要使用load_icode()为这个进程加载ELF镜像
env_run():运行用户进程

一上来就直接实现这几个函数可能十分的难,所以首先先来整理下这些函数的调用关系,如下图所示:


函数关系

env_init():
在前面我们已经位envs分配好了内存空间。env_init()的目的就是可用的进程串到env_free_list当中。根据注释中的提示信息,它要求我们数组中进程的顺序和链表中的顺序要相同。所以for从数组的后面开始,然后使用头插法插入到链表当中,从而使得链表中的第一个元素就是数组的envs[0]。知道了链表的头插法插入数据后,代码应该很好理解

void env_init(void)
{
    // Set up envs array
    // LAB 3: Your code here.
    for(int i = NENV-1; i >= 0; i--) {
        //头插法
        envs[i].env_id = 0;
        envs[i].env_status = ENV_FREE;
        envs[i].env_link = env_free_list;
        env_free_list = &envs[i];
    }
    // Per-CPU part of the initialization
    env_init_percpu();
}

env_setup_vm():
新的进程要有自己的page directory。阅读下xv6 book page table这一节(有点忘了,应该是在这节),我们知道每一个进程的地址空间都分为kernel portion和user space portion。这样一来就理解了注释中说的初始化kernel portion。page_alloc()用于分配一个物理页,返回可用的物理页对应的PageInfo结构。前面说了env_pgdir是虚拟地址,所以我们使用page2kva()将PageInfo结构转为虚拟地址。然后根据注释,我们不需要初始化地址空间中属于用户程序的那部分,所以e->env_pgdir[i] = 0;。但是需要初始化地址空间中属于内核的那部分,所以这一部分只需要将内核的page directory中page directory复制过来就行e->env_pgdir[i] = kern_pgdir[i];最后在进程自己的page directory将page directory对应的page entry写入就行。

    int i = 0;
    struct PageInfo *p = NULL;

    // Allocate a page for the page directory
    if (!(p = page_alloc(ALLOC_ZERO)))
        return -E_NO_MEM;
    e->env_pgdir = (pde_t *)page2kva(p);
    p->pp_ref++;
    for(; i<PDX(UTOP); i++){
        e->env_pgdir[i] = 0;
    }

    for(; i<NPDENTRIES; i++){
        e->env_pgdir[i] = kern_pgdir[i];
    }
        //进程的页目录表
    e->env_pgdir[PDX(UVPT)] = PADDR(e->env_pgdir) | PTE_P | PTE_U;

region_alloc():
这个函数主要完成的是:将进程的虚拟地址va和物理地址映射起来,长度是len。在前面的函数中,我们可以使用env_setup_vm()来获得page directory。想象一下,建立新的映射关系,相当于往page table(页表)中加入page table(当然有时候page table可能在page directory中还不存在,所以需要现在page directory存放page table的地址,这个工作室pgdir_walk()完成的,忘了的老铁可以看一下pgdir_walk()的实现)。另外注释也说了,注意起始地址和结束地址的对齐,所以我们还需要调用一下ROUNDUP和ROUNDDOWN。

    uintptr_t va_start =(uintptr_t) ROUNDDOWN(va,PGSIZE);
    uintptr_t va_end = (uintptr_t)ROUNDUP(va+len,PGSIZE);
    struct PageInfo *p;
    int result;
    for(uintptr_t i = va_start; i < va_end; i += PGSIZE) {
        p = page_alloc(ALLOC_ZERO);  //申请一个页
        result = page_insert(e->env_pgdir,p,(void *)i,PTE_W | PTE_U); //插入新申请的页到page table 当中
        if( p == NULL || result != 0) {
            cprintf("region_alloc:allocate memory failed");
        }
    }

load_icode():
这个代码有一个点比较有意思。这个函数要实现的就是将elf文件加载到它指定的内存地址当中去。这一过程和之前在boot阶段加载内核比较相似。为了将进程放到用户程序所属的地址空间去,肯定需要切换cr3寄存器,然后加载完了之后在切换到内核的page directory。因此这里引出一个问题,变换了cr3寄存器的内容,为什么代码还可以执行? 因为,我们在之前在用户进程中的page directory中把属于内核的那部分从内核的page directory中复制过来了,所以可以继续执行。 接下来要做的就是,加载用户程序。根据注释,我们只加载ph->p_type == ELF_PROG_LOAD的program header。每一个program header要被加载到的虚拟地址是ph->p_va。进程所需要的空间为ph->p_filesz 字节,这些字节是从binary + ph->p_offset开始的,应该复制到ph->p_va去。它还提到,通常来说ph->p_filesz <= ph->p_memsz,这里关于为什么直接去google搜下就行。还有一点,e->env_pgdir是虚拟地址在,所以我们在放入cr3寄存器的时候,需要用PADDR()将他转为物理地址。

    struct Elf *ELF_Header = (struct Elf*)binary; 
    if (ELF_Header->e_magic != ELF_MAGIC)
        panic("The binary is not a ELF magic!\n");
    e->env_tf.tf_eip = ELF_Header->e_entry;
    lcr3(PADDR(e->env_pgdir)); //切换cr3寄存器,这样才能操作用户程序的地址空间
    struct Proghdr *ph, *eph;    
    ph = (struct Proghdr *) ((uint8_t *) ELF_Header + ELF_Header->e_phoff); // 获得program header的地址
    eph = ph + ELF_Header->e_phnum;
    for (; ph < eph; ph++)
        if(ph->p_type == ELF_PROG_LOAD){
            if(ph->p_memsz < ph->p_filesz)
                panic("segment out of memory!\n");
            region_alloc(e, (void *)ph->p_va, ph->p_memsz);    //为进程申请内存
            memset((void *)ph->p_va, 0, ph->p_memsz);//这里注释解释了,先初始化进程空间的内容
            memcpy((void *)ph->p_va, binary+ph->p_offset, ph->p_filesz);//复制进程的内容
        }
    
    region_alloc(e, (void *)(USTACKTOP-PGSIZE), PGSIZE); //申请栈空间
    lcr3(PADDR(kern_pgdir));  //切换到内核的cr3

env_create():
创建一个新的进程,这个没啥好说的,比较简单,直接贴代码吧

void
env_create(uint8_t *binary, enum EnvType type)
{
    // LAB 3: Your code here.
    struct Env *e;
    if(env_alloc(&e, 0)<0)
        panic("fail to create a env!\n");
    load_icode(e, binary);
    e->env_type = type;
}

env_run():
这个也不怎么难,根据注释应该可以完成,直接贴代码吧,注意要把panic给注释了

void
env_run(struct Env *e)
{
    if (curenv && curenv->env_status == ENV_RUNNING) {
        curenv->env_status = ENV_RUNNABLE;
    }
    curenv = e;
    curenv->env_status = ENV_RUNNING;
    curenv->env_runs++;
    lcr3(PADDR(curenv->env_pgdir));
    
    env_pop_tf(&(curenv->env_tf));
}

当我们完成这些函数时候,如果此时我们直接运行make qemu,会发现qemu会一直重复输出一些内容,如下图

make qemu

这就是官网说的会连续不断的重启的结果,如果我们用的事MIT官网给的那个定制版qemu,会触发triple fault。之所以会发生triple fault,是因为我们此时还没有设置中断处理函数,当lib/entry.S中调用int 0x30的时候,这时候就出错了。这里应该做成gif合适点。不过应该可以看到上面结果,很多内容重复出现的。不过我们接下来就要先去判断下我们是否成功进入了用户程序。在一个窗口运行make qemu-gdb ,另外一个运行make gdb。然后设置一个断点b env_pop_tf。如果我们写的程序是对的话,执行了env_pop_tf之后应该由iret语句跳转到用户程序中去执行。
iret语句

对照一下env_pop_tf的源代码,目前来说都是对的,接着iret跳转到了lib/entry.S,第一条语句是cmp语句.如下图:
cmp

对照下entry的源代码,我们成功了。

Handling Interrupts and Exceptions

官网中关于中断和异常的文字比较多,翻译比较花时间,就不翻译了,应该也不是特别难懂(如果你已经知道一些关于x86的异常和中断处理的过程)。简单的来说,x86处理器有一个IDT用于存放所有的中断或者异常的处理函数。比如说int 1就是调用1号中断,CPU会从IDT中拿出这个中断处理函数的CS和EIP。然后跳转到中断处理函数去执行。当内核处理完中断后,还需要返回到用户程序,所以内核需要将用户程序的所属的各个寄存器的值保存起来。在JOS中,inc/trap.h定义了Trapframe这个结构,用于保存用户程序的各个寄存器。另外,当用户程序发生异常的时候(比如说除0),往往需要发生特权级的转移(由CPL=3转移到CPL=0)。内核是最重要的,所以不能枉然的使用用户程序的栈,也因此我们需要由内核栈切换到用户栈。为了获得内核栈的地址,有一个结构叫做TSS(task state segment),我们事先在里面写入了内核栈的地址。当发生了特权级转换的时候,处理器会从TSS中取得内核的栈地址,并且将用户程序的ss,esp,eflags,cs,eip压入到内核栈当中,这些是处理去做的。所以我们还需要手动的压入那些通用寄存器(待会要做的_alltraps要做的事情)。当然有时候内核也会发生异常,当在内核的中断处理函数中发生异常的时候,此时的就不需要压入栈了。只需要压入cs,eip,eflags就行。这种在中断中发生中断的情况叫做nested exceptions and interrupts(嵌套的中断和异常).
Setting Up the IDT
阅读完官网的那些文字,接下来要做的就是设置好IDT(interrupt descriptor table)和以及对应的中断处理函数。到现在为止,我们只需要设置0-31这些中断。下面这幅图描述了基本的流程:

idt

每一个中断或者异常应该在trapentry.S中有一个他们自己的handler,然后再trap_init()初始化。每一个handler应该构建一个Trapframe然后将指向trapframe的指针作为参数传给trap函数。然后trap()把这些中断分发到对应的中断处理函数去。
Exercise

修改 trapentry.S和trap.c文件并且实现上面说的功能。两个宏函数TRAPHANDLER和TRAPHANDER_NOEC还有定义在inc/trap.h中的T_*(一开始没看懂,原来就是说一些宏定义)对你很有帮助。你需要在trapentry.S中为每一个中断实现他们的entry point。还要实现一个_alltraps。你还需要修改trap_init()函数来初始化idt。
_alltraps:

  1. 往栈里面压入值,使得栈看起来像一个trapframe
  2. 加载GD_KD到ds和es寄存器
  3. 压入esp作为参数传给trap()
  4. 调用 trap

实现代码的前戏:
这道题的代码一开始我是做不来的,参考了别人的代码做完的。首先我们要做的是实现中断的entry point,题目要求说了在trapentry.S中写好entry point,注意这里要使用那两个宏函数来完成。首先先来讲解一下这两个宏函数,简书的markdown太垃圾了,竟然把宏函数给注释掉了,认真观察
TRAPHANDLER:

#define TRAPHANDLER(name, num)                      \
    .globl name;        /* define global symbol for 'name' */   \
    .type name, @function;  /* symbol type is function */       \
    .align 2;       /* align function definition */     \
    name:           /* function starts here */      \
    pushl $(num);                           \
    jmp _alltraps

TRAPHANDLER_NOEC:

#define TRAPHANDLER_NOEC(name, num)                 \
    .globl name;                            \
    .type name, @function;                      \
    .align 2;                           \
    name:                               \
    pushl $0;                           \
    pushl $(num);                           \
    jmp _alltraps

如果你仔细阅读了上面的文字,知道在中断中有一些中断会使得处理器压入error code,有些不会压入error code。看一下这两个宏定义的代码以及注释,可以看到这俩的区别就是:有些中断不会压入error code,所以TRAPHANDLER_NOEC有一条语句push 0用来替代没有error code的情况。通过这两个宏定义,我们把中断处理函数的entry point变得更加通用了。
此时我们大概了解了要借助于这两个宏函数来完成entry point的过程,另外一个关键点就是在idt中初始化一下。了解过C语言编译的过程,我们知道宏函数在编译的时候是被直接替换的,所以上面提到的TRAPHEANDLER和TRAPHANDLER_NOEC会被直接替换为对应的标号。我们只要把这些标号放入到每一个IDT成员中就行了,IDT就初始化完成了。
还有一个问题就是完成_alltraps。前面提到过,由用户程序切换到内核,我们需要保存用户程序的各个寄存器信息,这些信息都被保存到用户程序的Trapframe里面。_alltraps要做的事情就是构建一个trapframe接着代码跳转到trap()当中去执行。帮助理解,下面是trapframe的图示:

_alltraps

PS:error code并非任何时候都有,对于没有error code的中断,TRAPHANDLER_NOEC宏函数压入了0,宏函数中的push $(num)指令压入了trapno。

trap_init():
首先先做的就是初始化IDT,我们要填充kern/trap.c的trap_init()的代码。我们需要初始化的中断定义在inc/trap.h当中。与之对应我们声明一些中断处理函数,比如说除0,就叫divide_handler()下面是所有的中断处理函数。注意,只需要声明,具体的函数是在trapentry.S当中的。

void divide_handler();
void debug_handler();
void nmi_handler();
void brkpt_handler();
void oflow_handler();
void bound_handler();
void device_handler();
void illop_handler();
void tss_handler();
void segnp_handler();
void stack_handler();
void gpflt_handler();
void pgflt_handler();
void fperr_handler();
void align_handler();
void mchk_handler();
void simderr_handler();
void syscall_handler();
void dblflt_handler();

void trap_init(void)
{
    extern struct Segdesc gdt[];

    // LAB 3: Your code here.
    //#define SETGATE(gate, istrap, sel, off, dpl) 
    // 把SETGATE这个宏放在这里,帮助理解
    SETGATE(idt[T_DIVIDE], 0, GD_KT, divide_handler, 0);
    SETGATE(idt[T_DEBUG], 0, GD_KT, debug_handler, 0);
    SETGATE(idt[T_NMI], 0, GD_KT, nmi_handler, 0);
    SETGATE(idt[T_BRKPT], 0, GD_KT, brkpt_handler, 3);
    SETGATE(idt[T_OFLOW], 0, GD_KT, oflow_handler, 0);
    SETGATE(idt[T_BOUND], 0, GD_KT, bound_handler, 0);
    SETGATE(idt[T_DEVICE], 0, GD_KT, device_handler, 0);
    SETGATE(idt[T_ILLOP], 0, GD_KT, illop_handler, 0);
    SETGATE(idt[T_DBLFLT], 0, GD_KT, dblflt_handler, 0);
    SETGATE(idt[T_TSS], 0, GD_KT, tss_handler, 0);
    SETGATE(idt[T_SEGNP], 0, GD_KT, segnp_handler, 0);
    SETGATE(idt[T_STACK], 0, GD_KT, stack_handler, 0);
    SETGATE(idt[T_GPFLT], 0, GD_KT, gpflt_handler, 0);
    SETGATE(idt[T_PGFLT], 0, GD_KT, pgflt_handler, 0);
    SETGATE(idt[T_FPERR], 0, GD_KT, fperr_handler, 0);
    SETGATE(idt[T_ALIGN], 0, GD_KT, align_handler, 0);
    SETGATE(idt[T_MCHK], 0, GD_KT, mchk_handler, 0);
    SETGATE(idt[T_SIMDERR], 0, GD_KT, simderr_handler, 0);
    SETGATE(idt[T_SYSCALL], 0, GD_KT, syscall_handler, 3);
    // Per-CPU setup 
    trap_init_percpu();
}

trapentry.S
如果了解过C语言的编译过程,我们知道宏函数在编译的时候会被直接替换掉。如果我们调用TRAPHANDER_NOEC(divide_handler,T_DIVIDE)就相当于下面的代码:

divide_handler:
  push $0
  push T_DIVIDE

这样一来就和我们在trap_init()中的代码对应起来了,正式开始实现代码之前,先看一下x86中哪些中断有error code,osDev这里总结的比mit那个好,Error Code 。接下来只要调用两个宏函数就行了:

/*
 * Lab 3: Your code here for generating handler points for the different traps.
 */
TRAPHANDLER_NOEC(divide_handler, T_DIVIDE);
TRAPHANDLER_NOEC(debug_handler, T_DEBUG);
TRAPHANDLER_NOEC(nmi_handler, T_NMI);
TRAPHANDLER_NOEC(brkpt_handler, T_BRKPT);
TRAPHANDLER_NOEC(oflow_handler, T_OFLOW);
TRAPHANDLER_NOEC(bound_handler, T_BOUND);
TRAPHANDLER_NOEC(illop_handler, T_ILLOP);
TRAPHANDLER_NOEC(device_handler, T_DEVICE);
TRAPHANDLER(dblflt_handler, T_DBLFLT);
TRAPHANDLER(tss_handler, T_TSS);
TRAPHANDLER(segnp_handler, T_SEGNP);
TRAPHANDLER(stack_handler, T_STACK);
TRAPHANDLER(gpflt_handler, T_GPFLT);
TRAPHANDLER(pgflt_handler, T_PGFLT);
TRAPHANDLER_NOEC(fperr_handler, T_FPERR);
TRAPHANDLER(align_handler, T_ALIGN);
TRAPHANDLER_NOEC(mchk_handler, T_MCHK);
TRAPHANDLER_NOEC(simderr_handler, T_SIMDERR);
TRAPHANDLER_NOEC(syscall_handler, T_SYSCALL);

_alltraps:
代码如下,要记得一点就是_alltraps是要构建一个trapframe的。不过有一点我没有明白,为什么要push esp,这个是参考别人代码的,我觉得和x86 c calling convention可能有关。另外再说一下,当处理器在执行下面的代码的时候,已经是在CPL=0的内核中执行了。虽然已经发生了特权级的转换,但是中断发生的时候处理器只是替换了CS和EIP寄存器。所以此时的ds,es,eax,ebx寄存去都还是用户程序的寄存器。所以构建一个trapframe是可行的。

_alltraps:
    pushl %ds
    pushl %es
    pushal

    movl $GD_KD, %eax
    movl %eax, %ds
    movl %eax, %es

    push %esp  //我没有理解
    call trap

实验结果:
通过运行make grade,如果divezero,softint,badsegment都正确了的话说明你成功了。下面是我的实验结果;

实验结果

question

  1. 为什么每一个中断或者异常都有自己的处理函数(也就是说如果所有的中断和异常都使用相同的处理函数会怎么样)

答:这个题目感觉有点无厘头,有点废话,不同的中断当然需要不同的处理函数,难道除0和page fault做相同的事情吗?。。 不知道是不是我对题目理解有问题

  1. 怎么样才能使softint正常运行?评分脚本希望他产生13号中断,但是softint的代码是调用了14号中断,怎么做?
    答: 只要让14号中断的DPL=0就行了,softint是用户程序,它运行在DPL=3。当14号中断的DPL=0的时候,用户程序因为他的DPL比较低(DPL=0)所以没有权限去调用这个中断,因此会触发general protection fault。
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念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

推荐阅读更多精彩内容