Linux下編譯、鏈接和裝載


 

——《程序員的自我修養》讀書筆記

編譯過程

在Linux下使用GCC將源碼編譯成可執行文件的過程可以分解為4個步驟,分別是預處理(Prepressing)、編譯(Compilation)、匯編(Assembly)和鏈接(Linking)。一個簡單的hello word程序編譯過程如下:

1. 預處理

首先源代碼文件(.c/.cpp)和相關頭文件(.h/.hpp)被預處理器cpp預編譯成.i文件(C++為.ii)。預處理命令為:

gcc –E hello.c –o hello.i

預編譯過程主要處理那些源代碼中以#開始的預編譯指令,主要處理規則如下:

u  將所有的#define刪除,並且展開所有的宏定義;

u  處理所有條件編譯指令,如#if,#ifdef等;

u  處理#include預編譯指令,將被包含的文件插入到該預編譯指令的位置。該過程遞歸進行,及被包含的文件可能還包含其他文件。

u  刪除所有的注釋//和 /**/;

u  添加行號和文件標識,如#2 “hello.c” 2,以便於編譯時編譯器產生調試用的行號信息及用於編譯時產生編譯錯誤或警告時能夠顯示行號信息;

u  保留所有的#pragma編譯器指令,因為編譯器須要使用它們。

2. 編譯

編譯過程就是把預處理完的文件進行一系列詞法分析,語法分析,語義分析及優化后生成相應的匯編代碼文件(.s)。編譯的命令為:

gcc –S hello.i –o hello.s

或者從源文件直接輸出匯編代碼文件:

gcc –S hello.c –o hello.s

現在版本的GCC把預編譯和編譯兩個步驟合並成一個步驟,由程序cc1來完成(C++為cc1plus)。

3. 匯編

匯編就是將匯編代碼轉變成機器可以執行的命令,生成目標文件(.o),匯編器as根據匯編指令和機器指令的對照表一一翻譯即可完成。匯編的命令為:

gcc –c hello.s –o hello.o

或者從源文件直接輸出目標文件:

gcc –c hello.c –o hello.o

4. 鏈接

鏈接就是鏈接器ld將各個目標文件組裝在一起,解決符號依賴,庫依賴關系,並生成可執行文件。鏈接的命令為:

ld –static crt1.o crti.o crtbeginT.o hello.o –start-group –lgcc –lgcc_eh –lc-end-group crtend.o crtn.o

一般我們使用一條命令就可以完成上述4個步驟:

gcc hello.c

實際上gcc只是一些其它程序的包裝,它會根據不同參數去調用預編譯編譯程序cc1、匯編器as、鏈接器ld。

目標文件

Linux下的可執行文件格式為ELF(Executalbe Linkable Format),包括可執行文件、可重定位文件(目標文件.o、靜態庫.a)、共享目標文件(動態庫.so)、核心轉儲文件(core dump)。ELF目標文件的結構如下:

其中ELF文件中與段有關的重要結構就是段表(Section Header Table),該表描述了ELF文件包含的所有段的信息,比如每個段的名稱、長度、在文件中的偏移、讀寫權限及段的其他屬性。我們可以通過readelf工具來查看ELF文件的段:

幾個比較重要的段如下:

段名

說明

.text

存放編譯后的機器指令

.data

存放已初始化的全局靜態變量和局部靜態變量

.rodata

存放只讀數據,如全局const變量、字符串常量

.bss

存放未初始化的全局靜態變量和局部靜態變量

.symtab

符號表,記錄符號信息

.rel.xxx

重定位表,記錄.xxx段中需要重定位定位符號

鏈接過程的本質就是要把多個不同目標文件粘合成一個整體,目標文件之間相互拼合實際上是目標文件之間對地址的引用,即對函數和變量的地址的引用。在鏈接中,我們將函數和變量統稱為符號(Symbol),函數名和變量名就是符號名(Symbol Name),我們可以將符號看做是鏈接中的粘合劑,整個鏈接過程正是基於符號才能夠正確完成。每個目標文件都會有一個符號表(Symbol Table),即上圖的.symtab段,這個表里記錄了目標文件所用到的所有符號。每個定義的符號有一個對應的值,叫做符號值(Symbol Value),對於變量和函數來說,符號值就是它們的地址。我們可以通過readelf工具來查看符號表中所有的符號信息:

靜態鏈接

鏈接器鏈接的過程,就是將幾個輸入的目標文件加工后合並成一個輸出文件。合並的方法簡單來說,就是將相同性質的段合並到一起,比如將輸入文件的.text段合並到.text段,接着是.data段、.bss段等,如下圖所示:

鏈接器一般采用一種叫做兩步鏈接的方法:

  1. 空間與地址分配。鏈接器掃描所有的輸入目標文件,將它們的段進行合並,計算出輸出文件中各個段合並后的長度和位置,建立映射關系;並且將輸入目標文件的符號表中的所有符號定義和符號引用收集起來,統一放到一個全局的符號表。
  2. 符號解析和重定位。使用上一步收集到的所有信息,讀取輸入文件中段的數據、重定位信息,並且進行符號解析與重定位、調整代碼中的位置等,這一步是鏈接過程的核心。

在鏈接之前,目標文件中所有段的虛擬地址都是0,因為虛擬空間還沒有被分配。鏈接之后,輸出文件的各個段都被分配到了相應的虛擬地址。同樣的,鏈接器將輸入文件中段進行合並后,就能計算出符號表中的符號在所在段的新的偏移量,通過符號所在段的虛擬地址和符號在段中的偏移量,就可以計算出符號最終的虛擬的地址。

重定位是連接符號引用和符號定義的過程。在目標文件中,有一個叫重定位表(Relocation Table)的結構專門用來保存與重定位相關的信息,對於每個要被重定位的ELF段都有一個對應的重定位表,而一個重定位表往往就是ELF文件中的一個段。比如.text段和.data段都有被重定位的地方,那么就會有相應的重定位表.rel.text段和.rel.data段。每個要被重定位的地方叫做一個重定位入口(Relocation Entry),重定位入口的偏移表示該入口在要被重定位的段中的位置。重定位的過程中,每個重定位入口都是對一個符號的引用,那么當鏈接器需要對某個符號的引用進行重定位時,它就要確定這個符號的目標地址,這時候鏈接器就會去查找由所有輸入目標文件的符號表組成的全局符號表,找到相應的符號進行重定位。我們看下面的符號表:

類型為GLOBAL的符號shared和swap都是UND,這種未定義的符號是因為該目標文件中有關於它們的重定位項。所以在鏈接器掃描完所有的輸入目標文件之后,所有這些未定義的符號都應該能在全局符號表中找到,否則鏈接器就報符號未定義錯誤。

在靜態鏈接中,除了鏈接源代碼生成的目標文件,還需要鏈接其它靜態庫,如C語言靜態庫libc。其實靜態庫可以簡單地看成是一組目標文件的集合,即很多目標文件經過壓縮打包后形成的一個文件。我們可以使用ar工具來查看靜態庫中包含了那些目標文件:

鏈接器在鏈接靜態庫的時候是以目標文件為單位的,只有引用了靜態庫中某個目標文件中定義的符號,才會把改目標文件鏈進來。

裝載

可執行文件只有被裝載到內存以后才能被CPU執行。操作系統創建一個進程,然后裝載相應的可執行文件並且執行,這個過程最開始只需要做3件事情:

  1. 創建一個獨立的虛擬地址空間。創建虛擬空間實際上只是分配一個頁目錄,虛擬空間到物理內存的映射關系等到后面程序發生頁錯誤的時候再進行設置。
  2. 讀取可執行文件頭(Program Header Table),並且建立虛擬空間與可執行文件的映射關系。當操作系統捕獲到缺頁錯誤時,通過該映射關系就知道當前所需要的頁在可執行文件中的位置。這種映射關系是按照段(Segment)進行映射的,進程虛擬空間中的一個段叫做虛擬內存區域(VMA,Virtual Memory Area)。
  3. 將CPU的執行寄存器設置成可執行文件的入口地址,啟動運行。ELF文件頭中保存了入口地址,操作系統通過設置CPU指令寄存器將控制權轉交給進程,由此進程開始執行。

進程虛擬空間中的一個段叫做虛擬內存區域(VMA,Virtual Memory Area),一個VMA按照一個Segment來映射可執行文件,在ELF文件中把權限相同的Section合並成一個Segment,系統正式按照Segment而非Section來映射可執行文件的。從Section的角度來看ELF文件就是連接視圖(Linking View),從Segment的角度來看就是執行視圖(Executiong View)。當我們在談到ELF裝載時,段專門指Segment;而在其他情況下,段指的是Section。

ELF文件的Segment信息保存在可執行文件頭(Program Header Table),它描述了ELF文件如何被操作系統映射到進程的虛擬空間,可以通過readelf工具查看Segment:

 

在上圖中,類型為LOAD的兩個Segment是需要被映射的,我們還可以看到哪些Section被合並到了這兩個Segment中。

VMA除了被用來映射可執行文件中的各個Segment,進程在執行時用到的堆、棧等空間也是以VMA的形式存在的。我們可以查看進程虛擬空間的分布:

操作系統通過給進程空間划分出一個個VMA來管理進程的虛擬空間,基本原則是將相同權限屬性的、有相同映像文件的映射成一個VMA。一個進程基本上可以分為如下幾種VMA區域:

  • 代碼VMA,權限只讀、可執行,有映像文件。
  • 數據VMA,權限可讀寫、可執行,有映像文件。
  • 堆VMA,權限可讀寫、可執行,無映像文件,匿名,可向上擴展。
  • 棧VMA,權限可讀寫、不可執行,無映像文件,匿名,可向下擴展。

一個常見進程的虛擬空間如下圖所示:

動態鏈接

動態鏈接的基本思想是把程序按照模塊拆分成相對獨立的部分,在程序運行時才將它們鏈接在一起形成一個完整的程序,而不是像靜態鏈接一樣把所有的程序模塊都連接成一個單獨的可執行文件。ELF動態鏈接文件被稱為動態共享對象(DSO,Dynamic Shared Object),簡稱共享對象,它們一般都是.so為擴展名的文件。相比靜態鏈接,動態鏈接有兩個優勢,一是共享對象在磁盤和內存只有一份,節省了空間;二是升級某個共享模塊時,只需要將目標文件替換,而無須將所有的程序重新鏈接。

共享對象的最終裝載地址在編譯時是不確定的,而是在裝載時,裝載器根據當前地址空間的空閑情況,動態分配一塊足夠大小的虛擬地址空間給相應的共享對象。為了能夠使共享對象在任意地址裝載,在連接時對所有絕對地址的引用不作重定位,而把這一步推遲到裝載時再完成,即裝載時重定位。同時為了實現共享模塊的指令部分在多個進程間共享,共享的指令部分就不能因為裝載地址的改變而改變,解決方法就是把指令中那些需要被修改的部分分離出來,和數據部分放在一起,這樣指令部分就可以保持不變,而數據部分可以在每個進程中擁有一個副本,這種方案就是地址無關代碼(PIC,Position-independent Code)的技術,我們在GCC中使用-fPIC參數來生成地址無關代碼。對於模塊內部的符號引用使用的是相對地址,所以這種指令是不需要重定位的;而對於模塊外部的符號引用,做法是在數據段建立一個全局偏移表(GOT,Global Offset Table),代碼通過GOT中相對應的項進行間接引用,對GOT的引用同樣使用相對地址,基本機制如下:

在動態鏈接情況下,操作系統在映射完可執行文件之后,會啟動一個動態鏈接器(Dynamic Linker),動態鏈接器ld.so實際上也是一個共享對象,操作系統同樣通過映射的方式將它加載到進程的地址空間中,並將控制權交給動態鏈接器的入口地址,動態鏈接器開始執行一系列自身的初始化操作,然后根據當前的環境參數,開始對可執行文件進行動態鏈接工作,當所有動態鏈接工作完成以后,動態鏈接器會將控制權轉交到可執行文件的入口地址,程序開始正式執行。

動態鏈接ELF中最重要的結構是.dynamic段,這個段里面保存了動態鏈接器所需要的基本信息,如依賴於哪些共享對象、動態鏈接符號的位置、動態鏈接重定位表的位置、共享對象初始化代碼的地址等。使用readelf工具可以查看.dynamic段的內容:

另外還可以通過ldd工具來查看一個程序或共享庫依賴哪些共享庫:

為了表示動態鏈接模塊之間的符號導入導出關系,ELF專門有一個叫做動態符號表(Dynamic Symbol Table)的段來保存這些信息,這個段通常叫做.dynsym,它只保存了與動態鏈接相關的符號,靜態鏈接符號表.syntab保存了所有的符號,一般動態鏈接模塊同時擁有兩個符號表。可以使用readelf工具來查看ELF文件的動態符號表:

動態鏈接基本上分為3步:

  1. 動態鏈接器自舉。當操作系統將進程控制權交給動態鏈接器時,動態鏈接器的自舉代碼開始執行。自舉代碼獲得動態鏈接器本身的重定位表和符號表,將它們重定位后,才可以使用自己的全局變量和靜態變量。
  2. 裝載共享對象。完成自舉以后,動態鏈接器將可執行文件和鏈接器本身的符號表都合並到一個全局符號表。然后鏈接器開始尋找可執行文件所依賴的共享對象,並將這些共享對象的名字放入到一個裝載集合中。鏈接器開始從集合里取一個所需要的共享對象的名字,打開相應的文件並讀取ELF文件頭和.dynamic段,然后將它對應的代碼段和數據段映射到進程空間。如果這個ELF共享對象還依賴於其他的共享對象,那么將所依賴的共享對象放入到裝載集合中。如此循環知道所有依賴的共享對象都被裝載進來為止。
  3. 重定位和初始化。鏈接器開始重新遍歷可執行文件和每個共享對象的重定位表,將他們的GOT中每個需要重定位的位置進行修正。重定位完成之后如果某個共享對象有.init段,那么動態鏈接器就會執行.init段中的代碼,用以實現共享對象特有的初始化過程,比如共享對象中的C++全局/靜態對象的構造。

動態鏈接還有一種更加靈活的模塊加載方式,叫做顯式運行時鏈接,也就是讓程序自己在運行時控制加載指定的模塊,並且可以在不需要該模塊時將其卸載,這種共享對象往往被稱為動態裝載庫,可以用來實現諸如插件、驅動等功能。動態庫和一般的共享對象沒有區別,不同的是共享對象是有動態鏈接器在程序啟動之前負責裝載和鏈接的,這個過程對程序本身是透明的;而動態庫的裝載則是通過一系列由動態鏈接器提供的API,具體地講共有4個函數:打開動態庫(dlopen)、查找符號(dlsym)、錯誤處理(dlerror)和關閉動態庫(dlclose),程序可以通過這幾個API對動態庫進行操作。

動態鏈接和靜態鏈接相比,性能上大約要慢有1%-5%。有兩個原因影響了動態鏈接的性能,一是程序開始執行時,動態鏈接器都要進行一次鏈接工作,會減慢程序的啟動速度;二是對模塊外部的符號引用需要通過GOT進行間接訪問。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM