Linux動態共享對象(動態鏈接庫)裝載過程

之前開發項目時,曾遇到一個問題:模塊中用到的某so文件與模塊某源碼文件中存在同名函數(在鏈接器linker來看,是同名符號)時,so文件中同名函數被“覆蓋”,從而導致模塊啓動時崩潰。當時曾專門做過實驗,得到了避免出問題的一些小技巧(參見之前的某篇筆記 ),但其實對引發問題的底層原因並特別不清楚(當時由於這類問題對應的術語及其英文關鍵詞一無所知,導致google不出乾貨)。最近,閱讀《 程序員的自我修養:鏈接、裝載與庫 》一書,才明白這類問題背後的機理,作爲筆記,記錄於此。

1)共享對象

        關於dynamic shared object的介紹性內容(例如其基本概念、優缺點、基本使用方法等),可以google出很多經典資料,例如 wikipedia在說明library topic時介紹的,這裏不再贅述。

2)基本思想

        Linux共享對象實現動態鏈接的基本思想是:把程序按照模塊拆分成各個相對獨立部分,在程序運行時(確切地說,是正式運行前的裝載階段)纔將它們鏈接在一起形成完整的程序,而不是像靜態鏈接那樣事先把所有的程序模塊都鏈接成一個單獨的可執行文件。

3)裝載過程及涉及的問題

        當程序被裝載時,系統的動態鏈接器會將程序所需的所有動態鏈接庫(例如最基本的libc.so)裝載到進程的地址空間,且將程序中所有爲決議的符號綁定到相應的動態鏈接庫中,並進行重定位工作(術語叫 裝載時重定位 - load time relocation,在windows中,又叫 基址重置 -rebasing ,區別於靜態鏈接的 鏈接時重定位 -link time relocation)。也即,動態鏈接是把鏈接過程從本來的程序裝載前推遲到裝載時。共享對象的最終裝載地址在編譯時是不確定的,而是在裝載時,裝載器根據當前地址空間的空閒情況,動態分配一塊足夠大小的虛擬地址空間給相應的共享對象。        

        裝載時重定位的問題: so文件被load並映射至虛擬空間後,指令部分通常是多個進程間共享的,通常的裝載時重定位是通過修改指令實現的(主要是根據情況修改指令中涉及到的地址),所以無法做到同一份指令被多個進程共享(因爲指令被重定位後對每個進程來講是不同的)。這樣一來,就失去了動態鏈接節省memory的一大優勢。

         爲解決此問題,引入了 地址無關代碼 (PIC,Position-independent Code,詳細概念見 wikipedia )的技術, 基本思路 是把指令中那些需要被修改的部分分離出來,跟數據部分放到一起,這樣,剩下的指令就可以保持不變,而數據部分在每個進程中擁有一個副本。ELF針對各種可能的訪問類型(模塊內部指令調用、模塊內部數據訪問、模塊間指令調用、模塊間數據訪問),實現了對應地址引用方式,從而實現了PIC。

        對應到實際應用中,我們可以在編譯時指定-fPIC參數讓gcc產生地址無關碼。

4)延遲綁定

        影響動態鏈接性能的兩個主要原因:

       a. 與靜態鏈接相比,動態鏈接對全局和靜態的數據訪問都要進行GOT(Global Offset Table,實現PIC時引入的具體技術)定位,然後間接尋址;對於模塊間的調用也要先定位GOT,然後間接跳轉,如此,程序的運行速度就會減慢

       b. 程序裝載時,動態鏈接器要進行一次鏈接工作,即查找並裝載所需的共享對象,然後進行符號查找、地址重定位等工作,這會減慢程序啓動速度

       一方面,程序模塊往往包含了大量的函數調用,從而導致動態鏈接器在模塊間函數引用的符號查找及重定位方面耗費時間;另一方面很多函數並不會在程序運行初期就用到(尤其是有些異常處理函數),由此,EFL採用 延遲綁定 (lazy binding)來對動態鏈接做優化,其 基本思想 是當函數第一次被調用時才進行綁定(符號查找、重定位等),若沒有被調用則不進行綁定。這個思路可以大大加快程序啓動速度,對於有大量函數引用的程序啓動時,尤爲明顯。具體到實現,EFL採用 PLT (procedure linkage table)來實現,具體過程很是精妙複雜,本文只是拋磚引玉,不再詳述,有興趣的同學可以用PLT英文關鍵字google相關資料。

5)動態鏈接的步驟和實現

       動態鏈接步驟基本分爲3步:

        a. 動態鏈接器自舉

        linux系統的動態鏈接器(通常爲/lib/ld-linux.so)本身也是個共享對象。對於普通共享文件,其relocation由動態鏈接器來完成,若其依賴其它共享對象,則這些被依賴的共享對象由動態鏈接器負責鏈接和裝載。而對於動態鏈接器,雖然是共享對象,但具有某些 特殊性: 首先,其本身不能依賴其它任何共享對象;其次,其本身所需的全局和靜態變量的relocation由它本身來完成,這種啓動代碼的方式往往被稱爲 自舉(bootstrap, 術語描述可參照此處 )

        b. 裝載共享對象

        完成基本自舉後,動態鏈接器將可執行文件和鏈接器本身的符號表合併爲 全局符號表 (global symbol table),然後鏈接器尋找可執行文件依賴的共享對象。由此,鏈接器可以列出可執行文件所需的所有共享對象並將其名字放入裝載集合中,此後,鏈接器遍歷該集合,根據每個共享對象的名字找到對應文件後打開,讀取相應的ELF header和.dynamic段,然後將它對應的代碼段和數據段映射到進程空間。如果把依賴關係看着圖的話,這個裝載過程就是圖的遍歷過程,可用深度優先或廣度優先來遍歷,這取決於鏈接器的實現,比較常見的算法一般是廣度優先。

        裝載過程中,當新的共享對象被load後,它的symbol table會被合併到全局符號表中,當所有被依賴的共享對象都裝載完畢後,全局符號表中將包含進程中所有的動態鏈接所需的符號。

        上述裝載過程中,若2個不同模塊定義了同一個符號,會產生一個 符號的優先級問題: 在全局符號表中,最終存放的 到底 是哪個模塊中定義的符號?

        這種一個共享對象中的全局符號被另一個共享對象的同名全局符號覆蓋的現象又被稱爲共享對象 全局符合介入 (global symbol interpose)。對於這個問題,linux的動態鏈接器是這樣處理的: 它定義了一個規則,當一個符號需要被併入全局符號表時,若相同的符號名已存在,則後加入的符號被忽略。 這種先裝入的符號優先的優先級方式成爲 裝載序列 (load ordering)。

       WARNING: 由於存在這種重名符號被直接忽略的問題,當程序使用大量共享對象時應特別注意符號的重名問題,若兩個符號重名又執行不同功能,則程序運行時可能會將所有該符號名的引用解析到第一個被加入全局符號表的使用該符號名的符號,從而導致程序出現莫名其妙的錯誤!

        c. 重定位和初始化

       完成上面兩步後,鏈接器開始重新遍歷可執行文件和每個共享對象的重定位表,將它們的GOT/PLT中的每個需要重定位的位置進行修正(由於此時動態鏈接器已經建立起了進程的全局符號表,故修正過程也顯得比較容易)。

       重定位完成後,若某共享對象有.init段,則動態鏈接器會執行該段中的代碼,用以實現共享對象特有的初始化過程。相應地,共享對象可能還有.finit段,該段在進程退出時執行。

       當完成了重定位和初始化後,所有準備工作完成,進程所需的共享對象都裝載完畢且完成鏈接,這時動態鏈接器就如釋重負,將進程控制權轉交給程序入庫並開始執行程序。

6)顯式動態鏈接

       主要通過4個api完成:dlopen、dlsym、dlerror、dlclose,這些api比較簡單,具體用法man即可搞定,本文不再詳述。

       這裏需要引起注意的還是 符號優先級問題: 當進程有模塊是由dlopen()顯式裝入的,這些後裝入模塊的符號可能會與之前已裝入模塊間有重名符號, 此時,符號衝突如何解決?

       實際上,不論是之前由動態鏈接器裝入還是之後由dlopen()裝入的共享對象,動態鏈接器在進行符號解析及重定位時,都是採用裝載序列的原則,即先裝入的符號優先。

        那麼,當使用dlsym()進行符號的地址查找時,這個函數是不是也按照裝載序列的優先級進行符號查找呢?實際情況是, dlsym()對符號的查找優先級分兩種類型:

      a. 若在全局符號表中進行符號查找,即dlopen()時,第1個參數filename傳入NULL,那麼由於全局符號表使用裝載序列,此時dlsym()也採用裝載序列。

        b. 若對某個由dlopen()打開的共享對象進行符號查找,則採用一種叫做 依賴序列 (dependency ordering)的優先級,它以被dlopen()打開的那個共享對象爲root node,對它所有依賴的共享對象做 廣度優先遍歷 ,直到找到符號爲止。

發佈了8 篇原創文章 · 獲贊 26 · 訪問量 10萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章