CMake 常用关键字与命令全景:从 set到 target_link_options的现代工程视角

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本质是带帮助的缓存布尔变量,适合暴露"是否构建测试""是否开启断言""是否使用系统依赖"这类用户级开关。optionset(... CACHE BOOL ...)的区别在于:option更简单、语义更清晰,不适合设置字符串或路径型缓存变量。

3. if / elseif / else / endifforeachwhile

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. liststringfile

这三个是 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_requiredproject

复制代码
cmake_minimum_required(VERSION 3.18)
project(MyApp VERSION 1.4.0 LANGUAGES CXX C)

cmake_minimum_required决定可用语法和策略;project()会设置 PROJECT_NAMEPROJECT_VERSIONCMAKE_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++ 项目基本都靠它拼装:corenetuipluginstoolstests各自一个目录,各自声明自己的 target,顶层只做编排。

3. includeinclude_guard

复制代码
include(cmake/MyAppHelpers.cmake)

include用来加载宏、函数、公共逻辑。include_guard()防止同一个 cmake 片段被重复包含,类似 C/C++ 的头文件保护宏。写公共宏/函数库时几乎必用。

4. functionmacro

复制代码
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_executableqt_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_targetadd_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
)

等价于给编译器加 -DMACROPRIVATE宏只在实现里用,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。

复制代码
target_link_libraries(MyApp
    PRIVATE
        core
        net
        Qt6::Widgets
        Qt6::Quick
    PUBLIC
        api_types
    INTERFACE
        header_only_util
)

它可以接:

  • 项目内 target:corenet

  • 导入 target:Qt6::CoreBoost::systemOpenSSL::SSL

  • 全路径库文件:/usr/lib/libfoo.so

  • 裸库名:-lfoo

  • 链接标志:以 -开头的内容

关键是传播语义

  • PRIVATE:我只用,不传给孩子

  • PUBLIC:我用,孩子也用(比如核心类型库、公共 API 依赖)

  • INTERFACE:我自己不用,但孩子必须用(头文件库、概念层依赖)

一个大项目里 80% 的"为什么找不到头文件 / 为什么没链到库 / 为什么宏没定义",都和这里写错 PRIVATE/PUBLIC 有关。

复制代码
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+ 才稳定支持得比较好。

复制代码
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_pathfind_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. exportinstall(EXPORT)

复制代码
install(EXPORT MyAppTargets
    FILE MyAppTargets.cmake
    NAMESPACE MyApp::
    DESTINATION lib/cmake/MyApp
)

这会让你的库"像 Qt6 / fmt 一样"被别人用 find_package(MyApp)消费。中大型库项目迟早要学:只发 .so不够,还得发导入目标。

3. enable_testingadd_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 记成"命令清单",而要建立三层模型:

  1. 语言层setiffunctionforeachmessagelistfile------用来组织逻辑。

  2. 结构层projectadd_subdirectoryincludeoptionconfigure_file------用来组织项目和目录。

  3. 目标层add_executableadd_librarytarget_include_directoriestarget_compile_optionstarget_link_librariestarget_link_optionsfind_package------用来描述"这个目标怎么编译、怎么被消费、依赖谁"。

大项目写得好不好,不看你会不会用冷门命令,而看你是否做到三件事:依赖通过 target 传递、可见性用 PUBLIC/PRIVATE/INTERFACE 显式声明、第三方依赖通过 find_package / FetchContent 接入而不是全局变量硬塞

相关推荐
码匠许师傅2 小时前
【C++三方组件】cxxopts:轻量首选的命令行解析
c++
hansang_IR2 小时前
【讲解】CSP-S2026 第一轮初赛
c++·算法
Methy2 小时前
IoTHub半个月崩了五次?都是裸rethrow惹的祸
服务器·c++·后端
我不会起名字3222 小时前
一天一道力扣Hot100(36):深度优先算法---组合总和
数据结构·c++·后端·python·算法·go
纪念 2293 小时前
c++类和对象(四)
开发语言·c++
汉克老师3 小时前
GESP2026年9月认证C++六级( 第三部分编程题(2、分树规划))精讲
c++·gesp·小学生·学c++编程
学生小羊3 小时前
C++ 初阶 学习博客
c语言·c++·c++与c语言的区别·c++基础学习
辛苦才能3 小时前
C++多态原理:虚函数表的内存布局与动态绑定的汇编真相
开发语言·c++
西西弗Sisyphus4 小时前
Qt 实现一个 水波进度球
c++·qt·c