目錄
前言
編譯過程與動靜態庫
編譯過程
動靜態庫
dyld
📌 什么是 dyld?
dyld加載流程
_dyld_start
dyldbootstrap::start
dyld::main()
配置環境變量
共享緩存
主程序的初始化
插入動態庫
link主程序
link動態庫
弱符號綁定
執行初始化方法
程序啟動時(加載鏡像)
類首次被訪問時
程序退出時(卸載鏡像)
動態庫的卸載(例如插件機制)
尋找主程序入口
前言
我們平時編寫的程序的入口函數都是main.m文件里面的main函數,但在重新load方法后,會發現load方法是在main函數之前執行的,那么在main函數之前到底發生了哪些事呢?這篇文章我們就來探究一下。
編譯過程與動靜態庫
編譯過程
當我們在編譯器上按下按鈕進行開發調試時,編譯器其實幫我們做了許多事,整個過程可以拆分成四個步驟:預處理、編譯、匯編和鏈接
這四個步驟會完成這些事:
-
預處理:處理#開頭的預處理指令,替換宏、展開頭文件、刪除注釋,輸出中間文件:.i
-
編譯:對.i文件進行詞法、語法和語義分析,執行代碼優化,生成匯編代碼,輸出中間文件.s
-
匯編:將.s匯編文件翻譯成機器碼,輸出目標文件.o
-
鏈接:將多個.o文件與系統庫、框架等一起鏈接成可執行文件,解決函數/變量引用、地址重定位等,輸出最終文件:可執行程序。在這個過程中,鏈接器將不同的目標文件鏈接起來,因為不同的目標文件之間可能有相互引用的變量或調用的函數,如我們經常調用
Foundation
框架和UIKit
框架中的方法和變量,但是這些框架跟我們的代碼并不在一個目標文件中,這就需要鏈接器將它們與我們自己的代碼鏈接起來
動靜態庫
Foundation`和`UIKit`這種可以共享代碼、實現代碼的復用統稱為`庫`——它是可執行代碼的二進制文件,可以被操作系統寫入內存,它又分為`靜態庫`和`動態庫
靜態庫:靜態庫是一種將代碼編譯后封裝起來的二進制文件,在程序編譯鏈接階段被打包進最終的可執行文件中,運行時不再依賴外部庫文件。但同時由于需要將庫復制進最終程序,會使最終可執行文件體積變大。如.a、.lib都是靜態庫
動態庫:動態庫(Dynamic Library),也稱為 共享庫,是指在程序運行時動態加載的代碼模塊,不會在編譯時被打包進可執行文件中,而是以共享形式存在,運行時由操作系統加載。多個程序可以共享同一個動態庫的實例,系統只需加載一次動態庫,可以節省內存。如.dylib、.framework都是動態庫
dyld
📌 什么是 dyld?
dyld
(Dynamic Link Editor) 是蘋果操作系統中的 動態鏈接器,負責在程序啟動時將程序依賴的 動態庫(dynamic libraries)加載到內存,并完成 符號解析與重定位,以確保程序能夠正常運行。在應用被編譯打包成可執行文件格式的Mach-O文件之后 ,交由dyld負責鏈接,加載程序。
所以應用程序啟動應該是如下流程:
dyld_shared_cache:
為了優化程序啟動速度和利用動態庫緩存,蘋果從
iOS3.1
之后,將所有系統庫(私有與公有)編譯成一個大的緩存文件,這就是dyld_shared_cache
,該緩存文件存在iOS系統下的/System/Library/Caches/com.apple.dyld/
目錄下
dyld加載流程
我們在load方法和main方法處加一個斷點,使用LLDB——bt指令打印,可以看到最初的起點。在最新的xcode中,這個函數是start
而筆者在閱讀博客時,最初的起點是_dyld_start,而dyldbootstrap這個命名空間作用域里存在著這個start函數,_dyld_start會調用這個函數,整個啟動邏輯應該差別不大,這里先按照博客中閱讀的來講解
_dyld_start
在dyld源碼中可以搜索到_dyld_start這個函數,發現它是匯編實現的,并且它調用了dyldbootstrap::start方法。
dyldbootstrap::start
dyldbootstrap::start是指dyldbootstrap這個命名空間作用域里的 start函數。通過命名空間找到這個方法,方法的核心是返回值調用dyld的main函數,第一個參數是一個Mach-O(可執行文件)的頭部。Mach-O類型分為四個部分:Mach-O頭部、
Load Command、
section、
Other Data。
這個函數主要進行了一些 dyld 自身狀態的初始化,進行重定位、棧溢出保護、參數解析等等,最重要的一步是調用dyld::_main()(真正開始啟動app)。
dyld::main()
dyld::main的主要流程為:
-
配置環境變量:創建一個
RuntimeState
或類似結構體,保存當前 App 路徑、argc/argv/env/apple、主程序的 header、slide 等啟動信息。為接下來 image 加載和綁定做準備。(根據環境變量設置相應的值以及獲取當前運行架構) -
加載共享緩存:優先從預編譯的 dyld shared cache(系統框架緩存)中加載常用系統庫(如 libSystem、Foundation 等),提高性能。 如果無法使用 shared cache,就退回到逐個加載(fallback 邏輯)。
-
主程序初始化:通過 instantiateFromLoadedImage()實例化主程序 image(Mach-O 文件),構造一個 ImageLoader 實例(封裝 image 加載行為)
-
插入動態庫:解析環境變量 DYLD_INSERT_LIBRARIES,加載用戶或調試器插入的動態庫。
-
Link 主程序:遞歸解析主程序的依賴庫(LC_LOAD_DYLIB),完成主程序的符號綁定與鏈接(如解析外部函數地址)
-
Link 動態庫:將主程序依賴的所有 dylib 遞歸 link 完成,
-
弱符號綁定
-
執行初始化方法:執行所有 image 的 mod_init_funcs(C++ 構造函數、ObjC +load 等)
-
尋找并跳轉到主程序入口 main()
配置環境變量
平臺,版本,路徑,主機信息的確定
共享緩存
checkSharedRegionDisable檢查是否開啟共享緩存(在iOS中必須開啟)
mapSharedCache加載共享緩存庫,其中調用loadDyldCache函數有這么幾種情況:
-
僅加載到當前進程mapCachePrivate(模擬器僅支持加載到當前進程)
-
共享緩存是第一次被加載,就去做加載操作mapCacheSystemWide
-
共享緩存不是第一次被加載,那么就不做任何處理
主程序的初始化
調用instantiateFromLoadedImage函數實例化了一個imageLoader對象
進入instantiateFromLoadedImage方法,其中創建了一個ImageLoader實例對象,通過instantiateMainExecutable方法創建。
進入instantiateMainExecutable源碼,其作用是為主可執行文件創建映像,返回一個ImageLoader類型的image對象,即主程序。其中sniffLoadCommands函數會獲取Mach-O類型文件的Load Command的相關信息,并對其進行各種校驗
插入動態庫
遍歷DYLD_INSERT_LIBRARIES環境變量,調用loadInsertedDylib加載,通過該環境變量我們可以注入自定義的一些動態庫代碼從而完成安全攻防,loadInsertedDylib內部會從DYLD_ROOT_PATH、LD_LIBRARY_PATH、DYLD_FRAMEWORK_PATH等路徑查找dylib并且檢查代碼簽名,無效則直接拋出異常
link主程序
這里rebase函數執行的是重定位,即將原本指向自己的段內部地址,修正為加載到內存中的真實地址。
link動態庫
弱符號綁定
這里符號綁定(bind)是將原本指向自己的段內部地址,修正為加載到內存中的真實地址。
執行初始化方法
從函數調用棧里可以發現進入dyld::main()函數后,初始化的起點是dyld::initializeMainExecutable,進入initializeMainExecutable源碼,主要是循環遍歷去執行runInitializers
sImageRoots 是一個鏡像根列表,第 0 個是主程序,其它的是插入的動態庫;
遍歷這些庫,調用 runInitializers() 執行構造函數(構造器、initializer);
sMainExecutable->runInitializers(gLinkContext, initializerTimes[0]);這一行初始化主程序及其依賴的動態庫
找到runInitializers的源碼,核心代碼是 processInitializers,為初始化做準備
進入processInitializers函數的源碼實現,其中核心部分是對鏡像列表調用recursiveInitialization函數進行遞歸實例化。
這里的鏡像列表包含的是還未執行初始化的鏡像對象,主要包括:主程序本身、插入的動態庫、主程序依賴的系統或用戶動態庫、間接依賴庫。
找到recursiveInitialization函數,作用是獲取到鏡像的初始化
這里我們分成兩個部分來看,一部分是notifySingle函數,另一部分是負責初始化鏡像的doInitialization函數,首先探索notifySingle函數
源碼中與啟動流程相關的重點是一句通過靜態全局函數指針調用的回調,函數指針sNotifyObjCInit在別的地方賦值(即注冊回調),dyld在合適的時機進行調用(*sNotifyObjCInit)(image->getRealPath(), image->machHeader());
現在我們來探索在哪里對這個函數指針進行了賦值,全局搜索sNotifyObjcInit,可以找到賦值操作
搜索registerObjCNotifiers在哪里調用,發現只在_dyld_objc_notify_register里調用了,這個函數只在運行時提供給objc使用。
這時,_dyld_objc_notify_register的源碼需要在libobjc源碼中搜索,libobjc 是 Objective-C(OC)語言的核心庫之一,負責處理運行時的動態行為。為什么要去libobjc中搜索,因為dyld 本身不負責 Objective-C 的類注冊、+load 調用等邏輯,這些是 libobjc 的職責
dyld 只知道:
-
某個鏡像(dylib)被加載了
-
某個鏡像初始化完成了
所以 dyld 會說:
“我通知你(libobjc),該你干活了。”
libobjc 就會注冊回調進來,讓 dyld 通過這些回調「把控制權還給它」。
在objc源碼中搜索_dyld_objc_notify_register,發現在_objc_init的是實現中調用了該方法,并傳入了參數,所以sNotifyObjcInit中賦值的就是objc中的_load_images
這里的map_images、load_images、unmap_image三個函數都是在libobjc實現的,賦值給dyld中源碼,由dyld在合適的時機調用。load_images會調用所有的+load方法,可以繼續往里探索。
這里所謂合適的時機:具體的時機包括:
程序啟動時(加載鏡像)
dyld
初始化:在程序啟動時,dyld
會被加載并開始執行,負責加載主可執行文件以及其依賴的動態庫。在加載過程中,dyld
會調用map_images
來將這些庫的內容映射到內存中。加載動態庫時調用
load_images
:一旦鏡像被映射到內存,dyld
會調用load_images
以確保庫中的初始化代碼得以執行,包括處理 Objective-C 類的元數據。此時,如果庫中包含 Objective-C 類,libobjc
會接管對類元數據的管理,確保它們被正確初始化。類首次被訪問時
延遲初始化:當程序第一次訪問某個類時,
libobjc
會通過load_images
完成該類的初始化,包括注冊類、方法解析、執行+initialize
等操作。這意味著,雖然類的元數據在程序啟動時已經被加載到內存中,但類的完整初始化通常會延遲到第一次使用時。程序退出時(卸載鏡像)
unmap_image
的調用時機:在程序退出時,dyld
會清理和卸載不再需要的動態庫,這時unmap_image
會被調用,卸載那些已經加載的鏡像,并釋放相關資源。動態庫的卸載(例如插件機制)
如果程序在運行過程中動態加載和卸載插件或其他動態庫,
dyld
會在合適的時機調用unmap_image
來卸載鏡像。這通常發生在使用dlclose
等系統調用卸載共享庫時。
進入cal_load_methods()的源碼
這里的核心就是do-while循環調用+load方法,上圖紅框中是類調用load方法,下面是類別調用load方法
進入call_class_loads函數
可以看到這里的實現其實就是直接通過SEL找到了load對應的IMP進行load方法的調用
注意:這里的類即所有類,鏡像列表中會包含所有類的鏡像——包括所謂的“懶加載類”,因為從底層實現來看,所有編譯時就存在的類,其信息都會打包進對應的 Mach-O 鏡像,并由 dyld 在啟動或動態加載庫時統一處理。
??懶加載 ≠ 沒被加載到內存懶加載類的“懶”只是指
+initialize
的執行不是立即的,而不是類的元數據未被加載。懶加載類的元數據同樣在dyld啟動時加載到內存中。關于類別:
① 編譯時定義的分類(在
.m
文件中寫的@interface MyClass (CategoryName)
):
? 會被編譯器生成元數據,并寫入到 Mach-O 文件的:
__objc_catlist
(分類列表)
__objc_const
(分類的方法、屬性等結構)? 加載過程:
程序啟動或動態庫加載時,
dyld
加載鏡像。調用
libobjc
的load_images()
。遍歷
__objc_catlist
,找到所有分類。將分類合并到其主類(category -> class)上。
如果分類實現了
+load
方法,進入call_category_loads()
執行。? 所以分類雖然不是類自身的一部分,但它們是隨著鏡像加載一起加載的。
② 運行時動態注冊的分類:
🧩 通過 API 手動添加,比如:
extern void objc_addCategory(Class cls, Category *cat);注:這類函數不是公開 API,可能是私有或內部機制。
🚫 不依賴 dyld,也不會寫在 Mach-O 的
__objc_catlist
中。? 它們注冊后也能像普通分類一樣擴展類的功能(方法、屬性等),但不是通過鏡像加載的,而是手動注冊進 runtime 的哈希表中。
然而,實現了+load的類,也會非懶的效果,因為從語義上,人們把“懶加載類”理解為:
等到我真正用它的時候,它才在內存中變得活躍。
但
+load
徹底破壞了這種“懶”:
類注冊后立即調用
+load
這個類的所有元數據都要準備好
如果它還有分類有
+load
,也會一并調用因此,在行為上,這些類無法再被延遲加載使用,所以說它是“非懶加載類”。
類的元數據包括:objc_class、class_rw_t、class_ro_t與方法、屬性、變量、協議等列表結構(這些都來源于class_ro_t)
這時就又有了一個問題,_objc_init是什么時候調用的呢?還記得剛開始我們把recursiveInitialization分成了兩部分來看嗎?還有一部分就是doInitialization函數的源碼實現,現在我們來看看這個函數
這里也分成兩部分:doImageInit函數和doModInitFunctions函數
doImageInit函數:
這個函數主要是在鏡像加載完畢后,檢查當前 Mach-O 文件中是否存在 LC_ROUTINES_COMMAND
(舊版 Mach-O 中用于指定初始化函數),如果有,就提取出 init_address
,偏移 slide
后計算出真實地址,并立即調用它。
這個-init的作用是在 鏡像(動態庫或可執行文件)被 dyld 加載后立即執行某段初始化邏輯,目的是:
? 在程序或庫使用前,執行初始化代碼,配置運行環境、注冊資源、設置全局狀態等。
進入doModInitFunctions源碼,可以發現這個函數的重點就是加載了所有Cxx文件。
到現在還是不知道_objc_init何時調用,我們為它添加斷點查看堆棧信息可以發現在完成所有加載工作后,最先調用的函數是libSystem_initializer
在libsystem中查找libSystem_initializer,查看其中的實現:
可以發現會調用libdispatch_init函數,這個函數的源碼是在libdispatch開源庫中的,在libdispatch中搜索libdispatch_init
調用了_os_object_init函數
可以發現在這里調用了_objc_init
尋找主程序入口
最后,關于尋找主程序入口,底層通過匯編實現的,需要注意的是main是寫定的函數,寫入內存,讀取到dyld。