引言:施工队的”标准工具箱”
在上一节中,我们学会了如何把一栋大楼的不同区域分包给各个施工小队(add_subdirectory),让项目从”独栋别墅”变成了”规划社区”。但随着社区规模扩大,你可能会发现一个头疼的问题:每个小队都在重复造轮子。
A队在写如何开启C++17,B队也在写;C队在配置gtest,D队也要配一遍。这就好比每个施工小队上班前,都要自己亲手打造一把锤子和一副卷尺——不仅浪费时间,而且万一A队的锤子和B队的卷尺规格不一样,整个工地就乱套了。
聪明的包工头会怎么做?他会建立一个标准工具箱:把常用的锤子、扳手、安全操作规程都统一准备好,哪个小队需要就直接领取。在CMake的世界里,这个”标准工具箱”就是模块(Module)与函数/宏(Function/Macro)。
这一节,我们就来学习如何把重复的CMake代码封装成可复用的组件,让你的构建系统从”手工作坊”升级为”标准化工厂”。
要点1:include指令与模块文件——.cmake文件的编写与加载
在C++里,我们把可复用的代码放到头文件(.h)里,通过#include来引入。CMake也有类似的机制,只不过它的”头文件”扩展名是.cmake。
一个.cmake文件里可以包含任意CMake命令:设置变量、定义函数、查找依赖包等等。当你在其他地方使用include(文件名)时,CMake会把那个文件的内容原封不动地复制粘贴到当前位置。
与add_subdirectory的关键区别
很多初学者会混淆include和add_subdirectory。记住这个比喻:
add_subdirectory:在社区里新开一家分店(子项目),它有自己独立的账本(变量作用域)。include:把一本操作手册直接复印一份,塞进当前小队的文件夹里,没有新账本,所有变量都在当前作用域。
因此,include更适合用来加载通用配置片段,而不是管理独立的构建目标。
动手写一个模块
假设我们在项目根目录下新建一个文件夹cmake,里面放一个叫compiler_warnings.cmake的文件:
# cmake/compiler_warnings.cmake
# 这是一个标准工具:给指定目标添加统一的编译警告
function(add_project_warnings target_name)
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
target_compile_options(${target_name} PRIVATE
-Wall -Wextra -Wpedantic -Werror
)
elseif(CMAKE_CXX_COMPILER_ID STREQUAL "MSVC")
target_compile_options(${target_name} PRIVATE
/W4 /WX
)
endif()
endfunction()
然后在主CMakeLists.txt或任意子目录中使用它:
# 加载工具手册
include(cmake/compiler_warnings.cmake)
add_executable(my_app main.cpp)
# 直接调用手册里定义的函数
add_project_warnings(my_app)
要点2:CMake模块搜索路径——CMAKE_MODULE_PATH的配置
上面的例子中,我们写了include(cmake/compiler_warnings.cmake),用的是相对路径。但如果模块文件很多,或者分布在不同位置,每次都写一长串路径就很麻烦。CMake提供了一个专门的变量来管理模块的”仓库地址”:CMAKE_MODULE_PATH。
CMake在执行include(xxx)时,会按以下顺序寻找文件:
- 如果
xxx是绝对路径,直接使用。 - 如果是相对路径,先在CMAKE_MODULE_PATH列出的目录中查找。
- 如果还没找到,再去CMake内置的模块目录(如
/usr/share/cmake-3.x/Modules)里查找。
配置自定义模块路径
通常的做法是在项目根目录创建一个cmake或cmake/modules文件夹,专门存放这些.cmake工具文件。然后在根目录的CMakeLists.txt最开始就配置好路径:
cmake_minimum_required(VERSION 3.14)
project(MyProject)
# 把项目自己的cmake工具目录加入搜索路径
list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake")
# 现在可以直接用模块名,不用写相对路径了
include(compiler_warnings)
include(utility_helpers)
小贴士:使用list(APPEND ...)而不是set(...),这样可以保留系统或其他父项目已经设置好的路径,实现”追加”而非”覆盖”。
要点3:编写可复用的CMake函数——参数设计与文档规范
函数是标准化工厂里的核心。在CMake中定义函数使用function()命令,它的语法非常直观:
function(函数名 参数1 参数2 ...)
# 函数体
endfunction()
但CMake的函数和C++函数有一个重要区别:CMake函数没有返回值。你不能写ret = my_func()。那么怎么把结果传出去呢?通常有两种办法:
- 通过PARENT_SCOPE修改父作用域变量:适合传递单个结果。
- 直接操作传入的目标名:比如给目标添加属性、源文件等,这是Modern CMake最推荐的方式。
函数作用域演示
function(get_greeting name out_var)
set(greeting "Hello, ${name}!")
# 关键点:要加PARENT_SCOPE,否则greeting只是函数内的局部变量
set(${out_var} "${greeting}" PARENT_SCOPE)
endfunction()
get_greeting("CMake" result)
message(STATUS "结果:${result}") # 输出:结果:Hello, CMake!
文档规范
为了让团队成员(包括三个月后的你自己)看懂这个函数是干什么的,建议在函数上方写注释:
#[[
为指定目标配置标准编译选项
参数:
target_name - 要配置的目标名称
cxx_standard - 需要的C++标准,如17、20
示例:
setup_standard_target(my_app 20)
#]]
function(setup_standard_target target_name cxx_standard)
set_target_properties(${target_name} PROPERTIES
CXX_STANDARD ${cxx_standard}
CXX_STANDARD_REQUIRED ON
CXX_EXTENSIONS OFF
)
endfunction()
要点4:编写可复用的CMake宏——宏与函数的选择场景
CMake里除了function,还有一个叫macro的家伙。乍一看它俩很像,但底层机制截然不同:
- function(函数):有自己的变量作用域。函数内部
set(VAR 1)不会影响到外面,除非加PARENT_SCOPE。 - macro(宏):本质上就是文本替换(类似C语言的宏)。宏里没有独立作用域,它操作的就是调用者所在的位置。
选择建议
| 场景 | 推荐 | 原因 |
|---|---|---|
| 需要封装独立逻辑,避免污染外部变量 | function | 作用域隔离,更安全 |
| 需要修改调用者的变量 | macro | 能直接访问和修改外部变量 |
| 需要return/break跳出 | macro | 宏的break能影响调用者的循环 |
| 作为语法糖,包装多个命令 | function | 逻辑清晰,调试方便 |
宏的示例
假设我们想写一个宏,批量给多个目标加上相同的包含目录:
macro(add_common_includes)
# ARGN 是宏接收到的所有未命名参数
foreach(target ${ARGN})
target_include_directories(${target} PUBLIC "${CMAKE_SOURCE_DIR}/include")
endforeach()
endmacro()
# 调用
add_common_includes(app1 app2 app3)
警告:因为宏是文本替换,如果你在宏内部使用了if()或return(),可能会产生意想不到的后果。因此Modern CMake的座右铭是:能写函数,就别写宏。
要点5:参数解析——cmake_parse_arguments的完整用法
如果你的函数参数很多,或者有可选参数,靠位置传参(arg1 arg2 arg3)会让调用者很痛苦。想象一下:
# 这是什么意思?完全看不懂!
my_func(app ON 17 "a.cpp;b.cpp" "pthread;dl")
Modern CMake提供了cmake_parse_arguments,让你可以像调用命令行工具一样使用关键字参数:
my_func(
TARGET app
ENABLE_WARNINGS ON
STANDARD 17
SOURCES a.cpp b.cpp
DEPENDS pthread dl
)
语法详解
cmake_parse_arguments(
<前缀>
<布尔选项列表>
<单值关键字列表>
<多值关键字列表>
${ARGN}
)
- 布尔选项:出现就是TRUE,没出现就是FALSE(不需要给值)。
- 单值关键字:后面必须跟一个值。
- 多值关键字:后面可以跟多个值,解析后会组成一个列表。
完整实战示例
我们来封装一个”万能加目标”函数:
function(add_my_executable)
# 定义前缀为 ARG
# 布尔选项:ENABLE_WARNINGS
# 单值:NAME, CXX_STANDARD
# 多值:SOURCES, LINK_LIBS
cmake_parse_arguments(
ARG
"ENABLE_WARNINGS"
"NAME;CXX_STANDARD"
"SOURCES;LINK_LIBS"
${ARGN}
)
# 参数校验
if(NOT ARG_NAME)
message(FATAL_ERROR "add_my_executable: 必须提供 NAME 参数")
endif()
if(NOT ARG_CXX_STANDARD)
set(ARG_CXX_STANDARD 17)
endif()
# 创建目标
add_executable(${ARG_NAME} ${ARG_SOURCES})
# 设置C++标准
set_target_properties(${ARG_NAME} PROPERTIES
CXX_STANDARD ${ARG_CXX_STANDARD}
CXX_STANDARD_REQUIRED ON
)
# 链接库
if(ARG_LINK_LIBS)
target_link_libraries(${ARG_NAME} PRIVATE ${ARG_LINK_LIBS})
endif()
# 可选:添加警告
if(ARG_ENABLE_WARNINGS)
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
target_compile_options(${ARG_NAME} PRIVATE -Wall -Wextra)
endif()
endif()
endfunction()
调用方式非常优雅:
add_my_executable(
NAME my_tool
SOURCES main.cpp utils.cpp
LINK_LIBS fmt::fmt
ENABLE_WARNINGS
CXX_STANDARD 20
)
注意:解析后的变量名会带上你定义的前缀,比如ARG_NAME、ARG_SOURCES。如果调用者传了未定义的关键字,它们会被收集到ARG_UNPARSED_ARGUMENTS变量里,你可以据此报错或做兼容处理。
要点6:命名空间与命名规范——避免命名冲突的策略
当项目越来越大,或者你引入了很多第三方CMake模块时,命名冲突就成了隐形杀手。你的函数名叫setup_target(),第三方库也定义了一个setup_target(),CMake不会报错,但后面的定义会覆盖前面的,导致诡异的构建问题。
因此,从一开始就建立命名规范至关重要。
前缀命名法(推荐)
给你的所有自定义函数、宏和变量都加上项目前缀:
# ❌ 不好的命名:太通用,容易冲突
function(setup_warnings target)
function(add_utilities target)
# ✅ 好的命名:带有项目标识
function(myproj_setup_warnings target)
function(myproj_add_utilities target)
# 变量也一样
set(MYPROJ_ENABLE_TESTS ON)
set(MYPROJ_VERSION "1.2.0")
内部辅助函数的标记
如果一个函数只是给模块内部使用的,不应该被外部调用,建议用下划线前缀标明:
# 这是公共API
function(myproj_add_executable)
...
endfunction()
# 这是内部实现,不要直接调用
function(_myproj_internal_parse_args)
...
endfunction()
变量作用域控制
在模块文件顶层定义的变量是全局的。为了避免污染,尽量:
- 在函数内部使用
set(VAR value)(局部变量)。 - 如果必须在模块顶层设置变量,加上你的项目前缀。
- 使用
unset(VAR)在不需要时清理临时变量。
总结:从重复劳动到优雅复用
在这一节中,我们给CMake施工队配备了一套完整的标准化工具箱:
- 通过
include加载.cmake模块文件,把通用配置拆出去; - 利用
CMAKE_MODULE_PATH建立模块仓库,不用写冗长的相对路径; - 使用
function封装独立逻辑,配合PARENT_SCOPE传递结果; - 理解
macro的文本替换本质,谨慎使用; - 掌握
cmake_parse_arguments,告别”数参数位置”的痛苦; - 建立命名规范,用项目前缀为所有工具打上”防伪标签”。
当你把这些技巧组合起来,你会发现CMakeLists.txt不再是一团乱麻,而是一份清晰、可维护、可扩展的构建蓝图。下一节,我们将走出项目内部,看看如何把手伸向外部的开源世界——学习如何集成和管理外部依赖项目。


没有回复内容