JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

四個階段:前置處理、編譯、組譯、連結

當你打下 gcc hello.c,這條短短的指令背後,悄悄地接力跑了四支獨立的程式。本篇打開那個黑盒子,走過從一份文字檔到一個可執行檔的整段路——並說清楚為什麼「編譯」和「連結」是兩件不同的工作,而不是一件。

一條指令,四支程式

到這裡,你已經打過 `gcc hello.c`、看著一個 `a.out` 冒出來,再執行 `$ ./a.out`、看見你的問候語。從基礎那一階,你已經知道底下那道深層的分界:你的原始碼是人看得懂的文字,而 CPU 只服從機器碼。一定有某個東西把其中一邊翻成另一邊。這條指令藏起來的,是這份翻譯並不是一次跳躍——它是一條短短的裝配線,而整條裝配線合起來有個名字:工具鏈

具體來說,對一支 C 程式而言,有四支程式一支接一支地跑:前置處理器編譯器組譯器,以及連結器。每一支拿前一支的輸出,加以變換,再交下去——最上頭進去的是文字,最下頭出來的是一個可執行檔。`gcc` 其實是一個親切的驅動程式,替你呼叫這四支、又把中間檔刪掉,讓你從沒察覺。本篇的全部工作,就是讓你察覺。一旦你能為每個階段命名、說出它做什麼,後面幾篇裡那些晦澀的錯誤就不再神祕——你會確切知道,是四個階段裡的哪一個在抱怨。

  hello.c            <- you write this (text)
     |  preprocessor   (cpp)
  hello.i            <- text, still C, but expanded
     |  compiler       (cc1)
  hello.s            <- assembly text (human-readable instructions)
     |  assembler      (as)
  hello.o            <- object file (machine code, not yet runnable)
     |  linker         (ld)
  a.out              <- executable (runnable)
gcc 藏起來的管線。每個箭頭都是一支獨立的程式;每個方框都是一個真實的檔案,只要加上對的旗標你就能把它留下來。

第一階段——前置處理器:文字進,文字出

第一個碰到你檔案的東西,根本不是編譯器。它是前置處理器,而它刻意地笨得幾乎令人吃驚:它不懂 C。它是一個搬運文字的機器人,只服從那些以 `#` 開頭的行。當它看到 `#include <stdio.h>`,它真的就去找到那個標頭檔,把它的全部內容貼到那一行的位置。當它看到一個像 `#define MAX 100` 的巨集,它就走過剩下的文字,把每一個 `MAX` 都換成 `100`。它做的差不多就這些——複製、貼上、代換。

輸出仍然是 C 原始碼文字——只是更大、完全展開了,每個標頭都貼了進來、每個巨集都解析完了。你可以親眼看到:`gcc -E hello.c` 在這個階段之後就停下、印出結果。跑一次,你會發現你那三行的 `hello.c` 已經膨脹成好幾百行,因為整個 `stdio.h`(以及「它」又引入的一切)現在都坐在最上頭。這時什麼都還沒被編譯;一條機器指令都不存在。檔案仍然只是文字,準備好讓下一個階段來讀。

第二階段——編譯器:C 進,組合語言出

現在真正的翻譯開始了。編譯器拿起那份展開後的文字——它稱之為一個翻譯單元,一份原始檔加上所有被貼進它裡頭的東西——把 C 變成指令。它用自己的好幾個階段來做這件事:先把文字剖析成一個有結構的形式(檢查你的文法與型別是否有效,你大多數的 `error:` 訊息就是從這裡來的),然後最佳化,再把結果吐出來。但驚喜在這裡:編譯器並不吐出機器碼。它吐出的是組合語言——還是人看得懂的文字,但現在是一份近乎直譯的 CPU 指令清單。

為什麼停在組合語言、而不一路走到赤裸的位元組?因為組合語言是「我的語言」和「這台機器」之間的天然邊界。編譯器最費勁的工作——讀懂你的 C、挑出有效率的指令、決定哪些值住在哪些暫存器裡——無論這些指令最後怎麼被編碼,都是一樣的。這份輸出你也能偷看:`gcc -S hello.c` 在這裡停住,留下一個你可以打開來讀的 `hello.s` 檔。你會看到像 `mov`、`call` 這樣的行,還有暫存器的名字;本階梯後面的組合語言那一階,會教你流暢地讀懂它們。眼下,重點只是資料的形狀:文字進來,比較不友善一點的文字出去。

第三階段——組譯器:組合語言進,目的碼出

組譯器是所有階段裡最容易描述的:它拿起組合語言文字,把每一條指令編碼成它確切的數值機器碼——也就是 CPU 將會擷取、執行的那些真正的位元組。`mov` 變成一個特定的位元組樣式;一個暫存器的名字變成那個樣式裡的幾個位元。這裡幾乎沒有什麼巧思,只是一份忠實、機械式的、從一種記法到另一種記法的翻譯。結果是整條管線裡第一個真正是二進位、而非文字的檔案——它叫做目的檔,在大多數系統上以 `.o` 結尾。

關鍵的轉折在這裡,也是為什麼一個 `.o` 檔還不是一支你能執行的程式。目的檔是真正的機器碼,但它有洞。當你的程式碼寫 `printf("hi")`,組譯器根本不知道 `printf()` 實際住在哪裡——它的程式碼在某個函式庫檔裡,不在你這裡。所以目的檔等於是記下「呼叫 printf,位址稍後再填」。它帶著一份「自己提供的名字」清單,和一份「自己仍需要的名字」清單,這套記帳結構叫做符號表。一個 `.o` 是一塊已完成但尚未接上的拼圖:完整的機器碼,邊上帶著標好記號的卡榫,標示它必須在哪裡接到別的拼圖。

第四階段——連結器:把碎片接起來,以及它為何不同於編譯

連結器拿起一個或多個目的檔(你的,加上裝著 `printf()` 之類的系統目的檔),把它們組裝成一支單一、完整的程式。它做兩件工作。第一,符號解析:它讀過每一個「我仍需要的名字」的洞,把它對應到某個別的目的檔或函式庫裡那筆「我提供的名字」。你那個 `call printf` 的洞,會被對應到 C 函式庫裡真正的 `printf`。如果某個需要的名字沒有任何人提供,你就在這裡得到那句有名的 `undefined reference to 'printf'`——這是連結器錯誤,不是編譯器錯誤,因為編譯器從沒懷疑過 `printf()` 存在;它只是相信你會有人來供應它。

第二,重定位:每個目的檔在寫成時,都假裝自己會坐在位址 0,但它們不可能同時都住在那裡。連結器決定最終的佈局——你的程式碼放這裡、函式庫的程式碼放那裡——然後回過頭去,修補每一個依賴佈局的位址,把它先前留白的洞填上。在符號解析與重定位之後,每個名字都對應好了、每個位址都固定了:結果是一個單一的可執行檔。這正是為什麼編譯與連結是不同工作的核心:編譯孤立地翻譯「一個」翻譯單元,對其他單元一無所知;連結是唯一一個同時看見它們全部、並把它們縫在一起的階段。

把這道邊界想成一份合約會很有幫助。一個標頭檔是編譯器的承諾——「有一個叫 `printf()` 的函式存在,它收這些引數」——這就是編譯器翻譯你的呼叫所需要的全部。與之相符的機器碼,是那個承諾的兌現,而供應它是連結器的工作。編譯器檢查你有沒有正確地用了合約;連結器檢查那份合約是不是真的被某人履行了。兩個不同的問題、兩個不同的階段、兩種不同的錯誤訊息——這也是為什麼,同一個壞掉的呼叫,會視缺了什麼,而在這兩步的任何一步失敗。

四個階段之後:載入器讓它跑起來

這四個階段在磁碟上產出一個檔案——但檔案不是一支正在執行的程式。最後一個角色,在你打下 `$ ./a.out` 時登場:載入器,作業系統的一部分。它讀取可執行檔,把它的程式碼與資料複製進一塊全新的記憶體,設好你在記憶體那一階見過的堆疊,把起始位址放進 CPU,然後跳過去。直到此刻,才真的有東西開始執行。載入器不是 `gcc` 的一部分,它在一個完全不同的時機執行——每次你啟動這支程式時,各跑一次,遠在編譯結束之後。建置一支程式和執行一支程式,是兩件分開的事,而載入器是它們之間的橋。

所以現在你能把 `gcc hello.c` 讀成它真正的樣子:一個請求,要它跑前置處理器、再跑編譯器、再跑組譯器、再跑連結器——四道把文字變成可執行檔的變換——之後,載入器在另一個分開的時機,讓它活過來。你可以用 `-E`、`-S` 和 `-c` 親手在任何一道邊界停下,而你現在也知道哪個階段該為哪種錯誤負責。這張地圖,是本階其餘部分的地基:第 2 篇放大看前置處理器、第 3 篇看目的檔與符號、第 4 篇看連結器,而第 5 篇教你讀懂每個階段在出狀況時拋出的那些訊息。