iOS 底層探索系列前端
App
從被用戶在主屏幕上點擊以後就開啓了它的生命週期,那麼在這之中,究竟發生了什麼呢?讓咱們從 App
啓動開始探索。在探索以前,咱們須要熟悉一些前導知識點。git
如下參考自 WWDC 2016 Optimizing App Startup Time
:github
Mach-O is a bunch of file types for different run time executables.
Mach-O
是iOS
系統不一樣運行時期 可執行的文件的文件類型統稱。
維基百科上關於 Mach-O
的描述:sql
Mach-O 是 Mach object 文件格式的縮寫,它是一種用於記錄可執行文件、對象代碼、共享庫、動態加載代碼和內存轉儲的文件格式。做爲 a.out 格式的替代品,Mach-O 提供了更好的擴展性,並提高了符號表中信息的訪問速度。
大多數基於 Mach 內核的操做系統都使用 Mach-O。NeXTSTEP、OS X 和 iOS 是使用這種格式做爲本地可執行文件、庫和對象代碼的例子。
Mach-O
有三種文件類型: Executable
、Dylib
、Bundle
bootstrap
Executable
類型So the first executable, that's the main binary in an app, it's also the main binary in an app extension.
executable
是app
的二進制主文件,同時也是app extension
的二進制主文件
咱們通常能夠在 Xcode
項目中的 Products
文件夾中找到它:segmentfault
如上圖箭頭所示,App加載流程
就是咱們 App
的二進制主文件。數組
Dylib
類型A dylib is a dynamic library, on other platforms meet, you may know those as DSOs or DLLs.
dylib
是動態庫,在其餘平臺也叫DSO
或者DLL
。
對於接觸 iOS
開發比較早的同窗,可能知道咱們在 Xcode 7
以前添加一些好比 sqlite
的庫的時候,其後綴名爲 dylib
,而 Xcode 7
以後後綴名都改爲了 tbd
。xcode
這裏引用 StackoverFlow 上的一篇回答。緩存
So it appears that the .dylib file is the actual library of binary code that your project is using and is located in the /usr/lib/ directory on the user's device. The .tbd file, on the other hand, is just a text file that is included in your project and serves as a link to the required .dylib binary. Since this text file is much smaller than the binary library, it makes the SDK's download size smaller.
看起來.dylib
文件是項目中真正使用到的二進制庫文件,它位於用戶設備上的/usr/lib
目錄下。而.tbd
文件,只是位於你項目中的一個文本文件,它扮演的是連接到真正的.dylib
二進制文件的角色。由於文本文件的大小遠遠小於二進制文件的大小,因此讓Xcode 的
SDK` 的下載大小更小。
這裏再插一句,那麼有動態庫,確定就有靜態庫,它們的區別是什麼呢?安全
咱們先梳理一下整個的編譯過程。
固然,這個過程當中間其實還設計到編譯器前端的 詞法分析
、語法分析
、語義分析
、優化
等流程,咱們在後面探索 LLVM
和 Clang
的時候會詳細介紹。
回到剛纔的話題,靜態庫和動態庫的區別:
Static frameworks are linked at compile time. Dynamic frameworks are linked at runtime.
靜態庫和動態庫都是編譯好的二進制文件,只是用法不一樣。那爲何要分動態和靜態庫呢?
經過上面兩幅圖咱們能夠知道:
動態庫表現爲:程序編譯並不會連接到目標代碼中,在程序可執行文件裏面會保留對動態庫的引用。其中,動態庫分爲動態連接庫和動態加載庫。
Linked Framework and Libraries
設置的一些 share libraries
。【隨着程序啓動而啓動】dlopen
等經過代碼或者命令的方式來加載。【在程序啓動以後】Bundle
類型Now a bundle's a special kind of dylib that you cannot link against, all you can do is load it at run time by an dlopen and that's used on a Mac OS for plug-ins.
現階段Bundle
是一種特殊類型的dylib
,你是沒法對其進行連接的。你所能作的是在Runtime
運行時去經過dlopen
來加載它,它能夠在macOS
上用於插件。
Image
和 Framework
Image refers to any of these three types.
鏡像文件包含了上述的三種文件類型
a framework is a dylib with a special directory structure around it to holds files needed by that dylib.
有不少東西都叫作Framework
,但在本文中,Framework
指的是一個dylib
,它周圍有一個特殊的目錄結構來保存該dylib
所需的文件。
Mach-O
鏡像文件是由 segments
段組成的。
全部的段都是 page size
的倍數。
16
字節4
字節這裏再普及一下虛擬內存和內存頁的知識:
具備VM
機制的操做系統,會對每一個運行的進程建立一個邏輯地址空間logical address space
或者叫虛擬地址空間virtual address space
;該空間的大小由操做系統位數決定:32
位的操做系統,其邏輯地址空間的大小爲4GB
,64位的操做系統爲18 exabyes
(其計算方式是2^32
||2^64
)。
虛擬地址空間(或者邏輯地址空間)會被分爲相同大小的塊,這些塊被稱爲內存頁(page)。計算機處理器和它的內存管理單元(MMU - memory management uinit)維護着一張將程序的邏輯地址空間映射到物理地址上的分頁表
page table
。
在masOS
和早版本的iOS
中,分頁的大小爲4kB
。在以後的基於A7
和A8
的系統中,虛擬內存(64
位的地址空間)地址空間的分頁大小變爲了16KB
,而物理RAM上的內存分頁大小仍然維持在4KB
;基於A9及以後的系統,虛擬內存和物理內存的分頁都是16KB
。
在 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
工具查看 app
的二進制可執行文件能夠查看到:
segments
__TEXT
:代碼段,包括頭文件、代碼和常量。只讀不可修改
__DATA
:數據段,包括全局變量, 靜態變量等。可讀可寫。
__LINKEDIT
:如何加載程序, 包含了方法和變量的元數據(位置,偏移量),以及代碼簽名等信息。只讀不可修改。
Mach-O
通用文件,將多種架構的 Mach-O
文件合併而成。它經過 header
來記錄不一樣架構在文件中的偏移量,segement
佔多個分頁,header
佔一頁的空間。可能有人會以爲 header
單獨佔一頁會浪費空間,但這有利於虛擬內存的實現。
虛擬內存是一層間接尋址。
虛擬內存解決的是管理全部進程使用物理 RAM 的問題。經過添加間接層來讓每一個進程使用邏輯地址空間,它能夠映射到 RAM 上的某個物理頁上。這種映射不是一對一的,邏輯地址可能映射不到 RAM 上,也可能有多個邏輯地址映射到同一個物理 RAM 上。
page fault
。mmap()
的方式讀取。也就是把文件某個片斷映射到進程邏輯內存的某個頁上。當某個想要讀取的頁沒有在內存中,就會觸發 page fault
,內核只會讀入那一頁,實現文件的懶加載。也就是說 Mach-O
文件中的 __TEXT
段能夠映射到多個進程,並能夠懶加載,且進程之間共享內存。__DATA
段是可讀寫的。這裏使用到了 Copy-On-Write
技術,簡稱 COW
。也就是多個進程共享一頁內存空間時,一旦有進程要作寫操做,它會先將這頁內存內容複製一份出來,而後從新映射邏輯地址到新的 RAM
頁上。也就是這個進程本身擁有了那頁內存的拷貝。這就涉及到了 clean/dirty page
的概念。dirty page
含有進程本身的信息,而 clean page
能夠被內核從新生成(從新讀磁盤)。因此 dirty page
的代價大於 clean page
。
Mach-O
鏡像時 __TEXT
和 __LINKEDIT
由於只讀,都是能夠共享內存的,讀取速度就會很快。__DATA
由於可讀寫,就有可能會產生 dirty page
,若是檢測到有 clean page
就能夠直接使用,反之就須要從新讀取 DATA page
。一旦產生了 dirty page
,當 dyld
執行結束後,__LINKEDIT
須要通知內核當前頁面再也不須要了,當別人須要的使用時候就能夠從新 clean
這些頁面。
ASLR
(Address Space Layout Randomization) 地址空間佈局隨機化,鏡像會在隨機的地址上加載。
可能咱們認爲 Xcode
會把整個文件都作加密 hash
並用作數字簽名。其實爲了在運行時驗證 Mach-O
文件的簽名,並非每次重複讀入整個文件,而是把每頁內容都生成一個單獨的加密散列值,並存儲在 __LINKEDIT
中。這使得文件每頁的內容都能及時被校驗確並保不被篡改。
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
)。並將起始位置到0x000000
這段範圍的進程權限都標記爲不可讀寫不可執行。若是是32
位進程,這個範圍至少是4KB
;對於64
位進程則至少是4GB
。NULL
指針引用和指針截斷偏差都是會被它捕獲。這個範圍也叫作PAGEZERO
。
Unix 的前二十年很安逸,由於那時尚未發明動態連接庫。有了動態連接庫後,一個用於加載連接庫的幫助程序被建立。在蘋果的平臺裏是dyld
,其餘Unix
系統也有ld.so
。 當內核完成映射進程的工做後會將名字爲dyld
的Mach-O
文件映射到進程中的隨機地址,它將PC
寄存器設爲dyld
的地址並運行。dyld
在應用進程中運行的工做是加載應用依賴的全部動態連接庫,準備好運行所需的一切,它擁有的權限跟應用同樣。
從主執行文件的header
獲取到須要加載的所依賴動態庫列表,而header
早就被內核映射過。而後它須要找到每一個dylib
,而後打開文件讀取文件起始位置,確保它是Mach-O
文件。接着會找到代碼簽名並將其註冊到內核。而後在dylib
文件的每一個segment
上調用mmap()
。應用所依賴的dylib
文件可能會再依賴其餘dylib
,因此dyld
所須要加載的是動態庫列表一個遞歸依賴的集合。通常應用會加載100
到400
個dylib
文件,但大部分都是系統dylib
,它們會被預先計算和緩存起來,加載速度很快。
在加載全部的動態連接庫以後,它們只是處在相互獨立的狀態,須要將它們 綁定起來,這就是Fix-ups
。代碼簽名使得咱們不能修改指令,那樣就不能讓一個dylib
的調用另外一個dylib
。這時須要加不少間接層。
現代code-gen
被叫作動態 PIC(Position Independent Code),意味着代碼能夠被加載到間接的地址上。當調用發生時,code-gen
實際上會在__DATA
段中建立一個指向被調用者的指針,而後加載指針並跳轉過去。因此dyld
作的事情就是修正(fix-up
)指針和數據。Fix-up
有兩種類型,rebasing
和binding
。
Rebasing:在鏡像內部 調整指針的指向
Binding:將指針 指向鏡像外部的內容
dyld
的時間線由上圖可知爲:
Load dylibs -> Rebase -> Bind -> ObjC -> Initializers
在 iOS 13
以前,全部的第三方 App
都是經過 dyld 2
來啓動 App
的,主要過程以下:
Mach-O
的 Header
和 Load Commands
,找到其依賴的庫,並遞歸找到全部依賴的庫Mach-O
文件dyld3
被分爲了三個組件:
一個進程外的 MachO
解析器
search path
、@rpaths
和環境變量Mach-O
的 Header
和依賴,並完成了全部符號查找的工做daemon
進程,可使用一般的測試架構一個進程內的引擎,用來運行啓動閉包
dylib
之中,再跳轉到 main
函數Mach-O
的 Header
和依賴,也不須要符號查找。一個啓動閉包緩存服務
App
的啓動閉包被構建在一個 Shared Cache
中, 咱們甚至不須要打開一個單獨的文件App
,咱們會在 App
安裝或者升級的時候構建這個啓動閉包。iOS
、tvOS
、watchOS
中,這這一切都是 App
啓動以前完成的。在 macOS
上,因爲有 Side Load App
,進程內引擎會在首次啓動的時候啓動一個 daemon
進程,以後就可使用啓動閉包啓動了。dyld 3 把不少耗時的查找、計算和 I/O
的事前都預先處理好了,這使得啓動速度有了很大的提高。
好了,先導知識就總結到這裏,接下來讓咱們調整呼吸進入下一章~
咱們在探索 iOS
底層的時候,對於對象、類、方法有了必定的認知哦,接下來咱們就一塊兒來探索一下應用是怎麼加載的。
咱們直接新建一個 Single View App
的項目,而後在 main.m
中打一個斷點:
而後咱們能夠看到在 main
方法執行前有一步 start
,而這一流程是由 libdyld.dylib
這個動態庫來執行的。
這個現象說明了什麼呢?說明咱們的 app
在 main
函數執行以前其實還經過 dyld
作了不少事情。那爲了搞清楚具體的流程,咱們不妨從 Apple OpenSource 上下載 dyld
的源碼來進行探索。
咱們選擇最新的 655.1.1
版本:
dyld
源碼分析面對 dyld
的源碼,咱們不可能一行一行的去分析。咱們不妨在剛纔建立的項目中斷點一下 load
方法,看下調用堆棧:
這一次咱們發現,load
方法的調用要早於 main
函數的調用,其次,咱們獲得了一個很是有價值的線索: _dyld_start
。
咱們直接在 dyld 655.1.1
中全局搜索這個 _dyld_start
,咱們能夠來到 dyldStartup.s
這個彙編文件,而後咱們聚焦於 arm64
架構下的彙編代碼:
對於這裏的彙編代碼,咱們確定也不必逐行分析,咱們直接定位到 bl
語句後面(bl
在彙編層面是跳轉的意思):
bl __ZN13dyldbootstrap5startEPK12macho_headeriPPKclS2_Pm
咱們能夠看到這裏有一行註釋:
// call dyldbootstrap::start(app_mh, argc, argv, slide, dyld_mh, &startGlue)
這行註釋的意思是調用位於 dyldbootstrap
命名空間下的 start
方法,咱們繼續搜索一下這個 start
方法,結果位於 dyldInitialization.cpp
文件(從文件名咱們能夠看出該文件主要是用來初始化 dyld
),這裏查找 start
的時候可能會有不少結果,咱們其實能夠先搜索命名空間,再搜索 start
方法。
start
方法源碼以下:
// // This is code to bootstrap dyld. This work in normally done for a program by dyld and crt. // In dyld we have to do this manually. // uintptr_t start(const struct macho_header* appsMachHeader, int argc, const char* argv[], intptr_t slide, const struct macho_header* dyldsMachHeader, uintptr_t* startGlue) { // if kernel had to slide dyld, we need to fix up load sensitive locations // we have to do this before using any global variables slide = slideOfMainExecutable(dyldsMachHeader); bool shouldRebase = slide != 0; #if __has_feature(ptrauth_calls) shouldRebase = true; #endif if ( shouldRebase ) { rebaseDyld(dyldsMachHeader, slide); } // allow dyld to use mach messaging mach_init(); // kernel sets up env pointer to be just past end of agv array const char** envp = &argv[argc+1]; // kernel sets up apple pointer to be just past end of envp array const char** apple = envp; while(*apple != NULL) { ++apple; } ++apple; // set up random value for stack canary __guard_setup(apple); #if DYLD_INITIALIZER_SUPPORT // run all C++ initializers inside dyld runDyldInitializers(dyldsMachHeader, slide, argc, argv, envp, apple); #endif // now that we are done bootstrapping dyld, call dyld's main uintptr_t appsSlide = slideOfMainExecutable(appsMachHeader); return dyld::_main(appsMachHeader, appsSlide, argc, argv, envp, apple, startGlue); }
咱們剛纔探索到了 start
方法,具體流程以下:
dyld
的 Mach-O
文件的 header
判斷是否須要對 dyld
這個 Mach-O
進行 rebase
操做
mach
,使得 dyld
能夠進行 mach
通信。
env
指針設置爲恰好超出 agv
數組的末尾;內核將 apple
指針設置爲恰好超出 envp
數組的末尾
app
主二進制文件 Mach-O
的 header
來獲得偏移量 appSlide
,而後調用 dyld
命名空間下的 _main
方法。咱們經過搜索來到 dyld.cpp
文件下的 _main
方法:
_main方法
官方的註釋以下:
dyld
的入口。內核加載了dyld
而後跳轉到__dyld_start
來設置一些寄存器的值而後調用到了這個方法。
返回__dyld_start
所跳轉到的目標程序的main
函數地址。
咱們乍一看,這個方法有四五百行,因此咱們不能老老實實的一行一行來看,這樣太累了。咱們應該着重於有註釋的地方。
cdHash
值。這個哈希值 mainExecutableCDHash
在後面用來校驗 dyld3
的啓動閉包。
dyld
的加載。而後判斷當前是否爲模擬器環境,若是不是模擬器,則追蹤主二進制可執行文件的加載。
macOS
執行環境,若是是則判斷 DYLD_ROOT_PATH
環境變量是否存在,若是存在,而後判斷模擬器是否有本身的 dyld
,若是有就使用,若是沒有,則返回錯誤信息。
dyld 啓動開始
dyldbootstrap::_main
方法的參數來設置上下文exec
路徑的指針dyl
d移除臨時 apple [0]
過渡代碼exec
路徑是否爲絕對路徑,若是爲相對路徑,使用 cwd
轉化爲絕對路徑exec
路徑中取出進程的名稱 (strrchr
函數是獲取第二個參數出現的最後的一個位置,而後返回從這個位置開始到結束的內容)App
主二進制可執行文件 Mach-O
的 Header
的內容配置進程的一些限制條件
macOS
執行環境,若是是的話,再判斷上下文的一些配置屬性是否被設置了,若是沒有被設置,則再次進行一次 setContext
上下文配置操做。envp
檢查環境變量macOS
執行環境,若是是的話,再判斷當前 app
的 Mach-O
可執行文件是否爲 iOSMac
類型且不爲 macOS
類型的話,則重置上下文的根路徑,而後再判斷 DYLD_FALLBACK_LIBRARY_PATH
和 DYLD_FALLBACK_FRAMEWORK_PATH
這兩個環境變量是否都是默認後備路徑,若是是的話賦值爲受限的後備路徑。
DYLD_PRINT_OPTS
和 DYLD_PRINT_ENV
來判斷是否須要打印app
的 Mach-O
可執行文件的 header
和 ASLR
以後的偏移量來獲取架構信息。在這裏會判斷若是是 GC
的程序則會禁用掉共享緩存。
app
的 Mach-O
二進制可執行文件是否有段覆蓋了共享緩存區域,若是覆蓋了則禁用共享緩存。可是這裏的前提是 macOS
,在 iOS
中,共享緩存是必需的。
這裏爲了方便查看,咱們能夠摺疊一些分支條件。
dyld 2
仍是 dyld 3
的流程
dyld3
會建立一個啓動閉包,咱們須要來讀取它,這裏會如今緩存中查找是否有啓動閉包的存在,前面咱們已經說過了,系統級的 app
的啓動閉包是存在於共享緩存中,而咱們本身開發的 app
的啓動閉包是在 app
安裝或者升級的時候構建的,因此這裏檢查 dyld
中的緩存是有意義的。
若是 dyld
緩存中沒有找到啓動閉包或者找到了啓動閉包可是驗證失敗(咱們最開始提到的 cdHash
在這裏出現了)
從啓動閉包緩存中查找
dyld3 啓動開始
嘗試經過啓動閉包進行啓動
start()
是以函數指針的方式調用 _main
方法的返回的指針,須要進行簽名。至此,dyld3
的流程就處理完畢,咱們再接着往下分析 dyld2
的流程。
dyld
的鏡像文件到 UUID
列表中,主要的目的是啓用堆棧的符號化。
reloadAllImages
ImageLoader
是一個用於加載可執行文件的基類,它負責連接鏡像,但不關心具體文件格式,由於這些都交給子類去實現。每一個可執行文件都會對應一個ImageLoader
實例。ImageLoaderMachO
是用於加載Mach-O
格式文件的ImageLoader
子類,而ImageLoaderMachOClassic
和ImageLoaderMachOCompressed
都繼承於ImageLoaderMachO
,分別用於加載那些__LINKEDIT
段爲傳統格式和壓縮格式的Mach-O
文件。
接下來就來到重頭戲了 reloadAllImages
了:
實例化主程序
這裏咱們看到有一行代碼:
// instantiate ImageLoader for main executable sMainExecutable = instantiateFromLoadedImage(mainExecutableMH, mainExecutableSlide, sExecPath);
顯然,在這裏咱們的主程序被實例化了,咱們進入這個方法內部:
這裏至關於要爲已經映射到主可執行文件中的文件建立一個 ImageLoader*
。
從上面代碼咱們不難看出這裏真正執行的邏輯是 ImageLoaderMachO::instantiateMainExecutable
方法:
咱們再進入 sniiffLoadCommands
方法內部:
經過註釋不難看出:sniiffLoadCommands
會肯定此 mach-o
文件是否具備原始的或壓縮的 LINKEDIT
以及 mach-o
文件的 segement
的個數。
sniiffLoadCommands
完成後,判斷 LINKEDIT
是壓縮的格式仍是傳統格式,而後分別調用對應的 instantiateMainExecutable
方法來實例化主程序。
加載任何插入的動態庫
連接庫
先是連接主二進制可執行文件,而後連接任何插入的動態庫。這裏都用到了 link
方法,在這個方法內部會執行遞歸的 rebase
操做來修正 ASLR
偏移量問題。同時還會有一個 recursiveApplyInterposing
方法來遞歸的將動態加載的鏡像文件插入。
運行全部初始化程序
完成連接以後須要進行初始化了,這裏會來到 initializeMainExecutable
:
這裏注意執行順序:
在 runInitializers
內部咱們繼續探索到 processInitializers
:
而後咱們來到 recursiveInitialization
:
而後咱們來到 notifySingle
:
箭頭所示的地方是獲取鏡像文件的真實地址。
咱們全局搜索一下 sNotifyObjcInit
能夠來到 registerObjCNotifiers
:
接着搜索 registerObjCNotifiers
:
此時,咱們打開 libObjc
的源碼能夠看到:
上面這一連串的跳轉,結果很顯然:dyld
註冊了回調才使得 libobjc
能知道鏡像什麼時候加載完畢。
在 ImageLoader::recursiveInitialization
方法中還有一個 doInitialization
值得注意,這裏是真正作初始化操做的地方。
doInitialization
主要有兩個操做,一個是 doImageInit
,一個是 doModInitFunctions
:
doImageInit
內部會經過初始地址 + 偏移量拿到初始化器 func
,而後進行簽名的驗證。驗證經過後還要判斷初始化器是否在鏡像文件中以及 libSystem
庫是否已經初始化,最後才執行初始化器。
通知監聽 dyld 的 main
一切工做作完後通知監聽 dyld
的 main
,而後爲主二進制可執行文件找到入口,最後對結果進行簽名。
咱們直接經過 LLDB
大法來斷點調試 libObjc
中的 _objc_init
,而後經過 bt
命令打印出當前的調用堆棧,根據上一節咱們探索 dyld
的源碼,此刻一切的一切都是那麼的清晰明瞭:
咱們能夠看到 dyld
的最後一個流程是 doModInitFunctions
方法的執行。
咱們打開 libSystem
的源碼,全局搜索 libSystem_initializer
能夠看到:
而後咱們打開 libDispatch
的源碼,全局搜索 libdispatch_init
能夠看到:
咱們再搜索 _os_object_init
:
完美~,_objc_init
在這裏就被調用了。因此 _objc_init
的流程是
dyld -> libSystem -> libDispatch -> libObc -> _objc_init
本文主要探索了 app
啓動以後 dyld
的流程,整個分析過程確實比較複雜,但在探索的過程當中,咱們不只對底層源碼有了新的認知,同時對於優化咱們 app
啓動也是有不少好處的。下一章,咱們會對 objc_init
內部的 map_images
和 load_images
進行更深刻的分析,敬請期待~