CMake 并不是一个"脚本语言 + 编译器开关"的简单组合,而是一套以 target(目标) 和 property(属性) 为核心的构建系统生成器。很多项目之所以越写越乱,是因为把 CMake 当成"设置一堆全局变量"的批处理脚本;而现代 CMake 的正确心智模型是:先有 target,再围绕 target 配置头文件路径、编译选项、宏定义、链接依赖和链接器参数。
下面不按字典顺序罗列"CMake 全部关键字"(那会是上百个命令的长清单),而是按工程里真正高频、且容易混淆的命令分组讲清楚:变量与流程、项目与目录组织、目标定义、target 系列、查找依赖、链接与底层控制、以及应该尽量避免的全局命令。
一、变量、流程与控制:CMake 的"语言层"
1. set:不是赋值这么简单
set是 CMake 里最基础也最容易被误用的命令。它不仅可以设普通变量,还能设缓存变量、环境变量、父作用域变量。
set(SRC main.cpp util.cpp)
set(CMAKE_CXX_STANDARD 20)
set(MY_OPTION ON CACHE BOOL "enable my feature")
set(var2 ${var1} PARENT_SCOPE)
普通 set只在当前目录作用域及子作用域中可见;CACHE会把变量写进 CMakeCache.txt,可在命令行用 -D覆盖;PARENT_SCOPE则把值传回调用者目录。现代工程里,普通变量用来组织源文件列表没问题,但不要拿 set(CMAKE_CXX_FLAGS "...")当主要配置手段------它会污染所有目标。
2. option:用户开关的第一公民
option(BUILD_TESTS "Build unit tests" ON)
option(USE_SYSTEM_FMT "Use system libfmt instead of vendored" OFF)
option本质是带帮助的缓存布尔变量,适合暴露"是否构建测试""是否开启断言""是否使用系统依赖"这类用户级开关。option和 set(... CACHE BOOL ...)的区别在于:option更简单、语义更清晰,不适合设置字符串或路径型缓存变量。
3. if / elseif / else / endif、foreach、while
CMake 有完整控制流,但写法像老式宏语言:
if(BUILD_TESTS)
add_subdirectory(tests)
endif()
foreach(src ${SRC_LIST})
message(STATUS "source: ${src}")
endforeach()
需要注意:CMake 的 if()里写变量名,不写 ${}也能展开,但新手常踩坑。条件里判断目标、变量、缓存变量、策略是否存在,规则并不完全统一,因此工程里应尽量让条件简单可读。
4. message:调试 CMake 的唯一正道
message(STATUS "CXX standard: ${CMAKE_CXX_STANDARD}")
message(WARNING "fmt not found, falling back to bundled version")
message(FATAL_ERROR "Qt6::Core is required")
STATUS是普通信息,WARNING会黄字提醒,FATAL_ERROR直接中断配置。复杂项目里别靠猜,多用 message打印路径、组件名、导入目标名。
5. list、string、file
这三个是 CMake 的"标准库":
-
list(APPEND SRC a.cpp b.cpp):维护源文件列表 -
string(APPEND X "abc")、string(REGEX REPLACE ...):字符串处理 -
file(GLOB ...):扫文件,但不推荐用来替代显式源文件列表(增删文件不触发重新配置) -
file(READ/WRITE/COPY ...):配置文件、拷贝资源
经验法则:源文件尽量显式列出 ,file(GLOB)只用于生成代码、资源清单、自动扫描工具链场景。
二、项目与目录组织:把大项目拆开
1. cmake_minimum_required与 project
cmake_minimum_required(VERSION 3.18)
project(MyApp VERSION 1.4.0 LANGUAGES CXX C)
cmake_minimum_required决定可用语法和策略;project()会设置 PROJECT_NAME、PROJECT_VERSION、CMAKE_PROJECT_NAME等变量,并可启用 C/CXX/ASM/Fortran 等语言。大项目通常在最顶层调用一次 project,子目录不再重新 project(除非做超级构建或独立子工程)。
2. add_subdirectory:单仓库多模块的骨架
add_subdirectory(src/core)
add_subdirectory(src/ui)
add_subdirectory(src/app)
add_subdirectory(tests)
它告诉 CMake:"进入这个子目录,执行里面的 CMakeLists.txt"。大型 Qt6 / C++ 项目基本都靠它拼装:core、net、ui、plugins、tools、tests各自一个目录,各自声明自己的 target,顶层只做编排。
3. include与 include_guard
include(cmake/MyAppHelpers.cmake)
include用来加载宏、函数、公共逻辑。include_guard()防止同一个 cmake 片段被重复包含,类似 C/C++ 的头文件保护宏。写公共宏/函数库时几乎必用。
4. function与 macro
function(add_my_test name)
add_executable(${name} ${name}.cpp)
target_link_libraries(${name} PRIVATE Catch2::Catch2WithMain)
add_test(NAME ${name} COMMAND ${name})
endfunction()
function有自己作用域,参数按值传入,推荐;macro更像文本替换,变量在调用者作用域里生效,容易引发副作用,非必要不用。
5. configure_file:把 CMake 变量写进头文件
configure_file(
${CMAKE_CURRENT_SOURCE_DIR}/config.h.in
${CMAKE_CURRENT_BINARY_DIR}/config.h
)
适合生成版本号、开启特性宏、写入安装路径。生成的文件通常在二进制目录里,再通过 target_include_directories暴露给源码。
三、目标定义:一切 target_* 的前提
1. add_executable
add_executable(MyApp main.cpp mainwindow.cpp)
qt_add_executable(MyApp main.cpp) # Qt6 推荐变体
创建一个可执行文件目标。Qt6 项目里优先用 qt_add_executable、qt_add_qml_module等 Qt 扩展命令,它们会自动处理 MOC/UIC/RCC/QML 等资源逻辑。
2. add_library
add_library(core STATIC core.cpp engine.cpp)
add_library(net SHARED net.cpp)
add_library(plugin_a MODULE plugina.cpp)
add_library(fmt::fmt ALIAS fmt) # 别名目标
库类型包括:
-
STATIC:静态库 -
SHARED:动态库 -
MODULE:插件,不被链接,只被运行时加载(QPluginLoader 场景很常见) -
INTERFACE:不编译任何源文件,只传递头文件和编译属性(头文件库、接口层必备) -
OBJECT:只生成 .o/.obj,不直接成库
现代工程里,库不是"一堆 cpp 的容器",而是一个带使用需求的依赖节点。
3. add_custom_target与 add_custom_command
add_custom_command(
OUTPUT generated.cpp
COMMAND python3 ${CMAKE_CURRENT_SOURCE_DIR}/gen.py
DEPENDS gen.py schema.json
)
add_custom_target(run_clang_tidy
COMMAND clang-tidy ...
COMMENT "Running clang-tidy"
)
-
add_custom_command:生成某个文件,参与构建依赖图 -
add_custom_target:不生成文件,作为一个"总是过期的动作"存在,比如代码生成、lint、打包前处理、文档生成
大项目里代码生成、protobuf/thrift/idl、QML 类型注册经常靠这两个命令落地。
4. target_sources:给已有目标追加源文件
target_sources(core
PRIVATE
impl/foo.cpp
impl/bar.cpp
)
比在 add_library里一次性列完所有 cpp 更灵活,特别适合子目录往父模块"补源文件",也适合条件编译时按平台追加文件。
四、target_* 系列:现代 CMake 的核心
这一组命令的共同特点是:只作用于某个 target,并通过 PRIVATE/PUBLIC/INTERFACE 控制依赖传播。
1. target_include_directories
target_include_directories(core
PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}/include
PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/src
)
含义:
-
PUBLIC:core 自己要用,链接 core 的目标也要用 -
PRIVATE:只有 core 编译时用 -
INTERFACE:core 自己不编译用这些路径,但消费者要用(头文件库典型用法)
它替代了老式 include_directories()。SYSTEM关键字还能告诉编译器"这是第三方头文件,别给我报警告":
target_include_directories(app SYSTEM PRIVATE third_party/spdlog/include)
2. target_compile_definitions
target_compile_definitions(core
PRIVATE
BUILD_CORE_LIB
PUBLIC
MYAPP_VERSION_MAJOR=1
)
等价于给编译器加 -DMACRO。PRIVATE宏只在实现里用,PUBLIC宏会传给消费者,比如开启某个 API 特性。
3. target_compile_options
target_compile_options(core
PRIVATE
$<$<CXX_COMPILER_ID:GNU>:-Wall -Wextra>
$<$<CXX_COMPILER_ID:MSVC>:/W4>
)
用来加 -O2、-Wall、/W4、-fno-rtti这类编译选项。这里已经能看到现代 CMake 的杀手锏:生成器表达式(generator expressions) 。它允许"不同编译器、不同构建类型、不同平台"使用不同选项,而不靠一堆 if。
4. target_compile_features
target_compile_features(core PUBLIC cxx_std_17)
这是比 set(CMAKE_CXX_STANDARD 17)更现代的做法。它表达"用 C++17 的特性",并由 CMake 推导标准版本。库对外暴露 cxx_std_17/ cxx_std_20,消费者自动获得对应标准,不会被人偷偷改回 C++98。
5. target_link_libraries:依赖关系的枢纽
target_link_libraries(MyApp
PRIVATE
core
net
Qt6::Widgets
Qt6::Quick
PUBLIC
api_types
INTERFACE
header_only_util
)
它可以接:
-
项目内 target:
core、net -
导入 target:
Qt6::Core、Boost::system、OpenSSL::SSL -
全路径库文件:
/usr/lib/libfoo.so -
裸库名:
-lfoo -
链接标志:以
-开头的内容
关键是传播语义:
-
PRIVATE:我只用,不传给孩子 -
PUBLIC:我用,孩子也用(比如核心类型库、公共 API 依赖) -
INTERFACE:我自己不用,但孩子必须用(头文件库、概念层依赖)
一个大项目里 80% 的"为什么找不到头文件 / 为什么没链到库 / 为什么宏没定义",都和这里写错 PRIVATE/PUBLIC 有关。
6. target_link_options
target_link_options(MyApp
PRIVATE
$<$<CXX_COMPILER_ID:MSVC>:/DEBUG>
$<$<PLATFORM_ID:Linux>:-Wl,--no-undefined>
)
它加的是链接器参数,不是库名。比如:
-
Windows:
/DEBUG、/LTCG -
Linux:
-Wl,--as-needed、-Wl,-rpath,$ORIGIN/lib -
macOS:
-Wl,-dead_strip
不要把它当 target_link_libraries用;库依赖用后者,纯链接器开关才用前者。CMake 3.13+ 才稳定支持得比较好。
7. target_link_directories
target_link_directories(app PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/libs)
告诉链接器去哪里找库。听起来有用,但现代 CMake 里通常不推荐 。如果你用 find_package拿到导入目标,或用 target_link_libraries(app PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/libs/libfoo.a),根本不需要手动加链接目录。滥用它会让构建不可重现、跨平台痛苦。
8. target_precompile_headers
target_precompile_headers(core PRIVATE pch.h)
为目标生成预编译头,加快大型项目编译。适合把 <QtWidgets>、<vector>、<memory>、项目公共头塞进 PCH。但要注意 PCH 会放大"头文件改动导致大面积重编"的风险,不宜乱加。
五、查找依赖:从系统里把库"接进来"
1. find_package:现代依赖入口
find_package(Qt6 REQUIRED COMPONENTS Core Gui Widgets Quick)
find_package(Boost REQUIRED COMPONENTS system filesystem)
find_package(OpenSSL REQUIRED)
它有两种模式:
-
Module 模式 :CMake 自带
FindXXX.cmake,适合老库(Boost、OpenGL、ZLIB) -
Config 模式 :库自己安装了
XXXConfig.cmake/XXXConfigVersion.cmake,适合现代库(Qt6、fmt、spdlog、protobuf、abseil)
成功后会提供导入目标:
target_link_libraries(app PRIVATE Qt6::Widgets OpenSSL::SSL Boost::system)
REQUIRED表示找不到就终止;COMPONENTS表示要哪些子模块;CONFIG可强制走 Config 模式;NO_SYSTEM_ENVIRONMENT_PATH等参数用于控制搜索范围。
大项目里 find_package是"消费系统/包管理器依赖"的主入口,和 FetchContent(把依赖下载进构建树)是两套思路。
2. find_library
find_library(MYLIB_LIBRARY
NAMES mylib libmylib
PATHS /usr/local/lib
${CMAKE_SOURCE_DIR}/third_party/mylib/lib
)
当你要找的库没有 CMake 配置文件、也没有 Find 模块时,就用它定位 .so/.a/.dll/.lib。找到后通常再包一层导入目标:
add_library(mylib UNKNOWN IMPORTED)
set_target_properties(mylib PROPERTIES
IMPORTED_LOCATION ${MYLIB_LIBRARY}
INTERFACE_INCLUDE_DIRECTORIES ${MYLIB_INCLUDE}
)
比直接把路径写进 target_link_libraries更干净,也更容易复用。
3. find_path与 find_program
find_path(ZLIB_INCLUDE_DIR zlib.h)
find_program(PYTHON_EXECUTABLE NAMES python3 python)
-
find_path:找头文件所在目录 -
find_program:找可执行文件(python、protoc、clang-format、ninja)
它们和 find_library是同一类"底层查找原语",一般只有在写自己的 FindFoo.cmake时才频繁用。
六、安装、测试、导出:工程化闭环
1. install
install(TARGETS MyApp core
RUNTIME DESTINATION bin
LIBRARY DESTINATION lib
ARCHIVE DESTINATION lib
)
install(DIRECTORY include/ DESTINATION include)
install()决定 cmake --install时哪些东西进安装目录。大项目要做 SDK、要发头文件、要发 cmake 配置,就离不开它。
2. export与 install(EXPORT)
install(EXPORT MyAppTargets
FILE MyAppTargets.cmake
NAMESPACE MyApp::
DESTINATION lib/cmake/MyApp
)
这会让你的库"像 Qt6 / fmt 一样"被别人用 find_package(MyApp)消费。中大型库项目迟早要学:只发 .so不够,还得发导入目标。
3. enable_testing与 add_test
enable_testing()
add_test(NAME core_test COMMAND core_test)
配合 CTest 使用。add_test不等于"编译测试",它注册一个可执行测试项,ctest负责跑。
七、老式全局命令:知道就行,别再主用
以下命令不是不能用,而是会污染后续所有目标,在大型项目里很难维护:
-
include_directories():全局头文件路径 -
link_directories():全局链接路径 -
link_libraries():全局链接库 -
add_definitions():全局宏 -
add_compile_options():全局编译选项 -
set(CMAKE_CXX_FLAGS "..."):全局编译器开关
它们的问题在于:A 模块用的警告级别、头文件路径、宏定义,会悄悄渗给 B 模块。项目一大,就会出现"为什么这个文件突然多了一堆 -Werror""为什么 app 居然链了插件专用库"。
现代写法永远是:
target_include_directories(x PRIVATE ...)
target_compile_options(x PRIVATE ...)
target_compile_definitions(x PRIVATE ...)
target_link_libraries(x PRIVATE ...)
八、一张速查表
| 目的 | 老式写法 | 现代写法 |
|---|---|---|
| 头文件路径 | include_directories() |
target_include_directories(tgt PUBLIC/PRIVATE ...) |
| 编译选项 | add_compile_options() |
target_compile_options(tgt PRIVATE ...) |
| 宏定义 | add_definitions(-DX) |
target_compile_definitions(tgt PRIVATE X) |
| 链接库 | link_libraries(foo) |
target_link_libraries(tgt PRIVATE foo) |
| 链接目录 | link_directories() |
尽量用导入目标 / 全路径库 |
| C++ 标准 | set(CMAKE_CXX_STANDARD 17) |
target_compile_features(tgt PUBLIC cxx_std_17) |
| 找包 | 手填路径 | find_package(Qt6 REQUIRED COMPONENTS ...) |
| 找库文件 | 写死路径 | find_library()+ IMPORTED target |
九、总结
不要把 CMake 记成"命令清单",而要建立三层模型:
-
语言层 :
set、if、function、foreach、message、list、file------用来组织逻辑。 -
结构层 :
project、add_subdirectory、include、option、configure_file------用来组织项目和目录。 -
目标层 :
add_executable、add_library、target_include_directories、target_compile_options、target_link_libraries、target_link_options、find_package------用来描述"这个目标怎么编译、怎么被消费、依赖谁"。
大项目写得好不好,不看你会不会用冷门命令,而看你是否做到三件事:依赖通过 target 传递、可见性用 PUBLIC/PRIVATE/INTERFACE 显式声明、第三方依赖通过 find_package / FetchContent 接入而不是全局变量硬塞。