iOS 底層探索系列
App
從被用戶在主屏幕上點擊之后就開啟了它的生命周期,那么在這之中,究竟發(fā)生了什么呢?讓我們從 App
啟動開始探索。在探索之前,我們需要熟悉一些前導知識點。
一、前導知識
以下參考自 WWDC 2016 Optimizing App Startup Time
:
1.1 Mach-O
Mach-O is a bunch of file types for different run time executables.
Mach-O
是iOS
系統(tǒng)不同運行時期可執(zhí)行的文件的文件類型統(tǒng)稱。
維基百科上關(guān)于 Mach-O
的描述:
Mach-O 是 Mach object 文件格式的縮寫,它是一種用于記錄可執(zhí)行文件、對象代碼、共享庫、動態(tài)加載代碼和內(nèi)存轉(zhuǎn)儲的文件格式。作為 a.out 格式的替代品,Mach-O 提供了更好的擴展性,并提升了符號表中信息的訪問速度。
大多數(shù)基于 Mach 內(nèi)核的操作系統(tǒng)都使用 Mach-O。NeXTSTEP、OS X 和 iOS 是使用這種格式作為本地可執(zhí)行文件、庫和對象代碼的例子。
Mach-O
有三種文件類型: Executable
、Dylib
、Bundle
-
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
文件夾中找到它:
如上圖箭頭所示,App加載流程
就是我們 App
的二進制主文件。
-
Dylib
類型
A dylib is a dynamic library, on other platforms meet, you may know those as DSOs or DLLs.
dylib
是動態(tài)庫,在其他平臺也叫DSO
或者DLL
。
對于接觸 iOS
開發(fā)比較早的同學,可能知道我們在 Xcode 7
之前添加一些比如 sqlite
的庫的時候,其后綴名為 dylib
,而 Xcode 7
之后后綴名都改成了 tbd
。
這里引用 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` 的下載大小更小。
這里再插一句,那么有動態(tài)庫,肯定就有靜態(tài)庫,它們的區(qū)別是什么呢?
我們先梳理一下整個的編譯過程。
當然,這個過程中間其實還設計到編譯器前端的 詞法分析
、語法分析
、語義分析
、優(yōu)化
等流程,我們在后面探索 LLVM
和 Clang
的時候會詳細介紹。
回到剛才的話題,靜態(tài)庫和動態(tài)庫的區(qū)別:
Static frameworks are linked at compile time. Dynamic frameworks are linked at runtime.
靜態(tài)庫和動態(tài)庫都是編譯好的二進制文件,只是用法不同。那為什么要分動態(tài)和靜態(tài)庫呢?
通過上面兩幅圖我們可以知道:
- 靜態(tài)庫表現(xiàn)為:在鏈接階段會將匯編生成的目標文件與引用的庫一起鏈接打包進可執(zhí)行文件中。
- 動態(tài)庫表現(xiàn)為:程序編譯并不會鏈接到目標代碼中,在程序可執(zhí)行文件里面會保留對動態(tài)庫的引用。其中,動態(tài)庫分為動態(tài)鏈接庫和動態(tài)加載庫。
-
動態(tài)鏈接庫:在沒有被加載到內(nèi)存的前提下,當可執(zhí)行文件被加載,動態(tài)庫也隨著被加載到內(nèi)存中。在
Linked Framework and Libraries
設置的一些share libraries
。【隨著程序啟動而啟動】 -
動態(tài)加載庫:當需要的時候再使用
dlopen
等通過代碼或者命令的方式來加載。【在程序啟動之后】
-
動態(tài)鏈接庫:在沒有被加載到內(nèi)存的前提下,當可執(zhí)行文件被加載,動態(tài)庫也隨著被加載到內(nèi)存中。在
-
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.
現(xiàn)階段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
,它周圍有一個特殊的目錄結(jié)構(gòu)來保存該dylib
所需的文件。
1.1.1 Mach-O 結(jié)構(gòu)分析
1.1.1.1 segment 段
Mach-O
鏡像文件是由 segments
段組成的。
- 段的名稱為大寫格式<br />
所有的段都是page size
的倍數(shù)。 - arm64 上段大小為
16
字節(jié) - 其它架構(gòu)為
4
字節(jié)
這里再普及一下虛擬內(nèi)存和內(nèi)存頁的知識:
具有
VM
機制的操作系統(tǒng),會對每個運行的進程創(chuàng)建一個邏輯地址空間logical address space
或者叫虛擬地址空間virtual address space
;該空間的大小由操作系統(tǒng)位數(shù)決定:32
位的操作系統(tǒng),其邏輯地址空間的大小為4GB
,64位的操作系統(tǒng)為18 exabyes
(其計算方式是2^32
||2^64
)。
虛擬地址空間(或者邏輯地址空間)會被分為相同大小的塊,這些塊被稱為內(nèi)存頁(page)。計算機處理器和它的內(nèi)存管理單元(MMU - memory management uinit)維護著一張將程序的邏輯地址空間映射到物理地址上的分頁表
page table
。
在
masOS
和早版本的iOS
中,分頁的大小為4kB
。在之后的基于A7
和A8
的系統(tǒng)中,虛擬內(nèi)存(64
位的地址空間)地址空間的分頁大小變?yōu)榱?16KB
,而物理RAM上的內(nèi)存分頁大小仍然維持在4KB
;基于A9及之后的系統(tǒng),虛擬內(nèi)存和物理內(nèi)存的分頁都是16KB
。
1.1.1.2 section
在 segment
段內(nèi)部還有許多的 section
區(qū)。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
節(jié)實際上只是一個segment
段的子范圍,它們沒有頁面大小的任何限制,但是它們是不重疊的。?
通過 MachOView
工具查看 app
的二進制可執(zhí)行文件可以查看到:
1.1.1.3 常見的 segments
-
__TEXT
:代碼段,包括頭文件、代碼和常量。只讀不可修改
-
__DATA
:數(shù)據(jù)段,包括全局變量, 靜態(tài)變量等。可讀可寫。
-
__LINKEDIT
:如何加載程序, 包含了方法和變量的元數(shù)據(jù)(位置,偏移量),以及代碼簽名等信息。只讀不可修改。
1.1.2 Mach-O Universal Files
Mach-O
通用文件,將多種架構(gòu)的 Mach-O
文件合并而成。它通過 header
來記錄不同架構(gòu)在文件中的偏移量,segement
占多個分頁,header
占一頁的空間。可能有人會覺得 header
單獨占一頁會浪費空間,但這有利于虛擬內(nèi)存的實現(xiàn)。
1.2 虛擬內(nèi)存
虛擬內(nèi)存是一層間接尋址。
虛擬內(nèi)存解決的是管理所有進程使用物理 RAM 的問題。通過添加間接層來讓每個進程使用邏輯地址空間,它可以映射到 RAM 上的某個物理頁上。這種映射不是一對一的,邏輯地址可能映射不到 RAM 上,也可能有多個邏輯地址映射到同一個物理 RAM 上。
- 針對第一種情況,當進程要存儲邏輯地址內(nèi)容時會觸發(fā)
page fault
。 - 而第二種情況就是多進程共享內(nèi)存。
- 對于文件可以不用一次性讀入整個文件,可以使用分頁映射
mmap()
的方式讀取。也就是把文件某個片段映射到進程邏輯內(nèi)存的某個頁上。當某個想要讀取的頁沒有在內(nèi)存中,就會觸發(fā)page fault
,內(nèi)核只會讀入那一頁,實現(xiàn)文件的懶加載。也就是說Mach-O
文件中的__TEXT
段可以映射到多個進程,并可以懶加載,且進程之間共享內(nèi)存。 -
__DATA
段是可讀寫的。這里使用到了Copy-On-Write
技術(shù),簡稱COW
。也就是多個進程共享一頁內(nèi)存空間時,一旦有進程要做寫操作,它會先將這頁內(nèi)存內(nèi)容復制一份出來,然后重新映射邏輯地址到新的RAM
頁上。也就是這個進程自己擁有了那頁內(nèi)存的拷貝。這就涉及到了clean/dirty page
的概念。dirty page
含有進程自己的信息,而clean page
可以被內(nèi)核重新生成(重新讀磁盤)。所以dirty page
的代價大于clean page
。
1.3 多進程加載 Mach-O 鏡像
- 所以在多個進程加載
Mach-O
鏡像時__TEXT
和__LINKEDIT
因為只讀,都是可以共享內(nèi)存的,讀取速度就會很快。 - 而
__DATA
因為可讀寫,就有可能會產(chǎn)生dirty page
,如果檢測到有clean page
就可以直接使用,反之就需要重新讀取DATA page
。一旦產(chǎn)生了dirty page
,當dyld
執(zhí)行結(jié)束后,__LINKEDIT
需要通知內(nèi)核當前頁面不再需要了,當別人需要的使用時候就可以重新clean
這些頁面。
1.4 ASLR
ASLR
(Address Space Layout Randomization) 地址空間布局隨機化,鏡像會在隨機的地址上加載。
1.5 Code Signing
可能我們認為 Xcode
會把整個文件都做加密 hash
并用做數(shù)字簽名。其實為了在運行時驗證 Mach-O
文件的簽名,并不是每次重復讀入整個文件,而是把每頁內(nèi)容都生成一個單獨的加密散列值,并存儲在 __LINKEDIT
中。這使得文件每頁的內(nèi)容都能及時被校驗確并保不被篡改。
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()
是一個系統(tǒng)調(diào)用。系統(tǒng)內(nèi)核把應用映射到新的地址空間,且每次起始位置都是隨機的(因為使用ASLR
)。并將起始位置到0x000000
這段范圍的進程權(quán)限都標記為不可讀寫不可執(zhí)行。如果是32
位進程,這個范圍至少是4KB
;對于64
位進程則至少是4GB
。NULL
指針引用和指針截斷誤差都是會被它捕獲。這個范圍也叫做PAGEZERO
。
1.7 dyld
Unix 的前二十年很安逸,因為那時還沒有發(fā)明動態(tài)鏈接庫。有了動態(tài)鏈接庫后,一個用于加載鏈接庫的幫助程序被創(chuàng)建。在蘋果的平臺里是
dyld
,其他Unix
系統(tǒng)也有ld.so
。 當內(nèi)核完成映射進程的工作后會將名字為dyld
的Mach-O
文件映射到進程中的隨機地址,它將PC
寄存器設為dyld
的地址并運行。dyld
在應用進程中運行的工作是加載應用依賴的所有動態(tài)鏈接庫,準備好運行所需的一切,它擁有的權(quán)限跟應用一樣。
1.8 dyld 流程
- Load dylibs
從主執(zhí)行文件的
header
獲取到需要加載的所依賴動態(tài)庫列表,而header
早就被內(nèi)核映射過。然后它需要找到每個dylib
,然后打開文件讀取文件起始位置,確保它是Mach-O
文件。接著會找到代碼簽名并將其注冊到內(nèi)核。然后在dylib
文件的每個segment
上調(diào)用mmap()
。應用所依賴的dylib
文件可能會再依賴其他dylib
,所以dyld
所需要加載的是動態(tài)庫列表一個遞歸依賴的集合。一般應用會加載100
到400
個dylib
文件,但大部分都是系統(tǒng)dylib
,它們會被預先計算和緩存起來,加載速度很快。
- Fix-ups
在加載所有的動態(tài)鏈接庫之后,它們只是處在相互獨立的狀態(tài),需要將它們綁定起來,這就是
Fix-ups
。代碼簽名使得我們不能修改指令,那樣就不能讓一個dylib
的調(diào)用另一個dylib
。這時需要加很多間接層。
現(xiàn)代code-gen
被叫做動態(tài) PIC(Position Independent Code),意味著代碼可以被加載到間接的地址上。當調(diào)用發(fā)生時,code-gen
實際上會在__DATA
段中創(chuàng)建一個指向被調(diào)用者的指針,然后加載指針并跳轉(zhuǎn)過去。所以dyld
做的事情就是修正(fix-up
)指針和數(shù)據(jù)。Fix-up
有兩種類型,rebasing
和binding
。
- Rebasing 和 Binding
Rebasing:在鏡像內(nèi)部調(diào)整指針的指向
Binding:將指針指向鏡像外部的內(nèi)容
dyld
的時間線由上圖可知為:
Load dylibs -> Rebase -> Bind -> ObjC -> Initializers
1.9 dyld2 && dyld3
在 iOS 13
之前,所有的第三方 App
都是通過 dyld 2
來啟動 App
的,主要過程如下:
- 解析
Mach-O
的Header
和Load Commands
,找到其依賴的庫,并遞歸找到所有依賴的庫 - 加載
Mach-O
文件 - 進行符號查找
- 綁定和變基
- 運行初始化程序
dyld3
被分為了三個組件:
- 一個進程外的
MachO
解析器- 預先處理了所有可能影響啟動速度的
search path
、@rpaths
和環(huán)境變量 - 然后分析
Mach-O
的Header
和依賴,并完成了所有符號查找的工作 - 最后將這些結(jié)果創(chuàng)建成了一個啟動閉包
- 這是一個普通的
daemon
進程,可以使用通常的測試架構(gòu)
- 預先處理了所有可能影響啟動速度的
- 一個進程內(nèi)的引擎,用來運行啟動閉包
- 這部分在進程中處理
- 驗證啟動閉包的安全性,然后映射到
dylib
之中,再跳轉(zhuǎn)到main
函數(shù) - 不需要解析
Mach-O
的Header
和依賴,也不需要符號查找。
- 一個啟動閉包緩存服務
- 系統(tǒng)
App
的啟動閉包被構(gòu)建在一個Shared Cache
中, 我們甚至不需要打開一個單獨的文件 - 對于第三方的
App
,我們會在App
安裝或者升級的時候構(gòu)建這個啟動閉包。 - 在
iOS
、tvOS
、watchOS
中,這這一切都是App
啟動之前完成的。在macOS
上,由于有Side Load App
,進程內(nèi)引擎會在首次啟動的時候啟動一個daemon
進程,之后就可以使用啟動閉包啟動了。
- 系統(tǒng)
dyld 3 把很多耗時的查找、計算和 I/O
的事前都預先處理好了,這使得啟動速度有了很大的提升。
好了,先導知識就總結(jié)到這里,接下來讓我們調(diào)整呼吸進入下一章~
二、App 加載分析
我們在探索 iOS
底層的時候,對于對象、類、方法有了一定的認知哦,接下來我們就一起來探索一下應用是怎么加載的。
我們直接新建一個 Single View App
的項目,然后在 main.m
中打一個斷點:
然后我們可以看到在 main
方法執(zhí)行前有一步 start
,而這一流程是由 libdyld.dylib
這個動態(tài)庫來執(zhí)行的。
這個現(xiàn)象說明了什么呢?說明我們的 app
在 main
函數(shù)執(zhí)行之前其實還通過 dyld
做了很多事情。那為了搞清楚具體的流程,我們不妨從 Apple OpenSource 上下載 dyld
的源碼來進行探索。
我們選擇最新的 655.1.1
版本:
三、dyld
源碼分析
面對 dyld
的源碼,我們不可能一行一行的去分析。我們不妨在剛才創(chuàng)建的項目中斷點一下 load
方法,看下調(diào)用堆棧:
這一次我們發(fā)現(xiàn),load
方法的調(diào)用要早于 main
函數(shù)的調(diào)用,其次,我們得到了一個非常有價值的線索: _dyld_start
。
3.1 _dyld_start
我們直接在 dyld 655.1.1
中全局搜索這個 _dyld_start
,我們可以來到 dyldStartup.s
這個匯編文件,然后我們聚焦于 arm64
架構(gòu)下的匯編代碼:
對于這里的匯編代碼,我們肯定也沒必要逐行分析,我們直接定位到 bl
語句后面(bl
在匯編層面是跳轉(zhuǎn)的意思):
bl __ZN13dyldbootstrap5startEPK12macho_headeriPPKclS2_Pm
我們可以看到這里有一行注釋:
// call dyldbootstrap::start(app_mh, argc, argv, slide, dyld_mh, &startGlue)
這行注釋的意思是調(diào)用位于 dyldbootstrap
命名空間下的 start
方法,我們繼續(xù)搜索一下這個 start
方法,結(jié)果位于 dyldInitialization.cpp
文件(從文件名我們可以看出該文件主要是用來初始化 dyld
),這里查找 start
的時候可能會有很多結(jié)果,我們其實可以先搜索命名空間,再搜索 start
方法。
3.2 dyldbootstrap::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
方法,具體流程如下:
- 根據(jù)
dyld
的Mach-O
文件的header
判斷是否需要對dyld
這個Mach-O
進行rebase
操作
- 初始化
mach
,使得dyld
可以進行mach
通訊。
- 內(nèi)核將
env
指針設置為剛好超出agv
數(shù)組的末尾;內(nèi)核將apple
指針設置為剛好超出envp
數(shù)組的末尾
- 棧溢出保護
- 讀取
app
主二進制文件Mach-O
的header
來得到偏移量appSlide
,然后調(diào)用dyld
命名空間下的_main
方法。
3.3 dyldbootstrap::_main
我們通過搜索來到 dyld.cpp
文件下的 _main
方法:
_main方法
官方的注釋如下:
dyld
的入口。內(nèi)核加載了dyld
然后跳轉(zhuǎn)到__dyld_start
來設置一些寄存器的值然后調(diào)用到了這個方法。
返回__dyld_start
所跳轉(zhuǎn)到的目標程序的main
函數(shù)地址。
我們乍一看,這個方法有四五百行,所以我們不能老老實實的一行一行來看,這樣太累了。我們應該著重于有注釋的地方。
- 我們首先可以看到這里是從環(huán)境變量中獲取主要可執(zhí)行文件的
cdHash
值。這個哈希值mainExecutableCDHash
在后面用來校驗dyld3
的啟動閉包。
- 上圖代碼作用是追蹤
dyld
的加載。然后判斷當前是否為模擬器環(huán)境,如果不是模擬器,則追蹤主二進制可執(zhí)行文件的加載。
- 顯示宏定義判斷是否為
macOS
執(zhí)行環(huán)境,如果是則判斷DYLD_ROOT_PATH
環(huán)境變量是否存在,如果存在,然后判斷模擬器是否有自己的dyld
,如果有就使用,如果沒有,則返回錯誤信息。
- 打印日志:
dyld 啟動開始
- 根據(jù)傳入
dyldbootstrap::_main
方法的參數(shù)來設置上下文 - 拾取指向
exec
路徑的指針 - 從
dyl
d移除臨時apple [0]
過渡代碼 - 判斷
exec
路徑是否為絕對路徑,如果為相對路徑,使用cwd
轉(zhuǎn)化為絕對路徑 - 為了后續(xù)的日志打印從
exec
路徑中取出進程的名稱 (strrchr
函數(shù)是獲取第二個參數(shù)出現(xiàn)的最后的一個位置,然后返回從這個位置開始到結(jié)束的內(nèi)容) - 根據(jù)
App
主二進制可執(zhí)行文件Mach-O
的Header
的內(nèi)容配置進程的一些限制條件
- 判斷是否為
macOS
執(zhí)行環(huán)境,如果是的話,再判斷上下文的一些配置屬性是否被設置了,如果沒有被設置,則再次進行一次setContext
上下文配置操作。 - 根據(jù)傳入的參數(shù)
envp
檢查環(huán)境變量 - 默認未初始化的后備路徑
- 判斷是否為
macOS
執(zhí)行環(huán)境,如果是的話,再判斷當前app
的Mach-O
可執(zhí)行文件是否為iOSMac
類型且不為macOS
類型的話,則重置上下文的根路徑,然后再判斷DYLD_FALLBACK_LIBRARY_PATH
和DYLD_FALLBACK_FRAMEWORK_PATH
這兩個環(huán)境變量是否都是默認后備路徑,如果是的話賦值為受限的后備路徑。<br />
- 根據(jù)環(huán)境變量
DYLD_PRINT_OPTS
和DYLD_PRINT_ENV
來判斷是否需要打印 - 通過當前
app
的Mach-O
可執(zhí)行文件的header
和ASLR
之后的偏移量來獲取架構(gòu)信息。在這里會判斷如果是GC
的程序則會禁用掉共享緩存。<br />
- 判斷共享緩存是否開啟,如果開啟了就將共享緩存映射到當前進程的邏輯內(nèi)存空間內(nèi)
- 檢查共享緩存這里會先判斷
app
的Mach-O
二進制可執(zhí)行文件是否有段覆蓋了共享緩存區(qū)域,如果覆蓋了則禁用共享緩存。但是這里的前提是macOS
,在iOS
中,共享緩存是必需的。
這里為了方便查看,我們可以折疊一些分支條件。
- 通過共享緩存中的頭的版本信息來判斷是走
dyld 2
還是dyld 3
的流程
3.4 dyld3 的處理
- 由于
dyld3
會創(chuàng)建一個啟動閉包,我們需要來讀取它,這里會現(xiàn)在緩存中查找是否有啟動閉包的存在,前面我們已經(jīng)說過了,系統(tǒng)級的app
的啟動閉包是存在于共享緩存中,而我們自己開發(fā)的app
的啟動閉包是在app
安裝或者升級的時候構(gòu)建的,所以這里檢查dyld
中的緩存是有意義的。
- 宏定義判斷代碼執(zhí)行條件為真機。
- 如果
dyld
緩存中沒有找到啟動閉包或者找到了啟動閉包但是驗證失敗(我們最開始提到的cdHash
在這里出現(xiàn)了)- 從啟動閉包緩存中查找
- 如果還是沒有找到,那就創(chuàng)建一個新的啟動閉包
- 從啟動閉包緩存中查找
- 打印日志信息:
dyld3 啟動開始
- 嘗試通過啟動閉包進行啟動
- 如果啟動失敗,則創(chuàng)建一個新的啟動閉包嘗試再次啟動
- 如果啟動成功,由于
start()
是以函數(shù)指針的方式調(diào)用_main
方法的返回的指針,需要進行簽名。
至此,dyld3
的流程就處理完畢,我們再接著往下分析 dyld2
的流程。
3.5 dyld2 的處理
- 這里會添加
dyld
的鏡像文件到UUID
列表中,主要的目的是啟用堆棧的符號化。<br />
reloadAllImages
ImageLoader
是一個用于加載可執(zhí)行文件的基類,它負責鏈接鏡像,但不關(guān)心具體文件格式,因為這些都交給子類去實現(xiàn)。每個可執(zhí)行文件都會對應一個ImageLoader
實例。ImageLoaderMachO
是用于加載Mach-O
格式文件的ImageLoader
子類,而ImageLoaderMachOClassic
和ImageLoaderMachOCompressed
都繼承于ImageLoaderMachO
,分別用于加載那些__LINKEDIT
段為傳統(tǒng)格式和壓縮格式的Mach-O
文件。
接下來就來到重頭戲了 reloadAllImages
了:
實例化主程序
這里我們看到有一行代碼:
// instantiate ImageLoader for main executable
sMainExecutable = instantiateFromLoadedImage(mainExecutableMH, mainExecutableSlide, sExecPath);
顯然,在這里我們的主程序被實例化了,我們進入這個方法內(nèi)部:
這里相當于要為已經(jīng)映射到主可執(zhí)行文件中的文件創(chuàng)建一個 ImageLoader*
。
從上面代碼我們不難看出這里真正執(zhí)行的邏輯是 ImageLoaderMachO::instantiateMainExecutable
方法:
我們再進入 sniiffLoadCommands
方法內(nèi)部:
通過注釋不難看出:sniiffLoadCommands
會確定此 mach-o
文件是否具有原始的或壓縮的 LINKEDIT
以及 mach-o
文件的 segement
的個數(shù)。
sniiffLoadCommands
完成后,判斷 LINKEDIT
是壓縮的格式還是傳統(tǒng)格式,然后分別調(diào)用對應的 instantiateMainExecutable
方法來實例化主程序。
加載任何插入的動態(tài)庫<br />
鏈接庫
先是鏈接主二進制可執(zhí)行文件,然后鏈接任何插入的動態(tài)庫。這里都用到了 link
方法,在這個方法內(nèi)部會執(zhí)行遞歸的 rebase
操作來修正 ASLR
偏移量問題。同時還會有一個 recursiveApplyInterposing
方法來遞歸的將動態(tài)加載的鏡像文件插入。
運行所有初始化程序
完成鏈接之后需要進行初始化了,這里會來到 initializeMainExecutable
:
這里注意執(zhí)行順序:
- 先為所有插入并鏈接完成的動態(tài)庫執(zhí)行初始化操作
- 然后再為主程序可執(zhí)行文件執(zhí)行初始化操作
在 runInitializers
內(nèi)部我們繼續(xù)探索到 processInitializers
:
然后我們來到 recursiveInitialization
:
然后我們來到 notifySingle
:
箭頭所示的地方是獲取鏡像文件的真實地址。
我們?nèi)炙阉饕幌?sNotifyObjcInit
可以來到 registerObjCNotifiers
:
接著搜索 registerObjCNotifiers
:
此時,我們打開 libObjc
的源碼可以看到:
上面這一連串的跳轉(zhuǎn),結(jié)果很顯然:dyld
注冊了回調(diào)才使得 libobjc
能知道鏡像何時加載完畢。
在 ImageLoader::recursiveInitialization
方法中還有一個 doInitialization
值得注意,這里是真正做初始化操作的地方。
doInitialization
主要有兩個操作,一個是 doImageInit
,一個是 doModInitFunctions
:
doImageInit
內(nèi)部會通過初始地址 + 偏移量拿到初始化器 func
,然后進行簽名的驗證。驗證通過后還要判斷初始化器是否在鏡像文件中以及 libSystem
庫是否已經(jīng)初始化,最后才執(zhí)行初始化器。
通知監(jiān)聽 dyld 的 main
一切工作做完后通知監(jiān)聽 dyld
的 main
,然后為主二進制可執(zhí)行文件找到入口,最后對結(jié)果進行簽名。
四、探索 _objc_init
我們直接通過 LLDB
大法來斷點調(diào)試 libObjc
中的 _objc_init
,然后通過 bt
命令打印出當前的調(diào)用堆棧,根據(jù)上一節(jié)我們探索 dyld
的源碼,此刻一切的一切都是那么的清晰明了:
我們可以看到 dyld
的最后一個流程是 doModInitFunctions
方法的執(zhí)行。
我們打開 libSystem
的源碼,全局搜索 libSystem_initializer
可以看到:
然后我們打開 libDispatch
的源碼,全局搜索 libdispatch_init
可以看到:
我們再搜索 _os_object_init
:
完美~,_objc_init
在這里就被調(diào)用了。所以 _objc_init
的流程是
dyld -> libSystem -> libDispatch -> libObc -> _objc_init
五、總結(jié)
本文主要探索了 app
啟動之后 dyld
的流程,整個分析過程確實比較復雜,但在探索的過程中,我們不僅對底層源碼有了新的認知,同時對于優(yōu)化我們 app
啟動也是有很多好處的。下一章,我們會對 objc_init
內(nèi)部的 map_images
和 load_images
進行更深入的分析,敬請期待~