|
|
|
內容: |
|
|
|
一、_dl_map_object_deps函數分析二、相對轉移,絕對轉移三、_dl_relocate_object函數分析四、動態鏈接庫函數的解析五、動態鏈接庫的卸載六、前景與展望參考資料關於作者
|
Intel平台下linux中 ELF文件動態鏈接的加載、解析及實例分析(二): 函數解析與卸載
轉載自:IBM developerWorks 中國網站
王瑞川
從事 Linux 開發工作
2003 年 12 月
相信讀者已經看過了Intel平台下Linux中 ELF文件動態鏈接的加載、解析及實例分析(一): 加載的內容了,瞭解了ELF文件被加載的時候所經歷的一般過程。那我們現在就來解決在上一篇文章的最後所提到的那幾個問題,以及那些在dl_open_worker中沒有講解的代碼。
一、_dl_map_object_deps 函數分析
由於源代碼過分的冗長,並且由於效率的考慮,使原本很簡單的代碼變成了一件 TRAMPOLINE 的事情,所以我對它進行了大幅度的改變,不僅刪除了所有不必要的代碼,而且還用偽代碼來展現它最初的設計思想。
|
先説明,其實加載一個動態鏈接庫的依賴動態鏈接庫不是一件簡單的事,因為所有的動態鏈接庫可能還有它自己所依賴的動態鏈接庫,如果採用遞歸簡單方法實現不僅是不可能的-----因為你可以參看第一篇的文章,那裏提到了一個在加載動態鏈接庫中的加鎖問題,而且也是沒有必要的,你並不能保證這樣的動態鏈接庫依賴關係會不會形成一個依賴循環,就像下面的一張圖所顯示的那樣:
這樣最簡單的想法就是我們不重複的加載所有的動態鏈接庫,這裏就用一個單鏈實現-----在原來的程序中也是用這個方法,但那裏用來分配的方法是在棧中直接實現,這樣可以加快程序的運行,但程序可讀性大大減弱了。
23 行就首先就把 lmap 自己加入這個 struct list 中去,在 26 行的 for_each_in_list(add_list,curlmap) 其實是就是把 curlmap=curlmap->next,並判斷它的 curlmap!=NULL,
28 行的 for_every_DT_NEEDED_section(curlmap,needed_dyn)
主要就是 needed_dyn=curlmap->l_info[DT_NEEDED]; 但這裏要注意的是,在一個動態鏈接庫中可能有不只一個,就像在 readelf -a 的例子
|
更確切的是要在 lmap-> l_ld 的 dynamic section 中查找它的 d_tag 為 DT_NEEDED 中
30 行的 get_needed_name 用的方法是這樣的
|
很明顯這裏就會把這個動態鏈接庫映射來完成它的加載,而 35 行是要把 add_list 擴充,這裏只會對同一個動態鏈接庫加載一次,所以不會有前面的循環加載,再回過頭來看 26 行到 37 行之間的那個循環,如果在 35 行中加入了那個沒有重複的動態鏈接庫。那整個循環就可能繼續循環下去。
從 39 行到 51 行之中就把這個函數中已經得到的依賴動態鏈接庫 copy 入 l_searchlist 與 l_initfini 這兩個的重要數組中, 巧妙的是它們採用了一起分配的。最後前面的那個臨時單鏈表。
二、相對轉移,絕對轉移
在學習彙編語言的時候,我們對不同的尋址方式肯定有很深的印象。但對於在彙編語言中同樣重要的轉移指令,只是一筆帶過(用到了call 與 jxx ----------- 這裏的 jxx 是指如 jmp jae jbe 這樣的有條件轉移指令和無條件轉移指令)。然而,如果講到動態鏈接庫的鏈接實現則一定要提到這一內容。
所謂相對轉移,就是這個二進制代碼的中的它是可以在重定位的環境中不經修改,就可以運行的。如下面的情況,
|
變成一般的地址是這樣的
|
這裏旁邊的 719 就是這個 ELF 文件與起始地址相比的偏移量,而在裏面的 e9 e2 fe ff ff 如果寫成看的往後退 0x11e 因為這是 ff ff fe e2(intel 是 little endian 表示方法)所表示的 -0x11e 的數。如果把 719 加上 5 再減去 600 就是這個數了。這便是處理器的相對轉移。
還有另一種轉移方式,就是絕對轉移。
|
這個如果用最簡單的代碼來表示是
|
很明顯,就是把 eip 的內容變成了eax 中的內容,如果用 jmp 也是一樣的
|
上面的兩種轉移方式適應於不同的環境要求,如果是在一個ELF文件中的,採用相對轉移可帶來的好處有以下的幾點:
1、可以不用再訪問一次內存,在指令的執行時間上得到了大大的提高(這在PCI的總線結構中現在主流的最高主頻是133MHZ,而隨便一個INTEL CPU的主頻都能超過它)。
2、可以適應在動態加載與動態定位的內存環境,而不用再對原來的代碼修改便能實現(代碼段也不能在運行的時候修改),因為整個動態鏈接庫或可執行文件都是以連續的地址映射的。
但同樣帶來了幾個問題:
1、這樣的相對轉移沒有辦法在運行的時候準確的轉移到別的動態鏈接庫中的函數地址(因為雖然大部分的動態鏈接庫的加載地址是可以預計的,但從理論上來説是隨機的)。
2、這樣的代碼在平台之間的移植性帶來很大的問題,因為不同的機器沒有辦法知道這樣的數字是代表一個地址,還是代表了一個二進制數。所以在對平台移植有高要求的體系中用的是c++的虛函數指針------相對地址轉移的發展。如COM,corba體系中就是這樣的。
上面的這兩項缺點正好是絕對轉移的優勢。作一個對比,絕對轉移就相當於內存尋址時的立即尋址,而相對轉移相當於內存尋址的相對尋址。
在一般的動態鏈接庫中實際運用更是用了一個聰明的辦法。請看下一段的彙編語言片段:
|
這裏的2f7中的call 2fc <ok+0xc>是什麼意思呢,從我們上面的方法來看,這裏是什麼呢?就是把函數運行到了2fc處,根據是我上面所説的,因為是一個相對轉移。e8 00 00 00 00。如果用一般的觀點看這沒有什麼用處。但妙處就在這裏,2fc處的pop %ebx,是把什麼送到%ebx中呢,如果每一次call 都會把下一條要執行的指令的地址壓入棧中,那%ebx中在這裏的內容就是2d4這一條指令在內存中的地址了,回想動態鏈接庫的絕對地址是沒有辦法在編譯時得到,但這樣卻可以--------很巧妙,不對嗎?
那後面的add $0x10b0,%ebx又是什麼用處?如果我們這裏假定在內存中的地址是2fc,那加上10b0之後的值是0x13ac了,看在這裏是什麼呢?
|
這是一個got節, 它的全稱是global object table 就是全局對象表。它這裏存儲着要轉移的地址。如果在動態鏈接庫中,或是要調用一個在它之外的函數是怎樣實現呢?我們往下看:
|
這裏就要調用一個call 2e0 <ok-0x10>所在的函數。那在0x2e0處又是什麼呢?
|
很明顯,我們前面已經説了%ebx中所保存的就是.got節的起始地址,而這裏就是轉移到在.got起始地址偏移0xc處所存儲的地址量。而0x2e0所在的地址是在.plt(procedure linkage table)的節中。正是plt got的互相配合,才達到了動態鏈接的效果。下面的_dl_relocate_object函數就是在把動態鏈接庫加載之後將got中的內容初始化的作用,作好了以後函數解析的準備。
三、_dl_relocate_object函數分析
舉個例子。同樣來自上面的動態鏈接庫文件中內容。如果我們在這裏面調用了printf這個普通的函數,它的rel在文件中的位置是
|
這個值如果在文件中找到0x13b8(這是相對偏移量)的內容就是
|
由於intel 是little endian 所以這個數翻譯過來是0x02e6,那這裏是什麼呢?
|
這下就會全部明白了吧。它就是壓入0x0(這其實就是我們前面的printf在rel節中的索引數0------它是第一項)。而下面跳到的就是2d0(這是一個相對轉移)處
|
前面已經説過%ebx得到的是got的起始地址,所以這就是壓got[1]入棧,再轉移到got[2]中所包含的地址去,你可以看前面在elf_machine_runtime_setup中的2162行與2167行,它就是這個動態鏈接庫自身的struct link_map*的指針,與_dl_runtime_resolve所在的地址。下面一張圖就可以形象的説明這一點。
如果是第一次的函數調用,它所走的路線就是我在上圖中用紅線標出的,而要是在第二次以後調用,那就是藍線所標明的。原因在前面的代碼中已經給出了。
|
這裏要分兩步來完成,第一步的elf_machine_runtime_setup是把這個動態鏈接庫所代表的數據結構lmap的地址寫入一個在ELF文件中特別地方,而elf_machine_lazy_rel是對所有的要被調用的動態鏈接庫外部的函數重定位的實現。這兩步非常重要,因為如果沒有這兩步,那要實現動態鏈接庫的函數動態解析是不可能的,這個你可以在上面的 相對轉移,絕對轉移 中的論述得到詳細的瞭解。
|
明顯的,那個被寫入的ELF文件中的地址就是它的DT_PLTGOT節中的第二個項目-----第60行的內容。而寫入第一項的內容就是要調動的處理函數的地址,這一點在後面所提到的動態解析中的入口地址。
|
這裏的elf_machine_lazy_rel我只列出了在intel平台下的那種情況,其它的還要特別的內容,在這裏很明顯,我們只是寫把原來的在ELF文件的內容加上一個文件加載的地址,這就是lazy mode,因為動態鏈接庫的函數很可能在整個程序運行中不會被調用--------這一點與虛擬內存管理的原理是一樣的。
四、動態鏈接庫函數的解析
前面的60行的代碼----設定了動態解析的入口地址與給出的在動態鏈接庫中的在達到調用一個外部函數時所有的函數路線,已經到了 _dl_runtime_resolve處
|
從這裏定義的名稱ELF_MACHINE_RUNTIME_TRAMPOLINE,我們就可以看出這個函數不簡單(TRAMPOLINE在英語中是蹦牀的意思,就是要make your brain curving的那種怪怪的東西),後面的代碼也確實説明了這一點。
在前面的.text是下面的代碼是可執行,.globl _dl_runtime_resolve是表明這個函數是全局性的,如果沒有這一項,那我們前面看的got[2]=& _dl_runtime_resolve就不能編譯通過-----編譯器可能找不到它的定義。.type _dl_runtime_resolve, @function是函數説明。 .align 16處便是16字節對齊。
我們知道在前面的調用函數過程中已經壓入了兩個參數(第一個是動態鏈接庫的struct link_map* 指針,另一個是rel的索引值)這裏先保存以前的寄存器值,而到這個時候16(%esp)就是第二個參數,12(%esp)第一個參數,這裏作的原因是下面的fixup的函數以寄存器傳遞參數。
我先不管fixup具體內容是什麼,單就看它結束的內容就很能説明代碼作者的優秀。先pop兩個寄存器的值,而又xchg %eax,(%esp)與棧頂的內容,這有兩個目的,一是恢復了eax的值,另一個作用是棧頂是函數返回的地址,而fixup返回的eax就是我們想找的函數有內存中的地址。這就自然跳到那個地方去了。但如果你認為這就好了,那也錯了,因為你不要忘記我們之前還壓入了兩個參數在棧中。所以用了ret $8,這在intel的指令中表示
|
的組合。(很精彩!!!!!!!)
你還可以參看《程序的鏈接和裝入及Linux下動態鏈接的實現》 網址為 http://www-900.ibm.com/developerWorks/cn/linux/l-dynlink/index.shtml 裏面的有一幅圖正好説明此的ELF_MACHINE_RUNTIME_TRAMPOLINE。
那直接看fixup函數的內容
|
這裏是給出了從一個動態鏈接庫中可重定向的reloc_offset得到要解析函數的名稱,如果用圖示的方式表示就如下圖:
你可能會想:其實還可以用另一種方法,就是把這個reloc sym的st_value直接寫入前面的這個調用重定向函數相對應的got中。這樣解析時的速度會更快。但現實這樣卻可能對整個ELF文件結構體系帶來很大的麻煩。我將對每一點説明:
- 如果是這個reloc sym的地址,那對於一個動態鏈接庫而言,它的加載地址本身就是動態確定的。
- 如果用的是那個Elf32_Sym的st_value地址,那倒是可以與lmap->l_i nfo[DT_STRTAB]一起得到這個sym的name,但如果考慮到在編譯的時候有些函數是隻對本模塊有效,可見的,如在一個文件中定義為 static的函數,則它就是局部可見的,那個時候就不可能是解析為這個函數,而且對c++函數還有更為複雜的情況,這樣就會要求一個字段來表示它的屬性,這就是要有了st_info這個數據成員變量。這也就要有了sym的參與了。
- 光有Elf32_Sym還是不行,因為就重定位而言它本身還有一點信息,就是這一個relocation symbol是在本地解析,還是在另外一個真正意義上的動態鏈接庫內被解析,這一情況主要是發生在幾個文件編寫的模塊中,它們編寫的一些函數就在鏈接的時候被確定了,而另一些則沒有,區分的就是relocation 中的r_info了。
從上面的分析來看,一種規範的設計有許多的考慮因素,如果只單一的考慮,那是不行的,特別是要對多個操作系統與平台統一的規範,不能因為就是考慮效率一條就可以了。
在143行是對前面要重定位的函數實現真正的解析函數到位,這樣在這個函數被再次調用的時候就不用再來一次了,本來這時就對這個 relocation symbol r_info的判斷,現在都已經略去了。
真正的解析在do_lookup中實現了,我這裏還是它的實現偽代碼:
|
100行for_each_search_lmap_in_search_list就是從前面在 _dl_map_object_deps中得到的l_searchlist中取下的它本身的依賴動態鏈接庫,中間查找的方法就如下面那張圖中所顯示的。
上面所表示的就是一個在hash表中symidx偏移處所存的就是下一個偏移所在。最後如果strcmp==0就可以得到了,否則就會返回一個0表示失敗了。
現在我們已經把函數的解析過程分析完畢,有必要作一個小結工作:
- 在調用函數的動態鏈接庫中,它所用的方法是從plt節的代碼執行絕對轉移,而轉移的地址存放在got節中。
- 在被調用函數的動態鏈接庫中(就是函數實現的動態鏈接庫),它的函數在以DT_HASH與DT_SYMTAB, DT_STRTAB組織起來。組織的方式如下面的一張圖,以symtab中的Elf32_Sym中的st_value表示這個可導出的標記在動態鏈接庫中的偏移量,st_name則是在動態鏈接庫strtab中的偏移量。
- 在調用動態鏈接庫與被調用動態鏈接庫的聯繫能過的是Elf32_Rel(對MIPS等的體系結構中是Elf32_Rela),它的r_info體現了這個要導入標記(就是調用方中)的性質,而r_offset則是這個標記在動態鏈接庫中的偏移量。(這個可以看 elf_machine_lazy_rel中的實現)
五、動態鏈接庫的卸載
實際上卸載與加載只是反過程而已,但原來的代碼為了提高效率實現在棧內分配內存,不過這樣倒使原來簡單易懂的變的過於複雜,所以,我這裏作了很大的修改,這裏是偽代碼的實現。
|
這裏的has_removed_list就是記錄整個在這一次dl_close操作中已經被卸載了的動態鏈接庫,主要是為了防止再次卸載已經卸載的動態鏈接庫。其實先開始判斷這是否是已經沒有再依賴它本向的動態鏈接庫了。如果沒有了(減去1,等於0就是了),那才可以繼續去了,接下來不要先把它自己加入這個動態鏈接庫,試着去卸載它所依賴的動態鏈接庫,這些全做完之後就是它本身的各要點,一是它的DT_FINI_ARRAY中的卸載函數,還有就是DT_FINI中的函數,這之完了,便是加載到內存內容的去映射化,213行。再就是對struct link_map申請的內存就是了。
你可以看try_dl_close之後的代碼就能明白這種可能有的深度的遞歸過程。
|
綜合來看,dl_close這個函數如果是最終要卸載整個可執行文件的工作的話,那就要最高層的可執行文件開始,這裏採用對可能有錯綜複雜的依賴關係的動態鏈接庫使用了一個mark_removed與dl_close相結合的方法,在不斷的遞歸調用中,把所有的動態鏈接庫 l_opencount減少到0。最後釋放所有的內存空間。這種情況如果你與linux內核中delet_module的調用相對比,也可以看的更清楚。
六、前景與展望
動態鏈接庫的實現發展到現今已經相當完善,它在理論與實踐方面對於我們學習操作系統和編譯語言提供了一個很好的範例。但是,動態鏈接庫的實現畢竟還是隻能在一個操作系統,一個單機,一種編程語言(如果是c++編程語言,則這一點也滿足不了,因為不同的編譯器可能對function name mangling-----函數名稱混譯也不同),對於現在網絡化的信息產業是不夠的。所以,出現了以這個為目標的二進制實現規範,這就是OMG (object model group )所制定出來的 CORBA,和由 Microsoft 所制定出來的 COM,我可能以後的日子中詳細來探討這些最新發展。
參考資料
[1]glibc-2.3.2 sourcecode 這是我這裏主要的代碼來源,可以在 ftp://ftp.gnu.org 中下載
[2]John R.Levine "Linkers and Loaders" 介紹動態鏈接庫技術的經典 http://linker.iecc.com/
[3] Hongjiu Lu "ELF: From The Programmer's Perspective" 好的ELF編程的參考。在 http://linux4u.jinr.ru/usoft/WWW/www_debian.org/Documentation/elf/elf.html 可以看到
|
關於作者
王瑞川,從事 Linux 開發工作,願與志同道合的人士一起探討, |