彻底理解链接器:二,符号决议
符號決議
在這個過程當中,鏈接器需要做的工作就是確保所有目標文件中的符號引用都有唯一的定義。要想理解這句話我們首先來看看一個典型的c文件里都有些什么。
c源文件中都有什么
如圖所示是一個典型的c源文件,該文件中的變量可以劃分為兩類:
- 全局變量:比如x_global_uninit,x_global_init,fn_c。只要程序沒有結束運行,全局變量都可以隨時使用。注意,用static修飾的全局變量比如y_global_uninit,其生命周期也等同于程序的運行周期,只是這種全局變量只能在所被定義的文件當中使用,對其它文件不可見。
- 局部變量:比如y_local_uninit,y_local_init,局部局部變量的生命周期和全局變量不同,局部變量變量只能在相應的函數內部使用,當函數調用完成后該函數中的局部變量也就無法使用了。因為局部變量只存在于函數運行時的棧幀當中,函數調用完成后相應的棧幀被自動回收(如果你還不能理解這句話是什么意思沒有關系,我會在后面的文章當中詳細講解程序運行時的內存模型)。
目標文件里有什么
編譯器的任務就是把人類可以理解的代碼轉換成機器可以執行的機器指令,源文件編譯后形成對應的目標文件,這個我們在之前的章節中已經多次提到過了。源文件被編譯后生成的目標文件中本質上只有兩部分:
- 代碼部分:你可能會想,一個源文件中不都是代碼嗎,這里的代碼指的是計算機可以執行的機器指令,也就是源文件中定義的所有函數。比如上圖中定義的函數fn_b以及fn_c。
- 數據部分:源文件中定義的全局變量。如果是已經初始化后的全局變量,該全局變量的值也存在于數據部分。
到目前為止,你可以把一個目標文件簡單的理解為由兩部分組成,代碼部分中保存的是CPU可以執行的機器指令,這些機器指令來自程序員所定義的函數,編譯器將這些定義的函數翻譯成機器指令并存放在目標文件的代碼部分。數據部分存放的是機器指令所操作的數據。因此目前,你可以簡單的將目標文件理解為一個只有兩部分的文件,如圖所示:
你可能會好奇函數中定義的局部變量為什么沒有放到目標文件的數據段當中,這是因為局部變量是函數私有的,局部變量只能在該函數內部使用而全局變量時沒有這個限制的,所以函數私有的局部變量被放在了代碼段中,作為機器指令的操作數。
編譯器在編譯過程中遇到外部定義的全局變量或函數時,只要編譯器能找到相應的變量聲明就會在心里默念“all is well, all is well(一切順利)“,從這里可以看出編譯器的要求還是很低的,至于所使用變量的定義編譯器是不會費力去四處搜索,而是愉快的繼續接下來的編譯。注意,這里再次強調一下,編譯器在遇到外部定義的全局變量或者函數時只要能在當前文件找到其聲明,編譯器就認為編譯正確。而尋找使用變量定義的這項任務就被留給了鏈接器。鏈接器的其中一項任務就是要確定所使用的變量要有其唯一的定義。雖然編譯器給鏈接器留了一項任務,但為了讓鏈接器工作的輕松一點編譯器還是多做了一點工作的,這部分工作就是符號表(Symbol table)。
符號表(Symbol table)
我們在上一節中提到,雖然編譯器很不厚道的給鏈接器留了一項任務,但是編譯器為了鏈接器工作的輕松一點還是做了一點事情,這就是符號表。那符號表中保存的是什么呢,符號表中保存的信息有兩部分:
- 該目標文件中引用的全局變量以及函數
- 該目標文件中定義的全局變量以及函數
以上圖中的代碼為例,編譯器在編譯過程中每次遇到一個全局變量或者函數名都會在符號表中添加一項,最終編譯器會統計出如下所示的一張符號表:
z_global以及fn_a是未定義的,因為在當前文件中,這兩個變量僅僅是聲明,編譯器并沒有找到其定義。剩余的變量編譯器都可以在當前文件中找到其定義。
fn_b以及fn_c為當前文件定義的函數,因為在代碼段。
剩余的符號都是全局變量,因此放在了數據段。
有同學可能會問,為什么全局變量y_global_uninit ,y_global_init以及函數fn_b不可被其它目標文件引用,這是因為這些變量用static修飾過了,在C語言中經static修飾過的函數的函數以及變量都是當前文件私有的,對外部不可見,這里一定要注意。所以static這個關鍵字的用法就是,如果你認為一個變量只應該被當前文件使用而不暴露給外部,那么你就可以使用static關鍵字修飾一下。
本質上整個符號表只是想表達兩件事:
- 我能提供給其它文件使用的符號
- 我需要其它文件提供給我使用的符號
這里還有一個問題就是,編譯器將統計的這張符號表放在哪里了呢?
符號表存放在哪里
在目標文件里有什么這一小節中,我們將一個目標文件簡單的劃分了兩段,數據段和代碼段,現在我們要向目標文件中再添加一段,而符號表也被編譯器很貼心的放在目標文件中,因此一個目標文件可以理解為如圖所示的三段,而符號表中的內容就是上一節當中編譯器統計的表格。
有了符號表,鏈接器就可以進行符號決議了。
符號決議的過程
在上一節符號表中,我們知道符號表給鏈接器提供了兩種信息,一個是當前目標文件可以提供給其它目標文件使用的符號,另一個其它目標文件需要提供給當前目標文件使用的符號。有了這些信息鏈接器就可以進行符號決議了。如圖所示,假設鏈接器需要鏈接三個目標文件:
鏈接器會依次掃描每一個給定的目標文件,同時鏈接器還維護了兩個集合,一個是已定義符號集合D,另一個是未定義符合集合U,下面是鏈接器進行符合決議的過程:
1,對于當前目標文件,查找其符號表,并將已定義的符號并添加到已定義符號集合D中。
2,對于當前目標文件,查找其符號表,將每一個當前目標文件引用的符號與已定義符號集合D進行對比,如果該符號不在集合D中則將其添加到未定義符合集合U中。
3,當所有文件都掃描完成后,如果為定義符號集合U不為空,則說明當前輸入的目標文件集合中有未定義錯誤,鏈接器報錯,整個編譯過程終止。
上面的過程看似復雜,其實用一句話概括就是只要每個目標文件所引用變量都能在其它目標文件中找到唯一的定義,整個鏈接過程就是正確的。
如果你覺得上面的解釋比較晦澀的話,你也可以將鏈接符號決議這個過程想象成如下的游戲:
新學期開學后,幼兒園的小朋友們都帶了禮物要和其它的小朋友們分享,同時每個小朋友也有自己的心愿單,每個小朋友都可以依照自己的心愿單去其它的小朋友那里拿禮物,整個過程結束后,每個小朋友都能拿到自己想要的禮物。
在這個游戲當中,小朋友就好比目標文件,每個小朋友自己帶的禮物就好比每個目標文件的已定義符號集合,心愿單就好比每個目標文件中未定義符號的集合。
??
實例說明undefined reference
假設我們寫了一個math.c的數字計算程序,其中定義了一個add函數,該函數在main.c中被引用到,那么很簡單,我們只需要在main.c中include寫好的math.h頭文件就可以使用add函數了,如圖所示:
但是由于粗心大意,一不小心把math.c中的add函數給注釋掉了,當你在寫完main.c、打算很瀟灑的編譯一下時,出現了很經典的undefined reference to add(int, int)錯誤,如圖所示:
這個錯誤其實是這樣產生的:
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這樣的錯誤的是時候,你就應該可以很從容的去解決這類問題了。
接下來的內容我會在以下幾篇文章當中一一介紹:
徹底理解鏈接器:三,庫與可執行文件
徹底理解鏈接器:四,重定位
如果你喜歡這一系列的文章,也歡迎關注我的微信公共賬號,碼農的荒島求生,獲取更多內容。
這個系列完整的文章目錄:
總結
以上是生活随笔為你收集整理的彻底理解链接器:二,符号决议的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 吉利李书福:希望5年后绿色甲醇产业能真正
- 下一篇: 享年86岁 电影美术大师杨占家去世 手绘