iOS之武功秘籍?: 啟動優(yōu)化(重點(diǎn)是二進(jìn)制重排)

iOS之武功秘籍 文章匯總

寫在前面

啟動是App給用戶的第一印象,對用戶體驗至關(guān)重要.試想一個App需要啟動5s以上,你還想用它么疏咐?

最初的工程肯定是沒有這些問題的,但隨著業(yè)務(wù)需求不斷豐富,代碼越來越多.如果放任不管的話税肪,啟動時間會不斷上漲,最后讓人無法接受.

本文從優(yōu)化原理出發(fā)榜田,介紹了我是如何通過Clang插樁找到啟動所需符號益兄,然后修改編譯參數(shù)完成二進(jìn)制文件的重新排布提升應(yīng)用的啟動速度的.

本節(jié)可能用到的秘籍Demo

一、基本概念(知識儲備)

①. 虛擬內(nèi)存 & 物理內(nèi)存

早期的數(shù)據(jù)訪問是直接通過物理地址訪問的箭券,以這種方式訪問會存在以下兩個問題:

  1. 內(nèi)存不夠用
  2. 內(nèi)存數(shù)據(jù)的安全問題

①.1 內(nèi)存不夠用的解決方案:虛擬內(nèi)存

針對問題1净捅,我們在進(jìn)程和物理內(nèi)存之間增加一個中間層,這個中間層就是所謂的虛擬內(nèi)存辩块,主要用于解決當(dāng)多個進(jìn)程同時存在時灸叼,對物理內(nèi)存的管理.提高了CPU的利用率,使多個進(jìn)程可以同時庆捺、按需加載.所以虛擬內(nèi)存其本質(zhì)就是一張?zhí)摂M地址和物理地址對應(yīng)關(guān)系的映射表

  • 每個進(jìn)程都有一個獨(dú)立的虛擬內(nèi)存古今,其地址都是從0開始,大小是4G固定的滔以,每個虛擬內(nèi)存又會劃分為一個一個的(頁的大小在iOS中是16KB捉腥,其他的是4KB),每次加載都是以頁為單位加載的你画,進(jìn)程間是無法互相訪問的抵碟,保證了進(jìn)程間數(shù)據(jù)的安全性.

  • 一個進(jìn)程中,只有部分功能是活躍的坏匪,所以只需要將進(jìn)程中活躍的部分放入物理內(nèi)存拟逮,避免物理內(nèi)存的浪費(fèi)

  • 當(dāng)CPU需要訪問數(shù)據(jù)時,首先是訪問虛擬內(nèi)存适滓,然后通過虛擬內(nèi)存去尋址敦迄,即可以理解為在表中找對應(yīng)的物理地址,然后對相應(yīng)的物理地址進(jìn)行訪問

  • 如果在訪問時凭迹,虛擬地址的內(nèi)容未加載到物理內(nèi)存罚屋,會發(fā)生缺頁異常(pagefault),將當(dāng)前進(jìn)程阻塞掉嗅绸,此時需要先將數(shù)據(jù)載入到物理內(nèi)存脾猛,然后再尋址,進(jìn)行讀取.這樣就避免了內(nèi)存浪費(fèi)

如下圖所示鱼鸠,虛擬內(nèi)存與物理內(nèi)存間的關(guān)系


①.2 內(nèi)存數(shù)據(jù)的安全問題:ASLR技術(shù)

在上面解釋的虛擬內(nèi)存中猛拴,我們提到了虛擬內(nèi)存的起始地址與大小都是固定的羹铅,這意味著,當(dāng)我們訪問時愉昆,其數(shù)據(jù)的地址也是固定的职员,這會導(dǎo)致我們的數(shù)據(jù)非常容易被破解,為了解決這個問題撼唾,蘋果在iOS4.3開始引入了ASLR技術(shù).

ASLR的概念:(Address Space Layout Randomization ) 地址空間配置隨機(jī)加載,是一種針對緩沖區(qū)溢出的安全保護(hù)技術(shù)哥蔚,通過對堆倒谷、棧、共享庫映射等線性區(qū)布局的隨機(jī)化糙箍,通過增加攻擊者預(yù)測目的地址的難度渤愁,防止攻擊者直接定位攻擊代碼位置,達(dá)到阻止溢出攻擊的目的的一種技術(shù).

其目的是通過利用隨機(jī)方式配置數(shù)據(jù)地址空間深夯,使某些敏感數(shù)據(jù)(例如APP登錄注冊抖格、支付相關(guān)代碼)配置到一個惡意程序無法事先獲知的地址,令攻擊者難以進(jìn)行攻擊.

由于ASLR的存在咕晋,導(dǎo)致可執(zhí)行文件和動態(tài)鏈接庫在虛擬內(nèi)存中的加載地址每次啟動都不固定雹拄,所以需要在編譯時來修復(fù)鏡像中的資源指針,來指向正確的地址掌呜。即正確的內(nèi)存地址 = ASLR地址 + 偏移值

②. 可執(zhí)行文件

不同的操作系統(tǒng)滓玖,其可執(zhí)行文件的格式也不同.系統(tǒng)內(nèi)核將可執(zhí)行文件讀取到內(nèi)存,然后根據(jù)可執(zhí)行文件的頭簽名(magic魔數(shù))判斷二進(jìn)制文件的格式

其中PE质蕉、ELF势篡、Mach-O這三種可執(zhí)行文件格式都是COFF(Command file format)格式的變種,COFF的主要貢獻(xiàn)是目標(biāo)文件里面引入了“段”的機(jī)制模暗,不同的目標(biāo)文件可以擁有不同數(shù)量和不同類型的“段”

③. 通用二進(jìn)制文件

因為不同CPU平臺支持的指令不同禁悠,比如arm64x86,蘋果中的通用二進(jìn)制格式就是將多種架構(gòu)的Mach-O文件打包在一起兑宇,然后系統(tǒng)根據(jù)自己的CPU平臺碍侦,選擇合適的Mach-O,所以通用二進(jìn)制格式也被稱為胖二進(jìn)制格式隶糕,如下圖所示

通用二進(jìn)制格式的定義在<mach-o/fat.h>中祝钢,可以在下載xnu,然后根據(jù) xnu -> EXTERNAL_HEADERS ->mach-o中找到該文件.

通用二進(jìn)制文件開始的Fat Headerfat_header結(jié)構(gòu)體若厚,而Fat Archs是表示通用二進(jìn)制文件中有多少個Mach-O拦英,單個Mach-O的描述是通過fat_arch結(jié)構(gòu)體.兩個結(jié)構(gòu)體的定義如下:

所以,綜上所述:

  1. 通用二進(jìn)制文件是蘋果公司提出的一種新的二進(jìn)制文件的存儲結(jié)構(gòu)测秸,可以同時存儲多種架構(gòu)的二進(jìn)制指令疤估,使CPU在讀取該二進(jìn)制文件時可以自動檢測并選用合適的架構(gòu)灾常,以最理想的方式進(jìn)行讀取
  2. 由于通用二進(jìn)制文件會同時存儲多種架構(gòu),所以比單一架構(gòu)的二進(jìn)制文件大很多铃拇,會占用大量的磁盤空間钞瀑,但由于系統(tǒng)會自動選擇最合適的,不相關(guān)的架構(gòu)代碼不會占用內(nèi)存空間慷荔,且執(zhí)行效率高
  3. 還可以通過指令來進(jìn)行Mach-O的合并與拆分
    1. 查看當(dāng)前Mach-O的架構(gòu):lipo -info MachO文件
    2. 合并:lipo -create MachO1 MachO2 -output 輸出文件路徑
    3. 拆分:lipo MachO文件 –thin 架構(gòu) –output 輸出文件路徑

④. Mach-O文件

Mach-O文件是Mach Object文件格式的縮寫雕什,它是用于可執(zhí)行文件、動態(tài)庫显晶、目標(biāo)代碼的文件格式.作為a.out格式的替代贷岸,Mach-O格式提供了更強(qiáng)的擴(kuò)展性,以及更快的符號表信息訪問速度

熟悉Mach-O文件格式磷雇,有助于更好的理解蘋果底層的運(yùn)行機(jī)制偿警,更好的掌握dyld加載Mach-O的步驟

④.1 Mach-O文件

如果想要查看具體的Mach-O文件信息,可以使用MachOView軟件查看:將Mach-O可執(zhí)行文件拖動到MachOView工具打開

④.2 Mach-O文件格式

對于OS X 和iOS來說,Mach-O是其可執(zhí)行文件的格式唯笙,主要包括以下幾種文件類型

  • Executable:可執(zhí)行文件
  • Dylib:動態(tài)鏈接庫
  • Bundle:無法被鏈接的動態(tài)庫螟蒸,只能在運(yùn)行時使用dlopen加載
  • Image:指的是Executable、Dylib和Bundle的一種
  • Framework:包含Dylib崩掘、資源文件和頭文件的集合

下面圖示是Mach-O 鏡像文件格式

以上是Mach-O文件的格式七嫌,一個完成的Mach-O文件主要分為三大部分:

  • Header Mach-O頭部:主要是Mach-O的cpu架構(gòu),文件類型以及加載命令等信息
  • Load Commands 加載命令:描述了文件中數(shù)據(jù)的具體組織結(jié)構(gòu)苞慢,不同的數(shù)據(jù)類型使用不同的加載命令表示
  • Data 數(shù)據(jù):數(shù)據(jù)中的每個段(segment)的數(shù)據(jù)都保存在這里抄瑟,段的概念與ELF文件中段的概念類似.每個段都有一個或多個部分,它們放置了具體的數(shù)據(jù)與代碼枉疼,主要包含代碼皮假,數(shù)據(jù),例如符號表骂维,動態(tài)符號表等等

Header
Mach-O的Header包含了整個Mach-O文件的關(guān)鍵信息惹资,使得CPU能快速知道Mac-O的基本信息,其在MachO.h文件中針對32位和64位架構(gòu)的cpu航闺,分別使用了mach_headermach_header_64結(jié)構(gòu)體來描述Mach-O頭部.mach_header是連接器加載時最先讀取的內(nèi)容褪测,決定了一些基礎(chǔ)架構(gòu)、系統(tǒng)類型潦刃、指令條數(shù)等信息侮措,這里查看64位架構(gòu)的mach_header_64結(jié)構(gòu)體定義,相比于32位架構(gòu)的mach_header乖杠,只是多了一個reserved保留字段

其中filetype主要記錄Mach-O的文件類型分扎,常用的有以下幾種

#define MH_OBJECT   0x1     /* 目標(biāo)文件*/
#define MH_EXECUTE  0x2     /* 可執(zhí)行文件*/
#define MH_DYLIB    0x6     /* 動態(tài)庫*/
#define MH_DYLINKER 0x7     /* 動態(tài)鏈接器*/
#define MH_DSYM     0xa     /* 存儲二進(jìn)制文件符號信息,用于debug分析*/

相對應(yīng)的胧洒,Header在MachOView中的展示如下

Load Commands
在Mach-O文件中畏吓,Load Commands主要是用于加載指令墨状,其大小和數(shù)目在Header中已經(jīng)被提供,其在MachO.h中的定義如下

我們在MachOView中查看Load Commands菲饼,其中記錄了很多信息肾砂,例如動態(tài)鏈接器的位置、程序的入口宏悦、依賴庫的信息镐确、代碼的位置、符號表的位置等等饼煞,如下所示

其中LC_SEGMENT_64的類型segment_command_64定義如下

Data
Load Commands后就是Data區(qū)域源葫,這個區(qū)域存儲了具體的只讀、可讀寫代碼派哲,例如方法臼氨、符號表掺喻、字符表芭届、代碼數(shù)據(jù)、連接器所需的數(shù)據(jù)(重定向感耙、符號綁定等)褂乍。主要是存儲具體的數(shù)據(jù)。其中大多數(shù)的Mach-O文件均包含以下三個段:

  • __TEXT 代碼段:只讀即硼,包括函數(shù)逃片,和只讀的字符串
  • __DATA 數(shù)據(jù)段:讀寫,包括可讀寫的全局變量等
  • __LINKEDIT: __LINKEDIT包含了方法和變量的元數(shù)據(jù)(位置只酥,偏移量)褥实,以及代碼簽名等信息.

Data區(qū)中,Section占了很大的比例裂允,SectionMachO.h中是以結(jié)構(gòu)體section_64(在arm64架構(gòu)下)表示损离,其定義如下

Section在MachOView中可以看出,主要集中體現(xiàn)在TEXT和DATA兩段里绝编,如下所示

其中常見的section僻澎,主要有以下一些

所以,綜上所述十饥,Mach-O的格式圖示窟勃,如下所示

二、App啟動

進(jìn)程如果能直接訪問物理內(nèi)存無疑是很不安全的,所以操作系統(tǒng)在物理內(nèi)存之上又建立了一層虛擬內(nèi)存.蘋果在這個基礎(chǔ)上還有 ASLR(Address Space Layout Randomization) 技術(shù)的保護(hù)(前面概念有介紹).

iOS系統(tǒng)中虛擬內(nèi)存到物理內(nèi)存的映射都是以頁為最小單位的.當(dāng)進(jìn)程訪問一個虛擬內(nèi)存Page而對應(yīng)的物理內(nèi)存卻不存在時足删,就會出現(xiàn)Page Fault缺頁中斷僻焚,然后加載這一頁.雖然本身這個處理速度是很快的,但是在一個App的啟動過程中可能出現(xiàn)上千(甚至更多)次Page Fault谬运,這個時間積累起來會比較明顯了.

iOS系統(tǒng)中一頁是16KB.

我們常說的啟動是指點(diǎn)擊App到第一頁顯示為止隙赁,包含pre-mainmaindidFinishLaunchingWithOptions結(jié)束的整個時間.

另外梆暖,還有兩個重要的概念:冷啟動伞访、熱啟動.可能有些同學(xué)認(rèn)為殺掉再重啟App就是冷啟動了,其實是不對的.

  • 冷啟動
    程序完全退出轰驳,之間加載的分頁數(shù)據(jù)被其他進(jìn)程所使用覆蓋之后厚掷,或者重啟設(shè)備、第一次安裝级解,才算是冷啟動.

  • 熱啟動
    程序殺掉之后冒黑,馬上又重新啟動.這個時候相應(yīng)的物理內(nèi)存中仍然保留之前加載過的分頁數(shù)據(jù),可以進(jìn)行重用勤哗,不需要全部重新加載.所以熱啟動的速度比較快.

而我們這里所說的啟動優(yōu)化抡爹,一般是指冷啟動情況下的,這種情況下的啟動主要分為兩部分:

  • T1pre-main階段芒划,即main函數(shù)之前冬竟,操作系統(tǒng)加載App可執(zhí)行文件到內(nèi)存,執(zhí)行一系列的加載&鏈接等工作民逼,簡單來說泵殴,就是dyld加載過程
  • T2:main函數(shù)之后,即從main函數(shù)開始拼苍,到 AppdelegatedidFinishLaunching方法執(zhí)行完成為止笑诅,主要是構(gòu)建第一個界面,并完成渲染

所以疮鲫,T1+T2 的過程就是從用戶點(diǎn)擊App圖標(biāo)到用戶能看到app主界面的過程吆你,即需要啟動優(yōu)化的部分

①. pre-main階段的優(yōu)化

pre-main階段的啟動時間其實就是dyld加載過程的時間

針對main函數(shù)之前的啟動時間,蘋果提供了內(nèi)建的測量方法俊犯,在 Edit Scheme -> Run -> Arguments ->Environment Variables 點(diǎn)擊+添加環(huán)境變量 DYLD_PRINT_STATISTICS 設(shè)為 1)妇多,然后運(yùn)行,以下是iPhone6sp正常啟動的pre-main時間(以WeChat為例)

說明
pre-main階段總共用時1.1s

  • dylib loading time(動態(tài)庫耗時):主要是加載動態(tài)庫瘫析,用時297.53ms

    • 動態(tài)加載程序查找并讀取應(yīng)用程序使用的依賴動態(tài)庫.每個庫本身都可能有依賴項.雖然蘋果系統(tǒng)框架的加載是高度優(yōu)化的砌梆,但加載嵌入式框架可能會很耗時.為了加快動態(tài)庫的加載速度,蘋果建議您使用更少的動態(tài)庫贬循,或者考慮合并它們.
    • 建議的目標(biāo)是六個額外的(非系統(tǒng))框架.
  • rebase/binding time(偏移修正/符號綁定耗時):耗時133.43ms

    • 修正調(diào)整鏡像內(nèi)的指針(重新調(diào)整)和設(shè)置指向鏡像外符號的指針(綁定).為了加快重新定位/綁定時間咸包,我們需要更少的指針修復(fù).
    • rebase(偏移修正):任何一個app生成的二進(jìn)制文件,在二進(jìn)制文件內(nèi)部所有的方法杖虾、函數(shù)調(diào)用烂瘫,都有一個地址,這個地址是在當(dāng)前二進(jìn)制文件中的偏移地址.一旦在運(yùn)行時刻(即運(yùn)行到內(nèi)存中),每次系統(tǒng)都會隨機(jī)分配一個ASLR(Address Space Layout Randomization坟比,地址空間布局隨機(jī)化)地址值(是一個安全機(jī)制芦鳍,會分配一個隨機(jī)的數(shù)值,插入在二進(jìn)制文件的開頭)葛账,例如:二進(jìn)制文件中有一個 test 方法柠衅,偏移值是 0x0001,而隨機(jī)分配的 ASLR0x1f00籍琳,如果想訪問 test方法菲宴,其內(nèi)存地址(即真實地址)變?yōu)?ASLR+偏移值 = 運(yùn)行時確定的內(nèi)存地址(即0x1f00 + 0x0001 = 0x1f01
    • binding(綁定):例如 NSLog 方法,在編譯時期生成的mach-o文件中趋急,會創(chuàng)建一個符號喝峦!NSLog(目前指向一個隨機(jī)的地址),然后在運(yùn)行時(從磁盤加載到內(nèi)存中呜达,是一個鏡像文件)谣蠢,會將真正的地址給符號(即在內(nèi)存中將地址與符號進(jìn)行綁定,是dyld做的查近,也稱為動態(tài)庫符號綁定)眉踱,一句話概括:綁定就是給符號賦值的過程
  • ObjC setup time(OC類注冊的耗時):OC類越多,越耗時

    • Objective-C運(yùn)行時需要進(jìn)行設(shè)置類嗦嗡、類別和選擇器注冊.我們對重新定位綁定時間所做的任何改進(jìn)也將優(yōu)化這個設(shè)置時間
    • 如果有大量(大的是20000)Objective-C類勋锤、選擇器和類別的應(yīng)用程序可以增加800ms的啟動時間.
    • 如果應(yīng)用程序使用C++代碼饭玲,那么使用更少的虛擬函數(shù).
    • 使用Swift結(jié)構(gòu)體通常也更快
  • initializer time(執(zhí)行l(wèi)oad和構(gòu)造函數(shù)的耗時)

    • 運(yùn)行初始化程序.如果使用了Objective-C的 +load 方法侥祭,請將其替換為 +initialize 方法.

②. main函數(shù)階段的優(yōu)化

在main函數(shù)之后的 didFinishLaunching 方法中,主要是執(zhí)行了各種業(yè)務(wù)茄厘,有很多并不是必須在這里立即執(zhí)行的矮冬,這種業(yè)務(wù)我們可以采取延遲加載,防止影響啟動時間.

didFinishLaunching中的業(yè)務(wù)主要分為三個類型

  • 【第一類】初始化第三方sdk
  • 【第二類】app運(yùn)行環(huán)境配置
  • 【第三類】自己工具類的初始化等

main函數(shù)階段的優(yōu)化建議主要有以下幾點(diǎn):

  • 減少啟動初始化的流程次哈,能懶加載的懶加載胎署,能延遲的延遲,能放后臺初始化的放后臺窑滞,盡量不要占用主線程的啟動時間
  • 優(yōu)化代碼邏輯琼牧,去除非必須的代碼邏輯,減少每個流程的消耗時間
  • 啟動階段能使用多線程來初始化的哀卫,就使用多線程
  • 盡量使用純代碼來進(jìn)行UI框架的搭建巨坊,尤其是主UI框架,例如 UITabBarController.盡量避免使用Xib或者SB此改,相比純代碼而言趾撵,這種更耗時
  • 刪除廢棄類、方法

三共啃、二進(jìn)制重排 —— 主要是針對如何減少Page Fault的優(yōu)化

前面大致介紹了一些基本概念以及啟動優(yōu)化的思路占调,下面來著重介紹一個pre-main階段的優(yōu)化方案暂题,即二進(jìn)制重排

①. 二進(jìn)制重排原理

在虛擬內(nèi)存部分,我們知道究珊,當(dāng)進(jìn)程訪問一個虛擬內(nèi)存page薪者,而對應(yīng)的物理內(nèi)存不存在時,會觸發(fā)缺頁中斷(Page Fault)剿涮,因此阻塞進(jìn)程.此時就需要先加載數(shù)據(jù)到物理內(nèi)存啸胧,然后再繼續(xù)訪問.這個對性能是有一定影響的.

基于Page Fault,我們思考幔虏,App在冷啟動過程中纺念,會有大量的類、分類想括、三方等需要加載和執(zhí)行陷谱,此時產(chǎn)生的Page Fault所帶來的耗時是很大的.以WeChat為例,我們來看下瑟蜈,在啟動階段的Page Fault的次數(shù)

  • CMD+i快捷鍵烟逊,選擇System Trace
  • 點(diǎn)擊啟動(啟動前需要重啟手機(jī),清除緩存數(shù)據(jù))铺根,第一個界面出來后宪躯,停掉,按照下圖中操作


從圖中可以看出WeChat發(fā)生的PageFault有2900+次位迂,可想而知访雪,這個是非常影響性能的.

  • 然后我們再通過Demo查看方法在編譯時期的排列順序,在ViewController中按下列順序定義以下幾個方法
  • Build Settings -> Write Link Map File 設(shè)置為 YES
  • CMD+B編譯Demo掂林,然后在對應(yīng)的路徑下查找 link map文件.右鍵 Show In Finder打開包文件夾:
* 在包文件的上兩層級臣缀,找到 `Intermediates.noindex`:![](https://upload-images.jianshu.io/upload_images/2340353-4dc3261150c3766c.jpg?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)


* 沿路徑找到并打開① - 啟動優(yōu)化Demo-LinkMap-normal-arm64.txt文件:![](https://upload-images.jianshu.io/upload_images/2340353-8c6a7b7a7e2be50d.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
  • 函數(shù)順序(書寫順序),如下所示泻帮,可以發(fā)現(xiàn) 類中函數(shù)的加載順序是從上到下的精置,而文件的順序是根據(jù)Build Phases -> Compile Sources中的順序加載的

總結(jié)
從上面的Page Fault的次數(shù)以及加載順序,可以發(fā)現(xiàn)其實導(dǎo)致 Page Fault 次數(shù)過多的根本原因是啟動時刻需要調(diào)用的方法锣杂,處于不同的Page導(dǎo)致的.因此脂倦,我們的優(yōu)化思路就是:**將所有啟動時刻需要調(diào)用的方法,排列在一起元莫,即放在一個頁中赖阻,這樣就從多個 Page Fault 變成了一個 Page Fault **. 這就是二進(jìn)制重排的 核心原理,如下所示

注意:在iOS生產(chǎn)環(huán)境的app柒竞,在發(fā)生Page Fault進(jìn)行重新加載時政供,iOS系統(tǒng)還會對其做一次簽名驗證,因此 iOS 生產(chǎn)環(huán)境的 Page Fault 比Debug環(huán)境下所產(chǎn)生的耗時更多

②. 二進(jìn)制重排實踐

下面,我們來進(jìn)行具體的實踐布隔,首先理解幾個名詞

②.1 Link Map

Link Map 是iOS編譯過程的中間產(chǎn)物离陶,記錄了二進(jìn)制文件的布局,需要在Xcode的Build Settings 里開啟Write Link Map File,Link Map主要包含三部分:

  • Object Files 生成二進(jìn)制用到的link單元的路徑和文件編號
  • Sections 記錄Mach-O每個Segment/section的地址范圍
  • Symbols 按順序記錄每個符號的地址范圍

②.2 ld

ld是Xcode使用的鏈接器衅檀,有一個參數(shù)order_file招刨,我們可以通過在Build Settings -> Order File配置一個后綴為order的文件路徑.在這個order文件中,將所需要的符號按照順序?qū)懺诶锩姘Ь陧椖烤幾g時沉眶,會按照這個文件的順序進(jìn)行加載,以此來達(dá)到我們的優(yōu)化

所以二進(jìn)制重排的本質(zhì)就是對啟動加載的符號進(jìn)行重新排列

到目前為止杉适,原理我們基本弄清楚了谎倔,如果項目比較小,完全可以自定義一個order文件猿推,將方法的順序手動添加片习,但是如果項目較大,涉及的方法特別多蹬叭,此時我們?nèi)绾潍@取啟動運(yùn)行的函數(shù)呢藕咏?有以下幾種思路:

  1. hook objc_msgSend:我們知道,函數(shù)的本質(zhì)是發(fā)送消息秽五,在底層都會來到objc_msgSend孽查,但是由于objc_msgSend的參數(shù)是可變的,需要通過匯編獲取坦喘,對開發(fā)人員要求較高.而且也只能拿到 OCswift@objc 后的方法
  2. 靜態(tài)掃描:掃描 Mach-O 特定段和節(jié)里面所存儲的符號以及函數(shù)數(shù)據(jù)
  3. Clang插樁:即批量hook盲再,可以實現(xiàn)100%符號覆蓋,即完全獲取swift起宽、OC洲胖、C济榨、block函數(shù)

②.3 二進(jìn)制重排初體驗

二進(jìn)制重排坯沪,關(guān)鍵是order文件

  • 前面講objc源碼時,會在工程中看到order文件:
  • 打開.order文件擒滑,可以看到內(nèi)部都是排序好的函數(shù)符號
  • 這是因為蘋果自己的庫腐晾,也都進(jìn)行了二進(jìn)制重排

新進(jìn)一個Demo (② - 二進(jìn)制重排初體驗) 玩玩
我們打開創(chuàng)建的Demo項目,我想把排序改成load->test1->test2->ViewDidAppear->main

  • 在Demo項目根目錄創(chuàng)建一個tcj.order文件

    touch tcj.order
    
  • tcj.order文件中手動順序?qū)懭牒瘮?shù)(還寫了個不存在的hello函數(shù))
  • Build Settings中搜索order file,加入./tcj.order
  • Command + B編譯后丐一,再次去查看link map文件:
* 發(fā)現(xiàn)`order`文件中`不存在的函數(shù)`(hello)藻糖,編譯器會直接跳過
* 其他`函數(shù)符號`,完全按照我們`order`順序排列
* `order`中沒有的函數(shù)库车,按照默認(rèn)順序接在`order`函數(shù)后面
  • 那么問題來了.靠手寫一個個函數(shù)寫進(jìn)order文件中.代碼寫了那么多巨柒,還有些代碼不是我寫的,我怎么知道哪個函數(shù)先,哪個函數(shù)后呢洋满?晶乔?
    • 我們要做到的目標(biāo): 拿到啟動完成后的某個時刻之前的所有被調(diào)用函數(shù).勞煩你們自己排隊進(jìn)入我的order文件中(Clang插樁來實現(xiàn))

②.4 Clang插樁

要真正的實現(xiàn)二進(jìn)制重排牺勾,我們需要拿到啟動的所有方法正罢、函數(shù)等符號,并保存其順序驻民,然后寫入order文件翻具,實現(xiàn)二進(jìn)制重排.

抖音有一篇文章抖音研發(fā)實踐:基于二進(jìn)制文件重排的解決方案 APP啟動速度提升超15%,但是文章中也提到了瓶頸:

基于靜態(tài)掃描+運(yùn)行時trace的方案仍然存在少量瓶頸:

  • initialize hook不到
  • 部分block hook不到
  • C++通過寄存器的間接函數(shù)調(diào)用靜態(tài)掃描不出來

目前的重排方案能夠覆蓋到80%~90%的符號回还,未來我們會嘗試編譯期插樁等方案來進(jìn)行100%的符號覆蓋裆泳,讓重排達(dá)到最優(yōu)效果。

同時也給出了解決方案編譯期插樁.

在說clang插樁之前,我們來說說什么是hook?

hook是鉤子. -- 獲取原有函數(shù)符號內(nèi)存地址實現(xiàn)柠硕,勾住它晾虑,做一些自己想做的事情

  • 例如: 你遇到在公路上攔到一輛車.你可以跟他的車一起走(附加自己代碼),也可以直接搶了他的車自己開(重寫實現(xiàn)).

很明顯仅叫,我們此刻就是想勾住啟動結(jié)束前的所有函數(shù)帜篇,附加一些代碼,把函數(shù)名按順序存下來诫咱,生成我們的order文件

Q: 有沒有API笙隙,能讓我hook一切我想hook的東西?swift坎缭、oc竟痰、c函數(shù)我都要hook?
A: 有,clang插樁. 語法樹都是它生成的掏呼,順序它說了算.

Clang插樁
llvm內(nèi)置了一個簡單的代碼覆蓋率檢測(SanitizerCoverage).它在函數(shù)級坏快、基本塊級和邊緣級插入對用戶定義函數(shù)的調(diào)用.我們這里的批量hook,就需要借助于SanitizerCoverage.

關(guān)于 clang 的插樁覆蓋的官方文檔如下 : clang 自帶代碼覆蓋工具 文檔中有詳細(xì)概述憎夷,以及簡短Demo演示

我們創(chuàng)建TraceDemo項目莽鸿,按照官方給的示例,來嘗試開發(fā)

添加trace

  • 按照官方描述拾给,可以加入跟蹤代碼祥得,并給出了回調(diào)函數(shù).

打開我們的TranceDemo, 在Build Settings中搜索Other C,在 Other C Flags里加入-fsanitize-coverage=trace-pc-guard配置,編譯的話會報錯
objc Undefined symbol: ___sanitizer_cov_trace_pc_guard_init Undefined symbol: ___sanitizer_cov_trace_pc_guard

查看官網(wǎng)會需要我們添加兩個函數(shù):

#include <stdint.h>
#include <stdio.h>
#include <sanitizer/coverage_interface.h>

// This callback is inserted by the compiler as a module constructor
// into every DSO. 'start' and 'stop' correspond to the
// beginning and end of the section with the guards for the entire
// binary (executable or DSO). The callback will be called at least
// once per DSO and may be called multiple times with the same parameters.
extern "C" void __sanitizer_cov_trace_pc_guard_init(uint32_t *start,
                                                    uint32_t *stop) {
  static uint64_t N;  // Counter for the guards.
  if (start == stop || *start) return;  // Initialize only once.
  printf("INIT: %p %p\n", start, stop);
  for (uint32_t *x = start; x < stop; x++)
    *x = ++N;  // Guards should start from 1.
}

// This callback is inserted by the compiler on every edge in the
// control flow (some optimizations apply).
// Typically, the compiler will emit the code like this:
//    if(*guard)
//      __sanitizer_cov_trace_pc_guard(guard);
// But for large functions it will emit a simple call:
//    __sanitizer_cov_trace_pc_guard(guard);
extern "C" void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
  if (!*guard) return;  // Duplicate the guard check.
  // If you set *guard to 0 this code will not be called again for this edge.
  // Now you can get the PC and do whatever you want:
  //   store it somewhere or symbolize it and print right away.
  // The values of `*guard` are as you set them in
  // __sanitizer_cov_trace_pc_guard_init and so you can make them consecutive
  // and use them to dereference an array or a bit vector.
  void *PC = __builtin_return_address(0);
  char PcDescr[1024];
  // This function is a part of the sanitizer run-time.
  // To use it, link with AddressSanitizer or other sanitizer.
  __sanitizer_symbolize_pc(PC, "%p %F %L", PcDescr, sizeof(PcDescr));
  printf("guard: %p %x PC %s\n", guard, *guard, PcDescr);
}

我們把代碼添加到ViewController.m中,我們不需要 extern "C" 所以可以刪掉蒋得, __sanitizer_symbolize_pc() 還會報錯级及,不重要先注釋了然后繼續(xù).

函數(shù) __sanitizer_cov_trace_pc_guard_init 統(tǒng)計了方法的個數(shù).

運(yùn)行后,我們可以看到

讀取內(nèi)存之后额衙,我們可以看到一個類似計數(shù)器的東西.最后一個打印的是結(jié)束位置饮焦,按顯示是4位4位的怕吴,所以向前移動4位,打印出來的應(yīng)該就是最后一位.

解釋兩個參數(shù):

  • 參數(shù)1 start 是一個指針县踢,指向無符號int類型械哟,4個字節(jié),相當(dāng)于一個數(shù)組的起始位置殿雪,即符號的起始位置(是從高位往低位讀)
  • 參數(shù)2 stop暇咆,由于數(shù)據(jù)的地址是往下讀的(即從高往低讀,所以此時獲取的地址并不是stop真正的地址丙曙,而是標(biāo)記的最后的地址爸业,讀取stop時,由于stop占4個字節(jié)亏镰,stop真實地址 = stop打印的地址-0x4
  • startstop表示當(dāng)前文件的開始內(nèi)存地址和結(jié)束內(nèi)存地址扯旷。單位是int32 4字節(jié)
  • 如果多加幾個函數(shù),會發(fā)現(xiàn)stop地址值也會相應(yīng)的增加索抓。
  • 此處是指從start到stop的前閉后開區(qū)間钧忽。[ , ),所以stop地址往前偏移4字節(jié)逼肯,才是最后一個函數(shù)符號的地址

根據(jù)小端模式耸黑,0e 00 00 00對應(yīng)的是00 00 00 0e即14.

那么stop內(nèi)存地址中存儲的值表示什么?在增加一個方法/塊/c++/屬性的方法(多幾個)篮幢,發(fā)現(xiàn)其值也會增加對應(yīng)的數(shù).

例如先在ViewController.m增加一個touchesBegan方法,運(yùn)行:

根據(jù)小端模式大刊,0f 00 00 00對應(yīng)的是00 00 00 0f即15.

我們在增加一個函數(shù)test():運(yùn)行:

根據(jù)小端模式,10 00 00 00對應(yīng)的是00 00 00 10即16.

我們在增加一個block:運(yùn)行:

根據(jù)小端模式三椿,11 00 00 00對應(yīng)的是00 00 00 11即17.

到此時可以看到一共增加了3(block是匿名函數(shù))缺菌,計數(shù)器統(tǒng)計了函數(shù)/方法/塊的個數(shù),這里添加了三個搜锰,索引增加了3

從新整理一下代碼:

#import "ViewController.h"
#include <stdint.h>
#include <stdio.h>
#include <sanitizer/coverage_interface.h>

@interface ViewController ()

@end

@implementation ViewController

void test()
{
    block();
}

void(^block)(void) = ^(void){
    
};

- (void)viewDidLoad {
    [super viewDidLoad];
}

void __sanitizer_cov_trace_pc_guard_init(uint32_t *start, uint32_t *stop) {
  static uint64_t N;  // Counter for the guards.
  if (start == stop || *start) return;  // Initialize only once.
  printf("INIT: %p %p\n", start, stop);
  for (uint32_t *x = start; x < stop; x++)
    *x = ++N;  // Guards should start from 1.
}

void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
  if (!*guard) return;  // Duplicate the guard check.
//  void *PC = __builtin_return_address(0);
  char PcDescr[1024];
//  __sanitizer_symbolize_pc(PC, "%p %F %L", PcDescr, sizeof(PcDescr));
  printf("guard: %p %x PC %s\n", guard, *guard, PcDescr);
}

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    
    test();
}

@end

運(yùn)行項目清空打印區(qū):當(dāng)我們再點(diǎn)擊一下屏幕的時候:

我們在touchBegin伴郁、testblock__sanitizer_cov_trace_pc_guard都加入斷點(diǎn)蛋叼,運(yùn)行代碼:

【驗證一】執(zhí)行順序是:touchesBegan -> __sanitizer_cov_trace_pc_guard ->
test -> __sanitizer_cov_trace_pc_guard ->
block -> __sanitizer_cov_trace_pc_guard

【驗證二】touchesBegan時焊傅,進(jìn)入?yún)R編:

如果我們查看其他函數(shù)也會發(fā)現(xiàn)匯編代碼中有類似的顯示.那么每個函數(shù)在觸發(fā)時,都調(diào)用了__sanitizer_cov_trace_pc_guard函數(shù).

即:只要在Other C Flags處加入標(biāo)記鸦列,開啟了trace功能.LLVM會在每個函數(shù)邊緣(開始位置)租冠,插入一行調(diào)用__sanitizer_cov_trace_pc_guard的代碼.編譯期就插入了.所以可以100%覆蓋.(也就是說Clang插樁就是在匯編代碼中插入了 __sanitizer_cov_trace_pc_guard函數(shù)的調(diào)用)

解釋一下__sanitizer_cov_trace_pc_guard方法:主要是捕獲所有的啟動時刻的符號,將所有符號入隊.

拿到了全部的符號之后需要保存薯嗤,但是不能用數(shù)組,因為有可能會有在子線程執(zhí)行的纤泵,所以用數(shù)組會有線程問題 .這里我們使用原子隊列:

#import "ViewController.h"
#include <stdint.h>
#include <stdio.h>
#include <sanitizer/coverage_interface.h>
#import <libkern/OSAtomic.h>
#import <dlfcn.h>

@interface ViewController ()

@end

@implementation ViewController

//定義原子隊列: 特點(diǎn) 1.先進(jìn)后出 2.線程安全 3.只能保存結(jié)構(gòu)體
static OSQueueHead symbolList = OS_ATOMIC_QUEUE_INIT;

//定義符號結(jié)構(gòu)體鏈表
typedef struct{
    void *pc;
    void *next;
} SymbolNode;

void test()
{
    block();
}

void(^block)(void) = ^(void){
    
};

- (void)viewDidLoad {
    [super viewDidLoad];
}

/*
 - start:起始位置
 - stop:并不是最后一個符號的地址骆姐,而是整個符號表的最后一個地址镜粤,最后一個符號的地址=stop-4(因為是從高地址往低地址讀取的,且stop是一個無符號int類型玻褪,占4個字節(jié))肉渴。stop存儲的值是符號的
 */
void __sanitizer_cov_trace_pc_guard_init(uint32_t *start, uint32_t *stop) {
  static uint64_t N;  // Counter for the guards.
  if (start == stop || *start) return;  // Initialize only once.
  printf("INIT: %p %p\n", start, stop);
  for (uint32_t *x = start; x < stop; x++)
    *x = ++N;  // Guards should start from 1.
}

/*
 可以全面hook方法、函數(shù)带射、以及block調(diào)用同规,用于捕捉符號,是在多線程進(jìn)行的窟社,這個方法中只存儲pc券勺,以鏈表的形式
 
 - guard 是一個哨兵,告訴我們是第幾個被調(diào)用的
 */
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
//  if (!*guard) return;  // Duplicate the guard check. //將load方法過濾掉了灿里,所以需要注釋掉
    
    //獲取PC
    /*
     - PC 當(dāng)前函數(shù)返回上一個調(diào)用的地址
     - 0 當(dāng)前這個函數(shù)地址关炼,即當(dāng)前函數(shù)的返回地址
     - 1 當(dāng)前函數(shù)調(diào)用者的地址,即上一個函數(shù)的返回地址
    */
  void *PC = __builtin_return_address(0);
    
    //創(chuàng)建結(jié)構(gòu)體!
  SymbolNode * node = malloc(sizeof(SymbolNode));
    *node = (SymbolNode){PC, NULL};
    
    
    //加入隊列
    //符號的訪問不是通過下標(biāo)訪問匣吊,是通過鏈表的next指針儒拂,所以需要借用offsetof(結(jié)構(gòu)體類型,下一個的地址即next)
    OSAtomicEnqueue(&symbolList, node, offsetof(SymbolNode, next));
    
    Dl_info info;// 聲明對象
    dladdr(PC, &info);// 讀取PC地址色鸳,賦值給info
    printf("fnam:%s \n fbase:%p \n sname:%s \n saddr:%p \n",
           info.dli_fname,
           info.dli_fbase,
           info.dli_sname,
           info.dli_saddr);

}

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    
    test();
}

@end

運(yùn)行后這里我們可以看到很多打印社痛,只取一條來說明,很明顯其中sname就是我們需要的符號名了.

下面我們通過點(diǎn)擊屏幕導(dǎo)出所需要的符號命雀,需要注意的是C函數(shù)和Swift方法前面需要加下劃線.(這一點(diǎn)可以在前面提到的LinkMap文件中確認(rèn))

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    
    NSMutableArray <NSString *>* symbolNames = [NSMutableArray array];
    
    // 每次while循環(huán)褥影,都會加入一次hook (__sanitizer_cov_trace_pc_guard)   只要是跳轉(zhuǎn),就會被block
    // 直接修改[other c clang]: -fsanitize-coverage=func,trace-pc-guard 指定只有func才加Hook
    while (YES) {
        // 去除鏈表
        SymbolNode * node = OSAtomicDequeue(&symbolList, offsetof(SymbolNode, next));
        
        if (node == NULL) {
            break;
        }
        
        Dl_info info = {0};
        // 取出節(jié)點(diǎn)的pc,賦值給info
        dladdr(node->pc, &info);
        // 釋放節(jié)點(diǎn)
        free(node);
        // 存名字
        NSString * name = @(info.dli_sname);
        
        BOOL isObjc = [name hasPrefix:@"+["] || [name hasPrefix:@"-["]; //OC方法不處理
        NSString * symbolName = isObjc ? name : [@"_" stringByAppendingString:name]; //c函數(shù)咏雌、swift方法前面帶下劃線
        [symbolNames addObject:symbolName];
        printf("%s \n",info.dli_sname);

    }
    
    //取反(隊列的存儲是反序的)
    NSEnumerator * emt = [symbolNames reverseObjectEnumerator];
    //創(chuàng)建數(shù)組
    NSMutableArray<NSString*>* funcs = [NSMutableArray arrayWithCapacity:symbolNames.count];
    // 臨時變量
    NSString * name;
    // 遍歷集合凡怎,去重,添加到funcs中
    while (name = [emt nextObject]) {
        // 數(shù)組中去重添加
        if (![funcs containsObject:name]) {
            [funcs addObject:name];
        }
    }
    // 刪掉當(dāng)前方法赊抖,因為這個點(diǎn)擊方法不是啟動需要的
    [funcs removeObject:[NSString stringWithFormat:@"%s",__FUNCTION__]];
    // 文件路徑
    NSString * filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"tcj.order"];
    // 數(shù)組轉(zhuǎn)字符串
    NSString *funcStr = [funcs componentsJoinedByString:@"\n"];
    // 文件內(nèi)容
    NSData * fileContents = [funcStr dataUsingEncoding:NSUTF8StringEncoding];
    // 在路徑上創(chuàng)建文件
    [[NSFileManager defaultManager] createFileAtPath:filePath contents:fileContents attributes:nil];
    
    NSLog(@"%@",filePath);

}

這時如果你直接點(diǎn)擊屏幕统倒,有個巨坑,會看到控制臺一直在輸出氛雪,出現(xiàn)了死循環(huán):

我們在while里面設(shè)置一個斷點(diǎn):

然后查看匯編:

發(fā)現(xiàn) __sanitizer_cov_trace_pc_guard居然有10個房匆,這個地方會觸發(fā) __sanitizer_cov_trace_pc_guard中的入隊,這里又進(jìn)行出隊报亩,最后就死循環(huán)了.

解決辦法:
Build SettingsOther C Flags添加func配置浴鸿,即-fsanitize-coverage=func,trace-pc-guard.

官網(wǎng)對func的參數(shù)的解釋:只檢測每個函數(shù)的入口.

再次運(yùn)行點(diǎn)擊屏幕就不會有問題了.

注意點(diǎn):

  1. if(!guard) return;需要去掉,它會影響+load的寫入*
  2. while循環(huán)弦追,也會觸發(fā)__sanitizer_cov_trace_pc_guard(trace的觸發(fā)岳链,并不是根據(jù)函數(shù)來進(jìn)行hook的,而是hook了每一個跳轉(zhuǎn)(bl).while也有跳轉(zhuǎn)劲件,所以進(jìn)入了死循環(huán))

從真機(jī)上獲取order文件
我們把order文件存在了真機(jī)上的tmp文件夾中掸哑,要怎么拿到呢约急?

Window→Devices And Simulators(快捷鍵?+?+2)中:

下載到指定位置,顯示包內(nèi)容,在tmp文件夾內(nèi)可找到order文件.

Swift二進(jìn)制重排
Swift也可以重排么?當(dāng)然可以苗分!

Swift 二進(jìn)制重排厌蔽,與OC一樣.只是LLVM前端不同.

  • OC的前端編譯器是Clang,所以在other c flags處添加-fsanitize-coverage=func,trace-pc-guard
  • Swift的前端編譯器是Swift摔癣,所以在other Swift Flags處添加-sanitize=undefined-sanitize-coverage=func

我們在項目中添加一個Swift類奴饮,然后在ViewControllerload方法中調(diào)用一下:

Build Setting中Other Swift Flags設(shè)置:

運(yùn)行后點(diǎn)擊一下屏幕,查看控制臺:

補(bǔ)充:
swift符號自帶名稱混淆
未改變代碼時择浊,swift符號不會變
總之戴卜,order文件,請在代碼封版后近她,再生成

所有處理完之后叉瘩,最后需要Write Link Map File改為NO,把Other C Flags/Other Swift Flags的配置刪除掉

因為這個配置會在我們代碼中自動插入跳轉(zhuǎn)執(zhí)行 __sanitizer_cov_trace_pc_guard.重排完就不需要了粘捎,需要去除掉. 同時把ViewController中的 __sanitizer_cov_trace_pc_guard也要去除掉.

至此薇缅,Clang插樁和自動生成Order文件,都已完成.拿到order文件后,小伙伴們可以去自己的項目試試哦.

寫在后面

通過二進(jìn)制重排攒磨,讓啟動需要的方法排列更緊湊泳桦,減少了Page Fault的次數(shù).
獲取符號表時,采用Clang插樁可以直接hook到Objective-C方法娩缰、Swift方法灸撰、C函數(shù)、Block拼坎,可以不用區(qū)別對待.相比于抖音之前提出的方案確實簡單很多浮毯,門檻也要低一些.

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市泰鸡,隨后出現(xiàn)的幾起案子债蓝,更是在濱河造成了極大的恐慌,老刑警劉巖盛龄,帶你破解...
    沈念sama閱讀 216,402評論 6 499
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件饰迹,死亡現(xiàn)場離奇詭異,居然都是意外死亡余舶,警方通過查閱死者的電腦和手機(jī)啊鸭,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,377評論 3 392
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來匿值,“玉大人赠制,你說我怎么就攤上這事∏樱” “怎么了憎妙?”我有些...
    開封第一講書人閱讀 162,483評論 0 353
  • 文/不壞的土叔 我叫張陵库正,是天一觀的道長曲楚。 經(jīng)常有香客問我厘唾,道長,這世上最難降的妖魔是什么龙誊? 我笑而不...
    開封第一講書人閱讀 58,165評論 1 292
  • 正文 為了忘掉前任抚垃,我火速辦了婚禮,結(jié)果婚禮上趟大,老公的妹妹穿的比我還像新娘鹤树。我一直安慰自己,他們只是感情好逊朽,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,176評論 6 388
  • 文/花漫 我一把揭開白布罕伯。 她就那樣靜靜地躺著,像睡著了一般叽讳。 火紅的嫁衣襯著肌膚如雪追他。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,146評論 1 297
  • 那天岛蚤,我揣著相機(jī)與錄音邑狸,去河邊找鬼。 笑死涤妒,一個胖子當(dāng)著我的面吹牛单雾,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播她紫,決...
    沈念sama閱讀 40,032評論 3 417
  • 文/蒼蘭香墨 我猛地睜開眼硅堆,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了贿讹?” 一聲冷哼從身側(cè)響起渐逃,我...
    開封第一講書人閱讀 38,896評論 0 274
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎围详,沒想到半個月后朴乖,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 45,311評論 1 310
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡助赞,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,536評論 2 332
  • 正文 我和宋清朗相戀三年买羞,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片雹食。...
    茶點(diǎn)故事閱讀 39,696評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡畜普,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出群叶,到底是詐尸還是另有隱情吃挑,我是刑警寧澤钝荡,帶...
    沈念sama閱讀 35,413評論 5 343
  • 正文 年R本政府宣布,位于F島的核電站舶衬,受9級特大地震影響埠通,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜逛犹,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,008評論 3 325
  • 文/蒙蒙 一端辱、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧虽画,春花似錦舞蔽、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,659評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至脖岛,卻和暖如春朵栖,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背鸡岗。 一陣腳步聲響...
    開封第一講書人閱讀 32,815評論 1 269
  • 我被黑心中介騙來泰國打工混槐, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人轩性。 一個月前我還...
    沈念sama閱讀 47,698評論 2 368
  • 正文 我出身青樓声登,卻偏偏與公主長得像,于是被迫代替她去往敵國和親揣苏。 傳聞我的和親對象是個殘疾皇子蚌斩,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,592評論 2 353

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