CMake 不是编译器,也不直接构建代码–它是构建系统的生成器:你用声明式语法描述项目结构(有哪些目标、依赖什么、怎么编译),CMake 据此生成对应构建系统(Makefile、Ninja、VS 工程等)的文件。这层间接性让同一份 CMakeLists.txt 能跨平台、跨编译器工作。

本文从零开始搭建一个 C++ 项目,逐步引入 CMake 的核心概念,最终覆盖目标导向构建、依赖管理、安装导出和预设配置。

最小项目

一个可执行文件只需要三行:

1
2
3
cmake_minimum_required(VERSION 3.20)
project(hello VERSION 1.0 LANGUAGES CXX)
add_executable(hello main.cpp)
  • cmake_minimum_required:声明最低版本,同时隐式触发策略设置。必须放在第一行,project 之前。
  • project:命名项目,声明版本和使用的语言。LANGUAGES CXX 限定只启用 C++,避免不必要的 C 编译器探测。
  • add_executable:定义一个构建目标 hello,源文件是 main.cpp

对应的 main.cpp

1
2
3
4
5
6
#include <iostream>

int main() {
std::cout << "Hello, CMake!\n";
return 0;
}

构建流程

CMake 的工作分两个阶段:配置(configure)和生成(generate),之后才是构建(build)。

flowchart LR
  A["CMakeLists.txt"] --> B["配置阶段<br/>解析 CMake 语法<br/>检测编译器/依赖"]
  B --> C["生成阶段<br/>输出 Makefile/Ninja/VS 工程"]
  C --> D["构建阶段<br/>调用底层工具链<br/>编译链接"]
  D --> E["可执行文件/库"]

  classDef phase fill:#e3f2fd,stroke:#1565c0
  classDef io fill:#fff3e0,stroke:#ef6c00
  class B,C,D phase
  class A,E io

始终使用 out-of-source 构建:在源码目录之外创建 build 目录,避免污染源码树:

1
2
3
cmake -B build              # 配置 + 生成
cmake --build build # 构建
./build/hello # 运行

-B build 指定构建目录,CMake 会自动创建。构建产物全部留在 build/ 中,源码目录保持干净。

目标导向构建

现代 CMake 的核心是目标(target)。每个 add_executableadd_library 创建一个目标,后续所有配置都通过 target_* 命令挂到目标上,而非使用全局命令污染所有目标。

库目标

1
2
3
4
5
6
7
8
9
10
add_library(math STATIC
src/add.cpp
src/multiply.cpp
)

# 头文件搜索路径:PUBLIC 对消费者可见,PRIVATE 仅自己用
target_include_directories(math
PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include
PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src
)

PUBLIC / PRIVATE / INTERFACE 是传播范围的关键词:

关键字自己编译消费者继承适用场景
PRIVATE内部实现依赖
PUBLIC接口的一部分
INTERFACE仅头文件库

链接库

1
2
3
4
5
add_executable(calculator src/main.cpp)

# calculator 使用 math 库,链接关系是 PRIVATE
#(calculator 的消费者不需要知道 math 的存在)
target_link_libraries(calculator PRIVATE math)

编译选项与定义

1
2
3
4
5
6
7
8
9
10
11
12
13
# C++ 标准:用编译特性而非全局变量
target_compile_features(math PUBLIC cxx_std_20)

# 警告:仅对自己源文件生效
target_compile_options(math PRIVATE
$<$<CXX_COMPILER_ID:GNU,Clang>:-Wall -Wextra -Wpedantic>
$<$<CXX_COMPILER_ID:MSVC>:/W4>
)

# 编译定义
target_compile_definitions(math PRIVATE
$<$<CONFIG:Debug>:DEBUG_MODE>
)

$<...>生成器表达式,在生成阶段求值。上面的写法让警告选项只在 GCC/Clang 下生效,/W4 只在 MSVC 下生效,Debug 配置才定义 DEBUG_MODE

为什么用 target_compile_features 而非 set(CMAKE_CXX_STANDARD 20)?前者把标准要求绑定到具体目标,消费者链接时自动继承;后者是全局变量,子项目无法覆盖,且不表达"谁需要什么"的依赖关系。

依赖管理

find_package:查找系统已安装的库

1
2
3
4
find_package(fmt REQUIRED)

add_executable(app src/main.cpp)
target_link_libraries(app PRIVATE fmt::fmt)

REQUIRED 表示找不到就立即报错终止–这是正确的做法,不要写 fallback 逻辑掩盖缺失依赖。fmt::fmt 是 CMake 导入目标(imported target),带命名空间 ::,封装了头文件路径、库文件路径和编译选项,链接它即可获得一切。

FetchContent:拉取源码依赖

系统没装或需要特定版本时,用 FetchContent 从 Git 拉取源码直接编译:

1
2
3
4
5
6
7
8
9
10
include(FetchContent)

FetchContent_Declare(
fmt
GIT_REPOSITORY https://github.com/fmtlib/fmt.git
GIT_TAG 11.0.2
)
FetchContent_MakeAvailable(fmt)

target_link_libraries(app PRIVATE fmt::fmt)

FetchContent_MakeAvailable 一步完成下载和 add_subdirectory,依赖库的目标直接可用,和 find_package 找到的目标用法完全一致。

多目录项目

项目变大后,每个子目录放自己的 CMakeLists.txt,根目录用 add_subdirectory 串起来:

1
2
3
4
5
6
7
8
9
10
11
project/
├── CMakeLists.txt
├── math/
│ ├── CMakeLists.txt
│ ├── include/math/add.hpp
│ └── src/add.cpp
├── app/
│ ├── CMakeLists.txt
│ └── main.cpp
└── test/
└── CMakeLists.txt

CMakeLists.txt

1
2
3
4
5
6
7
8
cmake_minimum_required(VERSION 3.20)
project(calculator VERSION 1.0 LANGUAGES CXX)

add_subdirectory(math)
add_subdirectory(app)

enable_testing()
add_subdirectory(test)

math/CMakeLists.txt

1
2
3
4
5
add_library(math STATIC src/add.cpp)
target_include_directories(math
PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include
)
target_compile_features(math PUBLIC cxx_std_20)

app/CMakeLists.txt

1
2
add_executable(calculator main.cpp)
target_link_libraries(calculator PRIVATE math)

CMAKE_CURRENT_SOURCE_DIR 始终指向当前 CMakeLists.txt 所在目录,而 CMAKE_SOURCE_DIR 指向根目录。子项目可能被 add_subdirectory 引入到别的工程中,此时根目录会变,所以始终用 CMAKE_CURRENT_SOURCE_DIR 引用本地路径。

测试

1
2
3
4
5
6
7
8
9
10
11
12
13
# test/CMakeLists.txt

find_package(Catch2 REQUIRED)
include(Catch)

add_executable(unit_tests test_math.cpp)
target_link_libraries(unit_tests PRIVATE
math
Catch2::Catch2WithMain
)

# 自动注册每个 TEST_CASE 为 CTest 测试项
catch_discover_tests(unit_tests)

catch_discover_tests 在构建后运行测试可执行文件的 --list-tests,把每个测试用例注册为独立的 CTest 条目。运行测试:

1
ctest --test-dir build --output-on-failure

安装

1
2
3
4
5
6
7
8
9
10
11
12
13
# math/CMakeLists.txt 末尾追加

include(GNUInstallDirs)

install(TARGETS math
EXPORT mathTargets
LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
INCLUDES DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}
)

install(DIRECTORY include/ DESTINATION ${CMAKE_INSTALL_INCLUDEDIR})

GNUInstallDirs 提供平台无关的标准安装路径(Linux 下 lib/include/,Windows 下可能不同)。安装:

1
cmake --install build --prefix /usr/local

预设

CMakePresets.json 把常用的配置参数固化下来,省去每次手敲 -D-G

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
{
"version": 5,
"configurePresets": [
{
"name": "default",
"binaryDir": "${sourceDir}/build",
"cacheVariables": {
"CMAKE_BUILD_TYPE": "RelWithDebInfo"
}
},
{
"name": "debug",
"inherits": "default",
"cacheVariables": {
"CMAKE_BUILD_TYPE": "Debug"
}
}
],
"buildPresets": [
{ "name": "default", "configurePreset": "default" },
{ "name": "debug", "configurePreset": "debug" }
]
}

使用预设:

1
2
cmake --preset default          # 配置
cmake --build --preset default # 构建

预设文件应纳入版本控制,团队成员共享同一套配置。

常见陷阱

做法问题正确做法
include_directories()全局污染所有目标target_include_directories()
add_definitions()已弃用,全局污染target_compile_definitions()
link_libraries()全局污染target_link_libraries()
GLOB 收集源文件新增文件不触发重新配置显式列出源文件
CMAKE_SOURCE_DIR子项目不友好CMAKE_CURRENT_SOURCE_DIR
set(CMAKE_CXX_STANDARD 20)全局变量,不表达依赖target_compile_features()
在源码目录内构建污染源码树out-of-source 构建

速查

flowchart TB
  A["CMakeLists.txt"] --> B["目标<br/>add_executable / add_library"]
  B --> C["属性<br/>target_include_directories<br/>target_compile_options<br/>target_compile_features"]
  B --> D["依赖<br/>target_link_libraries<br/>find_package / FetchContent"]
  C --> E["生成<br/>Makefile / Ninja / VS"]
  D --> E
  E --> F["构建<br/>cmake --build"]

  classDef tgt fill:#e3f2fd,stroke:#1565c0
  classDef prop fill:#fff3e0,stroke:#ef6c00
  classDef gen fill:#e8f5e9,stroke:#2e7d32
  class B tgt
  class C,D prop
  class E,F gen

核心心智模型:一切围绕目标。声明目标,用 target_* 给目标挂属性,用 target_link_libraries 表达目标间依赖。依赖关系通过 PUBLIC/PRIVATE 自动传播。这就是现代 CMake 的全部–没有全局变量,没有目录级副作用,每个目标自包含。