iOS:isa指针

一、联合体

1. 概念

联合体,是一种特殊的数据类型,其目的是节省内存。联合体内部可以定义多种数据类型,但是同一时间只能表示某一种数据类型,且所有的数据类型共享同一段内存。联合体的内存大小等于所定义的数据类型中占用内存的最大者。

2. 互斥赋值/共用内存

允许装入该“联合”所定义的任何一种数据成员,但同一时间只能表示一种数据成员,采用了覆盖的技术;

如:

union Test {
    char name;
    int age;
    long height;
};

void printUnion(union Test t) {
    printf("%c\n",t.name);
    printf("%d\n",t.age);
    printf("%ld\n",t.height);
    printf("---------\n");
}

int main(int argc, const char * argv[]) {
    union Test t;
    t.name = 'a';
    printUnion(t);
    
    t.age = 200;
    printUnion(t);
    
    t.height = 10000;
    printUnion(t);

    return 0;
}

输出结果:

a
97
97
---------
\310
200
200
---------
�
10000
10000
---------

3. union所占内存长度

规则如下:

  • union 变量所占用的内存长度等于最长的成员的内存长度;

当然,union 所占内存长度也会受到内存对齐规则的限制,会区分不同架构,这里就不赘述了;

另外,联合体经常和位域一起使用。位域简而言之就是规定成员变量占有固定位数而不使用其类型所占大小,有几个特点:

  1. 位域的大小不能超过类型本身的大小;
  2. 位域单位为 bit 而不是字节;

举个例子:

struct {
    int aa;
    long bb;
    long cc;
} s1;

struct {
    int aa : 32;
    long bb : 32;
    long cc : 1;
} s2;
printf("%d\n",sizeof(s1)); //24
printf("%d\n",sizeof(s2)); //16

如上 s1 结构体为 4 + 8 + 8 = 20 ,然后按照 long 的 8 字节对齐为 24 字节;

而使用位域之后为 32/8 + 32/8 + 1/8 = 4 + 4 + 1 = 9 字节,然后按照 long 的 8 字节对齐为 16;

二、isa源码梳理

首先,我们肯定想直接来看看 isa ,那么尝试一下就会发现 isa 这个接口,但是会报提示:

error

也就是说,在原来的版本中 obj 可以直接访问 isa 指针,但是现在只能通过 object_getClass 来访问;

那么 object_getClass 的源码就是突破口,来看看:

Class object_getClass(id obj)
{
    if (obj) return obj->getIsa();
    else return Nil;
}

很显然,调用了 object 的 getIsa() 接口,可是搜索 getIsa() 接口发现有两个:

getIsa

那肯定是用宏定义区分了环境的,关键宏定义就是 SUPPORT_TAGGED_POINTERS,此时就引入了第一个问题~~~

1. Tagged Pointer

要说 Tagged Pointer ,大部分人应该度过 唐巧 - 深入理解 Tagged Pointer,这里就不再赘述了,总结一下:

  1. Tagged Pointer 针对的是 NSNumber、NSDate、NSString 等包装类型;
  2. 在 32 位架构向 64 位架构转换时,指向包装类型的指针从 4 字节扩大为 8 字节,浪费严重。Tagged Pointer 将指针分为 flag + data 两部分,不指向实际的内存地址。
  3. flag 部分标志该指针为 Tagged Pointer,data 部分包含实际数据;
  4. 因为不指向实际的内存地址,堆内存中就不会存在包装类型的真实对象,从而也不会有 malloc、dealloc 等操作,加快了运行速度;

一张图作总结:

Tagged Pointer

Tagged Pointer 相对于唐巧的文章,其实已经发生了比较大的变化,自己也实际测试并根据当时的情况作了下总结,相关文章:iOS:Tagged Pointer

2. SUPPORT_TAGGED_POINTERS

因为 Tagged Pointer 不指向实际的指针,那么在 objc 的 isa 体系下,原先直接返回 isa 指针的逻辑会出现问题。

比如 NSNumber 直接返回 isa 时,返回的是一个 Tagged Pointer 并不指向一个类对象,所以需要兼容 Tagged Pointer。而 SUPPORT_TAGGED_POINTERS 宏定义就是为了兼容这种情况,具体实现方式就是根据是否支持 Tagged Pointer 设计两个 ISA() 方法,如下:

不支持 Tagged Pointer 时:

// not SUPPORT_TAGGED_POINTERS

inline Class
objc_object::getIsa()  {
    return ISA();
}

此时很简单,直接调用 objc_object 的 ISA 方法返回指针即可;

支持 Tagged Pointer 时:

// SUPPORT_TAGGED_POINTERS
inline Class
objc_object::getIsa()  {
    if (fastpath(!isTaggedPointer())) return ISA();

    extern objc_class OBJC_CLASS_$___NSUnrecognizedTaggedPointer;
    uintptr_t slot, ptr = (uintptr_t)this;
    Class cls;

    slot = (ptr >> _OBJC_TAG_SLOT_SHIFT) & _OBJC_TAG_SLOT_MASK;
    cls = objc_tag_classes[slot];
    if (slowpath(cls == (Class)&OBJC_CLASS_$___NSUnrecognizedTaggedPointer)) {
        slot = (ptr >> _OBJC_TAG_EXT_SLOT_SHIFT) & _OBJC_TAG_EXT_SLOT_MASK;
        cls = objc_tag_ext_classes[slot];
    }
    return cls;
}

如上代码,第一句就先判断了该指针是否是 Tagged Pointer 类型的指针,如果不是仍然调用 ISA() 方法寻找指针,如果是,则进入了 Tagged Pointer 相关的处理逻辑;

fastpath 宏定义是编译器特性,意思是告诉编译器括号中的代码很高概率为 1,即大概率不是使用 Tagged Pointer 的 NSNumber、NSString 等类。和 fastpath 对应的是 slowpath。通过 fastpath 这种方式,编译器在编译过程中,会将可能性更大的代码紧跟着前面的代码,从而减少指令跳转带来的性能上的下降;

至于如果是 Tagged Pointer 时如何寻找真正的类对象,这里就不深入研究了。猜测大概逻辑是:一张表/数组中存储使用了 Tagged Pointer 的类,比如 NSNumber、NSString。另外,在使用 Tagged Pointer 时,不仅标记为 Tagged Pointer 为 YES,还会记录一个类似于 Index 之类的值用于寻找真正的类对象。

至此,ISA() 方法之前的逻辑已经理清楚了,总结一下逻辑吧:

ISA()

接下来看看 ISA() 方法......

3. 架构区分

搜索 objc_object::ISA 也会该方法发现存在两个,而影响其逻辑的宏定义为 SUPPORT_NONPOINTER_ISA,该宏定义的定义逻辑是:

#if !SUPPORT_INDEXED_ISA  &&  !SUPPORT_PACKED_ISA
#   define SUPPORT_NONPOINTER_ISA 0
#else
#   define SUPPORT_NONPOINTER_ISA 1
#endif

不关注 SUPPORT_PACKED_ISA,来看看 SUPPORT_INDEXED_ISA 的定义:

#if __ARM_ARCH_7K__ >= 2  ||  (__arm64__ && !__LP64__)
#   define SUPPORT_INDEXED_ISA 1
#else
#   define SUPPORT_INDEXED_ISA 0
#endif

objc 源码中全局搜索 __ARM_ARCH_7K__ 会发现:

__ARM_ARCH_7K__

也就是说这一段代码是为了判断架构是否为 armv7k 或者是 arm64_32,查阅官方文档可以发现:

armv7k 和 arm64_32

也就是说:

  1. arm64_32 是指 watchOS 上 指针为 4 字节的 arm64 架构下;
  2. armv7k 是指 armv7 在 32 位系统上的变体,也是用于 watchOS;

由此,可以发现 arm64_32 和 armv7k 都是针对 WatchOS的,所以,可以做出总结,对于 iOS 设备而言:

  1. SUPPORT_INDEXED_ISA = 0;
  2. SUPPORT_NONPOINTER_ISA = 1;

所以,后面看代码就会比较清晰了~~~

4. 源码分析

ISA() 函数的代码为:

inline Class
objc_object::ISA(bool authenticated)
{
    ASSERT(!isTaggedPointer());
    return isa.getDecodedClass(authenticated);
}

继续看 isa.getDecodedClass

inline Class
isa_t::getDecodedClass(bool authenticated) {
#if SUPPORT_INDEXED_ISA
    if (nonpointer) {
        return classForIndex(indexcls);
    }
    return (Class)cls;
#else
    return getClass(authenticated);
#endif
}

因为 SUPPORT_INDEXED_ISA = 0,所以直接看 getClass() 方法:

inline Class
isa_t::getClass(MAYBE_UNUSED_AUTHENTICATED_PARAM bool authenticated) {
#if SUPPORT_INDEXED_ISA
    return cls;
#else

    uintptr_t clsbits = bits;

#   if __has_feature(ptrauth_calls)
#       if ISA_SIGNING_AUTH_MODE == ISA_SIGNING_AUTH
    // Most callers aren't security critical, so skip the
    // authentication unless they ask for it. Message sending and
    // cache filling are protected by the auth code in msgSend.
    if (authenticated) {
        // Mask off all bits besides the class pointer and signature.
        clsbits &= ISA_MASK;
        if (clsbits == 0)
            return Nil;
        clsbits = (uintptr_t)ptrauth_auth_data((void *)clsbits, ISA_SIGNING_KEY, ptrauth_blend_discriminator(this, ISA_SIGNING_DISCRIMINATOR));
    } else {
        // If not authenticating, strip using the precomputed class mask.
        clsbits &= objc_debug_isa_class_mask;
    }
#       else
    // If not authenticating, strip using the precomputed class mask.
    clsbits &= objc_debug_isa_class_mask;
#       endif

#   else
    clsbits &= ISA_MASK;
#   endif

    return (Class)clsbits;
#endif
}

这段代码比较复杂,但是其实也就三部分:

  1. SUPPORT_INDEXED_ISA 的判断,因为 iOS 下必定是 0,直接忽略;
  2. __has_feature(ptrauth_calls) 逻辑;
  3. clsbits &= ISA_MASK;

说说第二点,__has_feature 是编译器特性,而 ptrauth_calls 指的是 Pointer Authentication Codes,简称PACs,在 A12 中被引入,即从 iphone X/XR/XS 开始被引入。

PACs 功能简单来说,因为 64 位架构下,指针基本用不满,所以在高位存储一个指针签名,使用时进行验证防止被篡改,具体可以参阅为什么 arm64e 的指针地址有空余支持 PAC?,这里就不赘述了。

总之,PACs 技术也会影响到 ISA 指针的寻址逻辑。略过 PACs 相关的代码,isa 的寻址逻辑就非常清晰了,就是两行代码:

inline Class
isa_t::getClass(MAYBE_UNUSED_AUTHENTICATED_PARAM bool authenticated) {
    uintptr_t clsbits = bits;
    clsbits &= ISA_MASK;
}

即:取出 bits 然后使用 ISA_MASK 进行掩码处理得到 isa 真实地址;

5. 结果验证

通过源码已经得出结论,那么接下来进行验证。

跑在真机 iphone7 iOS12 上,不是 iphoneX 及 A12 以上,所以 ISA_MASK0x0000000ffffffff8ULL,测试代码如下:

int main(int argc, char * argv[]) {
    NSObject *obj1 = [NSObject new];
    NSObject *obj2 = [NSObject new];

    Class cls1 = object_getClass(obj1);
    Class cls2 = object_getClass(obj2);

    NSLog(@"cls1:%@",cls1);
    NSLog(@"cls2:%@",cls2);
}

实验结果如下:

obj1

再来看看 obj2:

obj2

如上图,obj2 存储的指针和 obj1 为同一个,那都不用 MASK 了,肯定是同一个。

上述的 x/x4w 等操作涉及到大小端问题,就不再赘述了~~~

三、isa 结构体详解

objc4-818.2 中 isa 结构体源码:

union isa_t {
    isa_t() { }
    isa_t(uintptr_t value) : bits(value) { }

    uintptr_t bits;

private:
    // Accessing the class requires custom ptrauth operations, so
    // force clients to go through setClass/getClass by making this
    // private.
    Class cls;

public:
#if defined(ISA_BITFIELD)
    struct {
        ISA_BITFIELD;  // defined in isa.h
    };

    bool isDeallocating() {
        return extra_rc == 0 && has_sidetable_rc == 0;
    }
    void setDeallocating() {
        extra_rc = 0;
        has_sidetable_rc = 0;
    }
#endif

    void setClass(Class cls, objc_object *obj);
    Class getClass(bool authenticated);
    Class getDecodedClass(bool authenticated);
};

这里需要注意下 在 objc4-818.2 中,cls 被设置成了 private。看注释也可以很清楚的看到,因为 ptrauth 的原因,cls 已经不允许被直接访问了,操作 isa 指针,必须通过 setClass/getClass 方法;

其实在之前版本中,不涉及到 ptrauth,所以 isa 的联合体代码很简单,暂不研究 PACs 技术相关的实现,所以直接来看看 arm64 且 A12(iphoneX/XR/XS) 版本以下的源码 :

union isa_t {
    isa_t() { }
    isa_t(uintptr_t value) : bits(value) { }

    Class cls;
    uintptr_t bits;
#if defined(ISA_BITFIELD)
    struct {
        ISA_BITFIELD;  // defined in isa.h
    };
#endif
};

拎出 ISA_BITFIELD,使用旧版本的代码正式进入 isa 中 struct 的分析:

struct {
    uintptr_t nonpointer        : 1;
    uintptr_t has_assoc         : 1;
    uintptr_t has_cxx_dtor      : 1;
    uintptr_t shiftcls          : 33; /*MACH_VM_MAX_ADDRESS 0x1000000000*/ \
    uintptr_t magic             : 6;
    uintptr_t weakly_referenced : 1;
    uintptr_t unused            : 1;
    uintptr_t has_sidetable_rc  : 1;
    uintptr_t extra_rc          : 19
}

1. nonpointer

如果 nonpointer 为0,代表 raw isa,raw 英文是未加工的意思。比如生肉(rwa meat)、生铁(raw iron)等。所以,raw isa 就是在 iPhone 迁移到 64 位系统之前时 isa 的类型,此时的 isa 就是一个单纯的 Class 类型的指针;

如果为1,代表它不是指针,是已经优化的 isa,关于类的信息存储在shiftcls 中。

这里肯定恒为 1,为 0 也不会有这个 struct 了;

上例中可以看到 nonpointer 为 1:

nonpointer

2. has_assoc

是否有关联对象,详见iOS:关联对象(暂未完成)

3. has_cxx_dtor

iOS:析构流程(暂未完成)

4. shiftcls

真正存储类对象的位置,存储的是类对象的地址;

其实从这里就可以看出来ISA_MASK 在 A12 以下的 arm64 架构下,为什么为 0x0000000ffffffff8ULL

ISA_MASK

如上图,值为 1 的比特位正好为第 3 到 第 36 位,也就是 shiftcls 对应的位置;

那为什么这里使用 33 位就能表示一个 objc 中的指针呢?x86_64 位下为什么为 44 个呢?

首先,虚拟内存有一部分指向内核态,内核态常驻物理内存,所有进程共享。

另外,为了兼容 32 为系统,Mach-O 中有一个 Pagezero 的 Segment。mach-O 被加载进入内存之后,PageZero 会占据很大虚拟内存,多大忘记了,指向这部分内存的指针就是空指针。而 PageZero 这部分内存会覆盖整个 32 位系统下的虚拟内存大小。所以,在 64 位系统下,使用到了这个 32 位系统的虚拟指针就会被认为是空指针,进而触发一些兼容或者报错的逻辑。

最后,宏定义 MACH_VM_MAX_ADDRESS 标记了 Mach 系统下的最大虚拟内存地址,在 x86_64 和 arm64 下分别为 47 和 36 位。又因为 64 位情况下,内存最少以 8 字节对齐,所以指针的后三位必定为空,所以可以直接把最后三位去掉。

因此,shiftcls 的大小就是 33 或 44 个比特位。

另外,节约下来的 3 个比特位可以用来存储 3 个标记。但是存取这三个标记就涉及到位运算了,所以就能看到在 object_class 中的 rw 的 data()setData() 就有位运算操作。

详见:

  1. iOS底层原理:PAGEZERO的作用
  2. iOS类加载流程(二):类的静态初始化
  3. Java、OC、C/C++中的null

5. magic

用于调试器判断当前对象是真的对象还是没有初始化的空间 , 固定为 0x1a

6. weakly_referenced

是否存在弱引用,即是否有被 __weak 修饰的对象引用。这个对象在被释放之前,也就是析构函数中会判断这个标志位是否为 YES,如果是,则会去弱引用表中查找弱引用了该对象的指针并将其置为 nil。这也是 weak 和 assign 的本质区别。

弱引用表使用 SideTable 表示,其逻辑如下:

SideTable

详见:iOS:SideTable

7. unused

未知,应该是做保留位用;

8. has_sidetable_rc

是否有 sidetable;

extra_rc 在溢出时,会新建一个 sidetable,并将一半的引用计数器转移到 sidetable 中。这个标志位如果存在,则表明计算引用计数器时需要考虑到 sidetable 中的值;

9. extra_rc

其意义为:对象的引用计数 ;

这里需要注意,extra_rc 的含义是区分版本的:

  1. objc4-818.2:extra_rc = 引用计数器
  2. objc4-818.2 之前:extra_rc = 真实引用计数器 - 1(真实引用计数器 = extra_rc + 1);

objc4-818.2 中的源码:


rootRetainCount

objc4-756.2 中的源码:

rootRetainCount

与之匹配的,在 objc4-818.2 的 objc_object::initIsa() 方法中,将 extra_rc 设置为了 1;

接下来,看个实例,运行在 iOS12 真机上:

extra_rc和weakly_referenced

如上图,代码是 818.2 之前的版本,obj1 的引用计数器很明显为 3,所以 extra_rc = 3 - 1 = 2;

在来看看 obj2 是不是 1 进行对比确认:

obj1

如图,obj2 的 extra_rc = 1-1 = 0,且没有被 __weak 对象引用,所以 weakly_referenced 为 0;

至此,isa 的基础知识全部梳理完毕,接下来研究下 sidetable 的详细实现,一张图总结:

isa结构体

(注:图片来自“style_月月”的博客)

三、补充

1. sidetable 的使用

详见:iOS:SideTable

2. isa和superClass指向

这玩意讲太多,都讲烂了,相关的面试题也比较多,这里就不赘述了。关系如图:

isa和superClass指向

这里需要重点关注的是:

  1. 所有元类对象的 isa 指针都指向跟元类;
  2. 根元类(Meta_NSObject)的 superClass 指针指向跟类对象(NSObject);
  3. 跟类对象(NSObject)的 superClass 指向为nil;

由以上三点延伸出来的面试题就不再赘述了,真忘记了,看源码或者写代码做测试是最直接的,死记硬背一些面试题,个人感觉没什么太大意义~~~

实例对象中未存储 superClass,其信息存储在类对象/元类中。使用 super 关键字进行方法调用时,本质是调用 super_objcMsgSend()

四、疑问

1. shiftcls 为什么占 33 和 44 位?

根据 isa 的 shiftcls 中的注释可知,不同架构下存在 MACH_VM_MAX_ADDRESS ,根据平台会有不同,可以在 vm_param.h 中查到具体数值,比如 i386 上如下:

MACH_VM_MAX_ADDRESS

也就是说,这个 MACH_VM_MAX_ADDRESS 标记了不同架构下,CPU 寻址的最大地址。

cpu 架构从 32 位升级到 64 位之后,理论上,CPU 寻址能力从 4G 一下子变成 16777216 TB,但是实际上并不会用到这么多。所以这个值就标记了不同架构下 CPU 使用到的最大地址。

但是,仍然有个疑问,MACH_VM_MAX_ADDRESS 最大地址对应的值,在 x86_64 架构下的最大地址有 47位,而在 arm64 下有 37 位:

arm64
x86_64

shiftcls 分配的位数只有 44 和 33,为什么还少 3 位呢?

这是因为:

  • 在以 8 字节对齐的情况下,内存地址中的低三位永远为 0 (2的3次方)。

所以,理论上需要使用 37 和 47 个 bit 来存储内存地址,但是因为最后的 3 个比特位永远为 0,所以在存储和读取的时候使用位运算来转一下就可以了。这样,节约下来的 3 位可以用来存储 nonpointerhas_assochas_cxx_dtor,岂不是完美?

8 字节对齐的情况下,这三位必须放在低三位,而且也只能容纳 3 位,再多出 1 位,指针的值上,这个第四位就可能为 1 了。另外,iOS 上是 16 byte 对齐的,这个就是另一个知识点了~~

还需注意一点,内存对齐的单位是字节(byte),指针存储时的用量单位是 bit。这是因为 cpu 取数据的基本单位是 n 字节,n 就是内存对齐规则的基础数。而指针是一个数字,指向某个具体的内存块,所以存储指针使用 bit;

另外,简述一下内存对齐。因为 CPU 从寄存器取数据不是每次只取 1 byte 而是有一定的颗粒。正因为这个存取颗粒的存在,如果不对数据按照存取颗粒进行对齐,那么就会出现一个颗粒的数据需要两次才能取到完整的数据:

内存对齐

所以,所有的数据按照内存对齐之后,CPU 访问的内存地址一定是某个数据块的起始地址,而这个数据块在代码层面就可能是变量、结构体等。这样 CPU 取数据的效率就会大大提高。

位运算相关的代码:

shiftcls位运算

2. objc4-818.2 中为什么要改变 extra_rc 的计算逻辑?

存疑~~

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

推荐阅读更多精彩内容