LLVM編譯流程/Clang插件開發

LLVM(Low Level Virtual Machine)概述

  • LLVM是架構編譯器(compiler)的框架系統,以C++編寫而成,用於優化以任意程序語言編寫的程序的編譯時間(compile-time),鏈接時間(link-time),運行時間(run-time)以及空閒時間(idle-time),對開發者保持開放,並兼容已有腳本。

傳統編譯器設計

編譯器前端

  • 編譯器前端的任務是解析源代碼,它會進行:詞法分析,語法分析,語意分析,檢查源代碼是否存在錯誤,然後建構抽象語法樹(Abstract Syntax Tree,AST),LLVM的前端還會生成中間代碼(intermediate representation,IR)。

優化器(Optimizer)

  • 優化器負責進行各種優化,改善代碼運行時間,例如消除冗余計算等。

後端(Backend)/代碼生成器(CodeGenerator)

  • 將代碼映射到目標指令集,生成機器語言,並且進行機器相關的代碼優化。

iOS的編譯器架構

Objective C/C/C++使用的編譯器前端是Clang,Swift是Swift,後端都是LLVM

LLVM的設計

當編譯器決定支持多種源語言或多種硬件架構時,LLVM最重要的地方就來了。其他的編譯器如GCC,它方法非常成功,但由於他是作為整體應用程序設計的,因此他們的用途受到了很大的限制。

LLVM設計的最重要的方面是,使用通用的代碼表示形式(IR),他是用來在編譯器中表示代碼的形式,所以LLVM可以為任何編程語言獨立編寫前端,並且可以為任意硬件架構獨立編寫後端。

  • 簡單來說LLVM的設計就是將前後端分離,無論前端或是後端改變都不會影響另一個。

Clang簡介

  • clang是LLVM項目中的一個子項目,他是基於LLVM架構圖的輕量級編譯器,誕生之初是為了替代GCC提供更快的編譯速度,他是負責C、C++、OC語言的編譯器 ,屬於整個LLVM架構中的編譯器前端,對於開發者來說,研究Clang可以給我們帶來很多好處。

LLVM編譯流程

  • 新建一個文件,寫下如下程式碼
int test(int a,int b){
    return a + b + 3;
}

int main(int argc, const char * argv[]) {
    int a = test(1, 2);
    printf("%d",a);
    return 0;
}

  • 通過命令可以打印源碼編譯流程
//************終端命令************
 clang -ccc-print-phases main.m

 //************编译流程************
 //0 - 輸入文件,找到文件的路徑
+- 0: input, "main.m", objective-c

//1 - 預處理階段:這個過程處理包括宏的替換,頭文件的導入
+- 1: preprocessor, {0}, objective-c-cpp-output

//2 - 編譯階段:進行詞法分析,語法分析,檢測語法是否正確,最終生成IR
+- 2: compiler, {1}, ir

//3 - 後端:這裡LLVM會通過一個個的pass去優化,每個pass做一些事情,最終生成彙編代碼
+- 3: backend, {2}, assembler

//4 - 彙編代碼生成目標文件
+- 4: assembler, {3}, object

//5 - 鏈接:鏈接需要的動態庫和靜態庫,生成可執行文件
+- 5: linker, {4}, image(镜像文件)

//6 - 綁定:通過不同的架構,生成對應的可執行文件
6: bind-arch, "x86_64", {5}, image

以下針對上述流程,分別解釋個個流程,其中input主要是輸入文件,即找到源文件的路徑

preprocessor預處理編譯階段

  • 這個過程處理包括宏的替換,頭文件的導入,可以執行以下命令,執行完畢可以看到頭文件的導入和紅的替換
//在終端直接查看替換結果
clang -E main.m

//生成對應的文件查看替換後的源碼
clang -E main.m >> main2.m

  • 需要注意的是:
    • typedef在給數據取別名時,在預處理階段不會被替換掉
    • define 則在預處理階段時會被替換,所以經常被用來進行代碼混淆,目的是為了app安全,實現邏輯是:將app中的核心類,核心方法等用系統相似的名稱進行取別名了,然後在預處理階段就被替換了,來達到代碼混淆的目的。

編譯階段

  • 編譯階段主要是進行詞法,語法的分析檢查,然後生成中間代碼IR

1.詞法分析

  • 預處理完成後就會進行詞法分析,這裡會把代碼切成一個個token,比如大小括號,等於號還有字符串等。
  • 可以通過下面的命令查看
clang -fmodules -fsyntax-only -Xclang -dump-tokens main.m

  • 如果頭文件找不到,指定sdk
clang -isysroot (自己SDK路径) -fmodules -fsyntax-only -Xclang -dump-tokens main.m

 clang -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator14.1.sdk/ -fmodules -fsyntax-only -Xclang -dump-tokens main.m

  • 以下是代碼的詞法分析結果

2.語法分析

詞法分析完成後就是語法分析,它的任務是驗證語法是否正確,在詞法分析的基礎上將單詞序列合成各類詞法短語,如程序,語句,表達式等等,然後將所有節點組成抽象語法樹 (Abstract Syntax Tree—>AST),語法分析程序判斷程序在結構上是否正確

  • 可以通過下面終端命令查看語法分析結果
clang -fmodules -fsyntax-only -Xclang -ast-dump main.m

  • 如果導入頭文件找不到,可以指定SDK
clang -isysroot (自己SDK路径) -fmodules -fsyntax-only -Xclang -ast-dump main.m

 clang -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator14.1.sdk/ -fmodules -fsyntax-only -Xclang -ast-dump main.m

  • 下面是語法分析的結果

其中,主要說明幾個關鍵字的含義

  • -FunctionDecl 函數
  • -ParmVarDecl 參數
  • -CallExpr 調用一個函數
  • -BinaryOperator 運算符

3.生成中間代碼IR

完成以上步驟後,就開始生成中間代碼IR了,代碼生成器(Code Generation)會將語法數自頂向下遍歷逐步翻成LLVM IR。

  • 可以通過下面命令生成.ll的文本文件,查看IR代碼。OC代碼在這一步進行runtime橋接,property合成,ARC處理等
clang -S -fobjc-arc -emit-llvm main.m

/*
以下是IR基本語法

@ 全局標示
% 局部標示
alloca 開闢空間
align 內存
i32 32bit,4個字節
store 寫入內存
load 讀取數據
call 調用函數
ret 返回
*/

  • 下面是生成的中間代碼.ll文件
  • 其中,test函數的參數解釋為
  • IR文件在OC中是可以優化的,一般設置是在target - Build Setting - Optimization Level (優化器等級)中設置。LLVM的優化級別分別是-O0 -O1 -O2 -O3 -Os (第一個是大寫英文字母—>O),下面是帶優化的生成中間代碼IR的命令
clang -Os -S -fobjc-arc -emit-llvm main.m -o main.ll

  • 這是優化後的中間代碼
  • xcode7以後開啟bitcode,Apple會進一步優化,生成.bc的中間代碼,我們通過優化後的IR代碼生成.bc代碼
clang -emit-llvm -c main.ll -o main.bc

後端

LLVM在後端主要是通過一個個的pass去優化,每個Pass做一些事情,最終生成彙編代碼

  • 生成彙編代碼
  • 我們通過最終的.bc或者.ll代碼 生成彙編代碼
clang -S -fobjc-arc main.bc -o main.s clang -S -fobjc-arc main.ll -o main.s

  • 生成彙編代碼也可以進行優化
clang -Os -S -fobjc-arc main.m -o main.s

此時查看生成的main.s文件的格式為彙編代碼

生成目標文件

  • 目標文件的生成,是彙編器以彙編代碼作為插入,將彙編代碼轉換為機器代碼,最後輸出目標文件(object file)
clang -fmodules -c main.s -o main.o

  • 可以通過nm命令查看下main.o中的符號
$xcrun nm -nm main.o

  • 以下是main.o中的符號,其文件格式為目標文件
  • _printf函數是一個undefined 、external
  • undefined 表示在當前文件暫時找不到符号_printf
  • external表示這個符號是外部可以訪問

鏈接

鏈接主要是鏈接需要的動態庫和靜態庫,生成可執行文件,其中

  • 靜態庫會和可執行文件合併
  • 動態庫是獨立的

連接器把編譯生成的.o文件和.dyld .a文件鏈接,生成一個mach-o文件

clang main.o -o main

查看鏈接之後的符號

$xcrun nm -nm main

結果如下所示,其中的undefined 表示會在運行時進行動態綁定

  • 通過命令查看main是什麼格式,此時是mach-o可執行文件

綁定

綁定主要是通過不同的架構生成對應的mach-O格式的可執行文件

總結

編譯流程如下圖所示

  • 註:生成的.ll(IR)進行優化和之後生成的.s(彙編)再進行優化是一樣的,優化流程只有一次。

Clang插件開發

準備工作

  • 下載路徑

llvm | 镜像站使用帮助 | 清华大学开源软件镜像站 | Tsinghua Open Source Mirror

  • 下載LLVM項目
git clone <https://mirrors.tuna.tsinghua.edu.cn/git/llvm/llvm.git>

  • 在LLVM項目
git clone <https://mirrors.tuna.tsinghua.edu.cn/git/llvm/llvm.git>

  • 在LLVM的projects目錄下載compiler-rt、libcxx、libcxxabi
cd ../projects

git clone <https://mirrors.tuna.tsinghua.edu.cn/git/llvm/compiler-rt.git>

git clone <https://mirrors.tuna.tsinghua.edu.cn/git/llvm/libcxx.git> 

git clone <https://mirrors.tuna.tsinghua.edu.cn/git/llvm/libcxxabi.git>

  • 在clang的tools下安裝extra工具
cd ../tools/clang/tools

git clone <https://mirrors.tuna.tsinghua.edu.cn/git/llvm/clang-tools-extra.git>

LLVM編譯

由於最新的LLVM只支持cmake來編譯所以需要安裝cmake

安裝cmake

查看brew是否安裝cmake,如果已經安裝,則跳過下面步驟

brew list

通過brew安装cmake

brew install cmake

編譯LLVM

有兩種編譯方式

  • 通過xcode編譯LLVM
  • 通過ninja編譯LLVM

通過xcode編譯LLVM

  • cmake編譯成Xcode項目
mkdir build_xcode

cd build_xcode

cmake -G Xcode ../llvm

  • 使用xcode編譯Clang
    • 選擇自動創建Schemes
  • 選擇手動創Schemes,然後編譯Clang,ClangTooling即可
  • 編譯(CMD + B),選擇clang 進行編譯,預計1+小時

通過ninja編譯LLVM

  • 使用ninja進行便意則還需要安裝ninja,使用以下命令安裝ninja
brew install ninja

  • 在LLVM源碼根目錄下新建一個build_ninja 目錄,最終會在build_ninja 目錄下生成build.ninja
  • 在LLVM源碼根目錄下新建llvm_release 目錄,最終編譯文件會在llvm_release 文件夾路徑下
cd llvm_build

//注意DCMAKE_INSTALL_PREFIX后面不能有空格
cmake -G Ninja ../llvm -DCMAKE_INSTALL_PREFIX= 安装路径(本机为/ Users/xxx/xxx/LLVM/llvm_release)

  • 一次執行編譯,安裝指令
ninja

ninja install

創建插件

  • /llvm/tools/clang/tools 下新建插件ACPlugin
  • /llvm/tools/clang/tools 目錄下CMakeLists.txt 文件,新增add_clang_subdirectory(ACPlugin) ,此處的ACPlugin即為上一步創建的插件名稱
  • ACPlugin目錄下新建兩個文件,分別是ACPlugin.cppCMakeLists.txt ,並在CMakeLists.txt 中加上以下代碼
//通過終端在ACPlugin目錄下創建
touch ACPlugin.cpp

touch CMakeLists.txt

//CMakeList.txt中添加以下代碼
add_llvm_library( CJLPlugin MODULE BUILDTREE_ONLY 
    ACPlugin.cpp
)

  • 接下來利用cmake重新生成Xcode項目,在build_xcode 目錄下執行以下命令
cmake -G Xcode ../llvm

  • 最後可以在LLVM的xcode項目中可以看到Loadable modules 目錄下由自定義的ACPlugin目錄了,然後可以在裡面編寫插件代碼了

編寫插件代碼

  • 在ACPlugin目錄下的ACPlugin.cpp文件中,加入以下代碼

#include <iostream>
#include "clang/AST/AST.h"
#include "clang/AST/DeclObjC.h"
#include "clang/AST/ASTConsumer.h"
#include "clang/ASTMatchers/ASTMatchers.h"
#include "clang/Frontend/CompilerInstance.h"
#include "clang/ASTMatchers/ASTMatchFinder.h"
#include "clang/Frontend/FrontendPluginRegistry.h"

using namespace clang;
using namespace std;
using namespace llvm;
using namespace clang::ast_matchers;
//命名空间,和插件同名
namespace CJLPlugin {

//第三步:扫描完毕的回调函数
//4、自定义回调类,继承自MatchCallback
class CJLMatchCallback: public MatchFinder::MatchCallback {

private:
    //CI传递路径:CJLASTAction类中的CreateASTConsumer方法参数 - CJLConsumer的构造函数 - CJLMatchCallback的私有属性,通过构造函数从CJLASTConsumer构造函数中获取
    CompilerInstance &CI;

    //判断是否是用户源文件
    bool isUserSourceCode(const string filename) {
        //文件名不为空
        if (filename.empty()) return  false;
        //非xcode中的源码都认为是用户的
        if (filename.find("/Applications/Xcode.app/") == 0) return false;
        return  true;
    }

    //判断是否应该用copy修饰
    bool isShouldUseCopy(const string typeStr) {
        //判断类型是否是NSString | NSArray | NSDictionary
        if (typeStr.find("NSString") != string::npos ||
            typeStr.find("NSArray") != string::npos ||
            typeStr.find("NSDictionary") != string::npos/*...*/)
        {
            return true;
        }

        return false;
    }

public:
    CJLMatchCallback(CompilerInstance &CI) :CI(CI) {}

    //重写run方法
    void run(const MatchFinder::MatchResult &Result) {
        //通过result获取到相关节点 -- 根据节点标记获取(标记需要与CJLASTConsumer构造方法中一致)
        const ObjCPropertyDecl *propertyDecl = Result.Nodes.getNodeAs<ObjCPropertyDecl>("objcPropertyDecl");
        //判断节点有值,并且是用户文件
        if (propertyDecl && isUserSourceCode(CI.getSourceManager().getFilename(propertyDecl->getSourceRange().getBegin()).str()) ) {
            //15、获取节点的描述信息
            ObjCPropertyDecl::PropertyAttributeKind attrKind = propertyDecl->getPropertyAttributes();
            //获取节点的类型,并转成字符串
            string typeStr = propertyDecl->getType().getAsString();
//            cout<<"---------拿到了:"<<typeStr<<"---------"<<endl;

            //判断应该使用copy,但是没有使用copy
            if (propertyDecl->getTypeSourceInfo() && isShouldUseCopy(typeStr) && !(attrKind & ObjCPropertyDecl::OBJC_PR_copy)) {
                //使用CI发警告信息
                //通过CI获取诊断引擎
                DiagnosticsEngine &diag = CI.getDiagnostics();
                //通过诊断引擎 report报告 错误,即抛出异常
                /*
                错误位置:getBeginLoc 节点开始位置
                错误:getCustomDiagID(等级,提示)
                 */
                diag.Report(propertyDecl->getBeginLoc(), diag.getCustomDiagID(DiagnosticsEngine::Warning, "%0 - 这个地方推荐使用copy!!"))<< typeStr;
            }
        }
    }
};

//第二步:扫描配置完毕
//3、自定义CJLASTConsumer,继承自ASTConsumer,用于监听AST节点的信息 -- 过滤器
class CJLASTConsumer: public ASTConsumer {
private:
    //AST节点的查找过滤器
    MatchFinder matcher;
    //定义回调类对象
    CJLMatchCallback callback;

public:
    //构造方法中创建matcherFinder对象
    CJLASTConsumer(CompilerInstance &CI) : callback(CI) {
        //添加一个MatchFinder,每个objcPropertyDecl节点绑定一个objcPropertyDecl标识(去匹配objcPropertyDecl节点)
        //回调callback,其实是在CJLMatchCallback里面重写run方法(真正回调的是回调run方法)
        matcher.addMatcher(objcPropertyDecl().bind("objcPropertyDecl"), &callback);
    }

    //实现两个回调方法 HandleTopLevelDecl 和 HandleTranslationUnit
    //解析完一个顶级的声明,就回调一次(顶级节点,相当于一个全局变量、函数声明)
    bool HandleTopLevelDecl(DeclGroupRef D){
//        cout<<"正在解析..."<<endl;
        return  true;
    }

    //整个文件都解析完成的回调
    void HandleTranslationUnit(ASTContext &context) {
//        cout<<"文件解析完毕!"<<endl;
        //将文件解析完毕后的上下文context(即AST语法树) 给 matcher
        matcher.matchAST(context);
    }
};

//2、继承PluginASTAction,实现我们自定义的Action,即自定义AST语法树行为
class CJLASTAction: public PluginASTAction {

public:
    //重载ParseArgs 和 CreateASTConsumer方法
    bool ParseArgs(const CompilerInstance &ci, const std::vector<std::string> &args) {
        return true;
    }

    //返回ASTConsumer类型对象,其中ASTConsumer是一个抽象类,即基类
    /*
     解析给定的插件命令行参数。
     - param CI 编译器实例,用于报告诊断。
     - return 如果解析成功,则为true;否则,插件将被销毁,并且不执行任何操作。该插件负责使用CompilerInstance的Diagnostic对象报告错误。
     */
    unique_ptr<ASTConsumer> CreateASTConsumer(CompilerInstance &CI, StringRef iFile) {
        //返回自定义的CJLASTConsumer,即ASTConsumer的子类对象
        /*
         CI用于:
         - 判断文件是否使用户的
         - 抛出警告
         */
        return unique_ptr<CJLASTConsumer> (new CJLASTConsumer(CI));
    }

};

}

//第一步:注册插件,并自定义AST语法树Action类
//1、注册插件
static FrontendPluginRegistry::Add<CJLPlugin::CJLASTAction> CJL("CJLPlugin", "This is CJLPlugin");

其原理主要分三步

  • 【第一步】註冊插件,並自定義AST語法樹Atction類

    • 繼承自PluginASTAction 自定義ASTAction,需要重載兩個方法ParseArgsCreateASTConsumer ,其中的重點方法是CreateASTConsumer,方法中有個參數CI即編譯實例對象,主要用於以下兩個方面
      • 用於判斷文件是否是用戶的
      • 用於拋出警告
    • 通過FrontendPluginRegistry 註冊插件,需要關聯插件名與自定義的ASTAction類
  • 【第二步】掃描配置完畢

    • 繼承自ASTConsumer類,實現自定義的子類ACASTConsumer,有兩個參數MatchFinder對象matcher 以及CJLMatchCallback 自定義的回調對象callback
    • 實現構造函數,主要是創建MatchFinder 對象,以及將CI傳遞給回調對象
    • 實現兩個回調方法
      • HandleTopLevelDecl :解析完一個頂級的聲明,就回調一次
      • HandleTranslationUnit :整個文件都解析完成的回調,將文件解析完畢後的上下文context (即AST語法樹)給matcher
  • 【第三步】掃描完畢的回調函數

    • 繼承自MatchFinder::MatchCallback ,自定義回調類ACMatchCallback
    • 定義CompilerInstance 私有屬性,用於接收ASTConsumer 類傳遞過來的CI信息
    • 重寫run方法
      • 通過result,根據節點標記,獲取相對應節點,此時的比較需要與ACASTConsumer構造方法中一致
      • 判斷節點有值,屏且是用戶文件即isUserSourceCode 私有方法
      • 獲取節點的描述信息
      • 獲取解職點的類型,並轉成字符串
      • 判斷應該使用copy,但是沒有使用copy
      • 通過CI獲取診斷引擎
      • 通過診斷引擎報告錯誤

    所以,綜上所述,clang插件開發的流程圖如下

Clang插件開發流程.png
- 然後再終端測試插件
//命令格式
自己编译的clang文件路径  -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator14.1.sdk/ -Xclang -load -Xclang 插件(.dyld)路径 -Xclang -add-plugin -Xclang 插件名 -c 源码路径

//例子
/Users/XXX/Desktop/build_xcode/Debug/bin/clang -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator14.1.sdk/ -Xclang -load -Xclang /Users/XXXX/Desktop/build_xcode/Debug/lib/CJLPlugin.dylib -Xclang -add-plugin -Xclang CJLPlugin -c /Users/XXXX/Desktop/XXX/XXXX/测试demo/testClang/testClang/ViewController.m

Xcode集成插件

加載插件

打開測試項目,在target->Build Settings -> Other C Flags 添加以下內容

 Xclang -load -Xclang (.dylib)動態庫路徑 -Xclang -add-plugin -Xclang CJLPlugin

  • Build Settings欄目中新增兩項用戶定義的設置,分別是CCCXX

    • CC 對應的是自己編譯的clang 的絕對路徑
    • CXX 對應的是自己編譯的clang++ 的絕對路徑

接下來在Build Settings中搜尋index ,將Enable Index-Wihle-Building FunctionalityDefault 改為NO

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

推荐阅读更多精彩内容