動態鏈接庫的實現原理是什麼?

大家好,我是小風哥,今天簡單聊聊動態鏈接庫的實現原理。

假設有這樣兩段代碼,第一段代碼定義了一個全量變量 a 以及函數 foo,函數 foo 中引用了下一段代碼中定義的全局變量 b。

第二段代碼定義了全局變量 b 以及 main 函數,同時在 main 函數中調用了第一個模塊中定義的函數 foo。

接下來編譯器出場,編譯器會把這個兩個源文件編譯成對應的目標文件。

目標文件中主要有兩部分,代碼段和數據段,這兩部分裏面分別包含什麼內容呢?

我們定義的全局變量會被放到數據段,代碼被編譯生成的二進制指令會被放到代碼段,第二個目標文件也一樣。

注意看第一段代碼,這裏引用了一個其它模塊定義的全局變量 b,這一信息記錄在第一個目標文件,第二段代碼引用了其它模塊定義的函數 foo,這一信息記錄在第二個目標文件。

注意看第一段代碼,這裏定一個全局變量 a 和函數 foo,我們記錄下來,第二段代碼定義了全局變量 b 和函數 main,同樣記錄下來。

接着我們開始一個叫做連連看的遊戲。

第一個模塊引用了變量 b,變量 b 的定義可以在第二個模塊找到。

第二個模塊引用了函數 foo,foo 的定義可以在第一個模塊找到。

這個過程叫做符號解析。

這裏看到的引用以及定義的符號保存在所謂的符號表中。

而如果第二個模塊引用了一個叫做 bar 的變量,鏈接器翻遍所有其它模塊都沒找到 bar 這個符號的定義,而只找到了一個叫做 foo 的定義,這時鏈接器就會報一個叫做符合未定義的錯誤,這個錯誤寫 c/c++ 的程序員一定不陌生。

接下來鏈接器會把數據段合併到一起,代碼段合併到一起並確定符號的內存地址,這個過程叫做重定位。

瞭解了這些就可以開始講動態庫的實現原理了,動態庫又叫做共享庫,我們的問題是,動態庫是怎麼實現可以被程序之間共享的呢?

假設現在有兩個運行的程序和一個動態庫 liba. so,動態庫中定了一個全局變量 a,第一個程序把變量 a 修改爲了 10。

然後第二個程序開始運行,第二個程序也使用該動態庫,然後把全局變量 a 修改爲了 20。

這是第一個程序運行一段時間後決定打印變量 a,這時你會驚訝的發現變量 a 從 10 變成了 20,但是爲什麼。

原因就是這兩個程序共享了同一個數據段,所以一個程序對數據的修改對另一人程序是可見的,因此動態庫中的數據段不能共享,每個程序需要有自己的數據段。

現在數據的問題解決了,我們來看函數。

假設動態庫 liba.so 需要引用外部定義的 foo 函數,由於程序 1 和程序 2 都使用了該動態庫,因此必須定義出 foo 函數。

我們知道函數調用最終會被編譯器翻譯成 call 機器指令後跟函數地址。

接下來我們需要解析出 foo 函數的地址到底是什麼,這就是剛纔我們提到的重定位,只不過動態庫將這一過程推遲到了運行時。

由於程序 1 的 foo 函數位於內存地址 0x123 這個位置,因此鏈接器將 call 指令後的地址修正爲 0x123。

這時 CPU 執行這條 call 指令就能正確的跳轉到第一個程序的 foo 函數。

而第二個程序的 foo 函數爲內存地址 0x456 這個位置,接下來第二個程序開始運行,CPU 開始執行 foo 函數,由於第二個程序的 foo 函數在 0x456,因此我們希望 CPU 能跳轉到這裏,但由於動態庫中 call 指令後跟的是 0x123 這個內存地址,因此 CPU 執行 foo 函數時依然會跳轉到第一個程序的 foo 函數。

這時系統就出現了錯誤。

問題出在了哪裏呢?

主要是 call 這條機器指令,這條指令後跟了一個絕對的內存地址,而不要忘了,這條指令或者說動態庫是要被各個程序共享的,顯然我們不能直接使用絕對地址。

該怎麼辦呢?

計算機中所有問題都可以通過增加一箇中間層來解決。

這樣我們就摒棄了直接調用,而採用間接調用。

而我們這裏對函數的討論對於全局變量的應用也是一樣的道理,全局變量的使用也存在同樣的問題,只不過是從函數調用變成了內存讀寫,解決問題的方法一樣,我們從直接應用改爲間接引用。

接下來我們依然以函數調用爲例來講解。

那麼這個中間層到底是什麼呢?

答案就是 got。

還記得剛纔提到的每個程序都有自己的數據區嗎,這個 got 段就屬於數據區的一部分。

got 中有什麼呢?got 中記錄了引用的全局變量或者函數的地址,在程序運行時鏈接器會找到 foo 的內存地址,然後填到 got 表中,這樣通過查 got 表我們就能知道函數 foo 的內存地址了。

接下來的問題就是當 CPU 調用 foo 函數時怎麼才能知道 got 表在哪裏呢?

注意剛提到每個程序都有自己的數據區,實際上對於動態庫來說也有自己的代碼區。

我們現在只需要知道每個程序運行在自己的地址空間中,這些地址空間最終會被映射到真正的物理內存,動態庫中的數據區會被映射到不同的內存區域,但代碼段會被映射到同一段物理內存中,從而實現共享的目的。

接下來我們重點看進程地址空間中的動態庫佈局。

注意看,動態庫的數據區和代碼區總是相鄰的,也就是代碼區和 got 段的相對位置總是不變的,而不管動態庫被放到了哪個位置。

多個程序也一樣,也就是代碼區和數據區的相對位置總是固定的,這個相對位置在編譯時編譯器就能確定。

現在 foo 會被編譯成 call 指令,而程序在加載時鏈接器會向 got 段中寫入 foo 的內存地址,顯然兩個程序的 foo 地址是不一樣的。

接下來 CPU 開始執行第一個程序的 call 指令,此時 CPU 會做一個相對跳轉,這個跳轉距離是編譯器確定的,CPU 會跳轉到 got 表,然後查找 foo 的地址發現是 0x123,然後開始執行 0x123 這個位置的函數。

而如果 CPU 執行第二個程序中的 foo 函數,那麼 CPU 同樣會進行相對跳轉,這不過這次跳轉到的是第二個程序的 got 表,然後發現 foo 的地址是 0x456,然後開始執行第二個程序中的 foo 函數。

這樣我們就實現了執行同一個指令但卻會跳轉到不同地址的目的,從而在不改動動態庫代碼的前提先實現共享。

而如果一個動態庫中引用了很多外部函數會怎麼樣呢?

這樣程序在啓動時鏈接器不得不對所有函數進行重定位,因此會拖慢程序啓動速度。

而我們知道一個程序中不是所有的函數都會被調用到,經常調用的都是少數幾個函數,爲了利用這一點編譯鏈接系統使用 procedure linkage table, plt 來推遲重定位這個過程,也就是程序在啓動時不進行函數重定位,而是推遲到真正調用函數時,沒用調用過的函數根本就不進行重定位,從而加快程序啓動速度。

從這個一過程我們可以看到動態庫的這種間接調用實際上會對程序性能有一定影響,但相對於動態庫帶來的好處與便捷,這點影響可以忽略不計。

這樣,不管動態庫被加載到內存的哪個位置都能正確被各個程序共享。

動態庫的這個特性被稱之爲位置無關代碼,簡稱 position-independent code, pic,這就是爲什麼你在編譯生成動態庫時要加上 pic 編譯選項的原因。

希望這篇對大家理解動態庫有幫助。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/_-mRDg06AaiKc4-HZ8PgdQ