程序员的自我修养--链接、装载与库笔记:静态链接
1. 空間與地址分配
對于鏈接器來說,整個鏈接過程中,它就是將幾個輸入目標文件加工后合并成一個輸出文件。測試代碼a.c和b.c內容如下:
// a.c
extern int shared;int main()
{int a = 100;swap(&a, &shared);
}
// b.c
int shared = 1;void swap(int* a, int* b)
{*a ^= *b ^= *a ^= *b;
}
通過執行:$ gcc -c a.c b.c ,生成a.o, b.o。
可執行文件中的代碼段和數據段都是由輸入的目標文件合并而來的。對于多個輸入目標文件,鏈接器如何將它們的各個段合并到輸出文件?方法如下:
按序疊加:一個最簡單的方案就是將輸入的目標文件按照次序疊加起來,如下圖所示:就是直接將各個目標文件依次合并。但是這樣做會造成一個問題,在有很多輸入文件的情況下,輸出文件將會有很多零散的段。這種做法非常浪費空間,因為每個段都需要有一定的地址和空間對齊要求,比如對于x86的硬件來說,段的裝載地址和空間的對齊單位是頁,也就是4096字節,那么就是說如果一個段的長度只有1個字節,它也要在內存中占用4096字節。這樣會造成內存空間大量的內部碎片。
相似段合并:一個更實際的方法是將相同性質的段合并到一起,比如將所有輸入文件的”.text”合并到輸出文件的”.text”段,接著是”.data”段、”.bass”段等,如下圖所示:”.bss”段在目標文件和可執行文件中并不占用文件的空間,但是它在裝載時占用地址空間。所以鏈接器在合并各個段的同時,也將”.bss”合并,并且分配虛擬空間。
“鏈接器為目標文件分配地址和空間”這句話中的”地址和空間”其實有兩個含義:第一個是在輸出的可執行文件中的空間;第二個是在裝載后的虛擬地址中的虛擬地址空間。對于有實際數據的段,比如”.text”和”.data”來說,它們在文件中和虛擬地址中都要分配空間,因為它們在這兩者中都存在;而對于”.bss”這樣的段來說,分配空間的意義只局限于虛擬地址空間,因為它在文件中并沒有內容。
現在的鏈接器空間分配(只關注于虛擬地址空間的分配)的策略基本上都采用上述方法中的第二種,使用這種方法的鏈接器一般都采用一種叫兩步鏈接(Two-pass Linking)的方法。也就是說整個鏈接過程分兩步:
第一步:空間與地址分配:掃描所有的輸入目標文件,并且獲得它們的各個段的長度、屬性和位置,并且將輸入目標文件中的符號表中所有的符號定義和符號引用收集起來,統一放到一個全局符號表。這一步中,鏈接器將能夠獲得所有輸入目標文件的段長度,并且將它們合并,計算出輸出文件中各個段合并后的長度與位置,并建立映射關系。
第二步:符號解析與重定位:使用上面第一步中收集到的所有信息,讀取輸入文件中段的數據、重定位信息,并且進行符號解析與重定位、調整代碼中的地址等。
使用ld鏈接器將”a.o”和”b.o”鏈接起來:$ ld a.o b.o -e main -o ab
”-e main”表示將main函數作為程序入口,ld鏈接器默認的程序入口為_start。”-o ab”表示鏈接輸出文件名為ab,默認為a.out。使用objdump來查看鏈接前后地址的分配情況,如下圖所示:
VMA表示Virtual Memory Addredd,即虛擬地址,LMA表示Load Memory Address,即加載地址,正常情況下這兩個值應該是一樣的,但是有些嵌入式系統中,特別是在那些程序放在ROM的系統中時,LMA和VMA是不相同的。
鏈接前后的程序中所使用的地址已經是程序在進程中的虛擬地址,即我們關心上面各個段中的VMA和Size,而忽略文件偏移(File off)。我們可以看到,在鏈接之前,目標文件中的所有段的VMA都是0,因為虛擬空間還沒有被分配,所以他們默認都為0.等到鏈接之后,可執行文件”ab”中的各個段都被分配到了相應的虛擬地址。這里的輸出程序”ab”中,”.text”段被分配到了地址0x00000000004000e8,大小為0x00000071字節;”.data”段從地址0x 00000000006001b8開始,大小為0x00000004字節。可以看到,”a.o”和”b.o”的代碼段被先后疊加起來,合并成”ab”的一個”.text”段,加起來的長度為0x00000071。所以”ab”的代碼段里面肯定包含了main函數和swap函數的指令代碼。
符號地址的確定:在第一步的掃描和空間分配階段,鏈接器按照前面介紹的空間分配方法進行分配,這時候輸入文件中的各個段在鏈接后的虛擬地址就已經確定了,比如”.text”段起始地址為0x00000000004000e8,”.data”段的起始地址為0x 00000000006001b8。當前面一步完成之后,鏈接器開始計算各個符號的虛擬地址。因為各個符號在段內的相對位置是固定的,所以這時候其實”main”、”shared”和”swap”的地址也已經是確定的了,只不過鏈接器須要給每個符號加上一個偏移量,使它們能夠調整到正確的虛擬地址。比如我們假設”a.o”中的”main”函數相對于”a.o”的”.text”段偏移是X,但是經過鏈接合并以后,”a.o”的”.text”段位于虛擬地址0x00000000004000e8,那么”main”的地址應該是0x00000000004000e8+X。從前面”objdump”的輸出看到,”main”位于”a.o”的”.text”段的最開始,也就是偏移為0,所以”main”這個符號在最終的輸出文件中的地址應該是0x00000000004000e8+0,即0x00000000004000e8。我們也可以通過完全一樣的計算方法得知所有符號的地址,在這個例子里面,只有三個全局符號,所以鏈接器在更新全局符號表的符號地址以后,各個符號的最終地址如下圖所示:
2. 符號解析與重定位
重定位:在完成空間和地址的分配步驟以后,鏈接器就進入了符號解析與重定位的步驟。使用objdump的”-d”參數查看”a.o”的代碼段反匯編結果如下圖所示:在程序的代碼里面使用的都是虛擬地址,在這里也可以看到”main”的起始地址為0x0000000000000000,這是因為在未進行前面提到過的空間分配之前,目標文件代碼段中的起始地址以0x0000000000000000開始,等到空間分配完成以后,各個函數才會確定自己在虛擬地址空間中的位置。從反匯編結果中,可以看到”a.o”共定義了一個函數main。這個函數占用0x26個字節,共11條指令;最左邊那列是每條指令的偏移量,每一行代表一條指令(有些指令的長度很長)。圖中紅框標出了兩個引用”shared”和”swap”的位置,對于”shared”的引用是一條”mov”指定。另外一個是偏移為0x20的指令的一條調用指令,它其實就表示對swap函數的調用。
鏈接器在完成地址和空間分配之后就已經可以確定所有符號的虛擬地址了,那么鏈接器就可以根據符號的地址對每個需要重定位的指令進行地位修正。用objdump來反匯編輸出程序”ab”的代碼段,可以看到main函數的兩個重定位入口都已經被修正到正確的位置,如下圖所示:經過修正以后,”shared”和“swap”的地址分別為0x6001b8和0x40010f。
重定位表:專門用來保存與重定位相關的信息,它在ELF文件中往往是一個或多個段。對于可重定位的ELF文件來說,它必須包含有重定位表,用來描述如何修改相應的段里的內容。對于每個要被重定位的ELF段都有一個對應的重定位表,而一個重定位表往往就是ELF文件中的一個段,所以其實重定位表也可以叫重定位段。可以使用objdump來查看文件的重定位表,如下圖所示:”objdump -r a.o”命令可以用來查看”a.o”里面要重定位的地方,即”a.o”所有引用到外部符號的地址。每個要被重定位的地方叫一個重定位入口(Relocation Entry),可以看到”a.o”里面有兩個重定位入口。重定位入口的偏移(Offset)表示該入口在要被重定位的段中的位置,”RELOCATION RECORDS FOR [.text]”表示這個重定位是代碼段的重定位表,所以偏移表示代碼段中需要被調整的位置。對照前面的反匯編結果可以知道,這里的0x14和0x21分別就是代碼段中的”mov”指令和”callq”指令的地址部分。
對于64位的重定位表是一個Elf64_Rel結構的數組,每個數組元素對應一個重定位入口。Elf64_Rel的定義如下:
typedef struct
{Elf64_Addr??? r_offset; /* Address */Elf64_Xword?? r_info; /* Relocation type and symbol index */
} Elf64_Rel;
Elf64_Rel結構說明如下:
符號解析:如果直接使用ld來鏈接”a.o”,而不將”b.o”作為輸入,鏈接器就會發現shared和swap兩個符號沒有被定義,沒有辦法完成鏈接工作,如下圖所示:就是鏈接時符號未定義。導致這個問題的原因很多,最常見的一般都是鏈接時缺少了某個庫,或者輸入目標文件路徑不正確或符號的聲明與定義不一樣。
其實重定位過程也伴隨著符號的解析過程,每個目標文件都可能定義一些符號,也可能引用到定義在其它目標文件的符號。重定位的過程中,每個重定位的入口都是對一個符號的引用,那么當鏈接器須要對某個符號的引用進行重定位時,它就要確定這個符號的目標地址。這時候鏈接器就會去查找由所有輸入目標文件的符號表組成的全局符號表,找到相應的符號后進行重定位。比如查看”a.o”的符號表,如下圖所示”GLOBAL”類型的符號,除了”main”函數是定義在代碼段之外,其它兩個”shared”和”swap”都是”UND”,即”undefined”未定義類型,這種未定義的符號都是因為該目標文件中有關于它們的重定位項。所以在鏈接器掃描完所有的輸入目標文件之后,所有這些未定義的符號都應該能夠在全局符號表中找到,否則鏈接器就報符號未定義錯誤。
指令修正方式:不同的處理器指令對于地址的格式和方式都不一樣。尋址方式有如下區別:近址尋址或遠址尋址;絕對尋址或相對尋址;尋址長度為8位、16位、32位或64位。絕對尋址修正和相對尋址修正的區別就是絕對尋址修正后的地址為該符號的實際地址;相對尋址修正后的地址為符號距離被修正位置的地址差。
???????? 3. COMMON塊:現在的編譯器和鏈接器都支持一種叫COMMON塊(Common Block)的機制。現代的鏈接機制在處理弱符號的時候,采用的就是與COMMON塊一樣的機制,當不同的目標文件需要的COMMON塊空間大小不一致時,以最大的那塊為準。COMMON類型的鏈接規則是針對符號都是弱符號的情況,如果其中有一個符號為強符號,那么最終輸出結果中的符號所占空間與強符號相同。值的注意的是,如果鏈接過程中有弱符號大小大于強符號,那么ld鏈接器會報警告。未初始化的全局變量就是典型的弱符號。GCC的”-fno-common”也允許我們把所有未初始化的全局變量不以COMMON塊的形式處理,或者使用”__attribute__”擴展,即int global __attribute__((nocommon)); 一旦一個未初始化的全局變量不是以COMMON塊的形式存在,那么它就相當于一個強符號,如果其它目標文件中還有同一個變量的強符號定義,鏈接時就會發生符號重復定義錯誤。
4. C++相關問題
C++的一些語言特性使之必須由編譯器和鏈接器共同支持才能完成工作。最主要的有兩個方面,一個是C++的重復代碼消除,還有一個就是全局構造與析構。另外由于C++語言的各種特性,比如虛函數、函數重載、繼承、異常等,使得它背后的數據結構異常復雜,這些數據結構往往在不同的編譯器和鏈接器之間相互不能通用,使得C++程序的二進制兼容性成了一個很大的問題。
重復代碼消除:C++編譯器在很多時候會產生重復的代碼,比如模板(Templates)、外部內聯函數(Extern Inline Function)和虛函數表(Virtual Function Table)都有可能在不同的編譯單元里生成相同的代碼。如模板,從本質上來講很像宏,當模板在一個編譯單元里被實例化時,它并不知道自己是否在別的編譯單元也被實例化了。所以當一個模板在多個編譯單元同時實例化成相同的類型的時候,必然會生成重復的代碼。當然,最簡單的方案就是不管這些,將這些重復的代碼都保留下來,不過這樣做的主要問題有以下幾個方面:空間浪費、地址容易出錯、指令運行效率較低。
一個比較有效的做法就是將每個模板的實例代碼都單獨地存放在一個段里,每個段只包含一個模板實例。這樣鏈接器在最終鏈接的時候可以區分這些相同的模板實例段,然后將它們合并入最后的代碼段。這種做法被主流的編譯器所采用,GNU GCC編譯器和VISUAL C++編譯器都采用了類似的方法。GCC把這種類似的需要在最終鏈接時合并的段叫”Link Once”,它的做法是將這種類型的段命名為”.gnu.linkonce.name”,其中”name”是該模板函數實例的修飾后名稱。VISUAL C++編譯器做法稍有不同,它把這種類型的段叫做”COMDAT”,這種”COMDAT”段的屬性字段(PE文件的段表結構里面的IMAGE_SECTION_HEADER的Characteristics成員)都有IMAGE_SCN_LNK_COMDAT這個標記,在鏈接器看到這個標記后,它就認為該段是COMDAT類型的,在鏈接時會將重復的段丟棄。
這種重復代碼消除對于模板來說是這樣的,對于外部內聯函數和虛函數表的做法也類似。比如對于一個有虛函數的類來說,有一個與之相對應的虛函數表(Virtual Function Table,一般簡稱vtbl),編譯器會在用到該類的多個編譯單元生成虛函數表,造成代碼重復;外部內聯函數、默認構造函數、默認拷貝構造函數和賦值操作符也有類似的問題。它們的解決方式基本跟模板的重復代碼消除類似。
這種方法雖然能夠基本上解決代碼重復的問題,但還是存在一些問題。比如相同名稱的段可能擁有不同的內容,這可能由于不同的編譯單元使用了不同的編譯器版本或者編譯優化選項,導致同一個函數編譯出來的實際代碼有所不同。那么這種情況下鏈接器可能會做出一個選擇,那就是隨意選擇其中任何一個副本作為鏈接的輸入,然后同時提供一個警告信息。
函數級別鏈接:由于現在的程序和庫通常來講都非常龐大,一個目標文件可能包含成千上百個函數或變量。當我們需要用到某個目標文件中的任意一個函數或變量時,就需要把它整個地鏈接起來,也就是說那些沒有用到的函數也被一起鏈接了起來。這樣的后果是鏈接輸出文件會變得很大,所有用到的沒用到的變量和函數都一起塞到了輸出文件中。
VISUAL C++編譯器提供了一個編譯選項叫函數級別鏈接(Functional-Level Linking, /Gy),這個選項的作用就是讓所有的函數都向前面模板函數一樣,單獨保存到一個段里面。當鏈接器需要用到某個函數時,它就將它合并到輸出文件中,對于那些沒有用的函數則將它們拋棄。這種做法可以很大程度上減少輸出文件的長度,減少空間浪費。但是這個優化選項會減慢編譯和鏈接過程,因為鏈接器需要計算各個函數之間的依賴關系,并且所有函數都保存到獨立的段中,目標函數的段的數量大大增加,重定位過程也會因為段的數目的增加而變得復雜,目標文件隨著段數目的增加也會變得相對較大。
GCC編譯器也提供了類似的機制,它有兩個選擇分別是”-ffunction-sections”和”-fdata-sections”,這兩個選項的作用就是將每個函數或變量分別保持到獨立的段中。
全局構造與析構:一般的一個C/C++程序時從main開始執行的,隨著main函數的結束而結束。然而,其實在main函數被調用之前,為了程序能夠順利執行,要先初始化進程執行環境,比如堆分配初始化(malloc, free)、線程子系統等。C++的全局對象構造函數也是在這一時期被執行的,C++的全局對象的構造函數在main之前被執行,C++全局對象的析構函數在main之后被執行。
Linux系統下一般程序的入口是”_start”,這個函數是Linux系統庫(Glibc)的一部分。當我們的程序與Glibc庫鏈接在一起形成最終可執行文件以后,這個函數就是程序的初始化部分的入口,程序初始化部分完成一系列初始化過程之后,會調用main函數來執行程序的主體。在main函數執行完成以后,返回到初始化部分,它進行一些清理工作,然后結束進程。
ELF文件還定義了兩種特殊的段:
(1). .init:該段里面保存的是可執行指令,它構成了進程的初始化代碼。因此,當一個程序開始運行時,在main函數被調用之前,Glibc的初始化部分安排執行這個段的中的代碼。
(2). .fini:該段保存著進程終止代碼指令。因此,當一個程序的main函數正常退出時,Glibc會安排執行這個段中的代碼。
這兩個段.init和.fini的存在有著特別的目的,如果一個函數放到.init段,在main函數執行前系統就會執行它。同理,假如一個函數放到.fini段,在main函數返回后該函數就會被執行。利用這兩個特性,C++的全局構造和析構函數就由此實現。
C++與ABI:如果要使兩個編譯器編譯出來的目標文件能夠相互鏈接,那么這兩個目標文件必須滿足下面這些條件:采用同樣的目標文件格式、擁有同樣的符號修飾標準、變量的內存分布方式相同、函數的調用方式相同,等等。其中我們把符號修飾標準、變量內存布局、函數調用方式等這些跟可執行代碼二進制兼容性相關的內容稱為ABI(Application Binary Interface)。API往往是指源代碼級別的接口;而ABI是指二進制層面的接口。影響ABI的因素非常多,硬件、編程語言、編譯器、鏈接器、操作系統等都會影響ABI。對于C語言的目標代碼來說,以下幾個方面會決定目標文件之間是否二進制兼容:
(1). 內置類型(如int、float、char等)的大小和在存儲器中的放置方式(大端、小端、對齊方式等)。
(2). 組合類型(如struct、union、數組等)的存儲方式和內存分布。
(3). 外部符號(external-linkage)與用戶定義的符號之間的命名方式和解析方式,如函數名func在C語言的目標文件中是否被解析成外部符號_func。
(4). 函數調用方式,比如參數入棧順序、返回值如何保持等。
(5). 堆棧的分布方式,比如參數和局部變量在堆棧里的位置,參數傳遞方法等。
(6). 寄存器使用約定,函數調用時哪些寄存器可以修改,哪些需要保存,等等。
這只是一部分因素。到了C++的時代,語言層面對ABI的影響又增加了很多額外的內容,正是這些內容使C++要做到二進制兼容比C來得更為不易:
(7). 繼承類體系的內存分布,如基類,虛基類在繼承類中的位置等。
(8). 指向成員函數的指針(pointer-to-member)的內存分別,如何通過指向成員函數的指針來調用成員函數,如何傳遞this指針。
(9). 如何調用虛函數,vtable的內容和分布形式,vtable指針在object中的位置等。
(10). template如何實例化。
(11). 外部符號的修飾。
(12). 全局對象的構造和析構。
(13). 異常的產生和捕獲機制。
(14). 標準庫的細節問題,RTTI如何實現等。
(15). 內嵌函數訪問細節。
C++一直為人詬病的一大原因是它的二進制兼容性不好,或者說比起C語言來更為不易。不僅不同的編譯器編譯的二進制代碼之間無法相互兼容,有時候連同一個編譯器的不同版本之間兼容性也不好。
5. 靜態庫鏈接:
在一般的情況下,一種語言的開發環境往往會附帶有語言庫(Language Library)。這些庫就是對操作系統的API(Application Programming Interface, 應用程序編程接口)的包裝,比如C語言版”Hello World”程序,它使用C語言標準庫的”printf”函數來輸出一個字符串,”printf”函數對字符串進行一些必要的處理后,最后會調用操作系統提供的API。各個操作系統下,往終端輸出字符串的API都不一樣,在Linux下,它是一個”write”的系統調用,而在Windows下它是”WriteConsole”系統API。
其實一個靜態庫可以簡單地看成一組目標文件的集合,即很多目標文件經過壓縮打包后形成的一個文件。比如我們在Linux中最常用的C語言靜態庫libc位于/usr/lib/x86_64-linux-gnu/libc.a,它屬于glibc項目的一部分;像Windows這樣的平臺上,最常使用的C語言庫是由集成開發環境所附帶的運行庫,這些庫一般由編譯器廠商提供,比如Visual C++附帶了多個版本的C/C++運行庫。
在一個C語言的運行庫中,包含了很多跟系統功能相關的代碼,比如輸入輸出、文件操作、時間日期、內存管理等。glibc本身是用C語言開發的,它由成百上千個C語言源代碼文件組成,也就是說,編譯完成以后有相同數量的目標文件,比如輸入輸出有printf.o,scanf.o;文件操作有fread.o, fwrite.o;時間日期有date.o, time.o;內存管理有malloc.o等。把這些零散的目標文件直接提供給庫的使用者,很大程度上會造成文件傳輸、管理和組織方面的不便,于是通常人們使用”ar”壓縮程序將這些目標文件壓縮到一起,并且對其進行編號和索引,以便于查找和檢索,就形成了libc.a這個靜態庫文件。我們也可以使用”ar”工具來查看這個文件包含了哪些目標文件,如下圖所示,僅顯示一小部分:
Visual C++也提供了與Linux下的ar類似的工具,叫lib.exe,這個程序可以用來創建、提取、列舉.lib文件中的內容。使用”lib.exe /LIST ../libcmt.lib”就可以列舉出libcmt.lib中所有的目標文件,如下圖所示:
libc.a里面包含了很多個目標文件,我們如何在這么多目標文件中找到”printf”函數所在的目標文件呢?是使用”objdump”或”readelf”加上文本查找工具如”grep”,執行結果如下圖所示:可以看到”printf”函數被定義在了”printf.o”這個目標文件中。通過命令“$ar -x /usr/lib/x86_64-linux-gnu/libc.a”,會將libc.a中的所有目標文件”解壓”至當前目錄,也可以找到”printf.o”。
可以通過GCC的”--verbose”參數將整個編譯鏈接過程的中間步驟打印出來,默認情況下,GCC會自作聰明地將”Hello World”程序中只使用了一個字符串參數的”printf”替換成”puts”函數,以提高運行速度,要使用”-fno-builtin”參數關閉這個內置函數優化選項,執行結果如下圖所示:關鍵的三個步驟在圖中已經用紅線框起來了,第一步是調用cc1程序,這個程序實際上就是GCC的C語言編譯器,它將”hello.c”編譯成一個臨時的匯編文件”/tmp/ccRURta3.s”;然后調用as程序,as程序是GNU的匯編器,它將”/tmp/ccRURta3.s”匯編成臨時目標文件”/tmp/ccAATVNy.o”,這個”/tmp/ccAATVNy.o”實際上就是”hello.o”;接著最關鍵的步驟是最后一步,GCC調用collect2程序來完成最后的鏈接。實際上collect2可以看做是ld鏈接器的一個包裝,它會調用ld鏈接器來完成對目標文件的鏈接,然后再對鏈接結果進行一些處理,主要是收集所有與程序初始化相關的信息并且構造初始化的結構。可以看到最后一步中,有幾個庫和目標文件被鏈接入了最終可執行文件。
為什么靜態運行庫里面一個目標文件只包含一個函數:比如libc.a里面pritnf.o只有printf()函數。鏈接器在鏈接靜態庫的時候是以目標文件為單位的。比如我們引用了靜態庫中的printf()函數,那么鏈接器就會把庫中包含printf()函數的那個目標文件鏈接進來,如果很多函數都放在一個目標文件中,很可能很多沒用的函數都被一起鏈接進了輸出結果中。由于運行庫有成百上千個函數,數量非常龐大,每個函數獨立地放在一個目標文件中可以盡量減少空間的浪費,那么沒有被用到的目標文件(函數)就不要鏈接到最終的輸出文件中。
6. 鏈接過程控制
絕大部分情況下,我們使用鏈接器提供的默認鏈接規則對目標文件進行鏈接。這在一般情況下是沒有問題的,但對于一些特殊要求的程序,比如操作系統內核、BIOS(Basic Input Output Syste)或一些在沒有操作系統的情況下運行的程序(如引導程序Boot Loader或者嵌入式系統的程序,或者有一些脫離操作系統的硬盤分區軟件PQMagic等),以及另外的一些需要特殊的鏈接過程的程序,如一些內核驅動程序等,它們往往受限于一些特殊的條件,如需要指定輸出文件的各個段虛擬地址、段的名稱、段存放的順序等,因為這些特殊的環境,特別是某些硬件條件的限制,往往對程序的各個段的地址有著特殊的要求。
由于整個鏈接過程有很多內容需要確定:使用哪些目標文件?使用哪些庫文件?是否在最終可執行文件中保留調試信息、輸出文件格式(可執行文件還是動態鏈接庫)?還要考慮是否要導出某些符號以供調試器或程序本身或其它程序使用等。
操作系統內核:從本質上來講,它本身也是一個程序。比如Windows的內核ntoskrnl.exe就是一個我們平常看到的PE文件,它的位置位于C:\Windows\System32\ntoskrnl.exe。很多人誤以為Windows操作系統的內核很龐大,由很多文件組成。這是一個誤解,其實真正的Windows內核就是這個文件。
鏈接控制腳本:鏈接器一般都提供多種控制整個鏈接過程的方法,以用來產生用戶所需要的文件。一般鏈接器有如下三種方法:
(1). 使用命令行來給鏈接器指定參數,如前面所使用的ld的-o, -e參數就屬于這類。
(2). 將鏈接指令存放在目標文件里面,編譯器經常會通過這種方法向鏈接器傳遞指令。方法也比較常見,比如VISUAL C++編譯器會把鏈接參數放在PE目標文件的.drectve段以用來傳遞參數。
(3). 使用鏈接控制腳本,也是最為靈活、最為強大的鏈接控制方法。
由于各個鏈接器平臺的鏈接控制過程各不相同。ld鏈接器的鏈接腳本功能非常強大。VISUAL C++也允許使用腳本來控制整個鏈接過程,VISUAL C++把這種控制腳本叫做模塊定義文件(Module-Definition File),它們的擴展名一般為.def。
ld在用戶沒有指定鏈接腳本的時候會使用默認鏈接腳本。我們可以使用”$ ld -verboase”命令來查看ld默認的鏈接腳本。默認的ld鏈接腳本存放在/usr/lib/ldscripts/下,不同的機器平臺、輸出文件格式都有相應的鏈接腳本,如下圖所示:ld會根據命令行要求使用相應的鏈接腳本文件來控制鏈接過程。當然,為了更加精確地控制鏈接過程,我們可以自己寫一個腳本,然后指定該腳本為鏈接控制腳本,比如可以使用-T參數,如link.script已存在,執行命令”$ ld -T link.script”。
最”小”的程序:為了演示鏈接的控制過程,我們接著要做一個最小的程序:這個程序的功能是在終端上輸出”Hello world!”。這個”小程序”能夠脫離C語言運行庫,使用nomain作為整個程序的入口,將”小程序”的所有段都合并到一個叫”tinytext”的段,這個段是我們任意命名的,是由鏈接腳本控制鏈接過程生成的。
TinyHelloWorld.c源代碼如下:
char* str = "Hello world!\n";void print()
{asm("movl $13, %%edx \n\t""movl %0, %%ecx \n\t""movl $0, %%ebx \n\t""movl $4, %%eax \n\t""int $0x80 \n\t"::"r"(str):"edx", "ecx", "ebx");
}void exit()
{asm("movl $42, %ebx \n\t""movl $1, %eax \n\t""int $0x80 \n\t");
}void nomain()
{print();exit();
}
依次執行命令及結果如下:
從源代碼可以看到,程序入口為nomain()函數,然后該函數調用print()函數,打印”Hello World”,接著調用exit()函數,結束進程。這里的print函數使用了Linux的WRITE系統調用,exit()函數使用了EXIT系統調用。這里我們使用了GCC內嵌匯編。
GCC和ld的參數意義如下:
(1). -fno-builtin:GCC編譯器提供了很多內置函數(Built-in Function),它會把一些常用的C庫函數替換成編譯器的內置函數,以達到優化的功能。比如GCC會將只有字符串參數的printf函數替換成puts,以節省格式解析的時間。exit()函數也是GCC的內置參數之一,所以要使用-fno-builtin參數來關閉GCC內置函數功能。
(2). -static:這個參數表示ld將使用靜態鏈接的方式來鏈接程序,而不是使用默認的動態鏈接的方式。
(3). -e nomain:表示該程序的入口函數為nomain,這個參數就是將ELF文件頭的e_entry成員賦值成nomain函數的地址。
(4). -o TinyHelloWorld:表示指定輸出可執行文件名為TinyHelloWorld。
使用ld鏈接腳本:如果把整個鏈接過程比作一臺計算機,那么ld鏈接器就是計算機的CPU,所有的目標文件、庫文件就是輸入,鏈接結果輸出的可執行文件就是輸出,而鏈接控制腳本正是這臺計算機的”程序”,它控制CPU的運行,以”程序”要求的方式將輸入加工成所需要的輸出結果。鏈接控制腳本”程序”使用一種特殊的語言寫成,即ld的鏈接腳本語言。
無論是輸出文件還是輸入文件,它們的主要的數據就是文件中的各種段,我們把輸入文件中的段稱為輸入段(Input Sections),輸出文件中的段稱為輸出段(Output Sections)。簡單來講,控制鏈接過程無非是控制輸入段如何變成輸出段,比如哪些輸入段合并一個輸出段,哪些輸入段要丟棄;指定輸出段的命名、裝載地址、屬性,等等。TinyHelloWorld的鏈接腳本TinyHelloWorld.lds(一般鏈接腳本名都以lds作為擴展名ld script)的內容如下:
ENTRY(nomain)SECTIONS
{. = 0X08048000 + SIZEOF_HEADERS;tinytext : { *(.text) *(.data) *(.rodata) }/DISCARD/ : { *(.comment) }
}
這是一個非常簡單的鏈接腳本,第一行的ENTRY(nomain)指定了程序的入口為nomain()函數;后面的SECTIONS命令一般是鏈接腳本的主體,這個命令指定了各種輸入段到輸出段的變換,SECTIONS后面緊跟著的一對大括號里面包含了SECTIONS變換規則,其中有三條語句,每條語句一行。第一條是賦值語句,后面兩條是段轉換規則,它們的含義分別如下:
(1). . = 0x08048000 + SIZEOF_HEADERS:第一條賦值語句的意思是將當前虛擬地址設置成0x08048000 + SIZEOF_HEADERS,SIZEOF_HEADERS為輸出文件的文件頭大小。”.”表示當前虛擬地址,因為這條語句后面緊跟著輸出段”tinytext”,所以”tinytext”段的起始虛擬地址即為0x08048000 + SIZEOF_HEADERS。它將當前虛擬地址設置成一個比較巧妙的值,以便于裝載時頁映射更為方便。
(2). tinytext: { *(.text) *(.data) *(.rodata) }:第二條是個段轉換規則,它的意思即為所有輸入文件中的名字為”.text”, “.data”或”.rodata”的段依次合并到輸出文件的”tinytext”。
(3). /DISCARD/ : { *(.comment) }:第三條規則為:將所有輸入文件中的名字為”.comment”的段丟棄,不保存到輸出文件中。
通過上述兩條轉換規則,我們就達到了TinyHelloWorld程序的第三個要求:最終輸出的可執行文件只有一個叫做”tinytext”的段。通過以下命令編譯并且啟用該鏈接控制腳本,結果如下圖所示:
執行這個程序能夠在終端上正確顯示”Hello world!”。如果使用objdump查看TinyHelloWorld的段,我們達到了目的,有一個段”tinytext”。你可以通過ld的-s參數禁止鏈接器產生符號表,或者使用strip命令去除程序中的符號表。
ld鏈接腳本語法簡介:ld鏈接器的鏈接腳本語法繼承與AT&T鏈接器命令語言的語法,風格有點像C語言。鏈接腳本由一系列語句組成,語句分兩種,一種是命令語句,另外一種是賦值語句。如ENTRY(nomain)就是命令語句,而. = 0x08048000 + SIZEOF_HEADERS則是一個賦值語句。之所以說鏈接腳本語法像C語言,主要有如下幾點相似之處:
(1). 語句之間使用分號”;”作為分隔符:原則上講語句之間都要以”;”作為分隔符,但是對于命令語句來說也可以使用換行來結束該語句,對于賦值語句來說必須以”;”結束。
(2). 表達式與運算符:腳本語言的語句中可以使用C語言類似的表達式和運算操作符,比如+, -, *, /, +=, -=, *=等,甚至包括&, |, >>, <<等這些位操作符。
(3). 注釋和字符引用:使用/* */作為注釋。腳本文件中使用到的文件名、格式名或段名等凡是包含”;”或其它的分隔符的,都要使用雙引號將該名字全稱引用起來,如果文件名包含引號,則很不幸,無法處理。
命令語句一般的格式是由一個關鍵字和緊跟其后的參數所組成。
7. BFD庫
BFD庫(Binary File Descriptor library)是一個GNU項目,它的目標就是希望通過一種統一的接口來處理不同的目標文件格式。BFD這個項目本身是binutils項目的一個子項目。BFD把目標文件抽象成一個統一的模型,比如在這個抽象的目標文件模型中,最開始有一個描述整個目標文件總體信息的”文件頭”,就跟我們實際的ELF文件一樣,文件頭后面是一系列的段,每個段都有名字、屬性和段的內容,同時還抽象了符號表、重定位表、字符串表等類似的概念,使得BFD庫的程序只要通過操作這個抽象的目標文件模型就可以實現操作所有BFD支持的目標文件格式。
現在GCC、鏈接器ld、調試器GDB及binutils的其它工具都通過BFD庫來處理目標文件,而不是直接操作目標文件。這樣做最大的好處是將編譯器和鏈接器本身同具體的目標文件格式隔離開來,一旦我們需要支持一種新的目標文件格式,只需要在BFD庫里面添加一種格式就可以了,而不需要修改編譯器和鏈接器。
GitHub:?https://github.com/fengbingchun/Messy_Test?
總結
以上是生活随笔為你收集整理的程序员的自我修养--链接、装载与库笔记:静态链接的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 深度学习中的Dropout简介及实现
- 下一篇: 程序员的自我修养--链接、装载与库笔记: