【程式設計師的自我修養】第4章 靜態連結
第4章 靜態連結
空間與地址分配
整個連結過程,連結器就是將幾個輸入目標加工後合併成一個輸出檔案。
按序疊加:碎片
相似段合併
連結器為目標檔案分配地址和空間有兩個含義:第一是在輸出的可執行檔案中的空間;第二是在裝載後的虛擬地址中的虛擬地址空間。
現在的連結器空間分配的策略基本上是第二種,兩步連結法:
(1)空間與地址分配:掃描所有的輸入目標檔案,獲得它們每個段的長度、屬性和位置,並將輸入目標檔案中的符號中所有的符號定義和符號引用收集起來,統一放到全域性符號表。這一步中,連結器獲得所有輸入目標檔案的段長度,將它們合併,計算新的長度和位置,並建立對映關係。
(2)符號解析與重定位
符號解析與重定位
連結前後(完成地址和空間分配)的程式中使用的地址已經是程式在程序中的虛擬地址。
連結器需要根據新得到的全域性符號表對這些假地址進行修正,每一個這樣需要重定位的地方都稱為一個“重定位入口”。
首先,連結器是怎麼知道輸入目標檔案中哪些地方需要進行修正的呢?這完全歸功於編譯器單元建立的“重定位表”—用來描述如何修改相應的段裡的內容—在段表section header table中型別為SHT_REL。可重定位ELF檔案中每一個需要進行重定位的section都對應這一個重定位表,例如.text對應的.rel.text,.data對應的.rel.data。與ELF檔案中其他的“表”一樣,重定位表也是結構體
r_offset; ——該重定位入口相對於所在段起始的偏移量
r_info; —— 低8位表徵重定位入口型別和符號(不同型別,計算修正量地址的方法是不一樣的),高24位表徵重定位入口的符號在新的輸出可執行檔案中的實際地址
符號解析:重定位過程中,每個重定位的入口都是對一個符號的引用,那麼當聯結器須要對某個符號的引用最近重定位時,它就要確定這個符號的目標地址。這時聯結器就會去查詢由所有目標檔案的符號組成的全域性符號表,找到相應的符號後進行重定位。在連結器掃描完所有的輸入目標檔案之後,所有未定義的符號必須也在全域性符號表中,否則連結器報錯。
如果連結器在ab的新全域性符號表中沒有找到某個重定位入口符號的定義,則會報錯——這就是常見的連線錯誤型別;如果在全域性符號表中找到了這些未定義的符號,則會根據重定位入口指令的型別進行符號引用地址的修正,32位平臺下的計算方式包括絕對近址定址修正和相對近址定址修正兩種方法,分別對應於R_386_32型別的重定位入口和R_386_PC32型別的重定位入口。相對定址修正方式中,重定位入口所在指令的下一條指令的地址 + 修正後的地址值 = 該符號的實際定義地址。
COMMON塊
當前的連結器並不支援符號型別,即連結器並不知道各個符號的資料型別究竟是什麼,而僅僅知道該符號的大小而已。當輸入目標檔案中存在著多個同名符號時,連結器需要根據強符號/弱符號機制進行處理,對於均為弱符號的情況,則選擇最大的那一個進行連線——這也就是所謂的COMMON塊機制。
未初始化的全域性變數之所以被編譯器歸為COMMON而不是在.bss段中分配空間(即使.bss段在編譯時並未分配真正的空間),這是因為這種變數被劃定為弱符號,在編譯時並不能知道是否在其他目標檔案中存在相同的未定義全域性變數,編譯器不能確定在連結時這個名字的符號究竟佔多大的空間。但是連結器卻能夠知道最終這個符號佔多少空間,所以在最終的輸出可執行檔案中,這樣的未初始化全域性變數會在.bss段中被分配空間,即最終還是和未初始化的區域性靜態變數一起放在.bss段中的。
GCC中可以通過 -fno_common 編譯選項,或者在原始碼中使用 __attribute__((nocommon)) 定義未初始化全域性變數,這樣該變數就不會以COMMON符號方式被處理,而變成了一個強符號。
C++相關問題
重複程式碼消除:模版、外部行內函數、虛擬函式表都有可能在不同的編譯單元中生成相同的程式碼。一個有效的做法,就是將每個模版的例項程式碼單獨存放在一個段裡,每個段只包含一個模版例項,比如有個模版函式add<T>(),某個編譯單元用int和float例項化了該模版函式,那麼該編譯單元的目標檔案中就包含了兩個該模版例項的段:.temp.add<int>和.temp.add<float>。這樣,當別的編譯單元也以int或float例項化該模版函式後,也會生成同樣的名字,這樣連結器在最終連結的時候可以區分這些相同的模版例項段,最終將它們合併入最後的程式碼段。
外部行內函數、預設建構函式、預設拷貝建構函式和賦值操作符也採用類似的方法消除類似
函式級別連結:將每個函式或變數分別保持在獨立的段中。
全域性構造與析構:我們知道C++全域性物件的建構函式在main之前被執行,C++全域性物件的解構函式在main之後被執行。
Linux系統下一般程式的入口是“_start”,這個函式是Linux系統庫(Glibc)的一部分。當我們的程式與Glibc連結在一起形成最終可執行檔案以後,這個函式就是程式的初始化部分的入口,程式初始化部分完成一系列初始化過程之後,會呼叫main函式來執行程式主體。
在main函式前後執行的一般放在ELF檔案中的兩個特殊段:
l .init 該段裡面儲存的是可執行指令,它構成了程序的初始化程式碼。
l .fini 該段儲存著程序終止程式碼指令。
如果一個函式放在.init段,在main函式執行前系統就會執行它。同理,假如一個函式放到.fint段,在main函式返回後該函式就會被執行。
不同編譯器編譯出來的目標檔案是否可以連結在一起呢?
如果能,須要:採用相同的目標檔案格式,擁有同樣的符號修飾標準、變數的記憶體分佈方式相同、函式的呼叫方式相同等等。其中我們把符號修飾標準、變數記憶體佈局、函式呼叫方式等這些跟可執行程式碼二進位制相容相關的內容稱之為ABI:application binary interface,應用程式二進位制介面
影響ABI的因素非常多,硬體、程式語言、編譯器、聯結器、作業系統等都會有影響到ABI。
對於C語言的目的碼來說,一下幾個方面會決定目標檔案之間是否二進位制相容:
l 內建型別(int,float,char等)的大小和在儲存器中的防止位置(大小端、對齊方式等)
l 組合型別(struct、union、陣列等)的儲存方式和記憶體佈局
l 外部符號與使用者定義的符號之間的命名方式和解析方式,如函式func在C語言的mubi9ao檔案中是否被解析成為外部符號_func
l 函式的呼叫方式,比如引數入棧順序、返回值如何保持等
l 堆疊的分佈方式,比如引數和區域性變數在堆疊裡面的位置,引數傳遞方法等。
l 暫存器使用約定,函式呼叫時那些暫存器可以修改,哪些需要儲存等。
C++要做到二進位制相容比C更不容易:
l 繼承類體系的記憶體分佈,如基類,虛基類在繼承類中的位置等
l 指向成員函式的指標的記憶體分佈,如何通過指向成員函式的指標來呼叫成員函式,如何傳遞this指標
l 如何呼叫虛擬函式,vtable的內容和分佈形式,vtable指標在object中的位置等
l template如何例項化
l 外部符號的修飾
l 全句物件的構造和析構
l 一次的產生和捕獲機制
l 標準庫的細節問題,RTTI如何實現等
l 內嵌函式的訪問細節
C++一直為人詬病的一大原因是它的二進位制相容性不好。
靜態連結
靜態連結庫可以簡單看成一組目標檔案的集合。
連結過程控制
在大部分情況下,我們使用連結器提供的預設連結規則對目標檔案進行連結。這一版沒有問題,但對於特殊要求的程式,比如作業系統核心,BIOS或一些沒有作業系統的情況下執行的程式(Boot Loader或者嵌入式系統程式),以及另外的一些需要特殊的連結過程的程式,如核心驅動程式等。
聯結器提供的控制整個連結過程的方法:
l 使用命令列來給聯結器指定引數
l 將連結指令存放在目標檔案裡面,編譯通常通過這種方法想聯結器傳遞指令。VISUAL C++編譯器會把連結引數放在PE目標檔案的.drectve段。
l 使用連線控制指令碼
BFD庫是一個GNU專案,希望通過一種統一的介面來處理不同的目標檔案格式