1 幾個基本概念
編譯:編譯器對源文件的編譯過程,就是將源文件中的文本形式代碼翻譯為機器語言形式的目標文件的過程,此過程中會有一系列語法檢查、指令優化等,生成目標(OBJ)文件。
編譯單元:每一個CPP文件就是一個編譯單元,每個單元之間是互相獨立且不可知的。
目標文件:編譯步驟產生的文件,包含了編譯單元內所有代碼和數據,以二進制形式存在。請注意三個關鍵表:未解決符號表、導出符號表、地址重定向表。
鏈接:當編譯器將工程中所有CPP文件以分離形式編譯成各個對應目標(OBJ)文件之后,再由鏈接器進行鏈接生成一個EXE或者DLL文件。
2 一個例子
用一個例子來理解下編譯器與鏈接器的工作:
file1.cpp
int gVal = 123; void func1() { ++gVal; }
這個文件編譯出來的目標文件file1.obj 就會有一個段來包含上面的數據和函數,內容大致如下(只是示意,並非完全一樣):
偏移量 內容 長度
0x0000 gVal 4
0x0004 func1 ??
這里的??表示長度未知,實際目標文件的各個數據可能不是連續的,也不一定從0x0000開始。比如這里的func1可能是這樣:
0x0004 inc DWORD PTR[0x0000]
0x00?? ret
這里把++gVal翻譯為inc語句,也就是把本單元的0x0000地址的一個DWORD(4字節)進行加一。
file2.cpp
extern int gVal; void func2() { ++gVal; }
對應的file2.obj文件內容應該是:
偏移量 內容 長度
0x0000 func2 ??
可以看到這里並沒有gVal,原因是extern關鍵字聲明這是個外部引用符號,已經在別的單元里面定義了。由於單元之間是隔離的,所以這里的func2代碼就沒有辦法填寫地址,大致是這樣:
0x0004 inc DWORD PTR[????]
0x00?? ret
這個????就表示當前無法拿到有效地址,需要鏈接器來完成,如何告訴鏈接器?需要一個未解決符號表(unresolved symbol table),同樣提供gVal符號的目標文件也要提供一個導出符號表(export symbol table)來告知鏈接器可以提供的符號內容。
這兩個表之間依靠符號來進行關聯,在C/C++中每一個變量和函數都有自己的符號名,函數名略復雜(依據編譯器不同而有差異),假設這里函數func1的符號為_func1,那么file1.obj文件的導出符號為:
導出符號 地址
gVal 0x0000
_func1 0x0004
而對應的未解決符號表則為空,因為沒有依賴其他單元的內容。另一個file2.cpp文件的導出符號表為:
導出符號 地址
_func2 0x0000
而對應的未解決符號表為(下表的含義是說0x0001位置有一個地址不明,符號叫gVal):
未決符號 地址
gVal 0x0001
鏈接器會針對未解決符號表中的在所有導出符號表中進行匹配,如果找到則匹配填寫進來,如果找不到就會報鏈接錯誤。但這里可以發現一個問題,就是gVal的符號地址是0x0000,如果直接將解析的地址替換未解決符表中的????就會生成一個沖突的地址0x0000,與本單元的地址重復了。為了解決這個問題,還需要針對每一個編譯單元引入的一個地址偏移。例如file1.obj的地址從0x00001000開始,file2.obj的地址從0x00002000開始,這樣符號地址疊加后就不會重復了。記錄這樣的地址偏移量的表稱之為地址重定向表(address redirect table)。
3 鏈接器工作順序
當鏈接器進行鏈接的時候,首先決定各個目標文件在最終可執行文件里的位置。然后訪問所有目標文件的地址重定義表,對其中記錄的地址進行重定向(加上一個偏移量,即該編譯單元在可執行文件上的起始地址)。然后遍歷所有目標文件的未解決符號表,並且在所有的導出符號表里查找匹配的符號,並在未解決符號表中所記錄的位置上填寫實現地址。最后把所有的目標文件的內容寫在各自的位置上,再作一些另的工作,就生成一個可執行文件。
說明:實現鏈接的時候會更加復雜,一般實現的目標文件都會把數據,代碼分成好向個區,重定向按區進行,但原理都是一樣的。
extern:這就是告訴編譯器,這個變量或函數在別的編譯單元里定義了,也就是要把這個符號放到未解決符號表里面去(外部鏈接)。
static:如果該關鍵字位於全局函數或者變量的聲明前面,表明該編譯單元不導出這個函數或變量,因些這個符號不能在別的編譯單元中使用(內部鏈接)。如果是static局部變量,則該變量的存儲方式和全局變量一樣,但是仍然不導出符號。
默認鏈接屬性:對於函數和變量,默認鏈接是外部鏈接,對於const變量,默認內部鏈接。
外部鏈接:外部鏈接的符號在整個程序范圍內都是可以使用的,這就要求其他編譯單元不能導出相同的符號(不然就會報duplicated external symbols)。
內部鏈接:內部鏈接的符號不能在別的編譯單元中使用。但不同的編譯單元可以擁有同樣的名稱的符號。
為什么頭文件里一般只可以有聲明不能有定義:頭文件可以被多個編譯單元包含,如果頭文件里面有定義的話,那么每個包含這頭文件的編譯單元都會對同一個符號進行定義,如果該符號為外部鏈接,則會導致duplicated external symbols鏈接錯誤。
為什么公共使用的內聯函數要定義於頭文件里:因為編譯時編譯單元之間互不知道,如果內聯被定義於.cpp文件中,編譯其他使用該函數的編譯單元的時候沒有辦法找到函數的定義,因些無法對函數進行展開。所以如果內聯函數定義於.cpp里,那么就只有這個.cpp文件能使用它。