C/C++ 代碼在變成可執行文件以前須要經歷預處理、編譯、彙編以及連接這幾個步驟,最終生成的可執行文件包含了可以被系統處理的機器碼。可執行文件必須按照特定的格式進行組織才能被系統加載、執行,因此可執行文件是特定於操做系統的。對於 Linux 來講是 ELF(Executable Linkable Format) 格式的文件,Windows 是 PE(Portable) 格式。對於 Java 代碼,編譯生成的 Class 文件也是有着特定的格式,才能被 JVM 執行。程序員
一個程序通常由多個文件組成,文件之間會有變量和函數的引用,每一個文件各自編譯生成中間文件後必須通過連接才能生成最終的可執行文件。根據連接方式的不一樣能夠分爲靜態連接和動態連接,靜態連接是在連接期間重定位全部的符號引用,而動態連接則是在裝載或者執行期間進行。數組
本文主要分析 Linux 下 ELF 文件的格式以及靜態連接的過程。函數
源代碼被編譯生成的文件叫作目標,目標文件與可執行文件的格式是相似的,只是尚未經歷連接,其中包含的有些地址尚未被調整。spa
目標文件中包含機器碼、數據、符號表以及調試信息等,這些屬性按照不一樣的段(Section ) 進行存儲。段就是必定長度的的區域,不一樣的屬性放在不一樣名字的段,具體以下所示:操作系統
能夠看出,代碼放在了名爲 .text
的段,變量 global_init_var
和 static_var
放在了名爲 .data
的段,變量 global_uninit_var
和 static_var
放在名爲 .bss
的段。.bss
段存放的是未初始化的全局變量和局部靜態變量。3d
上圖的 EFL 文件除了幾個段,還有文件頭(File Header),其中包含了文件是否可執行、是靜態連接仍是動態連接以及目標硬件、操做系統等信息,還包括一個段表,段表是一個數組結構,描述了文件中各個段在文件中的偏移位置及段的屬性等。用 readelf -h
能夠讀取上面代碼編譯後目標文件的頭信息,以下圖:調試
從上圖能夠看到,其中包含了文件的魔數(Magic) 、字長(class)、CPU 類型等信息,若是是可執行文件,還包括程序的入口地址。Start of section headers
的值是段表的偏移量。code
目標文件中除了上面介紹的代碼段和數據段,還有不少其它段,readelf -S
命令能夠查看段表的信息,以下圖:orm
能夠看出,上面的目標文件總共有 12 個段,第一個爲無效段,其實是 11 個段。其中有字符串表 .strtab
、符號表 .symtab
以及註釋信息 .comment
等。還有一個段是 .rela.txt
段,這個是重定位表,在靜態連接過程當中須要用到。blog
在瞭解了 ELF 文件的結構以後,接下來介紹靜態連接的過程。如下面的代碼爲例:
/* 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; }
在上面的代碼中,b.c 定義了全局符號,分別是變量 shared
和函數 swap
,a.c
定義了一個全局符號 main
。在 a.c 中引用了 b.c 裏面的 shared
和 swap
。用 gcc -c -fno-stack-protector a.c b.c
編譯這兩個文件以後(-fno-stack-protector
是關閉堆棧保護功能),生成了兩個目標文件 a.o
和 b.o
,下一步就是要把這兩個文件連接在一塊兒,造成最終的可執行文件。
靜態連接的第一步是把多個目標文件進行合併,通常採用類似段合併的方式。經過掃描全部的輸入目標文件,而且得到它們各個段的長度、屬性和位置,而且將輸入目標文件中的符號表中全部的符號定義和符號引用收集起來,統一放到一個全局符號表。多個目標文件合併後以下圖所示:
利用上一步收集到的數據,進行符號解析與重定位、調整代碼中的地址等。利用命令 ld a.o b.o -e main -o ab
將 a.o
和 b.o
連接(-e main
是將 main
函數做爲程序的入口),生成可執行文件 ab
。連接先後段的地址信息以下所示:
上圖是 a.o
、b.o
以及連接後的 ab
的地址信息。其中 Size
是段的大小, VMA
是虛擬地址。對於 a.o
和 b.o
的 .text
段來講,大小分別是 0000002c
和 0000004b
, 加起來正好是 ab
的 .text
段的大小 00000077
。另外, a.o
和 b.o
的 VMA 都是 00000000
,此時它們尚未分配地址,而在 ab
中,地址變爲 00000000004000e8
,這就是分配的虛擬地址,當 ab
被加載到內存中後, .text
段的起始地址即是這個。
段的地址被肯定後,內部函數和變量的地址也就肯定了,由於在每一個段內,符號的表示是一個相對於段起始位置的偏移量。當段的起始位置被肯定後,每一個符號只要在偏移量的基礎上加上這個起始位置的地址就行。可是對於引用的外部符號來講,它們的地址還不得知,須要通過符號解析和重定位的過程。
在 a.c 中引用了變量 shared
和函數 swap
,單獨編譯 a.c 的時候並不知道 b.c 這個文件,因此在 a.o 中,用到 shared
的地方用 0
地址代替,等到連接階段,可以肯定這個變量的地址了,再把地址進行調整。
這裏的問題是連接器如何知道哪些指令須要被調整呢?這就用到上面提到過的重定位表,命令 objdump -r a.o
能夠查看 a.o
中的重定位表,以下圖:
每個須要被重定位的地方叫作一個重定位入口,能夠看到,a.o
中須要重定位的兩個符號 shared
和 swap
。將重定位入口的地址進行修正,才能完成連接過程,最終生成的可執行文件即可以被系統正常運行。
代碼從文本形式到最終的可執行文件須要經歷多個過程,其中連接主要作的是多個目標文件的合併以及符號的解析與重定位,最終生成特定格式的可執行文件。本文大概地介紹了 ELF 文件的結構和靜態連接的主要步驟,更詳細的內容能夠查看相關書籍深刻了解。
參考
若是個人文章對您有幫助,不妨點個贊支持一下(^_^)