跨平台 C++ 项目经常出现这样的情况:开发者本机可以正常编译,换到另一台电脑后却遇到依赖找不到、编译器版本不兼容、运行库冲突,或者 CI 环境无法复现本地结果。问题往往不在某一条 CMake 命令,而在于构建环境没有被完整记录。
CMakeLists.txt 只描述了项目如何构建,却不适合单独承担"使用哪个生成器、构建什么配置、依赖从哪里来、工具链如何加载"等环境职责。CMake Presets 可以把这些参数写成版本化配置,vcpkg 则负责依赖获取和工具链接入。两者结合后,开发者和 CI 可以使用同一套配置入口。
一、先明确要固定什么
可复现构建并不等于所有机器都使用完全相同的操作系统。更现实的目标是把影响结果的关键输入显式化:
- CMake 最低版本和生成器行为。
- C++ 标准、构建类型以及编译器相关选项。
- vcpkg 工具链文件的位置。
- 依赖清单及其版本基线。
- 构建目录、安装目录和缓存策略。
- CI 中使用的操作系统、编译器和系统包。
其中,CMake Presets 主要解决第 1、2、3、5 项;vcpkg manifest 和版本基线主要解决第 4 项;CI 配置仍然需要固定操作系统镜像和编译器安装方式。也就是说,Presets 能统一构建入口,但不能自动消除操作系统差异。
二、CMake Presets 的工作方式
CMake Presets 通常分为两类文件:
CMakePresets.json:项目共享配置,应提交到版本库。CMakeUserPresets.json:个人机器配置,通常不提交,用于覆盖本地路径或私有选项。
一个 preset 可以继承另一个 preset。常见的组织方式是先定义公共配置,再分别定义 Debug、Release 和 CI 入口。配置阶段使用 cmake --preset 名称,构建阶段使用 cmake --build --preset 名称,从而避免每个人手写一组不同参数。
需要注意,Presets 的具体字段受 CMake 版本支持范围影响。团队应先约定最低 CMake 版本,并在开发机和 CI 中执行同一版本范围内的命令。
三、创建一个最小项目
目录结构可以从下面的布局开始:
text
sample-project/
├── CMakeLists.txt
├── CMakePresets.json
├── vcpkg.json
├── vcpkg-configuration.json
├── src/
│ └── main.cpp
└── .gitignore
CMakeLists.txt 只保留项目结构和目标定义:
cmake
cmake_minimum_required(VERSION 3.25)
project(sample_project VERSION 1.0 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
add_executable(sample_app src/main.cpp)
if (MSVC)
target_compile_options(sample_app PRIVATE /W4)
else()
target_compile_options(sample_app PRIVATE -Wall -Wextra -Wpedantic)
endif()
这里没有直接写死 vcpkg 路径。工具链应该在配置入口中传入,这样项目文件不会绑定某台机器的目录结构。
四、用 manifest 描述依赖
假设程序需要使用一个 JSON 库,可以在 vcpkg.json 中声明依赖:
json
{
"name": "sample-project",
"version-string": "1.0.0",
"dependencies": [
"nlohmann-json"
]
}
vcpkg manifest 模式会根据这个文件安装项目依赖。为了让不同时间执行安装时尽量保持一致,可以增加 vcpkg-configuration.json,指定 registry 基线:
json
{
"default-registry": {
"kind": "builtin",
"baseline": "填写团队确认过的 vcpkg baseline"
}
}
baseline 不是随意填写的版本号,而应当来自团队实际采用的 vcpkg 提交基线。提交前应通过当前 vcpkg 工具验证该基线可用,并将 vcpkg 本身的获取方式也写入开发文档或 CI 初始化步骤。若项目使用私有 registry,还需要根据仓库权限配置访问凭据,但凭据不应写入版本库。
五、编写共享 Preset
下面的示例假设环境变量 VCPKG_ROOT 指向 vcpkg 根目录。它使用 Ninja 作为生成器,因此运行环境需要提前安装 Ninja;也可以根据团队平台改用 Visual Studio 或其他生成器。
json
{
"version": 6,
"cmakeMinimumRequired": {
"major": 3,
"minor": 25,
"patch": 0
},
"configurePresets": [
{
"name": "base",
"hidden": true,
"generator": "Ninja",
"binaryDir": "${sourceDir}/build/${presetName}",
"cacheVariables": {
"CMAKE_TOOLCHAIN_FILE": "$env{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake",
"CMAKE_EXPORT_COMPILE_COMMANDS": "ON"
}
},
{
"name": "debug",
"inherits": "base",
"cacheVariables": {
"CMAKE_BUILD_TYPE": "Debug"
}
},
{
"name": "release",
"inherits": "base",
"cacheVariables": {
"CMAKE_BUILD_TYPE": "Release"
}
}
],
"buildPresets": [
{
"name": "debug",
"configurePreset": "debug"
},
{
"name": "release",
"configurePreset": "release"
}
]
}
这里有几个重要约束:
binaryDir按 preset 分开,避免 Debug 和 Release 共享同一份缓存。CMAKE_TOOLCHAIN_FILE在第一次配置前传入。工具链文件会影响依赖查找和编译器环境,配置完成后再修改,通常需要清理构建目录重新配置。CMAKE_BUILD_TYPE适用于单配置生成器,例如 Ninja。若改用 Visual Studio 这类多配置生成器,应在构建时通过--config Debug或--config Release指定配置,不能机械照搬该字段。VCPKG_ROOT属于机器环境,不应把绝对路径提交到CMakePresets.json。
六、执行步骤
首先安装与团队约定范围相符的 CMake、编译器和 Ninja,然后准备 vcpkg,并设置环境变量。例如在类 Unix 环境中:
bash
export VCPKG_ROOT="$HOME/tools/vcpkg"
Windows PowerShell 可以使用:
powershell
$env:VCPKG_ROOT = "C:/tools/vcpkg"
在项目根目录执行配置和构建:
bash
cmake --preset debug
cmake --build --preset debug
发布构建使用:
bash
cmake --preset release
cmake --build --preset release
如果项目包含测试,可以在 CMakeLists.txt 中启用 CTest:
cmake
enable_testing()
add_test(NAME sample_app_runs COMMAND sample_app)
随后执行:
bash
ctest --test-dir build/debug --output-on-failure
这里的路径必须与 preset 的 binaryDir 一致。更复杂的项目可以额外定义 testPresets,但应先确认所使用的 CMake 版本支持相应字段。
七、接入 CI 的关键做法
CI 的目标不是重新发明一套构建脚本,而是调用仓库已经定义好的 preset。一个简化的 GitHub Actions 示例如下:
yaml
name: build
on:
push:
pull_request:
jobs:
linux:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install tools
run: |
sudo apt-get update
sudo apt-get install -y ninja-build
- name: Prepare vcpkg
shell: bash
run: |
git clone https://github.com/microsoft/vcpkg.git "$RUNNER_TEMP/vcpkg"
"$RUNNER_TEMP/vcpkg/bootstrap-vcpkg.sh" -disableMetrics
echo "VCPKG_ROOT=$RUNNER_TEMP/vcpkg" >> "$GITHUB_ENV"
- name: Configure
run: cmake --preset release
- name: Build
run: cmake --build --preset release --parallel
- name: Test
run: ctest --test-dir build/release --output-on-failure
示例中的依赖获取方式只是演示流程。生产环境还应考虑缓存策略、网络可用性、依赖源审计和工具版本固定。特别是缓存不能代替版本控制:当 vcpkg.json、baseline、编译器或 preset 发生变化时,应让缓存键随之变化,否则旧缓存可能掩盖配置问题。
如果 CI 需要在 Windows、macOS 和 Linux 上运行,建议先分别确认生成器和编译器差异,再决定是否共用一个 preset 名称。共享名称只有在命令语义确实一致时才有价值,不能为了形式统一而隐藏平台特定参数。
八、常见问题
1. 为什么修改了工具链路径却没有生效?
CMake 会把工具链相关结果写入缓存。若已有构建目录,修改工具链路径后仍可能继续使用旧值。应检查:
bash
cmake -LA -N build/debug | grep CMAKE_TOOLCHAIN_FILE
确认无误后删除对应构建目录,再执行 preset 配置。删除前应确认目录中没有需要保留的手工文件。
2. Debug 和 Release 能否共用一个构建目录?
单配置生成器不建议这样做。两种配置会共享缓存和中间文件,容易产生难以判断的增量构建结果。为每个配置使用独立目录是更清晰的做法。
3. vcpkg 安装失败是否说明 Preset 有问题?
不一定。失败可能来自网络、编译器、系统包、端口版本或平台支持范围。应先查看 vcpkg 的完整错误输出,再区分是依赖解析失败、端口构建失败,还是 CMake 找包阶段失败。
4. 能否把本机路径写入共享配置?
不建议。绝对路径会让其他开发者和 CI 直接失效。可以使用环境变量、用户级 preset 或 CI 注入变量表达机器差异,并在项目文档中列出必需的环境变量。
5. Presets 是否等于完全可重复构建?
不是。它统一了 CMake 层面的配置入口,但编译器补丁版本、系统库、CPU 架构和外部下载内容仍可能不同。对强复现要求较高的项目,还需要固定容器镜像、工具链文件、依赖基线,必要时使用制品仓库或离线依赖缓存。
总结
CMake Presets 的价值不只是减少命令行输入,更重要的是把构建约定变成可审查、可复用的项目配置。vcpkg manifest 负责描述依赖,baseline 负责约束依赖时间点,CI 则调用同一套 preset 验证构建结果。
落地时可以按以下顺序推进:先拆分 Debug 和 Release 构建目录,再把工具链路径移入环境变量,随后提交依赖清单和版本基线,最后让 CI 只调用 preset。完成这些步骤后,剩余差异会集中在操作系统和工具链本身,排查范围也会比"每台机器各写一套命令"更明确。