延遲綁定機(jī)制是指將符號(hào)的綁定工作推遲到符號(hào)第一次被程序調(diào)用的時(shí)候瘸彤。為了大家更好地理解延遲綁定的概念以及我們?yōu)槭裁匆舆t綁定,本文首先會(huì)介紹一些程序鏈接方面的知識(shí)闷祥。
相關(guān)背景
鏈接在編譯過程中的位置
一般來說盛霎,代碼的編譯過程分為以下幾個(gè)階段:
-
預(yù)處理
主要包括頭文件導(dǎo)入、宏展開等過程散吵。
-
語法以及語義分析
首先對(duì)代碼進(jìn)行掃描分析龙考,生成抽象語法樹(Abstract Syntax Tree),語法樹中還記錄了每個(gè)節(jié)點(diǎn)在源代碼中的位置矾睦,方便編譯器定位問題晦款;之后編譯器對(duì)其進(jìn)行更高級(jí)別的靜態(tài)分析,確保程序中沒有錯(cuò)誤枚冗,例如調(diào)用對(duì)象未實(shí)現(xiàn)的方法缓溅、類型轉(zhuǎn)換錯(cuò)誤、對(duì)可能導(dǎo)致內(nèi)存泄露的代碼進(jìn)行警告等赁温。
-
中間代碼生成及優(yōu)化
將代碼進(jìn)一步轉(zhuǎn)換成中間代碼(LLVM IR)坛怪,對(duì)其進(jìn)行相應(yīng)的優(yōu)化處理,并輸出匯編代碼股囊。
-
匯編
將匯編代碼生成相應(yīng)的目標(biāo)文件袜匿。
-
鏈接
合并多個(gè)目標(biāo)文件,最后輸出一個(gè)可執(zhí)行文件或是庫文件稚疹。
鏈接是程序編譯過程中的最后一步居灯,也是十分重要的一步,它主要有兩方面的工作:(1)符號(hào)解析贫堰。將符號(hào)的引用和符號(hào)的定義聯(lián)系起來穆壕;(2)重定位。將符號(hào)的定義和具體的地址對(duì)應(yīng)起來其屏,并修改所有對(duì)這些符號(hào)的引用喇勋,使它們指向相應(yīng)的地址。
靜態(tài)鏈接
在很久很久以前偎行,鏈接是指靜態(tài)鏈接川背,也就是將所需的庫文件全部都拷貝一份到程序中,最終形成一個(gè)可執(zhí)行文件蛤袒。靜態(tài)鏈接在技術(shù)上是沒有什么問題的熄云,但是隨著時(shí)間的推移,人們也發(fā)現(xiàn)了它身上的一些硬傷:
最終生成的可執(zhí)行文件十分臃腫妙真、巨大缴允。
編譯時(shí)間大大增加。
浪費(fèi)存儲(chǔ)器資源珍德。相同的代碼會(huì)被復(fù)制到不同的程序中练般,操作系統(tǒng)運(yùn)行的程序越多矗漾,浪費(fèi)就越嚴(yán)重。
更新和維護(hù)困難薄料。如果你要更新一個(gè)庫文件敞贡,那么你必須要重新修改編譯參數(shù),然后將以上費(fèi)時(shí)費(fèi)力的編譯過程重新跑一邊摄职,如果其中出了一點(diǎn)偏差誊役,那么不好意思,一切都要重新來過谷市,反復(fù)幾次后程序不一定能編譯出來蛔垢,但孩子一定可以打醬油了(?_?)
所以為了解決這些問題,勤勞歌懒、善良的程序員們又發(fā)明了新的技術(shù):動(dòng)態(tài)鏈接啦桌。
動(dòng)態(tài)鏈接
靜態(tài)鏈接問題的根源在于它使程序和靜態(tài)庫的聯(lián)系過于緊密溯壶,解決問題的關(guān)鍵是降低二者間的耦合度及皂,動(dòng)態(tài)鏈接技術(shù)為此應(yīng)運(yùn)而生。
與靜態(tài)鏈接相比且改,動(dòng)態(tài)鏈接將相關(guān)符號(hào)的綁定工作推遲到程序被加載到內(nèi)存中執(zhí)行的時(shí)候验烧,這不僅減少了程序的編譯時(shí)間,而且也使得庫文件能夠真正的被不同的程序所共享又跛。此處的“共享”有兩點(diǎn)含義:一是指庫文件在操作系統(tǒng)中只存在一份碍拆,而不是像靜態(tài)鏈接那樣將庫文件給每個(gè)程序都拷貝一份;二是指在程序運(yùn)行的過程中慨蓝,共享庫的 text 段的內(nèi)容可以被不同的進(jìn)程所共享感混。
與動(dòng)態(tài)鏈接技術(shù)相對(duì)應(yīng)的庫文件叫做動(dòng)態(tài)庫文件,在 Windows 上是 dll 文件礼烈,在 Linux 上是 so 文件弧满,而在 Mac 上則是 dylib 文件。
我們知道此熬,把對(duì)符號(hào)的引用修正為符號(hào)所對(duì)應(yīng)的地址是鏈接過程中的關(guān)鍵部分庭呜,動(dòng)態(tài)庫也是一樣,它也要做出相應(yīng)的修改犀忱,但是最尷尬的地方在于動(dòng)態(tài)庫只有一份募谎,但是它會(huì)被很多進(jìn)程加載,而且它事先并不知道自己會(huì)被加載到哪里阴汇。
舉個(gè)例子:假設(shè)動(dòng)態(tài)庫加載到進(jìn)程 A 的地址為 0xAAAAAA数冬,鏈接結(jié)束后動(dòng)態(tài)庫相關(guān)符號(hào)的地址也被修改,如果此后沒有進(jìn)程加載該動(dòng)態(tài)庫搀庶,那么一切都沒有問題拐纱》枰可是直到某一天,進(jìn)程 B 也要加載此動(dòng)態(tài)庫戳玫,還硬要把它加載到 0xBBBBBB 上熙掺,這樣麻煩就來了。動(dòng)態(tài)庫中符號(hào)的相關(guān)地址是基于 0xAAAAAA 的咕宿,在進(jìn)程 B 中是無法工作的币绩,那么要基于 0xBBBBBB 修改嗎?這樣進(jìn)程 A 就不能工作了府阀。重新拷貝一份到進(jìn)程 B 嗎缆镣?那你還共享個(gè)啥啊J哉恪6啊!
還有一個(gè)潛在的問題田巴,那就是動(dòng)態(tài)庫的 text 段必須是可寫的钠糊,否則加載的時(shí)候就無法修改相關(guān)符號(hào)進(jìn)行重定位,這就會(huì)使安全性大打折扣壹哺。
為了使動(dòng)態(tài)鏈接成為可能抄伍,我們勤勞、善良的程序員又發(fā)明了新的技術(shù)管宵,即 PIC(Position Independent Code)截珍。
Position Independent Code
PIC 又稱為位置無關(guān)代碼,即相關(guān)代碼加載到任何位置都可以正常運(yùn)行箩朴,動(dòng)態(tài)庫的編譯都需要加上 -fPIC -shared 選項(xiàng)岗喉。
Use -fPIC or -fpic to generate position independent code. Whether to use -fPIC or -fpic to generate position independent code is target-dependent. The -fPIC choice always works, but may produce larger code than -fpic (mnenomic to remember this is that PIC is in a larger case, so it may produce larger amounts of code). Using -fpic option usually generates smaller and faster code, but will have platform-dependent limitations, such as the number of globally visible symbols or the size of the code. The linker will tell you whether it fits when you create the shared library. When in doubt, I choose -fPIC, because it always works.
那么 PIC 是如何實(shí)現(xiàn)的?它又是如何在不修改 text 段的情況下炸庞,讓同一動(dòng)態(tài)庫加載到不同進(jìn)程的不同位置钱床?使用動(dòng)態(tài)庫的程序又需要做哪些工作?
答案其實(shí)很簡單燕雁,就是多一層引用關(guān)系诞丽。
既然 text 段不讓寫,那我就寫 data 段唄??拐格。因?yàn)?data 段是可寫入的僧免,所以就把符號(hào)加載后的地址放在 data 段中的相關(guān)位置,而當(dāng) text 段使用該符號(hào)時(shí)捏浊,就去 data 段中找到相應(yīng)的位置懂衩,從中取出符號(hào)的地址,這樣就解決了問題∽嵌矗可是這樣問題又來了牵敷,data 段的地址你總要知道吧,根據(jù)加載位置的不同法希,data 段地址也是不同的枷餐,PIC 又是怎樣做到位置無關(guān)呢?這依賴于以下兩個(gè)原理:
-
指令和數(shù)據(jù)間的距離是常量
在程序的虛擬地址空間中苫亦,data 段總是被映射到緊隨 text 段的地方毛肋,而且鏈接器知曉程序中每個(gè)段的大小以及它們的相對(duì)位置,所以這就造成了一個(gè)重要的事實(shí):text 段中的任何指令和 data 段中的任何數(shù)據(jù)之間的距離是一個(gè)運(yùn)行時(shí)常量屋剑。即使某一天編譯技術(shù)改了润匙,data 段不緊隨 text 段了,那也是沒有問題的唉匾,因?yàn)殒溄悠髦烂總€(gè)段的大小及位置孕讳,所以是有辦法知道指令和數(shù)據(jù)間的運(yùn)行時(shí)距離的。
-
獲取當(dāng)前指令地址的技巧?
指令和數(shù)據(jù)間的相對(duì)距離知道了巍膘,如果能夠知道指令的當(dāng)前地址就可以得到數(shù)據(jù)的絕對(duì)地址厂财,就能夠做到位置無關(guān)了,那么怎么拿到當(dāng)前指令的地址呢典徘?匯編語言中沒有為此提供方便蟀苛,但是我們可以利用以下代碼來巧妙地獲取當(dāng)前指令的地址:
call get_pc get_pc: pop %ebx
PC 寄存器總是保存下一條指令的地址益咬,而我們對(duì) get_pc 的調(diào)用會(huì)導(dǎo)致程序?qū)?PC 的值壓入棧頂逮诲,在上述程序中 PC 的值就是 pop 指令的地址。隨后 pop 指令將這個(gè)地址彈出到 ebx 寄存器中幽告,最終的結(jié)果就是將 PC 的值保存到 ebx 中梅鹦,這樣就達(dá)到了目的,拿到了指令的地址冗锁。
下面簡單介紹下 Linux 操作系統(tǒng)是如何運(yùn)用 PIC 技術(shù)的齐唆。
PIC 數(shù)據(jù)引用
編譯器會(huì)在 data 段中創(chuàng)建一個(gè)全局偏移量表(Global Offset Table, GOT),表中記錄了對(duì)動(dòng)態(tài)庫全局?jǐn)?shù)據(jù)的引用冻河。當(dāng)加載動(dòng)態(tài)庫時(shí)箍邮,動(dòng)態(tài)鏈接器會(huì)修改 GOT 中的條目,使其包含正確的絕對(duì)地址叨叙。在程序運(yùn)行時(shí)锭弊,通過 GOT 條目進(jìn)行間接的引用。
PIC 函數(shù)引用
對(duì)于動(dòng)態(tài)庫函數(shù)的引用擂错,雖然可以按照數(shù)據(jù)引用的方式進(jìn)行處理味滞,也就是加載動(dòng)態(tài)庫的時(shí)候修正每個(gè)函數(shù)的 GOT 條目,但大多數(shù)編譯系統(tǒng)不會(huì)這么做,因?yàn)檫@非常耗時(shí)剑鞍。根據(jù)程序運(yùn)行的局部性原理昨凡,程序會(huì)將80%的時(shí)間用于執(zhí)行20%的代碼,多數(shù)代碼并沒有被執(zhí)行蚁署,況且函數(shù)的調(diào)用遠(yuǎn)比全局?jǐn)?shù)據(jù)要多便脊,這會(huì)使程序做很多無用功,導(dǎo)致加載時(shí)間非常長光戈。因此對(duì)于函數(shù)引用就轧,編譯系統(tǒng)會(huì)對(duì)其進(jìn)行延遲綁定,也就是推遲到相關(guān)函數(shù)第一次被調(diào)用的時(shí)候再進(jìn)行綁定田度,這需要過程鏈接表(Procedure Linkage Table, PLT)與 GOT 相互配合妒御。
上述流程解釋如下:
程序調(diào)用 func 函數(shù),隨后控制傳遞到 PLT 表中與 func 相對(duì)的條目镇饺。
PLT 條目包含3條指令:第一條指令是跳轉(zhuǎn)到 GOT 條目中所記錄的 func 地址乎莉;第2條指令是準(zhǔn)備符號(hào)解析所需的相關(guān)信息;第3條指令是跳轉(zhuǎn)到 PLT[0] 中奸笤,開始綁定符號(hào)惋啃。
因?yàn)槭堑谝淮握{(diào)用函數(shù),所以 GOT 條目中并沒有相關(guān)函數(shù)的地址监右,此時(shí)它記錄的是相關(guān) PLT 條目的第二條指令的地址边灭,最終結(jié)果是程序跳回到 PLT 條目中,在準(zhǔn)備好符號(hào)解析的信息后繼續(xù)執(zhí)行健盒。
接下來绒瘦,程序跳轉(zhuǎn)到 PLT[0] 條目。PLT[0] 也包含了一連串的指令:它首先將 GOT[1] 的內(nèi)容入棧扣癣,GOT[1] 中記錄的是符號(hào)綁定所需的信息惰帽;接下來它跳轉(zhuǎn)到 GOT[2] 中記錄的地址,GOT[2] 包含的是動(dòng)態(tài)鏈接器的入口地址父虑;接下來该酗,動(dòng)態(tài)鏈接器開始綁定符號(hào)。
當(dāng)動(dòng)態(tài)鏈接器完成符號(hào)的綁定后士嚎,GOT 相關(guān)條目的內(nèi)容就會(huì)被更新為 func 的地址呜魄。
當(dāng)程序再次調(diào)用 func 函數(shù)時(shí),就無需再次進(jìn)行符號(hào)綁定了莱衩,只需要根據(jù) GOT 條目所記錄的地址來調(diào)用函數(shù)即可爵嗅。
PIC 補(bǔ)充
那么有些同學(xué)可能會(huì)想,主程序自身的符號(hào)也要這樣處理嗎膳殷?答案是否定的操骡。對(duì)同一目標(biāo)模塊的符號(hào)引用是不需要特殊處理的九火,結(jié)合 PC 以及偏移量即可,但是引用定義在動(dòng)態(tài)庫中的符號(hào)就不同了册招,需要上述特殊處理岔激。
如果是動(dòng)態(tài)庫使用自身的符號(hào)呢?對(duì)于 Linux 而言是掰,動(dòng)態(tài)庫使用自身的全局?jǐn)?shù)據(jù)或是函數(shù)虑鼎,也會(huì)生成相應(yīng)的 GOT 以及 PLT 條目。這是為了避免因 Linux 使用 flat namespace 而帶來的全局符號(hào)介入的問題键痛。當(dāng)動(dòng)態(tài)鏈接器將動(dòng)態(tài)庫加載到程序中時(shí)炫彩,會(huì)將它們的符號(hào)放在全局符號(hào)表中(Global Symbol Table),這肯定會(huì)導(dǎo)致符號(hào)沖突絮短,而 Linux 的動(dòng)態(tài)鏈接器會(huì)這樣處理:當(dāng)一個(gè)符號(hào)被放入全局符號(hào)表時(shí)江兢,如果和它同名的符號(hào)已經(jīng)存在,那么后加入的符號(hào)會(huì)被忽略丁频。
所以假設(shè)動(dòng)態(tài)庫中有 a杉允、b 兩個(gè)函數(shù),b 函數(shù)內(nèi)部調(diào)用了 a 函數(shù)席里,且 a 函數(shù)又被其他模塊的重名函數(shù)覆蓋掉叔磷。那么如果采用相對(duì)地址調(diào)用,就需要對(duì)其進(jìn)行重定位奖磁,就需要修改動(dòng)態(tài)庫的 text 段改基,所以編譯器只能為 a 函數(shù)生成 PLT 條目,避免動(dòng)態(tài)庫的代碼段受到影響咖为。
那么定義在動(dòng)態(tài)庫中的全局?jǐn)?shù)據(jù)呢秕狰?假設(shè)可執(zhí)行文件中有如下代碼:
extern int c;
int d() {
c++;
}
編譯器在編譯的時(shí)候并不知道 c 是在同一目標(biāo)模塊的其他文件中還是在動(dòng)態(tài)庫中,所以會(huì)為其在可執(zhí)行程序中創(chuàng)建一個(gè)副本案疲,假如程序所鏈接的動(dòng)態(tài)庫也定義了 int c封恰,就會(huì)出現(xiàn)問題。解決的辦法就是將所有對(duì)該符號(hào)的引用都指向可執(zhí)行程序的那個(gè)副本褐啡,在動(dòng)態(tài)庫中,就是為其生成 GOT 條目:如果在可執(zhí)行程序中有副本鳖昌,就將 GOT 條目指向該副本备畦,否則就指向動(dòng)態(tài)庫內(nèi)部的符號(hào)。
當(dāng)然你也可以將全局變量或是函數(shù)加上 static 關(guān)鍵字來解決問題许昨,但是這樣做會(huì)妨礙動(dòng)態(tài)庫內(nèi)部使用該符號(hào)懂盐,通常的做法是將相應(yīng)符號(hào)的 visibility 設(shè)置為 hidden,不讓其成為導(dǎo)出符號(hào)糕档,只允許它在動(dòng)態(tài)庫內(nèi)部使用莉恼,從而解決問題拌喉。具體的做法可以參考此文章。
但是在 Mac 平臺(tái)上俐银,事情卻不太一樣尿背。在 macOS 10.1 之后,默認(rèn)使用 two-level namespace捶惜,也就是說引用符號(hào)的同時(shí)還要指出包含該符號(hào)的庫的名稱田藐,這有以下好處:
提高符號(hào)解析效率。鏈接器明確知道該去哪個(gè)庫中搜索符號(hào)吱七,而不是像 flat namespace 那樣去搜索所有的庫汽久。
避免符號(hào)沖突。
因?yàn)?two-level namespace 的存在踊餐,即便動(dòng)態(tài)庫使用了自身的全局?jǐn)?shù)據(jù)或是函數(shù)景醇,在 Mac 平臺(tái)上編譯后也是采用相對(duì)地址調(diào)用,不會(huì)生成 GOT 或是 PLT 條目吝岭。程序在靜態(tài)鏈接的時(shí)候啡直,也必須指明所有引用符號(hào)所對(duì)應(yīng)的庫文件,編譯過程結(jié)束后苍碟,引用符號(hào)所對(duì)應(yīng)的庫信息會(huì)被記錄在符號(hào)表中酒觅。如果是 macOS 10.3 之后,你也可以使用 -undefined dynamic_lookup 選項(xiàng)微峰,將相關(guān)工作交給動(dòng)態(tài)鏈接器去做舷丹。
當(dāng)然有些特殊情況需要使用 flat namespce,這時(shí)你需要手動(dòng)加上 -flat_namespace 編譯選項(xiàng)蜓肆,如果存在未被解析的引用符號(hào)颜凯,那么你還要加上 -undefined suppress 選項(xiàng)。更多信息請(qǐng)參考此篇文檔仗扬。
iOS 系統(tǒng)中的延遲綁定
咳咳症概,說了這么一大推,終于進(jìn)入本文的主題了早芭,那就是講解在 iOS 系統(tǒng)上是如何進(jìn)行延遲綁定的彼城。雖然前面介紹過 Linux 上的延遲綁定,然而在 iOS 系統(tǒng)中退个,延遲綁定的過程略有不同募壕。
在 Xcode 中新建一個(gè) iOS 工程,寫下代碼來探究 NSlog 是如何被綁定的:
在 iOS 系統(tǒng)中语盈,當(dāng)程序調(diào)用動(dòng)態(tài)庫的函數(shù)時(shí)舱馅,它實(shí)際上是執(zhí)行__TEXT
段的 __stubs
節(jié)的代碼,下圖紅筆圈出的部分便是用來調(diào)用 NSLog 函數(shù)的 stub(你也可以將其理解成 Linux 中的 PLT)刀荒,它的地址是 0x100006bc0代嗤。
外部函數(shù)的地址放在 __DATA
段的__la_symbol_ptr
中棘钞,而__stub
的作用便是找到相應(yīng)的 __la_symbol_ptr
,并跳轉(zhuǎn)到它所包含的地址干毅。此處指向 NSLog 函數(shù)的 __la_symbol_ptr
的地址是 0x100008010宜猜,它所記錄的地址為 0x100006c50。
當(dāng)我們第一次使用 NSLog 時(shí)溶锭,__la_symbol_ptr
尚未記錄 NSLog 的地址宝恶,而是指向 __TEXT
段的__stub_helper
節(jié)中的相關(guān)內(nèi)容(0x100006c50):
在 __stub_helper
節(jié)中,它將綁定過程所需的參數(shù)放到 w16 中趴捅,之后跳轉(zhuǎn)到 0x100006c38 處垫毙,也就是 __stub_helper
節(jié)的首部,然后調(diào)用 dyld_stub_binder(動(dòng)態(tài)鏈接器的入口) 進(jìn)行符號(hào)綁定拱绑,最后會(huì)將 NSLog 的地址放到 __la_symbol_ptr
處综芥。
整個(gè)過程和 Linux 大體一致,但是細(xì)致觀察后發(fā)現(xiàn) w16 實(shí)際上是存放一個(gè) long 值猎拨,這個(gè) long 究竟代表什么呢膀藐,為什么動(dòng)態(tài)鏈接器可以利用它來綁定符號(hào)?實(shí)際上它是相對(duì)于 __LINKEDIT
段中 Lazy Binding Info 的偏移量:
動(dòng)態(tài)鏈接器根據(jù)這個(gè)偏移量便可以從 Lazy Binding Info 中找到綁定信息: 到 Foundation 庫中尋找 NSLog红省。我們可以通過調(diào)試來驗(yàn)證以上內(nèi)容:
運(yùn)行程序到 NSLog(@"123");
處额各,我們可以看到程序?qū)嶋H上是跳轉(zhuǎn)到 0x1000a6bc0 處的 __stub
代碼,反匯編如下:
但是等等吧恃,我們之前在 MachOView 中觀察到程序此時(shí)應(yīng)該跳轉(zhuǎn)到 0x100006bc0 處虾啦,但是為什么在這里卻是 0x1000a6bc0 呢? 這是因?yàn)榈刂房臻g布局隨機(jī)化的影響,通過計(jì)算 0x1000a6bc0 - 0x100006bc0
可以得到程序的偏移量為 0xa0000痕寓,我們繼續(xù)往下看傲醉。
由于 0x1000a6bc0 處的匯編代碼是 nop,所以程序不做任何動(dòng)作呻率,繼續(xù)下一條指令的執(zhí)行硬毕,也就是 ldr x16, #0x144c
,它是將當(dāng)前指令的地址 0x1000a6bc4 與立即數(shù) 0x144c 相加并將結(jié)果 0x1000a8010 放到 x16 中礼仗。結(jié)合上面計(jì)算得到的程序的偏移量以及 MachOView 查看的結(jié)果吐咳,0x1000a8010 正是存放 NSLog 函數(shù)地址的指針,而下一條指令 br x16
則是跳轉(zhuǎn)到指針記錄的地址藐守,即 0x1000a6c50挪丢。那它會(huì)是我們要找的 NSLog 嗎?因?yàn)槭堑谝淮握{(diào)用卢厂,所以肯定不是 NSLog,但是憑著嚴(yán)謹(jǐn)?shù)膽B(tài)度我們還是要驗(yàn)證一下惠啄,反匯編如下:
和我們用 MachOView 看到的代碼一模一樣慎恒!程序?qū)⑵屏糠诺?w16 中(此處的偏移量是0任内,也就是 Lazy Binding Info 的頭條),然后執(zhí)行解析例程融柬!而當(dāng)程序執(zhí)行 NSLog(@"123");
后死嗦,再次調(diào)用 NSLog(@"456");
時(shí),__la_symbol_ptr
記錄的地址早已經(jīng)變成 NSLog 的地址 0x0000000183d12598:
對(duì) 0x0000000183d12598 反匯編如下:
就是我們要找的 NSLog AQ酢T匠!
其他
上面介紹了 iOS 延遲綁定的機(jī)制外盯,因?yàn)樗婕暗匠绦蜴溄臃椒矫婷娴闹R(shí)摘盆,所以花了很大的篇幅來介紹相關(guān)背景,避免直接拋出結(jié)論導(dǎo)致大家一頭霧水饱苟。iOS 上用的動(dòng)態(tài)鏈接器是 dyld孩擂,它的原理是復(fù)雜而又多變的,想要了解的同學(xué)可以去讀它的源碼箱熬。