cmake之旅(5)
同系列文章:
cmake之旅(1):构建的过程
cmake之旅(2):CMakeLists.txt 核心语法
cmake之旅(3):多目录项目管理
cmake之旅(4):静态库与动态库
cmake之旅(5):函数、宏与 .cmake 模块
cmake之旅(6):查找和使用第三方库
cmake之旅(7):编译选项与条件编译
cmake之旅(8):Modern CMake 与 target 思维
函数、 宏 与 .cmake 模块
上一篇我们学习了静态库和动态库的构建方法。在结尾我们发现了一个问题:多个模块的 CMakeLists.txt 几乎一模一样,只是库名和源文件不同。如果有十个模块,就要写十份几乎相同的代码。
在 C++ 中遇到重复代码,我们会把它封装成函数。CMake 也有类似的机制——函数(function)和宏(macro)。 更进一步,我们可以把这些封装好的逻辑保存到独立的 .cmake 文件中,形成"模块",在不同项目之间复用。
这一篇我们就来学习 CMake 的代码复用三件套:function、macro、.cmake 模块文件。
1 macro —— 宏
1.1 基本用法
macro 的语法和 C 语言的宏有几分相似:
# 定义一个宏
macro(say_hello name)
message(STATUS "Hello, ${name}!")
endmacro()
# 调用宏
say_hello("CMake")
say_hello("World")
输出:
-- Hello, CMake!
-- Hello, World!
语法很简单:macro(名称 参数...) 开头,endmacro() 结尾,中间是宏体。
1.2 宏的参数
宏可以接收多个参数:
macro(print_info name version)
message(STATUS "库名称: ${name}")
message(STATUS "库版本: ${version}")
endmacro()
print_info("calc" "1.0.0")
CMake 的宏还提供了几个内置变量来处理参数:
| 变量 | 含义 |
|---|---|
ARGC |
参数总数 |
ARGV |
所有参数的列表 |
ARGN |
超出定义参数之外的额外参数 |
ARGV0、ARGV1… |
按位置访问各个参数 |
macro(flexible_macro first second)
message(STATUS "第一个参数: ${first}")
message(STATUS "第二个参数: ${second}")
message(STATUS "参数总数: ${ARGC}")
message(STATUS "额外参数: ${ARGN}")
endmacro()
flexible_macro("a" "b" "c" "d")
输出:
-- 第一个参数: a
-- 第二个参数: b
-- 参数总数: 4
-- 额外参数: c;d
ARGN 捕获了定义之外的额外参数 c 和 d,这在编写灵活的宏时非常有用。
1.3 宏的本质 —— 文本替换
宏的工作方式是纯文本替换,类似于 C 语言的 #define。这意味着宏没有自己的作用域,宏内部定义的变量会直接"泄漏"到调用者的作用域中。
macro(my_macro)
set(LEAK_VAR "我从宏里泄漏出来了")
endmacro()
my_macro()
message(STATUS ${LEAK_VAR}) # 能读到!
输出:
-- 我从宏里泄漏出来了
这有时候是你想要的效果,但更多时候它会造成意外——你可能不小心覆盖了调用者的变量。
2 function —— 函数
2.1 基本用法
function 的语法和 macro 几乎一样:
# 定义一个函数
function(say_hello name)
message(STATUS "Hello, ${name}!")
endfunction()
# 调用函数
say_hello("CMake")
看起来和宏没区别?关键区别在作用域。
2.2 函数有自己的作用域
函数会创建一个新的作用域。函数内部定义的变量在函数外部是不可见的:
function(my_function)
set(INNER_VAR "我在函数内部")
endfunction()
my_function()
message(STATUS "读取: ${INNER_VAR}") # 空的!读不到
这和上一篇讲的 add_subdirectory 的作用域规则一致。如果函数内部想把变量传递给外部,同样需要使用 PARENT_SCOPE:
function(my_function)
set(RESULT "计算结果" PARENT_SCOPE)
endfunction()
my_function()
message(STATUS "读取: ${RESULT}") # 能读到:计算结果
2.3 function 与 macro 的对比
| 对比项 | macro | function |
|---|---|---|
| 作用域 | 无独立作用域(文本替换) | 有独立作用域 |
| 变量泄漏 | 会泄漏到调用者作用域 | 不会泄漏 |
| 向外传递变量 | 直接 set 即可 | 需要 PARENT_SCOPE |
| 性能 | 略快(无作用域开销) | 略慢(可忽略) |
| 推荐程度 | 简单的文本替换场景 | 大多数场景推荐使用 |
建议:优先使用 function。 只有在你明确需要"在调用者作用域中直接设置变量"这种行为时,才考虑使用 macro。
3 实战:封装模块构建逻辑
回到我们的痛点。上一篇中 add 和 de 的 CMakeLists.txt 长这样:
if(CALC_BUILD_SHARED)
add_library(add_lib SHARED add.cpp)
else()
add_library(add_lib STATIC add.cpp)
endif()
target_include_directories(add_lib PUBLIC ${PROJECT_SOURCE_DIR}/include)
if(CALC_BUILD_SHARED)
set_target_properties(add_lib PROPERTIES VERSION ${PROJECT_VERSION} SOVERSION 1)
endif()
de 模块几乎一模一样。我们现在用 function 来消除重复:
function(add_calc_module MODULE_NAME MODULE_SOURCES)
# 根据选项决定库类型
if(CALC_BUILD_SHARED)
add_library(${MODULE_NAME} SHARED ${MODULE_SOURCES})
else()
add_library(${MODULE_NAME} STATIC ${MODULE_SOURCES})
endif()
# 设置头文件路径
target_include_directories(${MODULE_NAME} PUBLIC ${PROJECT_SOURCE_DIR}/include)
# 如果是动态库,设置版本号
if(CALC_BUILD_SHARED)
set_target_properties(${MODULE_NAME} PROPERTIES
VERSION ${PROJECT_VERSION}
SOVERSION 1
)
endif()
endfunction()
有了这个函数,各模块的 CMakeLists.txt 就简化为一行调用:
# add/CMakeLists.txt
add_calc_module(add_lib add.cpp)
# de/CMakeLists.txt
add_calc_module(de_lib de.cpp)
代码量一下子就降下来了。 但新的问题来了:这个 add_calc_module 函数定义在哪里?如果写在顶层 CMakeLists.txt 里,子目录可以使用(因为父作用域对子可见)。但如果其他项目也想复用这个函数呢?
这就引出了 .cmake 模块文件。
4 .cmake 模块文件
4.1 什么是 .cmake 文件
.cmake 文件就是一个普通的文本文件,里面写的是 CMake 代码,后缀名为 .cmake。你可以把它理解为 CMake 的"头文件"——把函数、宏、变量定义放在里面,然后在 CMakeLists.txt 中引入使用。
4.2 include 命令
include 命令用来加载一个 .cmake 文件,功能类似于 C++ 中的 #include:
include(path/to/module.cmake)
CMake 会读取指定文件的内容并在当前作用域中执行,就好像你把文件内容直接粘贴到了 include 这行的位置一样。
4.3 实战:创建项目级 .cmake 模块
我们把刚才封装的 add_calc_module 函数放到一个独立的 .cmake 文件中。
调整项目结构:
├── CMakeLists.txt
├── cmake
│ └── CalcUtils.cmake # 自定义的 CMake 模块
├── include
│ └── calc
│ ├── add.h
│ └── de.h
└── src
├── CMakeLists.txt
├── add
│ ├── CMakeLists.txt
│ └── add.cpp
├── de
│ ├── CMakeLists.txt
│ └── de.cpp
└── main.cpp
新增了一个 cmake/ 目录,用来存放自定义的 .cmake 模块文件。这是业界常见的约定。
cmake/CalcUtils.cmake:
# ============================================================
# CalcUtils.cmake
# 描述:Calculator 项目的通用构建工具函数
# ============================================================
# 添加计算模块的便捷函数
# 参数:
# MODULE_NAME - 目标名称(如 add_lib)
# MODULE_SOURCES - 源文件列表
function(add_calc_module MODULE_NAME)
# ARGN 捕获除 MODULE_NAME 之外的所有额外参数,即源文件列表
set(MODULE_SOURCES ${ARGN})
# 根据选项决定库类型
if(CALC_BUILD_SHARED)
add_library(${MODULE_NAME} SHARED ${MODULE_SOURCES})
else()
add_library(${MODULE_NAME} STATIC ${MODULE_SOURCES})
endif()
# 设置头文件路径
target_include_directories(${MODULE_NAME} PUBLIC ${PROJECT_SOURCE_DIR}/include)
# 如果是动态库,设置版本号
if(CALC_BUILD_SHARED)
set_target_properties(${MODULE_NAME} PROPERTIES
VERSION ${PROJECT_VERSION}
SOVERSION 1
)
endif()
endfunction()
注意这里用了 ${ARGN} 来接收源文件列表,这样调用时可以传入任意数量的源文件:
add_calc_module(add_lib add.cpp)
add_calc_module(math_lib math.cpp utils.cpp helper.cpp)
顶层 CMakeLists.txt:
cmake_minimum_required(VERSION 3.10)
project(Calculator VERSION 1.0.0 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED True)
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin)
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib)
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib)
option(CALC_BUILD_SHARED "构建动态库" OFF)
# 引入自定义模块
include(cmake/CalcUtils.cmake)
add_subdirectory(src)
各子模块的 CMakeLists.txt 就变得非常简洁:
src/add/CMakeLists.txt:
add_calc_module(add_lib add.cpp)
src/de/CMakeLists.txt:
add_calc_module(de_lib de.cpp)
src/CMakeLists.txt 和 main.cpp 保持不变。
4.4 CMAKE_MODULE_PATH
上面我们用 include(cmake/CalcUtils.cmake) 指定了完整的相对路径。但如果模块文件很多,每次都写路径会比较繁琐。
CMake 提供了一个变量 CMAKE_MODULE_PATH,你可以把自定义模块目录添加到这个变量中,之后 include 时就只需要写模块名(不带路径和后缀):
# 将 cmake/ 目录加入模块搜索路径
list(APPEND CMAKE_MODULE_PATH ${CMAKE_SOURCE_DIR}/cmake)
# 现在可以直接用模块名引入,CMake 会自动在 CMAKE_MODULE_PATH 中查找 CalcUtils.cmake
include(CalcUtils)
list(APPEND ...) 是往列表变量末尾追加元素的命令。这里把 cmake/ 目录的绝对路径追加到了 CMAKE_MODULE_PATH 中。
这种方式在中大型项目中非常常见。 一个项目可能有多个 .cmake 模块文件,统一放在 cmake/ 目录下,然后在顶层设置好 CMAKE_MODULE_PATH,后续随时引入。
4.5 include 防止重复加载
如果一个 .cmake 文件可能被多处 include,你可能担心它被执行多次。CMake 提供了 include_guard 命令来防止重复加载,类似于 C++ 头文件中的 #pragma once:
# CalcUtils.cmake 顶部加上这行
include_guard(GLOBAL)
# ... 后面的内容只会被执行一次
GLOBAL 表示在整个构建过程中只加载一次,不管在多少个地方 include 了这个文件。
5 cmake_parse_arguments —— 高级参数解析
当函数的参数变得复杂时,位置参数(第一个参数是什么、第二个是什么)会变得难以记忆。CMake 提供了 cmake_parse_arguments 来实现类似"命名参数"的效果。
假设我们想让 add_calc_module 支持更多选项:
include(CMakeParseArguments)
function(add_calc_module MODULE_NAME)
# 定义参数规则
# 第一个参数(前缀):解析后变量的前缀
# 第二个参数(选项):不带值的布尔选项
# 第三个参数(单值):接收一个值的参数
# 第四个参数(多值):接收多个值的参数
cmake_parse_arguments(
ARG # 前缀
"WITH_PIC" # 布尔选项
"OUTPUT_NAME" # 单值参数
"SOURCES;DEPENDS" # 多值参数
${ARGN} # 要解析的参数列表
)
# 根据选项决定库类型
if(CALC_BUILD_SHARED)
add_library(${MODULE_NAME} SHARED ${ARG_SOURCES})
else()
add_library(${MODULE_NAME} STATIC ${ARG_SOURCES})
endif()
# 设置头文件路径
target_include_directories(${MODULE_NAME} PUBLIC ${PROJECT_SOURCE_DIR}/include)
# 如果指定了 WITH_PIC,启用位置无关代码
if(ARG_WITH_PIC)
set_target_properties(${MODULE_NAME} PROPERTIES POSITION_INDEPENDENT_CODE ON)
endif()
# 如果指定了 OUTPUT_NAME,自定义输出文件名
if(ARG_OUTPUT_NAME)
set_target_properties(${MODULE_NAME} PROPERTIES OUTPUT_NAME ${ARG_OUTPUT_NAME})
endif()
# 如果指定了 DEPENDS,链接依赖库
if(ARG_DEPENDS)
target_link_libraries(${MODULE_NAME} PUBLIC ${ARG_DEPENDS})
endif()
endfunction()
调用时就非常清晰:
add_calc_module(add_lib
SOURCES add.cpp
OUTPUT_NAME "add"
WITH_PIC
)
add_calc_module(math_lib
SOURCES math.cpp utils.cpp
DEPENDS add_lib de_lib
)
每个参数的含义一目了然,不需要记住参数的位置顺序。在编写供他人使用的 CMake 模块时,强烈推荐使用这种方式。
cmake_parse_arguments 解析后,会生成以下变量(以前缀 ARG 为例):
| 变量 | 含义 | 示例值 |
|---|---|---|
ARG_WITH_PIC |
布尔选项是否被指定 | TRUE / FALSE |
ARG_OUTPUT_NAME |
单值参数的值 | “add” |
ARG_SOURCES |
多值参数的值列表 | “add.cpp” |
ARG_DEPENDS |
多值参数的值列表 | “add_lib;de_lib” |
ARG_UNPARSED_ARGUMENTS |
未被识别的参数 | 用于错误检查 |
6 CMake 内置模块
除了自定义的 .cmake 文件,CMake 本身也自带了大量的模块,存放在 CMake 安装目录的 Modules/ 文件夹下。
你可以通过以下命令查看 CMake 自带了哪些模块:
cmake --help-module-list
其中一些常用的内置模块:
| 模块名 | 作用 |
|---|---|
GNUInstallDirs |
提供标准安装目录变量(如 CMAKE_INSTALL_LIBDIR) |
CMakePackageConfigHelpers |
辅助生成包配置文件 |
CheckCXXCompilerFlag |
检查编译器是否支持某个编译选项 |
FetchContent |
在配置阶段下载和引入外部项目 |
CMakePrintHelpers |
提供便捷的调试打印函数 |
使用内置模块时,直接 include 模块名即可(不需要设置 CMAKE_MODULE_PATH):
# 使用内置的 CMakePrintHelpers 模块
include(CMakePrintHelpers)
set(MY_LIST "a" "b" "c")
cmake_print_variables(MY_LIST CMAKE_CXX_STANDARD)
输出:
-- MY_LIST="a;b;c"
-- CMAKE_CXX_STANDARD="17"
cmake_print_variables 是 CMakePrintHelpers 模块提供的便捷函数,比手动写 message 方便得多。调试时非常好用。
7 脚本模式:cmake -P
.cmake 文件除了被 include 引入之外,还可以作为独立脚本直接运行:
cmake -P script.cmake
这就是 CMake 的"脚本模式"。在脚本模式下,CMake 不会执行任何构建相关的操作(不会生成 Makefile),只是单纯地执行 .cmake 文件中的逻辑。
比如写一个简单的脚本 hello.cmake:
# hello.cmake
message("当前时间戳:")
string(TIMESTAMP CURRENT_TIME "%Y-%m-%d %H:%M:%S")
message("${CURRENT_TIME}")
# 文件操作
file(WRITE "${CMAKE_CURRENT_LIST_DIR}/output.txt" "Hello from CMake script!\n")
message("文件已写入")
运行:
cmake -P hello.cmake
脚本模式有什么用? 常见的用途包括:自动化部署脚本、 代码生成 、文件批处理、CI/CD 流程中的辅助脚本等。它让你可以用统一的 CMake 语法完成一些构建之外的任务,而不需要额外依赖 Bash 或 Python。
注意: 脚本模式下不能使用 add_executable、add_library、target_link_libraries 等构建相关的命令,因为脚本模式没有"构建上下文"。
8 本篇命令速查表
| 命令 | 作用 | 示例 |
|---|---|---|
macro() / endmacro() |
定义宏(无独立作用域) | macro(my_macro arg) ... endmacro() |
function() / endfunction() |
定义函数(有独立作用域) | function(my_func arg) ... endfunction() |
include() |
加载 .cmake 文件 | include(CalcUtils) |
include_guard() |
防止 .cmake 文件被重复加载 | include_guard(GLOBAL) |
cmake_parse_arguments() |
解析命名参数 | 见第 5 节 |
list(APPEND ...) |
向列表追加元素 | list(APPEND CMAKE_MODULE_PATH dir) |
cmake -P |
以脚本模式运行 .cmake 文件 | cmake -P script.cmake |
9 总结与下一篇预告
这一篇我们学习了 CMake 的代码复用机制:用 function 和 macro 封装逻辑,用 .cmake 文件组织模块,用 include 引入模块,用 CMAKE_MODULE_PATH 管理模块搜索路径,用 cmake_parse_arguments 实现优雅的命名参数,还了解了脚本模式 cmake -P。
现在我们已经掌握了 .cmake 文件的基础用法。在下一篇中,我们将看到 .cmake 文件最重要的应用场景之一——查找第三方库。你有没有想过,当你在 CMakeLists.txt 中写 find_package(OpenCV) 的时候,CMake 是怎么找到 OpenCV 的?它去哪里找?找到之后又做了什么?
下一篇——cmake之旅(6):查找和使用第三方库,我们来揭开 find_package 的工作原理。

浙公网安备 33010602011771号