CMake实战指南:从Makefile到Modern CMake构建思维
如果你翻过几个开源项目的根目录大概率见过CMakeLists.txt这个文件。说实话我第一次接触它的时候是带着情绪的——当时我负责把一套在 Linux 上跑得好好的 C 项目搬到 Windows换一个编译器原本的 Makefile 就基本成了一张废纸而 CMake 用同一份描述把两边都接住了。这篇内容不打算罗列 CMake 的官方文档而是从我自己从看不懂 CMakeLists到能用它组织多模块项目的实战经验出发把 CMake 的运作逻辑、核心写法、常见错误一次讲透。适合刚接触 CMake、或者写了好几年构建脚本但对 target 体系没有系统理解的开发者参考。你要做的不是背命令而是掌握它背后的生成构建系统这个思维方式。1. CMake到底解决了什么被Makefile救过也被Makefile坑过之后1.1 手动编译与脚本构建的天花板很多 C 项目都是从一条编译命令开始的比如g main.cpp utils.cpp -Iinclude -Llib -lmylib -o myapp在只有一个平台、一个编译器、一个人的时候这条命令完全够用。但项目稍微长大一点问题就来了新增文件要改命令、第三方库的位置变了要改命令、Debug 和 Release 要不同的优化标志于是很快你就会写一个 Shell 脚本或者 Makefile 去封装这套动作。Makefile 本身是个好东西它的依赖规则和增量编译思路即使放到今天也不过时。但它有一个天然边界它只描述在某个已存在的构建环境里怎么编译没法主动去发现环境、探测依赖、适配不同的编译器。一旦你跨平台问题就接踵而至——Linux 下库文件在/usr/libmacOS 上可能在/opt/homebrew/libWindows 上又变成了某个包的安装目录GCC 和 MSVC 的编译 flag 写法不一样Visual Studio 需要.sln工程Xcode 需要.xcodeproj。你总不能给每个平台各维护一套 Makefile 或工程文件那等于把一个项目拆成几个不同的项目在维护。我见过一个比较极端的老项目构建脚本有几千行里面塞满了一堆if platform判断后来接手的人每加一个源文件都得在三个脚本里同步修改漏一次就是编译报错。这种痛苦不是代码逻辑层面的而是工程组织层面的。1.2 构建系统的构建系统到底是个什么概念CMake 的定位和 Make 不完全一样。Make 是直接执行编译规则的工具而 CMake 做的是生成构建规则。它先读取你的CMakeLists.txt了解项目有哪些源文件、哪些库、依赖关系怎么样然后生成一套当前平台能用的构建配置。我用一个通俗的类比来理解这件事CMake 是设计院它出图纸Make、Ninja 是施工队它按图纸施工。设计院不亲自搬砖但它决定了房间怎么隔、水电怎么走。同一张图纸在不同施工队手里落地细节可能不同但户型是一致的。所以你在 CMake 里写的不是怎样调用 g而是这个可执行文件由哪些源文件组成、链接哪些库、需要哪些编译选项。至于最终是用 Makefile 编译还是生成 Ninja 文件或是变成一个 Visual Studio 工程由你执行cmake时选择的生成器决定。1.3 一份描述解决哪些具体场景用 CMake 统一描述项目之后下面这些场景会轻松很多场景传统做法CMake 做法跨平台构建每个平台维护一套构建脚本一份 CMakeLists运行时生成对应构建系统第三方依赖定位手写路径和库名find_package统一探测编译选项管理散落在各脚本里易冲突挂在 target 属性上定向生效多配置构建手动传参或者改路径Debug/Release 由生成器统一管理集成 IDE导入导出工程文件来回同步直接生成 VS/Xcode 工程测试、安装、打包另写脚本串起来CTest/CPack/install 内建注意第 2 章里我会强调一个关键转变CMake 写得好不好不取决于你会背多少命令而在于你有没有建立把依赖挂在 target 上的习惯。这个习惯直接决定你的构建脚本在项目变大之后是越用越清晰还是变成一个谁都改不动的泥潭。2. 先忘掉变量记住targetModern CMake的核心思维2.1 老式CMake教程为什么让你后面吃亏如果你在网上搜 CMake 教程很容易搜到十年前流传下来的写法。它长这样include_directories(include) link_directories(${THIRD_PARTY_LIB_DIR}) add_definitions(-DDEBUG) set(SOURCE_FILES a.cpp b.cpp c.cpp) add_executable(myapp ${SOURCE_FILES}) target_link_libraries(myapp mylib)这段代码在小项目里能跑起来所以很多人的第一印象就是CMake 不过如此。但项目一旦模块化这种全局设置的问题就会集中爆发。最典型的场景你的项目里有 A、B、C 三个库其中 A 依赖某个旧版本的头文件B 依赖另一个。在全局include_directories的写法下所有 target 的编译命令都会带上所有 include 路径编译器一旦找不到头文件就会按顺序一条条翻结果翻到 A 带进来的旧版本编译报错就变成了玄学。还有一个更隐蔽的问题——链接顺序。传统写法里链接库时经常依赖写在后面的库先满足前面库的符号这种隐含规则在 GCC 下这种顺序敏感问题特别常见。一旦你把target_link_libraries改成挂在 target 上CMake 会按依赖关系自动整理链接顺序这类问题会大大减少。2.2 target 把依赖关系变成可控对象Modern CMake 的核心单位是 target。一个 target 可以是一个可执行文件add_executable、一个静态或动态库add_library也可以是一个纯粹的头文件接口库add_library(xxx INTERFACE)。每个 target 自带一整套属性你可以用特定命令给这些属性赋值target_include_directories(mylib PUBLIC include) target_compile_definitions(myapp PRIVATE USE_LOGGING1) target_compile_options(mylib PRIVATE -Wall) target_link_libraries(myapp PRIVATE mylib)这三个作用域关键字是最值得花时间理解的PRIVATE只对当前 target 本身生效。比如一个库在实现文件里用了某个头文件但对外暴露的接口里没有那就用 PRIVATE。INTERFACE对当前 target 自身不生效但所有链接这个 target 的人都会继承。典型场景是 header-only 库——它没有源文件要编译但它提供的头文件路径必须传给别人。PUBLIC两者都生效。库里外都要用的内容比如某个库的头文件目录。我用一个具体场景解释你写了一个日志库mylog头文件在include/mylog/log.h源文件在src/log.cpp。log.cpp内部用了第三方库 zlib但log.h里没用到 zlib 的东西。那么 zlib 的 include 路径对mylog是 PRIVATE而include/mylog这个目录对mylog是 PUBLIC因为链接mylog的人必须能看到log.h。2.3 从变量堆到属性传递同一需求两种写法对比同样一个库 可执行文件的需求用两种方式写出来你可以直接感受差别。老式写法include_directories(include) add_library(mylog src/log.cpp) add_executable(myapp main.cpp) target_link_libraries(myapp mylog)现代写法add_library(mylog src/log.cpp) target_include_directories(mylog PUBLIC include) target_include_directories(mylog PRIVATE third_party_include) add_executable(myapp main.cpp) target_link_libraries(myapp PRIVATE mylog)区别在哪儿在现代写法里mylog 需要哪些 include 目录这个信息被绑定在 mylog 自己身上而且是区分了内部实现和外部接口的。当myapp链接mylog时CMake 知道把include加进myapp的编译命令但不会把third_party_include带过去。于是你不用在全局维护一个越来越长的 include 列表每个 target 的编译环境就是它自己该有的样子。我踩过的最深的一个教训是在一个十几个模块的项目里用老式写法管理 include某一天我为了给其中一个模块加一个头文件路径结果另一个毫不相关的模块的编译行为发生了变化。定位了很久才发现是全局 include 路径顺序导致的。从那以后我彻底倒向了 target 属性体系。说白了这个习惯解决的问题不是能不能编译而是构建脚本在项目膨胀之后还具不具备可维护性。3. 从零到能跑一个包含库与可执行文件的完整项目3.1 目录结构先行先看一个的模拟项目代号就叫模拟项目X吧。它包含一个工具库和一个命令行程序结构如下xproject/ ├── CMakeLists.txt ├── libs/ │ └── xcore/ │ ├── CMakeLists.txt │ ├── include/ │ │ └── xcore/ │ │ └── xcore.h │ └── src/ │ └── xcore.cpp └── app/ ├── CMakeLists.txt └── main.cpp把库和可执行文件分到不同子目录每个子目录有自己的CMakeLists.txt顶层用add_subdirectory把它们组合起来。这个分层方式有两点好处第一每个模块的构建细节只写在它自己的文件里不会互相污染第二后续新增模块的时候不用去改动顶层的大文件只要加一行add_subdirectory。3.2 每个CMakeLists.txt具体怎么写顶层 CMakeLists.txtcmake_minimum_required(VERSION 3.20) project(XProject VERSION 1.0.0 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) add_subdirectory(libs/xcore) add_subdirectory(app)cmake_minimum_required这行不是可有可无。CMake 在版本升级时通常保留旧语法但某些旧写法会产生弃用警告指定一个最低版本能帮你规避这类问题。project命令顺便定义项目版本号后面configure_file生成版本头文件时会用到。库模块的 CMakeLists.txtadd_library(xcore STATIC src/xcore.cpp ) target_include_directories(xcore PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include )这里的关键点是xcore对外暴露的头文件在include/xcore/xcore.h所以我把include目录设为 PUBLIC。这样任何链接了xcore的 target 都会自动获得这个头文件搜索路径。如果xcore.cpp内部使用了第三方库就把那个库的路径用 PRIVATE 加进来。应用层 CMakeLists.txtadd_executable(xapp main.cpp) target_link_libraries(xapp PRIVATE xcore)main.cpp里只要#include xcore/xcore.h就能找到头文件完全不用关心xcore的 include 目录到底在哪。这就是 target 属性传递带来的省心之处。3.3 为什么坚持 out-of-source 构建执行构建时我强烈建议在源码目录外建一个 build 目录cd xproject mkdir build cd build cmake .. cmake --build . ./xapp这种做法叫 out-of-source 构建。好处很直观所有 CMake 生成的临时文件、缓存文件、编译产物全在 build 目录里源码目录始终干净你甚至可以直接把源码目录整个拷贝走。想在同一个源码下同时保留 Debug 和 Release 两套构建也很简单建build-debug和build-release两个目录分别配置就行。另外从 CMake 3.20 开始我基本只用cmake --build .而不是直接调用make或ninja。cmake --build会自动识别当前目录用的生成器这个命令里的路径分隔符在 Windows 和 Linux 下的表现也都能正确处理跨平台操作时少了很多心智负担。3.4 单配置生成器与多配置生成器的差异新手最容易懵的是为什么我在 Linux 上执行cmake -DCMAKE_BUILD_TYPERelease ..就切换编译模式但在 Windows Visual Studio 生成器下传这个变量却似乎不起作用。原因在于生成器分两类。Makefile 和 Ninja 是单配置生成器一个 build 目录只绑定一种配置生成时通过-DCMAKE_BUILD_TYPE指定。而 Visual Studio Solution 和 Xcode 是多配置生成器一个解决方案工程里同时包含 Debug 和 Release等到编译时再通过--config选择cmake --build . --config Release所以CMAKE_BUILD_TYPE和--config是配套关系别搞混。如果项目本身只在一种配置下开发用单配置生成器就够了如果可能要在 VS 里开发就注意代码里别写死只在单配置下成立的逻辑。3.5 configure_file生成版本号头文件还有一个实用技巧用configure_file把版本号和编译时间写进头文件让程序能打印出自己是不是开发者本地编译的版本。在xcore模块里加一个模板头文件configure_file( ${CMAKE_CURRENT_SOURCE_DIR}/include/xcore/version.h.in ${CMAKE_CURRENT_BINARY_DIR}/include/xcore/version.h ) target_include_directories(xcore PUBLIC ${CMAKE_CURRENT_BINARY_DIR}/include )version.h.in写#pragma once #define XPROJECT_VERSION_MAJOR PROJECT_VERSION_MAJOR #define XPROJECT_VERSION_MINOR PROJECT_VERSION_MINORCMake 会把PROJECT_VERSION_MAJOR替换成顶层project命令里定义的版本号然后生成真正的头文件到 build 目录。这么做的好处是版本号只有一个来源程序里和安装包里不会出现两套数字。4. 在真实项目里五个我反复踩到的CMake问题4.1 缓存不刷新改了CMakeLists却像没改第一个要说的坑是缓存。CMake 第一次配置时会把结果写进build/CMakeCache.txt后续再执行cmake ..时会优先复用缓存里的值。这意味着你改了CMakeLists.txt里的某个变量重新构建时如果 CMake 认为没有变化它就不重新配置表现出来就是你明明加了新代码构建过程却完全没反应。排查思路是先把所有CMakeLists.txt里的改动都检查一遍确认不是语法错误。然后看缓存文件里对应的变量值是不是你期望的。检查方式cmake -LAH .这个命令会把当前缓存里的变量全部列出来方便比对。真正要强制刷新缓存时要么把 build 目录整个删掉重新配置要么用 CMake 3.24 以上带的--fresh参数cmake --fresh ..我的个人习惯是build 目录本身就是临时产物任何怀疑缓存导致的问题直接删掉 build 目录重新来一遍成本最低。把时间花在纠结为什么没更新上往往比重新构建耗时还多。4.2 find_package找不到库路径前缀与Config模式第二个高频坑是find_package报找不到包。比如find_package(SomeLib REQUIRED)报错信息一堆核心就一句找不到SomeLibConfig.cmake。很多人第一反应是这个库装了没有实际上绝大多数情况是装了但 CMake 没去正确的目录找。CMake 的查找分两种模式Module 模式和 Config 模式。Module 模式靠 CMake 自带的FindSomeLib.cmake脚本去找Config 模式靠包安装时自带的SomeLibConfig.cmake。绝大多数的现代库用的是 Config 模式而这个文件安装后不会放到系统默认搜索路径需要你通过CMAKE_PREFIX_PATH告诉 CMake 去哪里找cmake .. -DCMAKE_PREFIX_PATH/path/to/somelib/prefix这个 prefix 目录里要有lib/cmake/SomeLib/SomeLibConfig.cmake或者类似的目录结构。如果你用某个包管理器安装第三方库通常需要把包管理器提供的 toolchain 文件传给 CMake而不是自己随手 set 路径。在找库之前先看一下库里有没有带现成的*.cmake文件能省下不少试错时间。4.3 空格、中文路径与跨平台转义路径里有空格和中文是跨平台构建里一个隐形的坑。Linux 下的 Makefile 对空格的处理相对宽松Windows 上的 MSBuild 则常常被路径里的空格坑得莫名其妙。CMake 自己会在生成的构建文件里拼接路径如果路径里有空格又想传到编译器的命令行参数里就得处理好引号转义稍不注意就会出现明明路径在这编译器却提示找不到文件的诡异报错。我学到的教训很简单项目的源码目录尽量放在没有空格、没有中文、没有奇怪符号的纯英文路径下。这不是歧视某些语言环境纯粹是构建工具链在做字符串拼接时这类字符的兼容性一直不太理想。你要是非在C:\Users\张三\My Project这种路径下编译大型项目只会把时间消耗在跟转义规则较劲上。4.4 循环依赖与链接顺序当项目模块多了库与库之间可能出现循环依赖。比如libA依赖libBlibB又依赖libA。老式 CMake 遇到这种情况链接阶段通常报一堆 undefined reference。原因在于静态库的链接顺序链接器处理libA.a时发现需要libB.a里的符号就要把libB.a写在后面反过来又需要libA.a。两个库互相咬死链接器就犯难了。习惯用 target 体系后循环依赖仍然可能出现但至少你可以在设计阶段就识别出来。处理循环依赖的常规思路是先想清楚依赖方向对不对很可能是你把本应属于公共基础模块的代码放错了层导致 A 和 B 谁都离不开谁。实在无法避免时可以考虑把两者合并成一个库或者把公共部分抽成第三个库libCommon让 A、B 都依赖它而不是互相依赖。如果只是同一模块内多个库的链接顺序问题Modern CMake 的target_link_libraries在多数情况下会帮你排好顺序不必再手动调链接参数。4.5 换编译器却换不干净最后一个常见问题是编译器切换。项目本来用 GCC 构建A 同学想改用 Clang 试一下于是他在环境变量里改了CC和CXX再执行cmake ..结果发现编译器没变。原因还是缓存。CMAKE_CXX_COMPILER 这个变量在首次配置时就写进了缓存之后cmake ..不会重复探测环境变量。正确做法是先明确指定编译器再配置并且最好用新的 build 目录或先删掉缓存rm -rf build mkdir build cd build cmake .. -DCMAKE_CXX_COMPILERclang一套干净配置从零开始比在各种缓存状态里打补丁靠谱得多。编译器这类核心配置我从不图省事。5. 把CMake用顺手之后值得补上的三个习惯5.1 用Presets给团队定一套标准动作CMake 3.19 之后开始强化 Presets 机制3.21 之后比较成熟。Presets 的用途是把常用的配置参数、构建参数、测试参数固化成 JSON 文件团队成员不用再记一串复杂的-D参数。项目根目录建一个CMakePresets.json{ version: 6, configurePresets: [ { name: dev, generator: Ninja, binaryDir: ${sourceDir}/build/dev, cacheVariables: { CMAKE_BUILD_TYPE: Debug, CMAKE_CXX_STANDARD: 17 } } ], buildPresets: [ { name: dev, configurePreset: dev } ], testPresets: [ { name: dev, configurePreset: dev, output: {outputOnFailure: true} } ] }之后你只需要执行cmake --preset dev cmake --build --preset dev ctest --preset dev团队里所有人的构建动作就完全统一了。相比让每个人手动敲-DCMAKE_BUILD_TYPEDebug -DCMAKE_PREFIX_PATH...Presets 最大的价值是消除了人与人之间的配置漂移。5.2 让CTest接管测试给项目加测试不难难的是让测试成为一种有人经常跑的习惯。在 CMake 里集成 CTest 就是几个命令的事而且 CTest 能自动发现测试程序并把失败信息集中汇总。顶层 CMakeLists.txt 加include(CTest) if(BUILD_TESTING) add_subdirectory(tests) endif()tests 目录里写add_executable(test_xcore test_core.cpp) target_link_libraries(test_xcore PRIVATE xcore) add_test(NAME test_core COMMAND test_xcore)构建完以后执行ctest --test-dir build每个测试的运行状态一目了然。我实际用下来CTest 的回报在于当测试数量达到几十上百个时你不用在终端里手动找哪个程序跑挂了它会在结尾给一棵清晰的失败树还能让你在同一平台下用--repeat until-pass:3排查不稳定用例。这个能力对持续集成的友好度非常高。5.3 通过install/export让库能被别人find_package到最后一个习惯稍微进阶一点让自己的库安装之后可以被其他项目用find_package找到而不是手动拼路径去 include。核心两步第一步用 install(TARGETS) 把 target 安装到指定目录同时导出 target 集合第二步用 CMakePackageConfigHelpers 生成Config.cmake和版本文件。xcore 模块里可以这样写include(CMakePackageConfigHelpers) install(TARGETS xcore EXPORT xcoreTargets ARCHIVE DESTINATION lib LIBRARY DESTINATION lib RUNTIME DESTINATION bin ) install(DIRECTORY include/ DESTINATION include) install(EXPORT xcoreTargets NAMESPACE xproject:: DESTINATION lib/cmake/xcore ) configure_package_config_file( ${CMAKE_CURRENT_SOURCE_DIR}/xcoreConfig.cmake.in ${CMAKE_CURRENT_BINARY_DIR}/xcoreConfig.cmake INSTALL_DESTINATION lib/cmake/xcore ) write_basic_package_version_file( ${CMAKE_CURRENT_BINARY_DIR}/xcoreConfigVersion.cmake VERSION ${PROJECT_VERSION} COMPATIBILITY SameMajorVersion ) install(FILES ${CMAKE_CURRENT_BINARY_DIR}/xcoreConfig.cmake ${CMAKE_CURRENT_BINARY_DIR}/xcoreConfigVersion.cmake DESTINATION lib/cmake/xcore )安装之后另一个项目里就能这样用find_package(xcore CONFIG REQUIRED) target_link_libraries(myapp PRIVATE xproject::xcore)你可能会注意到我给导出 target 加了一个xproject::命名空间。这是个很有用的习惯当项目里出现同名目标时命名空间能避免冲突也能一眼看出它来自哪个模块。养成这个习惯后即使不安装导出在大型项目内部引用模块时也建议用类似的前缀风格。我把 CMake 从不得不用的工具变成新项目的第一个文件转折点其实就是想通了 target 这套思维。你不需要一次性掌握所有命令但只要你写出了第一个有 PUBLIC 属性的 target体验过一次别人链接我的库时 include 路径自动就位的感觉就很难再回到全局变量满天飞的写法了。最后提醒一句CMake 版本差异确实存在团队协作时把最低版本写进cmake_minimum_required好多莫名其妙的跨版本问题都可以避免。