在實習的過程中,偶爾會在編譯代碼的時候出現莫名其妙的鏈接錯誤,或者更慘的是,編譯鏈接通過了,運行的時候出現莫名其妙的coredump,查了半天原來是.a靜態庫更新了導致.h文件和.o文件不一致。
受夠了被這些錯誤支配的恐懼,所以決定補充一下這方面的知識。
以下內容參考自網絡。
1、編譯:編譯器對源文件進行編譯,就是把源文件中的文本形式存在的源代碼翻譯成機器語言形式的目標文件的過程,在這個過程中,編譯器會進行一系列的語法檢查。如果編譯通過,就會把對應的CPP轉換成OBJ文件。
2、編譯單元:根據C++標准,每一個CPP文件就是一個編譯單元。每個編譯單元之間是相互獨立並且互相不可知。
3、目標文件:由編譯所生成的文件,以機器碼的形式包含了編譯單元裡所有的代碼和數據,還有一些其他信息,如未解決符號表,導出符號表和地址重定向表等。目標文件是以二進制的形式存在的。
我們知道,在預編譯的時候,.h頭文件會被復制、擴展到包含它的.cpp文件裡,然後編譯器編譯該.cpp文件為一個.obj文件,該.cpp文件作為一個編譯單元獨立編譯。當編譯器將一個工程裡的所有.cpp文件以分離的方式編譯完畢後,再由鏈接器進行鏈接成為一個可執行文件。
這裡我們只關注下目標文件的生成。
假設有一個A.cpp文件,如下定義:
int n = 1; void FunA() { ++n; }
它編譯出來的目標文件A.obj就會有一個區域(或者說是段),包含以上的數據和函數,其中就有n、FunA,以文件偏移量形式給出可能就是下面這種情況:
偏移量 內容 長度
0x0000 n 4
0x0004 FunA ??
說明:實際目標文件的布局可能不是這樣,這裡只是方便學習才這樣表示,??表示長度未知,目標文件的各個數據可能不是連續的,也不一定是從0x0000開始。
FunA函數的內容可能如下:
0x0004 inc DWORD PTR[0x0000]
0x00?? ret
有另外一個B.cpp文件,定義如下:
extern int n;
void FunB()
{
++n;
}
它對應的B.obj的二進制:
偏移量 內容 長度
0x0000 FunB ??
由於n被聲明為extern,而extern關鍵字告訴編譯器n已經在別的編譯單元裡定義了,在這個單元裡不用定義。由於編譯單元之間是互不相關的,所以編譯器就不知道n究竟在哪裡,所以在函數FunB中就沒有辦法生成n的地址,那麼函數FunB中就是這樣的:
0x0000 inc DWORD PTR[????]
0x00?? ret
為了讓各個編譯單元結合起來,就需要鏈接器了。為了能讓鏈接器知道哪些地方的地址沒有填好(也就是還????),那麼目標文件中就要有一個表來告訴鏈接器,這個表就是“未解決符號表”(unresolved symbol table)。同樣,提供n的目標文件也要提供一個“導出符號表”(exprot symbol table),來告訴鏈接器自己可以提供哪些地址。
因此,一個目標文件不僅要提供數據和二進制代碼,還要提供兩個表:未解決符號表和導出符號表,來告訴鏈接器自己需要什麼和自己能提供些什麼。
那麼這兩個表是怎麼建立對應關系的呢?
在C/C++中,每一個變量及函數都會有自己的符號,如變量n的符號就是n,函數的符號會更加復雜,根據編譯器不同而不同。
A.obj的導出符號表為
符號 地址
n 0x0000
_FunA 0x0004
未解決符號為空。
B.obj的導出符號表為
符號 地址
_FunB 0x0000
未解決符號表為
符號 地址
n 0x0001
這個表告訴鏈接器,在本編譯單元0x0001位置有一個地址,該地址不明,但符號是n。
在鏈接的時候,鏈接器在B.obj中發現了未解決符號,就會在所有的編譯單元中的導出符號表去查找與這個未解決符號相匹配的符號名,如果找到,就把這個符號的地址填到B.obj的未解決符號的地址處。如果沒有找到,就會報鏈接錯誤。在此例中,在A.obj中會找到符號n,就會把n的地址填到B.obj的0x0001處。
但是,如果是這樣的話,B.obj的函數FunB的內容就會變成
inc DWORD PTR[0x000](因為n在A.obj中的地址是0x0000)
如果每個編譯單元的地址都是從0x0000開始,那麼最終多個目標文件鏈接時就會導致地址重復。所以鏈接器在鏈接時就會對每個目標文件的地址進行調整。比如B.obj的0x0000被定位到可執行文件的0x00001000上,而A.obj的0x0000被定位到可執行文件的0x00002000上,這樣就可以保證地址不會重復。為實現這一點,目標文件還要提供一個表,叫地址重定向表(address redirect table)。
總結:
目標文件至少要提供三個表:未解決符號表,導出符號表和地址重定向表。
未解決符號表:列出了本單元裡有引用但是不在本單元定義的符號及其出現的地址。
導出符號表:提供了本編譯單元具有定義,並且可以提供給其他編譯單元使用的符號及其在本單元中的地址。
地址重定向表:提供了本編譯單元所有對自身地址的引用記錄。
鏈接器的工作順序:
當鏈接器進行鏈接的時候,首先決定各個目標文件在最終可執行文件裡的位置。然後訪問所有目標文件的地址重定義表,對其中記錄的地址進行重定向(加上一個偏移量,即該編譯單元在可執行文件上的起始地址)。然後遍歷所有目標文件的未解決符號表,並且在所有的導出符號表裡查找匹配的符號,並在未解決符號表中所記錄的位置上填寫實現地址。最後把所有的目標文件的內容寫在各自的位置上,再作一些其他工作,就生成一個可執行文件。
說明:實現鏈接的時候會更加復雜,一般實現的目標文件都會把數據,代碼分成好向個區,重定向按區進行,但原理都是一樣的。
幾個經典的鏈接錯誤
unresolved external link..
這個很顯然,是鏈接器發現一個未解決符號,但是在導出符號表裡沒有找到對應的項。
解決方案就是在某個編譯單元裡提供這個符號的定義。(注意,這個符號可以是一個變量,也可以是一個函數),也可以看看是不是有什麼該鏈接的文件沒有鏈接。
duplicated external simbols...
這個則是導出符號表裡出現了重復項,因此鏈接器無法確定應該使用哪一個。這可能是使用了重復的名稱,也可能有別的原因。
C/C++針對這些而提供的特性:
extern:告訴編譯器,這個符號在別的編譯單元裡定義,也就是要把這個符號放到未解決符號表裡去。(外部鏈接)
static:如果該關鍵字位於全局函數或者變量的聲明的前面,表明該編譯單元不導出這個函數/變量的符號。因此無法在別的編譯單元裡使用。(內部鏈接)。如果是static局部變量,則該變量的存儲方式和全局變量一樣,但是仍然不導出符號。
默認鏈接屬性:對於函數和變量,默認外部鏈接,對於const變量,默認內部鏈接。(可以通過添加extern和static改變鏈接屬性)
外部鏈接的利弊:外部鏈接的符號,可以在整個程序范圍內使用(因為導出了符號)。但是同時要求其他的編譯單元不能導出相同的符號(不然就是duplicated external simbols)
內部鏈接的利弊:內部鏈接的符號,不能在別的編譯單元內使用。但是不同的編譯單元可以擁有同樣名稱的內部鏈接符號。
一些問題的解答
為什麼頭文件裡一般只可以有聲明不能有定義?
頭文件可以被多個編譯單元包含,如果頭文件裡有定義,那麼每個包含這個頭文件的編譯單元就都會對同一個符號進行定義,如果該符號為外部鏈接,則會導致duplicated external simbols。因此如果頭文件裡要定義,必須保證定義的符號只能具有內部鏈接。
為什麼類的靜態變量不可以就地初始化?
所謂就地初始化就是類似於這樣:
class A
{
static char msg[] = "aha";
};
由於class的聲明通常是在頭文件裡,如果允許這樣做,其實就相當於在頭文件裡定義了一個非const變量。
為什麼公共使用的內聯函數要定義於頭文件裡?
因為編譯時編譯單元之間互相不知道,如果內聯函數被定義於.cpp文件中,編譯其他使用該函數的編譯單元時沒有辦法找到函數的定義,因此無法對函數進行展開。所以說如果內聯函數定義於.cpp文件裡,那麼就只有這個cpp文件可以使用這個函數。
頭文件裡的內聯函數被拒絕會怎樣?
記住,內聯只是給編譯器的一個建議,如果定義於頭文件裡的內聯函數被拒絕,那麼編譯器會自動在每個包含了該頭文件的編譯單元裡定義這個函數並且不導出符號。