最近在整理个人项目的构建脚本,越整理越觉得CMakeLists.txt值得单独写一篇完整的个人总结。早期我也用过“只要能编译过就行”的写法,把 include 路径、链接库、宏定义全部往全局变量里塞,项目小的时候确实省事,等项目一点点大起来,问题就全冒出来了。这篇总结不是官方文档,更像是我这些年真写过、真编过、真踩过坑之后留下的笔记,适合正在用 CMake 组织 C/C++ 工程、尤其是从单文件往多目录工程迁移的开发者。我会尽量把每条写法的取舍和背后的原因讲清楚,让你拿到手能改能跑,也能在别人问“为什么这么写”的时候讲出个所以然来。
1. 从“能编译”到“有章法”:我整理这份总结的触发点
1.1 早期把所有配置塞进全局变量的问题
我最早写CMakeLists.txt的时候,基本就是三行解决:
cmake_minimum_required(VERSION 3.16) project(MyProject) include_directories(include) link_directories(lib)看起来干净利落,编译也确实没问题。但问题不在“今天能不能编译”,而在“半年后这个工程还维护不维护得动”。include_directories和link_directories是全局作用域,一旦被add_subdirectory引入,子目录里所有目标都会继承这些路径。假如你有两个第三方库,一个需要旧版本头文件,一个需要新版本头文件,全局路径会让其中一个永远先找到错误的头文件,这种 bug 的排查成本非常高。更麻烦的是,全局宏定义add_definitions(-DDEBUG)也会传染到所有目标,而实际上可能只有一个模块需要这个宏。
另一个让我决定改变的习惯是:不指定编译标准。很多项目写着写着就默认 “C++11 应该够用”,结果某个成员函数在不同编译器上的表现完全不一样。后来我在 CMake 层面强制声明标准,才彻底告别这种随机行为。
1.2 从单目录工程到多目录工程的转折
让我下决心认真整理 CMake 写法的转折点,是一个做了比较久的多模块图像处理 Demo。工程结构从“一个 main.cpp 加一堆同目录源文件”变成了:
include/ # 对外头文件 src/core/ # 核心算法模块 src/io/ # 输入输出模块 src/app/ # 主程序 third_party/ # 第三方依赖 build/ # 构建目录这种结构下,如果仍然用全局include_directories,所有子目录都会看到所有模块的头文件,模块边界形同虚设。我明明只想让core内部使用某个私有头文件,结果app也能轻松 include 到,长期下来代码耦合只会越来越严重。CMake 的target_include_directories配合PRIVATE/PUBLIC就是为这种场景设计的:每个模块的可见性可以精确控制,编译一个目标时只暴露它真正需要的东西。
从那时起,我开始把CMakeLists.txt当成工程的一部分来写,而不是随手糊一个“能跑就行”的脚本。这套总结也由此慢慢沉淀出来。
2. 一张可复用的 CMakeLists.txt 骨架:最小工程到分层模块
2.1 开头怎么写才不返工
一份合格的CMakeLists.txt开头,我建议固定包含这几行:
cmake_minimum_required(VERSION 3.16) project(ImageDemo VERSION 0.1.0 DESCRIPTION "An image processing demo for cmake practice" LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF)cmake_minimum_required不只是写个版本号给 CMake 看,它还决定了cmake_policy的默认行为。版本写太低,新版本 CMake 的一些警告被压住,但某些旧行为也会沿用,容易埋坑;版本写太高,团队里其它成员的 CMake 可能不支持。我个人的实践是看清当前环境的基线版本,一般写 3.16 以上比较稳妥。
project命令里的VERSION 0.1.0不是摆设,它会给整个工程生成PROJECT_VERSION和PROJECT_VERSION_MAJOR等变量,后续做安装打包、生成版本头文件时非常有用。LANGUAGES CXX明确告诉 CMake 只需要 C++ 编译器,如果工程里没有 C 代码,就不要留默认的C CXX,这样 configure 阶段能少做一次编译器探测,也能避免某些环境下 C 编译器缺失导致配置失败。
2.2 把源码组织成 target,而不是“全部编译”
面向 CMake 写代码和面向 Makefile 写代码,最大的思维转变是:要把“目标”作为组织单元。一个可执行文件、一个静态库、一个动态库,都是 target。头文件目录、编译选项、链接库、宏定义,都应该挂在 target 上,而不是挂在整个项目上。
拿我常用的模块化结构举例,根目录的CMakeLists.txt可以这么写:
# 根目录 cmake_minimum_required(VERSION 3.16) project(ImageDemo VERSION 0.1.0 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) add_subdirectory(src/core) add_subdirectory(src/io) add_subdirectory(src/app)然后在src/core/CMakeLists.txt里:
add_library(core processor.cpp image_io.cpp ) target_include_directories(core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include ) target_compile_features(core PUBLIC cxx_std_17)再在src/app/CMakeLists.txt里:
add_executable(demo main.cpp) target_link_libraries(demo PRIVATE core io)这个结构的好处非常明显:app链接core时,会自动获得core的PUBLICinclude 路径;而io如果不依赖core的内部头文件,就用PRIVATE把路径限制在自己内部。如果你想让子目录的 target 名字更短更统一,还可以用别名:
add_library(core ALIAS src_core)之后不管在哪层 CMakeLists 里都能用src_core引用。工程层次复杂以后,这个手法能让依赖关系一眼看清。
2.3 编译特性与头文件路径的可见性:PRIVATE 还是 PUBLIC
初学者最常见的困惑就是PRIVATE、PUBLIC、INTERFACE到底怎么选。我自己的判断标准很简单:当前 target 的编译行为会不会把这个要求传给下游目标。
- 如果某个头文件目录只用来编译当前 target 的
.cpp,下游不需要,那用PRIVATE。 - 如果当前 target 的
.h里暴露了该头文件路径(比如公共头文件 include 了第三方库的头文件),那下游也得知道,用PUBLIC。 - 如果当前 target 本身不需要某个头文件,只是给下游用的接口层,比如纯头文件模板库,用
INTERFACE。
一个很实用的INTERFACE场景是定义“功能开关头文件目录”:
add_library(common_headers INTERFACE) target_include_directories(common_headers INTERFACE include) target_compile_definitions(common_headers INTERFACE DEMO_CONFIG_H)这就是一个不产生任何编译产物的“接口库”。任何 target 只要target_link_libraries(demo PRIVATE common_headers),就自动获得它的 include 路径和编译宏。比全局include_directories干净得多,依赖关系也明确得多。
3. 构建目录、生成器与构建配置:外圈参数怎么控制
3.1 为什么要坚持 out-of-source 构建
CMake 最容易被忽略的一个好习惯是:永远不要在源码根目录里直接跑cmake .。所谓 out-of-source 构建,就是把构建产物放到独立的 build 目录里:
cmake -S . -B build cmake --build build-S .指定源码路径,-B build指定构建目录。这样源码目录里不会混入一堆.o、CMakeCache.txt、Makefile等生成文件。好处很实际:切换分支也好,打包源码也好,都不会被构建产物拖累;想要全新构建时,直接删掉 build 目录重来,不会残留任何旧状态。
有人会觉得多打两条命令麻烦,但配合一个 build 脚本或者 IDE 里的配置,这个成本完全可以忽略。一个容易踩的细节是:如果你把 build 目录放在了源码目录内部,比如build/,并且已经跑过一次,之后用git clean -fd清理时可能会误删重要东西,我建议把build/在忽略列表里加上,同时保持“源码目录内不生成任何文件”的原则。
3.2 单配置与多配置生成器的区别
CMake 的构建配置这件事,很多人在换到 Windows 之前都没意识到它有两套模型。以 Makefile 和 Ninja 为代表的生成器,属于“单配置”模型,也就是说你在 configure 阶段就必须选好 Debug 还是 Release:
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release如果之后想换成 Debug,得重新 configure,而不是直接cmake --build加个参数就切过去。而 Visual Studio 和 Xcode 这种多配置生成器,configure 阶段只需要执行一次,构建时用--config再选具体配置:
cmake -S . -B build cmake --build build --config Release这两个模型经常让人困惑。我整理成一张表方便对照:
| 模型 | 代表生成器 | 何时决定配置 | 切换配置的成本 |
|---|---|---|---|
| 单配置 | Unix Makefiles、Ninja | configure 时通过CMAKE_BUILD_TYPE | 重新 configure |
| 多配置 | Visual Studio、Xcode、Ninja Multi-Config | build 时通过--config | 只需重新 build |
在多配置模型下,CMAKE_BUILD_TYPE基本不起作用,因为build目录里可以同时存在 Debug 和 Release 两套产物。而在单配置模型下,-DCMAKE_BUILD_TYPE必须配合编译选项使用,比如-O2、-g等。写CMakeLists.txt时如果你依赖了配置相关变量,要注意这段差异,否则会出现“Windows 上能跑、Linux 上莫名没有优化”之类的怪现象。
3.3 我常用的构建命令组合与清理经验
日常开发最顺手的一套命令是:
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug cmake --build buildNinja 比 Makefile 快,尤其在连续编译时会明显感觉到差异。如果在 Windows 上用 Visual Studio 生成器,我不再需要写-DCMAKE_BUILD_TYPE,直接cmake --build build --config Release即可。如果你是手动敲命令的类型,我强烈建议把“configure 一次 + build 多次”作为纪律:configure 的变量尽量固定,不要频繁改,否则会反复触发全量重新编译。
清理时的判断也很关键。大部分“明明改了代码却感觉没生效”的问题,其实不是 CMakeLists 写错了,而是状态残留。遇到疑似缓存问题时,不要急着改代码,先:
cmake -S . -B build --fresh--fresh是 CMake 3.24 之后才有的选项,它会清空缓存重新 configure。更稳妥的做法就是删掉整个 build 目录。删 build 的成本不算高,但如果你写死了绝对路径、到处引用CMAKE_BINARY_DIR里的东西,重新构建后这些路径一般也能自动重新生成,真正怕的是手写死在代码里的路径。
4. 第三方库接入:find_package 到依赖传递的完整链路
4.1 find_package 查的是什么:Config 模式与 Module 模式
第三方库接入,最常见的操作就是:
find_package(SimCore REQUIRED) target_link_libraries(demo PRIVATE SimCore::SimCore)但很多人并不清楚find_package到底在找什么。CMake 找到第三方库有两种路径:一种是库自己提供了 CMake 配置文件,比如<name>Config.cmake,这种是 Config 模式;另一种是由 CMake 自带的 Find 模块去探测库,比如FindZLIB.cmake,这种是 Module 模式。实际查找时,CMake 会按先后顺序在几个位置搜索,其中最重要的是<PackageName>_DIR变量和CMAKE_PREFIX_PATH前缀路径:
cmake -S . -B build -DCMAKE_PREFIX_PATH=/path/to/sdk如果你的第三方库安装路径比较特殊,CMake 找不到,第一反应应该是去确认CMAKE_PREFIX_PATH是否正确,而不是盲目设置<PackageName>_DIR。从实操角度来说,我见过不少团队把SimCore_DIR这种库专属变量写死在CMakeLists.txt里,结果每个人机器上路径还不一样,互相覆盖,折腾一整天就为了切换环境。正确做法是把这类路径作为外部配置传入,而不是写死在项目脚本里。
另外一个经验点:find_package之后生成的SimCore::SimCore这种带::的 target,比传统变量SimCore_LIBRARIES更推荐。因为 target 自带了 include 路径、编译选项、依赖关系,直接链接它即可传递所有信息,你不需要手动再去include_directories一次。
4.2 PRIVATE/PUBLIC/INTERFACE:链接关系怎么传
现代 CMake 里最应该养成依赖理念的三个关键字,我觉得整个项目只用一个target_link_libraries(demo ...)就够了,不用相信网上那些老博客让你把所有库都link_directories后手写-lxxx的做法。依赖传播的核心规则:
| 关键字 | 当前 target 是否需要 | 下游 target 是否自动获得 | 典型场景 |
|---|---|---|---|
| PRIVATE | 需要 | 不需要 | demo内部用到了某个实现库 |
| PUBLIC | 需要 | 需要 | core的头文件间接暴露了某个库 |
| INTERFACE | 不需要 | 需要 | 纯头文件接口库,或让下游强制启用某些选项 |
举个例子:core是一个静态库,内部实现用了某个算法库SimCore,但core的公共头文件里完全没提到SimCore,那链接关系应该是:
target_link_libraries(core PRIVATE SimCore::SimCore)如果core的某个 public 头文件里定义了std::vector由SimCore提供类型的成员,那么下游目标也必须能搜到SimCore的头文件,这时就该用PUBLIC。依赖传错了方向,轻则下游编译报“找不到头文件”,重则出现相同的符号被链接多次。这类问题排查起来非常靠运气,所以我在项目里会刻意维护一张“依赖方向”的心智图,每个模块对外暴露什么,内部用什么都写清楚。
顺带提一个实践经验:如果发现某个全局用的库被十几个 target 链接,怀疑是不是应该把SimCore::SimCore改成这些 target 的公共依赖时,可以引入一个common_depsinterface 库,把所有公共依赖“打包”进去。这样既不污染全局,又方便统一管理。
4.3 只有 .pc 文件或手工库的替代方案
并不是所有第三方库都主动提供*Config.cmake。有些老库只提供libxxx.pc,或者干脆只有一坨.h和.a文件扔在一个目录里。这也很常见,我遇到过不止一次。
遇到只有.pc文件的库,可以用 CMake 自带的 PkgConfig 模块:
find_package(PkgConfig REQUIRED) pkg_check_modules(MY_LIB REQUIRED lib-utils) target_include_directories(demo PRIVATE ${MY_LIB_INCLUDE_DIRS}) target_link_libraries(demo PRIVATE ${MY_LIB_LIBRARIES})注意这里pkg_check_modules生成的变量是普通字符串列表,它不会自动传递 include 路径,所以你得自己把它们接到 target 上。简单粗暴但有边界感。
手工库更直接:如果只有几个头文件和静态库文件,我会把它们包成一个接口库加静态库的组合:
add_library(inline_utils INTERFACE) target_include_directories(inline_utils INTERFACE third_party/inline_utils/include) add_library(old_static STATIC third_party/old_static/old.c) target_include_directories(old_static PUBLIC third_party/old_static/include) target_link_libraries(demo PRIVATE inline_utils old_static)这样做的好处是,不管第三方库的形式多原始,你的业务代码只需要target_link_libraries(demo PRIVATE 某某),不需要关心它到底在哪、包含路径在哪。以后万一上游更新成正式 CMake 包,替换的成本也很低。
5. 跨平台差异与路径处理:一份脚本同时伺候不同系统
5.1 平台与编译器判断的两种写法
同一个CMakeLists.txt要同时跑在 Windows 和 Linux 上,平台判断是绕不开的。最常见的两种姿势是:
if(WIN32) target_compile_definitions(demo PRIVATE DEMO_WIN32=1) elseif(UNIX AND NOT APPLE) target_compile_definitions(demo PRIVATE DEMO_LINUX=1) endif()以及用生成器表达式:
target_compile_definitions(demo PRIVATE $<$<PLATFORM_ID:Windows>:DEMO_WIN32=1> $<$<PLATFORM_ID:Linux>:DEMO_LINUX=1> )两种写法都正确,区别在于:if()在 configure 阶段生效,一旦选定了平台,所有 target 都会统一拿到宏;生成器表达式则是在生成阶段求值,可以更精细地控制“哪个配置下给哪个 target 加什么宏”。如果你的条件只跟平台有关,用if(WIN32)就够了;如果还要叠加 Debug/Release 之类的配置维度,生成器表达式更合适。
与之配套的是编译器判断。比如 MSVC 和 GCC 在警告选项上差别很大:
target_compile_options(demo PRIVATE $<$<CXX_COMPILER_ID:MSVC>:/W4> $<$<CXX_COMPILER_ID:GNU>:-Wall -Wextra> )这比在注释里写“Windows 用户请手动打开 /W4”靠谱得多。写的时候还要注意:/W4前不能忘了加逗号分隔,否则 MSVC 会把后面的选项当成文件名。
5.2 路径分隔符、输出目录和绝对路径的三个注意点
跨平台上最容易炸的就是路径。Windows 用反斜杠,Linux 用正斜杠,但 CMake 里统一接受正斜杠/,所以写路径时尽量用/,避免在字符串里混入转义反斜杠。比如:
target_include_directories(demo PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include)比手写D:/src/include强一万倍。养成使用${CMAKE_CURRENT_SOURCE_DIR}的相对路径习惯后,整个源码树不管放到哪台机器,configure 出来的路径都能自动适配。
输出目录方面,如果希望把可执行文件和库统一放到指定目录,常见的做法是:
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin) set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib) set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib)但在多配置生成器下,Win 平台会自动追加 Debug/Release 子目录,所以如果你想要所有配置都输出到同一个目录,还需要分别设置带配置名的变量,比如CMAKE_RUNTIME_OUTPUT_DIRECTORY_DEBUG、CMAKE_RUNTIME_OUTPUT_DIRECTORY_RELEASE。这也是我最初没注意到的坑之一。
关于绝对路径,我的原则是:构建脚本里不要硬编码任何机器相关的绝对路径,也不要直接往头文件里#include带盘符的路径。一旦有这种硬编码,换台机器就得改代码,跨平台就成了空话。所有外部路径尽量通过find_package或命令行-D参数注入。
5.3 条件编译的生成器表达式:让配置更精细
生成器表达式是 CMake 里非常强大的语法,它用$<...>在生成阶段求值,支持各种条件组合。常用例子除了平台判断,还有配置判断:
target_compile_definitions(demo PRIVATE $<$<CONFIG:Debug>:DEMO_DEBUG_LOG=1> ) target_compile_options(demo PRIVATE $<$<AND:$<CONFIG:Release>,$<CXX_COMPILER_ID:MSVC>>:/O2> )生成器表达式不仅能用在target_compile_definitions,还能用在target_include_directories、target_link_options等几乎任何 target 级别的命令里。很多看起来需要拆好几个if才能实现的条件逻辑,用生成器表达式一行就能搞定。
不过我也要提醒一句:生成器表达式可读性一般,嵌套多了非常难调。我的习惯是在简单条件下直接if,只有跨维度组合时才用生成器表达式,并且务必加注释。毕竟一份脚本要能被半年后的自己一眼看懂,比“显得很高级”重要得多。
6. 高频踩坑现场:三次真实排查的复盘
6.1 静态库链接顺序导致的“未定义引用”
我在某个模拟项目里遇到过一次非常经典的链接错误:demo同时链接了静态库A和B,B的某些符号依赖A,结果链接器报 undefined reference。当时第一反应是去查代码,忙活了半天才发现是链接顺序问题。
GNU 链接器在扫描静态库时,是从左到右逐个处理的。如果demo先链接了B,再链接A,而B中引用的符号在A中,那么扫描B时链接器还不知道A里有什么,等到扫描A时又已经没有机会回填了,于是直接报错。这跟 CMake 关系不大,但 CMake 的写法会影响最终链接命令。直接在target_link_libraries里写A B很容易让顺序违背依赖关系。
正确做法是:把这种依赖关系交给 CMake 管理。比如B本来就需要A,就写成:
add_library(A STATIC ...) add_library(B STATIC ...) target_link_libraries(B PUBLIC A) target_link_libraries(demo PRIVATE B)这样 CMake 生成链接命令时会把A自动附加到demo后面,顺序也是对的。这个坑的教训一句话概括:别手动拼链接顺序,要相信 target 之间的依赖声明。
6.2 改了 CMakeLists 却是“旧版本”在编译的假象
还有一次,我改了CMakeLists.txt里某个源文件的路径,把src/old/file.cpp替换成了src/new/file.cpp。重新cmake --build时编译输出里居然还是src/old/file.cpp,而且更诡异的是那个文件已经从我磁盘上删除了,编译却提示“系统找不到路径”。我第一反应是 CMake 没有重新生成,但实际上 CMake 检测到CMakeLists.txt变化后会自动重新 running,问题不在 regenerate。
后来排查发现根因是:我把add_executable里的源文件用了一个缓存变量保存,而这个缓存在我第一次 configure 时已经写入了CMakeCache.txt。因为我大部分时间都是直接cmake --build,没有重新 configure 刷新缓存,所以源文件列表一直是旧值。
那次之后我养成了一个习惯:涉及缓存变量的修改,要么手动触发一次 configure,要么干脆删掉 build 目录重新来。靠cmake --build自动侦测文件变更虽然省事,但并不是所有变量都会被主动刷新,尤其是set(... CACHE FILEPATH ...)这种带缓存的写法。如果项目里你确实需要缓存变量,务必记住它的生效时机是 configure 阶段。
6.3 Windows DLL 导出符号缺失
最后一个高频坑和 Windows 动态库有关。我在 Windows 上把某个模块编译成 DLL 后发现,外部程序链接时怎么都找不到导出函数。代码里明明定义了__declspec(dllexport)的函数,理论上应该有导出,但实际上生成的.dll里没有符号。
原因在于 CMake 默认情况下,add_library(<name> SHARED ...)并不会自动导出所有符号,除非你显式声明WINDOWS_EXPORT_ALL_SYMBOLS ON,否则 MSVC 下只有带__declspec(dllexport)才导出,GCC 类工具链下可能需要__declspec(dllexport)或.def文件。我当时的工程里的确写了导出宏,但只在头文件里写了,源文件里的类实现没有正确引入这个宏,导致实际编译时符号没有被标记导出。
排查时可以打开:
cmake --build build --verbose看链接命令和是否有dllexport相关参数。也可以直接设置:
set(CMAKE_WINDOWS_EXPORT_ALL_SYMBOLS ON)让 CMake 帮我们处理一部分符号导出,但这属于临时手段,根治还得靠规范的导出宏。我给这个教训总结的规律是:跨平台开发里,不要想当然认为 Windows 和 Linux 的符号可见性规则一样。Linux 默认导出全部符号,Windows 默认只导出显式标记的符号,这是最基础也最容易忽略的差异。
7. 如果只留下一个习惯,我会保留什么
这篇总结写到这里,核心内容基本都覆盖了。如果让我从里面挑一个最值得养成的习惯,我会选“从 target 出发思考所有配置”:头文件路径挂到 target 上、编译选项挂到 target 上、依赖库挂到 target 上、宏定义也挂到 target 上。全局变量那种写法在极小型项目里确实省事,但只要工程开始拆目录、引入第三方库、跨平台编译,target 级配置带来的边界感会让你少踩无数坑。
最后再分享一个小技巧:我给工程写了一个非常薄的脚本统一封装 configure 和 build,日常开发几乎不用手敲长参数,但每次调试缓存问题或者生成器问题时,我都会立刻绕开脚本手动执行 CMake 命令,直接看原始输出。很多诊断线索就藏在 configure 阶段那几行提示里,可惜很多人从没认真看过。希望这篇总结能让你在下次修改CMakeLists.txt时,心里多一份透彻。