作 者:道哥,10+年嵌入式開發老兵,專注於:C/C++、嵌入式、Linux。
關注下方公眾號,回覆【書籍】,獲取 Linux、嵌入式領域經典書籍;回覆【PDF】,獲取所有原創文章( PDF 格式)。
最近因為專案上的需要,利用動態連結庫來實現一個外掛系統,順便就複習了一下關於Linux
中一些編譯、連結相關的內容。
在連結的過程中,符號重定位是比較麻煩的事情,特別是在動態連結的過程中,因為需要考慮到很多不同的情況。
這篇文章作為第一篇,先來聊一聊靜態連結中的重定位過程。
按照慣例,還是以一個簡短的示例程式碼作為載體,看一看GCC
在連結的過程中,是如何根據目標檔案(.o檔案
)來進行重定位,生成最終的可執行檔案的。
示例程式碼
示例程式碼很簡單,一共有2
個原始檔main.c
和 sub.c
。
在sub.c
中定義了一個全域性變數和一個全域性函式,然後在main.c
中使用這個全域性變數和全域性函式。程式碼如下:
sub.c
main.c
在一般的開發過程中,都是使用GCC
工具,直接把這2
個原始檔編譯得到可執行檔案。
但是,為了探究編譯、連結過程中的一些內部情況,我們需要把編譯、連結的過程拆開,從中間過程中產生的目標檔案(.o 檔案
)中,來檢視一些詳細資訊。
先把這2
個原始檔編譯成目標檔案sub.o
和main.o
:
$ gcc -m32 -c sub.c
$ gcc -m32 -c main.c
這樣就得到了兩個目標檔案,先來初步看一下這2
個目標檔案中的一些資訊。
以上這兩個編譯過程是各自獨立的,雖然main.o
中使用了兩個符號(全域性變數和全域性函式),但是此時main.o
並不知道這2
個符號是在哪個檔案中定義的。
當連結器把所有的.o
檔案連結成可執行檔案的過程中,才能確定這2
個符號是在哪裡。
在Linux
系統中,目標檔案(.o) 和可執行檔案都是ELF
格式的,因此如何檢視ELF
格式檔案的一些工具指令就非常有幫助。
很久之前總結過這篇文章:《Linux系統中編譯、連結的基石-ELF檔案:扒開它的層層外衣,從位元組碼的粒度來探索》,裡面詳細總結了ELF
檔案的內部結構,以及一些相關的工具。
sub.o 檔案內容分析
段資訊
首先來簡單瞄一眼一下sub.o
中的一些資訊。
sub.o
中的段資訊如下(指令:$ readelf -S sub.o
):
我們主要關心黃色的程式碼段和資料段就可以了,可以看出:
程式碼段(.text):地址Addr是 0x0000_0000(因為這是目標檔案,不是可執行檔案,所以不會安排地址),它在 sub.o 檔案中的偏移量(Off)是 0x34,長度是 0x0C 位元組;
資料段(.data):地址Addr是 0x0000_0000,它在 sub.o 檔案中的偏移量(Off)是 0x40,長度是 0x04 位元組;
簡單算一下:sub.o
的開始部分是ELF
的 header
,通過 readelf -h sub.o
指令可以看出來header
部分是52
個位元組(即:0x34
),如下:
因此可以得到:
程式碼段(.text)是緊接在 header 之後,長度是 0x0C 個位元組,在檔案中佔據著 0x34 ~ 0x3F 這部分空間(0x3F = 0x34 + 0x0C - 1);
資料段(.data)是進階在程式碼段之後,在檔案中佔據著 0x40 ~ 0x43 這部分空間;
符號表資訊
下面再來說說符號表的事情。
簡單來說,符號表就是一個檔案中定義的所有符號、引用的外部符號(在其它檔案中定義),包括:變數名、函式名、段名等等,都屬於符號。
當然了,在ELF
檔案中會詳細的說明每一個符號的型別、大小、可見性等資訊。如果對ELF
檔案格式有過了解的話,一定知道每一條符號資訊,都是通過一個結構體來描述具體含義的,描述符號表的結構體如下:
// 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
):
關注上圖中黃色矩形中的兩個符號:SubData
和SubFunc
,很明顯它們就是sub.c
中定義的兩個符號:全域性變數和全域性函式。
對於SubData
符號來說:
Size=4: 長度是 4 個位元組;
Type=OBJECT:說明這是一個資料物件;
Bind=GLOBAL:說明這個符號是全域性可見的,也就是在其他檔案中可以使用;
Ndx=2:說明這個符號是屬於第 2 個 段中,就是資料段(.data);
同樣的道理,對於SubFunc
符號來說:
Size=12: 長度是 12 個位元組;
Type=FUNC:說明這是一個函式;
Bind=GLOBAL:說明這個符號是全域性可見的,也就是在其他檔案中可以呼叫;
Ndx=1:說明這個符號是屬於第 1 個 段中,就是程式碼段(.text);
main.o 檔案分析
按照上面的步驟,把main.o
中的這幾個資訊也檢視一下。
段資訊
指令:readelf -S main.o
可以看出:
程式碼段(.text):地址Addr是 0x0000_0000(因為這是目標檔案,不是可執行檔案,所以不會安排地址),它在 sub.o 檔案中的偏移量(Off)是 0x34,長度是 0x32 位元組;
資料段(.data):地址Addr是 0x0000_0000,它在 sub.o 檔案中的偏移量(Off)是 0x66,長度是 0 個位元組,因為它沒有定義變數;
在檔案中的佈局如下所示:
符號表資訊
指令:readelf -s main.o
重點看一下黃色矩形中的3
個符號。
main
符號:
Size=50: 長度是 30 個位元組,也就對應著程式碼段的長度 0x32 ;
Type=FUNC:說明這是一個函式;
Bind=GLOBAL:說明這個符號是全域性可見的,也就是在其他檔案中可以呼叫;
Ndx=1:說明這個符號是屬於第 1 個 段中,就是程式碼段(.text);
下面兩個符號SubData
和SubFunc
,他們的Ndx
都是UND
,表示這2
個符號被main.o
使用,但是定義在其他檔案中。
我們知道,當連結成可執行檔案時,所有的符號都必須有確定的地址(虛擬地址),所以連結器就需要在連結的過程中找到這2
個符號在可執行檔案中的地址,然後把這兩個地址填寫到main
的程式碼段中。
可以先來看一下main.o
的反彙編程式碼:
指令: objdump -d main.o
黃色矩形框中是把數值0
儲存到eax
暫存器中,然後把eax
壓到棧中,然後紅色矩形框呼叫了一個函式。
從示例程式碼(.c
檔案)中可知:main
函式在呼叫sub.c
中的SubFunc
函式時,傳入了變數SubData
。
黃色部分的00 00 00 00
就應該是符號SubData
的地址,只不過此時main.o
還不知道這個符號的將會被連結器安排在什麼地址,所以只能空著(以4
個位元組的00
來佔位)。
紅色部分的呼叫(call
)地址為什麼是fc ff ff ff
?
按照小端格式計算一下:0xfffffffc
,十進位制的值就是-4
,為什麼設定成-4
呢?
對於x86
平臺的ELF
格式來說,對地址進行修正的方式有2
種:絕對定址和相對定址。
絕對定址
對於SubData
符號就是絕對定址,在連結成可執行檔案時,這個地址在程式碼段中偏移0x12
個位元組(黃色矩形框指令碼偏移0x11
個位元組,跨過一個位元組的指令碼a1
就是0x12
個位元組),這個地方4
個位元組的當前值是 00 00 00 00
。
連結器在修正的時候(就是連結成可執行檔案的時候),會把這4
個位元組修改為SubData
變數在可執行檔案中的實際地址(虛擬地址)。
相對定址
紅色矩形框中的函式呼叫(SubFunc
符號),就是相對定址,就是說:當CPU
執行到這條指令的時候,把PC
寄存中的值加上這個偏移地址,就是被呼叫物件的實際地址。
連結器在重定位的時候,目的就是計算出相對地址,然後替換掉fc ff ff ff
這四個位元組。
PC
暫存器中的值是確定的,當call
這條指令被CPU
取到之後,PC
暫存器被自動增加,指向下一條指令的開始地址(偏移0x1f
地址處)。
實際地址 = PC值 + xxxx_xxxx
,所以得到:xxxx_xxxx = 實際地址 - PC值
。
而PC
值與 xxxx_xxxx
所在的地址之間是有關係的:PC值 + (-4)
就得到 xxxx_xxxx
所在的地址,因此在main.o
中預先在這個地址處填 fc ff ff ff(-4)
。
問題來了,連結器怎麼知道main.o
中程式碼段的這兩個地方,需要進行地址修正?
這就是下面介紹的重定位表的作用了!
重定位表資訊
指令:objdump -r main.o
重定位表就表示: 該目標檔案中,有哪些符號需要在連結的時候進行地址重定位。
從圖中黃色矩形框可以看出:main.o
中程式碼段(.text
)的 SubData
和SubFunc
這 2 個符號都需要連結器對它進行重定位。
TYPE
列:R_386_32
表示絕對定址, R_386_PC32
表示相對定址; OFFSET
列表示需要重定位的符號在main.o
檔案程式碼段中的偏移位置。
剛才已經看了main.o
的反彙編程式碼,可以看到偏移0x12 和 0x1b
的地方,就是需要進行地址重定位的兩個符號。
可執行程式 main
有了 2 個目標檔案:sub.o
和main.o
,就可以連結得到可執行程式了:
$ ld -m elf_i386 main.o sub.o -e main -o main
段資訊
使用readelf
工具來看一下main
可執行檔案中的段資訊(指令:readelf -S main
):
紅色矩形框是程式碼段(.text),連結器把它放在虛擬地址 0x0804_8094;
黃色矩形框是資料段(.data),連結器把它放在虛擬地址 0x0804_9138;
從段資訊中可以看到main
檔案中程式碼段和資料段的佈局如下:
可執行程式main
是由main.o
和sub.o
這兩個目標檔案組成的,所以main
中的程式碼段是由main.o
中的程式碼段和sub.o
中的程式碼段組合得到的;對於資料段,由於 main.o
中資料段的長度為0
,所以main
中的資料段就是sub.o
中的資料段(長度為4
),如下圖所示:
符號表資訊
指令:readelf -s main
黃色矩形框中的SubData
屬於資料段,長度是 4 個位元組,虛擬地址是 0x0804_9138
,與段資訊中的值是一致的。
紅色矩形框中的SubFunc
屬於程式碼段,長度是 12 個位元組,虛擬地址是 0x0804_80c6
。
因為main
中的程式碼段包括 2 部分內容:
main.o 中的程式碼段 main 函式;
sub.o 中的程式碼段 SubFunc 函式;
所以,可執行檔案main
中的程式碼段,先存放的是main
函式,虛擬地址:0x0804_8094
,長度是0x32
(50 個位元組);
緊接著存放的是SubFunc
函式,虛擬地址:0x0804_80c6
,長度是0x0c
(12 個位元組)。
如下圖所示:
連結器在第一遍掃描所有的目標檔案時,把所有相同型別的段進行合併,安排到相應的虛擬地址,如上圖所示。
所謂的安排虛擬地址,就是指定這塊內容被載入到虛擬記憶體的什麼地方。當可執行檔案被執行的時候,載入器就把每一塊內容複製到虛擬記憶體相應的地址處。
同時,連結器還會建立一個全域性符號表,把每一個目標檔案中的符號資訊都複製到這個全域性符號表中。
對於我們的例項程式,全域性符號表中包括:
SubData: 屬於 sub.o 檔案,資料段,安排在虛擬地址 0x0804_9138;
SubFunc: 屬於 sub.o 檔案,程式碼段,安排在虛擬地址 0x0804_80c6;
其它符號資訊...
絕對地址重定位
然後,連結器第二遍掃描所有的目標檔案,檢查哪些目標檔案中的符號需要進行重定位。
對於我們的示例程式,首先來看一下main.o
中使用的外部變數SubData
的重定位。
從main.o
的重定位表中可知:SubData
符號需要進行重定位,需要把這個符號在執行時刻的絕對定址(虛擬地址),寫入到 main
可執行檔案中程式碼段中偏移0x12
位元組處。
也就是說需要解決 2 個問題
:
需要計算出在執行檔案 main 中的什麼位置來填寫絕對地址(虛擬地址);
填寫的絕對地址(虛擬地址)的值是多少;
首先來解決第一個問題。
從可執行檔案的段表中可以看出:目標檔案main.o
和sub.o
中的程式碼段被存放到可執行檔案main
中程式碼段的開始位置,先放main.o
程式碼段,再放sub.o
程式碼段。
程式碼段的開始地址距離檔案開始的偏移量是0x94
,再加上偏移量0x12
,結果就是0xa6
。
也就是說:需要在main
檔案中偏移0xa6
處填入SubData
在執行時刻的絕對地址(虛擬地址)。
再來解決第二個問題。
連結器從全域性符號表中發現:SubData
符號屬於sub.o
檔案,已經被安排在虛擬地址0x0804_9138
處,因此只需要把0x0804_9138
填寫到可執行檔案main
中偏移0xa6
的地方。
我們來讀取main
檔案,驗證一下這個位置處的虛擬地址是否正確:
指令:od -Ax -t x1 -j 166 -N 4 main
-Ax: 顯示地址的時候,用十六進位制來表示。如果使用 -Ad,意思就是用十進位制來顯示地址;
-t -x1: 顯示位元組碼內容的時候,使用十六進位制(x),每次顯示一個位元組(1);
-j 166: 跨過 166 個位元組(十六進位制 0xa6);
-N 4:只需要讀取 4 個位元組;
注意:顯示的是小端格式。
相對地址重定位
從上面描述的重定位表中看出:main.o
程式碼段中的SubFunc
符號也需要重定位,而且是相對定址。
連結器需要把SunFunc
符號在執行時刻的絕對地址(虛擬地址),減去call
指令的下一條指令(PC 暫存器
) 之後的差值,填寫到執行檔案main
中的main.o
程式碼段偏移0x1b
的地方。
同樣的道理,需要解決 2 個問題
:
需要計算出在執行檔案 main 中的什麼位置來填寫相對地址;
填寫的相對地址的值是多少;
首先來解決第一個問題。
從main.o
的重定位表中可知:需要修正的位置距離main.o
中程式碼段的偏移量是0x1b
位元組。
可執行檔案main
中程式碼段的開始地址距離檔案開始的偏移量是0x94
,再加上偏移量0x1b
就是0xaf
。
也就是說:需要在main
檔案中0xaf
偏移處填入一個相對地址,這個相對地址的值就是SubFunc
在執行時刻的絕對地址(虛擬地址)、距離call
指令的下一條指令的偏移量。
再來解決第二個問題。
連結器在第一遍掃描的時候,已經把sub.o
中的符號SubFunc
記錄到全域性符號表中了,知道SubFunc
函式被安排在虛擬地址0x0804_80c6
的地方。
但是不能把這個絕對地址直接填寫進去,因為 call
指令需要的是相對地址(偏移地址)。
連結器把main
程式碼段起始位置安排在 0x0804_8094
,那麼偏移0x1b
處的虛擬地址就是:0x0804_80af
,然後還需要再跨過4
個位元組(因為執行call
指令時,PC
的值自動增加到下一條指令的開始地址)才是此刻PC
暫存器的值,即:0x0804_80b3
,如下圖中紅色部分:
兩個虛擬地址都知道了,計算一下差值就可以了:0x0804_80c6 - 0x0804_80b3 = 0x13
。
也就是說:在可執行檔案main
中偏移為0xaf
的地方,填入相對地址0x0000_0013
就完成了SubFunc
符號的重定位。
還是用od
指令來讀取main
檔案的內容來驗證一下:
指令:od -Ax -t x1 -j 175 -N 4 main
總結
經過以上兩個重定位操作,main.c
中使用的兩個外部符號就解決了地址重定位問題。
再來看一下可執行檔案main
的反彙編程式碼:
從黃色和紅色的矩形框可以看出,二進位制指令中的地址值與上面的分析是一致的。
以上就是靜態連結過程中地址重定位的基本過程,與動態連結相比,靜態連結還是相對簡單很多。
以後有機會的話,我們再繼續聊一下動態連結中的一些操作,謝謝!
肝文不易,請支援一下道哥,把文章分享給更多的嵌入式小夥伴,謝謝!
推薦閱讀
【2】C語言指標-從底層原理到花式技巧,用圖文和程式碼幫你講解透徹
![](https://img2022.cnblogs.com/blog/1440498/202203/1440498-20220317202825614-1502450779.png