⭐Modern_CMake.md
modern cmake?这是什么意思
所谓 modern CMake 并不是一个官方版本号,而是一种使用 CMake 的“思维方式”从老一代写法向新一代写法的升级。
一句话总结区别:
老式 CMake:像在手写编译命令
现代 CMake:像在声明目标之间的依赖关系老式 CMake(混乱、全局变量驱动)
典型特征:
plaintext
1
2
3
4
5 include_directories(include)
add_definitions(-DUSE_XYZ)
set(SOURCES a.cpp b.cpp c.cpp)
add_executable(app ${SOURCES})
target_link_libraries(app foo bar)问题是:
• include 目录、编译选项全局污染
• 编译 flag 容易丢、互相覆盖
• 项目越大越混乱
• 每个子项目都得重复设置换句话说,它像是在“全局堆叠构建参数”,没有模块化、没有封装。
modern CMake(面向 target 的接口与封装)
target:构建目标
标志性的写法:
plaintext
1
2
3
4
5
6 add_library(mylib src/a.cpp src/b.cpp)
target_include_directories(mylib PUBLIC include)
target_compile_features(mylib PUBLIC cxx_std_20)
add_executable(app src/main.cpp)
target_link_libraries(app PRIVATE mylib)关键词:target(目标)
每个 target(库 / 可执行文件)有自己的:
• include 路径
• 编译特性
• 依赖
• 链接选项且依赖关系自动传播:
plaintext
1 app → mylib → include、flags、C++20无需在 app 里再重复写设置。
modern CMake 的核心理念(通俗解释)
老式写法:全局搞定编译器配置,然后让所有目标凑合着用
现代写法:把配置封装到每个目标上,然后它们自己负责传播依赖这带来的好处非常现实:
• 项目越大越不会乱
• 不同模块不会互相污染
• 依赖管理清晰透明
• 子项目直接复用,不重新配置
• 导出给别人用时非常方便(find_package)所以现代 CMake 是“项目越来越大越爽”,而老式 CMake 是“项目越大越地狱”。
它为什么叫 modern
因为这种理念在 CMake 3 之后逐渐成熟,大约从 3.10 ~ 3.15 时代开始成为行业共识。
如今写库的“标准范式”就是:plaintext
1
2
3
4 add_library(mylib ...)
target_include_directories(mylib PUBLIC ...)
target_compile_features(mylib PUBLIC cxx_std_20)
target_link_libraries(mylib PUBLIC otherlib)完全不用
include_directories()、add_definitions()、set(CMAKE_CXX_FLAGS ...)等老旧写法。一个非常现实的判断
如果你看到某项目的 CMakeLists 像一团全局命令和变量泥沼,那是老式 CMake。
如果你看到它只围绕target_xxx()操作,那就是 modern CMake。一旦习惯 modern CMake,你会明显感觉:
• 配置逻辑更清晰
• CMakeLists 好维护
• 不会随便把 include / flags 洒得到处都是
• 导出给别人用更容易如果愿意,我可以给你一个最小但真正“现代风格”的项目模板,再加上解释,让你一看就懂并直接能用到工作或项目里。
不使用include_directories,那我项目中的头文件路径如何能被引入呢
关键在于:现代 CMake 不再用全局的 include_directories,而是把 include 路径绑定到具体的 target 上。
你的问题可以改写成一句更本质的话:
“不给整个项目设 include 路径,那编译器怎么知道头文件在哪?”答案是:通过 target 的接口传播机制。
最直接的例子
假设你有:
plaintext
1
2
3 project/
├─ include/ # 头文件
└─ src/ # 源文件modern CMake 写法:
plaintext
1
2
3
4
5
6
7
8
9 add_library(mylib
src/a.cpp
src/b.cpp
)
target_include_directories(mylib
PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}/include
)不是全局注入,而是告诉 CMake:mylib 这个库需要这个包含路径。
现在你创建可执行程序:
plaintext
1
2 add_executable(app src/main.cpp)
target_link_libraries(app PRIVATE mylib)然后神奇的事发生了——
你不需要手动给 app 添加 include 路径。
因为:plaintext
1 mylib 的 PUBLIC include 会自动传播到 app这就是“接口传播”。
为什么这么做更安全
全局 include_directories 的问题:
• 任何模块都可能意外包含到不该访问的头文件
• include 顺序互相干扰
• 追踪“这个路径从哪来的”非常难
• 大项目里极容易爆炸成混乱target_include_directories 的特点:
• 依赖关系清晰
• 哪个模块暴露哪些头文件一目了然
• PRIVATE / PUBLIC / INTERFACE 语义明确
• 不会污染不相关的模块一个库声明 PUBLIC include,就是说:
“依赖我的人,也需要看到这些头文件。”
PRIVATE 只给自己用。
INTERFACE 只给别人传播,自己不需要。让人最安心的一句话总结
过去:
“把头文件路径丢进全局,让所有东西共享。”现在:
“把头文件路径绑定到库,让依赖关系自然传播。”这不仅更合理,而且对大型工程来说是救命稻草。
如果你的头文件在源代码旁边(常见)
如果你的库头文件在
src/同目录,不在独立的 include 文件夹,比如:plaintext
1
2
3
4
5 src/
a.cpp
a.h
b.cpp
b.hmodern CMake 推荐做法是声明为 INTERFACE 或 PUBLIC 目录:
plaintext
1
2
3
4 target_include_directories(mylib
PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}/src
)而不是 include_directories 全局注入。
简而言之:
你不是“放弃了 include 路径”
而是“把 include 路径正确地绑定给它所属的 target,让依赖关系自动管理范围。”现代 CMake 的威力就在这一点上:干净、封闭、可传播、可维护。
如果你愿意,我可以再往下扩展——
展示一个真正完整的现代 CMake 项目模板,从根到子模块、安装、导出、find_package 全部顺畅。
那会让你对这一套机制理解得更扎实。
好的
下面给出一个真正现代风格、可实际复用的 CMake 项目模板。
这个模板具备核心能力:• 层次清晰的目录结构
• 库对外暴露头文件,并支持依赖传播
• 可执行程序正确链接库
• 支持install和find_package
• 完全避免老式全局命令看完这一套,你就能理解 modern CMake 的完整工作流。
目录结构(标准推荐)
plaintext
1
2
3
4
5
6
7
8
9 MyProject/
├─ CMakeLists.txt
├─ cmake/ # 自定义 cmake 脚本放这里(可选)
├─ include/
│ └─ mylib/
│ └─ mylib.h # 对外头文件
└─ src/
├─ mylib.cpp # 库源文件
└─ main.cpp # 可执行程序顶层 CMakeLists.txt
cmake
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54 cmake_minimum_required(VERSION 3.20)
project(MyProject LANGUAGES CXX)
# 默认使用 C++20(推荐,不建议修改全局 CXX_FLAGS)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# 创建库 target
add_library(mylib
src/mylib.cpp
)
# 库的头文件路径绑定到库本身,而不是全局
target_include_directories(mylib
PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}/include
)
target_compile_features(mylib PUBLIC cxx_std_20)
# 创建可执行文件
add_executable(myapp src/main.cpp)
target_link_libraries(myapp PRIVATE mylib)
# 安装(让其他项目 find_package(MyProject))
install(TARGETS mylib myapp
EXPORT MyProjectTargets
ARCHIVE DESTINATION lib
LIBRARY DESTINATION lib
RUNTIME DESTINATION bin
)
install(DIRECTORY include/ DESTINATION include)
# 导出配置文件供 find_package() 使用
include(CMakePackageConfigHelpers)
write_basic_package_version_file(
"${CMAKE_CURRENT_BINARY_DIR}/MyProjectConfigVersion.cmake"
COMPATIBILITY SameMajorVersion
)
install(EXPORT MyProjectTargets
FILE MyProjectTargets.cmake
NAMESPACE MyProject::
DESTINATION lib/cmake/MyProject
)
configure_file(
MyProjectConfig.cmake.in
"${CMAKE_CURRENT_BINARY_DIR}/MyProjectConfig.cmake"
@ONLY
)
install(
FILES "${CMAKE_CURRENT_BINARY_DIR}/MyProjectConfig.cmake"
"${CMAKE_CURRENT_BINARY_DIR}/MyProjectConfigVersion.cmake"
DESTINATION lib/cmake/MyProject
)MyProjectConfig.cmake.in (写在工程根目录)
cmake
1
2
3 @PACKAGE_INIT@
include("${CMAKE_CURRENT_LIST_DIR}/MyProjectTargets.cmake")这三行就够,不用写乱七八糟的逻辑。
库源码示例
include/mylib/mylib.h:
cpp
1
2
3
4
std::string hello();src/mylib.cpp:
cpp
1
2
3
4
5
std::string hello() {
return "hello";
}main 示例
src/main.cpp:
cpp
1
2
3
4
5
6
int main() {
std::cout << hello() << std::endl;
}构建与安装验证
构建:
plaintext
1
2 cmake -S . -B build
cmake --build build安装:
plaintext
1 cmake --install build --prefix install然后任意项目都可以这样使用:
cmake
1
2
3 find_package(MyProject REQUIRED)
add_executable(test t.cpp)
target_link_libraries(test PRIVATE MyProject::mylib)总结这套架构的优点
• 没有
include_directories()、add_definitions()这种全局污染
• 所有依赖、头文件、编译特性都挂在 target 上
• app 自动继承 mylib 的 include,不需要重复设置
• 安装 / 导出逻辑标准、清晰
• 随时可扩展多个模块而不会乱掉这个就是现代 CMake 指南书级的最小范例。
如果之后你想继续升级构建系统,我可以逐步扩展:
➤ 加子模块(subdirectory)
➤ 加第三方依赖(find_package、FetchContent)
➤ 切换共享库 / 静态库
➤ 单元测试与 coverage
➤ CPack 打包发行你愿意研究哪个方向,我就继续往下带你。
所以这样的话,一个大型项目可以被分为多个子项目,当子项目中需要调用别的子项目中的头文件的时候,只需要引入对应的子项目就行了
完全正确,你已经抓到现代 CMake 的核心思想。
一个大型项目拆成多个子项目(模块 / 组件 / 库),每个子项目是一个 target。
当某个子项目需要使用另一个子项目的头文件时,不是“加 include 路径”,而是 链接那个子项目的 target。效果不是“我拿到它的头文件路径”,而是:
我依赖它,它的接口自动对我生效——头文件、编译选项、特性统统自动传过来。
举个更接近真实大型工程的例子:
plaintext
1
2
3
4
5
6
7
8
9
10
11
12
13
14 GameEngine/
├─ CMakeLists.txt
├─ Engine/
│ ├─ CMakeLists.txt
│ └─ ...
├─ Renderer/
│ ├─ CMakeLists.txt
│ └─ ...
├─ Physics/
│ ├─ CMakeLists.txt
│ └─ ...
└─ Game/
├─ CMakeLists.txt
└─ ...顶层 CMakeLists:
cmake
1
2
3
4 add_subdirectory(Engine)
add_subdirectory(Renderer)
add_subdirectory(Physics)
add_subdirectory(Game)每个模块都是独立 target:
Engine/CMakeLists.txt:
cmake
1
2 add_library(engine ...)
target_include_directories(engine PUBLIC include)Renderer/CMakeLists.txt:
cmake
1
2 add_library(renderer ...)
target_link_libraries(renderer PUBLIC engine)Physics/CMakeLists.txt:
cmake
1
2 add_library(physics ...)
target_link_libraries(physics PUBLIC engine)Game/CMakeLists.txt:
cmake
1
2 add_executable(game ...)
target_link_libraries(game PRIVATE renderer physics)依赖关系自然产生效果
game → renderer → engine
game → physics → enginegame 自动获得:
• Engine 暴露的头文件
• Engine 的编译特性(比如 C++20 或 SIMD)
• Engine 打开的预处理器定义
• Engine 依赖的库
• Engine 的 include 目录无须手动写 include_directories,也永远不会出现“忘了加 include 路径”这种蠢 bug。
为什么这对大型项目很重要
传统“手写 include 的时代”会出现这些地狱场景:
• 头文件路径依赖链混乱
• 新人看不懂依赖关系
• 模块间互相污染
• 模块解耦失败
• CI 经常因为路径乱套而爆炸而现代 CMake 改写了规则:
• 模块之间的依赖像搭积木一样清楚
• 依赖谁 → 链接那个 target,就行
• 子项目的公共 API 由PUBLIC控制
• 不必担心 include 顺序、冲突、污染大型系统开发从易碎变成可维护。
越到后期好处越明显
项目越大,modern CMake 的收益越大。
最终你会发现 CMakeLists.txt 的可读性甚至比很多 C++ 源文件还好:plaintext
1
2
3 Game depends on Renderer and Physics
Renderer depends on Engine
Physics depends on Engine这基本等于自动生成了依赖图,构建逻辑就不会失控。
你已经理解到核心了。
如果你愿意,我们可以继续进阶下去,例如:• 如何在一个模块中“只暴露部分头文件”
• 如何让子项目可以被外部项目单独构建(可插拔)
• 如何为库生成导出宏(支持跨平台 DLL)
• 引入第三方库的最佳方式(避免乱用子模块或全局变量)这些是大型 C++ 项目真正会遇到的问题,也是 modern CMake 的杀手级能力。





