天天看點

C/C++編譯器與連結器工作原理

這裡并沒不是讨論大學課程中所學的《編譯原理》,隻是寫一些我自己對C++編譯器及連結器的工作原理的了解和看法吧,以我的水準,還達不到講解編譯原理(這個很複雜,大學時幾乎沒學明白)。

要明白的幾個概念:

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

   2、編譯單元:根據C++标準,每一個CPP檔案就是一個編譯單元。每個編譯單元之間是互相獨立并且互相不可知。

   3、目标檔案:由編譯所生成的檔案,以機器碼的形式包含了編譯單元裡所有的代碼和資料,還有一些期他資訊,如未解決符号表,導出符号表和位址重定向表等。目标檔案是以二進制的形式存在的。

   根據C++标準,一個編譯單元(TranslationUnit)是指一個.cpp檔案以及這所include的所有.h檔案,.h檔案裡面的代碼将會被擴充到包含它的.cpp檔案裡,然後編譯器編譯該.cpp檔案為一個.obj檔案,後者擁有PE(PortableExecutable,即Windows可執行檔案)檔案格式,并且本身包含的就是二進制代碼,但是不一定能執行,因為并不能保證其中一定有main函數。當編譯器将一個工程裡的所有.cpp檔案以分離的方式編譯完畢後,再由連結器進行連結成為一個.exe或.dll檔案。

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

我們跳過文法分析,直接來到目标檔案的生成,假設我們有一個A.cpp檔案,如下定義:

    int n =1;

    voidFunA()

    {

       ++n;

    }

   它編譯出來的目标檔案A.obj就會有一個區域(或者說是段),包含以上的資料和函數,其中就有n、FunA,以檔案偏移量形式給出可能就是下面這種情況:

   偏移量   内容    長度

   0x0000    n      4

   0x0004   FunA    ??

   注意:這隻是說明,與實際目标檔案的布局可能不一樣,??表示長度未知,目标檔案的各個資料可能不是連續的,也不一定是從0x0000開始。

   FunA函數的内容可能如下:

    0x0004inc DWORD PTR[0x0000]

    0x00??ret

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

   有另外一個B.cpp檔案,定義如下:

    externint n;

    voidFunB()

    {

       ++n;

    }

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

   偏移量   内容    長度

   0x0000   FunB    ??

   這裡為什麼沒有n的空間呢,因為n被聲明為extern,這個extern關鍵字就是告訴編譯器n已經在别的編譯單元裡定義了,在這個單元裡就不要定義了。由于編譯單元之間是互不相關的,是以編譯器就不知道n究竟在哪裡,是以在函數FunB就沒有辦法生成n的位址,那麼函數FunB中就是這樣的:

    0x0000inc DWORD PTR[????]

    0x00??ret

   那怎麼辦呢?這個工作就隻能由連結器來完成了。

   為了能讓連結器知道哪些地方的位址沒有填好(也就是還????),那麼目标檔案中就要有一個表來告訴連結器,這個表就是“未解決符号表”,也就是unresolvedsymbol table。同樣,提供n的目标檔案也要提供一個“導出符号表”也就是exprotsymbol 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 DWORDPTR[0x000](因為n在A.obj中的位址是0x0000),由于每個編譯單元的位址都是從0x0000開始,那麼最終多個目标檔案連結時就會導緻位址重複。是以連結器在連結時就會對每個目标檔案的位址進行調整。在這個例子中,假如B.obj的0x0000被定位到可執行檔案的0x00001000上,而A.obj的0x0000被定位到可執行檔案的0x00002000上,那麼實作上對連結器來說,A.obj的導出符号地位址都會加上0x00002000,B.obj所有的符号位址也會加上0x00001000。這樣就可以保證位址不會重複。

   既然n的位址會加上0x00002000,那麼FunA中的inc DWORDPTR[0x0000]就是錯誤的,是以目标檔案還要提供一個表,叫位址重定向表,address redirect table。

   總結一下:

   目标檔案至少要提供三個表:未解決符号表,導出符号表和位址重定向表。

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

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

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

   連結器的工作順序:

   當連結器進行連結的時候,首先決定各個目标檔案在最終可執行檔案裡的位置。然後通路所有目标檔案的位址重定義表,對其中記錄的位址進行重定向(加上一個偏移量,即該編譯單元在可執行檔案上的起始位址)。然後周遊所有目标檔案的未解決符号表,并且在所有的導出符号表裡查找比對的符号,并在未解決符号表中所記錄的位置上填寫實作位址。最後把所有的目标檔案的内容寫在各自的位置上,再作一些另的工作,就生成一個可執行檔案。

   說明:實作連結的時候會更加複雜,一般實作的目标檔案都會把資料,代碼分成好向個區,重定向按區進行,但原理都是一樣的。

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

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

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

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

   預設連結屬性:對于函數和變量,預設連結是外部連結,對于const變量,預設内部連結。

外部連結的利弊:外部連結的符号在整個程式範圍内都是可以使用的,這就要求其他編譯單元不能導出相同的符号(不然就會報duplicatedexternal symbols)。

内部連結的利弊:内部連結的符号不能在别的編譯單元中使用。但不同的編譯單元可以擁有同樣的名稱的符号。

   為什麼頭檔案裡一般隻可以有聲明不能有定義:頭檔案可以被多個編譯單元包含,如果頭檔案裡面有定義的話,那麼每個包含這頭檔案的編譯單元都會對同一個符号進行定義,如果該符号為外部連結,則會導緻duplicatedexternal symbols連結錯誤。

   為什麼公共使用的内聯函數要定義于頭檔案裡:因為編譯時編譯單元之間互不知道,如果内聯被定義于.cpp檔案中,編譯其他使用該函數的編譯單元的時候沒有辦法找到函數的定義,因些無法對函數進行展開。是以如果内聯函數定義于.cpp裡,那麼就隻有這個.cpp檔案能使用它。

轉載處連結:

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