【C++ 筆記】編譯流程(Compilation Process)

很感謝你點進來這篇文章。

你好,我並不是什麼 C++、程式語言的專家,所以本文若有些錯誤麻煩請各位鞭大力一點,我極需各位的指正及指導!!本系列文章的性質主要以詼諧的口吻,一派輕鬆的態度自學程式語言,如果你喜歡,麻煩留言說聲文章讚讚吧!

編譯流程(Compilation Process)

共分四階段:

  1. Preprocessing(前置處理)
  2. Compilation(編譯)
  3. Assembly(組譯)
  4. Linking(連結)

image

Image Source:C++ Preprocessor And Preprocessor Directives - GeeksforGeeks

所謂的 Source File 也就是 Source Code,就是 .cpp / .c 檔案。

1. Preprocessing

範例指令:g++ -E main.cpp -o main.i

Preprocessing 前置處理是 C / C++ 編譯的第一步,主要對 # 開頭的指令(稱為 marco 巨集)進行處理。

簡單來說就是把 #include 展開(把標頭檔貼進程式),處理 #define 巨集、條件編譯這些東西,最後會輸出 .i 檔,裡面含一個乾淨的 C / C++ Code 跟插入的標頭檔和展開的巨集們。

接下來會做以下步驟的處理:

  1. Macro Definition & Replacement
    • 處理所有 #define 指令,把程式中出現的巨集全用定義內容替換掉。
    • #define PI 3.14 則將所有 PI 換成 3.14
  2. File Inclusion
    • 處理 #include 指令,把指定的 header file 內容複製貼進 Source Code 在的位置。
    • #include <stdio.h> 會把 stdio.h 內容插入開頭中,如果他放在開頭的話。
  3. Conditional Compilation
    • 處理 #if, #ifdef, #ifndef 等條件判斷指令,符合條件的程式碼會保留,而其他的則全移除。
    • 如在當只有定義 DEBUG 時,#ifdef DEBUG ... #endif 中的程式才會被編譯。
  4. Macro Operators
    • 處理 ### 等巨集運算符,# 用於字串化,## 用於拼接識別字。
    • #define STR(n) #n 會生成 "n"
  5. Predefined Macros
    • 展開預定義巨集,如 __FILE__, __LINE__, __DATE__ 等,直接替換為實際內容(在預處理階段展開)。
  6. Undefinition
    • 處理 #undef 指令,把已定義的巨集移除。
    • #undef PI 會移除先前定義的 PI
  7. Others
    • 處理如 #error(強制產生錯誤), #pragma(控制編譯器行為), #line(設定行、檔名資訊等)。

2. Compilation

範例指令:g++ -S main.i -o main.s

Compilation 編譯為 C / C++ 編譯流程中將預處理後的 Source Code 轉換成組合語言(Assembly Language)的階段,主要在做是檢查語法、語意這些東西,然後會把高階語言翻譯成符合 CPU 架構的組合語言程式碼。

用簡單幾句話來說就是把 C/C++ Source Code 翻譯成組合語言(assembly code),順便檢查語法、型態等錯誤,最後會得到一個 .s 檔(組合語言)。

以下是 Compilation 的步驟:

  1. Syntax Analysis(語法分析):
    編譯器會根據語法規則檢查程式碼結構是否正確,如括號配對、關鍵字使用、語句結構等。

  2. Semantic Analysis(語意分析):
    如未宣告變數、類型不匹配、不可操作之值運算都會在這檢查。

  3. Intermediate Representation, IR(中介碼):
    編譯器會將高階語言翻譯成一種介於 Source Code 和 Assembly Language 之間的中間語言表示,以便後續優化與產生組合語言代碼。

  4. Optimization(優化):
    編譯器會試著去改寫中介碼的程式碼,提升執行效率、減少不必要的運算。

  5. Assembly Code Generation:
    最後編譯器會從中介碼轉換成組合語言,這時候生成的組合語言會被輸出成 .s 檔。

3. Assembly

範例指令:g++ -c main.cpp -o main.o

Assembly 組譯階段為 C/C++ 編譯流程中將組合語言(.s 檔)轉換成目標檔(object file,通常副檔名為 .o.obj)的階段,目標檔是機器語言的二進位指令,可供連結器(Linker)使用。

這階段就只是把組合語言轉成機器碼(二進位指令),然後生成目標檔 .o 而已,然後電腦可以去執行這些低階指令,但還不能單獨去執行。

以下是 Assembly 的步驟:

  1. 讀取組合語言程式碼:
    組譯器將從編譯階段輸出的組合語言程式碼(文字格式)讀入,內容是針對特定 CPU 架構設計的指令序列。

  2. 指令轉換機器碼:
    把組合語言指令一一轉成其對應的二進位機器指令,轉換後的程式碼 CPU 可以執行。

  3. (這步驟老實講我不知道叫啥):
    組譯器會對程式中用到的符號(如變數、函數名稱)進行初步紀錄,但因為還沒去做連結的動作,具體地址會在連結階段決定。

  4. 產生目標檔案(.o or .obj):
    輸出二進位格式的 .o or .obj 檔,內含機器碼及符號表等等,用於後續連結器將不同目標檔合併。

4. Linking

範例指令:g++ main.o math_utils.o -o main

Linking 為 C/C++ 編譯流程中的最終階段,主要將多個 object files 和連結外部函式庫(如 iostream)合併成一個可執行檔(.exe),並將程式中所有符號(函數、變數等)解析和對應到真實的記憶體地址。

比如說 add(a, b) 函數定義在另一個 math.cpp 檔案裡面,而 main.cpp 要存取 math.cpp 裡面的 add(a, b) 像下面這樣:

1
2
3
4
// math.cpp
int add(int a, int b) {
return a + b;
}
1
2
3
4
5
6
7
8
9
10
11
// main.cpp
#include <iostream>
using namespace std;

// 宣告 add 函數(定義在 math.cpp)
int add(int a, int b);

int main() {
cout << add(3, 4) << endl;
return 0;
}

若要呼叫這個函數,首先要知道他到底在記憶體的哪裡,才能去呼叫這個函數。

(假設編譯完成)Linker 會將 main.o 裡呼叫的 add() 符號,和 math.o 裡定義的 add() 符號連起來,然後把兩個 .o 檔案合併成最終的執行檔。

總之 Linker 在做的事情就是把不同的檔案組合再一起,形成一個可執行檔,這個 .exe 檔案就是多個檔案的整體。

總結

前置處理(Preprocessing)

處理所有以 # 開頭的指令,如 #include 展開標頭檔、#define 巨集替換、條件編譯等。

會輸出純淨的 C/C++ 程式碼,存於 .i 檔。

編譯(Compilation)

檢查語法與語意正確性,將程式轉換為中介碼(IR),再優化並產生組合語言程式碼。

產出 .s 檔(Assembly code)。

組譯(Assembly)

將組合語言轉換為二進位機器碼,並生成目標檔(Object file)。

產出 .o.obj 檔案,包含機器碼與符號表,但還不能單獨執行。

連結(Linking)

將多個目標檔(.o)以及相關函式庫整合,解析所有符號(如函數、變數位置)。

最終輸出可執行檔(例如 .exe)。

結語

C++ 編譯流程大致上就是這樣:

.cpp.i.s.o.exe

參考資料

C 概念 & 編譯 - HackMD

[Day 3] 編譯流程 | iT 邦幫忙::一起幫忙解決難題,拯救 IT 人的一天

淺談 c++ 編譯到鏈結的過程 | by Alastor Wu | Medium

C++ Compilation process - GeeksforGeeks