用 CMake 这几年,我发现自己不是离不开某一个构建工具,而是离不开一个能把不同平台、不同工具链差异管理起来的描述层。CMakeLists.txt 就是这层描述。很多人一开始觉得它啰嗦,觉得“不就是编译项目吗,为什么非要写这么个配置文件”。但等文件数量上来、依赖变多、Windows 和 Linux 都要出包的时候,你才会意识到,手写编译命令的方案迟早会失控,而 CMakeLists.txt 这个文件,实际上变成了整个项目的“构建说明书”。
这篇总结我不打算从头抄文档,而是按我自己真实项目里的组织习惯,把 CMakeLists.txt 里最常用的命令、场景模板、坑和排查思路都写下来。适合刚接触 CMake、想快速搭起一个可维护项目的开发者,也适合写过一两份 CMakeLists 但总觉得自己写得不那么“正规”的人。
1. 为什么要用 CMakeLists.txt:从手写编译命令到声明式构建
先说一个很多人会搞错的概念:CMake 本身并不是编译器。你运行cmake命令的时候,它做的事情是“配置 + 生成”,它会读取 CMakeLists.txt 里的描述,根据当前环境的编译器和构建工具,生成对应的构建文件。比如在 Linux 上生成 Makefile,在装了 Ninja 的环境里生成 build.ninja,在 Windows 上生成 Visual Studio 的工程文件。真正把源码编译、汇编、链接成可执行文件或库的,是 make、ninja 或者 IDE 里的编译任务。
为什么要多出这样一层抽象?因为我做过的项目,很少有只在一种平台上构建的。同一个项目,可能同事在 Windows 上用 Visual Studio,自己在 Linux 上用 Ninja,还会有 CI 机器上跑 Makefile。如果没有 CMake 这层翻译,我就必须为每个环境各维护一套构建脚本,那维护成本就完全失控了。所以 CMakeLists.txt 的核心价值是:把项目构建规则描述一次,让 CMake 帮你在不同工具链上生成对应的构建逻辑。
手写编译命令解决的是“我这次怎么编译”,CMakeLists.txt 解决的是“这个项目应该怎么构建”。前者是一次性的操作备忘,后者是一份可复用的项目资产。
1.1 手写编译命令是怎么失控的
回忆一下最简单的场景。最开始只有一个main.cpp:
g++ main.cpp -o app -I./include -L./lib -lfoo这条命令还算清爽。等 src 目录下多了util.cpp、net.cpp,命令会拉长:
g++ main.cpp util.cpp net.cpp -o app -I./include -L./lib -lfoo文件再多一点,有人会开始写 shell 脚本或者 Makefile。但真正的问题从这里才开始。如果一个文件依赖某个第三方库,另一个文件依赖别的库,而这些库在 Linux 和 Windows 上的链接方式完全不一样,那条命令已经没法靠人肉记忆维护了。你还可能遇到这种情况:某天升级了库的版本,头文件安装路径变了,于是又得把所有脚本和命令都改一遍。这中间只要漏掉一处,就会出现一堆莫名其妙的编译错误。
手写编译命令还有一个本质缺陷:它缺少“依赖目标”的概念。你在链接 app 时,得自己把 foo 的 include 目录、库目录、链接库名字全部拼出来,还要自己在不同场景下维护这些信息。而 CMake 更自然的做法是先定义一个 foo 目标,把 include 目录、库文件位置都作为目标的属性,再让 app 通过target_link_libraries引用它。依赖怎么传递、链接项怎么生成,都交给 CMake 去处理。
这也是我把 CMakeLists.txt 理解为“声明式构建描述”的原因。你不需要告诉 CMake“第一步编译 a.cpp,第二步编译 b.cpp”,你只要告诉它“项目里有 app 这个目标,它链接 core 这个库,core 的头文件在 include 目录”。至于具体怎么排编译顺序、怎么跨平台处理,那是 CMake 的事。
1.2 构建系统生成器的工作方式
理清层次之后,最常用的命令就两句:
cmake -S . -B build -G Ninja cmake --build build-S指定源码目录,-B指定构建目录。这个“源码目录和构建目录分离”的习惯要尽早养成。很多人刚开始会把构建产物直接丢进源码目录,一编译,源码树里全是.o文件和可执行文件,版本控制也弄得乱七八糟。单独建一个 build 目录最省心,想要彻底干净就整个删掉重来,成本极低。
等你理解了cmake --build build这步实际是让底层构建工具干活之后,很多问题就有了方向。比如遇到某些奇怪的编译错误,你要先确认是 CMake 配置阶段出的错,还是生成 Makefile 之后的编译阶段出的错,这样去搜问题才会有的放矢。
2. CMakeLists.txt 基础语法,按行拆开讲
光知道概念不够,还得能写。这一节我挑最常用的命令,按照真实项目里的写法,逐个拆开讲背后要注意的细节。
2.1 cmake_minimum_required 为什么要放在第一行
cmake_minimum_required(VERSION 3.16)几乎每一份 CMakeLists.txt 的第一句都是它。它的作用不光是“检查 CMake 版本”,更重要的是指定兼容的策略(policy)行为。CMake 在版本更新过程中,经常会对某些命令的默认行为做调整,为了不让你以前的项目在新版本 CMake 上突然运行方式大变,它会根据你声明的最低版本,选择对应版本的行为规则。
这个细节很隐蔽。如果你不写cmake_minimum_required,新版本 CMake 会直接警告;如果你写了一个很旧的版本号,那一些新命令新写法就不能用。我自己习惯把版本定在不低于 3.16,因为 3.16 之后target_*这组命令已经非常成熟,而且很多流行工具链默认提供的 CMake 版本都高于这个数,兼容性比较好。
2.2 project 命令不只是起个名字
project(myapp VERSION 1.0.0 LANGUAGES C CXX)project会设置一系列有用的变量,最常用的是:
PROJECT_NAME:项目名称PROJECT_VERSION:项目版本号PROJECT_SOURCE_DIR:源码目录的绝对路径PROJECT_BINARY_DIR:构建目录的绝对路径
LANGUAGES参数值得注意。如果你写project(myapp),CMake 会根据默认情况启用一些语言检测,很多时候它会同时考虑 C 和 CXX。但如果你项目只需要 C++,明确写成LANGUAGES CXX更干净,也可以减少在奇怪环境里发生语言探测导致的多余检查。
我见过不少项目把project当作仪式性的一行,没意识到它还负责设置版本号。等到你要用find_package或者生成安装包,需要读版本号时,才发现版本号没有定义。所以建议一开始就把VERSION写上。
2.3 第一个目标:add_executable 与 add_library
CMake 里最重要的概念是“目标”(target)。目标可以是可执行文件,也可以是静态库、动态库,甚至是纯接口头文件库。
add_executable(app main.cpp utils.cpp) add_library(core STATIC core.cpp core.h) add_library(plugin SHARED plugin.cpp)add_executable生成可执行目标,add_library生成库目标。库的类型可以显式写STATIC(静态库)或SHARED(动态库),也可以不写。如果不写类型,在多数默认环境下产生的是静态库,但这依赖BUILD_SHARED_LIBS的设置,工程里容易产生歧义,所以建议每次都写清楚。
注意一点:target 名字在整个项目里是唯一的。你如果在两个子目录里都add_executable(app ...),CMake 会直接报重复的目标名错误。这个限制我在初期踩过,后来习惯把不同模块的目标名都起得足够清晰,避免撞名。
2.4 依赖传递的命脉:target_include_directories 与 target_link_libraries
这是“现代 CMake”风格的精华。你要尽量通过目标来传递依赖信息,而不是使用全局的include_directories和link_directories。
看这个典型组合:
add_library(core STATIC core.cpp core.h) target_include_directories(core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) add_executable(app main.cpp) target_link_libraries(app PRIVATE core)这里最关键的是core的PUBLIC。target_include_directories(core PUBLIC ...)表示“core 自己要用这个头文件目录,而且所有链接 core 的目标也需要自动获得这个目录”。于是app链接core之后,编译main.cpp时自动就能找到core.h,不需要再手动给app加一遍 include 路径。这就是目标依赖的传播能力。
那PRIVATE、PUBLIC、INTERFACE怎么选?我用一个类比:PRIVATE是自己用的,不允许别人用;PUBLIC是自己用,也要保证下游能用;INTERFACE是纯接口,自己不用,只给下游用。
| 可见性 | 当前目标自己 | 依赖当前目标的上游目标 | 典型场景 |
|---|---|---|---|
| PRIVATE | 可用 | 不可见 | 内部实现用的链接库 |
| PUBLIC | 可用 | 可见 | 头文件目录、接口库 |
| INTERFACE | 不用 | 可见 | 纯头文件库、接口目标 |
实际项目里,头文件目录多数情况下写成PUBLIC,因为别人只要 include 了你的头文件,就要能找到对应路径。而在链接第三方实现库时,我通常写PRIVATE,不希望把下游不需要的内部依赖暴露出去。
3. 按场景组织 CMakeLists.txt 的实际模板
理论说再多,不如直接给出一份可以参考的写法。我按自己遇到过的几类项目情况,总结了这些模板。
3.1 单目录小工具,几行搞定
最基础的场景:一个文件夹,几个.cpp,一个可执行文件。
cmake_minimum_required(VERSION 3.16) project(strtool VERSION 0.1.0 LANGUAGES CXX) add_executable(strtool main.cpp utils.cpp) target_compile_features(strtool PRIVATE cxx_std_17)这里补充了target_compile_features(strtool PRIVATE cxx_std_17)。它比直接写set(CMAKE_CXX_STANDARD 17)更精确,因为它是目标级属性,只对strtool生效。如果项目里后续加入一个需要 C++11 的旧模块,不会互相干扰。
这个模板足够支撑一个小工具的开发。你只需要在add_executable后面把源文件列表维护好,其余事情 CMake 会处理。
3.2 多目录模块化项目,用 add_subdirectory 拆分
项目开始变大以后,我不会把几十个源文件全塞进一个 CMakeLists.txt。常见做法是拆成根目录和若干子目录,每个子目录用add_subdirectory引入,并在自己的 CMakeLists.txt 里定义局部目标。
一个常见布局:
project/ CMakeLists.txt src/ CMakeLists.txt core/ core.cpp core.h app/ main.cpp根目录的 CMakeLists.txt:
cmake_minimum_required(VERSION 3.16) project(myapp CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) add_subdirectory(src)src/CMakeLists.txt:
add_subdirectory(core) add_subdirectory(app)core/CMakeLists.txt:
add_library(core STATIC core.cpp core.h) target_include_directories(core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})app/CMakeLists.txt:
add_executable(app main.cpp) target_link_libraries(app PRIVATE core)这样组织的优点是局部清晰:app 只需要关心自己依赖 core,core 只需要关心自己是怎么构建的。你要加一个新模块,就在 src 下建目录并写自己的 CMakeLists.txt,然后在 src/CMakeLists.txt 里加一行add_subdirectory。
关于目录作用域,有一个很重要的规则:子目录会继承父目录在调用add_subdirectory那一刻可见的普通变量,但子目录里用set()修改变量,不会影响父目录的同名变量。也就是说,子 CMakeLists.txt 里改了CMAKE_CXX_STANDARD,父目录不一定跟着变,这完全符合“局部作用域”的直觉。许多配置上的“灵异现象”都来自对这个作用域规则的误解。
3.3 接入第三方库:find_package 的两种模式
外部依赖引入是 CMake 里最让大家头疼的部分。常见的命令是find_package,但它背后有两种工作模式,理解之后排查问题会快很多。
第一种是模块模式(Module Mode)。CMake 会去找一个FindXXX.cmake模块脚本,脚本里通过搜索环境变量、常见安装路径等方式找到库的位置,然后给你设置XXX_INCLUDE_DIRS、XXX_LIBRARIES这类变量。这种模式比较传统,很多老库还在这样用。
第二种是配置模式(Config Mode)。库在安装的时候自己携带了xxx-config.cmake或者xxxConfig.cmake文件,里面直接定义了导入目标(imported target)。这种模式更现代,也是我优先推荐的。因为它把 include 目录、链接库名等属性都封装在目标上了,你不需要自己去拼接变量。
以某个图像处理库为例,新版库通常支持配置模式:
find_package(SomeLib REQUIRED COMPONENTS core imgproc) target_link_libraries(app PRIVATE SomeLib::core SomeLib::imgproc)而旧模块模式可能要求你这样写:
find_package(SomeLib REQUIRED) target_include_directories(app PRIVATE ${SomeLib_INCLUDE_DIRS}) target_link_libraries(app ${SomeLib_LIBRARIES})我自己的经验是:如果提供了导入目标,优先用导入目标;如果只能拿到_INCLUDE_DIRS和_LIBRARIES变量,也至少要把它们挂在目标级,而不是全局include_directories。
这里还有一个常用的配置项:CMAKE_PREFIX_PATH。很多库安装位置不在系统默认搜索路径里,你可以在配置时通过它指定搜索前缀:
cmake -S . -B build -DCMAKE_PREFIX_PATH=/your/local/prefix这样find_package就会优先在这个路径下寻找配置文件。遇到“找不到包”的错误,第一件事不该怀疑自己写错代码,而是确认库里是不是带了 config 文件,以及CMAKE_PREFIX_PATH是否正确指向了它的安装前缀。
3.4 安装规则与导出目标
构建完成后,如果想让别人能直接make install把产物装进系统,需要配置install规则。下面是我常用的写法:
install(TARGETS app core RUNTIME DESTINATION bin LIBRARY DESTINATION lib ARCHIVE DESTINATION lib PUBLIC_HEADER DESTINATION include)这里RUNTIME对应可执行文件和 Windows 下的 DLL,LIBRARY对应 Linux/mac 下的动态库,ARCHIVE对应静态库。简单说,就是告诉 CMake 每种类型的目标分别复制到哪个子目录。
如果项目是一个库,并且你希望别人在另一个 CMake 项目里通过find_package引用它,那就需要install(EXPORT ...)导出目标:
install(TARGETS core EXPORT coreTargets ARCHIVE DESTINATION lib PUBLIC_HEADER DESTINATION include) install(EXPORT coreTargets FILE coreTargets.cmake NAMESPACE core:: DESTINATION lib/cmake/core)这段代码会把core的构建信息导出到一个coreTargets.cmake文件里,下游项目通过某种方式找到它之后,就能直接使用core::core这样的导入目标。导出目标是进阶能力,如果只是做应用项目,暂时理解即可,不用一开始就铺开。
4. 编译选项与多平台构建经验
CMakeLists.txt 的另一块重要内容是编译选项和构建类型的配置。这里头有不少约定俗成,也有不少容易踩的坑。
4.1 构建类型:单配置生成器与多配置生成器
CMake 里有Debug、Release、RelWithDebInfo、MinSizeRel这几种常见构建类型。它们体现在生成器上分成两类:
- 单配置生成器:Makefile、Ninja 这种,一次配置只对应一种构建类型,通过
-DCMAKE_BUILD_TYPE=Release指定。 - 多配置生成器:Visual Studio、Xcode 这种,一次配置可以同时包含多种构建类型,构建时再选哪个配置。
对 Ninja 这类单配置生成器,通常这样用:
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release cmake --build build我个人的习惯是让构建类型由用户通过命令行传入,项目内部不要霸道地写死set(CMAKE_BUILD_TYPE Release)。因为如果你在项目里改了这个变量,用户后面用-DCMAKE_BUILD_TYPE=Debug传入时,可能会因为普通变量和缓存变量的关系,出现“我明明选了 Debug,为什么还是 Release”的困惑。写成默认值可以,但要留出让外部传入的余地。
4.2 按编译器添加警告选项
一个常见需求是把编译警告开起来。但不同编译器的参数风格不同:GCC/Clang 用-Wall -Wextra -Wpedantic,MSVC 用/W4。直接写一套参数,在另一个编译器上会编译失败。
所以更稳妥的做法是分平台设置:
if(MSVC) target_compile_options(app PRIVATE /W4) else() target_compile_options(app PRIVATE -Wall -Wextra -Wpedantic) endif()这里顺便解释一个细节:MSVC 的/Wall和 GCC 的-Wall并不等价。MSVC 的/Wall会开启非常多噪声很大的警告,实际项目里更常用/W4,而/WX表示把警告当错误,要不要开看团队纪律。
4.3 慎用全局修改编译参数的方式
set(CMAKE_CXX_FLAGS "-O3")看起来简单粗暴,但它是把构建类型机制架空的开端。如果你写死-O3,那Debug构建类型也没法低优化;如果你写死-DNDEBUG,断言也会被全局关掉。CMake 的构建类型本身就会根据Release、Debug传递对应的优化和宏选项,尽量少做全局覆盖。
把编译选项挂到目标上,配合target_compile_definitions和target_compile_options,比修改全局CMAKE_CXX_FLAGS更可控:
target_compile_definitions(app PRIVATE FOO_VERSION=1)这样,即使多个目标共存,也不会因为某个目标的选项改动污染其他目标。
5. 常踩的坑和排查方法
这里记录的是我在维护 CMakeLists.txt 过程中遇到过的真实问题,按“症状 → 原因 → 解决”的方式整理。
5.1 缓存问题:配置不生效,改了什么都没反应
CMake 会把配置期间检测到的信息存在CMakeCache.txt里。有时候你改了 CMakeLists.txt,但重新运行cmake --build build时,它没有重新配置,或者某些旧变量还残留在缓存里,表现就像“改了没生效”。
最直接的解决方法是删除构建目录重新生成:
rm -rf build cmake -S . -B build cmake --build build这不是野路子,而是排查 CMake 配置问题最可靠的“重启”手段。很多在构建目录里堆积出来的诡异状态,删掉重来往往就好了。我调试第三方库链接问题的时候,第一件事就是清掉构建目录,从全新状态观察错误是否还稳定复现。
5.2 目录作用域与 cache 变量带来的困惑
普通set()变量只在定义它的目录及其子目录中有效,子目录被调用时会继承父目录当时的变量值,但子目录里的修改不会回传。如果你希望在多个子目录里共享一个配置值,就需要用到 cache 变量。
set(MY_VAR "value" CACHE STRING "description")会把变量写进CMakeCache.txt,并且可以通过命令行-DMY_VAR=value覆盖。还有一种常见做法是使用option():
option(ENABLE_TEST "Build tests" ON)option本质是布尔型的 cache 变量,它能给用户一个明文的开关。项目里要开放某个可配置项时,我习惯用option,这样使用者直接-DENABLE_TEST=OFF就能切换,不需要改源码。
5.3 找不到头文件与链接失败
这一类问题我最常遇到,原因往往很简单:某个库的头文件目录在目标上写成了PRIVATE,而下游目标实际需要它。比如把target_include_directories写成PRIVATE就会造成 app 编译时找不到头文件。解决办法是把它改为PUBLIC,或者干脆让 app 也单独 include 对应目录。
链接失败的原因也可能是静态库顺序问题。Linux 下链接静态库时,库的顺序会影响符号解析。如果 A 库依赖 B 库,而你在链接时把 B 放在 A 前面,链接器可能报告未定义引用。当你手动传.a文件列表时,顺序必须小心。如果你使用 target 的方式链接库,CMake 通常会自动处理好顺序;当你从第三方库手册里抄到一串.a路径时,就要注意“被依赖的库放后面”。
5.4 用 file(GLOB) 收集源文件,新增文件却编不进去
有些项目会这样写:
file(GLOB_RECURSE SRC_FILES "src/*.cpp") add_executable(app ${SRC_FILES})好处是新增 .cpp 文件不用改 CMakeLists.txt。坏处是 CMake 默认不会在每次构建时重新扫描文件系统,你新加了一个文件,重新执行cmake --build build却可能发现它没有被编译进去。
解决思路有两种。一种是不用 GLOB,老老实实把源文件列在add_executable里,项目规模不大时这反而是最稳妥的。另一种是使用CONFIGURE_DEPENDS:
file(GLOB_RECURSE SRC_FILES CONFIGURE_DEPENDS "src/*.cpp")加了CONFIGURE_DEPENDS之后,CMake 会在每次构建前重新检查文件列表。但我个人仍倾向于明确列出源文件,因为隐式收集会让 CMakeLists.txt 的“因果关系”变得不好追踪,尤其是在多人协作时。
5.5 路径中的空格、中文以及大小写问题
如果你把构建目录放在一个带空格的路径下,比如有些用户目录里带姓名空格,部分生成器可能会出现解析错误。这不是每次必现,但等它出现时会非常难排查。我自己的经验是构建目录尽量放在干净、简短、无空格的路径下,例如项目的 build 子目录。
另外,Linux 上文件名是大小写敏感的,而 Windows 通常不敏感。项目里如果头文件写了#include "Core.h",但实际文件叫core.h,在 Windows 上可能碰巧能编译,到 Linux 就会报找不到头文件。这一条对跨平台项目特别值得注意,提交代码前最好在两套环境各构建一次。
6. 个人维护 CMakeLists.txt 的习惯
最后分享几个我在长期项目里验证过的维护习惯。它们不是强制规范,但从减少内耗的角度讲,确实帮我省了很多时间。
第一,目标写法优先于全局写法。项目里所有依赖传递、编译选项尽量挂在 target 上,不要到处用include_directories、add_definitions这些全局命令。这个习惯能让新增模块的成本降低很多。
第二,版本信息写清楚。cmake_minimum_required锁定最低版本,project写明VERSION。这样下游引用你的包时,能做版本判断。版本信息缺失,等到哪天需要做兼容检查,就只能临时翻代码猜。
第三,构建目录不要提交到版本库。源码目录和构建目录分离已经成为我固定习惯,build 目录随时可以删掉重建,这也让我对“清掉重来”这件事毫无心理负担,而“清掉重来”恰恰是排查 CMake 问题最有效的办法。
第四,基础配置要让新成员也能跑通。一个项目如果想让人快速接手,最好是“给出源码,按 README 跑两条命令就能构建”。CMakeLists.txt 写得够清晰,这个目标就自然完成了一半。
写 CMakeLists.txt 本质上不是在背 API,而是在记录构建意图。把目标是什么、依赖谁、怎么装、怎么调,用声明的方式写清楚,让包括三个月之后的自己在内的每一个人,都能顺着文件把项目重新构建起来,这就足够了。后面你遇到的具体需求,无非是在这个基础上继续扩展。