导语
在上一节中,我们学习了如何通过 add_subdirectory 将大型项目拆分为多个子目录,让每个模块拥有独立的 CMakeLists.txt,从而建立起清晰的目录层级。然而,随着项目规模进一步扩大,你会逐渐发现:不同子目录之间常常需要重复编写相似的配置逻辑。例如,每个可执行文件目标都需要统一设置 C++ 标准、开启一致的编译警告、或者链接同一套第三方库。
如果把这些重复逻辑散布在各处的 CMakeLists.txt 中,一旦需要调整(比如把警告级别从 -Wall 提升到 -Wall -Wextra -Werror),你就得逐个文件修改——这显然违背了”Don’t Repeat Yourself”的原则。
本节要解决的正是这个问题:如何将 CMake 配置逻辑提取为可复用的模块、函数与宏,并在整个项目中安全、规范地共享它们。这是从”会写 CMake”迈向”写好 CMake”的关键一步。
1. include 指令与模块文件
在 C/C++ 中,我们通过 #include 引入头文件来复用声明;在 CMake 中,对应的机制是 include() 指令,用于加载以 .cmake 为后缀的模块文件。被 include 的文件会原地展开,就像把该文件的内容直接复制粘贴到调用处一样。
1.1 基本用法
假设我们在项目根目录下创建一个 cmake/ 目录,用于存放所有的复用模块:
# cmake/CompilerWarnings.cmake
if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU" OR CMAKE_CXX_COMPILER_ID MATCHES "Clang")
set(MY_PROJECT_WARNINGS
-Wall
-Wextra
-Wpedantic
-Wshadow
-Wconversion
)
elseif(CMAKE_CXX_COMPILER_ID STREQUAL "MSVC")
set(MY_PROJECT_WARNINGS /W4 /permissive-)
endif()
然后在主 CMakeLists.txt 中引入它:
cmake_minimum_required(VERSION 3.20)
project(MyProject)
# 加载模块
include(cmake/CompilerWarnings.cmake)
add_executable(app main.cpp)
target_compile_options(app PRIVATE ${MY_PROJECT_WARNINGS})
1.2 include 与 add_subdirectory 的核心区别
很多初学者容易混淆 include 和 add_subdirectory,它们的关键差异如下:
- 作用域:
add_subdirectory会进入子目录,创建新的变量作用域;而include在当前作用域内”原地展开”,不创建新的作用域。这意味着include中设置的变量在调用处依然可见(也更容易造成污染,后面会讲如何避免)。 - 目标归属:
add_subdirectory中创建的目标(add_executable/add_library)仍然属于整个构建系统;而include的文件只是配置片段,不能单独构成一个子项目。 - 适用场景:
add_subdirectory适合管理有独立源码的组件;include适合抽取纯配置逻辑或工具函数。
2. CMake 模块搜索路径
上面的例子中我们使用了相对路径 include(cmake/CompilerWarnings.cmake)。但如果模块文件很多,或者你想像使用官方 FindXXX.cmake 那样直接写 include(MyHelpers),就需要配置模块搜索路径。
2.1 CMAKE_MODULE_PATH 的工作原理
CMake 在解析 include(<mod-name>) 或 find_package(<pkg>)(Module 模式)时,会依次搜索以下路径:
- CMake 内置模块目录(如
/usr/share/cmake/Modules) CMAKE_MODULE_PATH中列出的所有目录
2.2 配置自定义模块路径
最佳实践是在项目根目录的 CMakeLists.txt 顶部,将项目专属的 cmake/ 目录追加到 CMAKE_MODULE_PATH:
list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake")
# 现在可以直接通过模块名引入,无需相对路径
include(CompilerWarnings)
include(Utils)
注意:务必使用 list(APPEND ...) 而不是 set(CMAKE_MODULE_PATH ...),否则可能会覆盖其他工具链或父项目已经设置好的路径。
2.3 模块文件命名规范
- 文件名使用大驼峰(PascalCase),如
CompilerWarnings.cmake、StandardProjectSettings.cmake - 文件名应与内容主题一致,避免使用泛泛的名字如
common.cmake、utils.cmake - 模块内不应直接创建目标(
add_executable/add_library),除非该模块的设计意图就是封装一个目标的创建模板
3. 编写可复用的 CMake 函数
当逻辑复杂到变量替换无法胜任时,就应该使用 function() 将其封装为可调用的命令。函数拥有独立的作用域,参数传递清晰,是 Modern CMake 中组织复用逻辑的首选。
3.1 函数基础与参数访问
CMake 函数的参数没有类型,全部以字符串形式传入,通过位置参数名或 ARGN、ARGC、ARGV 等特殊变量访问。
# cmake/TargetHelpers.cmake
function(add_warnings TARGET_NAME)
# ARGV0 = TARGET_NAME
# ARGN = 其余所有参数(本例中没有)
message(STATUS "为目标 ${TARGET_NAME} 添加编译警告")
if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU" OR CMAKE_CXX_COMPILER_ID MATCHES "Clang")
target_compile_options(${TARGET_NAME} PRIVATE
-Wall -Wextra -Wpedantic
)
elseif(CMAKE_CXX_COMPILER_ID STREQUAL "MSVC")
target_compile_options(${TARGET_NAME} PRIVATE /W4)
endif()
endfunction()
调用方式:
add_executable(demo src/demo.cpp)
add_warnings(demo)
3.2 函数的作用域规则
函数内部通过 set() 创建的变量默认只在函数内部可见,不会污染调用方。如果需要返回值,可以通过 PARENT_SCOPE 显式提升:
function(get_source_files RESULT_VAR)
set(${RESULT_VAR} main.cpp utils.cpp helper.cpp PARENT_SCOPE)
endfunction()
get_source_files(MY_SOURCES)
message(STATUS "源码列表: ${MY_SOURCES}") # 输出: main.cpp;utils.cpp;helper.cpp
重要提示:PARENT_SCOPE 只会向上提升一层。如果你在函数 A 中调用函数 B,B 中设置 PARENT_SCOPE,则变量会提升到 A 的作用域,而不是 A 的调用方。
3.3 函数文档规范
为了让团队其他成员(以及三个月后的你自己)能快速理解函数的用途,建议在函数上方添加规范的注释头:
#[=======================================================================[
@brief 为目标统一添加项目标准的编译警告
@param TARGET_NAME 需要添加警告的目标名称
@usage
add_warnings(my_target)
#]=======================================================================]
function(add_warnings TARGET_NAME)
# 实现...
endfunction()
4. 编写可复用的 CMake 宏
宏(macro())与函数(function())在外部调用时几乎看不出区别,但内部机制截然不同。
4.1 宏的本质
宏不是一次函数调用,而是文本替换。宏体内的代码直接在调用者的作用域中执行,因此宏内设置的变量会直接出现在调用方,不需要 PARENT_SCOPE。
macro(set_project_version MAJOR MINOR PATCH)
set(PROJECT_VERSION_MAJOR ${MAJOR})
set(PROJECT_VERSION_MINOR ${MINOR})
set(PROJECT_VERSION_PATCH ${PATCH})
set(PROJECT_VERSION "${MAJOR}.${MINOR}.${PATCH}")
endmacro()
# 调用后,这四个变量直接存在于当前作用域
set_project_version(1 2 3)
message(STATUS "Version: ${PROJECT_VERSION}") # 输出 1.2.3
4.2 函数 vs 宏:如何选择?
| 特性 | function | macro |
|---|---|---|
| 作用域 | 新建独立作用域 | 在调用者作用域执行 |
| 变量隔离 | 内部变量默认不泄露 | 内部变量直接暴露 |
| return() | 只退出函数 | 会退出整个 CMake 文件(危险) |
| 适用场景 | 封装工具逻辑、目标操作 | 注册一组变量或简化重复声明 |
建议:在 Modern CMake 实践中,优先使用 function。宏的作用域穿透特性容易引发难以调试的变量污染问题,只在需要”让调用者获得一组变量”这类特殊场景下谨慎使用。
5. 参数解析:cmake_parse_arguments
当函数需要接收命名参数、可选开关或多值列表时,手动处理 ARGN 会非常繁琐且易错。CMake 提供的 cmake_parse_arguments() 命令(CMake 3.5+)正是解决这一痛点的利器。
5.1 基本语法
cmake_parse_arguments(
<prefix>
<options> # 布尔开关,无值参数
<one_value_keywords> # 单值参数
<multi_value_keywords> # 多值参数
<args_to_parse> # 要解析的参数,通常是 ${ARGN}
)
5.2 完整实战示例
假设我们要写一个 add_my_library 函数,支持以下调用方式:
add_my_library(
NAME mylib
TYPE SHARED
SOURCES a.cpp b.cpp c.cpp
DEPS fmt::fmt Eigen3::Eigen
WARNINGS_AS_ERRORS
)
模块实现如下:
# cmake/AddMyLibrary.cmake
function(add_my_library)
# 1. 定义参数规格
set(options WARNINGS_AS_ERRORS NO_INSTALL)
set(oneValueArgs NAME TYPE)
set(multiValueArgs SOURCES DEPS INCLUDES)
# 2. 解析参数
cmake_parse_arguments(
ARG # 前缀,解析后的变量都以 ARG_ 开头
"${options}" # 布尔选项
"${oneValueArgs}" # 单值关键词
"${multiValueArgs}" # 多值关键词
${ARGN} # 要解析的原始参数
)
# 3. 参数校验
if(NOT ARG_NAME)
message(FATAL_ERROR "add_my_library: 必须指定 NAME 参数")
endif()
if(NOT ARG_TYPE)
set(ARG_TYPE STATIC) # 默认值
endif()
if(NOT ARG_SOURCES)
message(FATAL_ERROR "add_my_library: 必须至少提供一个源文件")
endif()
# 4. 创建目标
add_library(${ARG_NAME} ${ARG_TYPE} ${ARG_SOURCES})
# 5. 处理依赖
if(ARG_DEPS)
target_link_libraries(${ARG_NAME} PUBLIC ${ARG_DEPS})
endif()
# 6. 处理包含目录
if(ARG_INCLUDES)
target_include_directories(${ARG_NAME} PUBLIC ${ARG_INCLUDES})
endif()
# 7. 处理布尔开关
if(ARG_WARNINGS_AS_ERRORS)
if(MSVC)
target_compile_options(${ARG_NAME} PRIVATE /WX)
else()
target_compile_options(${ARG_NAME} PRIVATE -Werror)
endif()
endif()
# 8. 处理另一个布尔开关
if(NOT ARG_NO_INSTALL)
install(TARGETS ${ARG_NAME}
LIBRARY DESTINATION lib
ARCHIVE DESTINATION lib
RUNTIME DESTINATION bin
)
endif()
endfunction()
5.3 未识别参数的处理
如果调用者传入了未定义的关键词(比如拼写错误 SOURCE 而不是 SOURCES),cmake_parse_arguments 会将其收集到 <prefix>_UNPARSED_ARGUMENTS 变量中。建议在函数末尾进行检查:
if(ARG_UNPARSED_ARGUMENTS)
message(WARNING "add_my_library: 未识别的参数 ${ARG_UNPARSED_ARGUMENTS}")
endif()
endfunction()
5.4 旧版兼容注意事项
在 CMake 3.4 及更早版本中,cmake_parse_arguments 位于 CMakeParseArguments 模块中,需要先 include(CMakeParseArguments)。CMake 3.5+ 已将其作为内置命令,无需 include。考虑到本系列面向现代 CMake,我们建议直接要求 CMake 3.15+。
6. 命名空间与命名规范
当项目中有数十个模块、上百个自定义函数时,命名冲突的风险会急剧上升。CMake 没有 C++ 的 namespace 关键字,因此需要通过命名约定来模拟命名空间。
6.1 前缀命名法(Pseudo-Namespace)
这是业界最广泛采用的策略:为项目中所有的自定义函数、宏、全局变量添加统一的项目前缀。
# 不推荐
function(add_warnings target) ... endfunction()
# 推荐
function(myproj_add_warnings target) ... endfunction()
# 对于通用工具模块,可以使用模块名作为前缀
function(util_add_test NAME SOURCES) ... endfunction()
function(util_generate_export_header TARGET) ... endfunction()
如果项目缩写是 MYPROJ,那么所有函数应以 myproj_ 开头;如果是公司内部共享的通用 CMake 工具库,可以使用更独特的前缀如 company_cmake_。
6.2 变量名的隔离策略
模块内部使用的临时变量,应尽量避免使用通用名字如 TEMP、RESULT、FLAGS。推荐做法:
- 在函数内部,优先使用函数参数名或
cmake_parse_arguments的前缀机制(如ARG_NAME) - 在模块顶层(
include的文件中),使用_myproj_internal_前缀标记内部变量 - 对于 cache 变量,务必添加项目前缀,如
MYPROJ_BUILD_TESTS
# cmake/InternalHelpers.cmake
set(_myproj_internal_compiler_checked FALSE CACHE INTERNAL "")
if(NOT _myproj_internal_compiler_checked)
# 执行一次性检查...
set(_myproj_internal_compiler_checked TRUE CACHE INTERNAL "")
endif()
6.3 避免修改全局状态
一个设计良好的模块应当尽可能地”无副作用”。避免在模块文件中直接修改以下全局变量,除非这正是该模块的设计目的:
CMAKE_CXX_FLAGS(应使用target_compile_options)CMAKE_MODULE_PATH(修改后应在文档中明确说明)CMAKE_PREFIX_PATH- 各种
CMAKE_*_OUTPUT_DIRECTORY(如果修改,应提供选项开关)
6.4 目录层级的模块可见性
在大型项目中,不同层级的子项目可能需要不同版本的”同名”辅助函数。此时可以通过不将辅助目录加入 CMAKE_MODULE_PATH,而是使用相对路径 include 来控制可见性:
# 子项目 A 使用自己的版本
add_subdirectory(libs/A)
# libs/A/CMakeLists.txt
include(cmake/AHelpers.cmake) # 仅影响 A 及其子目录
# 子项目 B 使用自己的版本
add_subdirectory(libs/B)
# libs/B/CMakeLists.txt
include(cmake/BHelpers.cmake)
7. 综合实战:编写一个项目标准初始化模块
最后,让我们将本节所有知识融会贯通,编写一个实际项目中常见的 StandardProjectSettings.cmake 模块。该模块的目标是让主 CMakeLists.txt 只需一行 include 就能获得 Modern CMake 的最佳实践初始化。
# cmake/StandardProjectSettings.cmake
#
# @brief 为本项目提供统一的初始化设置,包括:
# - 默认构建类型
# - C++ 标准
# - IPO/LTO 支持检测
# - 统一的输出目录
include_guard(GLOBAL) # 防止重复 include
# ---------------------------------------------------------
# 1. 默认构建类型
# ---------------------------------------------------------
function(myproj_set_default_build_type)
set(default_build_type "RelWithDebInfo")
if(EXISTS "${CMAKE_SOURCE_DIR}/.git")
set(default_build_type "Debug")
endif()
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
message(STATUS "设置默认构建类型为 ${default_build_type}")
set(CMAKE_BUILD_TYPE "${default_build_type}" CACHE STRING "选择构建类型" FORCE)
set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS
"Debug" "Release" "MinSizeRel" "RelWithDebInfo"
)
endif()
endfunction()
# ---------------------------------------------------------
# 2. C++ 标准
# ---------------------------------------------------------
function(myproj_set_cxx_standard TARGET_NAME)
set_target_properties(${TARGET_NAME} PROPERTIES
CXX_STANDARD 17
CXX_STANDARD_REQUIRED YES
CXX_EXTENSIONS NO
)
endfunction()
# ---------------------------------------------------------
# 3. 输出目录统一
# ---------------------------------------------------------
macro(myproj_set_output_directories)
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin)
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib)
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib)
endmacro()
# ---------------------------------------------------------
# 4. IPO/LTO 检测与启用
# ---------------------------------------------------------
function(myproj_enable_ipo_if_supported TARGET_NAME)
include(CheckIPOSupported)
check_ipo_supported(RESULT result OUTPUT output)
if(result)
set_property(TARGET ${TARGET_NAME} PROPERTY INTERPROCEDURAL_OPTIMIZATION TRUE)
else()
message(WARNING "IPO/LTO 不被当前编译器支持: ${output}")
endif()
endfunction()
# ---------------------------------------------------------
# 模块被 include 时自动执行的初始化
# ---------------------------------------------------------
myproj_set_default_build_type()
myproj_set_output_directories()
message(STATUS "StandardProjectSettings 模块加载完成")
主项目使用:
cmake_minimum_required(VERSION 3.20)
project(MyAwesomeApp VERSION 1.0.0)
list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake")
include(StandardProjectSettings)
add_executable(app main.cpp)
myproj_set_cxx_standard(app)
myproj_enable_ipo_if_supported(app)
小结
本节我们系统学习了 CMake 项目组织中的复用技术:
- 使用
include加载.cmake模块文件,将纯配置逻辑从CMakeLists.txt中剥离。 - 通过
CMAKE_MODULE_PATH管理模块搜索路径,实现”按名引入”的便捷体验。 - 使用
function封装带独立作用域的可复用逻辑,配合PARENT_SCOPE实现可控的返回值。 - 理解
macro的文本替换本质,谨慎使用,优先选择function。 - 掌握
cmake_parse_arguments,为自定义函数赋予类似内置命令的声明式参数接口。 - 建立命名空间规范,通过统一前缀、内部变量隔离和避免全局副作用,确保模块化架构的健壮性。
掌握了这些技能,你的 CMake 代码将不再是一堆 sprawling 的线性脚本,而是一个结构清晰、易于维护、可随处复用的构建系统。下一节,我们将把目光投向更广阔的生态系统——如何使用 ExternalProject 和 FetchContent 集成外部项目,让你的工程轻松借力开源社区。


没有回复内容