Mach-O探索
前言
我們都知道在iOS應(yīng)用程序中的可執(zhí)行文件的格式是Mach-O
,那么Mach-O
到底存儲(chǔ)了哪些數(shù)據(jù),又是怎么工作的呢?下面我們來探索一下。
1.Mach-O簡(jiǎn)介
維基百科對(duì)于Mach-O
的描述:
Mach-O
為Mach Object
文件格式的縮寫,它是一種用于可執(zhí)行文件,目標(biāo)代碼,動(dòng)態(tài)庫,內(nèi)核轉(zhuǎn)儲(chǔ)的文件格式。作為a.out
格式的替代,Mach-O
提供了更強(qiáng)的擴(kuò)展性,并提升了符號(hào)表中信息的訪問速度。
Mach-O
曾經(jīng)為大部分基于Mach
核心的操作系統(tǒng)所使用。NeXTSTEP
,Darwin
和Mac OS X
等系統(tǒng)使用這種格式作為其原生可執(zhí)行文件,庫和目標(biāo)代碼的格式。而同樣使用GNU Mach
作為其微內(nèi)核的GNU Hurd
系統(tǒng)則使用ELF
而非Mach-O
作為其標(biāo)準(zhǔn)的二進(jìn)制文件格式。
- Header 包含了 Mach-O 文件的基本信息,如 CPU 架構(gòu),文件類型,加載指令數(shù)量等
- Load Commands 是跟在 Header 后面的加載命令區(qū),包含文件的組織架構(gòu)和在虛擬內(nèi)存中的布局方式,在調(diào)用的時(shí)候知道如何設(shè)置和加載二進(jìn)制數(shù)據(jù)
- Data 包含 Load Commands 中需要的各個(gè) Segment 的數(shù)據(jù)。
絕大多數(shù) Mach-O 文件包括以下三種 Segment:
- __TEXT: 代碼段,包括頭文件、代碼和常量。只讀不可修改。
- __DATA:數(shù)據(jù)段,包括全局變量, 靜態(tài)變量等。可讀可寫。
- __LINKEDIT: 如何加載程序, 包含了方法和變量的元數(shù)據(jù)(位置,偏移量),以及代碼簽名等信息。只讀不可修改。
以下內(nèi)容參考自:WWDC 2016 Optimizing App Startup Time
1.1 Mach-O 的幾種類型
Executable
類型
Executable
主程序的二進(jìn)制文件,就是我們iOS應(yīng)用程序顯示包內(nèi)容的MachO文件
可以通過Products->xxx.app->Show in Finder->顯示包內(nèi)容 查看
Dylib
類型
Dylib
動(dòng)態(tài)庫,在其他平臺(tái)上也叫DSO
或者DLL
。
Bundle
類型
Bundle
不能被連接的Dylib
,只能在Runtime
運(yùn)行時(shí)通過dlopen
函數(shù)來加載它,它可以在macOS
上用于插件。
Image
類型
Image
是一種可執(zhí)行的二進(jìn)制文件或者包,包含了上述三種文件類型
Framework
類型
Framework
其實(shí)也是一種dylib
, 它周圍有一個(gè)特殊的目錄結(jié)構(gòu)來保存該dylib
所需的文件。
那么這些都有什么區(qū)別和應(yīng)用呢,請(qǐng)參考我的另一篇文章iOS開發(fā)中『庫』的區(qū)別應(yīng)用
1.2 Mach-O結(jié)構(gòu)分析
1.2.1 segment 段
Mach-O 文件是由 segment
段組成的,分別是TEXT段、DATA段、LINKEDIT段
- 段的名稱為大寫格式
- 所有段都是
page size
的倍數(shù) - arm64上段的大小為16K
- 其他架構(gòu)均為4K
此處實(shí)際上是指的虛擬內(nèi)存的一頁
1.2.2 section
在segment
段內(nèi)部還有許多section
節(jié),section
的名稱為小寫。
But sections are really just a subrange of a segment, they don't have any of the constraints of being page size, but they are non-overlapping.
但是sections
實(shí)際上只是segment
段的子范圍,它們沒有頁面大小的限制,也不會(huì)重疊在一起。
通過MachOView也可以看出上述的結(jié)構(gòu):
1.2.3 常見的 segment 與作用
-
__TEXT
: 代碼段,包括頭文件、代碼和常量以及mach header。 read-only(只讀的)
-
__DATA
:數(shù)據(jù)段,包括全局變量、靜態(tài)變量,是可讀可寫的。
-
__LINKEDIT
: 如何加載程序,包括了方法和變量的元數(shù)據(jù)(位置、偏移量),以及代碼簽名等信息。只讀不可修改。
1.2.4 Mach-O Universal Files
因?yàn)橛袝r(shí)候我們需要構(gòu)建多種架構(gòu)的Mach-O
文件,這個(gè)時(shí)候的做法是通過Mach-O Universal Files
來實(shí)現(xiàn)的,Xcode會(huì)重新生成不同架構(gòu)的二進(jìn)制文件,然后合并到一起,簡(jiǎn)稱Fat(胖)二進(jìn)制文件。它通過header
來記錄不同架構(gòu)在文件中的偏移量,segment
占多個(gè)分頁,header
占用一頁空間,那么header
占用一頁是不是浪費(fèi)了很多空間?答案是肯定的,那么為什么還要占用一頁空間呢?所有東西都基于頁面的好處是什么呢?下面我們通過虛擬內(nèi)存來解釋它。
1.3 virtual memory 虛擬內(nèi)存
PS: 軟件工程格言
every problem can be solved by adding a level of indirection.
每個(gè)問題都可以通過添加中間層來解決
所以說虛擬內(nèi)存是通過中間層間接尋址的一種技術(shù)
虛擬內(nèi)存解決是管理所有進(jìn)程使用物理內(nèi)存的問題。通過添加間接層來讓每個(gè)進(jìn)程使用邏輯地址空間,它可以映射到RAM上的某個(gè)物理頁面上,這種映射不是一對(duì)一的,邏輯地址也有可能映射不到RAM上,也有可能有多個(gè)邏輯地址映射到同一個(gè)物理RAM上。
virtual memory 應(yīng)用:
- 一個(gè)邏輯地址不映射任何物理RAM時(shí),進(jìn)程要訪問該地址時(shí)時(shí)會(huì)觸發(fā)
page fault
頁面錯(cuò)誤,內(nèi)核將停止該線程,并試圖找出解決方案,或者通過CPU調(diào)度去物理磁盤讀取缺失的內(nèi)容,或者其他處理 - 多個(gè)邏輯地址映射到同一物理RAM時(shí),兩個(gè)進(jìn)程共享一樣比特的RAM,通常就是我們說的共享緩存技術(shù),比如說我們的多個(gè)APP同時(shí)訪問
UIKit
- 另一個(gè)就是文件的映射,不用把整個(gè)文件讀入RAM,而是可以調(diào)用
mmap()
函數(shù)告訴虛擬內(nèi)存系統(tǒng),我想把這部分文件映射到進(jìn)程里的這段地址,為什么要這樣做呢?不用讀取整個(gè)文件,通過設(shè)置該映射第一次訪問這些不同的地址時(shí),如果已經(jīng)在內(nèi)存里讀過,每次訪問未訪問過的地址時(shí),都會(huì)觸發(fā)page fault
,內(nèi)核會(huì)處理該page fault
,時(shí)間文件的懶加載 - 通過以上的介紹我們可以知道任意一個(gè)
dylib
或者image
的TEXT段都可以映射到多個(gè)進(jìn)程中,并且可以實(shí)現(xiàn)懶加載,也可以實(shí)現(xiàn)進(jìn)程間共享。 - 那么DATA段呢?有一個(gè)策略叫寫入時(shí)復(fù)制,這個(gè)和APP的文件系統(tǒng)的克隆很相似,寫入時(shí)復(fù)制所做的是它積極地在所有進(jìn)程里共享DATA頁面,只要進(jìn)程只讀有共享內(nèi)容的全局變量,但是一旦有進(jìn)程想要寫入其DATA頁面,寫入時(shí)復(fù)制就是內(nèi)核會(huì)把該頁面進(jìn)行復(fù)制,放入另一個(gè)物理RAM并重定向映射,所以該進(jìn)程有了該頁面的副本,這把我們帶入了臟和干凈頁面,該副本被認(rèn)為是臟頁面。臟頁面是指含有進(jìn)程特定信息,干凈頁面是指內(nèi)核可以按照需要重新建立頁面,比如重新讀取磁盤,所以臟頁面比干凈頁面要昂貴許多。
- 頁面的權(quán)限界限,這指的是可以標(biāo)記一個(gè)頁面可讀可寫可執(zhí)行,或者它們的任何組合。
1.4 virtual memory & Mach-O 之間的映射
首先我們擁有一個(gè)Dylib文件,我們還沒有把他讀取到物理內(nèi)存中,只是先進(jìn)行了映射。這時(shí)候靜態(tài)鏈接器會(huì)把所有值為0的全局變量都移動(dòng)到了尾端。
當(dāng)我們第一次訪問的時(shí)候,虛擬內(nèi)存會(huì)觸發(fā)page fault
,這個(gè)時(shí)候內(nèi)核意識(shí)到它被映射到了一個(gè)文件,這個(gè)時(shí)候內(nèi)核會(huì)讀取這個(gè)文件將它放入物理RAM設(shè)置其映射。
當(dāng)我們還需要讀取其他頁面的時(shí)候,比如讀取LINKEDIT
和DATA
的時(shí)候也是同樣的流程。
但是當(dāng)我們要在DATA段寫入一些內(nèi)容的時(shí)候,就會(huì)觸發(fā)寫入時(shí)復(fù)制,這個(gè)時(shí)候DATA這個(gè)頁面就變?yōu)榕K頁面了,這個(gè)時(shí)候我們只有一個(gè)臟頁面和兩個(gè)干凈的頁面,如果一開始就加載全部,可能就都是臟頁面了。
此時(shí)如果另一個(gè)進(jìn)程也要加載該Dylib,就可以復(fù)用RAM1和RAM2,內(nèi)核只是簡(jiǎn)單的把映射重定向,不需要任何IO操作,如果DATA頁面那個(gè)RAM3沒有變成臟頁面也可以直接復(fù)用,如果變成臟頁面內(nèi)核會(huì)查看RAM3的副本是否在內(nèi)存中,如果還在就可以治截止使用,如果不在就會(huì)重新讀取。
這就實(shí)現(xiàn)了不同進(jìn)程共享這些Dylib,當(dāng)進(jìn)程都不需要使用某一段時(shí)比如LINKEDIT
,在別的進(jìn)程需要RAM時(shí),就會(huì)將其釋放。
1.5 安全如何影響DYLD
1.5.1 ASLR
ASLR
(Address Space Layout Randomization) 地址空間布局隨機(jī)化,鏡像會(huì)在隨機(jī)的地址上加載。內(nèi)存偏移量還需要計(jì)算ASLR
的位置。
1.5.1 Code Signing
在Xcode中 Code Signing
是指對(duì)整個(gè)文件運(yùn)行一個(gè)加密哈希算法,然后對(duì)文件進(jìn)行一個(gè)簽名。為了在運(yùn)行時(shí)進(jìn)行驗(yàn)證,整個(gè)文件都必須要重新讀取,所以在編譯階段,在每個(gè)Mach-O文件的每一個(gè)頁面都進(jìn)行自己的加密哈希算法,所有哈希都存儲(chǔ)在LINKEDIT
里,這使得你的每個(gè)未被修改的頁面,在被讀取的過程中都能得到及時(shí)驗(yàn)證。
1.6 exec()
Exec is a system call. When you trap into the kernel, you basically say I want to replace this process with this new program.
exec 是一個(gè)系統(tǒng)調(diào)用,當(dāng)你進(jìn)入內(nèi)核,我想把這個(gè)進(jìn)程換成這個(gè)新程序,內(nèi)核會(huì)抹去整個(gè)地址,映射指定的可執(zhí)行程序,
ASLR
把它映射到一個(gè)隨機(jī)地址,下一步是從該隨機(jī)地址回溯到0地址把整個(gè)區(qū)域標(biāo)記為不可訪問,就是不可讀,不可寫,不可執(zhí)行,該區(qū)域在32位處理器下至少4KB大小,64位處理器下至少4GB大小,這樣就可以捕獲任何空指針引用,捕獲任何指針截?cái)唷?/p>
2. dyld
2.1 dyld 簡(jiǎn)介
Unix 誕生初期一切都很簡(jiǎn)單,我只需映射一個(gè)程序,把指針引用指向它,開始運(yùn)行即可,后來人們有發(fā)明了共享緩存庫,那么誰來加載Dylibs
呢?這是件很復(fù)雜的事情,人們意識(shí)到不能讓內(nèi)核來做這件事,所以幫助程序就誕生了在Unix平臺(tái)人們叫它LD.SO
,在iOS上他被叫做DYLD
。
當(dāng)內(nèi)核完成進(jìn)程的映射,它現(xiàn)在映射到另一個(gè)Mach-O
文件,調(diào)用Dyld
進(jìn)入該進(jìn)程到另一個(gè)隨機(jī)地址,把指針引用指向Dyld
,讓Dyld
完成進(jìn)程的啟動(dòng),Dyld
的工作是加載所有依賴的Dylib
,讓它們?nèi)繙?zhǔn)備好,開始運(yùn)行。
2.2 dyld加載Mach-O流程
2.2.1 加載主流程(時(shí)間軸)
2.2.2 Load dylibs
-
Dyld
首先要根據(jù)內(nèi)核映射好的主可執(zhí)行文件的頭文件,該頭文件里有一個(gè)所有依賴的庫的列表,根據(jù)這個(gè)列表映射所有Dylib
。 - 找到所有
Dylib
后,確定它是一個(gè)MachO
文件后,通過代碼簽名對(duì)他進(jìn)行驗(yàn)證并注冊(cè)到內(nèi)核。 - 然后它可以在該
Dylib
里的每一段調(diào)用mmap()
,將其讀入內(nèi)存。 -
Dyld
還會(huì)對(duì)每個(gè)Dylib
進(jìn)行遞歸加載,因?yàn)槊總€(gè)不同的Dylib
還有可能依賴Dylib
(已加載的或者未加載的),直到全部加載完畢。 - 其實(shí)我們需要加載的
Dylib
有很多,大約有100
到400
個(gè),但是大部分都是OS Dylib
,這里系統(tǒng)為我們做了足夠多的優(yōu)化,以確保加載速度非常非常的快。
2.2.3 Fix-Ups
現(xiàn)在Dylibs
都已經(jīng)加載完畢了,但是它們都是彼此獨(dú)立的,我們下一條把它們綁定在一起。這就是Fix-Ups(修復(fù))。
由于代碼簽名的存在,我們無法修改指令,那么dylib
該如何調(diào)用另一dylib
呢?這個(gè)時(shí)候我們使用code-gen,即動(dòng)態(tài)PIC(Position Independent Code) 地址無關(guān)代碼,代碼可以加載到該地址,并且是動(dòng)態(tài)的,也就是說地址間接的被分配,為了一個(gè)調(diào)用另一個(gè),code-gen實(shí)際上在DATA段新建了一個(gè)指向被調(diào)用者的指針,任何加載該指針并跳轉(zhuǎn)過去。
所以所有的dyld
都在修復(fù)指針和數(shù)據(jù),修復(fù)有兩種,一種是重設(shè)基址,另一種是綁定。重設(shè)基址是指如果有一個(gè)指針指向Image內(nèi),需要作出所有修改;綁定是指 如果指針指向Image范圍外,也需要進(jìn)行不同的修復(fù)。
dyldinfo還有很多選項(xiàng)參數(shù),我們可以在任何二進(jìn)制文件上運(yùn)行,就可以看到所有的修復(fù)。
過去你可以為每一個(gè)dylib
指定首選加載地址,該首選加載地址是一個(gè)靜態(tài)指針和dyld
一起合作,比如若把它加載到該首選加載地址,所有指針和數(shù)據(jù)本應(yīng)該是內(nèi)部編碼的,都是正確的,那么dyld
就不用做任何修復(fù)。現(xiàn)在有了ASLR
,dylib
被加載到隨機(jī)地址上,它偏移到了其他的地址,也就是說所有的指針和數(shù)據(jù)都依然指向舊地址,所以為了修復(fù)它們,我們需要計(jì)算偏移值,并且對(duì)每一個(gè)內(nèi)部指針都添加該偏移值,所以重設(shè)基址就是指遍歷所有內(nèi)部數(shù)據(jù)指針,然后為它們添加一個(gè)偏移值。概念非常簡(jiǎn)單,就是讀取一個(gè)指針,添加偏移值,在寫入新值。那么這些數(shù)據(jù)指針都在哪里呢?這些指針都在LINKEDIT
段里存儲(chǔ)著。此時(shí)所有的映射都已經(jīng)結(jié)束,當(dāng)我們開始重設(shè)基址的時(shí)候?qū)嶋H上所有DATA頁面上都產(chǎn)生了錯(cuò)誤,然后對(duì)頁面進(jìn)行修改,觸發(fā)寫入時(shí)復(fù)制,所有的重設(shè)基址有時(shí)會(huì)非常昂貴,由于這些都需要I/O操作,但是有一個(gè)技巧,就是按順序操作,從內(nèi)核的角度來看,它認(rèn)為數(shù)據(jù)錯(cuò)誤順序按照產(chǎn)生,當(dāng)它如此認(rèn)為時(shí),內(nèi)核會(huì)進(jìn)行預(yù)讀,這樣就會(huì)降低很多I/O成本
2.2.4 Binding
綁定是針對(duì)那些指向dylib范圍外的指針而言的,這些指針通過名稱就是綁定,實(shí)際就是字符串,本例中LINKEDIT段里的malloc,也就是說該數(shù)據(jù)指針需要指向malloc,所以在運(yùn)行時(shí)dyld需要找到實(shí)現(xiàn)該符號(hào)的位置,這需要很多的計(jì)算,遍歷查找符號(hào)表,一旦找到就把值存到該數(shù)據(jù)指針里,計(jì)算復(fù)雜度比重設(shè)基址高的多。
2.2.5 Notify ObjC Runtime
- Objc有很多DATA結(jié)構(gòu),DATA結(jié)構(gòu)類,也就是指向方法的指針,以及高光指針,幾乎都已經(jīng)被修復(fù),通過重設(shè)基址或者綁定。
- 但是在Objc運(yùn)行時(shí)還需要一些額外的操作,首先Objc是一門動(dòng)態(tài)語言,可以把一個(gè)類用名稱實(shí)例化,即Objc運(yùn)行時(shí)需要維護(hù)一張表,包含所有名稱及其映射的類,每次加載的名稱都將定義一個(gè)類,名稱需要登記在一個(gè)全局表格里。
- 在C++中你可能聽說過脆弱的基類問題,但是在Objc中就不存在該問題,因?yàn)槲覀冏龅钠渲幸环N修復(fù)就是,在加載時(shí)動(dòng)態(tài)改變所有ivar的偏移量。
- 在Objc里可以定義Categories,有時(shí)候它們?cè)诹硪粋€(gè)dylib里,此時(shí)那些方法修復(fù)必須已經(jīng)完成。
- Objc基于選擇器是唯一的,所以我們需要唯一的選擇器
2.2.6 Initializers
So 我們現(xiàn)在完成了所有所有靜態(tài)描述的DATA的修復(fù),現(xiàn)在是進(jìn)行動(dòng)態(tài)DATA修復(fù)的時(shí)機(jī)。
- 在C++中有一個(gè)叫做Initializers的初始化器,可以指定你想要的任何表達(dá)式,在這里我們可以通過運(yùn)行初始化器來完成那些抽象表達(dá)式的初始化。
- 在Objc有一種方法叫
+load
方法,但是現(xiàn)在+load
方法已經(jīng)不在建議使用(建議使用+initialize
),如果使用了它現(xiàn)在將開始運(yùn)行 - 頂端是主可執(zhí)行文件,所有的dylibs依照這張大圖,必須要運(yùn)行初始化器從下往上運(yùn)行,原因是當(dāng)初始化器運(yùn)行時(shí)可能會(huì)調(diào)用一些dylib,你需要確保那些dylib已經(jīng)準(zhǔn)備好被調(diào)用。從下往上一直到類,可以很安全的調(diào)用依賴的內(nèi)容
- 但所有初始化器完成時(shí),我們實(shí)際已經(jīng)最終調(diào)用的主Dylib程序
dyld是一個(gè)幫助程序:
- 可以加載所有的依賴庫
- 修復(fù)所有DATA頁面
- 運(yùn)行初始化器,跳轉(zhuǎn)到主函數(shù)
2.3 dyld2 && dyld3
詳見WWDC2017 - 413 - App Startup Time: Past, Present, and Future
在iOS 13之前,所有APP都是通過dyld2來啟動(dòng)的,主要過程如下:
- 解析
MachO
的Header
和Load Commands
,找到其依賴的庫,并遞歸找到所有依賴的庫 - 加載
MachO
文件 - 進(jìn)行符號(hào)查找
- 綁定和重設(shè)基址
- 運(yùn)行初始化程序
dyld3被分為了三個(gè)組件:
- 一個(gè)進(jìn)程外的
MachO
解析器- 預(yù)先處理了所有可能影響啟動(dòng)速度的
Search Path
、@rpaths
和環(huán)境變量 - 開始分析
MachO
的Header
和依賴,并完成了所有符號(hào)查找的工作 - 最后將這些結(jié)果創(chuàng)建成了一個(gè)啟動(dòng)包
- 這是一個(gè)普通的
daemon
進(jìn)程,可以使用通常的測(cè)試架構(gòu)
- 預(yù)先處理了所有可能影響啟動(dòng)速度的
- 一個(gè)進(jìn)程內(nèi)的引擎,用來運(yùn)行啟動(dòng)閉包
- 這部分在進(jìn)程中處理
- 驗(yàn)證啟動(dòng)閉包的安全性,然后映射到
dylib
中,在跳轉(zhuǎn)到main函數(shù) - 不需要解析
Mach-O
的Header
和依賴,也不需要符號(hào)查找
- 一個(gè)啟動(dòng)閉包緩存服務(wù)
- 系統(tǒng)APP的啟動(dòng)閉包被構(gòu)建在一個(gè)
Shared Cache
中,我們甚至不需要打開一個(gè)單獨(dú)的文件 - 對(duì)于第三方的APP,我們會(huì)在APP安裝或者升級(jí)的時(shí)候構(gòu)建這個(gè)啟動(dòng)閉包
- 在iOS、tvOS、watchOS中,這一切都是APP啟動(dòng)之前完成的,在macOS上,由于有Side Load App,進(jìn)程內(nèi)引擎會(huì)在首次啟動(dòng)的時(shí)候啟動(dòng)一個(gè)daemon進(jìn)程,之后就可以啟動(dòng)閉包了。
- 系統(tǒng)APP的啟動(dòng)閉包被構(gòu)建在一個(gè)