引言
鏈接與裝載是一個比較晦澀的話題,大家往往容易陷入復雜的細節中而難以看清問題的本來面目。從本質上講各個系統的編譯、鏈接、裝載過程都是大同小異的,或許可以用一種更抽象的形式來理解這些過程,梳理清楚宏觀的來龍去脈有利于對特定系統進行深入學習。
本文主要根據《程序員的自我修養 —— 鏈接、裝載與庫》和自己的理解總結而來,書的內容是基于 GCC 的,不過筆者盡量以更抽象、更簡潔的方式把問題講清楚,避開那些惱人的細節。
一、源代碼是如何運行起來的
不直接使用機器語言進行應用程序開發是為了提高開發效率,但程序終究是機器運行的,所以才有了復雜的編譯鏈接過程,將源代碼轉換為機器指令。
程序員一般使用 IDE 進行應用程序開發,對于需要先編譯成機器語言再運行的程序,在執行運行指令后時常會陷入漫長的等待才能運行起來,這期間計算機做了大量工作:
- 預編譯:主要處理以“#”開始的預編譯指令。
- 編譯:
- 詞法分析:將字符序列分割成一系列的記號。
- 語法分析:根據產生的記號進行語法分析生成語法樹。
- 語義分析:分析語法樹的語義,進行類型的匹配、轉換、標識等。
- 中間代碼生成:源碼級優化器將語法樹轉換成中間代碼,然后進行源碼級優化,比如把 1+2 優化為 3。中間代碼使得編譯器被分為前端和后端,不同的平臺可以利用不同的編譯器后端將中間代碼轉換為機器代碼,實現跨平臺。
- 目標代碼生成:此后的過程屬于編譯器后端,代碼生成器將中間代碼轉換成目標代碼(匯編代碼),其后目標代碼優化器對目標代碼進行優化,比如調整尋址方式、使用位移代替乘法、刪除多余指令、調整指令順序等。
- 匯編:匯編器將匯編代碼轉變成機器指令。
- 靜態鏈接:鏈接器將各個已經編譯成機器指令的目標文件鏈接起來,經過重定位過后輸出一個可執行文件。
- 裝載:裝載可執行文件、裝載其依賴的共享對象。
- 動態鏈接:動態鏈接器將可執行文件和共享對象中需要重定位的位置進行修正。
- 最后,進程的控制權轉交給程序入口,程序終于運行起來了。
大致流程就是如此,不同平臺在細節處理上會有所不同,下面分析具體過程。
二、目標文件的結構
在靜態鏈接之前,可以簡單理解為程序員在 IDE 中寫的參與運行的代碼文件會轉換為對應的目標文件,了解目標文件的構成是理解鏈接裝載的前提。
目標文件中包含了編譯后的機器指令、數據,還包含了用于鏈接的信息、調試信息等,這些內容按照屬性不同以段 (Section) 的形式分開存儲。
該圖只是個大致結構,還有很多段沒有例舉出來。比如還有 Readonly Data 段存儲只讀數據(const 修飾變量和字符串常量),Debug 存儲調試信息,以及動態鏈接相關的 Dynamic 段等。這些繁雜的 Section 這里不直接展開,而是優先關注圖中這些具有代表性的結構。
為什么要把程序指令和程序數據分離?
- 很容易想到的理由是,這么做過后提高了查詢特定數據的效率,根據待查詢數據的類型直接定位到歸屬段,而不用每次都遍歷整個目標文件。
- 對于一個進程來說,代碼段是只讀的,數據段是可讀寫的,這樣可以便于操作系統對虛擬內存區域劃分訪問權限。
- CPU 一般設計成數據緩存和指令緩存分離,分離有利于 CPU 緩存命中。
- 多個進程可以共享內存中的只讀數據,比如代碼段和圖片資源等(參考共享庫原理),節約內存占用。
文件頭
文件頭是訪問目標文件的入口,是一個結構體,它包含了文件類型(并不是用拓展名判斷類型的)、字節序、入口地址等基本信息,這里最需要關注的是它提供了段表在目標文件中的偏移。
段表
Section Table 是一個非常重要的段,它是一個結構體數組,每一個元素包含了某個段的段名(實際上是在字符串表中的偏移)、段在目標文件的偏移、段的長度、段訪問權限、段類型(并不是用段名判斷類型的)、段虛擬地址等。
字符串表
目標文件中用到了段名、符號名等字符串,字符串的長度不定,無法用固定的格式表示,所以將這些字符串集中起來依次放入一個表,字符串之間用\0
分割。如此,目標文件中訪問字符串只需要提供一個偏移。
函數、變量等字符串往往主要是指令來訪問,段表名字符串主要是鏈接器來訪問,為了分離職責,使用 字符串表 來存儲普通字符串,使用 段表字符串表 來存儲段表中用到的字符串。
文件頭中除了包含段表的偏移,還包含了段表字符串表在段表中的下標,由此可見,通過訪問文件頭就能訪問到所有的段。
符號表
函數和變量統稱為 符號 ,符號表記錄了目標文件中用到的所有符號,值得注意的是還會包含段名,段名是編譯器生成的而不是源代碼中的。
符號表是一個結構體數組,每一個元素記錄了某個符號的符號名(在字符串表中的下標)、符號值、符號類型(段還是函數或變量)、符號綁定信息(局部還是全局、弱符號還是強符號)、符號所在段(在段表中的下標)、符號大小(數據類型的大小)。
這里需要注意的是符號值:
- 對段來說,符號值是該段的起始地址,這是編譯器生成便于后面快速查詢段。
- 對函數和變量來說,符號值是它們的地址。
由此可見,符號表類似于“路由器”的角色,它能告訴我們某個符號在哪個位置,當然目標文件中的符號表并非一個已經知曉所有“路由信息”的“路由器”,在后文分享鏈接時會詳細說明。
弱符號與強符號
符號分為弱符號與強符號,對于 C/C++ 來說,編譯器默認函數和已初始化的全局變量為強符號,未初始化的全局變量為弱符號,可以使用__attribute__ ((weak))
定義一個弱符號,編譯器決議符號時有如下規則:
- 不允許強符號被多次定義。
- 多個符號名重復且只有一個強符號時,選擇強符號。
- 多個符號名重復且都是弱符號時,選擇占用空間最大的一個。
弱符號的場景:組件提供弱符號的默認函數,開發者可以使用強符號的自定義函數覆蓋實現。與弱符號對應的還有弱引用,如果弱引用的符號有定義,鏈接器決議該符號,如果弱引用的符號未定義,鏈接器不認為是一個錯誤。
BSS 段
BSS 段存放是的未初始化的局部靜態變量,不同編譯器實現可能有差異,所以主要是理解思想。
BSS 段在圖中之所以標記為灰色是因為它不占用目標文件空間(可以理解為不占磁盤空間),但在裝載時和其它段一樣分配虛擬空間。應該很容易想到,未初始化的局部靜態變量之所以不占用磁盤是因為它們的默認值都為 0,既然都是 0 就沒必要專門拿磁盤空間來存它們的值。如果局部靜態變量初始值設置為了 0,編譯器仍然可能進行優化,把它放到這個 BSS 段。
BSS 段存在的意義就很明顯了:節約磁盤空間。
排除只會存在于棧中的局部變量、存在于只讀數據段的常量,還有一種符號可能也會放入 BSS 段:未初始化的全局變量。GCC 不會將其放入 BSS 段,而是在符號表中將其標記為 Common(具體看靜態鏈接 Common 塊)。
二、靜態鏈接
注意:此部分說的地址若非特別指明均指虛擬地址。
模塊在編譯成目標文件的過程中,編譯器會試圖修正內部的符號引用,如果符號是定義在模塊內部的,直接修正調用地址(多是相對調用,并沒有確定實際虛擬地址);如果符號是定義在模塊外部的,編譯器則無法得知這個符號的調用地址。
這個外部符號可能定義在其它目標文件中(這部分不考慮定義在共享文件中的情況),如何修正外部符號的引用正是靜態鏈接的核心問題。
靜態鏈接是指將多個目標文件合并為一個可執行文件,直觀感覺就是將所有目標文件的段合并。需要注意的是可執行文件與目標文件的結構基本一致,不同的是是否“可執行”。
另外,可執行文件要被操作系統裝載到內存中運行,所以還需要為其分配虛擬地址。
空間與地址分配
注意:頁映射、裝載相關請看后文。
首先需要明白有頁映射機制的存在,虛擬頁與物理頁大小一致,裝載是以頁為單位的,通常情況下需要保證一個虛擬頁的信息的訪問權限一致。
按序疊加
最簡單的方式是將各個目標文件的段按順序疊加,這樣做有個很大的問題就是無法判斷相鄰段之間的訪問權限是否一致,所以虛擬頁分配時只能將每一個段都視為不同屬性。那么如果頁大小是 4096 字節,即使段只有 1 字節,也要占用一個虛擬頁大小的地址空間,這樣會造成很多內存碎片浪費空間。
相似段合并
采用相似段合并策略,將相同屬性的段合并有利于管理,裝載完所有段將使用更少的虛擬地址頁,有效降低內存消耗(在裝載部分有分析進一步的優化)。
空間分配過程完成后,每個段的虛擬地址就確定了。值得注意的是,圖中段的位置并不是表示虛擬地址,而是可以理解為在磁盤中的位置。那么段的虛擬地址存放在哪兒呢?實際上就是存放在前面提到的段表里面,段表數組元素有一個屬性就是段虛擬地址。
需要注意的是,所有目標文件的符號表會合并為一個 全局符號表 ,這是一個非常重要的段。
內部定義符號地址的確定
段的虛擬地址確定后,就需要確定每一個段中的符號地址,之前提到的編譯時修正符號地址只是一個相對地址,比如0 + 0x66
(0x66
表示符號在段中的偏移)。這里修正的方式很簡單,就是段起始地址+符號偏移,比如段起始地址為0x88888888
,則修正為0x88888888 + 0x66
。
絕對地址引用比相對地址引用速度更快,所以鏈接器會盡可能的將符號引用修正為絕對地址引用。
另外,還要將 全局符號表 中對應的符號地址就行修正。
需要注意一點,這個步驟修正的仍然是某個段內部定義的符號,而對于這個段引用的外部符號仍然處于待修正狀態。
重定位表
經過上面的步驟,可執行文件生成了,各個段及其內部符號引用虛擬地址確定了,還差最后一步:修正各個段中對外部符號的引用地址,這個過程稱為 重定位 (各個目標文件已經合并為一個文件了,這里說的外部符號其實是對于合并之前而言)。
在這之前需要了解一下重定位入口的集合——重定位表。每一個需要重定位的段都有一個與之對應的重定位表。
重定位表也是一個結構體數組,該結構體包含:
- 重定位入口的偏移,即需要修正的位置相對于段起始的偏移。
- 重定位入口的符號在符號表中的下標。
- 重定位入口的類型。
符號解析與重定位
基于前面介紹的各種段結構,符號解析與重定位過程實際上非常簡單,無非就是根據重定位入口的符號在符號表的下標,找到該符號對應的目標地址,找出重定位表對應的段,根據重定位入口的偏移填入這個目標地址。
鏈接器掃描完所有的重定位表,所有的重定位入口符號都能在全局符號表中找到,否則鏈接器就會報符號未定義錯誤。
Common 機制
Common 機制可以理解為延遲決議,即可能有多個不定因素影響,在考慮完所有不定因素后才能決議。
未初始化的全局變量屬于弱符號,編譯器將其標記為 Common。對于某個目標文件來說,它無法確定其它目標文件中是否有強符號或者占用字節更長的弱符號(強弱符號前面有講解)。所以只有在鏈接器遍歷完所有目標文件后才能確定這個符號的占用空間大小,那個時候再去為未初始化的全局變量在 BSS 段分配虛擬空間。
這么處理的直接原因是編譯器允許符號重名。
三、裝載
可執行文件存在于磁盤中,需要讀入內存才能由 CPU 執行,在討論如何將可執行文件裝載之前,需要先了解物理內存分配策略。
物理內存分配策略
這里主要討論物理內存如何為各個進程分配空間。最簡單的方式就是直接為進程劃分物理內存區域,這會有很多缺點:
- 地址空間不隔離。程序直接訪問物理地址很容易出現進程間相互影響。
- 內存使用效率低。如果運行 A、B 兩個程序就用盡了物理空間,啟動 C 程序就只能將 A 或 B 換出到磁盤,然后把 C 讀入內存,效率很低。
- 程序運行地址不確定。由于空閑的物理地址不確定,那么程序中使用的絕對地址引用很可能是需要重新修正的,如果運行時去做這個事情將會非常耗時。
虛擬內存
加入虛擬內存中間層,直接解決地址空間不隔離、程序運行地址不確定的問題。我們在前文所提到的地址都是指的虛擬地址,對于每一個進程來說,都是自己獨占虛擬內存空間,而最終的物理地址區域由操作系統映射。
然而,單純的將程序所占虛擬地址空間直接映射到物理內存無法解決內存使用效率低的問題,物理內存仍然會快速消耗殆盡。
頁映射機制
程序局部性原理:一個程序在運行時,某段時間內只使用到了一部分程序數據。所以將虛擬地址、物理內存、磁盤空間都劃分為頁為單位,寫入物理內存的粒度縮小為頁,而非整個程序。
虛擬地址空間中的頁稱作 虛擬頁 (VP, Virtual Page) ,物理內存中的頁稱作 物理頁 (PP, Physical Page) ,磁盤中的頁叫做 磁盤頁 (DP, Disk Page) ,進程捕獲到虛擬頁未裝載時稱為 頁錯誤 (Page Fault) ,虛擬地址到物理地址的轉換一般使用 MMU (Memory Management Unit) 。
核心思路:進程讀取某個地址時,其所在虛擬頁 A_VP 發現未綁定物理頁 A_PP,發生頁錯誤,操作系統接管進程,找到虛擬頁 A_VP 對應的磁盤頁 A_DP,將 A_DP 寫入物理頁 A_PP(若物理頁使用殆盡會使用淘汰算法去清理或壓縮部分物理頁),將 A_VP 與 A_PP 綁定,之后控制權交由進程,訪問地址成功。
可執行文件生成時,如何提高物理內存使用率
前面已經分析了,可執行文件將段按照頁整數倍來分配虛擬地址,雖然已經將所有目標文件中相似段合并了,但每個段對于一個頁(比如 4096 字節)來說還是太小了,仍然會浪費很多虛擬地址空間,從而映射后也會浪費物理內存。
Segment 與 Section
對于操作系統來說,它并不關心每個段的類型,主要是關心它們的訪問權限。所以,前面提到的相似段合并的過程中,不僅將多個相似 Section 合并為一個 Section,鏈接器還會盡量將權限相同的 Section 放在一起,稱之為 Segment 。
那么鏈接器在進行虛擬地址分配時,就不用讓每一個 Section 進行頁對齊,而是讓每一個 Segment 進行頁對齊,如此一來進一步節約了虛擬地址空間。思考一下便知,Segment 只是在裝載時有用,在分析可執行文件及其鏈接過程只需要關心 Section 也不會有什么問題。
裝載時是以 Segment 為單位的,訪問權限需要基于這個 Segment 來設置。那么實際上有一個裝載時很重要的段:程序頭表 。
程序頭表也是結構體數組,每一個元素包含 Segment 在文件中的偏移、虛擬地址起點、訪問權限(Segment 中所有 Section 訪問權限一致)、虛擬地址空間長度、文件中空間長度等。
值得提出的是 關于 BSS 的處理 。對于 Segment 來說,可能并不能撐滿一個頁大小,那么就可以拓展一些虛擬空間,即其 虛擬地址空間長度 > 文件中空間長度 ,這表示拓展的部分只在裝載時占虛擬空間而不占磁盤,這正好用來存放各個 BSS Section。考慮其訪問權限,需要注意的是 BSS 可以和數據 Segment 合并,但不能和指令相關 Segment 合并。
BSS Section 見縫插針,進一步減少了內存碎片。
段地址對齊
盡管已經按照 Segment 裝載可執行文件,仍然存在一些內存碎片,所以有些 UNIX 系統做了更進一步的優化:將 Segment 接壤部分共享一個物理頁,然后將物理頁映射兩次。
然而這么做過后, Segment 的虛擬地址就不再是頁大小的整數倍了,就涉及到一些計算這里不展開了。
可執行文件的裝載
根據前面分析的頁映射機制,可執行文件裝載進內存需要兩個映射關系:
- 虛擬空間 : 物理內存
- 虛擬空間 : 可執行文件
創建一個進程,或者說創建一個虛擬空間,第一步是操作系統創建一個頁目錄(Page Directory),也就是虛擬空間與物理內存的映射表,映射關系可在發生頁錯誤時設置。
第二步是建立虛擬空間與可執行文件的映射關系。前面已經分析過了,可執行文件的 程序頭表 已經包含了每一個 Segment 的虛擬地址、在文件中的偏移。那么通過讀取程序頭表就能確定每一個虛擬頁對應的可執行文件區間(如果是以 Section 來裝載,這個思路同樣適用于段表)。
第三步就是將 CPU 指令寄存器設置為可執行文件入口,啟動運行。
四、動態鏈接
不將某些目標文件靜態鏈接在一起,而把鏈接過程推遲到運行時,這是 動態鏈接 的基本思想。這樣能實現一個最重要的功能,就是共享的目標文件在內存中只需要存在一份,然后由多個進程進行鏈接使用。這種共享的目標文件一般稱作 共享對象、共享庫、共享模塊 。
該圖簡明的表示了共享對象實現原理,進程 A 和 B 只使用了一份共享對象的指令內存數據。
動態鏈接共享對象帶來的好處:
- 多個進程運行時節約物理內存。
- 減少編譯和靜態鏈接的時間消耗,降低可執行文件所占磁盤空間。
- 共享對象的更新和發布更便捷,可執行文件一般不用重新編譯鏈接。
- 通過共享對象來做復雜的系統兼容,增強可執行文件的兼容性。
- 程序在運行時動態加載程序模塊,便于制作插件。
動態鏈接的缺點:
- 運行時重定位拖慢了程序啟動速度(通過 延遲綁定 優化)。
- 共享對象的間接尋址效率較低。
大致說明了動態鏈接的原理和特點,下面來具體分析技術細節。
共享對象的虛擬地址如何確定
簡單方案: 共享對象虛擬地址固定 。那就得在可執行文件的段分配虛擬地址時,為所用到的共享對象預留虛擬空間,似乎能解決問題。不過細想一下,這樣做存在兩個問題:
- 程序每引入一個共享庫或者共享庫更新后占用空間更大,就需要預留更大的虛擬空間,可執行文件或許就要重新編譯。
- 共享對象更新時,內部的符號地址可能變化,可執行文件又得重新編譯。
這些是致命問題,所以直接舍棄這種思路。
正確的思路是:裝載器根據當前虛擬地址空間空閑情況,動態分配一塊虛擬空間給共享對象。
裝載時重定位
共享對象并非完全能被多個進程復用(參照上面共享對象實現的圖),一般只有指令部分是進程共享的,而數據部分仍然是進程獨立的。原因很簡單,數據部分多是可讀寫的,進程間只能使用獨立的副本,而指令是只讀的,多進程共享也沒有影響。
共享對象的虛擬地址是裝載器動態分配的,那么共享對象的數據段里面絕對地址引用是需要修復的。
和目標文件一樣,共享對象數據段中若有絕對地址引用,會生成對應的重定位表,當動態鏈接器把這個共享對象裝載后,會根據重定位表將數據段中的地址引用修正。這個方法叫做 裝載時重定位 。
對于共享對象的指令部分來說,無法使用裝載時重定位來處理 。因為我們說的裝載實際上是指裝載到虛擬空間,那指令部分的絕對地址引用就需要根據當前進程的虛擬地址進行修正。然而各個進程的虛擬空間是獨立的,所以被修正的指令部分并不能被其它進程使用。
PIC 技術
地址無關代碼 (PIC, Position-independent Code) 技術:把指令中需要被修改的部分分離出來跟數據部分放在一起,那么指令部分裝載后就不需要修正內部引用地址,從而實現多進程共用。
模塊內部的數據訪問、調用或跳轉
和目標文件一樣,共享對象中的函數地址、變量的相對位置是不變的,所以調用和跳轉通過相對地址調用指令就能處理了,數據可以通過當前 PC 值加上偏移量來訪問。
模塊間的數據訪問、調用或跳轉
模塊間的符號引用要在裝載時才能確定,這對于每一個進程來說都是需要修正的。處理方式是,在數據段里面建立一個指向這些變量的指針數組,這個指針數組稱作 全局偏移表 (Global Offset Table, GOT) 。指令通過相對尋址就能找到數據段中的 GOT,從而找到需要訪問變量的目標地址。
共享對象的全局變量
定義在模塊內部的全局變量,有一種特殊情況:extern int global;
。這時編譯器其實判斷不了這個符號是定義在內部還是外部的,就不知道該不該分配空間。在共享庫編譯時,編譯器處理方式是默認把定義在模塊內部的全局變量當做定義在其它模塊,通過 GOT 實現。動態鏈接時就能進行判斷:若可執行文件中有副本,指向該副本;否則指向該共享對象中的副本。
全局符號介入
加入全局符號表時,一個共享對象 里的全局符號被 另一個共享對象 同名全局符號覆蓋的現象稱作全局符號介入。如果一個共享對象中使用相對尋址訪問這個全局符號,發生全局符號介入時就可能需要對這個引用重定位了,那么這個共享對象的指令部分就不能實現 PIC 了。所以對于全局符號來說,同樣采用 GOT 方式來訪問。
動態鏈接相關的段
Dynamic 段 類似于文件頭,是動態鏈接重要結構,包含了動態鏈接符號表、動態鏈接重定位表、動態鏈接字符串表、依賴的共享文件(遞歸加載所有依賴)等。這些眼熟的表名字實際上功能結構和靜態鏈接時那些表非常相似。最大的區別就是目標文件的重定位是在靜態鏈接時完成,共享對象的重定位是在裝載時完成。
值得提出的是可執行文件也可以編譯為共享對象形式。
動態鏈接的實現
- 動態鏈接器 自舉 。
- 根據共享對象 Dynamic 段的依賴共享文件屬性可形成了一個樹結構,動態鏈接器一般使用廣度優先搜索裝載這些共享文件。裝載共享文件時,它的符號表合并入全局符號表。裝載完所有共享文件時,全局符號表包含進程中所有的符號。
- 動態鏈接器遍歷可執行文件和所有共享對象的重定位表,通過重定位入口符號在全局符號表中找到對應的目標地址,通過重定位入口偏移將這個目標地址填入合適的位置(這和靜態鏈接過程基本一樣)。
后語
本文的編排和《程序員的自我修養 —— 鏈接、裝載與庫》類似,有很多筆者的總結、提煉、串聯的描述,總的來說算是形成了邏輯通路,希望能為讀者朋友提供一些幫助。
對于編譯、鏈接、裝載相關的技術細節,可能需要深入到具體平臺去研究,不然總是有些揮之不去的盲點。不過只要對基本流程原理有所把握,相信這并非難事。