gcc編譯生成可執行文件的過程中發生了什麼

前言

一直好奇程序的編譯過程到底做了哪些工作,後來學會在Ubuntu上使用gcc編譯程序,知道了生成可執行文件需要分爲預編譯、編譯、彙編和鏈接4個步驟,逐漸瞭解了其中的細節,但是過一段時間之後總是記不太清楚了,所以總結一下增強記憶,同時方便日後查找使用。

編譯方式

一步到位

使用gcc命令可以一步將main.c源文件編譯生成最終的可執行文件main_direct

gcc main.c –o main_direct

分步執行

gcc的編譯流程通常認爲包含以下四個步驟,實際上就是將上面的命令分成4步執行,這也是gcc命令實際的操作流程,生成的可執行文件main與上面單條命令生成的可執行文件main_direct是一模一樣的

  • 預處理,生成預編譯文件(.i文件):gcc –E main.c –o main.i
  • 編譯,生成彙編代碼(.s文件):gcc –S main.i –o main.s
  • 彙編,生成目標文件(.o文件):gcc –c main.s –o main.o
  • 鏈接,生成可執行文件(executable文件):gcc main.o –o main

編譯流程

這裏的編譯是指將源文件(.c)生成可執行文件(executable)的這個完整的過程,而不是上面提到的四個步驟中的第二步,爲了弄清楚編譯過程究竟都做了哪些工作,接下來我們可以分步驟來看一下gcc編譯.c文件的過程,瞭解了每一步的內容,也就明白了整個編譯流程,先給出源文件 mian.c 的源代碼。

#include <stdio.h>
#define A 100

// calc sum
int sum(int a, int b)
{
    return a + b;
}

int main()
{
    int b = 1;
    int c = sum(A, b);
    printf("sum = %d\n", c);

    return 0;
}

預處理

預處理又叫預編譯,是完整編譯過程的第一個階段,在正式的編譯階段之前進行。預處理階段將根據已放置在文件中的預處理指令來修改源文件的內容,對於C語言來說預處理的可執行程序叫做 cpp,全稱爲C Pre-Processor(C預處理器),是一個與 C 編譯器獨立的小程序,預編譯器並不理解 C 語言語法,它僅是在程序源文件被編譯之前,實現文本替換的功能。簡單來說,預處理就是將源代碼中的預處理指令根據語義預先處理,並且進行一下清理、標記工作,然後將這些代碼輸出到一個 .i 文件中等待進一步操作。

一般地,C/C++ 程序的源代碼中包含以 # 開頭的各種編譯指令,被稱爲預處理指令,其不屬於 C/C++ 語言的語法,但在一定意義上可以說預處理擴展了 C/C++。根據ANSI C 定義,預處理指令主要包括:文件包含、宏定義、條件編譯和特殊控制等4大類。

預處理階段主要做以下幾個方面的工作:

  1. 文件包含:#includeC 程序設計中最常用的預處理指令,格式有尖括號 #include <xxx.h> 和雙引號 #include "xxx.h" 之分,分別表示從系統目錄下查找和優先在當前目錄查找,例如常用的 #include <stdio.h> 指令,就表示使用 stdio.h 文件中的全部內容,替換該行指令。

  2. 添加行號和文件名標識: 比如在文件main.i中就有類似 # 2 "main.c" 2 的內容,以便於編譯時編譯器產生調試用的行號信息及用於編譯時產生編譯錯誤或警告時能夠顯示行號。

  3. 宏定義展開及處理: 預處理階段會將使用 #define A 100 定義的常量符號進行等價替換,文中所有的宏定義符號A都會被替換成100,還會將一些內置的宏展開,比如用於顯示文件全路徑的__FILE__,另外還可以使用 #undef 刪除已經存在的宏,比如 #undef A 就是刪除之前定義的宏符號A

  4. 條件編譯處理:#ifdef#ifndef#else#elif#endif等,這些條件編譯指令的引入使得程序員可以通過定義不同的宏來決定編譯程序對哪些代碼進行處理,將那些不必要的代碼過濾掉,防止文件重複包含等。

  5. 清理註釋內容: // xxx/*xxx*/ 所產生的的註釋內容在預處理階段都會被刪除,因爲這些註釋對於編寫程序的人來說是用來記錄和梳理邏輯代碼的,但是對編譯程序來說幾乎沒有任何用處,所以會被刪除,觀察 main.i 文件也會發現之前的註釋都被刪掉了。

  6. 特殊控制處理: 保留編譯器需要使用 #pragma 編譯器指令,另外還有用於輸出指定的錯誤信息,通常來調試程序的 #error 指令。

查看main.i文件

編譯

編譯過程是整個程序構建的核心部分,也是最複雜的部分之一,其工作就是把預處理完生成的 .i 文件進行一系列的詞法分析、語法分析、語義分析以及優化後產生相應的彙編代碼文件,也就是 .s 文件,這個過程調用的處理程序一般是 cc 或者 ccl。彙編語言是非常有用的,因爲它給不同高級語言的不同編譯器提供了可選擇的通用的輸出語言,比如 CFortran 編譯產生的輸出文件都是彙編語言。

  1. 詞法分析: 主要是使用基於有線狀態機的Scanner分析出token,可以通過一個叫做 lex 的可執行程序來完成詞法掃描,按照描述好的詞法規則將預處理後的源代碼分割成一個個記號,同時完成將標識符存放到符號表中,將數字、字符串常量存放到文字表等工作,以備後面的步驟使用。

  2. 語法分析: 對有詞法分析產生的token採用上下文無關文法進行分析,從而產生語法樹,此過程可以通過一個叫做 yacc 的可執行程序完成,它可以根據用戶給定的語法規則對輸入的記號序列進行解析,從而構建一棵語法樹,如果在解析過程中出現了表達式不合法,比如括號不匹配,表達式中缺少操作符、操作數等情況,編譯器就會報出語法分析階段的錯誤。

  3. 語義分析: 此過程由語義分析器完成,編譯器 cc 所能分析的語義都是靜態語義,是指在編譯期間可以確定的語義,通常包括聲明和類型的匹配,類型的轉換等。比如將一個浮點型的表達式賦值給一個整型的表達式時,語義分析程序會發現這個類型不匹配,編譯器將會報錯。而動態語義一般指在運行期出現的語義相關問題,比如將0作爲除數是一個運行期語義錯誤。語義分析過程會將所有表達式標註類型,對於需要隱式轉換的語法添加轉換節點,同時對符號表裏的符號類型做相應的更新。

  4. 代碼優化: 此過程會通過源代碼優化器會在源代碼級別進行優化,針對於編譯期間就可以確定的表達式(例如:100+1)給出確定的值,以達到優化的目的,此外還包括根據機器硬件執行指令的特點對指令進行一些調整使目標代碼比較短,執行效率更高等操作。

查看main.s文件

彙編

彙編過程是整個程序構建中的第三步,是將編譯產生的彙編代碼文件轉變成可執行的機器指令。相對來說比較簡單,每個彙編語句都有相對應的機器指令,只需根據彙編代碼語法和機器指令的對照表翻譯過來就可以了,最終生成目標文件,也就是 .o 文件,完成此工作的可執行程序通常是 as。目標文件中所存放的也就是與源程序等效的目標的機器語言代碼,通常至少包含代碼段和數據段兩個段,並且還要包含未解決符號表,導出符號表和地址重定向表等3個表。彙編過程會將extern聲明的變量置入未解決符號表,將static聲明的全局變量不置入未解決符號表,也不置入導出符號表,無法被其他目標文件使用,然後將普通變量及函數置入導出符號表,供其他目標文件使用。

  1. 代碼段: 包含主要是程序的指令。該段一般是可讀和可執行的,但一般卻不可寫。

  2. 數據段: 主要存放程序中要用到的各種全局變量或靜態的數據,一般數據段都是可讀,可寫,可執行的。

  3. 未解決符號表: 列出了在本目標文件裏有引用但不存在定義的符號及其出現的地址。

  4. 導出符號表: 列出了本目標文件裏具有定義,並且可以提供給其他目標文件使用的符號及其在出現的地址。

  5. 地址重定向表: 列出了本目標文件裏所有對自身地址的引用記錄。

查看main.o文件

鏈接

鏈接過程是程序構建過程的最後一步,通常調用可執行程序 ld 來完成,可以簡單的理解爲將目標文件和庫文件打包組裝成可執行文件的過程,其主要內容就是把各個模塊之間相互引用的部分都處理好,將一個文件中引用的符號同該符號在另外一個文件中的定義連接起來,使得各個模塊之間能夠正確的銜接,成爲一個能夠被操作系統裝入執行的統一整體。

雖然彙編之後得到的文件已經是機器指令文件,但是依然無法立即執行,其中可能還有尚未解決的問題,比如源代碼 main.c 中的 printf 這個函數名就無法解析,需要鏈接過程與對應的庫文件對接,完成的重定位,將函數符號對應的地址替換成正確的地址。前面提到的庫文件其實就是一組目標文件的包,它們是一些最常用的代碼編譯成目標文件後打成的包。比如 printf的頭文件是 stdio.h,而它的實現代碼是放在動態庫 libc.so.6 中的,鏈接的時候就要引用到這個庫文件。

從原理上講,連接的的工作就是把一些指令對其他符號地址的引用加以修正,主要包括了地址和空間分配、符號決議和重定位等步驟,根據開發人員指定的鏈接庫函數的方式不同,鏈接過程可分爲靜態鏈接和動態鏈接兩種,鏈接靜態的庫,需要拷貝到一起,鏈接動態的庫需要登記一下庫的信息。

  1. 靜態鏈接: 函數的代碼將從其所在地靜態鏈接庫中被拷貝到最終的可執行程序中。這樣該程序在被執行時,代碼將被裝入到該進程的虛擬地址空間中,靜態鏈接庫實際上是一個目標文件的集合,其中的每個文件含有庫中的一個或者一組相關函數的代碼,最終生成的可執行文件較大。

  2. 動態鏈接: 函數的代碼被放到動態鏈接庫或共享對象的某個目標文件中。鏈接處理時只是在最終的可執行程序中記錄下共享對象的名字以及其它少量的登記信息。在這樣該程序在被執行時,動態鏈接庫的全部內容將被映射到運行時相應進程的虛地址空間,根據可執行程序中記錄的信息找到相應的函數代碼。這種連接方法能節約一定的內存,也可以減小生成的可執行文件體積。

查看main可執行文件

總結

  • gcc編譯器的工作過程:源文件 --> 預處理 --> 編譯 --> 彙編 --> 鏈接 --> 可執行文件
  • gcc編譯過程文件變化:main.c --> main.i --> mian.s --> main.o --> main
  • 通過上面分階段的解釋編譯過程,我們也明白了gcc其實只是一個後臺程序的包裝,它會根據階段要求來調用 cppccasld 等命令

源代碼

整個編譯過程產生的中間文件及最終結果可以通過傳送門—— gcc編譯項目 來獲得,其中還有gccg++分別調用的對比,查看生成的文件可以發現,同樣的源代碼使用gccg++生成的文件是不一樣的,總的來說使用g++編譯生成的可執行文件要大一些。
gcc_compile