Makefile 走到盡頭之處
到現在,你已能寫出一份真正的 Makefile、交給它依賴圖,看著它只重建有變動的部分。在本階你也學會了連結到靜態函式庫或動態函式庫,並用 `-I` 與 `-L` 把編譯器指向正確的標頭檔與函式庫路徑。這是個確實有能力的配置——對「一台」機器而言。麻煩會在你的專案得在「別的地方」建構的那一刻開始。
想想你的 Makefile 寫死了哪些東西。它寫 `gcc`,但 macOS 上的同事用的是 `clang`,而 Windows 工具鏈把它的編譯器叫成完全不同的名字。它寫 `-I/usr/local/include`,但在另一台機器上函式庫卻裝在 `/opt`、`/usr/include`、或某個只有那個人知道的資料夾底下。這每一條路徑,都是關於「你的」電腦的事實,是 Makefile 自己無法發現的。把你的 Makefile 交給朋友,它會壞掉,不是因為你的邏輯錯了,而是因為他們的機器佈局不一樣。這就是可攜性問題,而純 Make 對它沒有答案。
你大可試著手動修補——在 Makefile 裡為每個作業系統寫 `if` 分支、四處探測函式庫可能裝在哪——但那條路會通向上千行、沒人讀得懂、且被每個專案各自重新發明一遍的 Makefile。誠實的解法是:別再自己寫 Makefile,而是用更高的層次描述你的建構,讓一個工具替「跑它的那台機器」「產生」正確的 Makefile(或 Visual Studio 專案、或 Ninja 檔)。那個工具,就是本篇接下來的主題。
CMake:替你寫建構檔的工具
CMake 是一套元建構系統:一套建構「建構系統」的建構系統。你不再寫 Makefile;你寫一份 CMakeLists.txt,抽象地說:「有一支叫 app 的程式,由這些原始檔組成,它需要這個函式庫。」CMake 讀那份描述、檢查它實際所在的那台機器——有哪個編譯器、函式庫真正住在哪——然後產出一份為那台機器量身打造的、具體的 Makefile(或 Ninja 建構、或 Xcode/Visual Studio 專案)。一份描述,多種可能的建構檔。
底下是本階稍早那個一樣的兩檔小程式,改用 CMake 描述。注意它比 Makefile「短」得多、也更宣告式:沒有編譯命令、沒有 `-c`、沒有連結行、完全沒提到目的檔。你陳述的是「意圖」——一支由這些原始檔產生的執行檔——而 CMake 會替底下那個平台推算出編譯與連結步驟,連同那張增量建構圖。
cmake_minimum_required(VERSION 3.16) project(app C) add_executable(app main.c util.c) # find a library named foo and link it; CMake locates its # include path and library path for us, on any platform find_package(Foo REQUIRED) target_link_libraries(app PRIVATE Foo::Foo)
用 CMake 建構分成兩步,值得看清楚。先是設定(configure)步驟,`cmake -B build`,它執行 CMake 本身:探測機器,把真正的建構檔產生到一個 `build` 資料夾,與你的原始碼分開(一次外部建構,讓產生的檔案永遠不污染你的程式碼)。接著是建構(build)步驟,`cmake --build build`,它執行剛產生出來的底層工具——常常就是 Make——去實際編譯與連結。這個切分很重要:configure 是那項昂貴的、做一次就好的平台偵探工作,而 build 是那項快速、可重複、一遍遍做的編譯。
尋找函式庫:CMake 真正物有所值之處
回想第四篇那項手動雜務:要用一個函式庫,你得傳 `-I` 讓編譯器找到它的標頭檔、傳 `-L -lfoo` 讓連結器找到它的程式碼,而確切路徑全得你親手敲。上面那行 `find_package(Foo REQUIRED)`,就是 CMake 把那場搜尋自動化。它在這台機器的標準位置裡四處搜尋,找出標頭檔的標頭路徑與相符的函式庫路徑,把它們打包成一個目標,讓你一行就連結上。你不再寫死 `/usr/local/include`,而讓工具逐台機器去定位它。
因為本階稍早那段函式庫的故事已內建進 CMake 的模型,一個關鍵字就決定了靜態還是動態。要求一個靜態函式庫,會在連結時把函式庫的程式碼直接複製進你的執行檔——檔案較大,但自給自足。要求一個動態函式庫,則留下一個參照,由載入器在執行時從另外的 .so 或 .dll 解析,這也正是為什麼兩支程式之後能在記憶體裡共用一份。CMake 把這件事表達成一個目標屬性,而非一堆旗標,所以在兩者間切換是一個字的改動,而不是重寫 Makefile。
但要對魔法止於何處誠實。`find_package(Foo REQUIRED)` 只在函式庫 Foo「已經安裝」於這台機器、且位於 CMake 知道怎麼找的地方、或附帶一個描述自己的 CMake 輔助檔時,才會成功。如果 Foo 根本不在,CMake 會帶著清楚的錯誤停下——`REQUIRED` 意思是「現在就大聲失敗」,而非在連結時神祕地壞掉。CMake 「定位」依賴;它本身並不「抓取」它們。那塊缺失的拼圖——首先得把函式庫弄到機器上——是另一個工具的工作。
套件管理器:抓取你沒寫的程式碼
幾乎沒有哪支真實程式是全然從零寫起的;它倚靠別人寫好的函式庫——一個 JSON 解析器、一段壓縮常式、一個 HTTP 用戶端。那每一個都是一項外部依賴,是你的專案需要、但本身並不含有的程式碼。一個套件管理器就是那個工具:替你抓取那些依賴、把它們裝到編譯器與 CMake 找得到的地方,並且——關鍵地——記下你用的究竟是哪個版本。在 Linux 上你見過 `apt` 或 `dnf` 這類系統套件管理器;對 C 與 C++ 專案,vcpkg 與 Conan 這類工具做的是同樣的事,但範圍縮限到你的專案。
記下版本之所以重要,原因和這整階存在的原因相同:建構必須可重現。如果你的專案說「用壓縮函式庫 1.3.1 版」,而隊友的機器悄悄裝的是 1.2.0,你可能得到不同的行為,或一個連結得起來、卻在執行時失常的建構。這正是函式庫版本管理與 ABI 相容性變得具體之處。一個函式庫的 ABI——它的函式與結構的確切二進位佈局——可能在版本之間改變,而若你的程式是依著某一種佈局建構、卻在執行時載入了另一種佈局的 .so,你會得到任何重新編譯「你的」程式碼都無法解釋的崩潰。在套件管理器裡釘住版本,就是你防止這件事發生的方法。
合起來看,現代的流程長這樣:套件管理器抓取並釘住你的依賴,CMake 定位它們、並抽象地描述你的建構,接著 CMake 產生那份與平台相關、由編譯器與連結器實際執行的 Makefile 或 Ninja 檔。每個工具掌管一項誠實的職責,且彼此乾淨地交接。這一切都不是魔法,也都沒有免除你理解底下各層的責任——但它正是「一個只在你今天這台筆電上能跑的建構」與「一個陌生人明年能在另一台機器上正確地複製並建構出來的建構」之間的差別。
退一步看:這一階給了你什麼
看看這五篇的弧線。你從親手敲編譯命令的切膚之痛出發,看清你究竟為何需要一套建構系統。你認識了 Make,寫出一份從依賴圖推導建構順序、且只重建有變動部分的 Makefile。你學到函式庫有兩種風味,靜態與動態,各有檔案大小、記憶體共用、以及程式碼何時被綁入這些真實的取捨。你練習了把編譯器與連結器指向正確的標頭檔與函式庫路徑。而現在你又往上爬了一層,來到讓這一切能跨機器可攜、可重現的那些工具。
誠實的收穫是:這些工具本身都不是重點——重點是那張依賴圖,以及可重現建構的紀律。Make、CMake 與套件管理器,是疊在一個頑固真相之上的三層自動化:一支真實的程式是許多段程式碼,由許多人寫成,必須以正確的順序、用正確的版本、在一台你或許永遠不會見到的機器上,被編譯並連結在一起。理解那個真相,正是讓你能拿起這些工具中任何一個——或下一個取代它們的工具——而不會迷失的關鍵。