:從核心語法到工程實踐)
1. 從一段真實經歷說起為什么你需要make老手可能早就離不開make了但如果你是剛接觸Linux開發(fā)的新手或者一直用小項目、單文件代碼“裸編”過來的那我建議你認真看完這篇文章。我當年第一次接手一個稍微像樣一點的C項目幾十個源文件需要在終端里一條一條敲gcc命令頭文件改一個就要重新編譯一堆文件編譯選項還特別長偶爾敲錯一個參數就得從頭排查。那段時間每次“編一下跑一下”都像在玩掃雷整個人都很痛苦。后來某位同事讓我看了他項目里的Makefile我才反應過來這東西就是在幫你把編譯、打包、清理這些重復勞動全部自動化。簡單說make是一個自動化構建工具而Makefile是給make讀的“構建劇本”里面寫清楚目標文件怎么生成、依賴哪些源文件、用什么命令生成。有了它你在終端里只需要敲一個 make剩下的編譯、鏈接、輸出可執(zhí)行文件全部自動完成。就算改了十幾個文件make也能精準識別哪些需要重編哪些不用動比全量重編快得多。這篇文章我不會跟你念man手冊。我只講兩件事一是Makefile的底層邏輯和核心語法到底怎么用二是我在實際項目里踩過的坑、驗證過的寫法。按這套思路走不管你之后是做嵌入式、Linux服務端、還是搞開源項目都能很快寫出能用的Makefile并且知道每行在干什么。2. Makefile三要素目標、依賴、命令之間的關系2.1 先把三要素掰開揉碎所有Makefile不管寫得多花哨本質都是若干個“規(guī)則”。一條規(guī)則長這樣target: prerequisites command三部分分別是目標target你要生成的東西可以是一個可執(zhí)行文件、一個.o文件也可以是一個動作名比如clean。依賴prerequisites生成目標之前必須具備的文件或其他目標。多個依賴之間用空格分隔。命令command真正干活的shell命令通常就是編譯、鏈接、拷貝這些。命令前面必須是一個Tab縮進不是四個空格不是兩個空格是Tab。這個坑我后面單獨說因為幾乎所有新手都在這里翻車。舉一個最直觀的例子hello: hello.o gcc -o hello hello.o hello.o: hello.c gcc -c hello.c -o hello.o第一條規(guī)則說要生成hello就得先有hello.o然后執(zhí)行gcc把hello.o鏈接成可執(zhí)行文件hello。第二條規(guī)則說要生成hello.o得有hello.c然后執(zhí)行gcc把hello.c編譯成hello.o。你在終端敲make的時候make會先讀Makefile找到第一個目標hello然后檢查它的依賴hello.o。如果hello.o不存在make就會去找hello.o對應的規(guī)則先編譯出hello.o再回來鏈接出hello。整個過程是自動遞歸的很像一個“依賴樹”。這是理解make工作的關鍵它不是一個簡單的從左到右執(zhí)行腳本的工具而是以目標為導向先算依賴關系再決定執(zhí)行哪些命令。2.2 make真正厲害的地方增量編譯不用make的時候手動編譯最煩的就是每次都要把全部文件重新編一遍。大型項目幾十個文件還好幾百個文件的時候一次全量編譯可能要幾分鐘甚至十幾分鐘而你只是改了一行代碼。make天生就是為了解決這個問題——它通過比較目標文件和依賴文件的時間戳來決定是否重新生成目標。舉個具體的流程你改了一個源文件那個文件對應的.o目標文件的時間戳就會比源文件舊make發(fā)現“哦這個目標過期了”于是只重新編譯這個源文件。其他沒改動的文件目標比依賴新make直接跳過。最后再重新鏈接一次。這就是增量編譯幾百個文件的項目修改單個文件后的構建可能只需要幾秒鐘實際效果很驚人。2.3 命令行里的空格與Tab問題說實話這是我在各種技術社區(qū)里看到最多、最經典的報錯現場Makefile:3: *** missing separator. Stop.這個錯誤幾乎都是命令前面用了空格而不是Tab。為什么make只認Tab這是歷史設計問題但你必須接受它。很多編輯器會在配置里默認把Tab轉換成空格這就會讓你的Makefile“看起來對齊了但實際沒法跑”。我目前的習慣是用Vim寫Makefile的時候在.vimrc里強行關閉expandtab并且設置自動縮進寬度為8用VS Code就把editor.insertSpaces設為false保證編輯的文件里插入的是真正的Tab字符。如果實在判斷不清可以在Vim里輸入set listTab字符會顯示為^I空格是普通的小點一眼就能分辨。2.4 偽目標清理操作的隱藏陷阱你可能已經注意到我前面提到的clean目標并不是一個真實文件名。它只是一個動作標簽。如果你真的在目錄里放了一個叫clean的文件再執(zhí)行make clean的時候make會發(fā)現clean這個“目標”已經存在了而且沒有依賴時間戳永遠比依賴新于是它直接告訴你“clean是最新的”不會執(zhí)行rm命令。解決這個問題的標準寫法就是..PHONY: clean clean: rm -f *.o hello.PHONY的完整形式是.PHONY: clean嗎其實標準寫法是clean:本身就是規(guī)則目標而.PHONY: clean這行是聲明格式就是clean:后面跟你的規(guī)則。這行聲明告訴makeclean不能當作文件名來看待它的作用是“命令標簽”每次都要執(zhí)行就算同名文件已經存在也一樣執(zhí)行。不僅clean像all、install、test這種動作型目標都建議聲明為.PHONY防止和同名文件沖突。3. 變量和自動化變量讓Makefile從“寫死”變“通用”3.1 用變量管好你自己的編譯參數剛開始寫Makefile的時候你可能會把編譯命令一行行寫死比如每個.o規(guī)則都是gcc -Wall -g -c xxx.c -o xxx.o。三個文件還行十幾個文件的時候你光改一個編譯選項比如加一個-stdc99就得改十幾行而且很容易漏。變量的引入就是為了解決這種重復勞動。Makefile里的變量定義和引用很有特點CC gcc CFLAGS -Wall -g -O2 -stdc99 LDFLAGS -lm hello: hello.o utils.o $(CC) $(LDFLAGS) -o hello hello.o utils.o hello.o: hello.c $(CC) $(CFLAGS) -c hello.c -o hello.o utils.o: utils.c $(CC) $(CFLAGS) -c utils.c -o utils.o變量用定義用$(變量名)引用。這樣修改編譯器或者編譯選項的時候只需要改開頭那幾行后面的規(guī)則全部自動生效。把變量定義集中到Makefile頂部幾乎可以算是一種行業(yè)習慣你去看那些開源項目基本鋪滿了一堆變量定義。變量賦值還有幾種寫法我用過一次就比較有印象遞歸展開式賦值變量的值是“最后時刻”的展開結果。比如A $(B) B hello這時$(A)展開之后是hello因為直到真正使用的時候才去解析B的值。:直接展開式賦值立刻把右邊的值計算好存下來。如果你想避免循環(huán)引用和二次解析帶來的意外用:更穩(wěn)。?如果變量之前沒有賦值就給它賦這個值常用于讓用戶可以通過命令行覆蓋默認參數。追加內容適合分步添加編譯選項。我自己寫Makefile的時候一旦涉及多個相互引用的變量都會傾向用:因為這樣變量的解析是確定的不會因為賦值順序不同而出現詭異的展開結果。你可以把理解成“活引用”:是“快照”大多數場景下快照更可控。3.2 自動化變量自動化變量的實操價值手動在每條規(guī)則里把目標名和依賴名都寫一遍依然是重復勞動。make提供了一套自動化變量專用來在規(guī)則命令中表示目標、依賴等信息最常用的有三個$表示當前規(guī)則的目標文件名。$^表示當前規(guī)則的所有依賴文件名會自動去重。$表示當前規(guī)則的第一個依賴文件名。拿前面的例子重寫立刻會清爽很多hello: hello.o utils.o $(CC) $(LDFLAGS) -o $ $^ hello.o: hello.c $(CC) $(CFLAGS) -c $ -o $ utils.o: utils.c $(CC) $(CFLAGS) -c $ -o $第一條規(guī)則里$會被替換成hello$^變成hello.o utils.o。第二條規(guī)則里$是hello.c$是hello.o。這樣不管以后新增多少源文件規(guī)則主體幾乎不用改。還有兩個不那么常用但偶爾能救命的$?比當前目標更新的依賴文件列表常用于部署場景比如只在某些源文件變化后重新執(zhí)行歸檔命令。$*模式匹配中匹配到的那部分文件名后面講模式規(guī)則時會用到。3.3 通配符和模式規(guī)則批量處理源文件沒有模式規(guī)則之前每個.c文件都要對應一條獨立的編譯規(guī)則項目里幾十個源文件就得復制幾十條看著就累。make支持模式規(guī)則用%做通配符可以把一類文件的構建規(guī)則濃縮成一條。%.o: %.c $(CC) $(CFLAGS) -c $ -o $這行規(guī)則的含義是任何.o文件只要有一個同名的.c文件作為依賴就用后面這條命令編譯。加上之前講的目標規(guī)則整個Makefile可以濃縮成CC gcc CFLAGS -Wall -g -O2 LDFLAGS -lm OBJS hello.o utils.o hello: $(OBJS) $(CC) $(LDFLAGS) -o $ $^ %.o: %.c $(CC) $(CFLAGS) -c $ -o $ clean: rm -f $(OBJS) hello .PHONY: clean這個Makefile已經接近我平時使用的實際形態(tài)了。新增一個源文件的時候我只需要在OBJS變量里加一個對應的.o文件名其他都不用動。這才是自動化構建工具該有的效率。但是手動維護OBJS這個列表還是有一點讓人不放心——如果忘了把新文件加進去make不會自動感知新出現的.c文件。更好的辦法是用函數自動收集目錄下所有源文件。4. 函數和include讓Makefile自己學會找文件4.1 wildcard和patsubst兩個高頻函數make內置了不少函數寫法看起來像變量引用但帶個符號。我平常用得最多的是這兩個SRCS $(wildcard *.c) OBJS $(patsubst %.c,%.o,$(SRCS))$(wildcard *.c)查找當前目錄下所有.c文件返回一個以空格分隔的文件名列表。就算新拖進來一個源文件SRCS也會自動帶上它你完全不用手動維護。$(patsubst %.c,%.o,$(SRCS))把SRCS里每個.c后綴替換成.o后綴得到對應的目標文件列表。這樣整個Makefile就可以改成CC gcc CFLAGS -Wall -g -O2 LDFLAGS -lm SRCS $(wildcard *.c) OBJS $(patsubst %.c,%.o,$(SRCS)) TARGET app $(TARGET): $(OBJS) $(CC) $(LDFLAGS) -o $ $^ %.o: %.c $(CC) $(CFLAGS) -c $ -o $ clean: rm -f $(OBJS) $(TARGET) .PHONY: clean這個版本已經具備“項目級”感覺了。你把任意一個只有.c文件的目錄交給它它都能自動識別并構建出可執(zhí)行文件app。但別太高興這套寫法有一個大問題它完全沒有處理頭文件依賴。如果你修改了一個頭文件只有include這個頭文件的.c文件才會檢測到變化嗎不會。make只比較.c和.o的時間戳根本不知道頭文件參與其中。這就引出了Makefile里最隱蔽、也最容易引發(fā)線上問題的topic之一。4.2 頭文件依賴大部分新手栽跟頭的地方舉個例子main.c里include了utils.h你改了utils.h里的一個宏定義期望main.o重新編譯。但make會發(fā)現main.c沒有變main.o和main.c的時間戳相比沒過期于是它直接不重新編譯main.o最后鏈接出來的程序還是老版本。這種“改了沒反應”的問題非常隱蔽查起來也費勁。解決思路是讓.o的依賴里包含它include的所有頭文件。你可以手動寫main.o: main.c utils.h但每個文件include哪些頭文件多了之后手動維護完全不堪重負還不如讓編譯器幫你統(tǒng)計。GCC提供了-MM選項可以輸出源文件對應的頭文件依賴列表gcc -MM main.c輸出大概是main.o: main.c utils.h common.h這正好是make能用的依賴規(guī)則。利用GCC的-M家族選項可以讓Makefile自動生成并包含這些依賴信息。目前我見過、也實際使用的是下面這套方案SRCS $(wildcard *.c) OBJS $(patsubst %.c,%.o,$(SRCS)) DEPS $(patsubst %.o,%.d,$(OBJS)) %.o: %.c $(CC) $(CFLAGS) -MMD -MP -c $ -o $ -include $(DEPS)-MMD在編譯的同時生成一個.d文件里面記錄當前.c文件包含的頭文件依賴。-MP為每個頭文件生成一個空的偽目標防止頭文件被刪除后make報錯找不到規(guī)則。-include和include不同即使.d文件不存在比如第一次清理后也不會報錯make會繼續(xù)執(zhí)行。每次編譯時.d文件都會被刷新頭文件依賴自動更新。你再也不用手動維護頭文件列表了。這套方案從我第一次用到現在一直很穩(wěn)幾乎成了我的標準配置。4.3 小心make的天然“遞歸陷阱”自動化收集源文件還有個邊角問題如果項目里有子目錄源文件分布在多個目錄wildcard *.c只能搜到當前目錄搜不到子目錄。你可以在每個子目錄里放一個子Makefile然后在根目錄Makefile里遞歸調用子make這是很多中型項目采用的方式。但遞歸make不是萬靈藥跨目錄的依賴追蹤會變復雜。另一種做法是收集所有子目錄的源文件統(tǒng)一在當前Makefile里編譯SRCS $(wildcard *.c) $(wildcard src/*.c) OBJS $(patsubst %.c,%.o,$(SRCS))這種情況下編譯生成的.o仍然放在各自源文件所在的目錄里不會污染頂層目錄。如果項目繼續(xù)變大、目錄越來越多我建議你還是考慮構建系統(tǒng)比如cmake。但就算用cmake理解make和Makefile的思維模式依然是必不可少的底層能力因為cmake最終生成的可能是Makefile而且很多詭異的構建問題最終都要追到make那一層去查。5. 實操手記一個多文件項目的構建拆解5.1 從零搭一個“有點真實感”的小項目光講語法用處不大我拿一個小項目來完整走一遍。項目結構是這樣project/ ├── Makefile ├── include/ │ └── utils.h └── src/ ├── main.c └── utils.c需求main.c里調用utils.c中的函數utils.h放在include目錄下編譯時要把include目錄加進頭文件搜索路徑。頭文件在不同目錄編譯命令里就要加-I參數。Makefile可以這么寫CC gcc CFLAGS -Wall -g -O2 -Iinclude LDFLAGS -lm SRCS $(wildcard src/*.c) OBJS $(patsubst %.c,%.o,$(SRCS)) DEPS $(patsubst %.o,%.d,$(OBJS)) TARGET app $(TARGET): $(OBJS) $(CC) $(LDFLAGS) -o $ $^ %.o: %.c $(CC) $(CFLAGS) -MMD -MP -c $ -o $ clean: rm -f $(OBJS) $(DEPS) $(TARGET) -include $(DEPS) .PHONY: clean注意自動生成的.o和.d文件都在src目錄里沒有把主目錄弄亂。執(zhí)行make輸出大致是gcc -Wall -g -O2 -Iinclude -MMD -MP -c src/main.c -o src/main.o gcc -Wall -g -O2 -Iinclude -MMD -MP -c src/utils.c -o src/utils.o gcc -lm -o app src/main.o src/utils.o我親手驗證過這個流程編譯、鏈接、生成可執(zhí)行文件一氣呵成。然后我故意改了include/utils.h里的一個結構體定義再次執(zhí)行make它立刻重新編譯了main.o和utils.o這正是依賴自動生成以后的效果。5.2 編譯流程中的關鍵一步鏈接順序有兩個源文件以上就會遇到鏈接順序問題特別是用了靜態(tài)庫的時候。比如utils.o里用了數學庫函數鏈接命令要寫成-lm放在對象文件的后面。很多新手寫鏈接命令會把庫放在最前面結果鏈接器報undefined reference錯誤。# 錯誤示例 gcc -lm -o app main.o utils.o # 正確示例 gcc -o app main.o utils.o -lm鏈接器是按順序掃描輸入文件的它記住的是尚未解析的符號當它掃到app的時候還沒有看到任何對象文件掃到utils.o里的未解析符號之后再遇到-lm才能找到對應的實現。把-lm放前面等于提前把庫讀了后面遇到需要解析的符號已經來不及了。這個經驗我在博客里反復提過因為真的很容易被忽略。5.3 一套清晰的目錄清理方案clean目標要記得把自動生成的.d也刪掉否則清理后重新構建會誤用舊的依賴信息。我前面那份Makefile里已經包含clean: rm -f $(OBJS) $(DEPS) $(TARGET)在規(guī)模大一點的項目里我還會加一個distclean目標用來清掉比clean更徹底的構建產物比如編譯緩存目錄和.config文件。這種命名習慣來自開源社區(qū)傳統(tǒng)看到distclean一般人都能猜到它的用途。如果你自己造一個remove_all這種名字也不是不行只是別人看你的Makefile會覺得有點微妙。6. 命令行使用技巧和常見問題排查實錄6.1 常用命令行參數用得順手的幾個make的命令行參數本身就是一門小課我挑幾個平時最常用的說一下make -n打印出會執(zhí)行的命令但不真正執(zhí)行。改完Makefile想檢查一下邏輯先跑一遍-n既安全又直觀。make -B強制重新生成所有目標無視時間戳。有些時候你懷疑增量編譯狀態(tài)不對可以用它做一次干凈的驗證。make -j4并行編譯4表示同時運行4個編譯任務?,F在的機器動不動8核16核-j$(nproc)可以直接自動填滿CPU大型項目編譯時間能縮短很多。make -C dir切換到指定目錄再執(zhí)行make。在頂層腳本里遞歸調用子項目時很有用。make V1這個參數不是make原生參數而是在Makefile里自己支持的。通過判斷V變量來決定打印詳細編譯命令還是簡化的提示信息。很多開源項目都支持V1輸出完整命令調試編譯選項的時候非常有用。6.2 幾個高頻報錯和排查思路做一個速查表都是我或者身邊同事實際碰到過的場景報錯信息常見原因解決辦法missing separator命令前面用了空格而不是Tab把空格換成真正的Tab用編輯器關閉自動轉空格No rule to make target xxx.o對應的.c文件不存在或路徑不對檢查SRCS收集方式確認文件有沒有在正確目錄下target xxx doesnt match the target pattern模式規(guī)則里的%匹配不到預期內容檢查.o和.c是否同名檢查模式規(guī)則是否寫錯undefined reference to function鏈接順序不對或沒有鏈接對應的庫調整庫參數順序確認庫路徑和庫名正確file changed as we read it編譯過程中源文件被改動了多半是編輯器后臺自動格式化保存時沖突重新make即可make: Nothing to be done目標已是最新沒有需要重新生成的正常情況想強編用make -B6.3 調試技巧打印變量和拿Makefile當Python寫遇到變量展開不符合預期的時候最快的辦法是在Makefile里加一行$(info SRCS $(SRCS)) $(info OBJS $(OBJS))執(zhí)行make的時候這兩行會直接把變量值打印出來方便你確認文件收集是否正確。比你在腦子里推演快得多。如果只想在調試模式下輸出還可以配合ifeq判斷ifdef DEBUG $(info CFLAGS $(CFLAGS)) endif然后執(zhí)行make DEBUG1它就會額外打印編譯選項。6.4 處理文件名中的“特殊情況”如果說還有哪個地方在實際開發(fā)中經常弄出麻煩那就是文件名或路徑里的空格。Linux下文件系統(tǒng)是允許路徑帶空格的但Makefile對空格極其敏感。如果項目路徑里有空格patsubst和wildcard返回的列表會被空格拆開導致make把一個文件名當成兩個文件處理。一個臨時方案是給變量里的路徑加上轉義但那個寫法又長又丑。更好的方案是別讓項目路徑帶空格這是最省事的解法。Windows下帶入路徑到Linux環(huán)境時記得檢查一下目錄名。反正我現在的習慣是創(chuàng)建任何開發(fā)相關目錄都只用字母、數字、下劃線絕對不用空格和中文這是從多個構建工具不只是make的承平年代里總結出來的血淚經驗。7. 從Makefile到更復雜的構建系統(tǒng)7.1 讓Makefile支持多種構建類型如果你發(fā)現項目開始區(qū)分debug版和release版可以這樣寫B(tài)UILD ? release ifeq ($(BUILD),debug) CFLAGS -Wall -g -O0 else CFLAGS -Wall -O2 endif執(zhí)行make BUILDdebug就是調試版不帶參數默認是release版。這種設計模式移植到其他構建系統(tǒng)也很常見。它讓Makefile變得像一個小型配置系統(tǒng)對中小項目來說夠用且高效。7.2 自動安裝目標實際發(fā)版的時候我還會加一個install目標把編譯好的可執(zhí)行文件拷貝到指定目錄PREFIX ? /usr/local install: $(TARGET) install -m 755 $(TARGET) $(PREFIX)/bin/?確保用戶可以通過make install PREFIX/opt/mytool覆蓋安裝路徑。同時install目標依賴$(TARGET)所以它會先確保編譯完成再執(zhí)行安裝命令。這樣整個構建、安裝流程就串起來了。7.3 什么時候該從make遷移到cmakeMakefile不是萬能的。當項目開始出現以下信號我會認真考慮遷移到cmake需要生成并安裝動態(tài)庫、靜態(tài)庫、可執(zhí)行文件多種產物需要跨平臺Windows、Linux、macOS構建需要處理復雜的條件編譯、第三方依賴查找項目多人協(xié)作希望構建系統(tǒng)自動探測環(huán)境。cmake通過CMakeLists.txt描述構建邏輯能生成對應平臺的構建腳本比如Linux下可以生成MakefileWindows下可以生成Visual Studio工程。但學cmake前我依然建議先吃透Makefile因為make的規(guī)則、依賴、變量和編譯參數這些底層概念在cmake里全都換了個形式出現底子不牢的話cmake里的target、interface這些抽象會讓你更迷失。8. 一些實實在在的經驗和碎碎念寫到這里主體內容基本講完了。最后分享幾個我個人在實際操作中的體會希望對你有幫助。第一Makefile不是背出來的是“用”出來的。你拿一個小項目反復改、反復跑很快就能把語法記牢。我一直覺得構建工具的掌握程度應該以“出了問題能不能自己查”為標準而不是能默寫多少函數名。第二調試Makefile問題的時候先分清是“語法錯誤”還是“邏輯錯誤”。語法錯誤通常一眼能瞄出來比如缺Tab、少冒號邏輯錯誤才真正燒腦比如改動沒觸發(fā)重編、依賴漏了頭文件。這種時候用make -d可以輸出極詳細的調試信息配合$(info ...)打印變量多試幾次總能找到癥結。第三保持Makefile簡單干凈。很多人為了炫技會把Makefile寫得像腳本語言用一堆嵌套函數和條件判斷。其實Makefile的可維護性跟代碼一樣重要。我見過一個幾千行的“新一代”Makefile最后沒人能改得動??酥剖堑谝辉瓌t。第四建議你用版本管理工具管理Makefile。它就像你的源代碼一樣被多個人修改和review出了問題可以快速回退。最后再說一個小技巧。如果你和我一樣經常要在一個目錄里反復調試編譯可以把debug和release兩種配置都寫在同一個Makefile里然后用一個變量去切換。我第一次用這個模式的時候還不以為意但項目一旦大起來這種小小的“配置化”思路會幫你省掉非常多改參數的時間。構建工具的本質就是讓機器去做重復勞動人只負責定義規(guī)則。make讓這件事變得足夠輕、足夠直接所以三十多年過去了它依然是Linux開發(fā)繞不開的基本功。希望能幫你在構建這條路上少踩幾個坑。