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