一个 C++ 项目从几百行长到几万行,最先坏掉往往不是代码,而是构建脚本:头文件路径散落各处、编译选项靠全局变量传递、链接顺序全凭运气,最后没人敢动 CMakeLists.txt。现代 CMake(3.15+)给出的答案很明确——一切围绕 target 组织,用属性描述依赖。
现代 CMake 的核心思想:不要改动全局状态,而是给每个 target 附加它自己需要的属性,并声明这些属性如何传播。
1. 为什么要”目标式”构建
CMake 有两种写法:以目录为中心的旧式写法,和以目标为中心的新式写法。二者都能编译出程序,但在项目规模和团队协作下,差别会迅速被放大。
1.1 全局命令的三宗罪
看看这段典型的旧式脚本,问题一目了然:
# 反例:全局状态到处污染
include_directories(include third_party/foo/include)
link_libraries(pthread foo)
add_definitions(-DFOO_ENABLE_LOGGING)
add_executable(app src/main.cpp src/engine.cpp)
- 作用域失控:这一行之后定义的所有 target 都被迫继承这些路径与宏,任何不需要它的模块也被牵连
- 依赖顺序脆弱:
link_libraries要求被依赖的库已经在前面出现过,顺序一调就链接报错 - 无法导出:项目要作为子模块给别人用时,没有可传递的接口描述,使用方只能靠文档猜测需要链接什么
这三条根因相同:信息挂在了”目录”上,而真正需要它的单位是”库”或”可执行文件”。
2. 从可执行文件开始建 target
新式写法的第一步,是让每个产物都有自己的名字,并把源码、包含路径、编译选项都挂在它身上。先建库、再建可执行文件,可执行文件只通过 target_link_libraries 表达”我用谁”。
cmake_minimum_required(VERSION 3.20)
project(demo VERSION 1.0 LANGUAGES CXX)
# 静态库 engine:源码 + 它自己的头文件目录
add_library(engine STATIC
src/engine.cpp
src/solver.cpp
)
target_include_directories(engine
PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include
PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src
)
target_compile_features(engine PUBLIC cxx_std_17)
# 可执行文件只声明"我依赖 engine"
add_executable(app src/main.cpp)
target_link_libraries(app PRIVATE engine)
注意 target_compile_features(engine PUBLIC cxx_std_17) 这一行:它把”C++17”变成了 engine 对外接口的一部分,任何链接 engine 的目标都自动获得同样的语言标准,无需再手动设置一遍。
3. 依赖传播:PUBLIC / PRIVATE / INTERFACE
这是现代 CMake 最容易被写错、也最值得花时间理解的一点。三个关键字描述的是该属性在这条依赖链上如何传播,而不是”重要程度”。
| 关键字 | 对当前 target 生效 | 对使用者传播 | 典型场景 |
|---|---|---|---|
| PRIVATE | 是 | 否 | 仅内部实现用到的头文件路径 |
| PUBLIC | 是 | 是 | 公开头文件中出现的类型、需要一并链接的库 |
| INTERFACE | 否 | 是 | 仅编译期需要(如宏定义)、纯头文件库 |
判断依据只有一条:会出现在公开头文件里的东西,就必须用 PUBLIC 或 INTERFACE。比如日志库的头文件里暴露了 fmt 的字符串类型,那么 fmt 就该是 PUBLIC;如果只在 .cpp 内部使用,PRIVATE 就够,使用者不必被迫也依赖 fmt。
target_link_libraries(engine
PUBLIC fmt::fmt # 头文件里用了 fmt,使用者也要链接
PRIVATE Threads::Threads # 只在实现里用线程
)
# 只影响编译的宏,用 INTERFACE 包一层,避免污染第三方依赖
add_library(engine_warnings INTERFACE)
target_compile_options(engine_warnings INTERFACE
-Wall -Wextra -Wpedantic)
target_link_libraries(engine PRIVATE engine_warnings)
4. 用 FetchContent 拉取依赖
依赖管理曾经是 C++ 最头疼的部分。CMake 3.11 引入的 FetchContent 提供了折中方案:在配置阶段自动下载源码并作为子项目构建,源码随项目一起编译,ABI 完全可控,不依赖系统预装版本。
include(FetchContent)
FetchContent_Declare(
googletest
GIT_REPOSITORY https://github.com/google/googletest.git
GIT_TAG v1.14.0
GIT_SHALLOW TRUE
)
# 让依赖只构建我们需要的部分
set(INSTALL_GTEST OFF CACHE BOOL "" FORCE)
FetchContent_MakeAvailable(googletest)
enable_testing()
add_executable(unit_tests test/solver_test.cpp)
target_link_libraries(unit_tests PRIVATE engine GTest::gtest_main)
include(GoogleTest)
实践建议:始终锁定 GIT_TAG(用 tag 或 commit,不要用分支名),否则同一份 CMakeLists 在不同时间拉到的代码可能不同,构建结果不可复现。网络受限时可用内网镜像或提前把仓库同步到内网 Git 服务。
5. 工具链与可移植性
同一个工程要在 Windows 的 MSVC、Linux 的 GCC、交叉编译的 ARM 工具链上都能构建,靠的不是到处写 if(WIN32),而是工具链文件 + 编译特性声明。
# 用法:cmake -B build -DCMAKE_TOOLCHAIN_FILE=cmake/arm-none-eabi.cmake
set(CMAKE_SYSTEM_NAME Generic)
set(CMAKE_SYSTEM_PROCESSOR arm)
set(CMAKE_C_COMPILER arm-none-eabi-gcc)
set(CMAKE_CXX_COMPILER arm-none-eabi-g++)
# 交叉编译时优先在工具链里找依赖,避免误用主机的库
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
- 用
target_compile_features而不是硬编-std=c++17:前者会翻译成各编译器对应的选项 - 编译选项用生成器表达式区分编译器:例如
$<$<CXX_COMPILER_ID:MSVC>:/W4>,避免一套 flag 打天下 - 默认构建类型要显式设置:不加参数时 CMake 不做优化,应设默认值兜底为 Release
一个干净的工程骨架通常长这样:根 CMakeLists.txt 只做 project() 与 add_subdirectory,每个模块目录各有一份自己的 CMakeLists.txt,工具链文件统一放 cmake/。
6. 总结
从旧式到新式,本质上是一次信息归属的迁移:把散落在目录上的全局状态,收拢到每个 target 的属性里。做到这点后,依赖关系变成声明式的,构建系统能自己算出正确的头文件路径与链接顺序,人只需要描述”谁依赖谁”。
三条可以直接落地的建议:新建项目一律从 add_library 开始;公共接口一律 PUBLIC、内部实现一律 PRIVATE;第三方依赖一律锁版本并放到独立目录,方便统一升级与替换。
如果这篇文章对你有帮助,请我喝杯茶吧