這篇是對 iOS 應(yīng)用啟動時,main 函數(shù)執(zhí)行前發(fā)生的事的一點總結(jié),限于水平,如有錯誤請指正~
FAT 二進制
FAT 二進制文件,將多種架構(gòu)的 Mach-O 文件合并而成。它通過 Fat Header 來記錄不同架構(gòu)在文件中的偏移量,F(xiàn)at Header 占一頁(64位16kb,32位4kb)的空間。
按分頁來存儲這些 segement 和 header 會浪費空間,但這有利于虛擬內(nèi)存的實現(xiàn)。

Mach-O 文件
Mach-O為 Mach Object 文件格式的縮寫,它是一種用于可執(zhí)行文件,目標代碼,動態(tài)庫,內(nèi)核轉(zhuǎn)儲的文件格式。
在 Mac OS X 系統(tǒng)中使用 Mach-O 作為其可執(zhí)行文件類型。
它的組成結(jié)構(gòu)如下圖所示:

每個 Mach-O 文件包括一個 Mach-O Header,然后是一系列的載入命令 load commands,再是一個或多個段(segment),每個段包括0到255個節(jié)(section)。Mach-O使用REL再定位格式控制對符號的引用。Mach-O在兩級命名空間中將每個符號編碼成“對象-符號名”對,在查找符號時則采用線性搜索法。
Mach-O包含了幾個 segment,每個 segment 又包含幾個 section。segment的名字都是大寫的,例如__DATA;section的名字都是小寫的, 例如 __text。在 Mach-O 的類型不為MH_OBJECT時,空間大小為頁的整數(shù)倍。頁的大小跟硬件有關(guān),在 arm64 架構(gòu)一頁是16kb,其余為4kb。
section 雖然沒有整數(shù)倍頁大小的限制,但是 section 之間不會有重疊。
Mach-O Header
推薦使用MachOView這個軟件查看 Mach-O 的文件結(jié)構(gòu)。注意需要手動關(guān)閉 processing,不然會閃退。下面是用 MachOView 查看自己的應(yīng)用結(jié)構(gòu):

東西有點多就沒有截取全部。我們查看一下Mach-O Header部分

下面是64位架構(gòu)下header的數(shù)據(jù)結(jié)構(gòu):
struct mach_header_64 {
uint32_t magic; /* mach magic number identifier */
cpu_type_t cputype; /* cpu specifier */
cpu_subtype_t cpusubtype; /* machine specifier */
uint32_t filetype; /* type of file */
uint32_t ncmds; /* number of load commands */
uint32_t sizeofcmds; /* the size of all the load commands */
uint32_t flags; /* flags */
uint32_t reserved; /* reserved */
};

Mach-O 全部的 filetype 和 flags在loader.h中找到。
除了MH_OBJECT以外的所有類型,段(Segment)的空間大小均為頁的整數(shù)倍。頁的大小跟硬件有關(guān)系,在 arm64 架構(gòu)下一頁為16kb,其它為4kb。
Load commands
Load commands緊跟在頭部之后, 當加載過 header 之后,會通過解析Load commands來加載剩下的數(shù)據(jù),確定其內(nèi)存的分布。
下面是 load commands 的結(jié)構(gòu)定義:
struct load_command {
uint32_t cmd; /* 載入命令類型 */
uint32_t cmdsize; /* total size of command in bytes */
};
所有load commands的大小即為 Header->sizeofcmds, 共有 Header->ncmds 條load command。
load command 以LC開頭,不同的加載命令有不同的專有的結(jié)構(gòu)體,cmd 和 cmdsize 是都有的,分別為命令類型(即命令名稱),這條命令的長度。這些加載命令告訴系統(tǒng)應(yīng)該如何處理后面的二進制數(shù)據(jù),對系統(tǒng)內(nèi)核加載器和動態(tài)鏈接器起指導(dǎo)作用。如果當前 LC_SEGMENT 包含 section,那么 section 的結(jié)構(gòu)體緊跟在 LC_SEGMENT 的結(jié)構(gòu)體之后,所占字節(jié)數(shù)由 SEGMENT 的 cmdsize 字段給出。
| cmd(命令名稱) | 作用 |
|---|---|
| LC_SEGMENT_64 | 將對應(yīng)的段中的數(shù)據(jù)加載并映射到進程的內(nèi)存空間去 |
| LC_SYMTAB | 符號表信息 |
| LC_DYSYMTAB | 動態(tài)符號表信息 |
| LC_LOAD_DYLINKER | 啟動動態(tài)加載連接器/usr/lib/dyld程序 |
| LC_UUID | 唯一的 UUID,標示該二進制文件,128bit |
| LC_VERSION_MIN_IPHONEOS/MACOSX | 要求的最低系統(tǒng)版本(Xcode中的Deployment Target) |
| LC_MAIN | 設(shè)置程序主線程的入口地址和棧大小 |
| LC_ENCRYPTION_INFO | 加密信息 |
| LC_LOAD_DYLIB | 加載的動態(tài)庫,包括動態(tài)庫地址、名稱、版本號等 |
| LC_FUNCTION_STARTS | 函數(shù)地址起始表 |
| LC_CODE_SIGNATURE | 代碼簽名信息 |
注意:不同類型的 segment 會使用不同的函數(shù)來加載
Segment
Mach-O 文件中由許多個段(Segment),每個段都有不同的功能,每個段包含了許多個小的Section。LC_SEGMENT意味著這部分文件需要映射到進程的地址空間去,幾乎所有 Mach-O 都包含這三個段:
- __TEXT:包含了執(zhí)行代碼和其它只讀數(shù)據(jù)(如C 字符串)。權(quán)限:只讀(VM_PROT_READ),可執(zhí)行(VM_PROT_EXECUTE)
- __DATA:程序數(shù)據(jù),包含全局變量,靜態(tài)變量等。權(quán)限:可讀寫(VM_PROT_WRITE/READ) 可執(zhí)行(VM_PROT_EXECUTE)
- __LINKEDIT:包含了加載程序的"元數(shù)據(jù)",比如函數(shù)的名稱和地址。權(quán)限:只讀(VM_PROT_READ)
除了上面三個,還有一個常見的 segment:
- __PAGEZERO:空指針陷阱段,映射到虛擬內(nèi)存空間的第一頁,用于捕捉對 NULL 指針的引用
LC_SEGMENT_64 的結(jié)構(gòu)定義為:
struct segment_command_64 { /* for 64-bit architectures */
uint32_t cmd; /* LC_SEGMENT_64 */
uint32_t cmdsize; /* includes sizeof section_64 structs */
char segname[16]; /* segment name */
uint64_t vmaddr; /* memory address of this segment */
uint64_t vmsize; /* memory size of this segment */
uint64_t fileoff; /* file offset of this segment */
uint64_t filesize; /* amount to map from the file */
vm_prot_t maxprot; /* maximum VM protection */
vm_prot_t initprot; /* initial VM protection */
uint32_t nsects; /* number of sections in segment */
uint32_t flags; /* flags */
};
可以看到這里大部分的成員變量都是幫助內(nèi)核將 segment 映射到虛擬內(nèi)存的。nsects即表明該段中包含多少個 section,section是具體數(shù)據(jù)存放的地方。cmdsize表示當前 segment 結(jié)構(gòu)體以及它所包含的所有 section 結(jié)構(gòu)體的總大小。
文件映射的起始位置由fileoff給出,映射到地址空間的vmaddr處。
Section
section 的名字均為小寫。section 是具體數(shù)據(jù)存放的地方,它的結(jié)構(gòu)體跟隨在 LC_SEGMENT 結(jié)構(gòu)體之后。在64位環(huán)境中它的結(jié)構(gòu)定義為:
struct section_64 { /* for 64-bit architectures */
char sectname[16]; /* name of this section */
char segname[16]; /* segment this section goes in */
uint64_t addr; /* memory address of this section */
uint64_t size; /* size in bytes of this section */
uint32_t offset; /* file offset of this section */
uint32_t align; /* section alignment (power of 2) */
uint32_t reloff; /* file offset of relocation entries */
uint32_t nreloc; /* number of relocation entries */
uint32_t flags; /* flags (section type and attributes)*/
uint32_t reserved1; /* reserved (for offset or index) */
uint32_t reserved2; /* reserved (for count or sizeof) */
uint32_t reserved3; /* reserved */
};
其中flasg字段儲存了兩個屬性的值:type 和 attributes。type 只能有一個值,而 attributes 的值可以有多個。如果 segment 中任何一個 section 擁有屬性 S_ATTR_DEBUG,那么該段所有的 section 都會擁有這個屬性。屬性詳情可以參考loader.h
| section name | 作用 |
|---|---|
| __text | 主程序代碼 |
| __stub_helper | 用于動態(tài)鏈接的存根 |
| __symbolstub1 | 用于動態(tài)鏈接的存根 |
| __objc_methname | Objective-C 的方法名 |
| __objc_classname | Objective-C 的類名 |
| __cstring | 硬編碼的字符串 |
| __lazy_symbol | 懶加載,延遲加載節(jié),通過 dyld_stub_binder 輔助鏈接 |
| _got | 存儲引用符號的實際地址,類似于動態(tài)符號表 |
| __nl_symbol_ptr | 非延遲加載節(jié) |
| __mod_init_func | 初始化的全局函數(shù)地址,在 main 之前被調(diào)用 |
| __mod_term_func | 結(jié)束函數(shù)地址 |
| __cfstring | Core Foundation 用到的字符串(OC字符串) |
| __objc_clsslist | Objective-C 的類列表 |
| __objc_nlclslist | Objective-C 的 +load 函數(shù)列表,比 __mod_init_func 更早執(zhí)行 |
| __objc_const | Objective-C 的常量 |
| __data | 初始化的可變的變量 |
| __bss | 未初始化的靜態(tài)變量 |
虛擬內(nèi)存
在 segment 的結(jié)構(gòu)體中,我們可以看到vmaddr和vmsize兩個成員變量,它們分別代表 segment 在虛擬內(nèi)存中的地址以及大小。
虛擬內(nèi)存就是一層間接尋址(indirection)。軟件工程中有句格言就是任何問題都能通過添加一個間接層來解決。虛擬內(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)容復(fù)制一份出來,然后重新映射邏輯地址到新的 RAM 頁上。也就是這個進程自己擁有了那頁內(nèi)存的拷貝。這就涉及到了 clean/dirty page 的概念。dirty page 含有進程自己的信息,而 clean page 可以被內(nèi)核重新生成(重新讀磁盤)。所以 dirty page 的代價大于 clean page。
在多個進程加載 Mach-O 文件時__TEXT和__LINKEDIT因為只讀,都是可以共享內(nèi)存的。而__DATA因為可讀寫,就會產(chǎn)生 dirty page。當 dyld 執(zhí)行結(jié)束后,__LINKEDIT就沒用了,對應(yīng)的內(nèi)存頁會被回收。
dyld

dyld(the dynamic link editor),Apple 的動態(tài)鏈接器。在內(nèi)核完成映射進程的工作后會啟動dyld,負責加載應(yīng)用依賴的所有動態(tài)鏈接庫,準備好運行所需的一切。
在 App 啟動的時候,首先會加載 App 的 mach-o 文件,從 load commands 中得到 dyld 的路徑,并且運行。隨后 dyld 做的事情順序概括如下:
- 初始化運行環(huán)境
- 加載主程序執(zhí)行文件 生成 image, 將image添加到一個全局容器中
- 加載共享緩存
- 根據(jù)依賴鏈遞歸加載動態(tài)鏈接庫 dylib,如果在緩存中有加載好的 image 則直接拿出來,否則生成一個新的 image,將image添加到一個全局容器中
- link 主執(zhí)行文件
- link dylib
- 根據(jù)依賴鏈遞歸修正指針 Rebase
- 根據(jù)依賴鏈遞歸符號綁定 Bind
- 初始化 dylib(runtime 的初始化就在這個時候)
在加載完所有的 dylib 之后,它們處于互相獨立的狀態(tài),所以還需要將它們綁定起來。代碼簽名讓我們不能修改指令,所以不能直接讓一個 dylib 調(diào)用另一個 dylib,這時需要很多間接層。
這個時候需要 dyld 來修正指針(rebasing)和綁定符號(binding)。
詳細可以查看 dyld 的源碼中的_main函數(shù)。
下面會分析上述的其中幾個步驟。
ImageLoader
ImageLoader是一個將 mach-o 文件里面二進制數(shù)據(jù)(編譯過的代碼、符號等)加載到內(nèi)存的基類,它負責將 mach-o 中的二進制數(shù)據(jù)映射到內(nèi)存,它的實例就是我們熟悉的 image。
每一個 mach-o 文件都會有一個對應(yīng)的 image,實例的類型根據(jù) mach-o 格式的不同也會不同。

- ImageLoaderMachOClassic: 用于加載
__LINKEDIT段為傳統(tǒng)格式的 mach-o 文件 - ImageLoaderMachOCompressed: 用于加載
__LINKEDIT段為壓縮格式的 mach-o 文件
因為dylib之間有依賴關(guān)系,所以系統(tǒng)會沿著依賴鏈遞歸加載 image。
Rebasing
dylib的二進制數(shù)據(jù)會隨機的映射到內(nèi)存的一個隨機地址ASLR(Address space layout randomization,)中,這個隨機的地址跟代碼和數(shù)據(jù)指向的舊地址(preferred_address)會有一定的偏差,dyld需要修正這個偏差(slide),做法就是將dylib內(nèi)部的指針地址都加上這個偏移值,偏移值的計算方法如下:
slide = actual_address - preferred_address
隨后就是不斷的將__DATA段中需要修正的指針加上這個偏移值。
注意:每次程序啟動后的地址都會變化,所以指針的地址都需要重新修正。
在 mach-o 的一個載入命令LC_DYLD_INFO_ONLY可以查看到rebase, bind, week_bind,lazy_bind的偏移量和大小。

Binding
binding處理那些指向dylib外部的指針,它們實際上被符號名稱(symbol)綁定,也就是個字符串。比如我們 objc 代碼中需要使用到 NSObject, 即符號OBJC_CLASS$_NSObject,但是這個符號不存在當前的 image 中,而是在系統(tǒng)庫 Foundation.framework中,因此就需要binding這個操作將對應(yīng)關(guān)系綁定到一起。
Lazy Binding
lazyBinding就是在加載動態(tài)庫的時候不會立即 binding, 當時當?shù)谝淮握{(diào)用這個方法的時候再實施 binding。 做到的方法也很簡單: 通過dyld_stub_binder這個符號來做。lazy binding 的方法第一次會調(diào)用到 dyld_stub_binder, 然后 dyld_stub_binder負責找到真實的方法,并且將地址bind到樁上,下一次就不用再bind了。
多數(shù)符號都是 lazy binding 的
Runtime
每一個dylib都有自己的初始化方法,當相應(yīng)的 image 被加載到內(nèi)存后,就會調(diào)用初始化方法。當然這不是調(diào)用名為initialize方法,而是C++靜態(tài)對象初始化構(gòu)造器,__attribute__((constructor))標記的方法以及Initializer方法。你可以在程序中設(shè)置環(huán)境變量DYLD_PRINT_INITIALIZERS來打印dylib的初始化方法。


我們可以看到程序首先調(diào)用了libSystem這個dylib的初始化方法。libSystem是很多系統(tǒng)的lib的集合,包括 libdispatch(GCD), libsystem_c(c語言庫), libsystem_blocks(block)。
在libSystem的源碼init.c中我們可以看到,它的初始化方法libSystem_initializer會調(diào)用libdispatch_init();, 然后逐步調(diào)用到_objc_init,也就是 objc 和 runtime 的入口。
添加一個符號斷點_objc_init,下面是方法調(diào)用棧:

注意:runtime 和 objc 屬于libobjc
下面是_objc_init的實現(xiàn):
void _objc_init(void)
{
// 省略...
_dyld_objc_notify_register(&map_images, load_images, unmap_image);
}
上面的map_images不是將 image 添加到內(nèi)存中的意思,在這個方法被調(diào)用的時候,已經(jīng)完成了 image 的映射以及指針修正,綁定符號的工作了。
這個函數(shù)實際上是將 image 中 OBJC 相關(guān)的信息進行初始化,具體實現(xiàn)可以查看_read_image的源碼,因為代碼太多所以這里就不貼出來了,下面是具體做的事情:
- 會將所有的 Class 存放在一張映射類名與 Class 的全局表中
gdb_objc_realized_classes - 隨后調(diào)用
readClass函數(shù)將 每一個 Class 添加到gdb_objc_realized_classes表中。 - 確定 selector 是唯一的
- read protocols: 讀取protocol
- realizeClasses:這一步的意義就是動態(tài)鏈接好class, 讓class處于可用狀態(tài),主要操作如下:
- 檢查ro是否已經(jīng)替換為rw,沒有就替換一下。
- 檢查類的父類和metaClass是否已經(jīng)realize,沒有就先把它們先realize
- 重新layout ivar. 因為只有加載好了所有父類,才能確定ivar layout
- 把一些flags從ro拷貝到rw
- 鏈接class的 nextSiblingClass 鏈表
- attach categories: 合并categories的method list、 properties、protocols到 class_rw_t 里面
- read categories:讀取類目
在map_images結(jié)束會調(diào)用load_images函數(shù)。這一步做的事情比較少,就是調(diào)用我們熟悉的+(load)函數(shù)。父類會先調(diào)用,除了 Class,每個類目的+(load)方法也會被調(diào)用一次,但順序就不一定了。
總結(jié)
在這里對 main 函數(shù)之前的操作做一個小總結(jié)吧:
- 將 App 的 mach-o header 讀取到內(nèi)存中
- 根據(jù) load commands 獲取 dyld 的路徑,運行 dyld
- 初始化運行環(huán)境,加載 dylib,如果緩存中存在則從緩存中拿出加載過的 image,否則新建一個 image,加載到內(nèi)存中
- 修正指針(rebase),綁定符號(bind)
- 初始化 dylib,運行 runtime
- runtime 將 image 中有關(guān) OBJC 的數(shù)據(jù)進行初始化
- 調(diào)用 +(load) 方法
- dyld 調(diào)用 main 函數(shù)
花了一周的時間用來研究這部分的內(nèi)容,終于填完坑了~很舒服
最大的感受就是學習完后,看 clang 編譯后的 C++ 代碼能看懂的更多了。比如添加完一個類目之后,會將這個這個類目添加到__DATA的section __objc_catlist中,以前不知道啥意思現(xiàn)在就明白了。也明白 xcode 的許多設(shè)置是用來干嘛的,總之好處多多~
學習也是一個遞歸的過程,總之,也多加油吧!
引用
iOS 程序 main 函數(shù)之前發(fā)生了什么
優(yōu)化 App 的啟動時間
dyld源碼分析-動態(tài)加載load
dyld與ObjC