我們在哪:四個階段中的一個,湊近細看
本階第一篇導引走過了整條流水線——前置處理、編譯、組譯、連結——把它當成單一一條 `gcc` 指令悄悄從頭跑到尾的四個階段。第二篇打開了前置處理器,示範了 `#include` 與巨集如何在真正的編譯器看到之前就「先」改寫你的文字。本篇則停在半路上,停在整趟旅程中最被低估的產物:目的檔,那個由編譯單一原始檔產出、卻還不是可執行程式的半成品包裹。
為什麼要在此逗留?因為幾乎每一支真正的程式都是由「許多」個原始檔建成、而非單一一個——而目的檔正是這些零件被產出、被儲存、被重複使用的單位。理解它,就是把建置當成魔法、與真正搞懂「為什麼 `gcc a.c b.c` 能成、為什麼改一個檔只會重編那一個檔、又為什麼一個函式名打錯字會以一則晦澀的連結器錯誤而非編譯器錯誤現身」之間的差別。整套設計有個名字——分離編譯——而目的檔正是它的拱心石。
翻譯單元:編譯器真正在編譯的東西
這裡有個前兩篇鋪好卻沒點名的微妙之處。編譯器並不是照你打出來的樣子去編譯你的 `.c` 檔。它編譯的是前置處理器處理「完」之後的檔——每個 `#include` 都完整貼入、每個巨集都展開、每個 `#if` 都解決掉。那一整坨完全展開的原始碼,叫做翻譯單元,它才是 C 編譯真正的原子。一個 `.c` 檔,加上它透過標頭檔拖進來的一切,恰好構成一個翻譯單元。
這解釋了一條原本顯得武斷的規則:編譯器一次只看一個翻譯單元,而且「只」看那一個。當它編譯 `main.c` 時,它對 `math.c` 裡面有什麼一無所知——它們是不同的翻譯單元,在各自獨立的執行中被編譯。`main.c` 唯一被允許知道、關於某個定義在別處的函式的事,就是它的函式原型——名字、回傳型別、參數型別——通常透過一個共用的標頭檔送達。原型是一句承諾:「有一個長這樣的函式存在於某處;相信我,儘管呼叫它。」它是否「真的」存在,不是編譯器的問題。那個問題被推遲到連結器。
目的檔:帶著洞的機器碼
編譯一個翻譯單元,你就得到一個目的檔——慣例上是一個 `.o` 檔。你可以明確地要它:`gcc -c main.c` 會產出 `main.o` 然後停下,完全跳過連結步驟(這正是 `-c` 的意思:只編譯,不連結)。在裡頭,一個目的檔是貨真價實的機器碼——和 CPU 最終要執行的指令一模一樣——但它「還不能」執行,理由只有一個、卻很頑固:它幾乎肯定指涉了住在「其他」目的檔裡的東西。
想像一支程式的 `main.o`,它的 `main()` 呼叫了一個定義在 `math.c` 裡、名為 `square()` 的函式。編譯器發出了呼叫 `square` 的指令,但它完全不知道 `square` 最後會落在哪個位址——`math.c` 是分開編譯的,搞不好都還沒編。於是呼叫指令裡留下的不是一個真正的位址,而是一個空白、一個佔位符,而目的檔記下一則註記:「這裡有個洞,需要填入名字 `square` 最後拿到的那個位址。」目的檔,照字面講,就是被打了一個個帶標籤的洞的機器碼,外加描述每個洞的那份文件。
真正的目的檔不是一堆自由發揮的位元組;它們遵循一套定義好的佈局——在 Linux 上是 ELF,在 macOS 上是 Mach-O。位元組被分門別類進具名的節區:`.text` 裝機器指令,`.data` 裝已初始化的全域變數(一個檔案層級的 `int counter = 5;`),`.rodata` 裝唯讀常數,像字串字面值,而 `.bss` 替一開始為零的全域變數保留空間(它在磁碟上幾乎不花成本,因為它只是「保留 N 個位元組的零」)。把程式碼、可變資料、常數分放在不同節區,也正是稍後讓載入器能把程式碼標記為唯讀、把資料標記為不可執行的依據。
符號:把零件接合起來的名字
`main.o` 裡的一個洞怎麼知道它要的是 `square`,而 `math.o` 又怎麼宣告自己有個 `square` 可以提供?靠的是符號。每個目的檔都帶著一張符號表:一份清單,列出這個檔案要嘛「向外界提供」、要嘛「向外界索取」的具名之物。每個頂層函式與全域變數都成為一個符號。`math.o` 裡的符號 `square` 被標記為已定義——它就在這、在我 `.text` 裡的這個偏移量處。`main.o` 裡同名的符號 `square` 則被標記為未定義——我用了這個名字,但我沒有它;勞駕誰來供應它。
main.o symbol table math.o symbol table
main : DEFINED (.text) square : DEFINED (.text)
square : UNDEFINED (needs it) printf : UNDEFINED (from libc)
printf : UNDEFINED (from libc)
relocations in main.o:
.text + 0x12 -> fill with address of 'square'
.text + 0x27 -> fill with address of 'printf'這道已定義對未定義的劃分,正是連結整件事的樞紐。你可以自己把這些名字列出來:在 Linux 上,`nm main.o` 會印出符號表,已在 `.text` 中定義的符號旁標一個大寫 `T`、仍需索取的未定義符號旁標一個 `U`。如果你哪天看到一則像「undefined reference to `square`」的連結器錯誤,它的意思恰恰就是這個:某個目的檔裡的 `square` 被標為 `U`,而在翻遍交給它的每個目的檔與函式庫之後,連結器始終沒找到一個相符的已定義符號來填那個洞。
從許多洞到一支程式:解析與重定位
現在來收割。編譯產出帶著洞的目的檔;連結器就是把那些洞填起來、再把零件焊接成一支程式的工具。它做兩件截然不同的工作,而把這兩件事分開命名,正是看清「編譯與連結確實是不同階段——而非同一件事的兩種說法」的最清楚方式。
- 符號解析。連結器蒐集每個目的檔(與函式庫),把每個未定義符號,配對到恰好一個同名的已定義符號。`main.o` 那個未定義的 `square`,被綁定到 `math.o` 裡定義的 `square`。如果某個名字哪裡都找不到定義,它就保持未定義,你會得到「undefined reference」錯誤;如果有兩個檔定義了同一個名字,你會得到「multiple definition」錯誤。解析純粹只關乎名字。
- 佈局。連結器把所有的 `.text` 節區一個接一個排進最終程式的位址空間,接著是所有的 `.data`、再來是 `.bss`,於是決定了每個函式與全域變數實際會住在哪個位址。在這一刻之前,`square` 根本沒有位址——只有它在 `math.o` 內的一個偏移量。
- 重定位。既然每個符號都有了真正的位址,連結器便走過那張帶標籤的洞清單(重定位項),逐一修補——把解析出的 `square` 位址寫進 `main` 呼叫指令裡那個空白,其餘每一處交叉參照亦然。把每個洞都補上,機器碼終於指向正確的地方。
- 輸出可執行檔。解析與重定位都完成後,連結器寫出單一一個可執行檔(在 Linux 上是一個 ELF 執行檔)——不再留有未定義符號、每個位址都已具體,準備好交給載入器。
最後一步:從磁碟上的檔案到執行中的行程
可執行檔此刻躺在磁碟上,但一個檔案還不是執行中的程式。當你輸入 `$ ./a.out`,作業系統的載入器接手:它讀取可執行檔的節區,把 `.text` 與 `.data` 對映到記憶體中連結器選定的位址、把 `.bss` 區域清零、架好初始的堆疊,最後跳到程式的進入點。那個進入點並不直接是 `main()`——一小段 C 執行期啟動碼會先跑,架好 argc 與 argv,然後才呼叫你的 `main()`。
於是你的程式碼完整的一生,是一場四次交接的接力:前置處理器產出翻譯單元、編譯器加組譯器把每個翻譯單元變成一個帶著洞的機器碼目的檔、連結器把所有目的檔解析並重定位成一支可執行檔、載入器再把那支可執行檔對映進記憶體並啟動它。每個階段交給下一個的都是不同的產物——原始文字、然後 `.o` 檔、然後一支可執行檔、然後一個活著的行程——而任一階段的失敗,都指向不同的元兇。搞清楚你手上拿的是哪種產物,正是正確讀懂建置錯誤的一半功夫,而那恰恰是接下來兩篇導引的去處。