我們在學習和開發C++程序中,理解編譯和鏈接的原理至關重要。下面將學習一下C++程序是如何從源代碼轉換爲可執行文件的過程,並結合示例代碼進行說明。也是爲了解開自己在剛學習C++的時候,編譯時間長的疑惑。
爲了不讓自己的學習之路這麼枯燥,我按照一個正常的開發流程梳理一下。這樣不但學習瞭如何寫代碼,更明白了自己的代碼爲什麼是這樣的運行的。
1,程序員編寫C++源代碼
首先,程序員會編寫C++源代碼文件,這些文件通常具有擴展名爲 .cpp 或者 .cc。
以下是一個簡單的C++代碼示例:
#include <iostream> int main() { std::cout << "Hello, World!" << std::endl; return 0; }
要編譯這個代碼,需要按照下面步驟:
step1:將上面代碼保存到一個文件中,比如 `hello.cpp`。
step2:打開命令行終端。
step3:在終端中,進入到保存代碼的文件所在的目錄。
step4:使用C++編譯器(如g++)編譯代碼。在終端中輸入下面命令:
g++ hello.cpp -o hello
這將把 `hello.cpp` 編譯成一個可執行文件 `hello` '-o' 選項指定輸出文件的名稱。
step5:如果編譯沒有錯誤,你可以允許生成一個可執行文件。在終端輸入以下命令:
./hello
這將執行程序,並輸出 `Hello World`。
注意 gcc 和 g++ 都屬於GCC的調用指令,gcc會根據文件後綴自動推斷語言類型,而g++不管後綴名是什麼都按照C++ 語言去編譯。通常情況下我們使用gcc編譯C程序,使用g++編譯C++程序。
實際上當程序員寫完代碼後,編譯過程通常包含以下幾個步驟:預處理,編譯,彙編和鏈接。
下面先簡單的解釋一下:
預處理:在這個階段,預處理器將對代碼進行處理,主要包括處理以“#”開頭的預處理指令,如#include,#define 等。預處理器會將頭文件包含進來,展開宏定義等,生成一個經過預處理的源代碼文件。
編譯:在這個階段,編譯器將預處理後的源代碼翻譯成中間代碼(通常是彙編代碼),這個中間代碼仍然是針對特定的硬件平臺的抽象代碼。
彙編:在這個階段,彙編器將編譯器生成的中間代碼翻譯成目標機器的機器碼,即彙編語言。彙編語言是特定於計算機體系結構的低級語言。
鏈接:在這個階段,鏈接器將編譯後的目標文件與所需的庫文件鏈接在一起,生成可執行文件。這些庫文件可能包含標準庫,第三方庫。
下面讓我們結合代碼來說明一下具體的步驟。
2 預處理
預處理主要包含如下幾步:展開頭文件,宏替換,去掉註釋,條件編譯。但是這些步驟我們是看不見的。
我們可以看一下展開的編譯命令:
g++ -E hello.cpp -o hello.i
這將把預處理後的代碼保存到 `hello.i` 文件中。預處理的代碼會包含 <iostream>中的內容,並且會展開 std::cout, std::endl等。
2.1 展開頭文件
函數聲明與定義:C/C++ 中規定使用函數之前,必須先聲明函數原型,編譯器根據函數聲明確定函數的傳入參數和返回值。函數可以有多個聲明,但是隻能有一個定義,C/C++通常將函數聲明放在頭文件中,函數定義放在源文件中。
預編譯階段,會將 #include 包含的頭文件展開到所包含的文件中,這樣當前文件就擁有了所引用頭文件內部函數的聲明,可以正常使用頭文件聲明的函數了。
(注意:頭文件中爲什麼不能有函數和變量定義?因爲頭文件會被多個其他源文件包含,這樣會導致函數和變量的重定義錯誤。)
頭文件重複包含:假設文件 A 包含 B 和 C,而 B 也包含了 C,則頭文件展開後,A 中會有兩份 C 的內容,這就是重複包含。如果 C 中包含了變量和函數的定義,則會產生重定義錯誤。
通常在頭文件中使用 #ifndef 來解決頭文件的重複包含問題。
2.2 宏替換
宏的作用:可以定義常量,實現一處修改,全部生效。減少函數調用開銷:帶參數的宏在預處理階段就進行了宏展開,提供執行效率。
2.3 條件編譯
條件編譯的指令包括:#ifdef、#ifndef、else、elif 和 #endif 等。
條件編譯類似於程序中 if else 語句,會選擇性執行對應的代碼。不過前者是在預編譯階段執行的條件選擇,後者是在程序運行時執行的條件選擇。
假設代碼中有如下的預編譯指令:
#include <iostream> #define PI 3.14159 int main() { std::cout << "Hello, World!" << std::endl; return 0; }
執行預處理後的代碼將是:
// Content of iostream included here... int main() { std::cout << "Hello, World!" << std::endl; return 0; }
3 編譯
編譯的命令是:
g++ -S hello.i -o hello.s
這將把編譯後的彙編代碼保存到 hellp.s 文件中。這些彙編代碼是針對特定的硬件平臺的抽象代碼。
執行的編譯後的代碼將是彙編代碼:
.section .text .globl main main: ; Code to print "Hello, World!" ; Code to return 0
編譯是將源代碼文件(.cpp或.c文件)轉換爲目標文件(.o或.obj文件)的過程,編譯器(如g++、clang++、MSVC)將C++源代碼文件翻譯成中間代碼(中間代碼通常稱爲彙編語言),這些中間代碼存儲在目標文件中。源文件中的每個函數通常會生成一個目標文件。編譯過程中會檢查代碼中的語法錯誤和類型錯誤,生成目標文件,並對代碼進行優化。生成的目標文件包含了函數的二進制代碼和一些元數據,但它們通常還沒有被鏈接到最終可執行文件中。
編譯階段可以分爲兩步:1,檢查函數和變量是否存在聲明; 2, 檢查語句是否符合C++語法。
(注意:內聯函數如果定義在源文件中,由於在編譯階段只引用聲明,這樣內聯函數就不能展開,也就達不到內聯的效果,所以內聯函數要定義在頭文件中。)
3.1 編譯優化
編譯器提供了 -O 優化選項,對應不同的優化方案。
-O0:關閉所有優化選項,等價於編譯時不帶 -O 選項。
-O1:提供基礎級別的優化,主要內容包括:
1. 延遲棧的彈出時間,在多個函數被調用後,一次性彈出
2. 合併常量,將多個編譯單元中相同的常量合併
3. 分支優化,對判斷條件有關聯的分支重定向
4. 循環優化,將常量表達式從循環中移除,簡化判斷循環的條件
5. 指令重排,根據指令週期時間重新安排指令。
-O2:比 O1 高級的優化,將執行幾乎所有不包含時間和空間折中的優化,與 O1 比較而言,O2 優化增加了編譯時間,但是提高了代碼的執行效率,推薦編譯線上代碼時使用。
除了打開所有的 O1 選項,並打開以下選項:
1. 在編譯函數的時候重新安排基本的塊,目的在於減少分支的個數
2. 編譯器嘗試重新排列指令,用以消除由於等待未準備好的數據而產生的延遲
3. 優化相關的以及末尾遞歸的調用
4. 使函數對準內存中特定邊界的開始位置,確保全部函數代碼位於單一內存頁面內。
-O3:最高級的代碼優化,除了執行 -O2 的選項,一般都是採取很多向量化算法,提高代碼的並行執行程度,利用現代CPU中的流水線,Cache等。
1. 構建用於保存變量的僞寄存器網絡
2. 普通函數的內聯
3. 針對循環的更多優化
-Og:當編譯選項包含 -g 時,該選項會選取不影響調試邏輯的優化選項進行優化。
3.2 編譯優化帶來的問題
調試問題:任何優化都將帶來代碼結構的改變,例如:對分支的合併和消除,對公用子表達式的消除,對循環內load/store操作的替換和更改等,都將會使目標代碼的執行順序變得面目全非,導致調試信息嚴重不足。
內存操作順序改變所帶來的問題:在 O2 優化後,會影響內存操作的執行順序。例如:-fschedule-insns 允許數據處理時先完成其他的指令;-fforce-mem有可能導致內存與寄存器之間的數據產生類似髒數據的不一致等。對於某些依賴內存操作順序而進行的邏輯,需要做嚴格的處理後才能進行優化。例如,採用volatile 關鍵字限制變量的操作方式,或者利用 barrier 迫使 cpu 嚴格按照指令序執行的。
(注意:在調試時要關閉所有優化選項,而編譯線上運行版本時,則打開 -O2 選項,儘量不要採用 -O3 選項優化,因爲 -O3 比較激進,可能會出現改變程序行爲的情況。)
4 彙編
彙編是將編譯階段得到的 .s 彙編文件翻譯成 .o 二進制機器指令文件的過程。
命令如下:
g++ -c hello.s -o hello.o
這將把彙編代碼轉換爲目標機器的機器碼,並保存在 hello.o 文件中。
5 鏈接
鏈接命令如下:
g++ hello.o -o hello
這將把 hello.o 文件與所需的庫文件鏈接在一起,生成可執行文件hello。
編譯和彙編後得到的是 .o 的二進制文件,但是不能獨立允許。鏈接是編譯的最後一步,會將之前編譯好的 .o文件,系統庫的 .o 文件和庫文件彼此相連接,把某個目標文件中引用的符號同另一個文件中的定義鏈接起來,將所有編譯好的單元組成一個可執行文件。
鏈接是將多個目標文件和庫文件組合成一個可執行文件的過程。在C++中,通常使用鏈接器來完成這個任務,鏈接器(如ld、linker)將解析目標文件中的符號和庫文件合併,並解析他們之間的符號引用。然後將他們解析到可執行程序中。在鏈接過程中,包含將函數,全局變量,庫函數,靜態變量等符號的引用與其定義進行匹配,以確保代碼可以正確執行。鏈接器生成可執行文件,其中包括二進制代碼和各種元數據,如程序入口點和動態鏈接庫引用。
5.1 符號解析
在鏈接中,將函數和變量統稱爲符號,函數名和變量名統稱爲符號名,每個目標文件要提供兩個符號表給鏈接器使用。
符號解析時,鏈接器根據目標文件提供的未解決符號表,去所有的編譯單元的導出符號表中去查找與這個未解決符號相匹配的符號名,如果找到,就把這個符號的地址填到未解決符號的地址處,如果沒有找到,就會報鏈接錯誤。
5.2 重定位
多個編譯單元的符號地址可能是相同的,比如都從 (0x0000) 開始,那麼最終多個目標文件鏈接時就會導致地址重複。所以鏈接器在鏈接時就會對每個目標文件的地址進行調整,這個調整的過程就是重定位。
5.3 鏈接過程
鏈接就是進行符號解析和重定位的過程。
鏈接器首先決定各個目標文件在最終可執行文件裏的位置。然後訪問所有目標文件的地址重定義表,對其中記錄的地址進行重定向(加上一個偏移量,即該編譯單元在可執行文件上的起始地址)。遍歷所有目標文件的未解決符號表,並且在所有的導出符號表裏查找匹配的符號,並在未解決符號表中所記錄的位置上填寫實現地址。
最後把所有的目標文件的內容寫在各自的位置上,就生成一個可執行文件。
5.4 靜態鏈接
靜態鏈接在編譯階段就將庫文件的所有代碼加到可執行文件中,因此生成的程序體積更大,其後綴名一般爲 .a,靜態庫的優點:
- 代碼裝載速度比動態庫快,執行效率也略高。
- 不依賴於外部庫安裝環境,部署方便。
5.5 動態鏈接
動態鏈接在編譯鏈接時並不會把庫文件的代碼加到可執行文件中,而是在運行時加載所需的動態庫,後綴名一般爲 .so,動態庫的優點:
- 生成的可執行程序更小。
- 共享庫是通過mmap映射的方式實現文件共享,多進程運行時更加節省內存。
- 庫文件修改時,可執行文件不需要重新編譯,只需要重啓即可。
5.6 庫文件符號重名
可執行文件如果依賴的多個庫文件中,如果有符號重名時,靜態庫和動態庫分別是
- 靜態庫間符號重名:鏈接失敗,編譯報錯。
- 動態庫間符號重名:根據鏈接順序,先被鏈接的動態庫符號佔用,後被鏈接的忽略。
6 生成可執行文件
在鏈接完成後,你將獲得一個可執行文件,你可以運用 `./hello` 來執行你的C++程序。
總結一下,編譯是將源代碼轉換爲目標文件,而鏈接是將多個目標文件和庫文件組合成最終的可執行文件。在鏈接階段,解析符號引用,將函數和變量鏈接在一起,以創建一個可執行文件,以便執行。