為核心的構(gòu)建與組織)
簡介CMakeLists.txt 是 C 大型工程構(gòu)建的核心這份資源以可運行的示例工程為核心面向需要掌握 CMake 組織方式的開發(fā)者講解如何利用 add_subdirectory、target_link_libraries、find_package 等命令劃分模塊、管理依賴并生成跨平臺構(gòu)建文件。資源壓縮包共 10 個文件包含 5 個 txt 構(gòu)建腳本、3 個 cpp 源文件和 2 個 h 頭文件體積僅 5KB結(jié)構(gòu)緊湊適合按目錄逐層比對學(xué)習(xí)。包內(nèi)示例從項目初始化開始到源文件收集、目標(biāo)屬性設(shè)置、依賴查找、測試集成與安裝步驟基本覆蓋了大型工程對 CMakeLists 的主要使用場景示例工程包含公共工具與輸入輸出等獨立子目錄每個目錄都有對應(yīng) CMakeLists便于觀察項目的分層組織方式并理解各命令在工程中的實際作用。已有 1422 人學(xué)習(xí)適合作為 CMake 入門后的實戰(zhàn)參考讀者可對照示例中的命令和目錄組織快速遷移到自己的多模塊項目中。1. 大型 C 工程用 CMakeLists 管理先分清“組織問題”和“構(gòu)建問題”接手過幾個用 CMakeLists 管理的大型 C 工程后我最大的感受是大多數(shù)工程“難改、難加、難查”的根源不是某個庫沒鏈接對而是 CMakeLists 的組織方式在一開始就沒把“目標(biāo)target”當(dāng)作核心。很多從業(yè)者拿到一份大數(shù)據(jù)量的代碼庫第一反應(yīng)是堆include_directories和add_definitions結(jié)果不到兩萬行代碼構(gòu)建系統(tǒng)就成了不敢動的黑匣子。這份資源的價值很直接它用一個可復(fù)現(xiàn)的大型工程實例把add_library、target_link_libraries、add_subdirectory、install和CTest這些基礎(chǔ)命令串成一套“按模塊組織、按層依賴、按需導(dǎo)出”的工程結(jié)構(gòu)。適合手里有多個模塊、多人協(xié)作、或者剛從單文件編譯轉(zhuǎn)向工程化構(gòu)建的 C 開發(fā)者。讀完你能學(xué)會的不只是命令而是“新模塊來了該往哪放、CMakeLists 該怎么分層、變量該在哪定義”這一整套決策邏輯。2. 把“目標(biāo)”當(dāng)?shù)谝还馻dd_library 與 target_include_directories 的用法重構(gòu)2.1 為什么現(xiàn)代 CMake 不再推薦全局 include_directories在大型工程里include_directories的最直接問題是“泄漏”。你給 A 模塊加了某個第三方頭文件路徑B 模塊只要在同一個目錄樹下編譯也可能意外拿到這個路徑。短期看是方便長期看就是頭文件沖突和“我明明沒包含這個目錄為什么宏定義變了”的玄學(xué)問題。CMake 官方和大多數(shù)一線團(tuán)隊的共識是所有頭文件路徑、編譯選項、宏定義都盡量綁在目標(biāo)上。也就是說用target_include_directories替代全局的include_directories用target_compile_definitions替代add_definitions。這份資源里給出的典型做法是這樣add_library(module_a STATIC src/module_a.cpp src/module_a_helper.cpp ) target_include_directories(module_a PUBLIC $BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include $INSTALL_INTERFACE:include ) target_include_directories(module_a PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src ) target_compile_definitions(module_a PRIVATE MODULE_A_BUILDING_LIB )這里最關(guān)鍵的是PUBLIC、PRIVATE、INTERFACE三個可見性關(guān)鍵字。PRIVATE表示當(dāng)前目錄只對module_a自己編譯時有用PUBLIC表示鏈接了module_a的其他目標(biāo)也會自動帶上這個頭文件路徑INTERFACE通常用于純頭文件庫或者只導(dǎo)出依賴而不參與編譯的庫。資源里對這三者的使用場景解釋得很清楚一個模塊的內(nèi)部實現(xiàn)頭文件放PRIVATE對外暴露的接口頭文件放PUBLIC如果是接口庫header-only則全部用INTERFACE。參數(shù)上要注意$BUILD_INTERFACE:...和$INSTALL_INTERFACE:...是生成器表達(dá)式。前者在源碼樹內(nèi)編譯時生效后者在install之后、被外部項目引用時生效。原因很簡單安裝后的目錄結(jié)構(gòu)和源碼樹目錄結(jié)構(gòu)往往不一致如果寫死${CMAKE_CURRENT_SOURCE_DIR}/include外部項目拿到的路徑就是錯的。這個細(xì)節(jié)是很多人把庫安裝出去之后發(fā)現(xiàn)“找不到頭文件”的頭號原因。2.2 用 target_link_libraries 表達(dá)模塊依賴而不是直接寫絕對路徑大型工程里最怕的就是手寫link_directories加絕對路徑。一旦某個庫換目錄或者從靜態(tài)庫換成動態(tài)庫CMakeLists 就要到處改。資源里反復(fù)強調(diào)的依賴表達(dá)方式是add_executable(main_app src/main.cpp ) target_link_libraries(main_app PRIVATE module_a module_b external_lib )這里PRIVATE的意思是main_app自己需要鏈接這些庫但main_app如果是庫它的使用者不需要知道這些依賴。如果main_app是個庫并且它對外暴露的頭文件里直接引用了module_a的類型那么這里就應(yīng)該用PUBLIC。這個決策聽起來簡單實際工程里經(jīng)常有人為了“反正都能編譯過”所有鏈接都寫PUBLIC結(jié)果就是接口庫的依賴被成倍放大外部項目鏈接時冒出一堆“沒必要的傳遞鏈接”最后變成鏈接順序和循環(huán)依賴問題。關(guān)于鏈接順序資源里有一個常見做法是被依賴的庫放在依賴者的后面。比如main_app依賴module_amodule_a依賴module_b那么寫鏈接時以main_app ... module_a module_b的順序排。這不是 CMake 強制的但 GNU 鏈接器在處理靜態(tài)庫時是單遍掃描順序錯了就會出現(xiàn)undefined reference而且這個報錯出現(xiàn)得很晚排查起來非常費勁。后面避坑章節(jié)里我會專門講這個現(xiàn)象。2.3 接口庫INTERFACE library在大型工程里的實際用途大型工程里有一類比較特殊的目標(biāo)叫接口庫它不產(chǎn)生任何編譯產(chǎn)物只用來傳遞頭文件路徑、編譯選項和依賴。資源里的例子很典型一個公共的編譯選項集合或者一個跨平臺的平臺宏定義集合都可以做成接口庫。add_library(common_warnings INTERFACE) target_compile_options(common_warnings INTERFACE -Wall -Wextra -Wpedantic ) target_compile_definitions(common_warnings INTERFACE _GLIBCXX_USE_CXX11_ABI1 )然后在其他目標(biāo)上統(tǒng)一使用target_link_libraries(module_a PRIVATE common_warnings )這么做的好處是工程里所有模塊共享同一套警告選項和 ABI 宏定義想調(diào)整時只改一處。如果直接用add_compile_options效果雖然一樣但沒法區(qū)分“哪個目標(biāo)用了哪套選項”將來做單元級編譯緩存或者單獨關(guān)掉某個模塊的警告時會很痛苦。資源里把接口庫當(dāng)作“配置的載體”而不是“代碼的載體”這個思考方式對大型工程非常實用。3. 大型工程的目錄結(jié)構(gòu)設(shè)計add_subdirectory 與 CMakeLists 分層3.1 頂層 CMakeLists 只做三件事項目聲明、全局選項、子目錄聚合很多失敗的工程頂層 CMakeLists 動輒幾百行變量定義、函數(shù)封裝、if分支全堆在一起。資源里給出的頂層結(jié)構(gòu)非??酥仆ǔV挥腥齻€部分cmake_minimum_required和project聲明、全局構(gòu)建選項如構(gòu)建類型、安裝路徑、測試開關(guān)、以及按順序add_subdirectory子模塊。一個典型的頂層CMakeLists.txt長這樣cmake_minimum_required(VERSION 3.16) project(large_engine LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) option(ENGINE_BUILD_TESTS Build unit tests ON) option(ENGINE_BUILD_EXAMPLES Build examples OFF) if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) set(CMAKE_BUILD_TYPE Release CACHE STRING Build type FORCE) set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS Debug Release RelWithDebInfo MinSizeRel) endif() add_subdirectory(modules) add_subdirectory(apps) if(ENGINE_BUILD_TESTS) enable_testing() add_subdirectory(tests) endif()注意這里沒有出現(xiàn)任何模塊相關(guān)的源文件路徑也不做業(yè)務(wù)變量傳遞。set(CMAKE_CXX_STANDARD 17)是全局統(tǒng)一的標(biāo)準(zhǔn)版本避免出現(xiàn)兩個模塊一個用 C14 一個用 C17 導(dǎo)致的 ABI 問題。CMAKE_CXX_EXTENSIONS OFF是關(guān)閉 GCC 的-stdgnu17擴展保證代碼在 MSVC 和 Clang 下行為一致。資源里特別提到這個開關(guān)不統(tǒng)一換編譯器后經(jīng)常出現(xiàn)“代碼在 GCC 能編在 MSVC 就報錯”的問題。3.2 模塊內(nèi)部每個模塊一個 CMakeLists變量作用域用 directory 隔離資源把大工程拆成modules/、apps/、tests/三個頂層目錄每個目錄下的子模塊各自持有獨立的 CMakeLists。關(guān)鍵點是CMake 的變量作用域是 directory 級別的子目錄里set的普通變量不會跑到父目錄父目錄的變量也不會自動泄漏到子目錄除非用CACHE變量。這意味著每個模塊的 CMakeLists 應(yīng)該自包含。比如modules/module_a/CMakeLists.txt# 這個文件只描述 module_a 怎么構(gòu)建 file(GLOB_RECURSE MODULE_A_SOURCES CONFIGURE_DEPENDS src/*.cpp src/*.cxx ) add_library(module_a STATIC ${MODULE_A_SOURCES}) target_include_directories(module_a PUBLIC $BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include ) target_link_libraries(module_a PRIVATE module_common )file(GLOB_RECURSE ... CONFIGURE_DEPENDS)是資源推薦的一種源文件收集方式。傳統(tǒng)觀點認(rèn)為 GLOB 不好因為新增文件時 CMake 不會自動感知但CONFIGURE_DEPENDS讓 CMake 在構(gòu)建時重新檢查 glob 結(jié)果解決了這個問題。不過要注意CONFIGURE_DEPENDS需要 CMake 3.12并且每次構(gòu)建都要做一次文件系統(tǒng)遍歷模塊數(shù)量多的時候會略微增加配置時間。資源里給出的取舍是模塊內(nèi)部文件多用 GLOB 減少維護(hù)成本模塊對外暴露的接口頭文件不推薦 GLOB因為頭文件列表應(yīng)該顯式出現(xiàn)在安裝和導(dǎo)出描述里。3.3 共享變量和函數(shù)用函數(shù)封裝代替到處 set大型工程里避免不了共享一些邏輯比如“創(chuàng)建一個帶默認(rèn)選項的模塊庫”。資源里的做法是用function封裝function(engine_add_module target_name) add_library(${target_name} STATIC ${ARGN}) target_compile_features(${target_name} PUBLIC cxx_std_17) target_link_libraries(${target_name} PRIVATE engine_common) set_target_properties(${target_name} PROPERTIES POSITION_INDEPENDENT_CODE ON ) endfunction()然后模塊里只需要engine_add_module(module_c src/module_c.cpp )這里ARGN會接住函數(shù)調(diào)用里多余的參數(shù)也就是源文件列表。POSITION_INDEPENDENT_CODE ON表示生成位置無關(guān)代碼這是為了將來能鏈接進(jìn)動態(tài)庫或者被python之類的宿主程序dlopen預(yù)留的。模塊多了以后你會發(fā)現(xiàn)這種封裝帶來的最大好處是新模塊的 CMakeLists 縮到十行以內(nèi)并且所有模塊的結(jié)構(gòu)完全統(tǒng)一檢查代碼時看一個模塊就等于看了一百個模塊。4. 從源碼樹到安裝包install 導(dǎo)出與 find_package 的完整閉環(huán)4.1 install 規(guī)則和 GNUInstallDirs大型工程最終要交付給別人用install規(guī)則寫不好庫裝了等于沒裝。資源里的常見做法是引入GNUInstallDirs來統(tǒng)一安裝路徑include(GNUInstallDirs) install(TARGETS module_a module_b EXPORT engine_targets ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} ) install(DIRECTORY include/ DESTINATION ${CMAKE_INSTALL_INCLUDEDIR})用${CMAKE_INSTALL_LIBDIR}而不是直接寫lib原因很樸素不同 Linux 發(fā)行版對庫目錄有不同約定比如 Fedora 用lib64Debian 系用lib。GNUInstallDirs會根據(jù)平臺自動選擇。這個細(xì)節(jié)看起來小但真正發(fā)布一個庫時如果路徑寫死下游用戶很可能需要手動改安裝路徑才能讓 pkg-config 或 CMake 找到。4.2 導(dǎo)出目標(biāo)和 find_package 的配合資源里把安裝后的工程消費方式也講清楚了用EXPORT生成一個 CMake 包配置文件下游用find_package就能拿到完整的庫目標(biāo)。具體寫法install(EXPORT engine_targets FILE engine_targets.cmake NAMESPACE engine:: DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/engine )這樣做的意義在于下游項目不再需要用target_link_libraries(my_app ${ENGINE_LIBRARIES})那套老舊的變量式查找而是直接find_package(engine REQUIRED) target_link_libraries(my_app PRIVATE engine::module_a)命名空間engine::不是裝飾而是為了統(tǒng)一引用風(fēng)格并且避免和工程里其他同名目標(biāo)沖突。資源里還有一段關(guān)于生成engineConfig.cmake和engineConfigVersion.cmake的說明用于支持版本檢查。這是大型工程發(fā)布時繞不開的部分否則下游項目沒法聲明find_package(engine 1.2 REQUIRED)。4.3 安裝頭文件時保留目錄結(jié)構(gòu)頭文件安裝是另一個重災(zāi)區(qū)。很多工程的include/下是engine/module_a/...這種帶模塊名的嵌套目錄如果直接install(DIRECTORY include/ DESTINATION include)安裝出來的路徑會丟掉engine前綴嗎不會include/下了engine/目錄會整體裝過去。但我見過不少工程為了“扁平化”把engine/module_a/config.h硬生生復(fù)制成include/config.h結(jié)果就是下游代碼里#include engine/module_a/config.h全部失效。資源里的原則是安裝頭文件時保持源碼樹里的相對路徑結(jié)構(gòu)因為接口頭文件里的相對#include是依賴目錄結(jié)構(gòu)的。5. 大型工程避坑與常見問題排查五個真實踩坑記錄5.1 現(xiàn)象鏈接時大量undefined reference to “xxx”但庫明明已經(jīng)在 target_link_libraries 里原因基本是靜態(tài)庫鏈接順序不對。GNU 鏈接器從左到右掃描靜態(tài)庫如果module_a引用了module_b里的符號但命令行里module_a在module_b后面鏈接器掃描到module_a時沒把它所需的符號記下來到module_b時這些符號找不到歸屬于是報錯。解決把被依賴的庫放在依賴者后面。CMake 里target_link_libraries的參數(shù)順序一般按“依賴關(guān)系從后往前”排。如果你用了PUBLIC傳遞有時這條規(guī)則會被隱藏的依賴鏈打亂排查時可以先用cmake --build . -- VERBOSE1看實際鏈接命令里的庫順序再調(diào)整 CMakeLists 里的書寫順序。5.2 現(xiàn)象一個模塊里定義了DEBUG宏其他模塊也跟著變原因是使用了add_definitions(-DDEBUG)這種目錄級全局命令它的作用范圍是整個目錄及其子目錄不是一個目標(biāo)?,F(xiàn)代 CMake 里凡是“只想影響某個模塊”的宏定義都應(yīng)該用target_compile_definitions綁定到目標(biāo)上。解決先把對所有add_definitions清理掉逐個拆到對應(yīng)的target_compile_definitions區(qū)分PUBLIC/PRIVATE。拆完以后構(gòu)建時如果某個模塊仍然看到不該有的宏就用cmake --build . -- VERBOSE1檢查對應(yīng)編譯命令里的-D參數(shù)。5.3 現(xiàn)象安裝后下游find_package能找到庫但#include engine/module_a.h編譯報錯“No such file or directory”原因大概率是安裝頭文件目錄結(jié)構(gòu)變了。比如源碼樹里是include/engine/module_a.h安裝時寫成了install(DIRECTORY include/engine/ DESTINATION include)裝出來就變成include/module_a.h下游代碼按#include engine/module_a.h自然找不到。解決安裝目錄規(guī)則里要么install(DIRECTORY include/ DESTINATION include)保留完整結(jié)構(gòu)要么在安裝后檢查一下落地路徑。我一般會用一個臨時目錄做cmake --install . --prefix /tmp/test_install然后直接find /tmp/test_install -name *.h核對路徑是否和源碼樹一致。5.4 現(xiàn)象同一份源碼GCC 能編譯MSVC 報C2065未聲明標(biāo)識符但代碼邏輯沒明顯錯原因往往是CMAKE_CXX_EXTENSIONS默認(rèn)是 ONGCC 用了gnu17標(biāo)準(zhǔn)允許了一些 GCC 擴展語法比如typeof而 MSVC 嚴(yán)格走標(biāo)準(zhǔn) C。另一個常見原因是某個模塊用了#include unistd.h這類 POSIX 頭文件在 Windows 上根本不存在。解決頂層設(shè)set(CMAKE_CXX_EXTENSIONS OFF)讓所有模塊嚴(yán)格走CMAKE_CXX_STANDARD指定的標(biāo)準(zhǔn)??缙脚_條件編譯用WIN32和UNIX判斷或者用target_compile_definitions加平臺宏后再在代碼里#ifdef隔離。5.5 現(xiàn)象修改了一個頭文件重新構(gòu)建時只有一半模塊重新編譯另外一半一直用舊產(chǎn)物原因是file(GLOB_RECURSE ...)沒加CONFIGURE_DEPENDSCMake 在配置階段就把源文件列表固定了。新增或刪除源文件不會觸發(fā)重新配置導(dǎo)致構(gòu)建系統(tǒng)不知道文件變化。解決所有 GLOB 收集源文件的地方補CONFIGURE_DEPENDS。如果項目已經(jīng)用了較老 CMake就改成顯式列出源文件。另一種更省事的做法是在 CMakeLists 里加set_property(DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} PROPERTY CONFIGURE_DEPENDS ON)但這也不是萬能藥最穩(wěn)妥的還是顯式源文件列表。6. 進(jìn)階用法把 CMake 構(gòu)建檢查變成日常習(xí)慣資源里有一塊很多教程很少講的內(nèi)容怎么讓 CMakeLists 本身可驗證、可審查。我現(xiàn)在的習(xí)慣是給工程加一個CMakePresets.json把常用的構(gòu)建配置固定下來{ version: 3, configurePresets: [ { name: dev, displayName: Development, generator: Ninja, binaryDir: ${sourceDir}/build/dev, cacheVariables: { CMAKE_BUILD_TYPE: Debug, ENGINE_BUILD_TESTS: ON, CMAKE_EXPORT_COMPILE_COMMANDS: ON } } ], buildPresets: [ { name: dev, configurePreset: dev } ] }這樣新同事拿到倉庫不用記一堆-D參數(shù)直接cmake --preset dev cmake --build --preset dev就能進(jìn)入標(biāo)準(zhǔn)構(gòu)建流程。CMAKE_EXPORT_COMPILE_COMMANDS打開后會生成compile_commands.json我用它來讓 clangd 或者編輯器索引整個工程定位跳轉(zhuǎn)比看 CMakeLists 里的變量快得多。對于大型工程我還會習(xí)慣性在每次提交前跑一遍cmake -S . -B build/check -DENGINE_BUILD_TESTSON -DCMAKE_BUILD_TYPEDebug cmake --build build/check -j$(nproc) ctest --test-dir build/check --output-on-failure這套流程是手動的“連續(xù)集成”檢查。先干凈配置一次排除舊緩存干擾再編譯排除漏頭文件、漏鏈接最后跑測試排除運行期問題。資源里有完整的CTest接入示例在tests/CMakeLists.txt里用add_test注冊可執(zhí)行文件配合enable_testing()就能在 CI 或本地一鍵驗證。從那以后我每次拿到一個不熟悉的 CMake 工程都強制自己先跑一遍“配置—編譯—測試”閉環(huán)再看 CMakeLists 分層結(jié)構(gòu)。很多所謂的構(gòu)建玄學(xué)最后都落在可見性和作用域上。希望這份資源里的工程實例和排查思路也能幫你把大型工程的 CMakeLists 從“能跑就行”變成“可維護(hù)、可審查、可交付”的狀態(tài)。希望幫到你。本文還有配套的精品資源點擊獲取