C/C++

现代 CMake 工程实战:构建可移植的 C++ 项目

还在用 include_directories 和 link_libraries 堆全局命令?项目一大就会出现"改个头文件路径全工程重编""依赖顺序一乱就链接失败"的连锁反应。本文用 target 式写法把依赖、编译选项与传播范围一次讲清,并给出可移植的工程骨架。

一个 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)

这三条根因相同:信息挂在了”目录”上,而真正需要它的单位是”库”或”可执行文件”。

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)

一个干净的工程骨架通常长这样:根 CMakeLists.txt 只做 project() 与 add_subdirectory,每个模块目录各有一份自己的 CMakeLists.txt,工具链文件统一放 cmake/。

6. 总结

从旧式到新式,本质上是一次信息归属的迁移:把散落在目录上的全局状态,收拢到每个 target 的属性里。做到这点后,依赖关系变成声明式的,构建系统能自己算出正确的头文件路径与链接顺序,人只需要描述”谁依赖谁”。

三条可以直接落地的建议:新建项目一律从 add_library 开始;公共接口一律 PUBLIC、内部实现一律 PRIVATE;第三方依赖一律锁版本并放到独立目录,方便统一升级与替换。