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

第一節:基本原理[2] 

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

要明白的幾個概念:

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

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

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

 

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

     在一個文件中使用#include <otherFile>包含另外一個文件時,在編譯時會把被包含文件源代碼嵌入到當前文件中,而且這種嵌入是遞歸的,即還會把被包含文件所包含的文件也嵌入到當前文件中。

 

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

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

 

    總結一下:

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

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

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

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

 

    鏈接器的工作順序:

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

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

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

 

    下面再看一看C/C++中提供的一些特性:

    extern:這就是告訴編譯器,這個變量或函數在別的編譯單元裏定義了,也就是要把這個符號放到未解決符號表裏面去(外部鏈接)。

 

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

 

    默認鏈接屬性:對於函數和變量,默認鏈接是外部鏈接,對於const變量,默認內部鏈接。

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

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

 

    爲什麼頭文件裏一般只可以有聲明不能有定義:頭文件可以被多個編譯單元包含,如果頭文件裏面有定義的話,那麼每個包含這頭文件的編譯單元都會對同一個符號進行定義,如果該符號爲外部鏈接,則會導致duplicated external symbols鏈接錯誤。

 

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

 

 第二節:標準C和C++將編譯過程[1] 

1.字符映射(Character Mapping)

    文件中的物理源字符被映射到源字符集中,其中包括三字符運算符的替換、控制字符(行尾的回車換行)的替換。許多非美式鍵盤不支持基本源字符集中的一些字符,文件中可用三字符來代替這些基本源字符,以??爲前導。但如果所用鍵盤是美式鍵盤,有些編譯器可能不對三字符進行查找和替換,需要增加-trigraphs編譯參數。在C++程序中,任何不在基本源字符集中的字符都被它的通用字符名替換。

 

2.行合併(Line Splicing)

    以反斜槓\結束的行和它接下來的行合併。

 

3.標記化(Tokenization)

    每一條註釋被一個單獨的空字符所替換。C++雙字符運算符被識別爲標記(爲了開發可讀性更強的程序,C++爲非ASCII碼開發者定義了一套雙字符運算符集和新的保留字集)。源代碼被分析成預處理標記。

 

4.預處理(Preprocessing)

    調用預處理指令並擴展宏。使用#include指令包含的文件,重複步驟1到4。上述四個階段統稱爲預處理階段。

 

5.字符集映射(Character-set Mapping)

    源字符集成員、轉義序列被轉換成等價的執行字符集成員。例如:'\a'在ASCII環境下會被轉換成值爲一個字節,值爲7。

 

6.字符串連接(String Concatenation)

    相鄰的字符串被連接。例如:"""hahaha""huohuohuo"將成爲"hahahahuohuohuo"。

 

7.翻譯(Translation)

    進行語法和語義分析編譯,並翻譯成目標代碼。

 

8.處理模板

    處理模板實例。

 

9.連接(Linkage)

 

 第三節:C++中extern “c”[3] 

       爲了解決C++中函數可以重載這個特性,詳細原因如下:函數經過編譯系統的翻譯成彙編,函數名對應着彙編標號。因爲C編譯函數名與得到的彙編代號基本一樣,如:fun()=>_fun, main=>_main但是C++中函數名與得到的彙編代號有比較大的差別。如:由於函數重載,函數名一樣,但彙編代號絕對不能一樣。爲了區分,編譯器會把函數名和參數類型合在一起作爲彙編代號,這樣就解決了重載問題。具體如何把函數名和參數類型合在一起,要看編譯器的幫助說明了。這樣一來,如果C++調用C,如fun(),則調用名就不是C的翻譯結果_fun,而是帶有參數信息的一個名字,因此就不能調用到fun(),爲了解決這個問題,加上extern "C"表示該函數的調用規則是C的規則,則調用時就不使用C++規則的帶有參數信息的名字,而是_fun,從而達到調用C函數的目的。

 

第四節:查看g++編譯過程 

【參考文件】

1、http://blog.csdn.net/shiwenbin333/archive/2010/01/08/5157797.aspx

2、http://blog.sina.com.cn/s/blog_5f8817250100i3oz.html

3、http://blog.myspace.cn/e/400991786.htm

4、http://xredman.iteye.com/blog/700901

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章