导语
在上一节中,我们学习了 CMake 中目标(Target)的概念,掌握了如何通过 add_executable 和 add_library 创建可执行文件与库。不过,仅有”目标”的骨架还不够,我们需要把真正的源代码”血肉”填充进去。正如盖房子不能只搭框架而不砌砖,一个 CMake 项目的好坏,很大程度上取决于源文件管理是否规范。
本节将系统讲解如何从 CMake 层面管理你的 .cpp、.h 文件。你会学到显式与自动搜集源文件的取舍、头文件包含目录的正确添加方式、PRIVATE/PUBLIC/INTERFACE 这一 Modern CMake 核心传播机制,以及预编译头和 Unity Build 等高级编译加速技巧。请准备好你的代码编辑器,我们将通过大量实例来夯实这些基本功。
显式列出源文件:推荐的手动管理方式
在 Modern CMake 的最佳实践中,显式(Explicitly)列出每一个源文件是最受推荐的方式。虽然这看起来有些繁琐,但它能带来最精确的控制和最好的 IDE 支持。
基本做法
直接在 add_executable 或 add_library 命令中列出所有源文件:
add_executable(my_app
src/main.cpp
src/utils.cpp
src/utils.h
src/core/engine.cpp
src/core/engine.h
)
或者先使用变量收集,再传递给目标:
set(SOURCES
src/main.cpp
src/utils.cpp
src/core/engine.cpp
)
set(HEADERS
src/utils.h
src/core/engine.h
)
add_executable(my_app ${SOURCES} ${HEADERS})
为什么推荐显式列出?
- 增量构建准确:CMake 能够精确追踪每个文件的修改时间戳。增删文件时必须修改
CMakeLists.txt,这会触发 CMake 自动重新配置,确保构建系统始终与源码保持一致。 - IDE 友好:在 Visual Studio、CLion 等 IDE 中,显式列出的文件会正确出现在项目树中,且能准确反映文件归属。
- 避免编译污染:不会意外将临时文件、测试文件或平台特定文件卷入构建。
使用变量还是直接罗列?
对于中小型项目,直接罗列在 add_executable 中更加直观。对于大型项目,使用变量(如上面的 SOURCES)配合 target_sources 可以更灵活地组织代码:
add_executable(my_app "")
target_sources(my_app PRIVATE
src/main.cpp
src/utils.cpp
)
注意 add_executable(my_app "") 中的空字符串是必要的占位符,表示我们先创建一个没有源文件的目标,随后再用 target_sources 追加。
自动搜集源文件:aux_source_directory 与 file(GLOB) 的利弊
显式管理虽好,但当项目规模膨胀到上百个源文件时,手动维护列表确实令人头疼。CMake 提供了两种自动搜集机制,但请务必了解它们的副作用。
aux_source_directory
这是 CMake 2 时代遗留的命令,用于将某个目录下所有源文件搜集到一个变量中:
aux_source_directory(src SRC_LIST)
add_executable(my_app ${SRC_LIST})
缺点
- 只会扫描
.c、.cpp等已知源文件后缀,不会搜集头文件,导致 IDE 可能不显示头文件。 - 如果目录下新增了文件,CMake 不会自动感知(因为
CMakeLists.txt本身没有变化),必须手动重新运行 cmake 配置。 - 会无条件搜集目录下所有源文件,包括你可能不想编译的临时文件或平台特定文件。
结论:aux_source_directory 在现代 CMake 中已不推荐使用。
file(GLOB)
file(GLOB) 允许使用通配符模式匹配文件,看起来更加灵活:
file(GLOB SOURCES "src/*.cpp")
file(GLOB HEADERS "src/*.h")
add_executable(my_app ${SOURCES} ${HEADERS})
甚至可以递归搜索子目录:
file(GLOB_RECURSE SOURCES "src/*.cpp" "src/*.h")
缺点
- 无法检测新增文件:与
aux_source_directory类似,如果开发者新增了一个.cpp文件,CMake 缓存中记录的文件列表不会自动更新,导致新文件不会被编译,链接时可能报符号缺失错误。 - 可能搜到不希望编译的文件:例如备份文件、测试存根文件等。
折中方案:CONFIGURE_DEPENDS(CMake 3.12+)
如果你确实想使用 GLOB,CMake 3.12 引入了 CONFIGURE_DEPENDS 选项,让构建系统尝试在每次构建时检查通配符匹配的文件是否有变化:
file(GLOB SOURCES CONFIGURE_DEPENDS "src/*.cpp")
add_executable(my_app ${SOURCES})
但这并非万能药,某些生成器(如部分 IDE 的项目文件生成器)对此支持有限,且仍会增加配置时间。对于正式项目,依然建议显式列表;对于快速原型或小型工具脚本,可以考虑 GLOB。
头文件处理与包含目录:target_include_directories 的使用
C++ 项目离不开头文件(.h / .hpp)。如何让编译器找到这些头文件?在 Modern CMake 中,绝不应该再使用全局的 include_directories() 命令,而应该使用目标级的 target_include_directories()。
基础用法
add_library(math_utils STATIC
src/math/add.cpp
src/math/add.h
)
target_include_directories(math_utils PUBLIC
"${CMAKE_CURRENT_SOURCE_DIR}/src/math"
)
这告诉编译器:在编译 math_utils 这个目标时,添加 src/math 到头文件搜索路径中。
引用路径的基准
推荐使用绝对路径(基于 CMAKE_CURRENT_SOURCE_DIR)来避免歧义:
target_include_directories(my_target PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/include
${CMAKE_CURRENT_BINARY_DIR}/generated # 生成的头文件通常在这里
)
为什么不用 include_directories()?
老式的 include_directories() 会全局地为当前目录及其子目录的所有目标添加包含路径。这会导致:
- 不同目标之间的头文件路径互相污染。
- 依赖关系不清晰,一个目标可能意外依赖了另一个目标的私有头文件。
target_include_directories 则将路径绑定到具体目标,配合可见性关键字(PRIVATE / PUBLIC / INTERFACE),能够精确控制路径的传播范围。
PRIVATE/PUBLIC/INTERFACE 传播机制:三种可见性的详细对比
这是 Modern CMake 中最重要的概念之一,也是许多初学者最容易混淆的地方。这三个关键字不仅用于 target_include_directories,也适用于 target_link_libraries、target_compile_definitions 等命令。理解它们,你就掌握了 CMake 依赖传播的核心逻辑。
类比理解
想象你在写一个库 mylib,它有三种不同的”秘密”:
- PRIVATE:仅库内部实现使用的头文件/定义。好比你的”私人日记”,不对外公开,使用你库的人看不到。
- INTERFACE:库自身实现不需要,但库的头文件中引用了其他库的头文件。好比你的”公开声明中提到的外部参考资料”,你自己不看,但别人看你的声明时需要这些资料。
- PUBLIC:库内部实现需要,且库的头文件也需要。好比你的”公开著作中引用的你自己写的另一本公开书”,你自己看,别人读你的书时也需要看。
技术定义与代码示例
PRIVATE
仅用于当前目标的编译,不传播给依赖当前目标的其他目标。
target_include_directories(mylib PRIVATE include/internal)
# 含义:mylib 编译时搜索 include/internal
# 链接 mylib 的 exe 不会自动获得这个路径
INTERFACE
当前目标自身编译不使用,但会传播给依赖当前目标的其他目标。
target_include_directories(mylib INTERFACE include/mylib)
# 含义:mylib 自身编译时不需要搜索 include/mylib(因为它的源码已用 #include "..." 相对路径包含)
# 但使用 mylib 的目标需要这个路径来解析 mylib 的头文件
PUBLIC
是 PRIVATE + INTERFACE 的叠加。当前目标自身使用,且传播给依赖者。
target_include_directories(mylib PUBLIC include/mylib)
# 含义:mylib 编译时需要,链接 mylib 的 exe 也需要
完整项目示例
假设项目结构如下:
project/
├── CMakeLists.txt
├── app/
│ ├── CMakeLists.txt
│ └── main.cpp
├── libA/
│ ├── CMakeLists.txt
│ ├── src/a.cpp
│ └── include/libA/a.h
└── libB/
├── CMakeLists.txt
├── src/b.cpp
└── include/libB/b.h
libA/CMakeLists.txt:
add_library(libA STATIC src/a.cpp include/libA/a.h)
target_include_directories(libA PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}/include
)
libB/CMakeLists.txt(libB 的实现依赖 libA,但其头文件不暴露 libA):
add_library(libB STATIC src/b.cpp include/libB/b.h)
target_include_directories(libB
PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}/include
PRIVATE
${CMAKE_SOURCE_DIR}/libA/include # 仅实现需要
)
target_link_libraries(libB PRIVATE libA)
app/CMakeLists.txt:
add_executable(app main.cpp)
target_link_libraries(app PRIVATE libB)
此时,app 会自动获得 libB 的 PUBLIC 包含目录(libB/include),但不会获得 libB 的 PRIVATE 目录(libA/include)。同时,由于 libB 对 libA 的链接是 PRIVATE,app 甚至不会链接 libA。这种依赖的传递控制正是 Modern CMake 优雅之处。
传播机制总结表
| 关键字 | 当前目标编译时使用? | 依赖当前目标的目标使用? | 典型场景 |
|---|---|---|---|
PRIVATE |
✅ 是 | ❌ 否 | 仅实现内部依赖的头文件路径或第三方库 |
INTERFACE |
❌ 否 | ✅ 是 | 纯头文件库(Header-only)的包含路径 |
PUBLIC |
✅ 是 | ✅ 是 | 库对外暴露的 API 头文件路径 |
源文件分组与 IDE 显示优化:source_group 命令
在 Visual Studio、Xcode 等 IDE 中,CMake 默认会按照目标平铺展示所有源文件。如果源文件数量很多,项目树会显得非常混乱。source_group 命令允许你自定义 IDE 中的文件分组(文件夹),使其与真实的磁盘目录结构对应,或按功能逻辑分组。
基础用法
source_group("Header Files" FILES include/foo.h include/bar.h)
source_group("Source Files" FILES src/foo.cpp src/bar.cpp)
add_executable(my_app
include/foo.h
include/bar.h
src/foo.cpp
src/bar.cpp
)
与目录结构同步(TREE 选项,CMake 3.8+)
手动为每个文件写 source_group 也很繁琐。CMake 3.8 引入了 TREE 选项,可以自动根据文件系统目录生成 IDE 分组:
file(GLOB_RECURSE SOURCES
"${CMAKE_CURRENT_SOURCE_DIR}/src/*.cpp"
"${CMAKE_CURRENT_SOURCE_DIR}/include/*.h"
)
source_group(TREE "${CMAKE_CURRENT_SOURCE_DIR}" FILES ${SOURCES})
add_executable(my_app ${SOURCES})
这样,IDE 中的文件树就会呈现出与 src/、include/ 目录一致的层次结构,极大地提升了大型项目的可维护性。
注意事项
source_group主要影响多配置 IDE 生成器(如 Visual Studio、Xcode)。对于 Makefile 或 Ninja 这类基于命令行的生成器,该命令没有任何效果。- 如果使用了
target_sources在不同子目录中分散添加源文件,建议在每个子目录的CMakeLists.txt中配合source_group使用。
预编译头(Precompiled Headers)配置:target_precompile_headers
在大型 C++ 项目中,大量模板库(如 STL、Boost、Eigen)和系统头文件的重复解析会占据相当多的编译时间。预编译头(PCH)可以将这些不常变动的头文件预先编译成二进制形式,后续编译时直接加载,从而显著提速。
CMake 3.16+ 的原生支持
CMake 3.16 引入了 target_precompile_headers 命令,原生支持 MSVC、GCC、Clang 的预编译头,且使用极其简单:
add_executable(my_app main.cpp utils.cpp)
target_precompile_headers(my_app PRIVATE
"<iostream>"
"<vector>"
"<string>"
"<map>"
"<memory>"
)
CMake 会自动处理:
- 生成合适的预编译头文件(如 MSVC 的
.pch或 GCC 的.gch)。 - 在编译目标源文件时自动添加预编译头使用标志。
- 当预编译头中的头文件列表发生变化时,自动重新生成。
使用外部预编译头文件
如果你的项目已经有了标准的预编译头文件(如 pch.h),可以这样引用:
target_precompile_headers(my_app PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/src/pch.h
)
PRIVATE vs PUBLIC
与 target_include_directories 类似,target_precompile_headers 也支持可见性:
PRIVATE:仅当前目标使用预编译头。PUBLIC:当前目标及依赖它的目标都会继承这些预编译头。这在接口库或基础库中很有用。
add_library(common_lib INTERFACE)
target_precompile_headers(common_lib INTERFACE
"<vector>"
"<string>"
)
add_executable(app main.cpp)
target_link_libraries(app PRIVATE common_lib)
# app 会自动使用 common_lib 的预编译头配置
禁用预编译头
如果某个源文件有特殊需求,不需要预编译头,可以在源文件属性中排除:
set_source_files_properties(special.cpp PROPERTIES SKIP_PRECOMPILE_HEADERS ON)
统一构建(Unity Build)加速编译:CMAKE_UNITY_BUILD
Unity Build(统一构建/合并构建)是另一种加速编译的技术。它的原理是:将多个 .cpp 文件通过 #include 合并成一个巨大的”Unity 文件”,再只编译这一个(或少数几个)大文件。这减少了编译器重复解析头文件和链接符号表的开销,在模板密集型项目中效果尤为明显。
CMake 3.16+ 的原生支持
CMake 提供了两种启用方式:
方式一:全局启用(推荐用于快速尝试)
set(CMAKE_UNITY_BUILD ON)
# 放在顶层 CMakeLists.txt 中,所有目标都会启用 Unity Build
方式二:针对单个目标启用
add_library(my_lib STATIC a.cpp b.cpp c.cpp d.cpp)
set_target_properties(my_lib PROPERTIES UNITY_BUILD ON)
Unity Build 的工作原理
假设 my_lib 有 a.cpp、b.cpp、c.cpp、d.cpp,CMake 会自动生成类似如下的 Unity 文件(通常位于构建目录中):
/* cmake_unity_0.cpp */
#include "/path/to/a.cpp"
#include "/path/to/b.cpp"
#include "/path/to/c.cpp"
#include "/path/to/d.cpp"
然后只编译这个 cmake_unity_0.cpp。原本 4 次编译+4 个目标文件的工作,变成了 1 次编译+1 个目标文件。
批量大小控制
你可以控制每个 Unity 文件中包含多少个原始源文件:
set_target_properties(my_lib PROPERTIES
UNITY_BUILD ON
UNITY_BUILD_BATCH_SIZE 8 # 每个 Unity 文件包含 8 个源文件
)
对于核心数量很多的机器,适当降低 UNITY_BUILD_BATCH_SIZE 可以生成多个 Unity 文件,从而利用并行编译。
常见问题与解决
Unity Build 并非银弹,它可能暴露代码中的内部链接污染问题:
问题 1:全局变量/函数重名
如果 a.cpp 和 b.cpp 中都定义了全局变量 int g_count = 0;(未加 static 或匿名命名空间),普通编译时它们在不同翻译单元中互不干扰;但在 Unity Build 中,它们被合并到同一个翻译单元,会导致重定义错误。
解决:将内部符号放入匿名命名空间:
// a.cpp
namespace {
int g_count = 0;
}
问题 2:使用了 __FILE__ 宏
如果代码依赖 __FILE__ 获取当前文件名,Unity Build 中它会指向生成的 Unity 文件路径。
解决:使用 __FILE_NAME__(部分编译器支持)或避免在日志中直接使用 __FILE__。
问题 3:宏定义冲突
a.cpp 中 #define DEBUG 1 可能影响后续被包含的 b.cpp。
解决:在源文件头部使用 #undef 清理,或规范宏的使用范围。
与预编译头配合使用
Unity Build 和预编译头(PCH)可以叠加使用,获得更大的编译速度提升。但 Unity Build 本身已经减少了很多头文件重复解析,所以 PCH 的边际收益会相对降低。建议先启用 Unity Build,如果仍不满足需求,再叠加 PCH。
小结
本节我们深入探讨了 CMake 源文件管理的完整知识体系:
- 显式列出源文件是最可靠、最推荐的做法,虽繁琐但可控。
- 自动搜集(
aux_source_directory/file(GLOB))有无法感知新增文件的致命缺陷,仅在原型阶段谨慎使用。 - target_include_directories 取代了全局的
include_directories,是 Modern CMake 管理头文件路径的标准方式。 - PRIVATE/PUBLIC/INTERFACE 是依赖传播的精髓,务必通过本节示例透彻理解。
- source_group 能优化 IDE 中的项目展示,提升大型项目的导航体验。
- target_precompile_headers 和 CMAKE_UNITY_BUILD 是 CMake 3.16+ 提供的编译加速利器,能显著缩短大型项目的构建时间。
掌握了源文件管理,你的 CMake 项目就已经具备了良好的骨架与血肉。下一节我们将继续深入,讲解如何通过 target_compile_definitions、target_compile_options 等命令精确控制编译与链接行为,让你的构建系统更加强大。


没有回复内容