GCC 鏈接過程中的【重定位】過程分析
最近因為項目上的需要,利用動態(tài)鏈接庫來實現(xiàn)一個插件系統(tǒng),順便就復(fù)習(xí)了一下關(guān)于Linux中一些編譯、鏈接相關(guān)的內(nèi)容。
在鏈接的過程中,符號重定位是比較麻煩的事情,特別是在動態(tài)鏈接的過程中,因為需要考慮到很多不同的情況。
這篇文章作為第一篇,先來聊一聊靜態(tài)鏈接中的重定位過程。
按照慣例,還是以一個簡短的示例代碼作為載體,看一看GCC在鏈接的過程中,是如何根據(jù)目標(biāo)文件(.o文件)來進(jìn)行重定位,生成最終的可執(zhí)行文件的。
示例代碼
示例代碼很簡單,一共有2個源文件main.c和 sub.c。
在sub.c中定義了一個全局變量和一個全局函數(shù),然后在main.c中使用這個全局變量和全局函數(shù)。代碼如下:
sub.c
main.c
在一般的開發(fā)過程中,都是使用GCC工具,直接把這2個源文件編譯得到可執(zhí)行文件。
但是,為了探究編譯、鏈接過程中的一些內(nèi)部情況,我們需要把編譯、鏈接的過程拆開,從中間過程中產(chǎn)生的目標(biāo)文件(.o 文件)中,來查看一些詳細(xì)信息。
先把這2個源文件編譯成目標(biāo)文件sub.o和main.o:
$ gcc -m32 -c sub.c
$ gcc -m32 -c main.c
這樣就得到了兩個目標(biāo)文件,先來初步看一下這2個目標(biāo)文件中的一些信息。
以上這兩個編譯過程是各自獨立的,雖然main.o中使用了兩個符號(全局變量和全局函數(shù)),但是此時main.o并不知道這2個符號是在哪個文件中定義的。
當(dāng)鏈接器把所有的.o文件鏈接成可執(zhí)行文件的過程中,才能確定這2個符號是在哪里。
在Linux系統(tǒng)中,目標(biāo)文件(.o) 和可執(zhí)行文件都是ELF格式的,因此如何查看ELF格式文件的一些工具指令就非常有幫助。
sub.o 文件內(nèi)容分析
段信息
首先來簡單瞄一眼一下sub.o中的一些信息。
sub.o中的段信息如下(指令:$ readelf -S sub.o):
我們主要關(guān)心黃色的代碼段和數(shù)據(jù)段就可以了,可以看出:
- 代碼段(.text):地址Addr是 0x0000_0000(因為這是目標(biāo)文件,不是可執(zhí)行文件,所以不會安排地址),它在 sub.o 文件中的偏移量(Off)是 0x34,長度是 0x0C 字節(jié);
- 數(shù)據(jù)段(.data):地址Addr是 0x0000_0000,它在 sub.o 文件中的偏移量(Off)是 0x40,長度是 0x04 字節(jié);
簡單算一下:sub.o的開始部分是ELF的 header,通過 readelf -h sub.o 指令可以看出來header部分是52個字節(jié)(即:0x34),如下:
因此可以得到:
- 代碼段(.text)是緊接在 header 之后,長度是 0x0C 個字節(jié),在文件中占據(jù)著 0x34 ~ 0x3F 這部分空間(0x3F = 0x34 + 0x0C - 1);
- 數(shù)據(jù)段(.data)是進(jìn)階在代碼段之后,在文件中占據(jù)著 0x40 ~ 0x43 這部分空間;
符號表信息
下面再來說說符號表的事情。
簡單來說,符號表就是一個文件中定義的所有符號、引用的外部符號(在其它文件中定義),包括:變量名、函數(shù)名、段名等等,都屬于符號。
當(dāng)然了,在ELF文件中會詳細(xì)的說明每一個符號的類型、大小、可見性等信息。如果對ELF文件格式有過了解的話,一定知道每一條符號信息,都是通過一個結(jié)構(gòu)體來描述具體含義的,描述符號表的結(jié)構(gòu)體如下:
// Symbol table entries for ELF32.
struct Elf32_Sym {
Elf32_Word st_name; // Symbol name (index into string table)
Elf32_Addr st_value; // Value or address associated with the symbol
Elf32_Word st_size; // Size of the symbol
unsigned char st_info; // Symbol's type and binding attributes
unsigned char st_other; // Must be zero; reserved
Elf32_Half st_shndx; // Which section (header table index) it's defined in
};
再來看一下sub.o中的符號表,下面這張圖(指令:readelf -s sub.o):
關(guān)注上圖中黃色矩形中的兩個符號:SubData和SubFunc,很明顯它們就是sub.c中定義的兩個符號:全局變量和全局函數(shù)。
對于SubData符號來說:
- Size=4: 長度是 4 個字節(jié);
- Type=OBJECT:說明這是一個數(shù)據(jù)對象;
- Bind=GLOBAL:說明這個符號是全局可見的,也就是在其他文件中可以使用;
- Ndx=2:說明這個符號是屬于第 2 個 段中,就是數(shù)據(jù)段(.data);
同樣的道理,對于SubFunc符號來說:
- Size=12: 長度是 12 個字節(jié);
- Type=FUNC:說明這是一個函數(shù);
- Bind=GLOBAL:說明這個符號是全局可見的,也就是在其他文件中可以調(diào)用;
- Ndx=1:說明這個符號是屬于第 1 個 段中,就是代碼段(.text);
main.o 文件分析
按照上面的步驟,把main.o中的這幾個信息也查看一下。
段信息
指令:readelf -S main.o
可以看出:
- 代碼段(.text):地址Addr是 0x0000_0000(因為這是目標(biāo)文件,不是可執(zhí)行文件,所以不會安排地址),它在 sub.o ; 文件中的偏移量(Off)是 0x34,長度是 0x32 字節(jié);
- 數(shù)據(jù)段(.data):地址Addr是 0x0000_0000,它在 sub.o 文件中的偏移量(Off)是 0x66,長度是 0 個字節(jié),因為它沒有定義變量;
在文件中的布局如下所示:
符號表信息
指令:readelf -s main.o
重點看一下黃色矩形中的3個符號。
main符號:
- Size=50: 長度是 30 個字節(jié),也就對應(yīng)著代碼段的長度 0x32;
- Type=FUNC:說明這是一個函數(shù);
- Bind=GLOBAL:說明這個符號是全局可見的,也就是在其他文件中可以調(diào)用;
- Ndx=1:說明這個符號是屬于第 1 個 段中,就是代碼段(.text);
下面兩個符號SubData和SubFunc,他們的Ndx都是UND,表示這2個符號被main.o使用,但是定義在其他文件中。
我們知道,當(dāng)鏈接成可執(zhí)行文件時,所有的符號都必須有確定的地址(虛擬地址),所以鏈接器就需要在鏈接的過程中找到這2個符號在可執(zhí)行文件中的地址,然后把這兩個地址填寫到main的代碼段中。
可以先來看一下main.o的反匯編代碼:
指令:objdump -d main.o
黃色矩形框中是把數(shù)值0存儲到eax寄存器中,然后把eax 壓到棧中,然后紅色矩形框調(diào)用了一個函數(shù)。
從示例代碼(.c文件)中可知:main函數(shù)在調(diào)用sub.c中的SubFunc函數(shù)時,傳入了變量SubData。
黃色部分的00 00 00 00就應(yīng)該是符號SubData的地址,只不過此時main.o還不知道這個符號的將會被鏈接器安排在什么地址,所以只能空著(以4個字節(jié)的00來占位)。
紅色部分的調(diào)用(call)地址為什么是fc ff ff ff?
按照小端格式計算一下:0xfffffffc,十進(jìn)制的值就是-4,為什么設(shè)置成-4呢?
對于x86平臺的ELF格式來說,對地址進(jìn)行修正的方式有2種:絕對尋址和相對尋址。
絕對尋址
對于SubData符號就是絕對尋址,在鏈接成可執(zhí)行文件時,這個地址在代碼段中偏移0x12個字節(jié)(黃色矩形框指令碼偏移0x11個字節(jié),跨過一個字節(jié)的指令碼a1就是0x12個字節(jié)),這個地方4個字節(jié)的當(dāng)前值是 00 00 00 00。
鏈接器在修正的時候(就是鏈接成可執(zhí)行文件的時候),會把這4個字節(jié)修改為SubData變量在可執(zhí)行文件中的實際地址(虛擬地址)。
相對尋址
紅色矩形框中的函數(shù)調(diào)用(SubFunc符號),就是相對尋址,就是說:當(dāng)CPU執(zhí)行到這條指令的時候,把PC寄存中的值加上這個偏移地址,就是被調(diào)用對象的實際地址。
鏈接器在重定位的時候,目的就是計算出相對地址,然后替換掉fc ff ff ff這四個字節(jié)。
PC寄存器中的值是確定的,當(dāng)call這條指令被CPU取到之后,PC寄存器被自動增加,指向下一條指令的開始地址(偏移0x1f地址處)。
實際地址 = PC值 + xxxx_xxxx,所以得到:xxxx_xxxx = 實際地址 - PC值。
而PC值與 xxxx_xxxx 所在的地址之間是有關(guān)系的:PC值 + (-4)就得到 xxxx_xxxx 所在的地址,因此在main.o中預(yù)先在這個地址處填 fc ff ff ff(-4)。
問題來了,鏈接器怎么知道m(xù)ain.o中代碼段的這兩個地方,需要進(jìn)行地址修正?
這就是下面介紹的重定位表的作用了!
重定位表信息
指令:objdump -r main.o
重定位表就表示: 該目標(biāo)文件中,有哪些符號需要在鏈接的時候進(jìn)行地址重定位。
從圖中黃色矩形框可以看出:main.o中代碼段(.text)的 SubData和SubFunc這 2 個符號都需要鏈接器對它進(jìn)行重定位。
TYPE列:R_386_32表示絕對尋址, R_386_PC32 表示相對尋址; OFFSET列表示需要重定位的符號在main.o文件代碼段中的偏移位置。
剛才已經(jīng)看了main.o的反匯編代碼,可以看到偏移0x12 和 0x1b的地方,就是需要進(jìn)行地址重定位的兩個符號。
可執(zhí)行程序 main
有了 2 個目標(biāo)文件:sub.o和main.o,就可以鏈接得到可執(zhí)行程序了:
$ ld -m elf_i386 main.o sub.o -e main -o main
段信息
使用readelf工具來看一下main可執(zhí)行文件中的段信息(指令:readelf -S main):
- 紅色矩形框是代碼段(.text),鏈接器把它放在虛擬地址 0x0804_8094;
- 黃色矩形框是數(shù)據(jù)段(.data),鏈接器把它放在虛擬地址 0x0804_9138;
從段信息中可以看到main文件中代碼段和數(shù)據(jù)段的布局如下:
可執(zhí)行程序main是由main.o和sub.o這兩個目標(biāo)文件組成的,所以main中的代碼段是由main.o中的代碼段和sub.o中的代碼段組合得到的;對于數(shù)據(jù)段,由于 main.o中數(shù)據(jù)段的長度為0,所以main中的數(shù)據(jù)段就是sub.o中的數(shù)據(jù)段(長度為4),如下圖所示:
符號表信息
指令:readelf -s main
黃色矩形框中的SubData屬于數(shù)據(jù)段,長度是 4 個字節(jié),虛擬地址是 0x0804_9138,與段信息中的值是一致的。
紅色矩形框中的SubFunc屬于代碼段,長度是 12 個字節(jié),虛擬地址是 0x0804_80c6。
因為main中的代碼段包括 2 部分內(nèi)容:
- main.o 中的代碼段 main 函數(shù);
- sub.o 中的代碼段 SubFunc 函數(shù);
所以,可執(zhí)行文件main中的代碼段,先存放的是main函數(shù),虛擬地址:0x0804_8094,長度是0x32(50 個字節(jié));
緊接著存放的是SubFunc函數(shù),虛擬地址:0x0804_80c6,長度是0x0c(12 個字節(jié))。
如下圖所示:
鏈接器在第一遍掃描所有的目標(biāo)文件時,把所有相同類型的段進(jìn)行合并,安排到相應(yīng)的虛擬地址,如上圖所示。
所謂的安排虛擬地址,就是指定這塊內(nèi)容被加載到虛擬內(nèi)存的什么地方。當(dāng)可執(zhí)行文件被執(zhí)行的時候,加載器就把每一塊內(nèi)容復(fù)制到虛擬內(nèi)存相應(yīng)的地址處。
同時,鏈接器還會建立一個全局符號表,把每一個目標(biāo)文件中的符號信息都復(fù)制到這個全局符號表中。
對于我們的實例程序,全局符號表中包括:
SubData: 屬于 sub.o 文件,數(shù)據(jù)段,安排在虛擬地址 0x0804_9138;
SubFunc: 屬于 sub.o 文件,代碼段,安排在虛擬地址 0x0804_80c6;
其它符號信息...
絕對地址重定位
然后,鏈接器第二遍掃描所有的目標(biāo)文件,檢查哪些目標(biāo)文件中的符號需要進(jìn)行重定位。
對于我們的示例程序,首先來看一下main.o中使用的外部變量SubData的重定位。
從main.o的重定位表中可知:SubData符號需要進(jìn)行重定位,需要把這個符號在執(zhí)行時刻的絕對尋址(虛擬地址),寫入到 main可執(zhí)行文件中代碼段中偏移0x12字節(jié)處。
也就是說需要解決 2 個問題:
- 需要計算出在執(zhí)行文件 main 中的什么位置來填寫絕對地址(虛擬地址);
- 填寫的絕對地址(虛擬地址)的值是多少;
首先來解決第一個問題。
從可執(zhí)行文件的段表中可以看出:目標(biāo)文件main.o和sub.o中的代碼段被存放到可執(zhí)行文件main中代碼段的開始位置,先放main.o代碼段,再放sub.o代碼段。
代碼段的開始地址距離文件開始的偏移量是0x94,再加上偏移量0x12,結(jié)果就是0xa6。
也就是說:需要在main文件中偏移0xa6處填入SubData在執(zhí)行時刻的絕對地址(虛擬地址)。
再來解決第二個問題。
鏈接器從全局符號表中發(fā)現(xiàn):SubData符號屬于sub.o文件,已經(jīng)被安排在虛擬地址0x0804_9138處,因此只需要把0x0804_9138填寫到可執(zhí)行文件main中偏移0xa6的地方。
我們來讀取main文件,驗證一下這個位置處的虛擬地址是否正確:
指令:od -Ax -t x1 -j 166 -N 4 main
-Ax: 顯示地址的時候,用十六進(jìn)制來表示。如果使用 -Ad,意思就是用十進(jìn)制來顯示地址;
-t -x1: 顯示字節(jié)碼內(nèi)容的時候,使用十六進(jìn)制(x),每次顯示一個字節(jié)(1);
-j 166: 跨過 166 個字節(jié)(十六進(jìn)制 0xa6);
-N 4:只需要讀取 4 個字節(jié);
注意:顯示的是小端格式。
相對地址重定位
從上面描述的重定位表中看出:main.o代碼段中的SubFunc符號也需要重定位,而且是相對尋址。
鏈接器需要把SunFunc符號在執(zhí)行時刻的絕對地址(虛擬地址),減去call指令的下一條指令(PC 寄存器) 之后的差值,填寫到執(zhí)行文件main中的main.o代碼段偏移0x1b的地方。
同樣的道理,需要解決 2 個問題:
- 需要計算出在執(zhí)行文件 main 中的什么位置來填寫相對地址;
- 填寫的相對地址的值是多少;
首先來解決第一個問題。
從main.o的重定位表中可知:需要修正的位置距離main.o中代碼段的偏移量是0x1b字節(jié)。
可執(zhí)行文件main中代碼段的開始地址距離文件開始的偏移量是0x94,再加上偏移量0x1b就是0xaf。
也就是說:需要在main文件中0xaf偏移處填入一個相對地址,這個相對地址的值就是SubFunc在執(zhí)行時刻的絕對地址(虛擬地址)、距離call指令的下一條指令的偏移量。
再來解決第二個問題。
鏈接器在第一遍掃描的時候,已經(jīng)把sub.o中的符號SubFunc記錄到全局符號表中了,知道SubFunc函數(shù)被安排在虛擬地址0x0804_80c6的地方。
但是不能把這個絕對地址直接填寫進(jìn)去,因為 call 指令需要的是相對地址(偏移地址)。
鏈接器把main代碼段起始位置安排在 0x0804_8094,那么偏移0x1b處的虛擬地址就是:0x0804_80af,然后還需要再跨過4個字節(jié)(因為執(zhí)行call指令時,PC的值自動增加到下一條指令的開始地址)才是此刻PC寄存器的值,即:0x0804_80b3,如下圖中紅色部分:
兩個虛擬地址都知道了,計算一下差值就可以了:0x0804_80c6 - 0x0804_80b3 = 0x13。
也就是說:在可執(zhí)行文件main中偏移為0xaf的地方,填入相對地址0x0000_0013就完成了SubFunc符號的重定位。
還是用od指令來讀取main文件的內(nèi)容來驗證一下:
指令:od -Ax -t x1 -j 175 -N 4 main
總結(jié)
經(jīng)過以上兩個重定位操作,main.c中使用的兩個外部符號就解決了地址重定位問題。
再來看一下可執(zhí)行文件main的反匯編代碼:
從黃色和紅色的矩形框可以看出,二進(jìn)制指令中的地址值與上面的分析是一致的。
以上就是靜態(tài)鏈接過程中地址重定位的基本過程,與動態(tài)鏈接相比,靜態(tài)鏈接還是相對簡單很多。
以后有機(jī)會的話,我們再繼續(xù)聊一下動態(tài)鏈接中的一些操作,謝謝!