实现一个简单的64位操作系统 (0x04)实现一个完整的Boot

0x01 概述

之前已经熟悉过Boot的写法和FAT12文件系统的结构,现在要开始实现一个完整的Boot,以构建一个FAT12文件系统、从文件系统中搜索并读入Loader文件以及跳转到加载的Loader处执行。

0x02 实现

首先是一些伪代码,用来定义一些预计算的值来减少计算。在FAT12中,一些值是固定的,但是在标准的计算当中又需要使用这些值参与计算。如果将这些值提前计算好,然后用伪指令定义出来,就能节省计算成本和空间成本(代码量)。

; start address
org 0x7c00

BaseOfStack     equ 0x7c00
RootDirSecNum   equ 14                  ; sector count of root directory  (BPB_RootEntCnt * 32) / BPB_BytesPerSec
DirStruPerSec   equ 16                  ; directory structure in on sector
RootDirStart    equ 19
BufferAddr      equ 0x8000
DataStart       equ 31                  ; realstart - 2
FATTabStart     equ 1
BaseOfLoader    equ 0x1000
OffsetOfLoader  equ 0x0000

org指定起始地址位0x7c00。BaseOfStack用来给SP赋值作为初始栈底。
RootDirSecNum是根据BPB_RootEntCnt计算的,由于根目录下每个目录结构占32 Bytes,所以用BPB_RootEntCnt * 32 / BPB_BytesPerSec就能得到根目录占用的扇区数。在这就是228 * 32 / 512 = 14
DirStruPerSec是每个扇区的目录结构数,在遍历根目录时需要用到。计算方法是用BPB_BytesPerSec也就是每个扇区的字节数,除以每个目录结构的大小。在这就是512 / 32 = 16
RootDirStart是根目录的起始扇区数,上一章算过。
BufferAddr是用来存储临时数据的地址。
DataStart是数据区的起始扇区。计算方法是用根目录起始扇区加上根目录占用扇区,也就是RootDirStart + RootDirSecNum = 19 + 14 = 33。但是由FAT表中的簇号转换为线性扇区数时,需要减2(FAT表前两项保留,为了不浪费空间,数据区从0开始存储数据),将这个减2提取出来,提前算好,也就得到了31这值。给出公式的话,就是LinearSecNum = DataStart + (OffsetInFAT - 2) * BPB_SecPerClus = DataStart - 2 * BPB_SecPerClus + OffsetInFAT * BPB_SecPerClus,而在这里BPB_SecPerClus是1,也就变成了LinearSecNum = DataStart - 2 + OffsetInFAT,而DataStart算出来是33,索性提前减2变成31,之后计算起来就简单了,用DataStart + OffsetInFAT就能算出当前簇的起始扇区。
FATTabStart是FAT1表的起始扇区。上一章算过。
BaseOfLoader和OffsetOfLoader分别是Loader的基址和偏移,最后Loader将存到BaseOfLoader:OffsetOfLoader中去,换成线性地址就是BaseOfLoader << 4 + OffsetOfLoader,在这就是0x1000 << 4 + 0x0000 = 0x10000
接下来是FAT12文件系统的一些结构定义。

; Entry point of boot sector
jmp     short   Label_Start             ; jump to boot program
nop                                     ; placeholder
BS_OEMName          db  'WINIMAGE'      ; OEM Name
BPB_BytesPerSec     dw  512             ; bytes per section
BPB_SecPerClus      db  1               ; sectors per cluster
BPB_RsvdSecCnt      dw  1               ; reserved sector count (boot sector)
BPB_NumFATs         db  2               ; number of FAT tables
BPB_RootEntCnt      dw  224             ; max dir count of root dir
BPB_TotSec16        dw  2880            ; total number of sectors
BPB_Media           db  0xf0            ; drive type
BPB_FATSz16         dw  9               ; size of each FAT table
BPB_SecPerTrk       dw  18              ; sectors per track
BPB_NumHeads        dw  2               ; number of magnetic heads
BPB_HiddSec         dd  0               ; number of hidden sectors
BPB_TotSec32        dd  0               ; this value effects when BPB_TotSec16 is 0
BS_DrvNum           db  0               ; number of drives
BS_Reserved1        db  0               ; Reserved
BS_BootSig          db  29h             ; boot signature
BS_VolID            dd  0               ; volume ID
BS_VolLab           db  'bootloader '   ; volume name, padding with space(20h)
BS_FileSysType      db  'FAT12   '      ; file system type

jmp short Label_Start放在第一行的目的是跳转到真正的程序入口,因为下面全都是一些数据的定义。NOP放在这的原因不明,感觉像是一个占位符。每一个字段的作用可以参考FAT Filesystem
真正的引导程序入口从Label_Start处开始。
首先是寄存器初始化。

; entry point
Label_Start:
; init registers
mov     ax, cs
mov     ds, ax
mov     es, ax
mov     ss, ax
mov     sp, BaseOfStack

然后跟之前的示例Boot一样,清屏、设置光标位置和打印引导字符串:

; clear screen
; AH = 06h roll pages
; AL = page num (0 to clear screen)
; BH = color attributes
; CL = left row, CH = left column
; DL = right row, DL = right column
mov     ax, 0600h
mov     bx, 0700h
mov     cx, 0
mov     dx, 184Fh
int     10h

; set focus
; AH = 02h set focus
; DL = row
; DH = column
; BH = page num
mov     ax, 0200h
mov     bx, 0000h
mov     dx, 0000h
int     10h

; display boot string
push    0000h
push    StartBootMessageLength
push    StartBootMessage
call    Func_PrintString

接下来调用了一个函数,来从根目录寻找特定文件名的文件。

push    LoaderFileName
call    Func_FindFile

这个函数是自己实现的,从栈里接受一个字符串地址作为参数,从根目录里寻找一个文件名为参数指向文件名的文件,然后将它的第一个簇号用EAX返回。搜索成功返回第一个簇号,搜索失败返回0。Func_FindFile实现如下:

;;; Function:         Func_FindFile
;;; Params:           Stack: FileNameAddress
;;; Return value:     AX = FirstCluster, zero if not found.
;;; Descryption:      Find the file named [FileNameAddress] in root directory.
;;;                   The length of file name must be 11 bytes.
Func_FindFile:
; construct stack frame
push    bp
mov     bp, sp

xor     cx, cx ; ch = inner, cl = outer

Label_StartSearch:
cmp     cl, RootDirSecNum
ja      Label_FileNotFound

mov     ax, RootDirStart
add     al, cl ; AX = current sector

push    BufferAddr
push    ax
call    Func_ReadOneSector

xor     ch, ch
Label_InnerLoop:
mov     al, ch
xor     ah, ah
mov     bx, 32
mul     bx
add     ax, BufferAddr
mov     bx, ax ; BX = cur dir struc addr

; BX = cur file name (11 btyes)

push    bx
call    Func_CompareFileName
cmp     ax, 0
jnz     Label_FileFound

inc     ch

cmp     ch, DirStruPerSec
jle     Label_InnerLoop

; go to next round
inc     cl
jmp     Label_StartSearch

Label_FileFound:
mov     ax, [bx + 0x1a]
jmp     Label_FuncReturn

Label_FileNotFound:
xor     ax, ax

Label_FuncReturn:
mov     sp, bp
pop     bp
ret     02h

之后所有的函数定义都将在上面标出它的函数名、参数传递方法、返回值以及函数描述。我自认为这是一个比较好的习惯,也坚持这样做了。因为这样做的话,很久之后再去调用这个函数的话就能很快想起调用它的方法。
Func_FindFile函数使用栈传参,接受一个参数,参数为要搜索的文件名地址。返回值为这个文件的第一个簇号。
先建立一个栈帧:

push    bp
mov     bp, sp

将之前的bp入栈,保护之前的bp,然后将栈顶的地址给bp,之后使用bp来寻找传进来的参数。
之后将cx清0:xor cx,cx,由于之后需要用两层循环来进行文件查找(逐扇区读入,每个扇区内按目录结构32 B大小查找),并且每个循环次数都在0xff以内,所以使用cl来记录外层循环次数,用ch来记录内层循环记录。
然后开始外层循环:

Label_StartSearch:
cmp     cl, RootDirSecNum
ja      Label_FileNotFound

比较clRootDirSecNum的大小,如果大于RootDirSecNum就跳到Label_FileNotFound。也就是,如果已经搜索完根目录的每一个扇区还没有找到指定文件的话,就判断为根目录下不存在指定,跳到文件不存在的标签处。

mov     ax, RootDirStart
add     al, cl ; AX = current sector

push    BufferAddr
push    ax
call    Func_ReadOneSector

然后将RootDirStart传给AX,并加上cl。这里也就是根据根目录的开始扇区,加上偏移,得到当前需要读入的扇区。然后将缓存区地址和扇区号入栈,传给Func_ReadOneSector来将当前循环到的目录扇区读入Buffer中。Func_ReadOneSector是实现的函数,用来从指定扇区中读入一个扇区的数据到指定内存中。后面会提到它的实现。
接着就进入了内层循环。

xor     ch, ch
Label_InnerLoop:
mov     al, ch
xor     ah, ah
mov     bx, 32
mul     bx
add     ax, BufferAddr
mov     bx, ax ; BX = cur dir struc addr

; BX = cur file name (11 btyes)

每次进入内层循环前先将计数器清零xor ch,ch,因为要从Buffer的头部,也就是这个扇区的开始处,进行文件查找。然后,每次循环时用偏移值ch乘32得到字节偏移,并加上BufferAddr,得到指向当前目录结构的指针,存入bx中。并且,根据目录结构的定义,开头11个字节为文件名,所以bx是目录结构指针的同时也是文件名指针。
然后要开始得到目录结构指针以及判断文件名了。

push    bx
call    Func_CompareFileName
cmp     ax, 0
jnz     Label_FileFound

bx作为参数传入,并调用Func_CompareFileName。这个函数会比较传入的文件名指针指向的字符串与Loader文件名是否相等,如果不相等返回0,相等则返回一个非0值。对返回值ax进行判断,如果是非0值,则跳到Label_FileFound,执行找到文件的流程。否则继续后续循环。

inc     ch

cmp     ch, DirStruPerSec
jle     Label_InnerLoop

; go to next round
inc     cl
jmp     Label_StartSearch

这里先对内层计数器加1,然后比较与DirStruPerSec的大小,如果不大于这个值,也就是没到该扇区结尾的话,就继续下次内层循环。否则跳出内层循环,增加外层循环计数器,并且跳到外层循环的下一次循环判断处。这两个循环对应到C语言的逻辑上,伪代码应该下面这样的:

char i = 0;
do
{
    ...
    for(char j = 0; j<= DirStruPerSec; ++j)
    {
        ...
    }
    ++i;
} while(i <= Label_FileNotFound)

然后就是两个判断结果的逻辑。

Label_FileFound:
mov     ax, [bx + 0x1a]
jmp     Label_FuncReturn

Label_FileNotFound:
xor     ax, ax

如果找到了文件,就会执行Label_FileFound标签处的指令,将bx + 0x1a处的值,也就是目录结构里的DIR_FstClus(首簇簇号)传给ax,并跳到返回逻辑处。如果没找到文件,就会将ax清0并返回。
最后就是返回逻辑了。

Label_FuncReturn:
mov     sp, bp
pop     bp
ret     02h

将bp的值给sp,用来平衡栈。然后将被保护的bp出栈恢复,最后使用ret 02h返回。这里我实现的函数用的都是std call的调用约定,由被调用者清栈。因为这里不需要用到可变参数,为了调用方便,使用std call是最好的方法。
然后,上面有两个很重要的函数还没有提到实现。分别是Func_ReadOneSectorFunc_CompareFileName。接下来就是它们的实现了。
先是Func_ReadOneSector,给出它的描述:

;;; Function:         Func_ReadOneSector
;;; Params:           Stack: SectorNum, BufAddr 
;;; Return value:     AH = StatusCode
;;; Descryption:      Read one sector from floppy, SectorNum is the sector number,
;;;                   BufAddr is the buffer address to store data of the sector read.

函数接受两个参数,用栈传递。第一个参数是要读的扇区号(线性),第二个参数是要读入的内存地址。要注意的是,由于使用的是std call调用约定,参数是从右往左入栈的。返回值是读入的状态号,用AH存储。具体又哪些状态号,可以查阅INT 0x13中断的说明。
然后也是形成栈帧,并保护需要用到的寄存器。

push    bp
mov     bp, sp
sub     sp, 02h

; protect registers
push    bx
push    cx
push    dx

; SectorNum = bp + 4
; BufAddr   = bp + 6

形成栈帧后,就能用bp对参数和局部变量寻址了。这里将栈顶抬高了0x2 bytes,目的是用两个字节来存储转化后的物理扇区号。bp + 4处是传入的线性扇区号,bp + 6是传入的缓存区地址。bp - 2是局部变量物理扇区号。由于现在是16位实模式,入栈的返回地址和保护的bp都是0x2 bytes,所以第一个参数是从bp + 4处开始的。
形成栈帧之后对bx、cx、dx进行了入栈保护。
接下来就对线性扇区进行计算,得到磁头号、柱面号和扇区号。得到这三个物理位置后,就能确定软盘上唯一的一个扇区了。关于计算方法,FAT12文件系统 数据存储方式详解这篇文章中有比较详细的介绍。我这里对他的计算方法中能够提前计算的地方都进行了提前计算,实现上稍有不同。但是原理是一样的。

mov     ax, [bp + 4]
mov     bx, [BPB_SecPerTrk]
div     bx

inc     dx
mov     [bp - 2], dx ; [bp - 2] is sector num

mov     bx, [BPB_NumHeads]
xor     bh, bh
xor     dx, dx
div     bx ; AX is cylinder, DX is head num

先将传入的线性扇区号传入AX中,并且将每个磁道的扇区数传入bx中,将它们相除,得到商ax和余数dx。将余数加1,得到物理扇区号,存入[bp - 2]局部变量中。然后将磁头数存入bx中,用之前得到的商除以磁头数,得到柱面号ax和磁头号dx。这样就得到读入一个扇区需要的三个物理位置了。
接下来开始使用INT 0x13中断进行数据读入。

mov     cx, [bp - 2] ; CL = sector num
mov     ch, al ; CH = cylinder
mov     dh, dl ; DH = head num
mov     dl, [BS_DrvNum] ; DL = drive num
mov     al, 1 ; AL = read count
mov     ah, 02h ; AH = 0x02
mov     bx, [bp + 6]
int     13h

INT 0x13的描述中可以得到,AH传入功能号,这里是0x2,代表从磁盘/软盘中读入数据。AL传的是读入的扇区数量。ES:BX传入的是读入的内存地址。CL传物理扇区号。CH传柱面号。DL传驱动器号。DH传磁头号。将上面计算得到的值传入对应位置,然后使用0x13号中断就能进行读入了。读入结果状态码会传入AH中。接下来只要将其返回就行了。

; recover registers
pop     dx
pop     cx
pop     bx

; recover stack frame
mov     sp, bp
pop     bp 
ret     04h

同样的,将保护的dx、cx、bx出栈恢复,然后关闭栈帧,最后用RET 04h返回。因为有两个字的参数,一共4字节,所以是04h。
接着是判断文件名的Func_CompareFileName函数。
先看它的描述:

;;; Function:         Func_CompareFileName
;;; Parms:            Stack: FileNameAddr
;;; Return value:     AX = not zero if equal, 0 if not equal
;;; Descryption:      Compare if the file name is equal the loader file name.

Func_CompareFileName函数从栈中接受一个参数FileNameAddr,指向需要判断的文件名。返回值存在AX中,如果字符串相同返回1,否则返回0。
然后是它的实现:

Func_CompareFileName:
push    bx
push    cx

; FileNameAddr = [sp + 6]

mov     bx, sp
mov     ax, 1
cld
mov     cx, 11
mov     si, [bx + 6]
mov     di, LoaderFileName
repe cmpsb
jcxz   Label_Equal

xor     ax, ax

Label_Equal:
pop     cx
pop     bx
ret 02h

由于没使用到局部变量,为了节省空间,这里就没有形成栈帧了。先将bx、cx入栈保护。入栈后,sp + 6处就是传入的参数地址(返回地址0x2 + bx0x2 + cx0x2 = 0x6)。
先将sp的值给bx,因为只有bx和bp能够使用间接寻址。然后将ax传1,目的是初始化返回值为1。接下来用cld清空方向寄存器。接着将字符串长度11传给cx,然后将传入的地址传入si、loader文件名地址传给di,并使用repe cmpsb来进行逐byte比较,比较11次。cmpsb规定了比较跨度,按byte进行比较。repe规定了比较方法,当前两个字节中有一个字节不相等就会跳出这条语句。每比较一次,sidi会自增1,cx会自减1。比较这条语句结束时的cx值就能判断两个字符串是否相等。使用jcxz,当cx为0时判断两个字符串相等,跳到Label_Equal,否则将ax清零。
最后将保护的cx和bx出栈恢复,并返回。
到这里整个查找文件的过程就完成了。接下来回到主流程上,继续引导程序。

cmp     ax, 0
jne     Label_LoaderFound

; loader not found
push    0x0100
push    ErrLoaderNotFoundLength
push    ErrLoaderNotFound
call    Func_PrintString

jmp     $

判断Func_FildFile的返回值。如果是0则说明没有找到文件,打印一个没找到文件的错误提示后使用jmp $循环等待。否则跳到Label_LoaderFound执行读文件过程。

Label_LoaderFound:
mov     [CurrentCluster], ax

; read FAT Table to buffer
mov     bx, BufferAddr
xor     cx, cx
Label_ReadFATTable:
mov     ax, FATTabStart
add     ax, cx
push    bx
push    ax
call    Func_ReadOneSector

add     bx, [BPB_BytesPerSec]
inc     cx
cmp     cx, [BPB_FATSz16]
jle     Label_ReadFATTable

先将Func_FindFile返回的文件首簇号存到CurrentCluster全局变量中。本来是要避免使用全局变量的,但是考虑到节省空间(偷懒),使用了一个全局变量来存。
要读文件,先要将FAT表读到内存中。因为要通过FAT表进行索引,才能找到文件所有的簇。将Buffer地址传给bx,将cx清零,并进入读取循环。将FATTabStart,也就是FAT表起始扇区号,传给ax,并加上偏移cx得到当前要读入的扇区号,然后调用Func_ReadOneSector将这个扇区读入到Buffer中。每次循环都将bx指针后移BPB_BytesPerSec个字节,在这就是512字节,来存放下一个扇区的数据。自增cx计数器后判断是否不大于Label_ReadFATTable,也就是判断是否到FAT表结尾扇区。如果没有读到FAT表结尾扇区,则继续下一次循环,读取下一个扇区,否则跳出循环。
接下来就要根据首簇号在FAT表中索引来读入整个文件了。

; BX = Loader address
mov     bx, BaseOfLoader
mov     es, bx
mov     bx, OffsetOfLoader
Label_StartRead:
mov     ax, [CurrentCluster]
add     ax, DataStart
push    bx
push    ax
call    Func_ReadOneSector

; move bx to next buffer addr
add     bx, [BPB_BytesPerSec]

mov     ax, [CurrentCluster]
call    Func_GetNextCluster
mov     [CurrentCluster], ax
cmp     ax, 0xfef
jle     Label_StartRead

先将BaseOfLoader传给bx,作为中间值存放,传给es段寄存器(段寄存器不能直接传立即数),然后再将OffsetOfLoader传给bx,这样es:bx就是BaseOfLoader:OffsetOfLoader了。
然后,将当前簇号传给ax,并加上DataStart得到其在数据区的扇区号,调用Func_ReadOneSector来读入这个扇区的内容,由于每个簇在这是一个扇区,就不用考虑多簇的情况了。
读入后,将bx指针往后移动一个扇区的字节数,也就是下一个扇区的存放处。
接着,将当前簇号传给ax,并调用Func_GetNextCluster来得到下一个簇号。Func_GetNextCluster是一个函数,实现为通过当前簇号查询FAT表得到下一个簇号。然后比较下一簇的值是否不大于0xfef,如果不大于则判断为下一簇有效,继续读入下一簇。关于FAT表中每个取值范围的意义,可以参考FAT Filesystem
这里还没有提到Func_GetNextCluster的具体实现。下面是其描述。

;;; Function:       Func_GetNextCluster
;;; Params:         AX = CurrentCluster
;;; Return value:   AX = NextCluster
;;; Descryption:    Get next cluster number according to current clus num.

Func_GetNextCluster接受ax作为参数,表示当前簇号,并通过ax返回下一簇的簇号。
下面是其实现。

Func_GetNextCluster:
push    bx
push    cx
push    bp

; use bp to judge odd
mov bp, ax

mov     bx, 3
mul     bx
shr     ax, 1 ; AX = CurClus * 3 / 2 = CurClus * 1.5
mov     bx, ax
mov     bx, [bx + BufferAddr]

shr     bp, 1
jc      Label_Odd

and     bx, 0x0fff
jmp Label_GetNextClusRet

Label_Odd:
shr     bx, 4

Label_GetNextClusRet:
mov     ax, bx

pop     bp
pop     cx
pop     bx
ret

关于FAT表的索引方法,可以参考上面提到的两个文献。里面都比较清晰地说明了计算下一个索引的方法。或者参考我写的上一章,也提到了计算方法,并使用C语言进行了实现。
首先仍然是保护寄存器。然后将ax的值传给bp,用来判断奇偶。这里的bp仅仅是用来判断奇偶的,而不是用来寻址的。接着,就要用当前簇号的值乘1.5得到当前的字节偏移。由于只需要结果的整数部分,不要求精度,所以不需要去用FP寄存器计算了。这里使用当前簇号乘3再右移1位(除2)来实现乘1.5。得到字节偏移之后,将其传给bx,用BufferAddr加上这个偏移,得到下一个簇号的一个word的值。判断当前簇号是偶数还是奇数,如果是偶数,则用这个值与0x0fff做与操作,如果是奇数,则右移4位,得到最终的下一簇簇号。
将下一簇簇号传入ax中,并恢复寄存器,然后返回。
到这里,从构造的FAT12文件系统中寻找和读入Loader文件的过程就完成了。接下来只需要一个jmp跳转过去,控制权就交给Loader了。而Loader大小可以非常大,不像Boot限制在一个扇区内,实现的时候可以不用缩手缩脚的了。
跳转到loader:

; jump to loader
jmp BaseOfLoader:OffsetOfLoader

剩下的一些字符串、全局变量和padding以及signature如下:

; Strings
StartBootMessageLength  equ 16
StartBootMessage        db 'Start booting...'
ErrLoaderNotFoundLength equ 24
ErrLoaderNotFound       db 'Error! Loader not found!'
LoaderFileName          db 'LOADER  BIN'

; values
CurrentCluster          dw  0

; padding zero
times   510 - ($ - $$) db 0
; boot signature
dw 0xaa55

0x03 总结

这一章实现了一个完整的Boot程序,在构建的FAT12文件系统根目录中查找Loader文件,将Loader载入内存中并跳转到Loader处执行。
在实现Boot的时候,我总是有一个担心:512 B到底够不够,我写到这是不是快满了?所以实现起来缩手缩脚的,很多想法都不敢去实现。事实证明,真的快满了。如下图。

编译后的Boot

能看到编译后离Signature0x55 0xAA只有红框处的一点空间了。
通过对Boot进行编写,能够熟悉FAT12文件系统的工作原理,为之后构建更复杂的文件系统打下基础。
下一章就要开始编写Loader,进行内核加载了。

当前实现的进度我都会push到Github中,可以通过Github来获取完整代码:
Github地址

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

推荐阅读更多精彩内容