iOS里面APP的啟動,過程有些復雜,今天我們來抽絲剝繭,一步步探討一下APP的啟動會經歷哪些過程。
首先,用戶點擊iPhone里面的某個APP的icon,Kernel內核會開始初始化空間並創建進程, 在調用exec_active_image后,開始加載Mach-O文件。
這里我們簡要說一下Mach-O文件。
Mach-O
Mach-O是iPhone下的可執行文件格式,我們的APP對應的ipa文件,解壓縮以后就會看到這個Mach-O文件,我們可以用MachOView這個軟件來查看一下,如圖:
(注:這里使用的是x86架構下的mach-o文件,也就是模擬器生成的,如果是arm架構的話會有一些區別,不過區別不大,整體結構差不多)
我們拿其中幾個比較重要的來講解一下。
Mach64 Header:描述了Mach-O的CPU架構、文件類型以及加載命令等信息。
Load Commands:一系列的加載的命令集合,在Mach-O文件加載的時候用於給kernel和dyld調用,如圖:
LC_SEGMENT_64(__PAGEZERO):映射虛擬內存的第一頁地址和大小,一般是4G(0x1000000)大小。
LC_SEGMENT_64(__TEXT):代碼段的Header,里面記錄了__TEXT的各種類型的偏移地址,如圖:
表明了__stubs的偏移地址以及一些相關的頭信息,其他的Header也類似。
LC_SEGMENT_64(__DATA):數據段,里面記錄的信息也是偏移地址和一些相關頭信息。
LC_SEGMENT_64(__LINKEDIT):記錄的是動態鏈接相關的偏移地址和頭信息(主要是dyld),動態鏈接十分重要,我們在后面會說到。
LC_DYLD_INFO_ONLY:記錄了動態鏈接的rebase,binding,lazy binding等的頭信息和偏移地址。
LC_SYMTAB:符號表的信息,記錄符號表的位置,偏移量,數據個數等。通常跟Symbol Table還有String Table一起來查找符號地址,如下圖:
在__Text代碼段找到代碼-[XFCorrelationNewsJSExport onload]的符號地址:0x1000014E0,通過LC_SYMTAB中的Symbol Table Offset找到地址 0x0012C218,然后根據此地址找到Symbols -[XFCorrelationNewsJSExport onload] 的偏移地址 0x00006D70 與 String Table的起始地址相加后計算出符號地址為:0x0017DB7C,然后就可以找到我們符號對應的字符串,如果要收集crash,也就可以拿到符號地址對應的符號的名字了。
LC_LOAD_DYLINKER:該Mach-O使用的鏈接器信息,記錄了具體使用哪個鏈接器接管內核后續的加載工作,以及鏈接器的位置信息。
LC_LOAD_DYLIB:依賴庫信息,dyld會通過這個段去加載動態庫。列出了所有依賴的動態庫。
Mach-O文件就暫時介紹到這里,后續提到動態鏈接器(dyld),動態庫(dylib),動態庫的延遲綁定問題時,還會繼續介紹Mach-O相關的Section。
這里分享一點關於Mach-O的小感悟,一開始我在看Mach-O文件的各個section和segment的時候,覺得這么多的section,這么多的segment,我怎么可能搞清楚每一個都是干什么的,就算搞清楚了,時間長了也會忘記。后來我仔細想了一下,覺得Mach-O只是一種操作系統認識的可執行文件格式,所以他的各個section或者segment都是為了在不同的時候和不同的階段提供不同的信息給操作系統使用的,所以,我個人認為,只需要了解他的大致結構(MachHeader)和比較核心的幾個點(Load Commands,動態庫和動態鏈接相關)就可以了。 |
在加載了Mach-O后,會開始載入動態鏈接器。
我們來簡要說一下動態鏈接器。
動態鏈接器
在介紹動態鏈接器之前,我們有必要先介紹一下什么是鏈接,什么是動態鏈接。
鏈接
鏈接就是通過鏈接器將執行文件中引用的其他符號(變量和方法)做地址重定位的過程。鏈接分為:靜態鏈接和動態鏈接。
靜態鏈接
現在假設文件A,里面有方法 a(),方法a()里面引用了文件B里面的方法b(),那么在編譯器編譯的時候,會將方法a里面調用的方法b的地址以0x0,0x2等這些來暫時代替,然后輸出可執行文件C,等到調用靜態鏈接器的時候,由靜態鏈接器來將真實的方法b的地址(這里的真實地址其實是指的虛擬地址)修改到C對應的位置上。
這里有個問題就是靜態鏈接器如何知道哪些符號的地址需要重定位呢?
因為在編譯A的時候,會生成一個重定位表,里面記錄了哪些符號需要被重定位。
動態鏈接
動態鏈接區別於靜態鏈接在於鏈接的時機不同,靜態鏈接是編譯的時候做鏈接,而動態鏈接是在APP啟動時做鏈接,而且對於動態庫而言,里面的方法並不會做鏈接操作,只有當第一次運行到這個方法時,才會去做鏈接操作,從而得到真正的地址,這也叫:延遲綁定。
動態鏈接主要是針對動態庫(dylib,或者也可以叫共享庫)的鏈接操作,在系統的/usr/lib目錄下,存放了大量供系統與應用程序調用的動態庫文件。動態庫不能直接運行,而是需要通過系統的動態鏈接器(dyld)進行加載到內存后執行,當dyld加載完動態庫以后,不同的APP可以使用同樣的動態庫(跨進程共享代碼和部分數據)。但是需要注意的是,對於各進程共享的部分,只包括代碼和不需要修改的數據部分,對於會變動的數據部分,是會被分離出來,每個進程一個副本。
這里有一個問題,就是如何才能在各個進程間共享可以共享的動態庫的代碼和無需修改的數據呢?
因為各進程調用動態庫的地址都是各個進程的虛擬地址,彼此獨立,所以你沒辦法修正動態庫的代碼的地址來適應所有進程調用,於是有人想到了用絕對地址,雖然可以滿足這一要求的,但是會帶來新的問題,即:
- 程序每引入一個共享庫或者共享庫更新后占用空間更大,就需要預留更大的虛擬空間(但是事實上並不是每個函數都會被調用到),可執行文件或許就要重新編譯。
- 共享對象更新時,內部的符號地址可能變化,可執行文件又得重新編譯。
所以用到了地址無關代碼 (PIC, Position-independent Code) 技術:
無論目標模塊(包括共享目標模塊)被加載到內存中的什么位置,數據段總是緊跟着地址段的。因此,代碼段中的任意指令與數據段中的任意變量之間的距離在運行時都是一個常量,而與代碼和數據加載的絕對內存位置無關。
例子:
1 //動態庫代碼 Person.h 2 extern const NSString * _Nonnull str; 3 4 extern int add(int a, int b); 5 6 NS_ASSUME_NONNULL_BEGIN 7 8 @interface Person : NSObject 9 10 - (void)printStr:(NSString *)str; 11 12 @end 13 14 //動態庫代碼 Person.m 15 const NSString * _Nonnull str = @"abc"; 16 17 int add(int a, int b) { 18 return a + b; 19 } 20 21 @implementation Person 22 23 - (void)printStr:(NSString *)str { 24 25 NSLog(@"sss:%@", str); 26 } 27 28 @end 29 30 //另一個項目引入動態庫后調用的代碼 31 - (void)viewDidLoad { 32 [super viewDidLoad]; 33 // Do any additional setup after loading the view. 34 Person *person = [[Person alloc] init]; 35 [person printStr:@"ttt"]; 36 37 NSLog(@"%@", str); 38 39 NSLog(@"%d", add(3, 5)); 40 }
動態鏈接對於數據引用和方法引用,處理的方式有些區別。
數據引用:
編譯器在代碼段和數據段之間創建了一個GOT(Global Offset Table,全局偏移表),里面存儲的是目標模塊引用的動態庫中的變量,如圖:
初始狀態下,這些GOT中的地址都是0x0,到了app啟動的時候,在Binding階段(后面會講到)動態鏈接器會將GOT中的數據地址都做一次修正。因為GOT是一個數組,所以修正的方式比較簡單,即:GOT[n] = 代碼段的地址 + 代碼段與數據段的固定偏移 + GOT數據大小。
方法引用(延遲綁定):
編譯器在編譯的時候會在__TEXT,__stubs里面將動態庫的add方法生成一個占位,這個占位主要用來指向__DATA,____la_symbol_ptr里面對應的項,如圖:
當運行到上面的代碼第39行,目標函數調用動態庫中的add方法,對應匯編如圖:
bl是匯編指令,跳轉到子程序的意思,使用Hopper Disassembler查看一下匯編,如圖:
ldr:將內存中的值存入到寄存器x16中,此時0x10000c018正好對應__DATA,____la_symbol_ptr中的項,
br:x16 跳轉到x16指向的地址,如圖:
第一次調用add方法的時候,__DATA,____la_symbol_ptr里面尚未記錄add的地址,而是指向__TEXT,__stub_helper里面相關的內容(0x0000001000065E4),如圖:
w16:寄存器x16的低32位
.long 0x0000003f 找尋Dynamic Loader Info 中Lazy Binding Info的偏移3f的符號
上述代碼的意思就是:跳轉到__TEXT,__stub_helper頭部(65CC),然后調用 dyld_stub_binder(動態鏈接器的入口) 進行符號綁定,最后會將 add 的地址放到 __la_symbol_ptr
處,下次再調用就可以直接取add的地址調用了。
繞了這么大一圈終於完成了方法的綁定,簡化一下:
生產stub占位 -> 運行時調用 -> 指向la_symbol_ptr -> 如果有地址則返回地址,如果沒有地址則指向stub_helper -> 調用dyld_stub_binder來綁定方法地址並修正la_symbol_ptr的地址。
這里會產生一個問題,為什么需要la_symbol_ptr,直接在stub里面修改地址不就完了嗎?
因為stub是代碼段,而代碼段是只讀的,動態庫的指導思想就是共享代碼段,分離出可變數據段,所以需要la_symbol_ptr。
綜上所述,我們可以簡單羅列一下靜態鏈接庫和動態鏈接庫的區別: 1、靜態鏈接庫在編譯后,庫里的方法及變量地址就確定了(虛擬地址),動態鏈接庫則是在運行時才能確定,而動態庫中的方法則需要到調用到的時候才能確定。 2、靜態鏈接庫會打包進APP中,而動態鏈接庫則在系統的/usr/lib目錄下,如果是自己制作的動態庫,也會隨着APP一起打包進去。 |
動態鏈接器(dyld)
蘋果操作系統的重要組成部分,負責鏈接和裝載動態庫,當xnu內核(開源的系統底層代碼,下載地址)加載了動態鏈接器以后,APP將從內核態過度到用戶態。
dyld本身也是mach-o格式的文件,但是dyld中不會再引用其他動態庫的東西,所以就不存在動態綁定這個過程了,拿MachOView看看如圖:
動態鏈接器也是開源的,下載地址。
接下來App的啟動就進入Rebase,Binding階段了。
這幾個階段都是由dyld來控制的,我們來簡單分析一下他的這幾個過程
Rebasing
在過去,會把 dylib 加載到指定地址,所有指針和數據對於代碼來說都是對的,dyld 就無需做任何 fix-up 了。如今用了 ASLR 后會將 dylib 加載到新的隨機地址(actual_address),這個隨機的地址跟代碼和數據指向的舊地址(preferred_address)會有偏差,dyld 需要修正這個偏差(slide),做法就是將 dylib 內部的指針地址都加上這個偏移量,偏移量的計算方法如下:
Slide = actual_address - preferred_address
Binding
主要是針對那些外部符號做的綁定操作,比如我們上面說的GOT中的內容。
剩余啟動事件
App啟動到這里接下來就是進入到Runtime環節,會初始化Runtime環境並初始化,處理category和調用+load()方法。
initializers 調用所有動態庫的initializer方法,初始化動態庫。
調用App的main函數,正式進入App的生命周期。
小結
App的啟動我們來回顧一下,主要分為:加載Mach-O、加載dyld、rebase、binding、加載dylib,Runtime、Initializer、main這幾個過程,我們主要講解了一下Mach-O的文件結構,動態鏈接的GOT和動態綁定過程,還簡單介紹了rebase和binding。
可以看出來,App的啟動過程十分復雜,還有很多細節和知識點需要我們仔細深入研究和學習。