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

為什麼你需要一套建置系統

一個檔案用一條 gcc 指令就能編譯——那為什麼每個真實的專案都附上一份 Makefile?本篇看著一個手打的建置在自己的重量下垮掉,再點出一套建置系統給你的兩個想法:一張它讀得懂的相依圖,以及只重建真正改動過的東西的紀律。

當一條指令不再夠用

在工具鏈那一階,你學會了把 `gcc hello.c` 讀成它真正的樣子:前置處理器、編譯器、組譯器、連結器一支接一支地跑,把文字變成一個可執行檔。對單一一份原始檔來說,那一條指令真的就是你需要的全部。麻煩在於,沒有哪個有趣的程式只是單一一份原始檔。當你的專案長到五個檔、再到二十個、再到兩百個時,那條讓人安心的單行指令就開始嘎吱作響——而取代它的,正是本整階的主題:一套建置系統

讓我們把這個痛處弄得具體一點。假設你的程式住在三個檔案裡:`main.c`、`parser.c` 和 `util.c`,共用 `parser.h` 與 `util.h` 兩個標頭檔。你已經從分離編譯的習慣裡知道正確的形狀了——把每個 `.c` 各自編譯成它自己的目的檔,再把這三個連結在一起。靠手打的話,那是每次想測試一個改動就要打的四條指令,而且你必須照正確的順序、帶著正確的旗標來打,一個都不能漏。順序打錯、或漏掉一個檔,你就連結到了過時的程式碼,而沒有任何警告告訴你你這麼做了。

gcc -c main.c      ->  main.o
gcc -c parser.c    ->  parser.o
gcc -c util.c      ->  util.o
gcc main.o parser.o util.o -o app   (the link step)
手工建置三個檔案:三條編譯指令加一條連結。現在想像兩百個檔案,再想像每改一次就重打一遍這些。

那個藏著更糟問題的偷懶解法

最直覺的第一反應,是把那些指令塞進一個 shell 腳本、再去跑那個腳本。這省掉了打字,卻悄悄造出一個又新又更昂貴的問題:那個腳本「每次」都重建「全部」。在 `util.c` 裡改一個註解,腳本就盡責地連 `main.c` 和 `parser.c` 也一起重新編譯,即使它們所依賴的東西半點沒動。三個檔案時,這點浪費不痛不癢。但在一個完整建置要花上幾分鐘——有時是好幾分鐘——的大型專案裡,為了測一行改動而把整個東西重編一遍,會把你那緊湊的「編輯—編譯—執行」循環變成一場喝咖啡的休息。

所以一套建置系統真正要回答的問題,比「我怎樣才能不用打四條指令」尖銳得多。它是:「既然我剛剛只改了一個檔案,那把程式帶到最新狀態的最小一組步驟是什麼?」要回答這個,需要兩樣 shell 腳本沒有的東西。第一,這個工具必須知道哪些檔案依賴哪些——知道 `main.o` 是從 `main.c` 以及它所引入的標頭檔建出來的。第二,它必須能分辨出什麼已經是最新的、然後跳過它。這兩種能力合起來,就是一套建置系統值得學的全部理由。

相依圖:藏在一切底下的那個想法

第一種能力,立基於一個叫做相依圖的結構。把每個檔案想成一個點,再從一個檔案,朝著它所從之建出的每個檔案各畫一條箭頭。你最後的程式 `app` 是從三個 `.o` 檔建出來的,所以有三條箭頭指進它。每個 `.o` 又是從它對應的 `.c`、以及那個 `.c` 所引入的每個標頭建出來的。結果是一張俐落的因果圖:改動任何東西,箭頭就會精確告訴你,它下游有哪些現在過時了。這正是建置圖的核心,而你一旦看見它,就再也無法當作沒看見。

util.h ----+
           v
util.c --> util.o ----+
                      |
parser.h --+          |
           v          v
parser.c-> parser.o -> app
           ^          ^
main.c --> main.o ----+
           ^
main.h ----+
一張小小的相依圖。箭頭從輸入指向「從它們建出的東西」。改了 util.h,就只有沿著它的箭頭能到達的東西——util.o,然後 app——需要重建。

注意到這張圖已經把標頭也納進來了,這正是讓它不只是一份花俏清單的原因。如果你改了 `util.h`,每個引入它的 `.c` 都受影響,因為前置處理器會把那個標頭貼進來,於是編譯出的結果其意義就可能改變。一套好的建置系統會自動跟著那些箭頭走:動了 `util.h`,它就知道要重建它下游的每個目的檔,然後重建最後的程式。漏掉那條相依——就像一個天真的腳本那樣——你就會得到最糟的那種臭蟲:你的程式表現得彷彿你根本沒做那個改動,因為過時的 `.o` 被無聲無息地重用了。

增量建置:只做改變了的那部分工作

第二種能力是增量建置,而它背後的訣竅很樸實:時間戳記。磁碟上每個檔案都記著它最後被修改的時間。一套建置系統會拿一個目標的時間,去跟餵給它的那些輸入的時間相比。如果 `main.o` 比 `main.c` 以及 `main.c` 引入的每個標頭都還新,那麼 `main.o` 就已經是最新的了,重建它純粹是浪費——於是工具就跳過它。如果有任何一個輸入比目標新,那這個目標就過時了、必須重做。把這條單一規則套到整張圖上,就是讓一次建置只做最少必要工作的關鍵。

  1. 你改了 util.c 並存檔。檔案系統現在把 util.c 記成剛剛被修改過。
  2. 建置工具走過這張圖、比對時間戳記。util.o 現在比 util.c 舊了,所以 util.o 過時了。
  3. 它只把 util.c 重新編譯成一個新的 util.o。main.o 和 parser.o 不動、被跳過,因為它們的輸入沒有改變。
  4. 因為 util.o 變了,app 現在比它的某個輸入舊,於是工具把這三個目的檔重新連結成一個新的 app——然後停手。

這就是全部的回報:一次編譯加一次連結,而不是三次編譯加一次連結。在真實的程式庫上,這個差別非常戲劇化——一個本來要花上五分鐘完整重建的改動,往往不到一秒就完成,因為那張圖證明了幾乎所有東西都已經是最新的。這正是為什麼,你永遠不會看到一個正經的 C 或 C++ 專案,被當成一堆原始檔、外加一個寫著「把它們全部編譯」的 README 發布。它附帶的是一份把這張圖編碼進去的建置描述,好讓工具替你做這套推理。

重用:你的程式碼不全是你的

建置系統之所以重要,除了有效率地重建你自己的檔案之外,還有第二個理由,而它從連結器的工作裡自然長出來。在工具鏈那一階,當你呼叫 `printf()` 時,連結器在 C 函式庫裡找到了它的機器碼——那是你從沒寫過的程式碼。真實的程式倚賴的遠不只如此:一個用來剖析 JSON 的函式庫、一個做密碼學的、一個畫視窗的。這就是函式庫外部相依的世界。一套建置系統,也正是你用來說明「你的程式需要哪些外部函式庫」、以及「建置在哪裡能找到它們的程式碼與標頭」的地方。

那些考量——要哪些函式庫、怎麼連結、在哪裡找到、從哪裡取得——大到足以讓本階把它大部分的篇幅都獻給它們。後面幾篇會把靜態與動態連結分開講,解釋編譯器怎麼找到標頭、連結器怎麼找到函式庫檔,並認識那些替你下載相依、好讓你不必自己一個個去翻找的套件管理員。眼下,只要先抓住這個形狀:一套建置系統協調兩個半邊——把「你的」改動過的原始碼變成目的檔,再把它們跟「別人的」已編譯函式庫接起來——而且它只做能產出一支正確、最新的程式所需的最少工作。

你會遇到的兩個工具,以及本階的形狀

有兩個名字主宰這個世界,現在先把它們安放好會有幫助。第一個是 Make,那個經典的工具:你寫一份 Makefile,列出每個目標、它所依賴的輸入、以及建出它的指令——你直接描述那張圖,而 Make 做時間戳記的推理。Make 小、老、無所不在,也正是把這些想法以最赤裸的形式學起來的好地方,這就是為什麼第 2 篇從它開始。它老實的弱點是:那些規則是手寫的,而且綁死在某一個平台的指令與路徑上。

第二個名字是 CMake,它坐在高一層的地方。你不自己寫那些建置規則,而是更抽象地描述你的專案——這些原始檔做出這支程式,它用到這些函式庫——然後 CMake 替你產生實際的建置檔,視它面前是什麼系統而定:在一台機器上產出一份 Makefile,在另一台上產出另一種專案檔。它是一個「後設建置」工具:一套寫出建置系統的建置系統。這就是為什麼它成了大型、可移植的 C 與 C++ 專案的標準,也是為什麼本階最後一篇,會在 Make 給了你欣賞 CMake 在自動化什麼的詞彙之後,把它連同套件管理員一起介紹。

所以這就是本階的地圖。你來的時候,懂得把一個檔案變成一支程式;你離開的時候,會懂得在不被淹沒的情況下,管理許多檔案與許多函式庫。藏在這一切底下的那個大想法,正是本篇試著替你牢牢釘住的那一個:一次建置就是一張相依圖,而唯一明智的建置方式,是從那張圖去計算出把一切帶到最新狀態的最小一組步驟。Make 是那個想法的手寫版;CMake 是那個想法替你產生出來的版本;套件管理員則把它延伸到你自己程式碼的邊界之外。接下來的一切,都是掛在這個骨架上的細節。