【翻译】Cocoa Touch 64位转移指南

原文链接:64-Bit Transition Guide for Cocoa Touch

将你的App转换成64位的二进制文件

总的来说,下面列出了创建一个同时适用32位和64位运行时环境的app的步骤:

  1. 安装最新版的Xcode
  2. 打开你的工程。Xcode会提示你去更新你的工程。这使得编译64位的app的时候会报新的warning和error。
  3. 修改你的工程设置以支持iOS 5.1.1或更高版本。目标iOS版本低于5.1.1时不支持编译64位工程。
  4. 把工程的编译设置中的体系结构置为“Standard Architectures (including 64-bit).”
  5. 更新app以支持64位运行时环境。新的编译器报的warning和error会帮助你实现这个步骤。然而它不能帮你搞定所有的事,本文档能在调查你的代码的时候帮助你。
  6. 在真实的64位的硬件上测试你的app。iOS模拟器在开发时很有用,但有些变化(例如函数调用的规则)只能在真机运行时看出来。
  7. 使用Instruments来测试app的内存表现。
  8. 提交一个包含32位和64位两种体系结构的app以供审核。
    本章剩余部分描述了一些把app转移到64位运行时环境常见的问题。这些段落能帮你仔细研究你的代码。

不要把指针强转为整型数

总有些强转指针到整型数的理由。当一致地用指针类型时,你可以保证所有的变量都足够大到放得下一个地址。
例如,代码清单2-1中的代码把一个指针转成了一个int,因为它想对地址进行算术运算。在32位运行时中,这段代码是有效的,因为一个int和一个指针大小相同。然而在64位运行时中,指针比int要大,所以这个赋值操作丢失了指针的一部分数据。正确的做法是直接递增这个指针,因为指针会随着本机的内存地址的宽度而改变。

代码清单2-1 把指针强转为int

int *c = something passed in as an argument....
int *d = (int *)((int)c + 4);  // 错误。
int *d = c + 1;                // 正确!

如果你一定要这样做,请使用uintptr_t类型来避免截断。通过整数运算来改变指针的值,然后在转回指针会违反基本类型别名的规则,请注意。这也会导致编译器的一些意外的行为,以及访问不对齐的指针时处理器错误。

数据类型前后一致

代码中数据类型前后不一致会导致很多常见的程序错误。虽然编译器会对这样的行为给出警告,但我们还是有必要来看一些这种问题来帮助我们在代码中识别这种不一致。
当调用函数时,要保持接受返回结果的变量和函数返回值类型保持一致。如果返回值类型比接受返回结果的变量更长,那么这个值会被截断。代码清单2-2展示了这种问题的一个例子。函数PerformCalculation的返回值是long型。在32位环境下,intlong都是32位的,所以把返回值赋值给一个int变量是不会出错的,虽然这段代码是不正确的。在64位环境下,这样赋值会导致返回值的高32位丢失。所以这个返回值应该被赋予一个long型数,这样在两种环境中都是正确的。

代码清单2-2 返回值赋值时发生截断

long PerformCalculation(void);

  int x = PerformCalculation();  // 错误
  long y = PerformCalculation();  // 正确!

同样的错误会在传参的时候发生。例如,在代码清单2-3中,在64位环境下入参会被截断。

代码清单2-3 传入参数时发生截断

int PerformAnotherCalculation(int input);
  long i = LONG_MAX;
  int x = PerformAnotherCalculation(i);

在代码清单2-4中,在64位环境下返回值同样会被截断,因为返回的值超过了函数返回值类型的范围。

代码清单2-4 返回值发生截断

int ReturnMax()
{
  return LONG_MAX;
}

这些例子都假定intlong是等长的。ANSI C 标准没有这个假设,并且这样在64位环境中是完全错误的。如果你的工程是最新的,编译器的-Wshorten-64-to-32选项是默认自动开启的,所以编译器在大多数情况下会自动发出关于截断的警告。如果你的工程不是最新的,你应该明确地启用这个编译器选项。或者你也可以包含-Wconversion选项,这虽然有冗余但是会找出更多潜在的错误。
在Cocoa Touch app中,找到使用下列整型数的地方,并保证他们使用正确:

  • long
  • NSInteger
  • CFIndex
  • size_tsizeof结果的真正类型)
    在32位和64位这两种环境中,fpos_toff_t类型长度都是64位,所以不要把它们赋值给int变量。

枚举也是有类型的

在LLVM编译器中,枚举类型可以指定枚举值的大小。这意味着有时候枚举类型的大小会超过你的预期。解决方案和上面类似,不要假定枚举值数据类型的大小。而应该将枚举值赋给合适类型的变量。

Cocoa Touch中常见的类型转换的问题

Cocoa Touch,特别是Core Foundation和Foundation,除了上述的问题还有额外的需要关注的情况,因为它们提供了序列化C数据和在Objective-C对象中拿到C数据的方式。
NSInteger在64位中的长度和32位中的不同。NSInteger的使用范围遍布整个Cocoa Touch,它在32位环境是32位整数,在64位环境是64位整数。所以当我们从框架中的一个方法获得NSInteger的值的时候,应该使用NSInteger的变量来保存这个结果。
即使你永远不要假定NSIntegerint一样长,我们也需要查找一下以下一些极端情况的例子:

  • 转到NSNumber对象或者从NSNumber对象转来时。
  • NSCoder类编码或解码一段数据时。特别地,如果你在64位环境下编码一个NSInteger然后在32位环境下解码,如果结果超过了32位整数的范围,解码的方法会抛出一个异常。你大概会需要使用一种严格的整数类型来替代它(参见使用严格的整数类型
  • 当使用框架内定义的NSInteger常量时。特别注意的是常量NSNotFound。在64位环境中这个值比int的最大值更大,所以如果这个值发生截断通常会带来错误。
    CGFloat在64位中的长度和32位中的不同。CGFloat类型变为64位的浮点数。和NSInteger一样,你不能假定CGFloatfloat还是double。所以一致地使用CGFloat。代码清单2-5展示了一个例子,使用Core Foundation来创建了一个CFNumber变量。但是这段代码假定CGFloatfloat一样长,这是不对的。

代码清单2-5 一致使用CGFloat

// 错误。
CGFloat value = 200.0;
CFNumberCreate(kCFAllocatorDefault, kCFNumberFloatType, &value);

// 正确!
CGFloat value = 200.0;
CFNumberCreate(kCFAllocatorDefault, kCFNumberCGFloatType, &value);

小心地计算整型数

虽然截断是最常见的问题,你还可能遇到其他与整型数变化相关的问题。这一节包含了一些额外的信息可以帮助你更新代码。

C和C衍生语言的符号扩展规则

C以及类似的语言使用一套符号扩展规则,它决定了当把值赋到一个更宽的变量时是否把整数的最高位当做符号位。这套规则如下:

  1. 当赋值给更宽的类型时,无符号值高位补0(而不是补符号位)。
  2. 带符号值永远补符号位,及时结果类型是无符号的。
  3. 常量(除非是有后缀的,例如0x8L)被认为是可以放得下这个值的最短类型。编译器认为十六进制形式的字面量是intlonglong long类型的,并且有可能是signed或者unsigned。十进制数都被认为是signed
  4. 同样宽度的带符号值和无符号值的和是无符号的。
    代码清单2-6的例子展示了这些规则意外的行为以及相应的解释。

代码清单2-6 符号扩展例1

int a = -2;
unsigned int b = 1;
long c = a + b;
long long d = c; // 为了格式化输出

printf("%lld\n", d);

问题:当这段代码运行在32位环境时,结果是-10xffffffff)。当运行在64位环境时,结果是42949672950x00000000ffffffff),这结果可能不是你所期望的。
原因:为什么会这样?首先,这两个数相加。带符号加无符号结果是无符号的(规则4)。然后这个值赋到了一个更宽的类型的变量中。而这一步并没有扩展符号位。
解决:为了修复这个bug并且兼容32位,需要把b转换成long型的整数。这个转换会在加法操作之前强制把b通过无符号扩展变成64位,因此强制把那个带符号整数(带符号地)扩展成64位来匹配b。这样改之后,结果应该是-1了。
代码清单2-7展示了一个相关的例子以及相应的解释。

代码清单2-7 符号扩展例2

unsigned short a = 1;
unsigned long b = (a << 31);
unsigned long long c = b;

printf("%llx\n", c);

问题:预期的结果(32位执行的结果)是0x80000000。64位执行的结果却是0xffffffff8000000
原因:为什么发生了符号扩展?首先当移位操作符调用的时候,变量a被隐式转换为int。因为所有short值都包含在带符号的int范围内,所以转换的结果是带符号的。
其次当移位完成时,结果存入了一个long型整数。因此,当(a << 31)代表的32位的值转换到64位的值的时候进行了符号扩展(规则2),即使结果类型是无符号的。
解决:在移位操作之前把初始值转成long型数。这个short值只进行一次转换——而这次是转换到64位类型(当编译成64位可执行文件时至少要到64位)。

使用位操作和掩码

当对64位的值进行位操作和使用掩码时,你希望避免不经意地得到32位的值。下面有一些有帮助的建议。
不要假定一种数据类型有特定的长度。如果你正在遍历一个long型数的位,使用LOG_BIT来确定long型数有多少位。超出长度的移位的结果取决于不同的体系结构。
如果需要的话,使用取反的掩码。当对long型数使用掩码时要谨慎,因为在32位和64位环境中long的宽度是不同的。有两种创建掩码的方法,选择哪一种取决于你希望掩码是高位补0还是补1:

  • 如果你希望在64位环境下掩码的高32位都是0,那正常的固定宽度的掩码是不会出错的,因为这个掩码会按照无符号的方式扩展到64位。
  • 如果你希望掩码的高位都是1,对掩码做两次按位取反,正如代码清单2-8展示的那样。

代码清单2-8 使用取反掩码来进行符号扩展

function_name(long value)
{
  long mask = ~0x3;  // 0xfffffffc 或 0xfffffffffffffffc
  return (value & mask);
}

在这段代码里,在64位情况下,掩码的高位都是1。

确保创建的数据结构是固定长度和对齐的

当数据在你的app的32位和64位版本中共享的时候,你可能需要创建32位和64位表示都相同的数据结构,常见的场景是,当数据保存在文件或者通过网络传输交换到其他设备时,两个设备的运行环境可能不同。同样要记住的是,用户可能吧他们的数据备份在32位的设备上,然后在64位的设备上恢复。所以数据的互换性是你必须解决的问题。

使用明确的整数类型

C99标准提供了明确宽度的内置数据类型,与底层硬件的体系结构无关。当你的数据必须是固定宽度的或者你知道某个变量所有可能的值在有限的范围内,你应该用上述的这些类型。通过使用一个合适的数据类型,你可以得到一个在内存中固定宽度的类型,并且你可以避免因为给变量分配了过大的内存二导致的内存浪费。
表格2-1列出了C99标准所有的整数类型和他们的取值范围。

表格2-1 C99的明确的整数类型

类型 范围
int8_t -128 ~ 127
int16_t -32,768 ~ 32,767
int32_t -2,147,483,648 ~ 2,147,483,647
int64_t -9,223,372,036,854,775,808 ~ 9,223,372,036,854,775,807
uint8_t 0 ~ 255
uint16_t 0 ~ 65,535
uint32_t 0 ~ 4,294,967,295
uint64_t 0 ~ 18,446,744,073,709,551,615

谨慎地对64位整型进行对齐

在64位运行时中,所有64位整数的对齐宽度都从4字节变为了8字节。即使你明确指定了每个整数的类型,在32位和64位环境中两个结构体还是可能有所区别。在代码清单2-9中,这个结构体的对齐方式在不同位数的环境下会发生变化,即使使用了明确的整型。
代码清单2-9 结构体的64位整数的对齐

struct bar {
  int32_t foo0;
  int32_t foo1;
  int32_t foo2;
  int64_t bar;
};

当这段代码用32位的编译器编译时,成员bar从结构体头往后12字节处开始。当同样的代码用64位编译器编译时,成员bar从结构体头玩够16字节处开始。在foo2成员后加了4字节的填充使得bar成员可以对齐到8字节边界。
如果你在定义一个新的结构体,把有最大对齐值的成员放在最前面,最小的放在最后面。这样排布可以排除大部分的填充字节。如果你在使用一个已存在的包含了不对齐整数的结构体,你可以用pragma预编译指令强制规定合适的对齐。代码清单2-10中的结构体和上面的一样,但是这里强制使用了32位的对齐规则。

代码清单2-10 使用pragma预编译指令控制对齐

#pragma pack(4)
struct bar {
  int32_t foo0;
  int32_t foo1;
  int32_t foo2;
  int64_t bar;
};
#pragma options align=reset

只有当你需要的时候才使用这个选项,因为访问不对齐的内存有性能损耗。例如,你可以在使用你的32位版本的app内存在的数据结构时,使用这个选项来保持向下兼容。

用sizeof来分配内存

永远不要在调用malloc分配变量内存时给明确的大小(例如malloc(4))。而应该用sizeof获得任何结构体或变量正确的大小后来分配内存。在你的代码里寻找任何没有配合sizeof使用的malloc

使用正确的格式化字符串来支持两种运行时

printf一样的打印函数在支持两种运行时的时候可能会有点棘手,因为数据类型发生了变化。为了解决打印指针宽度的整数和其他标准的类型时的问题,可以使用头文件inttypes.h里定义的宏。
表格2-2展示了多种类型的格式化字符串。表格2-3展示了inttypes.h中定义的额外类型。

类型 格式化字符串
int %d
long %ld
long long %lld
size_t %zu
ptrdiff_t %td
任意指针 %p

表格2-2 标准格式化字符串

类型 格式化字符串
int %d
long %ld
long long %lld
size_t %zu
ptrdiff_t %td
任意指针 %p
类型 格式化字符串
int[N]_t(例如int32_t PRId[N](例如PRId32
uint[N]_t PRIu[N]
int_least[N]_t PRIdLEAST[N]
uint_least[N]_t PRIuLEAST[N]
int_fast[N]_t PRIdFAST[N]
uint_fast[N] PRIuFAST[N]
intptr_t PRIdPTR
uintptr_t PRIuPTR
intmax_t PRIdMAX
uintmax_t PRIuMAX

表格2-3 inttypes.h中额外的格式化字符串(N代表数字)

类型 格式化字符串
int[N]_t(例如int32_t PRId[N](例如PRId32
uint[N]_t PRIu[N]
int_least[N]_t PRIdLEAST[N]
uint_least[N]_t PRIuLEAST[N]
int_fast[N]_t PRIdFAST[N]
uint_fast[N] PRIuFAST[N]
intptr_t PRIdPTR
uintptr_t PRIuPTR
intmax_t PRIdMAX
uintmax_t PRIuMAX

例如,当需要打印一个intptr_t的变量(一个指针宽度的整数)和一个指针时,你可以按照代码清单2-11这样来写:
代码清单2-11 体系结构无关的打印输出

#include <inttypes.h>
void *foo;
intptr_t k = (intptr_t)foo;
void *ptr = &k;

printf("The value of k is %" PRIdPTR "\n", k);
printf("The value of ptr is %p\n", ptr);

谨慎处理函数和函数指针

64位环境中的函数调用和32位中的处理方式不同。最严重的区别就是变参函数和定参函数使用不同的指令序列来读取参数。代码清单2-12展示了两种函数的原型。第一个函数(fixedFunction)只接受一对整数作为参数。第二个函数接受可变数量的数作为参数(但至少两个)。在32位环境中,这两个函数使用类似的指令序列来读取参数。在64位环境中,这两个函数在编译时就使用了不同的规则。

代码清单2-12 函数类型不同,64位调用规则也不同

int fixedFunction(int a, int b);
int variadicFunction(int a, ...);

int main
{
  int value2 = fixedFunction(5, 5);
  int value1 = variadicFunction(5, 5);
}

因为64位环境下的调用规则更加精确了,所以你需要确保函数调用的正确性,以保证被调用者能找到调用者所传递的参数。

确保函数都定义了原型

当工程设置为最新时,编译器会在代码尝试调用没有明确原型的函数时生成错误。给出明确原型是为了帮助编译器确定函数是变参函数还是定参函数。

函数指针必须使用正确的原型

如果你在代码中传递了一个函数的指针,那这个指针的调用规则必须和原函数保持一致。它必须接受和原来函数同样的参数集合。不要把一个变参函数转成定参函数(反之亦然)。代码清单2-13给出了一个有问题的函数调用的示例。因为这个函数指针被转换后会使用不同的调用规则,所以调用者会把参数放在被调函数所不期望的位置。这种不匹配可能会导致app崩溃或者其他不可预测的行为。

代码清单2-13 在变参函数和定参函数之间的转换会导致错误

int MyFunction(int a, int b, ...);

int (*action)(int, int, int) = (int (*)(int, int, int))MyFunction;
action(1, 2, 3); // 错误!

重要:如果你像这样转换一个函数,编译器不会报任何错误和警告,并且在iOS模拟器中它的不确定的行为也是不可见的。所以一定要在发布app之前在真机上测试。

使用方法函数原型来分发Objective-C消息

上述转换函数指针的规则有个例外,就是调用用来发送消息的objc_msgSend函数或者其他类似的Objective-C运行时函数。尽管这些消息相关的函数的原型是可变参的,但是Objective-C运行时调用的这些方法函数的原型一般不尽相同。Objective-C运行时直接分发到实现了这个方法的函数,所以这里的调用规则就像上面描述的一样,是不匹配的。所以你必须把objc_msgSend函数转换成匹配那个被调用的方法函数的原型。
代码清单2-14展示了一个合理的例子,它用底层的消息函数把一条消息发给了一个对象。在这个例子中,doSomething:方法接受一个参数,并且没有不可变的版本。它使用这个方法函数的原型来转换objc_msgSend。注意到一个方法函数第一个参数永远是id类型的变量,第二个参数是一个选择器(selector)。在objc_msgSend转换成一个函数指针后,这个调用被分发到这个函数指针。

代码清单2-14 使用强转来调用Objective-C消息发送的函数

- (int)doSomething:(int)x { ... }
- (void)doSomeThingElse {
  int (*action)(id, SEL, int) = (int (*)(id, SEL, int))objc_msgSend;
  action(self, @selector(doSomething:), 0);
}

谨慎地调用变参函数

可变参数列表(varargs)不提供参数的类型信息,而且这些参数不会自动转换到更宽的类型。如果你希望分辨不同的入参类型,你需要用格式化字符串或者其他类似的机制来提供varargs的类型信息。如果调用方没有正确地给出这些信息(或者varargs函数没有解释正确),结果会出错。
特别地,如果你的varargs函数需要一个long型整数,但你传了一个32位值,那么这个varargs函数就获得了32位数据和后一个参数的32位无效信息(这32位是必然会丢失的)。类似地,如果你的varargs函数需要一个int类型而你传了一个long型数,你只能得到一半的数据,并且剩下的另一半会错误地出现在下一个参数中。(这种情况的例子是在printf中使用了错误的格式化字符串。)

不要直接获取Objective-C指针

如果你在代码中直接获取一个对象的isa成员,在64位环境中这段代码不会通过。isa不再保存一个指针了。它现在包含了一些指针数据,余下的位来存放其他运行时数据。这个优化改进了内存使用并增强了性能。
现在取得对象的isa内的信息可以用class属性或者调用object_getClass函数。往isa里写东西,则是调用object_setClass函数。

重要:因为这些错误不会被iOS模拟器发现,所以请在真机测试app。

使用内置的同步原语

有时app会实现自己的同步原语来提高性能。iOS运行时提供了一整套同步原语,而它在运行的时候是特别为了各种CPU而优化了的。这个运行时库位新体系结构提供了一些新特性。已经用了这个库的32位app会在64位环境中自动获得新的CPU特性。如果你在app的旧版中实现了自定义的原语,现在会比内置的原语慢若干个数量级。所以请使用内置原语。

永远不要硬编码虚存的页面大小

大部分app没必要知道虚存的页面大小,然而有些app困难会需要这个值来分配缓存空间或是调用一些框架。从iOS7开始,32位和64位设备的虚存页面大小会不相同。所以当需要获得虚存页面大小时,使用getpagesize()函数。

实践地址无关性

64位运行时只支持地址无关可执行文件(PIE)。现代app编译时默认都是地址无关的。如果你有一些阻止编译成地址无关代码的东西,例如静态链接库或者汇编代码,你需要在转移到64位环境时更新你的代码。32位环境下地址无关性也是极度推荐的。
更多信息请见编译生成一个地址无关的可执行文件

使你的app在32位环境下运行良好

目前为了64位环境写的app也要支持32位环境。所以你需要使得你的app在两种环境下都运行良好。通常这意味着一种设计要支持两种环境。偶尔也会需要为不同环境给出不同的解决方案。
例如,你有可能不得不在整个代码中用64位整数;两种环境都支持64位整数,并且在所有地方使用同一种整数类型会使得app的设计变得简单。如果你在任何地方都用64位整数,app在32位环境下会运行得较为缓慢。64位处理器处理64位整数和32位整数的速度一样快,但是32位处理器在进行64位计算时会慢很多。而且在两种环境中变量会比实际需要的花费更多内存。一个变量应当用符合值的范围的整数类型。如果计算值域在32位整数内就用32位整数。参见使用明确的整数类型;

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念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

推荐阅读更多精彩内容