1. 程式人生 > >徹底理解連結器:符號決議

徹底理解連結器:符號決議

符號決議

在這個過程當中,連結器需要做的工作就是確保所有目標檔案中的符號引用都有唯一的定義。要想理解這句話我們首先來看看一個典型的c檔案裡都有些什麼。

c原始檔中都有什麼

如圖所示是一個典型的c原始檔,該檔案中的變數可以劃分為兩類:

  • 全域性變數:比如x_global_uninit,x_global_init,fn_c。只要程式沒有結束執行,全域性變數都可以隨時使用。注意,用static修飾的全域性變數比如y_global_uninit,其生命週期也等同於程式的執行週期,只是這種全域性變數只能在所被定義的檔案當中使用,對其它檔案不可見。
  • 區域性變數:比如y_local_uninit,y_local_init,區域性區域性變數的生命週期和全域性變數不同,區域性變數變數只能在相應的函式內部使用,當函式呼叫完成後該函式中的區域性變數也就無法使用了。因為區域性變數只存在於函式執行時的棧幀當中,函式呼叫完成後相應的棧幀被自動回收(如果你還不能理解這句話是什麼意思沒有關係,我會在後面的文章當中詳細講解程式執行時的記憶體模型)。

clipboard.png

目標檔案裡有什麼

編譯器的任務就是把人類可以理解的程式碼轉換成機器可以執行的機器指令,原始檔編譯後形成對應的目標檔案,這個我們在之前的章節中已經多次提到過了。原始檔被編譯後生成的目標檔案中本質上只有兩部分:

  • 程式碼部分:你可能會想,一個原始檔中不都是程式碼嗎,這裡的程式碼指的是計算機可以執行的機器指令,也就是原始檔中定義的所有函式。比如上圖中定義的函式fn_b以及fn_c。
  • 資料部分:原始檔中定義的全域性變數。如果是已經初始化後的全域性變數,該全域性變數的值也存在於資料部分。

到目前為止,你可以把一個目標檔案簡單的理解為由兩部分組成,程式碼部分中儲存的是CPU可以執行的機器指令,這些機器指令來自程式設計師所定義的函式,編譯器將這些定義的函式翻譯成機器指令並存放在目標檔案的程式碼部分。資料部分存放的是機器指令所操作的資料。因此目前,你可以簡單的將目標檔案理解為一個只有兩部分的檔案,如圖所示:

clipboard.png

你可能會好奇函式中定義的區域性變數為什麼沒有放到目標檔案的資料段當中,這是因為區域性變數是函式私有的,區域性變數只能在該函式內部使用而全域性變數時沒有這個限制的,所以函式私有的區域性變數被放在了程式碼段中,作為機器指令的運算元。

編譯器在編譯過程中遇到外部定義的全域性變數或函式時,只要編譯器能找到相應的變數宣告就會在心裡默唸“all is well, all is well(一切順利)“,從這裡可以看出編譯器的要求還是很低的,至於所使用變數的定義編譯器是不會費力去四處搜尋,而是愉快的繼續接下來的編譯。注意,這裡再次強調一下,編譯器在遇到外部定義的全域性變數或者函式時只要能在當前檔案找到其宣告,編譯器就認為編譯正確。而尋找使用變數定義的這項任務就被留給了連結器。連結器的其中一項任務就是要確定所使用的變數要有其唯一的定義。雖然編譯器給連結器留了一項任務,但為了讓連結器工作的輕鬆一點編譯器還是多做了一點工作的,這部分工作就是符號表(Symbol table)。

符號表(Symbol table)

我們在上一節中提到,雖然編譯器很不厚道的給連結器留了一項任務,但是編譯器為了連結器工作的輕鬆一點還是做了一點事情,這就是符號表。那符號表中儲存的是什麼呢,符號表中儲存的資訊有兩部分:

  • 該目標檔案中引用的全域性變數以及函式
  • 該目標檔案中定義的全域性變數以及函式

以上圖中的程式碼為例,編譯器在編譯過程中每次遇到一個全域性變數或者函式名都會在符號表中新增一項,最終編譯器會統計出如下所示的一張符號表:

clipboard.png

z_global以及fn_a是未定義的,因為在當前檔案中,這兩個變數僅僅是宣告,編譯器並沒有找到其定義。剩餘的變數編譯器都可以在當前檔案中找到其定義。

fn_b以及fn_c為當前檔案定義的函式,因為在程式碼段。

剩餘的符號都是全域性變數,因此放在了資料段。

有同學可能會問,為什麼全域性變數y_global_uninit ,y_global_init以及函式fn_b不可被其它目標檔案引用,這是因為這些變數用static修飾過了,在C語言中經static修飾過的函式的函式以及變數都是當前檔案私有的,對外部不可見,這裡一定要注意。所以static這個關鍵字的用法就是,如果你認為一個變數只應該被當前檔案使用而不暴露給外部,那麼你就可以使用static關鍵字修飾一下。

本質上整個符號表只是想表達兩件事:

  • 我能提供給其它檔案使用的符號
  • 我需要其它檔案提供給我使用的符號

這裡還有一個問題就是,編譯器將統計的這張符號表放在哪裡了呢?

符號表存放在哪裡

在目標檔案裡有什麼這一小節中,我們將一個目標檔案簡單的劃分了兩段,資料段和程式碼段,現在我們要向目標檔案中再新增一段,而符號表也被編譯器很貼心的放在目標檔案中,因此一個目標檔案可以理解為如圖所示的三段,而符號表中的內容就是上一節當中編譯器統計的表格。

clipboard.png

有了符號表,連結器就可以進行符號決議了。

符號決議的過程

在上一節符號表中,我們知道符號表給連結器提供了兩種資訊,一個是當前目標檔案可以提供給其它目標檔案使用的符號,另一個其它目標檔案需要提供給當前目標檔案使用的符號。有了這些資訊連結器就可以進行符號決議了。如圖所示,假設連結器需要連結三個目標檔案:連結器會依次掃描每一個給定的目標檔案,同時連結器還維護了兩個集合,一個是已定義符號集合D,另一個是未定義符合集合U,下面是連結器進行符合決議的過程:1,對於當前目標檔案,查詢其符號表,並將已定義的符號並新增到已定義符號集合D中。2,對於當前目標檔案,查詢其符號表,將每一個當前目標檔案引用的符號與已定義符號集合D進行對比,如果該符號不在集合D中則將其新增到未定義符合集合U中。3,當所有檔案都掃描完成後,如果為定義符號集合U不為空,則說明當前輸入的目標檔案集合中有未定義錯誤,連結器報錯,整個編譯過程終止。

上面的過程看似複雜,其實用一句話概括就是隻要每個目標檔案所引用變數都能在其它目標檔案中找到唯一的定義,整個連結過程就是正確的。

如果你覺得上面的解釋比較晦澀的話,你也可以將連結符號決議這個過程想象成如下的遊戲:新學期開學後,幼兒園的小朋友們都帶了禮物要和其它的小朋友們分享,同時每個小朋友也有自己的心願單,每個小朋友都可以依照自己的心願單去其它的小朋友那裡拿禮物,整個過程結束後,每個小朋友都能拿到自己想要的禮物。在這個遊戲當中,小朋友就好比目標檔案,每個小朋友自己帶的禮物就好比每個目標檔案的已定義符號集合,心願單就好比每個目標檔案中未定義符號的集合。​​

clipboard.png

例項說明undefined reference

假設我們寫了一個math.c的數字計算程式,其中定義了一個add函式,該函式在main.c中被引用到,那麼很簡單,我們只需要在main.c中include寫好的math.h標頭檔案就可以使用add函數了,如圖所示:

clipboard.png

但是由於粗心大意,一不小心把math.c中的add函式給註釋掉了,當你在寫完main.c、打算很瀟灑的編譯一下時,出現了很經典的undefined reference to add(int, int)錯誤,如圖所示:

clipboard.png

這個錯誤其實是這樣產生的:1, 連結器發現了你寫的程式碼math.o中引用了外部定義的add函式(不要忘了,這是通過檢查目標檔案math.o中的符號表得到的資訊),所以連結器開始查詢add函式到底是在哪裡定義的。2,連結器轉而去目標檔案math.o的目標檔案符號表中查詢,沒有找到add函式的定義。3,連結器轉而去其它目標檔案符號表中查詢,同樣沒有找到add函式的定義。4,連結器在查找了所有目標檔案的符號表後都沒有找到add函式,因此連結器停止工作並報出錯誤undefined reference to `add(int, int)',如上圖所示。

因此如果你很清楚連結器符號決議這個過程的話就會進行如下排查:1:main.c中對add函式的函式名有沒有寫正確。2:連結命令中有沒有包含math.o,如果沒有新增上該目標檔案。3:如果連結命令沒有問題,檢視math.c中定義的add函式定義是否有問題。4:如果是C和C++混合程式設計時,確保相應的位置添加了extern "C"。

一般情況下經過這幾個步驟的排查基本能夠解決問題。所以當你再次看到undefined reference這樣的錯誤的是時候,你就應該可以很從容的去解決這類問題了。