1. 程式人生 > >C++之編譯器與鏈接器工作原理

C++之編譯器與鏈接器工作原理

www 重復項 export 容易 區域 修改 direct 自身 默認

http://www.cnblogs.com/kunhu/p/3629636.html

原文來自:http://blog.sina.com.cn/s/blog_5f8817250100i3oz.html

這裏並沒不是討論大學課程中所學的《編譯原理》,只是寫一些我自己對C++編譯器及鏈接器的工作原理的理解和看法吧,以我的水平,還達不到講解編譯原理(這個很復雜,大學時幾乎沒學明白)。

要明白的幾個概念:

1、編譯:編譯器對源文件進行編譯,就是把源文件中的文本形式存在的源代碼翻譯成機器語言形式的目標文件的過程,在這個過程中,編譯器會進行一系列的語法檢查。如果編譯通過,就會把對應的CPP轉換成OBJ文件。

2、編譯單元:根據C++標準,每一個CPP文件就是一個編譯單元。每個編譯單元之間是相互獨立並且互相不可知。

3、目標文件:由編譯所生成的文件,以機器碼的形式包含了編譯單元裏所有的代碼和數據,還有一些期他信息,如未解決符號表,導出符號表和地址重定向表等。目標文件是以二進制的形式存在的。

根據C++標準,一個編譯單元(Translation Unit)是指一個.cpp文件以及這所include的所有.h文件,.h文件裏面的代碼將會被擴展到包含它的.cpp文件裏,然後編譯器編譯該.cpp文件為一個.obj文件,後者擁有PE(Portable Executable,即Windows可執行文件)文件格式,並且本身包含的就是二進制代碼,但是不一定能執行,因為並不能保證其中一定有main函數。當編譯器將一個工程裏的所有.cpp文件以分離的方式編譯完畢後,再由鏈接器進行鏈接成為一個.exe或.dll文件。

下面讓我們來分析一下編譯器的工作過程:

我們跳過語法分析,直接來到目標文件的生成,假設我們有一個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

這時++n已經被翻譯成inc DWORD PTR[0x0000],也就是說把本單元0x0000位置的一個DWORD(4字節)加1。

有另外一個B.cpp文件,定義如下:

extern int n;

void FunB()

{

++n;

}

它對應的B.obj的二進制應該是:

偏移量 內容 長度

0x0000 FunB ??

這裏為什麽沒有n的空間呢,因為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,函數的符號會更加復雜,假設FunA的符號就是_FunA(根據編譯器不同而不同)。

所以,

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上,那麽實現上對鏈接器來說,A.obj的導出符號地地址都會加上0x00002000,B.obj所有的符號地址也會加上0x00001000。這樣就可以保證地址不會重復。

既然n的地址會加上0x00002000,那麽FunA中的inc DWORD PTR[0x0000]就是錯誤的,所以目標文件還要提供一個表,叫地址重定向表,address redirect table。

總結一下:

目標文件至少要提供三個表:未解決符號表,導出符號表和地址重定向表。

未解決符號表:列出了本單元裏有引用但是不在本單元定義的符號及其出現的地址。

導出符號表:提供了本編譯單元具有定義,並且可以提供給其他編譯單元使用的符號及其在本單元中的地址。

地址重定向表:提供了本編譯單元所有對自身地址的引用記錄。

鏈接器的工作順序:

當鏈接器進行鏈接的時候,首先決定各個目標文件在最終可執行文件裏的位置。然後訪問所有目標文件的地址重定義表,對其中記錄的地址進行重定向(加上一個偏移量,即該編譯單元在可執行文件上的起始地址)。然後遍歷所有目標文件的未解決符號表,並且在所有的導出符號表裏查找匹配的符號,並在未解決符號表中所記錄的位置上填寫實現地址。最後把所有的目標文件的內容寫在各自的位置上,再作一些另的工作,就生成一個可執行文件。

說明:實現鏈接的時候會更加復雜,一般實現的目標文件都會把數據,代碼分成好向個區,重定向按區進行,但原理都是一樣的。

明白了編譯器與鏈接器的工作原理後,對於一些鏈接錯誤就容易解決了。

現在我們可以來看看幾個經典的鏈接錯誤了:
unresolved external link..
這個很顯然,是鏈接器發現一個未解決符號,但是在導出符號表裏沒有找到對應的項。
解決方案麽,當然就是在某個編譯單元裏提供這個符號的定義就行了。(註意,這個符號可以是一個變量,也可以是一個函數),也可以看看是不是有什麽該鏈接的文件沒有鏈接
duplicated external simbols...
這個則是導出符號表裏出現了重復項,因此鏈接器無法確定應該使用哪一個。這可能是使用了重復的名稱,也可能有別的原因。


我們再來看看C/C++語言裏針對這一些而提供的特性:
extern:這是告訴編譯器,這個符號在別的編譯單元裏定義,也就是要把這個符號放到未解決符號表裏去。(外部鏈接)

static:如果該關鍵字位於全局函數或者變量的聲明的前面,表明該編譯單元不導出這個函數/變量的符號。因此無法在別的編譯單元裏使用。(內部鏈接)。如果是static局部變量,則該變量的存儲方式和全局變量一樣,但是仍然不導出符號。

默認鏈接屬性:對於函數和變量,模認外部鏈接,對於const變量,默認內部鏈接。(可以通過添加extern和static改變鏈接屬性)

外部鏈接的利弊:外部鏈接的符號,可以在整個程序範圍內使用(因為導出了符號)。但是同時要求其他的編譯單元不能導出相同的符號(不然就是duplicated external simbols)

內部鏈接的利弊:內部鏈接的符號,不能在別的編譯單元內使用。但是不同的編譯單元可以擁有同樣名稱的內部鏈接符號。

為什麽頭文件裏一般只可以有聲明不能有定義:頭文件可以被多個編譯單元包含,如果頭文件裏有定義,那麽每個包含這個頭文件的編譯單元就都會對同一個符號進行定義,如果該符號為外部鏈接,則會導致duplicated external simbols。因此如果頭文件裏要定義,必須保證定義的符號只能具有內部鏈接。

為什麽常量默認為內部鏈接,而變量不是:
這就是為了能夠在頭文件裏如const int n = 0這樣的定義常量。由於常量是只讀的,因此即使每個編譯單元都擁有一份定義也沒有關系。如果一個定義於頭文件裏的變量擁有內部鏈接,那麽如果出現多個編譯單元都定義該變量,則其中一個編譯單元對該變量進行修改,不會影響其他單元的同一變量,會產生意想不到的後果。

為什麽函數默認是外部鏈接:
雖然函數是只讀的,但是和變量不同,函數在代碼編寫的時候非常容易變化,如果函數默認具有內部鏈接,則人們會傾向於把函數定義在頭文件裏,那麽一旦函數被修改,所有包含了該頭文件的編譯單元都要被重新編譯。另外,函數裏定義的靜態局部變量也將被定義在頭文件裏。

為什麽類的靜態變量不可以就地初始化:所謂就地初始化就是類似於這樣的情況:
class A
{
static char msg[] = "aha";
};
不允許這樣做得原因是,由於class的聲明通常是在頭文件裏,如果允許這樣做,其實就相當於在頭文件裏定義了一個非const變量。

在C++裏,頭文件定義一個const對象會怎麽樣:
一般不會怎麽樣,這個和C裏的在頭文件裏定義const int一樣,每一個包含了這個頭文件的編譯單元都會定義這個對象。但由於該對象是const的,所以沒什麽影響。但是:有2種情況可能破壞這個局面:
1。如果涉及到對這個const對象取地址並且依賴於這個地址的唯一性,那麽在不同的編譯單元裏,取到的地址可以不同。(但一般很少這麽做)
2。如果這個對象具有mutable的變量,某個編譯單元對其進行修改,則同樣不會影響到別的編譯單元。

為什麽類的靜態常量也不可以就地初始化:
因為這相當於在頭文件裏定義了const對象。作為例外,int/char等可以進行就地初始化,是因為這些變量可以直接被優化為立即數,就和宏一樣。

內聯函數:
C++裏的內聯函數由於類似於一個宏,因此不存在鏈接屬性問題。

為什麽公共使用的內聯函數要定義於頭文件裏:
因為編譯時編譯單元之間互相不知道,如果內聯函數被定義於.cpp文件中,編譯其他使用該函數的編譯單元的時候沒有辦法找到函數的定義,因此無法對函數進行展開。所以說如果內聯函數定義於.cpp文件裏,那麽就只有這個cpp文件可以是用這個函數。

頭文件裏內聯函數被拒絕會怎樣:
如果定義於頭文件裏的內聯函數被拒絕,那麽編譯器會自動在每個包含了該頭文件的編譯單元裏定義這個函數並且不導出符號。

如果被拒絕的內聯函數裏定義了靜態局部變量,這個變量會被定義於何處:
早期的編譯器會在每個編譯單元裏定義一個,並因此產生錯誤的結果,較新的編譯器會解決這個問題,手段未知。

為什麽export關鍵字沒人實現:
export要求編譯器跨編譯單元查找函數定義,使得編譯器實現非常困難。

C++之編譯器與鏈接器工作原理