C++編譯原理


C++----》cpp----》預編譯(加頭文件)-----》編譯----》obj---》鏈接器-----》可執行文件

 

幾個概念:

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

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

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

我們知道,在預編譯的時候,.h頭文件會被復制、擴展到包含它的.cpp文件里,然后編譯器編譯該.cpp文件為一個.obj文件,該.cpp文件作為一個編譯單元獨立編譯。當編譯器將一個工程里的所有.cpp文件以分離的方式編譯完畢后,再由鏈接器進行鏈接成為一個可執行文件。

編譯器的工作過程:

這里我們只關注下目標文件的生成。

假設有一個A.cpp文件,如下定義:

    int n = 1; void FunA() { ++n; }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
它編譯出來的目標文件A.obj就會有一個區域(或者說是段),包含以上的數據和函數,其中就有n、FunA,以文件偏移量形式給出可能就是下面這種情況:

偏移量    內容    長度

0x0000    n       4

0x0004    FunA    ??

說明:實際目標文件的布局可能不是這樣,這里只是方便學習才這樣表示,??表示長度未知,目標文件的各個數據可能不是連續的,也不一定是從0x0000開始。

FunA函數的內容可能如下:

    0x0004 inc DWORD PTR[0x0000] 0x00?? ret
  • 1
  • 2

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

    extern int n; void FunB() { ++n; }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

它對應的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文件可以使用這個函數。

頭文件里的內聯函數被拒絕會怎樣?

記住,內聯只是給編譯器的一個建議,如果定義於頭文件里的內聯函數被拒絕,那么編譯器會自動在每個包含了該頭文件的編譯單元里定義這個函數並且不導出符號。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM