6. 2.2 源文件管理

导语

在上一节中,我们学习了 CMake 中目标(Target)的概念,掌握了如何通过 add_executableadd_library 创建可执行文件与库。不过,仅有”目标”的骨架还不够,我们需要把真正的源代码”血肉”填充进去。正如盖房子不能只搭框架而不砌砖,一个 CMake 项目的好坏,很大程度上取决于源文件管理是否规范。

本节将系统讲解如何从 CMake 层面管理你的 .cpp.h 文件。你会学到显式与自动搜集源文件的取舍、头文件包含目录的正确添加方式、PRIVATE/PUBLIC/INTERFACE 这一 Modern CMake 核心传播机制,以及预编译头和 Unity Build 等高级编译加速技巧。请准备好你的代码编辑器,我们将通过大量实例来夯实这些基本功。

显式列出源文件:推荐的手动管理方式

在 Modern CMake 的最佳实践中,显式(Explicitly)列出每一个源文件是最受推荐的方式。虽然这看起来有些繁琐,但它能带来最精确的控制和最好的 IDE 支持。

基本做法

直接在 add_executableadd_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_librariestarget_compile_definitions 等命令。理解它们,你就掌握了 CMake 依赖传播的核心逻辑。

类比理解

想象你在写一个库 mylib,它有三种不同的”秘密”:

  1. PRIVATE:仅库内部实现使用的头文件/定义。好比你的”私人日记”,不对外公开,使用你库的人看不到。
  2. INTERFACE:库自身实现不需要,但库的头文件中引用了其他库的头文件。好比你的”公开声明中提到的外部参考资料”,你自己不看,但别人看你的声明时需要这些资料。
  3. 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 会自动获得 libBPUBLIC 包含目录(libB/include),但不会获得 libBPRIVATE 目录(libA/include)。同时,由于 libBlibA 的链接是 PRIVATEapp 甚至不会链接 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_liba.cppb.cppc.cppd.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.cppb.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_headersCMAKE_UNITY_BUILD 是 CMake 3.16+ 提供的编译加速利器,能显著缩短大型项目的构建时间。

掌握了源文件管理,你的 CMake 项目就已经具备了良好的骨架与血肉。下一节我们将继续深入,讲解如何通过 target_compile_definitionstarget_compile_options 等命令精确控制编译与链接行为,让你的构建系统更加强大。

请登录后发表评论

    没有回复内容

正在唤醒异次元光景……