【圖片+程式碼】:GCC 連結過程中的【重定位】過程分析

IOT物聯網小鎮發表於2022-03-17

作 者:道哥,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.omain.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):

我們主要關心黃色的程式碼段和資料段就可以了,可以看出:

  1. 程式碼段(.text):地址Addr是 0x0000_0000(因為這是目標檔案,不是可執行檔案,所以不會安排地址),它在 sub.o 檔案中的偏移量(Off)是 0x34,長度是 0x0C 位元組;

  2. 資料段(.data):地址Addr是 0x0000_0000,它在 sub.o 檔案中的偏移量(Off)是 0x40,長度是 0x04 位元組;

簡單算一下:sub.o的開始部分是ELF header,通過 readelf -h sub.o 指令可以看出來header部分是52個位元組(即:0x34),如下:

因此可以得到:

  1. 程式碼段(.text)是緊接在 header 之後,長度是 0x0C 個位元組,在檔案中佔據著 0x34 ~ 0x3F 這部分空間(0x3F = 0x34 + 0x0C - 1);

  2. 資料段(.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):

關注上圖中黃色矩形中的兩個符號:SubDataSubFunc,很明顯它們就是sub.c中定義的兩個符號:全域性變數和全域性函式。

對於SubData符號來說:

  1. Size=4: 長度是 4 個位元組;

  2. Type=OBJECT:說明這是一個資料物件;

  3. Bind=GLOBAL:說明這個符號是全域性可見的,也就是在其他檔案中可以使用;

  4. Ndx=2:說明這個符號是屬於第 2 個 段中,就是資料段(.data);

同樣的道理,對於SubFunc符號來說:

  1. Size=12: 長度是 12 個位元組;

  2. Type=FUNC:說明這是一個函式;

  3. Bind=GLOBAL:說明這個符號是全域性可見的,也就是在其他檔案中可以呼叫;

  4. Ndx=1:說明這個符號是屬於第 1 個 段中,就是程式碼段(.text);

main.o 檔案分析

按照上面的步驟,把main.o中的這幾個資訊也檢視一下。

段資訊

指令:readelf -S main.o

可以看出:

  1. 程式碼段(.text):地址Addr是 0x0000_0000(因為這是目標檔案,不是可執行檔案,所以不會安排地址),它在 sub.o 檔案中的偏移量(Off)是 0x34,長度是 0x32 位元組;

  2. 資料段(.data):地址Addr是 0x0000_0000,它在 sub.o 檔案中的偏移量(Off)是 0x66,長度是 0 個位元組,因為它沒有定義變數;

在檔案中的佈局如下所示:

符號表資訊

指令:readelf -s main.o

重點看一下黃色矩形中的3個符號。

main符號:

  1. Size=50: 長度是 30 個位元組,也就對應著程式碼段的長度 0x32 ;

  2. Type=FUNC:說明這是一個函式;

  3. Bind=GLOBAL:說明這個符號是全域性可見的,也就是在其他檔案中可以呼叫;

  4. Ndx=1:說明這個符號是屬於第 1 個 段中,就是程式碼段(.text);

下面兩個符號SubDataSubFunc,他們的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)的 SubDataSubFunc這 2 個符號都需要連結器對它進行重定位。

TYPE列:R_386_32表示絕對定址, R_386_PC32 表示相對定址; OFFSET列表示需要重定位的符號在main.o檔案程式碼段中的偏移位置。

剛才已經看了main.o的反彙編程式碼,可以看到偏移0x12 和 0x1b的地方,就是需要進行地址重定位的兩個符號。

可執行程式 main

有了 2 個目標檔案:sub.omain.o,就可以連結得到可執行程式了:

$ ld -m elf_i386 main.o sub.o -e main -o main

段資訊

使用readelf工具來看一下main可執行檔案中的段資訊(指令:readelf -S main):

  1. 紅色矩形框是程式碼段(.text),連結器把它放在虛擬地址 0x0804_8094;

  2. 黃色矩形框是資料段(.data),連結器把它放在虛擬地址 0x0804_9138;

從段資訊中可以看到main檔案中程式碼段和資料段的佈局如下:

可執行程式main是由main.osub.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 部分內容:

  1. main.o 中的程式碼段 main 函式;

  2. 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 個問題

  1. 需要計算出在執行檔案 main 中的什麼位置來填寫絕對地址(虛擬地址);

  2. 填寫的絕對地址(虛擬地址)的值是多少;

首先來解決第一個問題。

從可執行檔案的段表中可以看出:目標檔案main.osub.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 個問題

  1. 需要計算出在執行檔案 main 中的什麼位置來填寫相對地址;

  2. 填寫的相對地址的值是多少;

首先來解決第一個問題。

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的反彙編程式碼:

從黃色和紅色的矩形框可以看出,二進位制指令中的地址值與上面的分析是一致的。

以上就是靜態連結過程中地址重定位的基本過程,與動態連結相比,靜態連結還是相對簡單很多。

以後有機會的話,我們再繼續聊一下動態連結中的一些操作,謝謝!


------ End ------

肝文不易,請支援一下道哥,把文章分享給更多的嵌入式小夥伴,謝謝!

推薦閱讀

【1】《Linux 從頭學》系列文章

【2】C語言指標-從底層原理到花式技巧,用圖文和程式碼幫你講解透徹

【3】原來gdb的底層除錯原理這麼簡單

【4】內聯彙編很可怕嗎?看完這篇文章,終結它!

其他系列專輯:精選文章應用程式設計物聯網C語言

星標公眾號,第一時間看文章!

![](https://img2022.cnblogs.com/blog/1440498/202203/1440498-20220317202825614-1502450779.png

相關文章