GNU C 編譯器內部/GNU C 編譯器架構
本節基於 Red Hat 雜誌文章 [1]。
GNU 編譯器集合 (GCC) 包含多個用於不同程式語言的編譯器。主要的 GCC 可執行檔案 gcc 處理用 C、C++、Objective-C、Objective-C++、Java、Fortran 或 Ada 編寫的原始檔,併為每個原始檔生成彙編檔案。它是一個驅動程式,根據原始檔的語言呼叫相應的編譯程式。對於 C 原始檔,它們是預處理器和編譯器 cc1、彙編器 as 和連結器 collect2。第一個和第三個程式附帶 GCC 發行版,彙編器是 GNU binutils 包的一部分。本書描述了預處理器和編譯器 cc1 的內部工作原理。
每個編譯器都包含以下三個元件:前端、中間端和後端。GCC 一次編譯一個檔案。原始檔依次透過所有三個元件。圖 1 說明了與每個元件相關的元件和原始檔表示。
前端的目的是讀取原始檔,解析它,並將其轉換為標準的抽象語法樹 (AST) 表示。每種程式語言都有一個前端。由於語言的差異,生成的 AST 的格式對於每種語言略有不同。AST 生成後的下一步是統一步驟,其中 AST 樹被轉換為稱為泛型的統一形式。之後,編譯器的中間端部分開始接管。首先,樹被轉換為另一種稱為GIMPLE 的表示。在此形式中,每個表示式最多包含三個運算元,所有控制流結構都表示為條件語句和 goto 運算子的組合,函式呼叫的引數只能是變數,等等。圖 2 說明了泛型形式的樹和 GIMPLE 形式的樹之間的差異。GIMPLE 是最佳化原始碼的方便表示。
在 GIMPLE 之後,原始碼被轉換為靜態單賦值 (SSA) 表示。這種形式的核心思想是,每個變數只被賦值一次,但可以在表示式的右側被多次使用。每次在 GIMPLE 形式的樹中重新賦值同一個變數時,編譯器都會建立一個該變數的新版本,並將新值儲存到其中。當在條件表示式的兩個分支中都將同一個變數賦值時,需要將變數的兩個可能值合併成一個變數。此操作在 SSA 形式中表示為 PHI 函式。
SSA 形式也用於最佳化。GCC 對 SSA 樹執行 20 多種不同的最佳化。在 SSA 最佳化傳遞之後,樹被轉換回 GIMPLE 形式,然後用於生成樹的暫存器傳輸語言 (RTL) 形式。RTL 是基於硬體的表示,它對應於具有無限多個暫存器的抽象目標架構。RTL 最佳化傳遞最佳化 RTL 形式的樹。最後,GCC 後端使用 RTL 表示為目標架構生成彙編程式碼。後端的例子包括 x86 後端、mips 後端等等。
在接下來的部分,我們將描述 C 前端和 x86 後端的內部工作原理。編譯器從其初始化和命令列選項處理開始。之後,C 前端預處理原始檔,解析它並執行一些最佳化。然後,後端為目標平臺生成彙編程式碼並將其儲存到檔案。
| 要點: | GCC 是一個編譯器集合,它包含每個程式語言的前端、一箇中間端以及每個架構的後端。每個原始檔依次透過的 主要表示形式是前端的 AST、中間端的 RTL 以及後端的彙編表示。GCC 一次編譯一個檔案。 |
C 前端包括 C/C++ 預處理器和 C 編譯器。程式 cc1 包含預處理器和 C 編譯器。它編譯 C 原始檔並生成彙編 (.S) 檔案。
編譯器前端和後端透過稱為語言鉤子的回撥函式相互互動。所有鉤子都被包含在檔案 langhooks.h 中定義的全域性變數 struct lang_hooks lang_hooks 中。有以下型別的鉤子:用於樹內聯的鉤子、用於呼叫圖的鉤子、用於函式的鉤子、用於樹轉儲的鉤子、用於型別的鉤子、用於宣告的鉤子以及特定於語言的鉤子。鉤子的預設值在檔案 langhooks-def.h 中定義。
GCC 初始化包括命令列選項解析、初始化後端、建立全域性範圍以及初始化內建資料型別和函式。
每個宣告都與一個範圍相關聯。例如,區域性變數與其函式的範圍相關聯。全域性宣告與全域性範圍相關聯。
函式 toplev_main() 是處理命令列選項、初始化編譯器、編譯檔案以及釋放分配資源的函式。函式 decode_options() 處理命令列選項並在編譯器中設定相應的變數。
在命令列選項解析之後,呼叫函式 do_compile()。它透過呼叫函式 backend_init() 執行後端初始化。之後,函式 lang_dependent_init() 執行特定於語言的初始化,包括前端和後端的初始化。C 初始化函式 c_objc_common_init() 建立內建資料型別,初始化全域性範圍並執行其他初始化任務。函式 c_common_nodes_and_builtins() 建立檔案 builtin-types.def 中描述的預定義型別。
標準 C 型別在初始化時建立。下表列出了幾種型別
| 變數名稱 | C 型別 |
| char_type_node | char |
| integer_type_node | int |
| unsigned_type_node | unsigned int |
| void_type_node | void |
| ptr_type_node | void* |
GCC 內建函式是在編譯時求值的函式。例如,如果 strcpy() 函式的大小引數是一個常量,那麼 GCC 會用所需的賦值數量替換函式呼叫。編譯器用內建函式替換標準庫呼叫,並在構建函式的 AST 後對其進行求值。在 strcpy() 的情況下,編譯器會檢查大小引數,如果引數是常量,則使用 strcpy() 的最佳化內建版本。內建 builtin_constant_p() 允許確定其引數的值是否在編譯時已知。GCC 內建函式超出了 GCC 的使用範圍。例如,Linux 核心的字串處理庫使用 builtin_constant_p() 在編譯時已知字串大小的情況下呼叫字串處理函式的最佳化版本。
GCC 使用相應的 expand_builtin() 函式來評估每個內建函式。例如,builtin_strcmp() 使用 expand_builtin_strcmp() 求值。下表列出了 GCC 內建函式的例子
| 內建名稱 | 解釋 |
| builtin_constant_p | 如果引數是常量,則返回 true |
| builtin_memcpy | 等效於 memcpy() |
| builtin_strlen | 等效於 strlen() |
| 要點: | GCC 初始化包括命令列選項解析、初始化後端、建立全域性範圍以及初始化內建資料型別和函式。 |
在初始化之後,函式 do_compile() 呼叫函式 compile_file()。此函式呼叫 parse_file() 前端語言鉤子,該鉤子對於 C 語言被設定為函式 c_common_parse_file()。後面的函式呼叫函式 finish_options(),該函式初始化預處理器並處理 -D、-U 和 -A 命令列選項(分別等效於 #define、#undef 和 #assert)。C 預處理器處理原始碼中的預處理器指令,例如 #define、#include。
在預處理器初始化完成後,呼叫了 c_parse_file() 函式。該函式使用標準的 lex/bison 工具來解析檔案。預處理器作為詞法分析器的一部分實現。C 語言詞法分析器函式 c_lex() 呼叫 libcpp 函式 cpp_get_token(),它處理預處理器關鍵字。預處理器的狀態由變數 cpp_reader *parse_in 定義。型別 struct cpp_reader 最重要的是包含正在處理的文字緩衝區列表。每個緩衝區對應一個原始檔(.c 或 .h)。函式 cpp_get_token() 為合法的預處理器關鍵字呼叫相應的函式。例如,當遇到 #include 時,呼叫函式 do_include_common()。它分配一個新的緩衝區並將其放置在緩衝區棧的頂部,使其成為當前緩衝區。當編譯一個新檔案時,緩衝區從棧中彈出,舊檔案的編譯繼續進行。
當使用 #define 關鍵字定義新的宏時,呼叫函式 do_define()。
| 要點: | 預處理器負責處理預處理器指令,例如 #include 和 #ifdef。 |
在執行預處理器後,GCC 為原始檔的每個函式構建一個抽象語法樹 (AST)。AST 是若干個連線的節點,型別為 struct tree。每個節點都有一個樹碼,它定義了樹的型別。宏 TREE_CODE() 用於引用程式碼。樹碼在檔案 tree.def 和 c-common.def 中定義。具有不同樹碼的樹被分組到樹類中。以下樹類在 GCC 中定義,除此之外還有其他一些樹類。
| 樹類 | 解釋 |
| 'd' | 宣告 |
| '<' | 比較 |
| '2' | 二元算術 |
樹有兩種型別:語句和表示式。語句對應於 C 結構,例如表示式後跟一個 ';',條件語句,迴圈語句等等。表示式是語句的組成部分。表示式的示例包括賦值表示式,算術表示式,函式呼叫等等。樹碼的示例如下表所示。
| 樹碼 | 樹類 | 解釋 | 運算元 |
| MODIFY_EXPR | 'e' | 賦值表示式 | TREE_OPERAND(t,0) - 左側;TREE_OPERAND(t,1) - 右側; |
| CALL_EXPR | 'e' | 函式呼叫 | TREE_OPERAND(t,0) - 函式定義;TREE_OPERAND(t,1) - 函式引數; |
| FUNCTION_DECL | 'd' | 變數宣告 | DECL_SOURCE_FILE(t) - 原始檔;DECL_NAME(t) - 變數名; |
| ARRAY_TYPE | 't' | 陣列型別 | TREE_TYPE(t) - 陣列元素的型別;TYPE_DOMAIN(t) - 索引的型別; |
| DECL_STMT | 'e' | 變數宣告 | TREE_OPERAND(t,0) - 變數;DECL_INITIAL(TREE_OPERAND(t,1)) - 初始值; |
除了定義樹型別的樹碼之外,還有許多與每種樹型別不同的運算元可用。例如,賦值表示式有兩個運算元,對應於表示式的左側和右側。宏 TREE_OPERAND 用於引用運算元。宏 IDENTIFIER_POINTER 用於查詢 IDENTIFIER_NODE 樹表示的識別符號的名稱。下表展示了一些樹節點,它們的目的以及它們的運算元。
每棵樹都有一個型別,它對應於它表示的 C 表示式的型別。例如,MODIFY_EXPR 節點的型別是左側運算元的型別。 NOP_EXPR 和 CONVERT_EXPR 樹用於型別轉換。
樹 NULL_TREE 等效於 NULL。函式 debug_tree() 將樹列印到 stderr。
解析器作為 bison 語法實現。語法呼叫構建 AST 的 GCC 函式。當一個新的識別符號被詞法分析時,它會被新增到 GCC 維持的字串池中。識別符號的樹碼為 IDENTIFIER_NODE。當同一個識別符號再次被詞法分析時,返回同一個樹節點。函式 get_identifier() 返回識別符號的樹節點。
新的變數宣告在多個函式呼叫中被處理。首先,函式 start_decl() 被呼叫,帶有宣告的名稱,詞法分析器返回的型別,以及它的屬性。該函式呼叫 grokdeclarator() 檢查型別和引數節點,並返回一個程式碼適合宣告的樹:VAR_DECL 用於變數,TYPE_DECL 用於型別等等。然後,宣告被新增到作用域中。作用域包含在一個函式內建立的所有宣告,但不包含全域性宣告。還有全域性作用域,它包含全域性宣告。當解析一個函式時,它的宣告作為 BLOCK 節點附加到函式體。當建立宣告時,使用 IDENTIFIER_SYMBOL_VALUE 將識別符號節點與宣告節點關聯。函式 lookup_name() 返回給定識別符號的宣告。當宣告離開作用域時,樹屬性 C_DECL_INVISIBLE 被斷言。
GCC 中沒有維護符號表。相反,編譯器使用識別符號池和 C_DECL_INVISIBLE 屬性。語言鉤子 lang_hooks.decls.getdecls() 返回作用域中的變數,這些變數連結在一起。
函式 start_init() 和 finish_init() 被用於初始化宣告。函式 finish_decl() 完成宣告。對於陣列宣告,它計算初始化陣列的大小。然後呼叫函式 layout_decl()。它計算宣告的大小和對齊方式。
解析函式取決於函式體是否存在。函式宣告使用與變數宣告相同的函式解析。對於函式定義,呼叫函式 start_function()。然後,編譯器解析函式體。當函式結束時,呼叫函式 finish_function()。
函式 start_decl() 和 start_function() 將宣告的屬性引數作為其引數之一。屬性在 GCC 手冊 中描述。屬性是 GNU 實現 C 的擴充套件。下表展示了一些屬性並解釋了它們的目的。
| 屬性 | 解釋 |
| constructor | 在 main() 之前自動呼叫函式 |
| destructor | 在 main() 之後自動呼叫函式 |
| alias | 另一個函式的別名 |
對於每種型別的 C 語句,都有一個函式構建對應型別的樹節點。例如,函式 build_function_call() 為函式呼叫構建 CALL_EXPR 節點。它以函式名的識別符號和引數作為引數。該函式使用 lookup_name() 查詢函式宣告,並使用 default_conversion() 對引數進行型別轉換。
然後,AST 被轉換為 SSA,最終轉換為 RTL 表示。是否在解析每個函式之後或解析整個檔案時進行轉換由編譯器選項 -funit-at-a-time 控制。預設情況下為假。
| 要點: | GCC 解析器作為 bison 語法編寫。它建立原始檔的 AST 表示。AST 由樹節點構成。每個節點都有一個程式碼。樹節點對應於 C 的語句和表示式。函式 debug_tree() 打印出樹。 |
後端為指定的目標平臺生成彙編程式碼。每個寫入彙編檔案的指令都會呼叫函式 output_asm_insn()。函式 final_start_function() 在函式儲存到彙編檔案之前生成函式的序言。
| 要點: | 後端為指定的目標平臺生成彙編程式碼。 |