徹底理解連結器:四

碼農的荒島求生發表於2018-09-17

承接上一篇文章《徹底理解連結器:三》

目錄

動態庫vs靜態庫

過程三:重定位

編譯器的工作


動態庫vs靜態庫

在計算機的歷史當中,最開始程式只能靜態連結,但是人們很快發現,靜態連結生成的可執行檔案存在磁碟空間浪費問題,因為對於每個程式都需要依賴的libc庫,在靜態連結下每個可執行檔案當中都有一份libc程式碼和資料的拷貝,為解決該問題才提出動態庫。

在前幾節我們知道,動態連結下可執行檔案當中僅僅保留動態庫的必要資訊,因此解決了靜態連結下磁碟浪費問題。動態庫的強大之處不僅僅於此,我們知道對於現代計算機系統,比如PC,通常會執行成百上千個程式(程式),且程式只有被載入到記憶體中才可以使用,如果使用靜態連結那麼在記憶體中就會有成百上千份同樣的libc程式碼,這對於寶貴的記憶體資源同樣是極大的浪費,而使用動態連結,記憶體中只需要有一份libc程式碼,所有的程式(程式)共享這一份程式碼,因此極大的節省了記憶體資源,這也是為什麼動態庫又叫共享庫。

動態庫還有另外一個強大之處,那就是如果我們修改了動態庫的程式碼,我們只需要重新編譯動態庫就可以了而無需重新新編譯我們自己的程式,因為可執行檔案當中僅僅保留了動態庫的必要資訊,重新編譯動態庫後這些必要都資訊是不會改變的(只要不修改動態庫的名字和動態庫匯出的供可執行檔案使用的函式),編譯好新的動態庫後只需要簡單的替換原有動態庫,下一次執行程式時就可以使用新的動態庫了,因此動態庫的這種特性極大的方便了程式升級和bug修復。我們平時使用都客戶端程式,比如我們常用QQ,輸入法,播放器,都利用了動態庫的這一優點,原因就在於方便升級以bug修復,只需要更新相應的動態庫就可以了。

動態庫的優點不止於此,我們知道動態連結可以出現在執行時(run-time dynamic link),動態連結的這種特性可以用於擴充套件程式能力,那麼如何擴充套件呢?你肯定聽說過一樣神器,沒錯,就是外掛。你有沒有想過外掛是怎麼實現的?實現外掛時,我們只需要實現幾個規定好的幾個函式,我們的外掛就可以執行了,可這是怎麼做到的呢,答案就在於執行時動態連結,可以將外掛以動態的都方式實現。我們知道使用執行時動態連結無需在編譯連結期間告訴連結器所使用的動態庫資訊,可執行檔案對此一無所知,只有當執行時才知道使用什麼動態庫,以及使用了動態庫中哪些函式,但是在編譯連結可執行檔案時又怎麼知道外掛中定義了哪些函式呢,因此所有的外掛實現函式必須都有一個統一的格式,程式在執行時需要載入所有外掛(動態庫),然後呼叫所有外掛的入口函式(統一的格式),這樣我們寫的外掛就可以被執行起來了。

動態庫都強大優勢還體現在多語言程式設計上。我們知道使用Python可以快速進行開發,但Python的效能無法同C/C++相比(因為Python是解釋型語言,至於什麼是解釋型語言我會在後面碼農的荒島求生系列文章當中給大家詳細講解),有沒有辦法可以兼具Python的快速開發能力以及C/C++的高效能呢,答案是可以的,我們可以將C/C++程式碼編譯連結成動態庫,這樣python就可以直接呼叫動態庫中的函式了。不但Python,Perl以及Java等都可以通過動態庫的形式呼叫C/C++程式碼。動態庫的使用使得同一個專案不同語言混合程式設計成為可能,而且動態庫的使用更大限度的實現了程式碼複用。

瞭解了動態庫的這麼多優點,那麼動態庫就沒有缺點嗎,當然是有的。

首先由於動態庫是程式載入時或執行是才進行連結的,因此同靜態連結相比,使用動態連結的程式在效能上要稍弱於靜態連結,這時因為對於載入時動態連結,這無疑會減慢程式都啟動速度,而對於執行時連結,當首次呼叫到動態庫的函式時,程式會被暫停,當連結過程結束後才可以繼續進行。且動態庫中的程式碼是地址無關程式碼(Position-Idependent Code,PIC),之所以動態庫中的程式碼是地址無關程式碼是因為動態庫又被成為共享庫,所有的程式都可以呼叫動態庫中的程式碼,因此在使用動態庫中的程式碼時程式要多做一些工作,這裡我們不再具體展開講解到底程式多做了哪些工作,對此感興趣當同學可以參考CSAPP(深入理解計算機系統)。這裡我們說動態連結的程式效能相比靜態連結稍弱,但是這裡的效能損失是微乎其微的,同動態庫可以帶來的好處相比,我們可以完全忽略這裡的效能損失,同學們可以放心的使用動態庫。

動態庫的一個優點其實也是它的缺點,即動態連結下的可執行檔案不可以被獨立執行(這裡討論的是載入時動態連結,load-time dynamic link),換句話說就是,如果沒有提供所依賴的動態庫或者所提供的動態庫版本和可執行檔案所依賴的不相容,程式是無法啟動的。動態庫的依賴問題會給程式的安裝部署帶來麻煩,在Linux環境下尤其嚴重,以筆者曾參與開發維護的一個虛擬桌面系統為例,我們在開發過程中依賴的一些比較有名的第三方庫預設不會隨著安裝包釋出,這就會導致使用者在較低版本Linux中安裝時經常會出現程式無法啟動的問題,原因就在於我們編譯連結使用都動態庫和使用者Linux系統中都動態庫不相容。解決這個問題的方法通常有兩種,一個是使用者升級系統中都動態庫,另一個是我們講需要都第三方庫隨安裝包一起釋出,當然這是在取得許可的情況下。

在瞭解了動態庫的優缺點後,接下來我們來看一下靜態庫。

靜態連結是最古老也是最簡單的連結技術。靜態連結都最大優點就是使用簡單,編譯好的可執行檔案是完備的,即靜態連結下的可執行檔案不需要依賴任何其它的庫,因為靜態連結下,連結器將所有依賴的程式碼和資料都寫入到了最終的可執行檔案當中,這就消除了動態連結下的庫依賴問題,沒有了庫都依賴問題就意味著程式都安裝部署都得到了極大都簡化。請大家不要小看這一點,這對當今那些擁有海量使用者的後端系統來說至關重要,比如類似微信這種量級的系統,其後端會部署在成千上萬臺機器上,這麼多的機器其系統的安裝部署以及升級會給運維帶來極大挑戰,而靜態連結下的可執行檔案由於不依賴任何庫,因為部署非常方便,僅僅用一個新的可執行檔案進行覆蓋就可以了,因此極大的簡化了系統部署以及升級。筆者之前所在的某電商廣告後端系統就完全使用靜態連結來簡化部署升級。

而靜態庫的缺點相信大家都已經清楚了,那就是靜態連結會導致可執行檔案過大,且多個程式靜態連結同一個靜態庫的話會導致磁碟浪費的問題。

到這裡關於靜態庫和動態庫的討論就告一段落了,相信大家對於這兩種連結型別都有了清晰都認知。接下來讓我們稍作休息,開始連結器的下一個重要功能,重定位。

 

過程三:重定位

程式的執行過程就是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)。因此在這裡我們進一步豐富目標檔案中的內容,如圖所示:

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

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

接下來的內容我會在該系列的下一篇文章當中介紹,如果你喜歡改系列的文章,歡迎關注我的維信公共賬號,碼農的荒島求生,獲取更多內容。

 

相關文章