现在我们已经掌握了足够的信息,可以开始讨论 CMake 的核心功能: 构建项目 。在 CMake 中,一个项目包含所有源文件以及管理解决方案实现过程所需的配置。配置从执行所有检查开始:验证目标平台是否受支持,确保所有基本依赖项和工具都存在,并确认提供的编译器与所需功能的兼容性。
初步检查完成后,CMake 会继续生成针对所选构建工具量身定制的构建系统。然后执行该构建系统,这意味着编译源文件并将其与各自的依赖项链接在一起,以创建输出成果物。
生成的产物可以通过多种方式分发给使用者。它们可以作为二进制包直接分享给用户,让他们通过包管理器在系统上安装;也可以发布为单一可执行安装程序。此外,终端用户还可以通过访问开源仓库中共享的项目自行创建这些产物。这种情况下,用户可以使用 CMake 在自己的机器上编译项目并进行安装。
充分发挥 CMake 项目的潜力能显著提升开发体验和生成代码的整体质量。通过利用 CMake 的强大功能,可以自动化许多常规任务,例如构建后执行测试、运行代码覆盖率检查器、格式化工具、验证器、代码检查工具等。这种自动化不仅能节省时间,还能确保开发过程中的一致性并提升代码质量。
要释放 CMake 项目的强大功能,我们首先需要做出几个关键决策:如何正确配置整个项目,以及如何划分项目并设置源码树结构,确保所有文件都能整齐地组织在正确的目录中。从一开始就建立清晰的结构和组织方式,才能有效管理和扩展不断演进的 CMake 项目。
接下来,我们将考察项目的构建环境。我们需要了解目标架构、可用工具及其支持的功能,以及所使用的语言标准。为确保所有环节协调一致,我们将编译一个测试用的 C++文件,验证所选编译器是否符合项目设定的标准要求。这一切都是为了确保项目、所用工具与选定标准之间能够无缝契合。
本章将涵盖以下主要内容:
理解基本指令和命令
项目划分
考虑项目结构
界定环境范围
配置工具链
禁用源码内构建
理解基本指令和命令
在第 1 章 《CMake 入门 》中,我们已经看过一个简单的项目定义。让我们重新回顾一下:这是一个包含 CMakeLists.txt 文件的目录,该文件含有若干配置语言处理器的命令。
cmake_minimum_required(VERSION 3.26)
project(Hello)
add_executable(Hello hello.cpp)
在同一章节的项目文件部分,我们学习了一些基本命令。下面将深入解释这些命令。
指定 CMake 最低版本
在项目文件和脚本的最顶部使用 cmake_minimum_required() 命令非常重要。该命令不仅验证系统是否安装了正确的 CMake 版本,还会隐式触发另一个命令 cmake_policy(VERSION) ,该命令用于指定项目使用的策略。这些策略定义了 CMake 中命令的行为方式,它们是在 CMake 发展过程中引入的,以适应支持语言和 CMake 本身的变更与改进。
为确保语言简洁明了,CMake 团队在每次引入不向后兼容的变更时都会制定相应的策略。每项策略启用与该变更相关的新行为。这些策略确保项目能够适应 CMake 不断演进的功能特性,同时保持与旧代码库的兼容性。
通过调用 cmake_minimum_required() ,我们告知 CMake 需要应用参数指定版本中配置的默认策略。当 CMake 升级时,我们无需担心它会破坏项目,因为新版本带来的策略不会被自动启用。
策略可能影响 CMake 的方方面面,包括 project() 等其他重要命令。因此,务必在 CMakeLists.txt 文件开头就设置您所使用的版本号,否则将会收到警告和错误提示。
每个 CMake 版本都会引入大量策略。不过除非你在将旧项目升级到最新 CMake 版本时遇到问题,否则无需深入研究细节。这种情况下,建议查阅官方策略文档以获取全面信息和指导:cmake-policies(7) — CMake 4.1.0-rc1 Documentation。
定义语言和元数据
建议将 project() 命令紧接在 cmake_minimum_required() 之后,尽管技术上并非必须。这样做能确保我们在配置项目时使用正确的策略。我们可以采用以下两种形式之一:
project(<PROJECT-NAME> [<language-name>...])
或者:
project(<PROJECT-NAME>
[VERSION <major>[.<minor>[.<patch>[.<tweak>]]]]
[DESCRIPTION <project-description-string>]
[HOMEPAGE_URL <url-string>]
[LANGUAGES <language-name>...])
我们需要指定 <PROJECT-NAME> ,但其他参数是可选的。调用此命令将隐式设置以下变量:
PROJECT_NAME
CMAKE_PROJECT_NAME (only in the top-level CMakeLists.txt)
PROJECT_IS_TOP_LEVEL, <PROJECT-NAME>_IS_TOP_LEVEL
PROJECT_SOURCE_DIR, <PROJECT-NAME>_SOURCE_DIR
PROJECT_BINARY_DIR, <PROJECT-NAME>_BINARY_DIR
支持哪些语言?相当多。而且你可以同时使用多种语言!以下是你可以在项目中配置使用的语言关键字列表:
ASM, ASM_NASM, ASM_MASM, ASMMARMASM, ASM-ATT :汇编语言的方言
C :C 语言
CXX: C++
CUDA : 英伟达统一计算设备架构
OBJC: Objective-C
OBJCXX: Objective-C++
Fortran: Fortran
HIP : 异构计算可移植性接口(适用于 Nvidia 和 AMD 平台)
ISPC : 隐式 SPMD 程序编译器语言
CSharp: C#
Java : Java(需要额外步骤,详见官方文档)
CMake 默认同时启用 C 和 C++,因此对于 C++ 项目,您可能需要明确指定仅 CXX 。原因在于: project() 命令会检测并测试所选语言可用的编译器,明确指定所需语言可节省配置阶段的时间,跳过对未使用语言的检查。
指定 VERSION 关键字将自动设置相关变量,这些变量可用于配置软件包,或在头文件中暴露以供编译时使用(我们将在配置头文件章节中详细讨论,见第 7 章 使用 CMake 编译 C++源代码 ):
PROJECT_VERSION, <PROJECT-NAME>_VERSION
CMAKE_PROJECT_VERSION (only in the top-level CMakeLists.txt)
PROJECT_VERSION_MAJOR, <PROJECT-NAME>_VERSION_MAJOR
PROJECT_VERSION_MINOR, <PROJECT-NAME>_VERSION_MINOR
PROJECT_VERSION_PATCH, <PROJECT-NAME>_VERSION_PATCH
PROJECT_VERSION_TWEAK, <PROJECT-NAME>_VERSION_TWEAK
我们还可以设置 DESCRIPTION 和 HOMEPAGE_URL ,这将为类似用途设置以下变量:
PROJECT_DESCRIPTION, <PROJECT-NAME>_DESCRIPTION
PROJECT_HOMEPAGE_URL, <PROJECT-NAME>_HOMEPAGE_URL
cmake_minimum_required() 和 project() 命令将帮助我们创建一个基础列表文件并初始化一个空项目。虽然对于小型单文件项目而言,结构可能不是重要问题,但随着代码库的扩展,这就变得至关重要。你该如何为此做好准备?
项目划分
随着解决方案的代码行数和文件数量不断增长,我们显然必须应对一个迫在眉睫的挑战:要么开始对项目进行分区,要么就要承受其复杂性带来的压力。我们可以通过两种方式解决这个问题:拆分 CMake 代码或将源文件重新定位到子目录中。无论采用哪种方式,我们的目标都是遵循名为已关注点分离的设计原则。简而言之,我们将代码分解为更小的部分,将紧密相关的功能归为一组,同时保持其他代码片段分离,以建立清晰的边界。
在讨论第 1 章 《CMake 入门》中的列表文件时,我们曾简要提及 CMake 代码的分区问题。我们介绍过 include() 命令,该命令允许 CMake 执行外部文件中的代码。
该方法有助于实现已关注点分离,但效果有限——专用代码被提取到单独文件中,甚至可以在不相关的项目间共享,但如果作者不够谨慎,其内部逻辑仍可能污染全局变量作用域。
你看,调用 include() 并不会引入超出文件已定义范围之外的任何额外作用域或隔离。让我们通过一个例子来看看为什么这可能是个问题:假设有一个支持小型汽车租赁公司的软件,它会有许多源文件来定义软件的不同方面:管理客户、汽车、停车位、长期合同、维修记录、员工记录等等。如果我们将所有这些文件放在一个目录下,查找任何内容都将是一场噩梦。因此,我们在项目的主目录中创建若干子目录,并将相关文件移入其中。我们的 CMakeLists.txt 文件可能类似这样:
cmake_minimum_required(VERSION 3.26.0)
project(Rental CXX)
add_executable(Rental
main.cpp
cars/car.cpp
# more files in other directories
)
这看起来不错,但正如你所见,我们仍然在顶层文件中保留了嵌套目录的源文件列表!为了进一步分离已关注点,我们可以将源文件列表提取到另一个列表文件中,并将其存储在 sources 变量中:
set(sources
cars/car.cpp
# more files in other directories
)
现在我们可以使用 include() 命令引用该文件来访问 sources 变量:
cmake_minimum_required(VERSION 3.26.0)
project(Rental CXX)
include(cars/cars.cmake)
add_executable(Rental
main.cpp
${sources} # for cars/
)
CMake 会在与 add_executable 相同的作用域内有效设置 sources ,用所有文件填充该变量。这个解决方案可行,但存在一些缺陷:
嵌套目录中的变量会污染顶层作用域(反之亦然):
虽然在简单示例中这不是问题,但在更复杂的多层级树结构中,当多个变量在流程中被使用时,它很快就会变成难以调试的问题。如果我们有多个包含的 listfile 都定义了它们的 sources 变量怎么办?
所有目录将共享相同的配置 :
随着项目逐年发展,这个问题会真正显现出来。由于缺乏粒度控制,我们不得不对所有源文件一视同仁,无法为部分代码指定不同的编译标志、选择更新的语言版本或在特定代码区域静默警告。一切都是全局性的,这意味着我们需要同时对所有翻译单元进行更改。
存在共享编译触发器 :
任何配置变更都将导致所有文件需要重新编译,即使某些文件的变更并无实际意义。
所有路径均相对于顶层目录 :
注意在 cars.cmake 中,我们必须为 cars/car.cpp 文件提供完整路径。这会导致大量重复文本破坏可读性,并违反简洁编码的不要重复自己 (DRY)原则(不必要的重复会导致错误)。重命名目录将会非常麻烦。
另一种方法是使用 add_subdirectory() 命令,它引入了变量作用域等功能。让我们来看看。
使用子目录管理作用域
按照文件系统的自然结构来组织项目是一种常见做法,其中嵌套目录代表应用程序的离散元素、业务逻辑、图形用户界面、应用程序接口和报告功能,最后还有包含测试、外部依赖项、脚本和文档的独立目录。为支持这一概念,CMake 提供了以下命令:
add_subdirectory(source_dir [binary_dir] [EXCLUDE_FROM_ALL])
如前所述,这会将一个源目录添加到我们的构建中。可选地,我们可以指定构建文件输出的路径( binary_dir 或构建树)。使用 EXCLUDE_FROM_ALL 关键字可禁用子目录中定义目标的自动构建(我们将在下一章详细讨论目标 )。这对于分离项目中非核心功能部分(如示例或扩展模块 )非常有用。
add_subdirectory() 将评估 source_dir 路径(相对于当前目录)并解析其中的 CMakeLists.txt 文件。该文件会在目录作用域内进行解析,从而避免了前文方法中提到的问题:
变量被隔离在嵌套作用域内。
嵌套构件可独立配置。
修改嵌套的 CMakeLists.txt 文件无需重建无关目标。
路径被限定在目录本地,若需要可添加到父级包含路径中。
这是我们示例的目录结构:
├── CMakeLists.txt
├── cars
│ ├── CMakeLists.txt
│ ├── car.cpp
│ └── car.h
└── main.cpp
这里有两个 CMakeLists.txt 文件。顶层文件将使用嵌套目录 cars :
cmake_minimum_required(VERSION 3.26.0)
project(Rental CXX)
add_executable(Rental main.cpp)
add_subdirectory(cars)
target_link_libraries(Rental PRIVATE cars)
最后一行用于将来自 cars 目录的构件链接到 Rental 可执行文件。这是一个特定于目标的命令,我们将在下一章第 5 章 、 目标操作中深入讨论。
让我们看看嵌套的列表文件是什么样子的:
add_library(cars OBJECT
car.cpp
# more files in other directories
)
target_include_directories(cars PUBLIC .)
在本示例中,我使用 add_library() 创建了一个全局可见的目标 cars ,并通过 target_include_directories() 将 cars 目录添加至其公共包含目录 。这告知了 CMake cars.h 所在位置,因此当使用 target_link_libraries() 时, main.cpp 文件可直接引用头文件而无需提供相对路径:
#include "car.h"
我们可以在嵌套的列表文件中看到 add_library() 命令,那么在这个例子中我们是否开始处理库了呢?实际上并没有。由于我们使用了 OBJECT 关键字,这表明我们只对生成目标文件感兴趣(正如我们在上一个例子中所做的那样)。我们只是将它们分组到一个逻辑目标( cars )下。你可能已经对目标是什么有了概念。先记住这个想法——我们将在下一章详细解释。
何时使用嵌套项目
在前一节中,我们简要提到了 add_subdirectory() 命令中使用的 EXCLUDE_FROM_ALL 参数,用于标识代码库中的非必要元素。CMake 文档建议,如果这些部分存在于源码树内部,它们应该在各自的 CMakeLists.txt 文件中包含 project() 命令,以便生成独立的构建系统并能单独构建。
还有哪些场景下这种做法会派上用场?当然有。例如,当你处理多个 C++项目共用一个 CI/CD 流水线时(可能是构建框架或库集合的情况)。又或者,你正在从 GNU Make 等使用普通 makefiles 的遗留解决方案迁移构建系统。这种情况下,你可能需要逐步将内容分解为更独立的模块——可能是为了放入单独的构建流水线,或是为了在 CLion 等 IDE 中缩小工作范围。通过在嵌套目录的列表文件中添加 project() 命令即可实现这一目标,只需记得在前面加上 cmake_minimum_required() 。
既然支持项目嵌套,我们能否以某种方式将并行构建的相关项目连接起来?
保持外部项目的独立性
虽然从技术上讲,在 CMake 中可以从一个项目引用另一个项目的内部内容,但这并非常规或推荐做法。CMake 确实提供了一些支持,包括使用 load_cache() 命令从其他项目的缓存中加载值。然而,采用这种方法可能导致循环依赖和项目耦合问题。最好避免使用此命令,而是做出决策:我们的相关项目应该采用嵌套结构、通过库连接,还是合并为单个项目?
我们可用的分区工具包括: 列表文件 、 添加子目录和嵌套项目 。但应如何使用这些工具,才能让项目保持可维护性,并易于导航和扩展?为此,我们需要一个定义明确的项目结构。
考虑项目结构
众所周知,随着项目规模扩大,无论是列表文件还是源代码中,查找内容都会变得越来越困难。因此,从一开始就保持良好的项目卫生至关重要。
设想这样一个场景:你需要提交一些重要且时效性强的修改,但这些修改在项目的两个目录中都不太合适。于是,你不得不额外推送一个清理提交来重构文件层次结构,以便妥善安置这些修改。更糟的情况是,你可能决定随便找个地方塞进去,并添加一个 TODO 标记,留待日后处理。
一年下来,这些问题不断累积,技术债务日益增长,维护代码的成本也随之攀升。当生产系统出现需要紧急修复的严重缺陷时,或是当不熟悉代码库的人员需要偶尔进行修改时,这种情况就会变得极其棘手。
因此,我们需要一个良好的项目结构。但这具体意味着什么?我们可以借鉴系统设计等软件开发其他领域的一些规则。项目应具备以下特征:
易于导航和扩展
良好边界(项目特定文件应限制在项目目录内)
独立目标遵循层次结构树
虽然没有一个绝对正确的解决方案,但在网上各种项目结构模板中,我推荐使用这个方案,因为它既简单又可扩展:

本项目为以下组件规划了目录结构:
cmake : 共享宏与函数、模块查找工具及一次性脚本
src : 二进制文件与库的源文件及头文件
test : 自动化测试源代码
在此结构中, CMakeLists.txt 文件应存在于以下目录中:项目顶层目录、 test 目录以及 src 目录及其所有子目录。主列表文件本身不应声明任何构建步骤,而应配置项目的总体方面,并通过 add_subdirectory() 命令将构建职责委托给嵌套的列表文件。反过来,这些列表文件在需要时可以将工作进一步委托给更深层的文件。
部分开发者建议将可执行文件与库文件分离,创建两个顶层目录而非一个: src 和 lib 。CMake 对这两种产物一视同仁,在此层级进行分离并无实质影响。若您偏好此模式,可自由采用。
在 src 目录中设置多个子目录对大型项目非常实用。但若您仅构建单个可执行文件或库,则可跳过这些子目录,直接将源文件存放在 src 目录中。无论哪种情况,请记得在该目录中添加 CMakeLists.txt 文件并执行所有嵌套列表文件。以下是一个简单独立目标的文件树可能呈现的结构:

在图 4.1 中,我们可以看到 src 目录根部的 CMakeLists.txt 文件——它将配置关键项目设置并包含来自嵌套目录的所有列表文件。 app1 目录(如图 4.2 所示)包含另一个 CMakeLists.txt 文件以及 .cpp 实现文件: class_a.cpp 和 class_b.cpp 。此外还有包含可执行程序入口点的 main.cpp 文件。 CMakeLists.txt 文件应定义一个使用这些源代码构建可执行文件的目标——我们将在下一章学习具体操作方法。
我们的头文件存放在 include 目录中,可用于为其他 C++翻译单元声明符号。
接下来,我们有一个 lib3 目录,它仅包含该可执行文件专用的库(项目中其他地方使用或对外导出的库应存放在 src 目录中)。这种结构提供了极大的灵活性,便于项目扩展。随着我们不断添加更多类,可以方便地将它们分组到库中以提高编译速度。让我们看看一个库的具体构成:

库文件应遵循与可执行文件相同的结构,仅存在一处细微差别:在包含目录中会添加一个可选的 lib1 目录。当该库需要被项目外部使用时,此目录将被包含其中。它存放着其他项目在编译时需要使用的公共头文件。我们将在第 7 章 《 使用 CMake 编译 C++源代码 》开始构建自己的库时重新讨论这一主题。
至此,我们已经讨论了文件在目录结构中的布局方式。现在,是时候来看看各个 CMakeLists.txt 文件如何共同构成一个项目,以及它们在更宏观场景中所扮演的角色了。

在前面的图表中,每个方框代表位于各目录中的 CMakeLists.txt 列表文件,而斜体标签则表示每个文件执行的操作(从上到下)。让我们再次从 CMake 的角度分析这个项目(完整细节请查看 ch04/05-structure 目录中的示例):
执行从项目的根目录开始——即位于源代码树顶层的 CMakeLists.txt 列表文件。该文件将设置所需的最低 CMake 版本及相应策略,配置项目名称、支持的语言和全局变量,并包含 cmake 目录中的文件,使其内容全局可用。
下一步是通过调用 add_subdirectory(src bin) 命令进入 src 目录的作用域(我们希望将编译产物放在 <binary_tree>/bin 而非 <binary_tree>/src 中)。
CMake 读取 src/CMakeLists.txt 文件后发现其唯一用途是添加四个嵌套子目录: app1 、 app2 、 lib1 和 lib2 。
CMake 进入 app1 的变量作用域后,会识别出另一个嵌套库 lib3 (该库拥有自己的 CMakeLists.txt 文件);接着进入 lib3 的作用域。您可能已经注意到,这是对目录结构的深度优先遍历。
lib3 库添加了一个同名的静态库目标。CMake 返回到 app1 的父作用域。
app1 子目录添加了一个依赖于 lib3 的可执行文件。CMake 返回到 src 的父作用域。
CMake 将继续进入剩余的嵌套作用域并执行它们的列表文件,直到所有 add_subdirectory() 调用都已完成。
CMake 返回到顶层作用域并执行剩余的命令 add_subdirectory(test) 。每次 CMake 进入新作用域时,都会从相应的列表文件中执行命令。
所有目标均已收集并验证其正确性。CMake 现已具备生成构建系统所需的全部信息。
需要注意的是,前面这些步骤的执行顺序与我们写在列表文件中的命令顺序完全一致。在某些情况下,这个顺序很重要,而在其他情况下可能不那么关键。我们将在下一章第 5 章 《 目标对象操作 》中更深入地探讨这个话题,以理解其影响。
那么,何时才是创建目录来容纳项目所有元素的合适时机呢?我们应该从一开始就创建未来所需的所有目录并保持其空置状态,还是等到实际有文件需要归类时再创建?这是一个选择问题——我们可以遵循极限编程 (XP)的 YAGNI 原则( 你不需要它 ),也可以尝试使项目具有前瞻性,为未来加入的新开发者奠定良好基础。
尝试在这些方法之间取得良好的平衡——如果你怀疑项目将来可能需要一个 extern 目录,那就添加它(你的版本控制系统可能需要一个空的 .keep 文件才能将目录检入仓库)。
另一种有效指导他人安排外部依赖关系的方法是创建一个 README 文件,用于概述推荐的项目结构。这对于未来参与项目的经验较少的程序员尤其有益。你可能已经注意到:开发人员往往不愿意创建目录,特别是在项目根目录下。如果我们能提供一个良好的项目结构,其他人自然会倾向于遵循它。
有些项目几乎可以在任何环境中构建,而另一些则对构建环境有特定要求。顶级列表文件正是确定适当操作方案的理想场所。下面我们来看看具体如何实现。
界定环境范围
CMake 提供了多种查询环境的方式,包括 CMAKE_ 变量、 ENV 变量以及特殊命令。例如,收集到的信息可用于支持跨平台脚本。这些机制使我们能够避免使用特定于平台的 shell 命令,这些命令可能不易移植或在不同的环境中命名方式不同。
对于性能关键型应用,了解构建平台的所有特性(例如指令集、CPU 核心数等)将非常有用。这些信息随后可以传递给编译后的二进制文件,以便对其进行完美调优(我们将在下一章学习如何传递这些信息)。让我们来探索 CMake 提供的原生信息。
检测操作系统
了解目标操作系统是什么在很多情况下都很有用。即使是像文件系统这样普通的事物,在 Windows 和 Unix 之间也存在巨大差异,比如大小写敏感性、文件路径结构、扩展名的存在、权限等等。一个系统上的大多数命令在另一个系统上可能不可用;它们可能有不同的名称(例如,Unix 上的 ifconfig 和 Windows 上的 ipconfig )或者产生完全不同的输出。
如果你需要用单个 CMake 脚本支持多个目标操作系统,只需检查 CMAKE_SYSTEM_NAME 变量,以便采取相应措施。这里有一个简单的例子:
if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
message(STATUS "Doing things the usual way")
elseif(CMAKE_SYSTEM_NAME STREQUAL "Darwin")
message(STATUS "Thinking differently")
elseif(CMAKE_SYSTEM_NAME STREQUAL "Windows")
message(STATUS "I'm supported here too.")
elseif(CMAKE_SYSTEM_NAME STREQUAL "AIX")
message(STATUS "I buy mainframes.")
else()
message(STATUS "This is ${CMAKE_SYSTEM_NAME} speaking.")
endif()
如有需要,可使用包含操作系统版本的变量: CMAKE_SYSTEM_VERSION 。但建议尽量使解决方案与系统无关,利用 CMake 内置的跨平台功能。特别是文件系统操作时,应使用附录中所述的 file() 命令。
交叉编译——什么是主机系统与目标系统?
交叉编译指的是在一台机器上编译代码,以便在不同的目标平台上执行的过程。例如,使用适当的工具集,可以在 Windows 机器上运行 CMake 来编译 Android 应用程序。虽然交叉编译超出了本书的范围,但理解它如何影响 CMake 的某些部分非常重要。
实现交叉编译的必要步骤之一是将 CMAKE_SYSTEM_NAME 和 CMAKE_SYSTEM_VERSION 变量设置为适合目标操作系统的值(CMake 文档中称之为目标系统 )。用于执行构建的操作系统则称为主机系统 。
无论配置如何,主机系统的信息始终可通过名称中带有 HOST 关键字的变量访问: CMAKE_HOST_SYSTEM 、 CMAKE_HOST_SYSTEM_NAME 、 CMAKE_HOST_SYSTEM_PROCESSOR 和 CMAKE_HOST_SYSTEM_VERSION 。
还有几个变量名称中带有 HOST 关键字,请注意这些变量明确引用的是主机系统。除此之外,所有变量都引用目标系统(通常情况下目标系统就是主机系统,除非我们正在进行交叉编译)。
若想了解更多关于交叉编译的内容,建议参考 CMake 官方文档 cmake-toolchains(7) — CMake 4.1.0-rc1 Documentation。
缩写变量
CMake 会预定义若干变量,这些变量将提供主机与目标系统的相关信息。当使用特定系统时,相应的变量会被设为非 false 值(即 1 或 true ):
ANDROID, APPLE, CYGWIN, UNIX, IOS, WIN32, WINCE, WINDOWS_PHONE
CMAKE_HOST_APPLE, CMAKE_HOST_SOLARIS, CMAKE_HOST_UNIX, CMAKE_HOST_WIN32
对于 32 位和 64 位 Windows 及 MSYS 系统(该值因历史原因保留), WIN32 和 CMAKE_HOST_WIN32 变量将显示为 true 。此外,在 Linux、macOS 和 Cygwin 系统上, UNIX 变量会显示为 true 。
主机系统信息
CMake 本可以提供更多变量,但为了节省时间,它不会主动查询环境中那些很少需要的信息,比如处理器是否支持 MMX 指令集或物理内存总量是多少 。这并不意味着这些信息不可获取——您只需通过以下命令显式请求即可:
cmake_host_system_information(RESULT <VARIABLE> QUERY <KEY>...)
我们需要提供一个目标变量和一组我们感兴趣的键。如果只提供一个键,变量将包含单个值;否则,它将是一个值列表。我们可以获取关于环境和操作系统的许多详细信息:
|
Key |
描述 |
|
|
主机名 |
|
|
完全限定域名 |
|
|
总虚拟内存(MiB) |
|
|
可用虚拟内存(单位:MiB) |
|
|
物理内存总量(单位:MiB) |
|
|
可用物理内存(单位:MiB) |
|
|
若存在此命令则输出 |
|
|
操作系统子类型,例如 |
|
|
操作系统构建 ID |
|
|
On 在 Windows 系统中, |
如果需要,我们甚至可以查询处理器特定信息:
|
Key |
描述 |
|
|
逻辑核心数量 |
|
|
物理核心数量 |
|
|
|
|
|
处理器序列号 |
|
|
人类可读的处理器名称 |
|
|
人类可读的完整处理器描述 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
该平台是32位还是64位架构?
在64位架构中,内存地址、处理器寄存器、处理器指令、地址总线和数据总线均为64位宽。虽然这是简化定义,但能大致说明64位平台与32位平台的区别。
在 C++中,不同的架构意味着某些基本数据类型( int 和 long )以及指针具有不同的位宽。CMake 利用指针大小来收集目标机器的信息。这些信息可通过 CMAKE_SIZEOF_VOID_P 变量获取,对于 64 位系统其值为 8 (因为指针宽度为 8 字节),对于 32 位系统则为 4 (4 字节):
if(CMAKE_SIZEOF_VOID_P EQUAL 8)
message(STATUS "Target is 64 bits")
endif()
系统的字节序是什么?
架构可以根据处理器中字或自然数据单元内的字节顺序分为大端序或小端序 。在大端序系统中,最高有效字节存储在最低内存地址,而最低有效字节存储在最高内存地址。相反,在小端序系统中,字节顺序是相反的,最低有效字节存储在最低内存地址,最高有效字节存储在最高内存地址。
在大多数情况下, 字节序无关紧要,但当你编写需要跨平台运行的位操作代码时,CMake 会通过 CMAKE_<LANG>_BYTE_ORDER 变量提供 BIG_ENDIAN 或 LITTLE_ENDIAN 值,其中 <LANG> 可能是 C 、 CXX 、 OBJC 或 CUDA 。
既然我们已经了解了如何查询环境信息,现在让我们将注意力转向项目的关键设置。
配置工具链
对于 CMake 项目而言,工具链包含了构建和运行应用程序所需的所有工具——例如工作环境、生成器、CMake 可执行文件本身以及编译器。
想象一下,当一位经验较少的用户遇到构建因神秘的编译和语法错误而中断时的心情。他们不得不深入源代码,试图理解发生了什么。经过一小时的调试后,才发现正确的解决方案是更新编译器。我们能否为用户提供更好的体验,在开始构建前就检查编译器中是否包含所有必需的功能呢?
当然!有多种方式可以指定这些需求。如果工具链不支持所有必需功能,CMake 会提前终止并显示明确的问题提示,要求用户介入处理。
设置 C++标准
我们可以考虑的第一步是指定编译器构建项目时需要支持的 C++标准。对于新项目,建议至少设置为 C++14,但最好选择 C++17 或 C++20。从 CMake 3.20 开始,如果编译器支持,还可以将所需标准设为 C++23。此外,自 CMake 3.25 起,虽然目前只是占位符,但已有选项可将标准设为 C++26。
C++11 正式发布已超过 10 年,它已不再被视为现代 C++标准 。除非目标环境非常陈旧,否则不建议使用此版本启动项目。
坚持使用旧标准的另一个原因是构建难以升级的遗留目标。不过,C++委员会非常努力地保持 C++向后兼容,在大多数情况下,将标准提升到更高版本不会遇到任何问题。
CMake 支持基于逐个目标设置标准(这在代码库部分非常老旧时很有用),但最好在整个项目中统一采用单一标准。可以通过将 CMAKE_CXX_STANDARD 变量设置为以下值之一来实现: 98 、 11 、 14 、 17 、 20 、 23 或 26 ,例如:
set(CMAKE_CXX_STANDARD 23)
这将成为所有后续定义目标的默认值(因此最好将其设置在根列表文件的顶部附近)。如有需要,可按目标单独覆盖此设置,如下所示:
set_property(TARGET <target> PROPERTY CXX_STANDARD <version>)
或者:
set_target_properties(<targets> PROPERTIES CXX_STANDARD <version>)
第二版允许我们在需要时指定多个目标。
坚持标准支持
上一节提到的 CXX_STANDARD 属性不会阻止 CMake 继续构建,即使编译器不支持所需版本——它被视为一种偏好设置。CMake 无法判断我们的代码是否实际使用了旧版编译器不具备的全新功能,它会尝试基于当前可用的资源进行构建。
若我们确信这不会成功,可以设置另一个变量(该变量可像前一个那样按目标覆盖)来明确指定我们所需的标准:
set(CMAKE_CXX_STANDARD_REQUIRED ON)
在此情况下,若系统中现有的编译器不支持所需标准,用户将看到以下提示信息且构建过程会终止:
Target "Standard" requires the language dialect "CXX23" (with compiler extensions), but CMake does not know the compile flags to use to enable it.
要求使用 C++23 可能有些过分,即便在现代开发环境中也是如此。但在更新至最新版本的系统上,C++20 应该没有问题,因为自 2021/2022 年起 GCC/Clang/MSVC 已普遍支持该标准。
厂商特定扩展
根据您所在组织实施的策略,您可能对允许或禁用供应商特定扩展感兴趣。这些是什么?简单来说,由于 C++标准的发展速度无法满足某些编译器厂商的需求,他们决定自行对语言进行增强——您也可以称之为扩展 。例如,C++ 技术报告 1(TR1)作为一个库扩展,在正则表达式、智能指针、哈希表和随机数生成器成为标准功能之前就引入了这些特性。为支持 GNU 项目发布的此类插件,CMake 会将负责标准( -std=c++14 )的编译器标志替换为 -std=gnu++14 。
一方面,这可能是期望的效果,因为它能提供一些便捷的功能。另一方面,你的代码将失去可移植性,因为如果切换到不同的编译器(或用户这样做时),它将无法构建。这也是一个针对特定目标的属性,存在一个默认变量 CMAKE_CXX_EXTENSIONS 。CMake 在这方面更为宽松,除非我们明确禁止,否则它允许使用扩展功能:
<span><span><code><span>set</span>(CMAKE_CXX_EXTENSIONS OFF)
</code></span></span>
如有可能,我建议这样做,因为该选项将坚持使用与供应商无关的代码。此类代码不会对用户施加任何不必要的要求。与之前的选项类似,您可以使用 set_property() 来针对每个目标单独更改此值。
过程间优化
通常,编译器在单个翻译单元级别进行代码优化,这意味着您的 .cpp 文件将被预处理、编译,然后优化。这些操作生成的中间文件随后传递给链接器以创建单个二进制文件。然而,现代编译器具备在链接时执行过程间优化的能力,也称为链接时优化 。这使得所有编译单元能够作为一个统一模块进行优化,原则上将获得更好的结果(有时以构建速度变慢和内存消耗增加为代价)。
如果您的编译器支持过程间优化,使用它可能是个好主意。我们将采用相同的方法。负责此设置的变量称为 CMAKE_INTERPROCEDURAL_OPTIMIZATION 。但在设置之前,我们需要确认其支持以避免错误:
include(CheckIPOSupported)
check_ipo_supported(RESULT ipo_supported)
set(CMAKE_INTERPROCEDURAL_OPTIMIZATION ${ipo_supported})
如您所见,我们必须包含一个内置模块来访问 check_ipo_supported() 命令。这段代码会优雅地处理失败情况,如果优化不被支持,则会回退到默认行为。
检查支持的编译器功能
正如我们之前讨论的,如果构建失败,最好尽早失败,这样我们可以向用户提供清晰的反馈信息并缩短等待时间。有时我们特别已关注哪些 C++功能受支持(哪些不支持)。CMake 会在配置阶段询问编译器,并将可用功能列表存储在 CMAKE_CXX_COMPILE_FEATURES 变量中。我们可以编写非常具体的检查来询问某个功能是否可用:
list(FIND CMAKE_CXX_COMPILE_FEATURES cxx_variable_templates result)
if(result EQUAL -1)
message(FATAL_ERROR "Variable templates are required for compilation.")
endif()
正如你所猜测的,为每个使用的功能编写检测是一项艰巨的任务。就连 CMake 的作者也建议仅检查某些高级元功能是否存在: cxx_std_98 、 cxx_std_11 、 cxx_std_14 、 cxx_std_17 、 cxx_std_20 、 cxx_std_23 和 cxx_std_26 。每个元功能都表示编译器支持特定的 C++标准。如果需要,你可以像我们在前一个示例中那样直接使用它们。
CMake 已知功能的完整列表可在文档中找到:CMAKE_CXX_KNOWN_FEATURES — CMake 4.1.0-rc1 Documentation。
编译测试文件
我在使用 GCC 4.7.x 编译应用程序时遇到了一个特别有趣的情况。我已在编译器参考手册中手动确认我们使用的所有 C++11 功能都受支持。然而,解决方案仍然无法正常工作。代码静默忽略了标准 <regex> 头文件的调用。后来发现是这个特定编译器存在 bug,没有实现 regex 库。
没有任何单一检查能完全防范这类罕见错误(你也不应该需要检查它们!),但有时你可能想使用最新标准中的某些前沿实验性功能,却不确定哪些编译器支持这些特性。这时,你可以通过创建测试文件来验证项目能否正常工作——在小型示例中运用这些特殊需求的功能,快速完成编译和执行测试。
CMake 提供了两个配置时命令, try_compile() 和 try_run() ,用于验证目标平台是否支持所需的所有功能。
try_run() 命令为您提供了更大的自由度,因为您不仅能确保代码通过编译,还能验证其正确执行(例如可以测试 regex 是否正常工作)。当然,这种方式不适用于交叉编译场景(因为主机无法运行针对不同目标平台构建的可执行文件)。需要注意的是,此检查的目的是为用户提供编译是否成功的快速反馈,因此不应运行任何单元测试或复杂操作——保持文件尽可能简单。例如像这样的代码:
#include <iostream>
int main()
{
std::cout << "Quick check if things work." << std::endl;
}
调用 try_run() 其实并不复杂。我们首先设定所需的标准,然后调用 try_run() 并将收集到的信息打印给用户:
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
try_run(run_result compile_result
${CMAKE_BINARY_DIR}/test_output
${CMAKE_SOURCE_DIR}/main.cpp
RUN_OUTPUT_VARIABLE output)
message("run_result: ${run_result}")
message("compile_result: ${compile_result}")
message("output:
" ${output})
这个命令乍看可能令人望而生畏,但实际上只需几个必要参数就能编译并运行一个非常基础的测试文件。此外,我还使用了可选的 RUN_OUTPUT_VARIABLE 关键字来收集 stdout 的输出结果。
下一步是通过使用一些我们将在实际项目中采用的更现代的 C++特性来扩展我们的基础测试文件——比如添加一个可变参数模板,以验证目标机器上的编译器能否处理它。
最后,我们可以在条件块中检查收集的输出是否符合预期,并在出现问题时打印 message(SEND_ERROR <error>) 。记住, SEND_ERROR 关键字会让 CMake 继续执行配置阶段,但会阻止生成构建系统。这有助于在终止构建前显示所有遇到的错误。现在我们已经掌握了如何确保编译能完整完成的方法。让我们继续下一个主题:禁用源码内构建。
禁用源码内构建
在第 1 章 CMake 入门中,我们讨论了源码内构建,并建议始终指定构建路径为源码外构建。这不仅能使构建树更整洁、 .gitignore 文件更简洁,还能降低意外覆盖或删除源文件的风险。
如需提前终止构建,可使用以下检查:
cmake_minimum_required(VERSION 3.26.0)
project(NoInSource CXX)
if(PROJECT_SOURCE_DIR STREQUAL PROJECT_BINARY_DIR)
message(FATAL_ERROR "In-source builds are not allowed")
endif()
message("Build successful!")
如需了解更多关于 STR 前缀和变量引用的信息,请重新查阅第 2 章 CMake 语言 。
不过请注意,无论你在前面的代码中如何操作,CMake 似乎仍会创建一个 CMakeFiles/ 目录和一个 CMakeCache.txt 文件。
网上可能有建议使用未公开变量来确保用户在任何情况下都无法写入源目录。但不推荐依赖未公开变量来限制源目录写入权限,这些变量可能并非在所有版本中都有效,且可能未经警告就被移除或修改。
如果您担心用户会将这些文件留在源目录中,可以将它们添加到 .gitignore (或等效项)中,并将提示信息更改为要求手动清理。
总结
本章介绍了为构建健壮且面向未来的项目奠定坚实基础的重要概念。我们探讨了如何设置最低 CMake 版本,以及配置项目名称、支持语言和元数据字段等核心要素。这些基础工作的确立将使项目具备良好的可扩展性。
我们探讨了项目分区方法,对比了基础 include() 与 add_subdirectory 的使用,后者具有作用域变量管理、路径简化和增强模块化等优势。创建嵌套项目并分别构建的能力,在将代码逐步拆分为更独立单元时展现出实用价值。在理解现有分区机制后,我们深入研究了如何构建透明、健壮且可扩展的项目结构。我们分析了 CMake 遍历列表文件的流程及配置步骤的正确顺序。接着,我们研究了如何限定目标机与宿主机的环境范围,它们之间的差异所在,以及通过不同查询能获取哪些关于平台和系统的信息。此外,我们还涵盖了工具链配置内容,包括指定所需 C++版本、处理厂商特定的编译器扩展以及启用重要优化选项。我们掌握了如何测试编译器对必要特性的支持,并通过执行示例文件来验证编译支持情况。
尽管目前所涉及的技术要点对项目至关重要,但要让项目真正实用,这些还远远不够。为了提升项目的实用性,我们需要理解目标这一概念。虽然之前简单提及过这个话题,但现在我们已具备扎实的基础知识,可以全面探讨它了。下一章将介绍的目标概念,将在进一步提升项目功能和效能方面发挥关键作用。


















暂无评论内容