連接與裝載是一個比較晦澀的話題,你們每每容易陷入複雜的細節中而難以看清問題的原本面目。從本質上講各個系統的編譯、連接、裝載過程都是大同小異的,或許能夠用一種更抽象的形式來理解這些過程,梳理清楚宏觀的前因後果有利於對特定系統進行深刻學習。前端
本文主要根據《程序員的自我修養 —— 連接、裝載與庫》和本身的理解總結而來,書的內容是基於 GCC 的,不過筆者儘可能以更抽象、更簡潔的方式把問題講清楚,避開那些惱人的細節。程序員
不直接使用機器語言進行應用程序開發是爲了提升開發效率,但程序終究是機器運行的,因此纔有了複雜的編譯連接過程,將源代碼轉換爲機器指令。算法
程序員通常使用 IDE 進行應用程序開發,對於須要先編譯成機器語言再運行的程序,在執行運行指令後時常會陷入漫長的等待才能運行起來,這期間計算機作了大量工做:後端
大體流程就是如此,不一樣平臺在細節處理上會有所不一樣,下面分析具體過程。數組
在靜態連接以前,能夠簡單理解爲程序員在 IDE 中寫的參與運行的代碼文件會轉換爲對應的目標文件,瞭解目標文件的構成是理解連接裝載的前提。緩存
目標文件中包含了編譯後的機器指令、數據,還包含了用於連接的信息、調試信息等,這些內容按照屬性不一樣以段 (Section) 的形式分開存儲。函數
該圖只是個大體結構,還有不少段沒有例舉出來。好比還有 Readonly Data 段存儲只讀數據(const 修飾變量和字符串常量),Debug 存儲調試信息,以及動態連接相關的 Dynamic 段等。這些繁雜的 Section 這裏不直接展開,而是優先關注圖中這些具備表明性的結構。學習
文件頭是訪問目標文件的入口,是一個結構體,它包含了文件類型(並非用拓展名判斷類型的)、字節序、入口地址等基本信息,這裏最須要關注的是它提供了段表在目標文件中的偏移。優化
Section Table 是一個很是重要的段,它是一個結構體數組,每個元素包含了某個段的段名(其實是在字符串表中的偏移)、段在目標文件的偏移、段的長度、段訪問權限、段類型(並非用段名判斷類型的)、段虛擬地址等。操作系統
目標文件中用到了段名、符號名等字符串,字符串的長度不定,沒法用固定的格式表示,因此將這些字符串集中起來依次放入一個表,字符串之間用\0
分割。如此,目標文件中訪問字符串只須要提供一個偏移。
函數、變量等字符串每每主要是指令來訪問,段表名字符串主要是連接器來訪問,爲了分離職責,使用 字符串表 來存儲普通字符串,使用 段表字符串表 來存儲段表中用到的字符串。
文件頭中除了包含段表的偏移,還包含了段表字符串表在段表中的下標,因而可知,經過訪問文件頭就能訪問到全部的段。
函數和變量統稱爲 符號 ,符號表記錄了目標文件中用到的全部符號,值得注意的是還會包含段名,段名是編譯器生成的而不是源代碼中的。
符號表是一個結構體數組,每個元素記錄了某個符號的符號名(在字符串表中的下標)、符號值、符號類型(段仍是函數或變量)、符號綁定信息(局部仍是全局、弱符號仍是強符號)、符號所在段(在段表中的下標)、符號大小(數據類型的大小)。
這裏須要注意的是符號值:
因而可知,符號表相似於「路由器」的角色,它能告訴咱們某個符號在哪一個位置,固然目標文件中的符號表並不是一個已經知曉全部「路由信息」的「路由器」,在後文分享連接時會詳細說明。
符號分爲弱符號與強符號,對於 C/C++ 來講,編譯器默認函數和已初始化的全局變量爲強符號,未初始化的全局變量爲弱符號,可使用__attribute__ ((weak))
定義一個弱符號,編譯器決議符號時有以下規則:
弱符號的場景:組件提供弱符號的默認函數,開發者可使用強符號的自定義函數覆蓋實現。與弱符號對應的還有弱引用,若是弱引用的符號有定義,連接器決議該符號,若是弱引用的符號未定義,連接器不認爲是一個錯誤。
BSS 段存放是的未初始化的局部靜態變量,不一樣編譯器實現可能有差別,因此主要是理解思想。
BSS 段在圖中之因此標記爲灰色是由於它不佔用目標文件空間(能夠理解爲不佔磁盤空間),但在裝載時和其它段同樣分配虛擬空間。應該很容易想到,未初始化的局部靜態變量之因此不佔用磁盤是由於它們的默認值都爲 0,既然都是 0 就不必專門拿磁盤空間來存它們的值。若是局部靜態變量初始值設置爲了 0,編譯器仍然可能進行優化,把它放到這個 BSS 段。
BSS 段存在的意義就很明顯了:節約磁盤空間。
排除只會存在於棧中的局部變量、存在於只讀數據段的常量,還有一種符號可能也會放入 BSS 段:未初始化的全局變量。GCC 不會將其放入 BSS 段,而是在符號表中將其標記爲 Common(具體看靜態連接 Common 塊)。
注意:此部分說的地址若非特別指明均指虛擬地址。
模塊在編譯成目標文件的過程當中,編譯器會試圖修正內部的符號引用,若是符號是定義在模塊內部的,直接修正調用地址(可能是相對調用,並無肯定實際虛擬地址);若是符號是定義在模塊外部的,編譯器則沒法得知這個符號的調用地址。
這個外部符號可能定義在其它目標文件中(這部分不考慮定義在共享文件中的狀況),如何修正外部符號的引用正是靜態連接的核心問題。
靜態連接是指將多個目標文件合併爲一個可執行文件,直觀感受就是將全部目標文件的段合併。須要注意的是可執行文件與目標文件的結構基本一致,不一樣的是是否「可執行」。
另外,可執行文件要被操做系統裝載到內存中運行,因此還須要爲其分配虛擬地址。
注意:頁映射、裝載相關請看後文。
首先須要明白有頁映射機制的存在,虛擬頁與物理頁大小一致,裝載是以頁爲單位的,一般狀況下須要保證一個虛擬頁的信息的訪問權限一致。
最簡單的方式是將各個目標文件的段按順序疊加,這樣作有個很大的問題就是沒法判斷相鄰段之間的訪問權限是否一致,因此虛擬頁分配時只能將每個段都視爲不一樣屬性。那麼若是頁大小是 4096 字節,即便段只有 1 字節,也要佔用一個虛擬頁大小的地址空間,這樣會形成不少內存碎片浪費空間。
採用類似段合併策略,將相同屬性的段合併有利於管理,裝載完全部段將使用更少的虛擬地址頁,有效下降內存消耗(在裝載部分有分析進一步的優化)。
空間分配過程完成後,每一個段的虛擬地址就肯定了。值得注意的是,圖中段的位置並非表示虛擬地址,而是能夠理解爲在磁盤中的位置。那麼段的虛擬地址存放在哪兒呢?實際上就是存放在前面提到的段表裏面,段表數組元素有一個屬性就是段虛擬地址。
須要注意的是,全部目標文件的符號表會合併爲一個 全局符號表 ,這是一個很是重要的段。
段的虛擬地址肯定後,就須要肯定每個段中的符號地址,以前提到的編譯時修正符號地址只是一個相對地址,好比0 + 0x66
(0x66
表示符號在段中的偏移)。這裏修正的方式很簡單,就是段起始地址+符號偏移,好比段起始地址爲0x88888888
,則修正爲0x88888888 + 0x66
。
絕對地址引用比相對地址引用速度更快,因此連接器會盡量的將符號引用修正爲絕對地址引用。
另外,還要將 全局符號表 中對應的符號地址就行修正。
須要注意一點,這個步驟修正的仍然是某個段內部定義的符號,而對於這個段引用的外部符號仍然處於待修正狀態。
通過上面的步驟,可執行文件生成了,各個段及其內部符號引用虛擬地址肯定了,還差最後一步:修正各個段中對外部符號的引用地址,這個過程稱爲 重定位 (各個目標文件已經合併爲一個文件了,這裏說的外部符號實際上是對於合併以前而言)。
在這以前須要瞭解一下重定位入口的集合——重定位表。每個須要重定位的段都有一個與之對應的重定位表。
重定位表也是一個結構體數組,該結構體包含:
基於前面介紹的各類段結構,符號解析與重定位過程實際上很是簡單,無非就是根據重定位入口的符號在符號表的下標,找到該符號對應的目標地址,找出重定位表對應的段,根據重定位入口的偏移填入這個目標地址。
連接器掃描完全部的重定位表,全部的重定位入口符號都能在全局符號表中找到,不然連接器就會報符號未定義錯誤。
Common 機制能夠理解爲延遲決議,便可能有多個不定因素影響,在考慮完全部不定因素後才能決議。
未初始化的全局變量屬於弱符號,編譯器將其標記爲 Common。對於某個目標文件來講,它沒法肯定其它目標文件中是否有強符號或者佔用字節更長的弱符號(強弱符號前面有講解)。因此只有在連接器遍歷完全部目標文件後才能肯定這個符號的佔用空間大小,那個時候再去爲未初始化的全局變量在 BSS 段分配虛擬空間。
這麼處理的直接緣由是編譯器容許符號重名。
可執行文件存在於磁盤中,須要讀入內存才能由 CPU 執行,在討論如何將可執行文件裝載以前,須要先了解物理內存分配策略。
這裏主要討論物理內存如何爲各個進程分配空間。最簡單的方式就是直接爲進程劃分物理內存區域,這會有不少缺點:
加入虛擬內存中間層,直接解決地址空間不隔離、程序運行地址不肯定的問題。咱們在前文所提到的地址都是指的虛擬地址,對於每個進程來講,都是本身獨佔虛擬內存空間,而最終的物理地址區域由操做系統映射。
然而,單純的將程序所佔虛擬地址空間直接映射到物理內存沒法解決內存使用效率低的問題,物理內存仍然會快速消耗殆盡。
程序局部性原理:一個程序在運行時,某段時間內只使用到了一部分程序數據。因此將虛擬地址、物理內存、磁盤空間都劃分爲頁爲單位,寫入物理內存的粒度縮小爲頁,而非整個程序。
虛擬地址空間中的頁稱做 虛擬頁 (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 字節)來講仍是過小了,仍然會浪費不少虛擬地址空間,從而映射後也會浪費物理內存。
對於操做系統來講,它並不關心每一個段的類型,主要是關心它們的訪問權限。因此,前面提到的類似段合併的過程當中,不只將多個類似 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, Position-independent Code) 技術:把指令中須要被修改的部分分離出來跟數據部分放在一塊兒,那麼指令部分裝載後就不須要修正內部引用地址,從而實現多進程共用。
和目標文件同樣,共享對象中的函數地址、變量的相對位置是不變的,因此調用和跳轉經過相對地址調用指令就能處理了,數據能夠經過當前 PC 值加上偏移量來訪問。
模塊間的符號引用要在裝載時才能肯定,這對於每個進程來講都是須要修正的。處理方式是,在數據段裏面創建一個指向這些變量的指針數組,這個指針數組稱做 全局偏移表 (Global Offset Table, GOT) 。指令經過相對尋址就能找到數據段中的 GOT,從而找到須要訪問變量的目標地址。
定義在模塊內部的全局變量,有一種特殊狀況:extern int global;
。這時編譯器其實判斷不了這個符號是定義在內部仍是外部的,就不知道該不應分配空間。在共享庫編譯時,編譯器處理方式是默認把定義在模塊內部的全局變量當作定義在其它模塊,經過 GOT 實現。動態連接時就能進行判斷:若可執行文件中有副本,指向該副本;不然指向該共享對象中的副本。
加入全局符號表時,一個共享對象 裏的全局符號被 另外一個共享對象 同名全局符號覆蓋的現象稱做全局符號介入。若是一個共享對象中使用相對尋址訪問這個全局符號,發生全局符號介入時就可能須要對這個引用重定位了,那麼這個共享對象的指令部分就不能實現 PIC 了。因此對於全局符號來講,一樣採用 GOT 方式來訪問。
Dynamic 段 相似於文件頭,是動態連接重要結構,包含了動態連接符號表、動態連接重定位表、動態連接字符串表、依賴的共享文件(遞歸加載全部依賴)等。這些眼熟的表名字實際上功能結構和靜態連接時那些表很是類似。最大的區別就是目標文件的重定位是在靜態連接時完成,共享對象的重定位是在裝載時完成。
值得提出的是可執行文件也能夠編譯爲共享對象形式。
本文的編排和《程序員的自我修養 —— 連接、裝載與庫》相似,有不少筆者的總結、提煉、串聯的描述,總的來講算是造成了邏輯通路,但願能爲讀者朋友提供一些幫助。
對於編譯、連接、裝載相關的技術細節,可能須要深刻到具體平臺去研究,否則老是有些揮之不去的盲點。不過只要對基本流程原理有所把握,相信這並不是難事。