Thunk程序的實現(xiàn)原理以及在iOS中的應(yīng)用(二)

本文導(dǎo)讀:虛擬內(nèi)存以及虛擬內(nèi)存的remap機制备畦,以及通過remap機制來實現(xiàn)通過靜態(tài)指令來構(gòu)造thunk代碼塊抖剿。

??Thunk程序的實現(xiàn)原理以及在iOS中的應(yīng)用 入口處苦蒿。

thunk程序其實就是一段代碼塊预伺,這段代碼塊可以在運行時動態(tài)構(gòu)造也可以在編譯時構(gòu)造茬故。thunk程序除了在第一篇文章中介紹的用途外還可以作為某些真實函數(shù)調(diào)用的跳板(trampoline)代碼职祷,以及解決一些函數(shù)參數(shù)不一致的調(diào)用對接問題氏涩。從設(shè)計模式的角度來講thunk程序可以作為一個適配器(Adapter)届囚。本文將重點介紹如何通過編譯時的靜態(tài)代碼來實現(xiàn)thunk程序的方法,以便解決上一篇文章對于iOS系統(tǒng)下指令動態(tài)構(gòu)造的約束限制的問題是尖。

虛擬內(nèi)存實現(xiàn)的簡單介紹

在介紹靜態(tài)構(gòu)造thunk程序之前意系,首先要熟悉一個知識點:虛擬內(nèi)存。虛擬內(nèi)存是現(xiàn)代操作系統(tǒng)對于內(nèi)存管理的一個很重要的技術(shù)饺汹。通過虛擬內(nèi)存的映射機制蛔添,使得每個進程都可以擁有非常大而且完全隔離和獨立的內(nèi)存空間。操作系統(tǒng)對虛擬內(nèi)存的分配和管理是以頁為單位兜辞,當(dāng)將一個可執(zhí)行文件或者動態(tài)庫加載到內(nèi)存中執(zhí)行時迎瞧,操作系統(tǒng)會將文件中的代碼段部分和數(shù)據(jù)段部分的內(nèi)容通過內(nèi)存映射文件的形式映射到對應(yīng)的虛擬內(nèi)存區(qū)域中。程序執(zhí)行的代碼所在的代碼段部分總是被分配在一片具有可執(zhí)行權(quán)限的虛擬內(nèi)存區(qū)域中逸吵,不同的操作系統(tǒng)對可執(zhí)行代碼所處的內(nèi)存區(qū)域要求的不同凶硅,就比如iOS系統(tǒng)來說,可執(zhí)行代碼所在的虛擬內(nèi)存區(qū)域的權(quán)限只能是可執(zhí)行的胁塞,否則就會產(chǎn)生系統(tǒng)崩潰咏尝,這也就是說我們不可以在具有可讀寫權(quán)限的內(nèi)存區(qū)域中(比如堆內(nèi)存或者棧內(nèi)存空間)動態(tài)的構(gòu)造出指令來供CPU執(zhí)行。也就是說在iOS系統(tǒng)中不支持將某段內(nèi)存的保護機制先設(shè)置為讀寫以便填充好數(shù)據(jù)后再設(shè)置為可執(zhí)行的保護機制來實現(xiàn)動態(tài)的指令構(gòu)造(也就是所謂的JIT技術(shù))啸罢。不過好在操作系統(tǒng)提供了虛擬內(nèi)存的remap機制來解決這個問題编检。所謂虛擬內(nèi)存的remap機制就是可以將新分配的虛擬內(nèi)存頁重新映射到已經(jīng)分配好的虛擬內(nèi)存頁中,新分配的虛擬內(nèi)存頁可以和已經(jīng)存在的虛擬內(nèi)存頁中的內(nèi)容保持一致扰才,并且可以繼承原始虛擬內(nèi)存頁面的保護權(quán)限允懂。虛擬內(nèi)存的remap機制使得進程之間或者進程內(nèi)中的虛擬內(nèi)存共享相同的物理內(nèi)存。

虛擬內(nèi)存到物理內(nèi)存之間的映射

從上面的圖中可以得出一些結(jié)論:

  1. 無論是物理內(nèi)存還是虛擬內(nèi)存的管理都是以頁為單位來進行管理的衩匣,并且一般情況下二者的尺寸保持一致蕾总。
  2. 操作系統(tǒng)為每個進程建立一張進程頁表,頁表記錄著虛擬內(nèi)存頁到物理內(nèi)存頁的映射關(guān)系以及相關(guān)的權(quán)限琅捏。并且頁表是保存在物理內(nèi)存頁中的生百。因此所謂的虛擬內(nèi)存分配其本質(zhì)就是在頁表中建立一個從虛擬內(nèi)存頁到物理內(nèi)存頁的映射關(guān)系而已。而所謂的remap就是將不同的虛擬頁號映射到同一個物理頁號而已柄延。就如例子中進程1的第1頁和第4頁都是映射在同一個6號物理頁中蚀浆。
  3. 不同進程之間的不同虛擬頁號可以映射到相同的物理頁號。這樣的一個應(yīng)用是解決動態(tài)庫的共享加載問題搜吧,比如UIKit這個框架庫在第一個進程運行時被加載到內(nèi)存中市俊,那么當(dāng)?shù)诙€進程運行時并且需要UIKit庫時就不再需要重新從文件加載內(nèi)存中而是共享已經(jīng)加載到物理內(nèi)存的UIKit動態(tài)庫。上面的例子中進程1的第5頁和進程2的第7頁共享相同的物理內(nèi)存第9頁滤奈。
  4. 操作系統(tǒng)還會維持一個全局物理頁空閑信息表摆昧,用來記錄當(dāng)前未被分配的物理內(nèi)存。這樣一旦有進程需要分配虛擬內(nèi)存空間時就從這個表中查找空閑的區(qū)域進行快速分配蜒程。

iOS的內(nèi)核系統(tǒng)中有一層Mach子系統(tǒng)绅你,Mach子系統(tǒng)是內(nèi)核中的內(nèi)核伺帘,它是一種微內(nèi)核。Mach子系統(tǒng)中將進程(task)勇吊、線程曼追、內(nèi)存的管理都稱之為一個對象窍仰,并且為每個對象都會分配一個被稱之為port的端口號汉规,所有對象之間的通信和功能調(diào)用都是通過port為標識的mach message來進行通信的。

虛擬內(nèi)存的remap機制

下面的代碼將展示虛擬內(nèi)存分配銷毀以及虛擬內(nèi)存的remap機制驹吮。例子里面演示了通過remap機制來實現(xiàn)同一個函數(shù)實現(xiàn)的兩個不同的入口地址的調(diào)用實現(xiàn):


#import <mach/mach.h>

//因為新分配的虛擬內(nèi)存是以頁為單位的针史,所以要被映射的內(nèi)存也要頁對齊,所以這里的函數(shù)起始地址是以頁為單位對齊的碟狞。
int __attribute__ ((aligned (PAGE_MAX_SIZE))) testfn(int a, int b)
{
    int c = a + b;
    return c;
}

int main(int argc, char *argv[])
{
    //通過vm_alloc以頁為單位分配出一塊虛擬內(nèi)存啄枕。
    vm_size_t page_size = 0;
    host_page_size(mach_host_self(), &page_size);  //獲取一頁虛擬內(nèi)存的尺寸
    vm_address_t addr = 0;
    //在當(dāng)前進程內(nèi)的空閑區(qū)域中分配出一頁虛擬內(nèi)存出來,addr指向虛擬內(nèi)存的開始位置。
    kern_return_t ret = vm_allocate(mach_task_self(), &addr, page_size, VM_FLAGS_ANYWHERE);
    if (ret == KERN_SUCCESS)
    {
        //addr被分配出來后族沃,我們可以對這塊內(nèi)存進行讀寫操作
        memcpy((void*)addr, "Hello World!\n", 14);
        printf((const char*)addr);
        //執(zhí)行上述代碼后频祝,這時候內(nèi)存addr的內(nèi)容除了最開始有“Hello World!\n“其他區(qū)域是一篇空白,而且并不是可執(zhí)行的代碼區(qū)域脆淹。
        
        //虛擬內(nèi)存的remap重映射常空。執(zhí)行完vm_remap函數(shù)后addr的內(nèi)存將被重新映射到testfn函數(shù)所在的內(nèi)存頁中,這時候addr所指的內(nèi)容將不在是Hello world!了盖溺,而是和函數(shù)testfn的代碼保持一致漓糙。
        vm_prot_t cur,max;
        ret = vm_remap(mach_task_self(), &addr, page_size, 0, VM_FLAGS_FIXED | VM_FLAGS_OVERWRITE, mach_task_self(), (vm_address_t)testfn, false, &cur, &max, VM_INHERIT_SHARE);
        if (ret == KERN_SUCCESS)
        {
           int c1 = testfn(10, 20);    //執(zhí)行testfn函數(shù)
           int c2 = ((int (*)(int,int))addr)(10,20); //addr重新映射后將和testfn函數(shù)具有相同內(nèi)容,所以這里可以將addr當(dāng)做是testfn函數(shù)一樣被調(diào)用烘嘱。
           NSAssert(c1 == c2, @"oops!");
        }

       vm_deallocate(mach_task_self(), addr, page_size);
    }

   return 0;
}

首先我們用vm_allocate函數(shù)以頁的尺寸大小為單位在空閑區(qū)域分配出一頁虛擬內(nèi)存出來并由addr指向內(nèi)存的首地址昆禽。當(dāng)分配成功后我們就可以像操作普通內(nèi)存一樣任意對這塊內(nèi)存進行讀寫處理。這里對addr分別進行了memcpy的寫操作蝇庭,以及printf函數(shù)對addr進行讀操作醉鳖。這時候addr所指的內(nèi)存具有讀寫屬性。addr內(nèi)存中存儲的信息如下:


addr地址的內(nèi)存布局

接下來我們又通過vm_remp函數(shù)來對addr內(nèi)存地址進行重新映射哮内,vm_remap函數(shù)中分別有兩個port參數(shù)分別用來指定目標進程和原進程盗棵,也就是說vm_remap函數(shù)可以將任何兩個進程中的內(nèi)存地址進行相互映射。這種內(nèi)存映射的支持其實也可以用來實現(xiàn)進程之間的通信處理牍蜂,當(dāng)然在iOS系統(tǒng)中是無法實現(xiàn)跨進程的內(nèi)存映射的漾根,因此目標進程和原進程必須具有相同的port。除了指定源進程和目標進程端口外鲫竞,還需要指定目標地址和源地址辐怕,也就是vm_remap函數(shù)使得目標地址映射到源地址上,使得目標地址所指的內(nèi)存和源地址保持一致从绘。而上面的目標地址是addr,而源地址則是函數(shù)testfn的起始地址寄疏。經(jīng)過映射操作后的結(jié)果是addr所指的內(nèi)存和testfn所指的內(nèi)容將保持一致是牢,而且addr還會繼承源地址testfn的保護權(quán)限。因為testfn是編譯時的代碼陕截,最終會存放在代碼段中并只具有可執(zhí)行權(quán)限驳棱, 這樣最終的結(jié)果是addr也變成只具有可執(zhí)行權(quán)限的內(nèi)存區(qū)域了,而且它所指向的內(nèi)容就是和函數(shù)testfn所指向的內(nèi)容都一樣了农曲,都是一段可執(zhí)行的代碼社搅。而后續(xù)的兩個函數(shù)調(diào)用的結(jié)果保持一致,也證明了結(jié)果是正確的乳规。我們可以看出addr和testfn所指向的內(nèi)容已經(jīng)完全一致了:


addr地址被remap后的內(nèi)存布局

通過vm_remap函數(shù)我們能夠?qū)崿F(xiàn)兩個不同的虛擬內(nèi)存地址所指向的物理地址保持一致形葬。

一個很有意思的說法是,在面向?qū)ο笙到y(tǒng)中一個對象的唯一標識是對象所處的內(nèi)存地址暮的,包括一些系統(tǒng)中的基類的equal函數(shù)的實現(xiàn)往往是比較對象的地址是否相等笙以。那如果在有vm_remap的處理下,這個結(jié)論將被打破冻辩,因此通過vm_remap我們就能實現(xiàn)一個對象可以通過多個不同的地址來進行訪問猖腕,這里我們也可以思考一下是否可以用這種技術(shù)來解決一些目前的一些問題呢?

vm_allocate可以用來實現(xiàn)虛擬內(nèi)存的分配恨闪,malloc也可以用來實現(xiàn)堆內(nèi)存的分配倘感,這兩者之間有什么關(guān)系呢?前者其實是更加底層的內(nèi)存管理API凛剥,而且分配的內(nèi)存的尺寸都是以頁的倍數(shù)作為邊界的侠仇;而后者中的堆內(nèi)存是高級內(nèi)存管理API,一個進程的堆內(nèi)存區(qū)域在實現(xiàn)中其實是先通過vm_allocate分配出來一大片內(nèi)存區(qū)域(包括棧內(nèi)存也如此)犁珠。然后再在這塊大的內(nèi)存區(qū)域上進行分割管理以及空閑復(fù)用等等高級操作來實現(xiàn)一些零碎和范圍內(nèi)存分配操作逻炊。但是不管如何最終我們都可以借助這些函數(shù)來對分配出來的內(nèi)存進行讀寫處理。

上面的addr對testfn的映射后addr 能夠和testfn具有相同的能力犁享,但是這種能力其實是需要對testfn的函數(shù)體所有約束的余素,這個約束就是testfn中不能出現(xiàn)一些常量以及全局變量以及不能再出現(xiàn)函數(shù)調(diào)用,原因是這些操作在編譯為機器指令后訪問這些數(shù)據(jù)都是通過相對偏移來實現(xiàn)的炊昆,因此如果addr映射成功后因為函數(shù)實現(xiàn)的基地址有變化桨吊,如果通過addr進行訪問時,那么指令中的相對偏移值將是一個錯誤的結(jié)果凤巨,從而造成函數(shù)調(diào)用時的崩潰發(fā)生视乐。

靜態(tài)構(gòu)造thunk程序

上一篇文章中實現(xiàn)了通過在內(nèi)存中動態(tài)的構(gòu)造機器指令來實現(xiàn)一段thunk代碼,但是這種機制在iOS系統(tǒng)中是無法在發(fā)布版證書打包的程序中運行的敢茁。仔細考察手動構(gòu)造thunk代碼指令:

    mov x2, x1
    mov x1, x0
    ldr x0, #0x0c
    ldr x3, #0x10
    br x3
  arg0:
    .quad 0
  realfn:
    .quad 0

就可以看出佑淀,指令塊的重點是在第3條和第4條指令。這兩條指令通過讀取距離當(dāng)前指令偏移0x0c和0x10處的數(shù)據(jù)來賦值給特定的寄存器彰檬,而我們又可以在內(nèi)存構(gòu)造時動態(tài)的調(diào)整和設(shè)置這部分內(nèi)存的值伸刃,從而實現(xiàn)運行時的thunk的能力』牙現(xiàn)在將上述的代碼改動一下:

     mov x2, x1
     mov x1, x0
     ldr x0, PAGE_MAX_SIZE - 8
     ldr x3, PAGE_MAX_SIZE - 4
     br x3

可以看出第3條和第4條指令的偏移變?yōu)榱薖AGE_MAX_SIZE也就是變?yōu)橐粋€虛擬內(nèi)存頁尺寸的值,指令取數(shù)據(jù)的偏移位置被放大了捧颅【巴迹可問題是如果只動態(tài)構(gòu)造了很小一部分內(nèi)存來存儲指令,并沒有多分配一頁內(nèi)存來存儲數(shù)據(jù)碉哑,那這樣有什么意義呢挚币?

想象一下如果上面的那部分指令并不是被動態(tài)構(gòu)造,而是靜態(tài)編譯時就存在的代碼呢谭梗?這樣這部分代碼就不會因為簽名問題而無法在iOS系統(tǒng)上運行忘晤。進一步來說宛蚓,我們可以在運行時分配2頁虛擬內(nèi)存激捏,當(dāng)分配完成后,將第1頁虛擬內(nèi)存地址remap到上述那部分代碼所在的內(nèi)存地址凄吏,而將第2頁分配的虛擬內(nèi)存用來存放指令中所指定偏移的數(shù)據(jù)远舅。根據(jù)上面對remap機制的描述可以得出當(dāng)進行remap后所分配的第1頁虛擬內(nèi)存具備了可執(zhí)行代碼的能力,而又因為代碼中第3痕钢、4條指令所取的數(shù)據(jù)是對應(yīng)的第2頁虛擬內(nèi)存的數(shù)據(jù)图柏,這樣就可以實現(xiàn)在不動態(tài)構(gòu)造指令的情況下來解決生成thunk程序的問題了。整個實現(xiàn)的原理如下:

靜態(tài)指令來實現(xiàn)thunk程序的流程

從上面的流程圖中可以很清楚的了解到通過對虛擬內(nèi)存進行remap就可以不用動態(tài)構(gòu)造指令來完成構(gòu)建一個thunk程序塊的能力任连,下面我們就結(jié)合第一篇文章中的快速排序蚤吹,以及本文的remap機制來實現(xiàn)靜態(tài)構(gòu)造thunk塊的能力

  1. 首先在你的工程里面添加一個后綴為.s的匯編代碼文件(new file -> assembly file)。本文件中的代碼只實現(xiàn)對arm64位系統(tǒng)的支持
//
//  thunktemplate.s
//  thunktest
//
//  Created by youngsoft on 2019/1/30.
//  Copyright ? 2019年 youngsoft. All rights reserved.
//

#if __arm64__

#include <mach/vm_param.h>

/*
  指令在代碼段中随抠,聲明外部符號_thunktemplate裁着,并且指令地址按頁的大小對齊!
 */
.text
.private_extern _thunktemplate
.align PAGE_MAX_SHIFT
_thunktemplate:
mov x2, x1
mov x1, x0
ldr x0, PAGE_MAX_SIZE - 8
ldr x3, PAGE_MAX_SIZE - 4
br x3

#endif

  1. 然后我們在另外一個文件中實現(xiàn)排序的代碼:
extern void *thunktemplate;   //聲明使用thunk模板符號拱她,注意不要帶下劃線

typedef struct
{
    int age;
    char *name;
}student_t;

//按年齡升序排列的函數(shù)
int  ageidxcomparfn(student_t students[], const int *idx1ptr, const int *idx2ptr)
{
    return students[*idx1ptr].age - students[*idx2ptr].age;
}

int main(int argc, const char *argv[])
{
    vm_address_t thunkaddr = 0;
    vm_size_t page_size = 0;
    host_page_size(mach_host_self(), &page_size);
    //分配2頁虛擬內(nèi)存二驰,
    kern_return_t ret = vm_allocate(mach_task_self(), &thunkaddr, page_size * 2, VM_FLAGS_ANYWHERE);
    if (ret == KERN_SUCCESS)
    {
        //第一頁用來重映射到thunktemplate地址處。
        vm_prot_t cur,max;
        ret = vm_remap(mach_task_self(), &thunkaddr, page_size, 0, VM_FLAGS_FIXED | VM_FLAGS_OVERWRITE, mach_task_self(), (vm_address_t)&thunktemplate, false, &cur, &max, VM_INHERIT_SHARE);
        if (ret == KERN_SUCCESS)
        {
            student_t students[5] = {{20,"Tom"},{15,"Jack"},{30,"Bob"},{10,"Lily"},{30,"Joe"}};
            int idxs[5] = {0,1,2,3,4};
            
            //第二頁的對應(yīng)位置填充數(shù)據(jù)秉沼。
            void **p = (void**)(thunkaddr + page_size);
            p[0] = students;
            p[1] = ageidxcomparfn;
            
            //將thunkaddr作為回調(diào)函數(shù)的地址桶雀。
            qsort(idxs, 5, sizeof(int), (int (*)(const void*, const void*))thunkaddr);
            for (int i = 0; i < 5; i++)
            {
                printf("student:[age:%d, name:%s]\n", students[idxs[i]].age, students[idxs[i]].name);
            }
        }
        
        vm_deallocate(mach_task_self(), thunkaddr, page_size * 2);
    }
    
   return 0;
}

可以看出通過remap機制可以創(chuàng)造性的解決了動態(tài)構(gòu)造內(nèi)存指令來實現(xiàn)thunk程序的缺陷問題,整個過程不需要我們構(gòu)造指令唬复,而是借用現(xiàn)有已經(jīng)存在的指令來構(gòu)造thunk程序矗积,而且這樣的代碼不存在簽名的問題,也可以在iOS的任何簽名下被安全運行敞咧。當(dāng)然這個技巧也是可以使用在linux/unix系統(tǒng)之上的棘捣。

后記

本文中所介紹的技術(shù)和技巧參考自開源庫libffi中對閉包的支持以及iOS的runtime中通過一個block對象來得到IMP函數(shù)指針的實現(xiàn)方法。


歡迎大家訪問歐陽大哥2013的github地址簡書地址

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末妄均,一起剝皮案震驚了整個濱河市柱锹,隨后出現(xiàn)的幾起案子哪自,更是在濱河造成了極大的恐慌,老刑警劉巖禁熏,帶你破解...
    沈念sama閱讀 206,378評論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件壤巷,死亡現(xiàn)場離奇詭異,居然都是意外死亡瞧毙,警方通過查閱死者的電腦和手機胧华,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,356評論 2 382
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來宙彪,“玉大人矩动,你說我怎么就攤上這事∈推幔” “怎么了悲没?”我有些...
    開封第一講書人閱讀 152,702評論 0 342
  • 文/不壞的土叔 我叫張陵,是天一觀的道長男图。 經(jīng)常有香客問我示姿,道長,這世上最難降的妖魔是什么逊笆? 我笑而不...
    開封第一講書人閱讀 55,259評論 1 279
  • 正文 為了忘掉前任栈戳,我火速辦了婚禮,結(jié)果婚禮上难裆,老公的妹妹穿的比我還像新娘子檀。我一直安慰自己,他們只是感情好乃戈,可當(dāng)我...
    茶點故事閱讀 64,263評論 5 371
  • 文/花漫 我一把揭開白布褂痰。 她就那樣靜靜地躺著,像睡著了一般偏化。 火紅的嫁衣襯著肌膚如雪脐恩。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 49,036評論 1 285
  • 那天侦讨,我揣著相機與錄音驶冒,去河邊找鬼。 笑死韵卤,一個胖子當(dāng)著我的面吹牛骗污,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播沈条,決...
    沈念sama閱讀 38,349評論 3 400
  • 文/蒼蘭香墨 我猛地睜開眼需忿,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起屋厘,我...
    開封第一講書人閱讀 36,979評論 0 259
  • 序言:老撾萬榮一對情侶失蹤涕烧,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后汗洒,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體议纯,經(jīng)...
    沈念sama閱讀 43,469評論 1 300
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 35,938評論 2 323
  • 正文 我和宋清朗相戀三年溢谤,在試婚紗的時候發(fā)現(xiàn)自己被綠了瞻凤。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 38,059評論 1 333
  • 序言:一個原本活蹦亂跳的男人離奇死亡世杀,死狀恐怖阀参,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情瞻坝,我是刑警寧澤蛛壳,帶...
    沈念sama閱讀 33,703評論 4 323
  • 正文 年R本政府宣布,位于F島的核電站湿镀,受9級特大地震影響炕吸,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜勉痴,卻給世界環(huán)境...
    茶點故事閱讀 39,257評論 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望树肃。 院中可真熱鬧蒸矛,春花似錦、人聲如沸胸嘴。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,262評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽劣像。三九已至乡话,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間耳奕,已是汗流浹背绑青。 一陣腳步聲響...
    開封第一講書人閱讀 31,485評論 1 262
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留屋群,地道東北人闸婴。 一個月前我還...
    沈念sama閱讀 45,501評論 2 354
  • 正文 我出身青樓,卻偏偏與公主長得像芍躏,于是被迫代替她去往敵國和親邪乍。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 42,792評論 2 345

推薦閱讀更多精彩內(nèi)容