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

Make 與你的第一個 Makefile

第一篇主張你需要一套建構系統;現在我們來認識那位經典人選。我們會一行一行寫出一個真正的 Makefile,看著 Make 自己推算出建構順序,並親眼看清它為何能跳過那些不必重做的工作。

從「你需要一套」到「這就是一套」

第一篇論證了:當一個專案長到超過單一檔案,手動敲入那些編譯與連結命令會變得緩慢、易錯,而且根本記不住。一套建構系統就是那位知道整份食譜、且每次都以相同方式照辦的廚房助手。本篇要介紹的,正是類 Unix 系統上最古老、也最普及的那位助手:Make。它有數十年歷史、無所不在,而能讀懂它的指示,就能讓你建構出多到驚人的現有軟體。

使用 Make 的方式,是把你的建構指示寫進一個名為 Makefile 的純文字檔,然後在含有它的目錄裡敲 `make`。如果說建構系統是一位通用的廚房助手,那 Make 就是其中一位特定、非常有名的助手,而 Makefile 是你交給它的那張手寫食譜卡。沒有任何魔法:Make 讀那個檔案、推算該做什麼,然後執行普通的 shell 命令——就是你本來會自己敲的那些 `gcc` 命令,只是改由它替你挑選並排序。

回想「工具鏈」那一階:建構一支 C 程式其實分成兩個階段——先把每個原始檔各自編譯成一個目的檔(`gcc -c main.c` 會產生 main.o),再把所有目的檔連結成單一支執行檔。這個切分就是分離編譯,而它正是 Make 之所以值得的原因:因為每個 .c 都能獨立編譯,Make 就能只重新編譯你改動的那一個檔案,其餘的拿來重用。我們在 Makefile 裡的全部工作,就是描述那些編譯與連結步驟,以及它們如何彼此相連。

規則的文法:目標、前置條件、配方

Makefile 大致上就是一串規則,而每條規則都有相同的三段式樣貌。想像做一個三明治:三明治是你要的成品,麵包與餡料是它的材料,而「把餡料夾進兩片之間」是做法。用 Make 的詞彙來說,這三段就是目標、前置條件與配方。目標是你想產出的檔案;前置條件是它的材料檔案;配方是把材料變成成品的那串確切 shell 命令。

寫出來時,一條規則把目標與它的前置條件放在同一行、以冒號隔開,接著在它下方寫一行或多行配方。假設我們有 main.c 與 util.c,而 util.c 仰賴一個標頭檔 util.h。底下是這個小專案一份完整、誠實的 Makefile——三條規則分別編譯出兩個目的檔,再一條規則把它們連結成一支叫 `app` 的程式。

app: main.o util.o
<TAB>gcc -o app main.o util.o

main.o: main.c util.h
<TAB>gcc -c main.c

util.o: util.c util.h
<TAB>gcc -c util.c

( each <TAB> above is one real TAB character, not spaces )
第一份 Makefile:把 app 從兩個目的檔連結起來,每個目的檔再從它的原始檔與標頭檔編譯而來。

Make 自己推算出順序

接下來這部分,第一次見到時會覺得像魔法。你從未告訴 Make 該以什麼順序建構東西。上面那三條規則你是用某個隨意的順序打的,然而 `make` 永遠會先編譯出 main.o 與 util.o,才去連結 app。它是從目標與前置條件如何相連推算出這件事的:app 把 main.o 與 util.o 列為前置條件,所以 Make 知道那兩個必須先存在;而每個 .o 又各自列出一個 .c 與一個 .h,那些已是你寫好、現成存在的檔案。Make 讀那些連結、推導出順序——你描述的是「依賴關係」,而「先後次序」會自然從中浮現。

Make 在腦中實際建起的,是一張建構圖:把每個檔案想成一個點,從每樣東西畫一個箭頭指向它的材料。app 指向 main.o 與 util.o;main.o 指向 main.c 與 util.h。當你執行 `make`,它會從你要求的目標(預設是 app,也就是第一個目標)沿著這張圖往下走到原始檔,在每個點上判斷那個檔案是否需要(重新)建構。這張圖不是你畫的;而是 Make 從你寫下的前置條件自動組裝出來的。

「誠實地」列出前置條件,是 Make 交還給你的那唯一一項責任,而經典的失誤就是偷懶不做。注意我們把 util.h 同時寫成 main.o 與 util.o 的前置條件。如果 main.c 裡寫了 `#include "util.h"`,你卻忘了把 util.h 列上,那麼編輯 util.h 並不會讓 Make 重新建構 main.o——Make 根本不知道它們相連。建構會悄無聲息地用一個過時的 main.o 成功收場,於是你得到一支與原始碼不符的程式,連個錯誤訊息都沒有。一張不完整的圖比一次緩慢的建構更糟,因為它在對你說謊。

它為何跳過工作:時間戳與增量建構

這張圖真正的回報,是 Make 不會每次都盲目地重建一切——它只做真正需要的工作,這叫一次增量建構。它用的訣竅妙在極其簡單:檔案時間戳。磁碟上每個檔案都帶有一個最後修改時間。Make 的判準是:一個目標若存在、且比它所有前置條件都新,就算是最新的;否則它就過時了,它的配方必須執行。

  1. 你編輯 util.c 並存檔。它的最後修改時間跳到「現在」,超前了 util.o。
  2. 你執行 `make`。Make 拿 util.o 與它的前置條件比對,發現 util.o 現在比 util.c 還舊——過時了——於是重跑 `gcc -c util.c`,產生一個全新的 util.o。
  3. 接著 Make 拿 app 與它的前置條件比對,發現 app 比新的 util.o 還舊,於是重新連結:`gcc -o app main.o util.o`。
  4. main.o 被完全擱著不動——main.c 沒變,所以 main.o 仍比它的原始檔新,Make 從不重跑它的配方。

這就是全部的機制,也正是它讓在大程式上反覆迭代變得可以忍受:改一個檔案,一秒內就重建好,而不必重新編譯整個專案。但要對它的極限誠實——時間戳是「內容有沒有變」的「替身」,而非事實本身。如果你的系統時鐘不對、如果一個檔案被碰過卻沒真正編輯、或如果某個前置條件從圖裡漏掉了,Make 就可能錯誤地跳過工作(過時的建構)或錯誤地重做。當一次建構行為莫名其妙時,標準的重置手段是一次乾淨建構:刪掉所有產生出來的建構產物——main.o、util.o、app——再從原始碼重新建一次,不留任何殘餘來擾亂時間戳邏輯。

減少重複:假目標、變數、樣式

我們第一份 Makefile 能用,但有兩個惱人之處:沒有便利的方法刪除建構產物,而且那些 .o 規則重複著同樣的 `gcc -c` 樣貌。三個小特性能解決它。一個假目標是一條規則,它的名字是個動作而非檔案;一個變數讓你把一個值寫一次、再三重用;而一條樣式規則為整個檔案家族寫出單一條規則,而不必逐一複製。

假目標用 `.PHONY: clean` 來宣告。平常 `make clean` 會跑一條名為 clean 的規則——但如果資料夾裡真的出現一個字面上叫 clean 的檔案,Make 會把它看成一個已經最新的目標,而拒絕執行配方。把它標成 `.PHONY` 就是告訴 Make:「這個名字是個總要執行的動作,絕不是要檢查的檔案。」變數寫成 `CC = gcc` 或 `CFLAGS = -O2 -Wall`,用作 `$(CC)` 或 `$(CFLAGS)`,於是編譯器與旗標集中在一處,改一行就能調整。而樣式規則用 `%` 當萬用字元:單一條規則 `%.o: %.c` 說明如何從相符的 .c 做出任一個 .o,其中 `<` 指第一個前置條件(那個 .c),`@` 指目標(那個 .o)。

CC = gcc
CFLAGS = -O2 -Wall

app: main.o util.o
<TAB>$(CC) -o app main.o util.o

%.o: %.c util.h
<TAB>$(CC) $(CFLAGS) -c $<      # $< = the .c ; $@ would be the .o

.PHONY: clean
clean:
<TAB>rm -f app main.o util.o
同一個建構,用一個變數、一條樣式規則與一個假目標 clean 整理過後。

這些特性把一面複製貼上的牆,變成簡短而好維護的東西,改一個編譯器旗標只是一行編輯。誠實的代價是可讀性:像 `$@`、`$<`、`$^`(全部前置條件)這些自動變數雖強大,對新手卻很晦澀,而一份過度炫技的 Makefile 可能真的變得難以除錯。適度使用,它們正是讓真實專案的建構保持清醒的關鍵。也要留意純 Make 的一個真實限制:它本身並不知道編譯器或函式庫在不同作業系統上住在哪——這正是元建構工具 CMake(本階最後一篇的主題)存在的原因,它站在更高一層,替你產生 Makefile。