連接器,是把目標(biāo)文件連接成可執(zhí)行文件或動(dòng)態(tài)庫的工具。
它是將高級(jí)語言代碼轉(zhuǎn)化成二進(jìn)制程序的最后一步。
編譯之后的目標(biāo)文件里,函數(shù)和全局變量的地址并不是真實(shí)內(nèi)存地址,而是一個(gè)重定位符號(hào)。
連接器的作用,就是把這些重定位符號(hào)處理成真實(shí)的內(nèi)存地址。
int printf(const char* fmt, ...);
int main()
printf("hello world");
return 0;
這段代碼在編譯時(shí)有2個(gè)沒法確定的數(shù)據(jù):一是printf()函數(shù)的地址,二是字符串常量"hello world"的地址。
printf()函數(shù)是個(gè)庫函數(shù),它的地址可以在動(dòng)態(tài)庫里,也可以在靜態(tài)庫里,還可以在其他.o文件里,編譯器是沒法提前知道的。
字符串常量"hello world"是一個(gè)全局常量,它要放在.rodata數(shù)據(jù)段里。
.rodata數(shù)據(jù)段的位置編譯器也是沒法確定的,因?yàn)樽罱K可能是多個(gè)目標(biāo)文件連接成1個(gè)可執(zhí)行程序,.rodata數(shù)據(jù)段的具體位置需要連接器來確定。
所以,編譯器就在生成.o文件時(shí)就添加1個(gè)重定位節(jié)、1個(gè)符號(hào)表,他們包含2個(gè)重定位信息:printf()和"hello world"。
然后,由連接器去重寫真實(shí)的內(nèi)存地址。
上面代碼用gcc -c編譯成.o文件之后,用readelf -a查看它的信息,如下圖:
![]()
ELF頭
從ELF頭可以看出,編譯后的文件是可重定位文件,運(yùn)行的系統(tǒng)架構(gòu)是x86_64。
從它各個(gè)節(jié)的列表里可以找到.rela.text重定位節(jié)和.rodata節(jié),前者存儲(chǔ)重定位信息,后者存儲(chǔ)常量數(shù)據(jù)。
![]()
各個(gè)節(jié)的列表
重定位節(jié).rela.text的內(nèi)容有2條:
1,一個(gè)指向.rodata節(jié),表示這條重定位的地址在.rodata段里。
2,另一個(gè)沒有具體的節(jié),但給了一個(gè)函數(shù)名puts,表示要找的是這個(gè)函數(shù)(gcc在編譯時(shí)都是把printf轉(zhuǎn)化成puts函數(shù))。
![]()
重定位節(jié)和符號(hào)表
在上圖的符號(hào)表.symtab節(jié)里,也可以找到這2條信息:
1,其中的第5條(從0開始)就是"hello world"字符串的信息:它是一個(gè)LOCAL的字符串,也就是它的數(shù)據(jù)在當(dāng)前文件里的某個(gè)節(jié)(SECTION),這個(gè)節(jié)的索引號(hào)是5(Ndx列)。
去上面的節(jié)列表里查找,可以發(fā)現(xiàn).rodata段確實(shí)是第5個(gè)節(jié)。
2,第11條就是puts()函數(shù)的信息,它是GLOBAL的全局函數(shù),不在當(dāng)前文件的某個(gè)節(jié)里(Ndx是UND,undefined),需要連接器去其他地方找(庫文件、其他.o文件,etc)。
Ndx這一列表示重定位數(shù)據(jù)所在的節(jié),當(dāng)前文件里實(shí)現(xiàn)的函數(shù)或變量都有節(jié)的索引號(hào),但外部全局函數(shù)的索引號(hào)都是不確定的(UND)。
![]()
代碼段,main函數(shù)的機(jī)器碼
從代碼段.text里的main()函數(shù)的機(jī)器碼可以看出,裝載"hello world"字符串的指令和調(diào)用printf()的指令里的地址都是00 00 00 00。
也就是說,這里需要的真實(shí)內(nèi)存地址是32位的整數(shù),有待連接器進(jìn)一步填寫。
00 00 00 00也就是高級(jí)語言里的NULL,在代碼里都是無效的內(nèi)存地址,如果不重填的話肯定會(huì)發(fā)生段錯(cuò)誤。
lea指令裝載全局變量時(shí)使用的內(nèi)存地址,是變量地址與當(dāng)前指令地址的偏移量。
rip,指令指針寄存器,它存的是當(dāng)前指令的地址,x86_64對(duì)全局變量的尋址,都是使用的這種方式。
如果是靜態(tài)連接,連接器把靜態(tài)庫.a和main函數(shù)的.o文件合在一起,然后修改這兩個(gè)地址就可以了。
如果是動(dòng)態(tài)連接,還需要用到全局偏移量表(GOT,global offset table)和PLT(過程連接表,procedure linkage table)。
![]()
動(dòng)態(tài)連接之后的ELF頭
gcc動(dòng)態(tài)連接之后生成的可執(zhí)行文件。
以前gcc都是生成可執(zhí)行文件EXEC,現(xiàn)在都是生成動(dòng)態(tài)庫DYN直接運(yùn)行了(即使main函數(shù)所在的文件也這樣)。
上圖ELF頭可以看出類型是DYN,入口地址是0x530。
![]()
節(jié)的列表
動(dòng)態(tài)鏈接之后文件有特別多的節(jié),其中以.dyn開頭的都是動(dòng)態(tài)庫相關(guān)的節(jié)。
.plt、.plt.got、.got,這3個(gè)就是動(dòng)態(tài)連接所必須的節(jié)。
.rela.plt和.rodata依然存在,內(nèi)容和靜態(tài)連接得差不多。
![]()
所需的動(dòng)態(tài)庫信息
因?yàn)槌绦蜻\(yùn)行時(shí)要首先加載所需的動(dòng)態(tài)庫,所以必須含有動(dòng)態(tài)庫的信息,如上圖。
這個(gè)程序比較簡(jiǎn)單,只需要libc.so.6庫。
以下兩圖是重定位節(jié)的內(nèi)容和動(dòng)態(tài)庫支持的庫函數(shù)列表,可以看到他們都包含puts()函數(shù),即main()函數(shù)所需的printf()。
![]()
重定位節(jié)
![]()
動(dòng)態(tài)庫函數(shù)的信息
最后簡(jiǎn)單說一下plt和got的內(nèi)容:
plt分為2個(gè)節(jié).plt和.plt.got。
.plt是只讀的可執(zhí)行代碼,.plt.got是可寫的數(shù)據(jù)。
操作系統(tǒng)不允許在運(yùn)行時(shí)修改代碼,只允許在運(yùn)行時(shí)修改數(shù)據(jù),所以動(dòng)態(tài)連接的程序要想獲得庫函數(shù)的地址必須要一個(gè)小技巧[呲牙]
加載器必須把庫函數(shù)的地址放在一個(gè)全局的函數(shù)指針變量里,然后讓一段過渡代碼去調(diào)用這個(gè)函數(shù)指針,從而實(shí)現(xiàn)動(dòng)態(tài)運(yùn)行。
這個(gè)全局的函數(shù)指針就是.plt.got里的一項(xiàng)。
當(dāng)程序需要多個(gè)庫函數(shù)時(shí),這些函數(shù)指針就形成了一個(gè)函數(shù)指針數(shù)組,這就是.plt.got表。
調(diào)用(多個(gè))庫函數(shù)的過渡代碼數(shù)組就是.plt表:它是有運(yùn)行權(quán)限的,而且是只讀的。
如下圖:
1,最開始的時(shí)候,這個(gè)函數(shù)指針是加載器的加載函數(shù)。
2,當(dāng)?shù)谝淮握{(diào)用puts()函數(shù),加載函數(shù)會(huì)去動(dòng)態(tài)庫里查找它的真實(shí)地址,并填寫在這里。
3,之后再調(diào)用時(shí),就直接調(diào)用puts()函數(shù)了。
這是linux系統(tǒng)動(dòng)態(tài)庫函數(shù)的需求加載機(jī)制。
如果是普通變量,把它的地址放在.got表里就行。
![]()
動(dòng)態(tài)庫函數(shù)的需求加載






