簡介
C/C++ 代碼在變成可執(zhí)行文件之前需要經歷預處理、編譯箫津、匯編以及鏈接這幾個步驟俭嘁,最終生成的可執(zhí)行文件包含了能夠被系統(tǒng)處理的機器碼‰燃眨可執(zhí)行文件必須按照特定的格式進行組織才能被系統(tǒng)加載氓栈、執(zhí)行,所以可執(zhí)行文件是特定于操作系統(tǒng)的婿着。對于 Linux 來說是 ELF(Executable Linkable Format) 格式的文件授瘦,Windows 是 PE(Portable) 格式。對于 Java 代碼竟宋,編譯生成的 Class 文件也是有著特定的格式提完,才能被 JVM 執(zhí)行。
一個程序一般由多個文件組成丘侠,文件之間會有變量和函數(shù)的引用徒欣,每個文件各自編譯生成中間文件后必須經過鏈接才能生成最終的可執(zhí)行文件。根據(jù)鏈接方式的不同可以分為靜態(tài)鏈接和動態(tài)鏈接蜗字,靜態(tài)鏈接是在鏈接期間重定位所有的符號引用打肝,而動態(tài)鏈接則是在裝載或者執(zhí)行期間進行。
本文主要分析 Linux 下 ELF 文件的格式以及靜態(tài)鏈接的過程挪捕。
目標文件的格式
源代碼被編譯生成的文件叫做目標粗梭,目標文件與可執(zhí)行文件的格式是類似的,只是還沒有經歷鏈接级零,其中包含的有些地址還沒有被調整断医。
目標文件中包含機器碼、數(shù)據(jù)、符號表以及調試信息等孩锡,這些屬性按照不同的段(Section ) 進行存儲酷宵。段就是一定長度的的區(qū)域亥贸,不同的屬性放在不同名字的段躬窜,具體如下所示:
可以看出,代碼放在了名為 .text
的段炕置,變量 global_init_var
和 static_var
放在了名為 .data
的段荣挨,變量 global_uninit_var
和 static_var
放在名為 .bss
的段。.bss
段存放的是未初始化的全局變量和局部靜態(tài)變量朴摊。
上圖的 EFL 文件除了幾個段默垄,還有文件頭(File Header),其中包含了文件是否可執(zhí)行甚纲、是靜態(tài)鏈接還是動態(tài)鏈接以及目標硬件口锭、操作系統(tǒng)等信息,還包括一個段表介杆,段表是一個數(shù)組結構鹃操,描述了文件中各個段在文件中的偏移位置及段的屬性等。用 readelf -h
可以讀取上面代碼編譯后目標文件的頭信息春哨,如下圖:
從上圖可以看到荆隘,其中包含了文件的魔數(shù)(Magic) 、字長(class)赴背、CPU 類型等信息椰拒,如果是可執(zhí)行文件,還包括程序的入口地址凰荚。Start of section headers
的值是段表的偏移量燃观。
目標文件中除了上面介紹的代碼段和數(shù)據(jù)段,還有很多其它段便瑟,readelf -S
命令可以查看段表的信息缆毁,如下圖:
可以看出,上面的目標文件總共有 12 個段胳徽,第一個為無效段积锅,實際上是 11 個段。其中有字符串表 .strtab
养盗、符號表 .symtab
以及注釋信息 .comment
等缚陷。還有一個段是 .rela.txt
段,這個是重定位表往核,在靜態(tài)鏈接過程中需要用到箫爷。
靜態(tài)鏈接
在了解了 ELF 文件的結構之后,接下來介紹靜態(tài)鏈接的過程。以下面的代碼為例:
/* 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
和函數(shù) 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
柱徙,下一步就是要把這兩個文件鏈接在一起缓屠,形成最終的可執(zhí)行文件。
空間與地址分配
靜態(tài)鏈接的第一步是把多個目標文件進行合并护侮,一般采用相似段合并的方式敌完。通過掃描所有的輸入目標文件,并且獲得它們各個段的長度羊初、屬性和位置滨溉,并且將輸入目標文件中的符號表中所有的符號定義和符號引用收集起來,統(tǒng)一放到一個全局符號表长赞。多個目標文件合并后如下圖所示:
符號地址的確定
利用上一步收集到的數(shù)據(jù)晦攒,進行符號解析與重定位、調整代碼中的地址等涧卵。利用命令 ld a.o b.o -e main -o ab
將 a.o
和 b.o
鏈接(-e main
是將 main
函數(shù)作為程序的入口)勤家,生成可執(zhí)行文件 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
中歼跟,地址變?yōu)?00000000004000e8
,這就是分配的虛擬地址格遭,當 ab
被加載到內存中后哈街, .text
段的起始地址便是這個。
段的地址被確定后拒迅,內部函數(shù)和變量的地址也就確定了骚秦,因為在每個段內她倘,符號的表示是一個相對于段起始位置的偏移量。當段的起始位置被確定后作箍,每個符號只要在偏移量的基礎上加上這個起始位置的地址就行硬梁。但是對于引用的外部符號來說,它們的地址還不得知胞得,需要經過符號解析和重定位的過程荧止。
符號解析與重定位
在 a.c 中引用了變量 shared
和函數(shù) swap
,單獨編譯 a.c 的時候并不知道 b.c 這個文件懒震,所以在 a.o 中罩息,用到 shared
的地方用 0
地址代替嗤详,等到鏈接階段个扰,能夠確定這個變量的地址了,再把地址進行調整葱色。
這里的問題是鏈接器如何知道哪些指令需要被調整呢递宅?這就用到上面提到過的重定位表,命令 objdump -r a.o
可以查看 a.o
中的重定位表苍狰,如下圖:
每一個需要被重定位的地方叫做一個重定位入口办龄,可以看到,a.o
中需要重定位的兩個符號 shared
和 swap
淋昭。將重定位入口的地址進行修正俐填,才能完成鏈接過程,最終生成的可執(zhí)行文件便可以被系統(tǒng)正常運行翔忽。
總結
代碼從文本形式到最終的可執(zhí)行文件需要經歷多個過程英融,其中鏈接主要做的是多個目標文件的合并以及符號的解析與重定位,最終生成特定格式的可執(zhí)行文件歇式。本文大概地介紹了 ELF 文件的結構和靜態(tài)鏈接的主要步驟驶悟,更詳細的內容可以查看相關書籍深入了解。
參考
- 《程序員的自我修養(yǎng):鏈接材失、裝載與庫》
- 《深入理解計算機系統(tǒng)》