鏈接器做什麼

 轉自:http://www.dutor.net/index.php/2012/02/what-linkers-do/

前幾天,在組內分享了關於鏈接器的一些東西,在這裏總結一下。討論的背景主要是基於C/C++,Linux平臺相關。

鏈接器相關的一些基本問題

  學習或者瞭解鏈接器,有一些基本的問題需要關心:鏈接器做些什麼;鏈接器和體系結構;程序是怎樣生成的。下面做簡要介紹。

鏈接器做些什麼

  鏈接器之所以存在或者產生,基本上是由於程序開發的模塊化。這裏講的模塊,主要是編譯概念上的模塊,通常他們按照功能劃分,比如一個.c或者.cpp文件就是一個編譯單元,就是一個模塊,編譯後就產生一個.o目標文件。爲了最終生成一個可執行文件、靜態庫或者動態庫,就需要把各個編譯單元按照特定的約定組合到一起。這裏特定的約定指的就是“目標文件格式”,它定義了目標文件、庫文件和可執行文件的格式,這裏組合這一過程就叫做鏈接。
  一個編譯模塊中,通常是函數的定義和全局數據的定義,數據類型的定義通常在頭文件中,編譯時會被包含在編譯模塊中。函數和數據由符號來標識,一般符號有全局和靜態之分,全局符號可以被其他模塊引用,而靜態符號只能在本模塊中引用。編譯各個模塊時,編譯器會解析該模塊。重要的一項工作就是建立符號表,符號表中包含了本模塊有哪些符號可以被其他模塊引用(導出符號),還包括本模塊引用(導入符號,即未定義符號)、但在其他模塊中定義的符號。每一個符號都關聯一個地址,這個地址指明瞭該符號在本模塊中的偏移地址(通常是一個從0開始的地址)。
  鏈接器在鏈接過程中,會掃描各個模塊的符號表,得到一個“全局符號表”,鏈接器由此決定一個符號在哪裏被定義,在哪裏被引用。並且,將符號引用處替換爲定義處的地址,這一過程就叫做符號解析。
  鏈接器的一項終極目標就是生成可執行文件。通常,可執行文件和普通目標文件的重要區別就是地址空間的使用。主流操作系統中,可執行文件都是基於虛擬地址空間的,即每個可執行文件都有相同且獨立的地址空間,並且文件中各個段(代碼段,數據段,以及進程空間中的堆棧段)都有相似的佈局。而普通目標文件卻使用從零開始的地址空間,這樣一來,模塊M中的符號m就可能和模塊N中的符號n擁有“相同”的地址。在鏈接器鏈接各個模塊時,會從各個模塊中“提取”類型相同的段進行合併,並將合併後的段寫入可執行文件中。這一過程被稱爲存儲空間的分配。值得一提的是,棧、堆以及未初始化的數據這些“運行時”需要的空間不會在可執行文件中佔據磁盤空間,但它們佔用相應的地址空間。
  由於存在上述“合併”過程,前面提到的符號解析就涉及到另外一個過程:重定位。由於各個模塊中的函數/數據地址會被重新排放,那麼對這些符號的引用也必須被相應地調整。這一調整過程被稱作重定位。
  符號解析,存儲空間分配,還有重定位,這三個過程是一個有機的整體,是“同時”進行的,且這三個過程也是模塊化所帶來的必須要解決的問題。

鏈接器和體系結構

  在我們編寫普通應用程序時,不需要過多的關係體系結構的問題,但對於鏈接器學習者/編寫者來說,相當大的工作都必須圍繞體系結構展開,比如目標平臺的ABI,內存地址,指令格式,尋址方式等等,下面做大致介紹。
  一個體繫結構的ABI,即二進制程序接口,主要包括硬件和操作系統兩個層面。內存字長,對齊方式,子程序調用時的約定(參數傳遞方式,返回值傳遞方式),系統調用如何進行,目標文件格式等等,都屬於ABI的範疇,都是鏈接器工作時要密切關注的問題。
  另外,多字節數據的字節序也是鏈接器需要考慮的。字節序是關於如何使用線性的連續字節來表示多字節數據的問題,有Little-endian和Big-endian兩種字節序。小端序是指將多字節數據的低權重字節放在內存的低地址處,大端序則正好相反。直觀講,從內存的低地址向高地址方向看,先看到多字節數據的低權重字節的就是小端序,否則就是大端序。至於爲什麼會存在兩種字節序,兩者有何優劣,我覺得這只是個“個人喜好”問題,就好象剝雞蛋先磕破哪一頭,蹲廁所時臉裏還是臉朝外一樣,事實上,直到前些天,我才親眼看到,真的是有“茅房拉屎臉朝內”的人的。
  指令格式和尋址方式也會影響鏈接器的工作,因爲在符號重定位的時候,鏈接器需要修改指令中操作數部分,所以需要知道每種指令的指令格式及尋址方式,以便對指令做出適當的調整。

程序是怎樣生成的

  事情漸漸明瞭了,編譯器前端對語言進行詞法、語法分析,建立語法樹,編譯器後端在語法樹的基礎上針對特定的平臺生成指令,並按照特定的格式輸出到磁盤中的目標文件。鏈接器按照前面所說的過程生成最終的可執行文件或程序庫。(這裏的程序庫特指動態庫,因爲靜態庫只是目標文件的簡單集合,理論上不需要鏈接器的參與)本文只針對鏈接器進行簡單的討論,關於編譯器的功能和相關原理,有大量的資料可以參考。

目標文件格式

  目標文件格式是指令、數據在磁盤中存儲形式的一種約定。它描述了指令、數據的存儲格式和佈局,並且針對不同類型的文件(普通目標文件,可執行文件,動態庫)有不同描述側重點。另外它還描述了一些供外部程序使用的元信息,例如普通目標文件中的符號表的內容和組織形式,待重定位符號的信息;可執行文件中各個段的信息,程序的入口點(程序從何處開始執行),哪些符號需要在運行時解析,這些符號包含在哪些動態庫中,以及解析這些庫需要哪種動態鏈接器等等;動態庫爲了實現同時被多個進程鏈接需要什麼樣的組織形式,本身又引用了其他動態庫的哪些符號等等。通常,不同的平臺都會有自己的目標文件的格式標準:

  • COM:DOS最初採用的一種非常簡單的格式,除了指令、數據之外,基本不包含其他信息。
  • PE:Windows當前採用的目標格式,繼承自COFF,是一種主流的現代目標格式,相比COM有更強大的功能支持。
  • a.out:最初UNIX平臺採用的目標格式,簡單且功能強大,但對於C++這樣的高級語言支持不足。
  • ELF:當前Linux/Unix平臺採用的主流格式,繼承自a.out,且對高級語言支持很友好。

  除了ABI,目標格式的不同,也是在一種操作系統下編譯的程序無法在另一種操作系統中執行的原因。

程序庫

  終於到庫了,研究庫很有趣,也相當實用。概念上,庫可以分爲靜態庫,動態庫,且這兩種庫都可以實現爲“共享庫”,但在實現上,靜態共享庫由於需要考慮態度的問題、實現太過複雜且得不償失,現實中很少有這種類型的庫。所以,應用中只存在兩種庫:靜態庫和動態共享庫,下面分別做簡要介紹,關於在Linux中如何創建這兩種庫,可以參考我之前寫的一篇博客,或者其他更詳盡更優秀的資料。

靜態庫

  在功能特性上,靜態庫是指這樣一種庫,在鏈接時,其中被引用的代碼、數據被“複製”到引用該庫的程序中。在格式上,靜態庫十分簡單,他是普通目標文件的集合,是一種簡單的拼接。事實上,Linux平臺下靜態庫.a文件使用獨立的歸檔工具ar建立,爲了使鏈接器能夠有效地查找庫中包含的各個目標文件以及符號,經常還需要一個叫做ranlib的工具在.a文件中建立索引。
  在鏈接時,鏈接器想普通目標文件一樣使用靜態庫,僅僅多了在庫中查找符號及對應目標文件的過程。

動態鏈接庫

  動態鏈接庫和靜態庫差異較大,Linux平臺,它由ELF格式直接支持。但由於共享庫的特殊性,它需要一些特殊特性的支持:
  PIC(Position Independent Code),位置無關代碼。動態共享庫需要在運行時動態地加載到內存,爲了在各個進程中調用共享庫中的代碼和數據,就需要將該庫映射到不同進程的進程空間。由於各個進程的地址空間使用情況不盡相同,很難將共享庫映射到各進程相同的位置。這樣一來,就對共享庫的代碼提出了挑戰,它需要能夠在不同的地址區間上都正確的執行。位置無關代碼就是因此而提出的。
  位置無關代碼的基本思想是這樣的:將共享中對絕對地址的引用轉化爲相對地址的形式。對於函數調用,實現起來很簡單,因爲代碼是隻讀的,指令間的相對地址也是固定的,只需要將函數調用轉化會相對地址即可。但對於數據的引用就複雜很多了,由於各個進程都需要訪問共享庫中的數據,而這些進程通常是毫無關聯的,一個進程對共享庫數據的修改不應該被其他進程看到。一種好的方法就是讓各個進程都擁有自己的一份數據拷貝。但這又引出一個問題,共享庫是被動態映射的,數據空間也只能在映射時才需要分配,那麼在共享庫代碼中如何引用這些數據,以達到不同進程使用相同的代碼訪問不同的數據呢?
  於是另外一種結構被引入了,即GOT(Global Offset Table),全局偏移量表。它的基本思想也是相對地址引用。在共享庫的數據段加入GOT,GOT的表項保存各個數據的地址。由於共享庫中指令和數據段的相對地址在鏈接後是固定的,這樣在指令中就可以使用相對地址來找到GOT的起始地址,然後根據各個數據在GOT的偏移量來找到其對應的地址。而該地址是在共享庫被映射到進程空間時,由動態鏈接器在相應的進程空間中分配並設置的。
  接下來的問題就是,進程中如何調用共享庫中的代碼呢?ELF使用一種延遲加載的方法,即當進程調用共享庫中一個函數時,才解析該函數的地址,且只有第一次才解析,第一次解析之後的調用就不會被再次解析,而是將之前解析到的地址保存下來。這裏又引入一種機制,叫做PLT(Procedure Linkage Table),它和GOT一起(使用共享庫的進程也有一個GOT)引入了一個函數調用的間接層。
  類似共享庫的GOT,進程的GOT表項保存了本進程引用的共享庫中的函數地址,但在第一次對該函數的調用之前,該表項保存的並不是函數的地址,而是指向PLT中一個指令的地址。爲了方便說明問題,假設進程中main函數引用了libmath.so中的函數add,那麼PLT大致是這個樣子的,

.plt0:
   0x080483d0:	pushl  0x8049ff8
   0x080483d6:	jmp    *0x8049ffc
...
add@plt:
   0x080483e0 <+0>:	jmp    *0x804a000
   0x080483e6 <+6>:	push   $0x0
   0x080483eb <+11>:	jmp    0x80483d0
...
main:
...
   0x080484ec <+24>:	call   0x80483e0 <add@plt> # 對add函數的調用
...

第十二行對add函數的調用跳轉到第五行,這是一條jmp指令,0x804a000是它的地址操作數,該地址即是add在GOT中的地址項,最初該地址處保存的地址是0x080483e6,即jmp指令的下一條push指令。於是最初jmp指令執行後沒有任何效果,直接執行下一條指令。push指令將add在重定位項的索引入棧,通過該重定位項可以得到add符號本身(即字符串add)。然後又是一條jmp指令它跳轉到第二行,又是一個push指令,接下來又是jmp指令,這個jmp指令也使用了GOT中的一個表項,該表項存儲的是動態鏈接器(ld.so)的加載/解析函數。在解析函數中,查找add符號在共享庫中的地址,將該地址填入add對應的GOT表項,然後跳轉至add函數開始執行。到下一次調用add函數,第五行的jmp指令就直接跳轉到add函數了。
  動態鏈接基本就是這個過程了。在這個過程中有許多有意思的指令,將棧玩弄於鼓掌,像變魔術一樣,有興趣的話可以使用gdb等相關工具調試一下。

一些工具

  玩弄二進制,很多實用工具是離不了的,最重要的就是GNU Binutils二進制工具鏈了。包括查看ELF文件信息的readelf,對目標文件、可執行文件、共享庫、core內存轉儲文件等反彙編的objdump,重量級的調試器gdb,查看共享庫使用情況的ldd等等。

一些參考資料

  瞭解鏈接器工作原理,現成的資料並不多,《Linkers and Loaders》算是經典了,中文版也可以買得到,翻譯得還算中規中矩。另外,《程序員的自我修養:鏈接、裝載與庫》寫的很淺顯,不錯的一本書。
  爲了更好的理解鏈接器,必須對ELF的細節有所瞭解,Executable and Linkable Format這份文檔可以參考。
  研究二進制,彙編知識是必須的,如果是Linux平臺,瞭解些GNU 彙編(AT&T彙編)是再好不過了,不過講解GNU彙編的資料更是少上加少,布魯姆的《彙編語言程序設計》雖然內容不多,但對非專業彙編程序員也足夠用了,這本書是有英文電子版的。
  有了相關工具和入門資料,剩下的就是折騰了。
  最後,附上之前做的幻燈片,就像這篇文章一樣,臭長,枯燥。


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