iOS之武功秘籍①:OC对象原理-上(alloc & init & new)

iOS之武功秘籍 文章汇总

写在前面

春节的夜晚,十分的难以入睡,梦醒时分,翻开秘籍最新objc4-818.2源码,有个小伙在渐渐的发着呆......

一、探索的线索和方向

拿到秘籍的那一刻,脑子就一直在高速的运转着,要怎么才能学好呢?

我们想着手开始探索"武林绝学"(iOS的底层),但又不知道从哪里开始,怎么办呢?

那就从main函数入手!

我们先开启上帝视角!来观察一个粗略的加载流程.进行准备工作:

  • main函数中直接打断点,然后我们这时打印一下堆栈信息瞧瞧(bt - lldb 调试指令打印堆栈信息)

嗯哼,我们都知道main函数是非常之早的,但是结果告诉我们在main函数之前,系统还做了其他的!!那么在main函数之前还有什么呢?来我们来瞧瞧

  • 添加三个符号断点libSystem_initializerlibdispatch_init_objc_init


    我们按上图操作依次添加好libSystem_initializerlibdispatch_init_objc_init符号断点

然后我们来运行一下程序看看:

此时会来到我们下的第一个符号断点libSystem_initializer,通过堆栈信息我们会看到程序会来到非常著名的dyld,经过一系列流程后在来到libSystem_initailizer.这也就从dyld来到了libSystem库.

接下来会来到我们的第二个符号断点libdispatch_init,也就来到了libdispatch库了

libdispatchGCD的源码,我们后续在研究这个.
过掉这个断点来到我们下的第三个符号断点_objc_init,也就来到了libobjc的底层,它是整个一个runtime的一些源码.

过完以上三个断点才会来到我们熟悉的main函数.

过掉main函数的断点就会来到我们熟悉的了

走完这些流程,可能有些小可爱会问?咦,你这咋有这么详细的堆栈信息呢?

只需关闭 Xcode 左侧 Debug 区域最下面的第一个按钮就行 show only stack frames with debug symbols and between libraries

Snip20200918_56.png

到此我们来总结一波.

通过以上的堆栈信息,我们可以总结一个简单的加载流程:
  • dyld启动加载动态库、共享内存、全局C++对象的构造函数的调用、一系列的初始化、dyld注册回调函数
  • libsystem 的初始化 libSystem_initializer
  • libdispatch_init 队列环境的准备
  • _os_object_init 过渡到 _objc_init
  • 以及_dyld_objc_notify_register 镜像文件的映射
  • 类-分类-属性-协议-SEL-方法 的加载
  • 展开分析 Runtime 各个部分的原理
  • main函数的启动

这里面的分析角度和思维都是比较有意思的,为了让大家有比较好的体验感.接下来,我们先从大家都比较熟悉的OC对象开始分析吧.

二、alloc原理初探 一 OC对象的alloc

我们要研究对象,肯定要从创建开始研究的!下面我有一个非常有意思的提问,小伙伴们不妨花个十秒钟思考一下!来代码如下:


%@ 打印对象 %p 打印地址 &p 指针地址

问题:
1.这里p1对象是否创建完成
2.p1、p2、p3以及p4是否为同一个对象


不知道你脑海中的答案是否和上面的打印一致:

  • 从上面可以得出我们创建了四个临时对象p1、p2、p3、p4
  • p1、p2、p3这三个对象的指针是不同的但是他们所指向的内存是同一片,而p4对象的指针和他所指向的内存地址都和p1、p2、p3不同(为什么呢? - 看完本编你就知道为什么了)
    • 遗留问题:
    • ①.p1、p2、p3对象和地址打印都一致, 为何&p打印不一致?
    • ②.p4的地址为什么和p1、p2、p3都不一样?
  • 从反向可以证明alloc才是创建对象-开辟内存
  • init只是一个初始化构造函数.
  • newalloc出了另一内存空间

嗯哼,alloc出来就已经把对象的内存地址确定了,那么是怎么确定的呢?下面开始探索

  • 现在我们跳进这个万恶之源(通过Command+单击->Jump to Defintion的方式进入)

  • 发现跳不进去查看实现,怎么办,请来到 objc4官方源码objc4小编配好可运行的源码,接下来几天都会动不动就进去了!!我希望每一个小伙伴都不要只在这外面蹭一蹭,深层交流才有意义

  • 没有注释

  • 没有源码实现

  • 更加不知道下一步流程

发现进不去了,怎么办?看不到具体的源码实现!很多时候我们经常也会遇到这样的情况,就是想做一些事,就是碰壁,无从下手!大家请注意这里:我要开始装逼咯!

三、alloc底层探索思路(底层探索分析的三种方法)

下面介绍三种方式来查看他的实现.

方法一:符号断点直接定位

添加alloc符号断点(在前面(探索的线索和方向)已经介绍了怎么加符号断点)

  • 先将alloc符号断点先置灰(alloc函数在很多地方被调用,在到达我们目标位置前,先置灰)
  • Xcode开启运行,程序到达[TCJPerson alloc]断点后,开启alloc符号断点
  • 点击 Xcode日志栏的继续运行按钮

结果如下


  • [NSObject alloc] 成功看到所在链接库libobjc.A.dylib
  • 其底层调用的就是 _objc_rootAlloc函数

方法二:代码跟踪 - control + step into

  • ①关掉之前的相关符号断点,来到研究对象断点处


  • ②按住键盘control键+鼠标点击 Xcode日志栏的step into按钮

进去后可以看到objc_alloc

  • ③如果你是用真机的请继续第二部的操作,后来到
  • ④如果你是用模拟器的话,在第二部后需要添加objc_alloc符号断点后,点击 Xcode日志栏的继续运行按钮

  • ⑤不管你是真机还是模拟器最终都来到了libobjc.A.dylib,进而也看到了底层objc_alloc

  • ⑥和方法一不谋而合

方式三:汇编进入分析

  • ①关闭其他的符号断点,来到研究对象断点

  • Xcode 工具栏 选择 Debug --> Debug Workflow --> Always Show Disassembly,这个 选项表示 始终显示反汇编 ,即 通过汇编 跟流程

  • ③在汇编显示16行处添加断点到objc_alloc

  • ④如果你是用真机操作,按住control键和step into键结果如下:

之后继续按住control键和step into键得到:

  • ⑤如果你是用模拟器的话,在第三步添加符号断点后,按住control键和step into键结果如:

之后需要添加objc_alloc符号断点后,点击 Xcode日志栏的继续运行按钮

  • 嗯哼libobjc.A.dylib - objc_alloc: 也就轻松得到!

此时此刻,还有谁!就这些东西能难倒我们?不存在的

四、alloc流程分析

①.汇编配合源码跟流程

通过前面alloc底层探索思路(底层探索分析的三种方法)的介绍,我们知道了三种探索底层实现的方法,那我们来玩一玩.
我们打开准备好的可编译的objc4源码
我们刚刚前面查到了alloc流程,我们在源码里面搜索一下:

在源码里面看到了alloc方法,我的天,好高兴啊,来到这里就有底层的实现.我们点击_objc_rootAlloc方法来到:

继续点击callAlloc方法来到:

到这的源码可能就会让你头晕目眩,不想看了

本来看源码就枯燥,还有这么多if-else逻辑岔路口,就会有很多人关闭了Xcode.

看啥不好看源码,是嫌自己头发太旺盛吗?

别急,我这里已经帮你掉过头发了(捋过思路了)

那么他到底走的是哪一个流程呢?我们来验证一下

汇编和源码同步辅导来跟流程

  • 在我们的第一份代码里面加入我们刚刚捋过的三个符号断点_objc_rootAlloccallAlloc_objc_rootAllocWithZone.

  • 先关闭符号断点,来到我们的研究对象断点处
  • 打开我们刚刚下的三个符号断点,来到第一个符号断点_objc_rootAlloc:

  • 过掉此_objc_rootAlloc断点来到了_objc_rootAllocWithZone断点:

来我们根据刚刚看的源码来捋个草图:


根据源码我们知道在callAlloc的时候出现了分叉:objc_msgSend_objc_rootAllocWithZone,那么他到底是往那个分叉走的呢?根据刚刚我们的走的汇编,我们得到的是走的_objc_rootAllocWithZone.
而我们跑汇编跟流程的时候,只断了两下即:objc_rootAlloc直接来到了_objc_rootAllocWithZone.然后callAlloc这个断点变没有断住?为什么呢?请看下文

②.编译器优化

我们先来看下面的例子(使用真机调试,看汇编):

运行程序得到汇编代码:

看到结果有些小伙伴可能会问?为什么有wx呢?
这涉及到寄存器的知识.w代表32位,x代表64位.那为什么我们跑到真机上还有w呢?这考虑到兼容问题,例如我们存储一个int = 10类型的数据,在32位下就能存储,不需要用64位.

寄存器 - 其寄存器的作用就是进行数据的临时存储

  • ARM64拥有有31个64位的通用寄存器 x0 到 x30,这些寄存器通常用来存放一般性的数据,称为通用寄存器(有时也有特定用途)
    • 比如x0 ~ x7 用来存储参数,x0主要用来存储参数和接收返回值.
    • 那么w0 到 w28 这些是32位的. 因为64位CPU可以兼容32位.所以可以只使用64位寄存器的低32位.
    • 比如 w0 就是 x0的低32位!
  • 通常,CPU会先将内存中的数据存储到通用寄存器中,然后再对通用寄存器中的数据进行运算

我们刚刚在 int a = 10处打了一个断点,那么哪个代表他呢,我们打印一下:

接下来又来到 mov w9, #0x14:

接下来来到add w9, w9, w10即: 10 + 20 放到 w9 里面:

在正常开发过程中我们都是Debug模式下,想要提高编译速度,可将Debug环境也选中Fastest,Smallest[-OS]模式:

  • target ->BuildSettings: 搜索:optimization
    我们发现Optimization Level中,Release环境下,已自动选择Fastest,Smallest[-OS]
  • 接下来我们将Debug模式下也选中Fastest,Smallest[-OS]模式:

Fastest,Smallest[-OS]模式下,会发现汇编页面展示的代码已精简很多:

我们直接读取一下:

那么Fastest,Smallest[-OS]代表什么意思呢?就是按照最快最小的路径来执行.

在下来我们看源码的过程中都会看到有很多的过程都会被优化掉 - 这就是编译器的强大.
这也就是我们在发布版本的时候要调到Release版本(现在苹果在我们发版的时候会自动帮我们选择Release环境,早期的时候需要我们手动设置选择). 因为Release环境下,系统自动选择Fastest,Smallest[-OS]模式,完成编译器优化,节省性能.

③.alloc源码流程

我们先来看下面的代码

接下来我先给出他们各自调用alloc方法后的堆栈详情图:


看到上面的调用堆栈图,我们不难发现两个问题:
问题一:不管我是NSObject类,还是自定义的TCJPerson类调用alloc方法为什么最开始走的是objc_alloc
问题二:NSObject没有走alloc方法
问题三:自定义的TCJPerson类为什么走了两次callAlloc

③.1 objc_alloc 方法

为什么首先会来到objc_alloc?

第一处解释:源码中的Calls [cls alloc]告诉我们,当我们调用alloc方法时底层是调用

第二处解释:我们一起来看看汇编代码:

汇编代码也告诉我们首先调用的是objc_alloc.

第三处解释:需要借助llvm源码来帮助我们.

  • 打开llvm源码文件(用Xcode打开比较慢,可用Visual Studio CodeVSCode打开),搜索alloc,找到CGObjC.cpp文件

  • 可以看到这里有明确标注,[self alloc] -> objc_alloc(self)

  • 函数中显示,当接收到alloc名称的selector时,调用EmitObjCAlloc函数.继续全局搜索EmitObjCAlloc:

由此可以得出当我们调用alloc方法时会调用 objc_alloc,其实这部分是由系统在llvm底层帮我们转发到objc_alloc的.llvm在我们编译启动时,就已经处理好了.

我们来验证一下:

  • 首先来我们的研究对象断点处:
  • 接着在objc4源码中的objc_alloc方法实现处打下断点:


  • 结果都来到了objc_alloc方法,接着调用callAlloc方法.

  • 那么问题一问题二的答案我们相信大家都知道了吧.

③.2 callAlloc 方法

static ALWAYS_INLINE id 中的 ALWAYS_INLINE说明
inline 是一种降低函数调用成本的方法,其本质是在调用声明为 inline 的函数时,会直接把函数的实现替换过去,这样减少了调用函数的成本. 是一种以空间换时间的做法.

#define ALWAYS_INLINE inline __attribute__((always_inline))
ALWAYS_INLINE宏会强制开启inline

②if (slowpath(checkNil && !cls))判断

#define fastpath(x) (__builtin_expect(bool(x), 1))
#define slowpath(x) (__builtin_expect(bool(x), 0))

这两个宏使用__builtin_expect函数

__builtin_expect(EXP, N)
__builtin_expect是gcc引入的

  • 作用: 允许程序员将最有可能执行的分支告诉编译器.编译器可以对代码进行优化,以减少指令跳转带来的性能下降.即性能优化
  • 函数: __builtin_expect(EXP, N) 表示 EXP==N的概率很大

fastpath:定义中__builtin_expect((x),1)表示 x 的值为真的可能性更大;即 执行if 里面语句的机会更大
slowpath:定义中的__builtin_expect((x),0)表示 x 的值为假的可能性更大。即执行 else 里面语句的机会更大

在日常的开发中,也可以通过设置来优化编译器,达到性能优化的目的,设置的路径为:Build Setting --> Optimization Level --> Debug --> 将None 改为 fastest 或者 smallest(前面有介绍)

③if (fastpath(!cls->ISA()->hasCustomAWZ()))判断
跟进hasCustomAWZ()实现可发现:

FAST_CACHE_HAS_DEFAULT_AWZ的定义为:

判断的主要依据:还是看缓存中是否有默认的alloc/allocWithZone方法(这个值会存储在metaclass中).

而对于NSObject类而言就有少许不同了:因为NSObject的初始化,系统在llvm编译时就已经初始化好了.因此缓存中就有alloc/allocWithZone方法了.即hasCustomAWZ()false那么!cls->ISA()->hasCustomAWZ()就为true:

而我们自定义的TCJPerson类初次创建是没有默认的alloc/allocWithZone实现的。所以继续向下执行进入到msgSend消息发送流程,调用[NSObject alloc]方法,即就是alloc方法,接着会来到_objc_rootAlloc,后再次来callAlloc,而这次因为调用的是NSObject类的,所以缓存中存在alloc/allocWithZone实现,接着走_objc_rootAllocWithZone方法.

自定义类第一次进入callAllocmsgSend消息发送流程:

第二次进入callAlloc_objc_rootAllocWithZone:

到这也就解释了问题三:自定义的TCJPerson类为什么走了两次callAlloc.

③.3 alloc 方法

③.4 _objc_rootAlloc 方法

③.5 callAlloc 方法(自定义类二次进入)

调用 NSObject[NSObject alloc]不会来到③.3-③.4-③.5这个流程,只有自定义的类TCJPerson调用[TCJPerson alloc]才会来到③.3-③.4-③.5这个流程

③.6 _objc_rootAllocWithZone 方法

③.7 _class_createInstanceFromZone 方法 (alloc的核心方法)


hasCxxCtor()

hasCxxCtor()是判断当前class或者superclass是否有.cxx_construct 构造方法的实现

hasCxxDtor()

hasCxxDtor()是判断判断当前class或者superclass是否有.cxx_destruct 析构方法的实现

canAllocNonpointer()

canAllocNonpointer()是具体标记某个类是否支持优化的isa,即是对 isa 的类型的区分,如果一个类和它父类的实例不能使用 isa_t 类型的 isa 的话,返回值为 false.在 Objective-C 2.0 中,大部分类都是支持的.

size = cls->instanceSize(extraBytes)

instanceSize(extraBytes) 计算需要开辟的内存大小,传入的extraBytes 为 0

跳转至instanceSize的源码实现

通过断点调试,会执行到cache.fastInstanceSize方法

继续跟断点,进入align16源码实现(16字节对齐算法):

既然提到了内存对齐(后面文章会详细讲解),那我们就来预热一下:

内存字节对齐原则

在解释为什么需要16字节对齐之前,首先需要了解内存字节对齐的原则,主要有以下三点:

  • 数据成员对齐规则:struct 或者 union 的数据成员,第一个数据成员放在offset0的地方,以后每个数据成员存储的起始位置要从该成员大小或者成员的子成员大小(只要该成员有子成员,比如数据、结构体等)的整数倍开始(例如int32位机中是4字节,则要从4的整数倍地址开始存储)
  • 数据成员为结构体:如果一个结构里有某些结构体成员,则结构体成员要从其内部最大元素大小的整数倍地址开始存储(例如:struct a里面存有struct bb里面有char、int、double等元素,则b应该从8的整数倍开始存储)
  • 结构体的整体对齐规则:结构体的总大小,即sizeof的结果,必须是其内部最大成员的整数倍,不足的要补齐.

为什么需要16字节对齐

  • 提高性能,加快存储速度:
    通常内存是由一个个字节组成,cpu在存储数据时,是以固定字节块为单位进行存取的.这是一个以空间换时间的一种优化方式,这样不用考虑字节未对齐的数据,极大节省了计算资源,提升了存取速度。
  • 更安全
    由于在一个对象中,第一个属性isa8字节,当然一个对象可能还有其他属性,当无其他属性时,会预留8字节,即16字节对齐.因为苹果公司现在采用的16字节对齐(早期是8字节对齐--objc4-756.2及以前版本),如果不预留,就相当于这个对象的isa和其他对象的isa紧挨着,在CPU存取时它以16字节为单位长度去访问的,这样会访问到相邻对象,容易造成访问混乱,那么16字节对齐后,可以加快CPU读取速度,同时使访问更安全,不会产生访问混乱的情况

下面以align16(size_t 8)->(8 + size_t(15)) & ~size_t(15)为例,图解16字节对齐算法的计算过程,如下所示

  • 首先将原始的内存 8size_t(15)相加,得到 8 + 15 = 23其二进制:0000 0000 0001 0111
  • size_t(15)15的二进制:0000 0000 0000 1111进行~(取反)操作其取反二进制为:1111 1111 1111 0000~(取反)的规则是:1变为0,0变为1
  • 最后将 23的二进制15的取反结果的二进制 进行 &(与)操作,&(与)的规则是:都是1为1,反之为0,最后的结果为0000 0000 0001 000016(十进制),即内存的大小是以16的倍数增加的.

calloc()

用来动态开辟内存,返回地址指针.没有具体实现代码,接下来的文章会讲到malloc源码

(这里的zone基本是不会走的,苹果废弃了zone开辟空间,并且这里zone的入参传入的也是nil

根据size = cls->instanceSize(extraBytes)计算的内存大小,向内存中申请大小为size的内存,并赋值给obj.

  • 执行前打印obj只有cls类名,执行后打印,已为成功申请内存的首地址了.
  • 但并不是我们想象中的格式<TCJPerson: 0x0000000101906140>,这是因为这一步只是单纯的完成内存申请,返回首地址.
  • 而类和地址的关联:是在接下来我们要说的obj->initInstanceIsa(cls, hasCxxDtor)完成

obj->initInstanceIsa(cls, hasCxxDtor) 类与isa关联

已知zone=false,fast=true,则(!zone && fast)=true

内部调用initIsa(cls, true, hasCxxDtor) 初始化isa指针,并将isa指针指向申请的内存地址,在将指针与cls类进行关联(具体的isa结构和绑定关系,后续会作为单独章节进行讲解)

经过initIsa后,打印obj,此时发现地址与类完成绑定:

在_class_createInstanceFromZone中,主要做了3件事,1.计算对象所需的空间大小;2.根据计算大小开辟空间,返回地址指针;3.初始化isa,使其与当前对象关联

到此处一个TCJPerson对象就创建完成了.

五、init源码分析

那么init 做了什么?
init什么也不做,就是给开发者使用工厂设计模式提供一个接口


补充:关于子类中if (self = [super init])为什么要这么写——子类先继承父类的属性,再判断是否为空,如若为空没必要进行一系列操作了直接返回nil.

就是一个初始化的构造方法!提供构造能力:比如array初始化 字典 还有button 这就是给工厂设计!

六、new源码分析

那么 new 又做了什么?

  • 底层就是调用了 alloc 下层的 callAlloc 创建对象
  • 然后调用了 init 的初始化方法
  • new 方法也就是为了方便直接!

但是一般在开发过程中不建议使用new,主要是因为有时会重写init方法做一些自定义的操作.

写在后面

最后我们来一起解答前面最开始留下的两个问题:

  • ①.p1、p2、p3对象和地址打印都一致, 为何&p打印不一致?
  • ②.p4的地址为什么和p1、p2、p3都不一样?

解答:

问题1:p1、p2、p3对象和地址打印都一致, 为何&p打印不一致?
其实说白了alloc就做到了对象指针的确定,我们开辟内存真正的家伙就是alloc. 他们的指针都是同一个,但是因为都是不同对象接受而已,所以执行不同的地址,即&p打印的是他们自身的地址
问题二:p4的地址为什么和p1、p2、p3都不一样?
因为p1、p2、p3是同一个alloc开辟出来的,而p4是new出来的,new会单独调用alloc. 所以他们打印肯定不一样.

总结:

  • 对象的开辟内存交由 alloc 方法封装
  • init 只是一种工厂设计方案,为了方便子类重写:自定义实现,提供一些初始化就伴随的东西
  • new 封装了 alloc 和init
  • 这一篇里面也涉及了一些探索的思路和方法:
    • 源码跟入
    • 汇编分析
    • 符号断点设置
  • 和谐学习,不急不躁.我还是我,颜色不一样的烟火.
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,324评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,303评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,192评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,555评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,569评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,566评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,927评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,583评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,827评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,590评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,669评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,365评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,941评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,928评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,159评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,880评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,399评论 2 342

推荐阅读更多精彩内容