1. 程式人生 > >徹底理解連結器:重定位

徹底理解連結器:重定位

重定位

程式的執行過程就是CPU不斷的從記憶體中取出指令然後執行執行的過程,對於函式呼叫來說比如我們在C/C++語言中呼叫簡單的加法函式add,其對應的彙編指令可能是這樣的:

 call 0x4004fd

其中0x4004fd即為函式add在記憶體中的地址,當CPU執行這條語句的時候就會跳轉到0x4004fd這個位置開始執行函式add對應的機器指令。再比如我們在C語言中對一個全域性變數g_num不斷加一來進行計數,其對應的彙編指令可能是這樣的:

 mov 0x400fda %eax
 add $0x1 %eax

這裡的意思是把記憶體中 0x400fda 這個地址的資料放到暫存器當中,然後將暫存器中的資料加一,在這裡g_num這個全域性變數的記憶體地址就是0x400fda。

好奇的同學可能會問,那這些函式以及資料的記憶體地址是怎麼來的呢?

確定程式執行時的記憶體地址就是接下來我們要講解的重點內容,這裡先給出答案,可執行檔案中程式碼以及資料的執行時記憶體地址是連結器指定的,也就是上面示例中add的記憶體地址0x4004fd其是連結器指定的。確定程式執行時地址的過程就是這裡重定位(Relocation)。

為什麼這個過程叫做重定位呢,之所以叫做重定位是因為確定可執行檔案中程式碼和資料的執行時地址是分為兩個階段的,在第一個階段中無法確定這些地址,只有在第二個階段才可以確定,因此就叫做重定位。接下來讓我們來看看這兩個階段,合併同類型段以及引用符號的重定位。

編譯器的工作

讓我們回憶一下前幾節的內容,原始檔首先被編譯器編譯生成目標檔案,目標檔案種有三段內容:資料段、程式碼段以及符號表,所有的函式定義被放在了程式碼段,全域性變數的定義放在了資料段,對外部變數的引用放到了符號表。

編譯器在將原始檔編譯生成目標檔案時可以確定一下兩件事:定義在該原始檔中函式的記憶體地址定義在該原始檔中全域性變數的記憶體地址注意這裡的記憶體地址其實只是相對地址,相對於誰的呢,相對於自己的。為什麼只是一個相對地址呢?因為在生成一個目標檔案時編譯器並不知道這個目標檔案要和哪些目標檔案進行連結生成最後的可執行檔案,而連結器是知道要連結哪些目標檔案的。因此編譯器僅僅生成一個相對地址。

而對於引用類的變數,也就是在當前程式碼中引用而定義是在其它原始檔中的變數,對於這樣的變數編譯器是無法確定其記憶體地址的,這不是編譯器需要關心的,確定引用類變數的記憶體地址是連結器的任務,連結器在進行連結時能夠確定這類變數的記憶體地址。因此當編譯器在遇到這樣的變數時,比如使用了外部定義的函式時,其在目標檔案中對應的機器指令可能是這樣的:

call 0x000000

也就是說對於編譯器不能確定的地址都這設定為空(0x000000),同時編譯器還會生成一條記錄,該記錄告訴連結器在進行連結時要修正這條指令中函式的記憶體地址,這個記錄就放在了目標檔案的.rel.text段中。相應的如果是對外部定義的全域性變數的使用,則該記錄放在了目標檔案的.rel.data段中。即連結器需要在連結過程中根據.rel.data以及.rel.text來填好編譯器留下的空白位置(0x000000)。因此在這裡我們進一步豐富目標檔案中的內容,如圖所示:

clipboard.png

生成目標檔案後,編譯器完成任務,編譯器確定了定義在該原始檔中函式以及全域性變數的相對地址。對於編譯器不能確定的引用類變數,編譯器在目標檔案的.rel.text以及.rel.data段中生成相應的記錄告訴連結器要修正這些變數的地址。

接下來就是連結器的工作了。

連結器的工作

我們在靜態庫下可執行檔案的生成一節中知道,連結器會將所有的目標檔案進行合併,所有目標檔案的資料段合併到可執行檔案的資料段,所有目標檔案的程式碼段合併到可執行檔案的程式碼段。當所有合併完成後,各個目標檔案中的相對地址也就確定了。因此在這個階段,連結器需要修正目標檔案中的相對地址。

在這裡我們以合併目標檔案中的資料段為例來說明連結器是如何修正目標檔案的相對地址的,合併程式碼段時修正相對位置的原理是一樣的。

我們假設連結器需要連結三個目標檔案:

  • 目標檔案一:該檔案資料段定義了兩個變數apple和banana,apple的長度為2位元組,banana的長度4位元組,因此目標檔案一的資料段長度為6位元組。從圖中也可以看出apple的記憶體地址為0,也就是相對地址,即apple這個變數在目標檔案一的地址是0,banana的地址為2。
  • 目標檔案二:該檔案的資料段比較簡單,只定義了一個變數orange,其長度為2,因此該目標檔案的資料段長度為2。
  • 目標檔案三:該檔案的資料段定義了三個變數grape、mango以及limo,其長度分別為4位元組、2位元組以及2位元組,因此該目標檔案的資料段長度為8位元組。

clipboard.png

連結器在連結三個目標檔案時其順序是依次連結的,連結完成後:

  • 目標檔案一:該資料段的起始地址為0,因此該資料段中的變數的最終地址不變。
  • 目標檔案二:由於目標檔案一的資料段長度為6,因此連結完成後該資料段的起始地址為6(這裡的起始地址其實就是偏移offset),相應的orange的最終記憶體地址為0+offset即6。
  • 目標檔案三:由於前兩個資料段的長度為8,因此該資料段的起始地址為8(即offset為8),因此所有該資料段中的變數其地址都要加上該offset,即grape的最終地址為8,即0+offset,mango的最終地址為4+offset即12,limo的最終地址為6+offset即14。

從這個過程中可以看到,資料段中的相對地址是通過這個公式來修正的,即:

  相對地址 + offset(偏移) = 最終記憶體地址

而每個段的偏移只有在連結完成後才能確定,因此對相對地址的修正只能由連結器來完成,編譯器無法完成這項任務。

當所有目標檔案的同類型段合併完畢後,資料段和程式碼段中的相對地址都被連結器修正為最終的記憶體位置,這樣所有的變數以及函式都確定了其各自位置。

至此,重定位的第一階段完成。接下來是重定位的第二階段,即引用符號的重定位。

相對地址是編譯器在編譯過程中確定了,在連結器完成後被連結器修正為最終地址,而對於編譯器沒有確定的所引用的外部函式以及變數的地址,編譯器將其記錄在了.rel.text和.rel.data中。

由於在第一階段中,所有函式以及資料都有了最終地址,因此重定位的第二階段就相對簡單了。我們知道編譯器引用外部變數時將機器指令中的引用地址設定為空(比如call 0x000000),並將該資訊記錄在了目標檔案的.rel.text以及.rel.data段中。因此在這個階段連結器依次掃描所有的.rel.text以及.rel.data段並找到相應變數的最終地址(這些位置都已在第一階段確定),並將機器指令中的0x000000修正為所引用變數的最終地址就可以了。

到這裡連結器的重定位就講解的這裡,作為程式設計師一般很少會有問題出現在重定位階段,因此這個階段對程式設計師相對透明。請同學們注意一點,這裡的分析僅限於目標檔案的靜態連結。我們知道靜態連結下,連結器會將需要的程式碼和資料都合併到可執行檔案當中,因此需要確定程式碼和資料的最終位置。而對於動態連結庫來說情況則有所不同,動態連結庫可以同時被多個程序使用,如果動態連結庫的機器指令中不可以存在引用變數的最終位置,否則在被多個程序使用時會出現一個程序中使用的資料被其它程序修改。因此動態庫下的機器指令都是PIC程式碼,即位置無關程式碼(Position-Independent Code)。關於PIC的機制原理就不在這裡闡述了,對此感興趣的同學可以關注微信公眾號,碼農的荒島求生,我會在那裡來講解。

問題:為什麼連結器能確定執行時地址

我們知道只有把可執行檔案載入到記憶體當中程式才可以開始執行。不同的程式會被載入到記憶體的不同位置。我們從前兩節的過程中可以看出,連結器完全沒有考慮不同的程式會被載入不同的記憶體位置被執行。比如對於一個可執行檔案我們分別執行兩次,如下圖所示,因為兩個程式資料段變數的地址是一樣的,那麼程式一的資料會不會被程式二修改呢?

clipboard.png

如果你去試一試的話就會發現顯然不會有這種問題的。而當可執行檔案載入到記憶體的時候也不會根據程式載入的起始地址再去修改可執行檔案中變數的地址(這樣就啟動速度就太慢了),那麼作業系統又是如何能做到基於同一個可執行檔案的兩個程式能在各自的記憶體空間中執行而不相互干擾呢,連結器在可執行檔案中確定的到底是不是程式最終的執行地址呢,我會在後面的文章當中給出答案,歡迎同學們關注微信公共賬號碼農的荒島求生獲取更多內容。

clipboard.png