cmake之旅(5)
- 函数、宏与 .cmake 模块
- 1 macro —— 宏
- 1.1 基本用法
- 1.2 宏的参数
- 1.3 宏的本质 —— 文本替换
- 2 function —— 函数
- 2.1 基本用法
- 2.2 函数有自己的作用域
- 2.3 function 与 macro 的对比
- 3 实战:封装模块构建逻辑
- 4 .cmake 模块文件
- 4.1 什么是 .cmake 文件
- 4.2 include 命令
- 4.3 实战:创建项目级 .cmake 模块
- 4.4 CMAKE_MODULE_PATH
- 4.5 include 防止重复加载
- 5 cmake_parse_arguments —— 高级参数解析
- 6 CMake 内置模块
- 7 脚本模式:cmake -P
- 8 本篇命令速查表
- 9 总结与下一篇预告
同系列文章:
cmake之旅(1):构建的过程
cmake之旅(2):CMakeLists.txt 核心语法
cmake之旅(3):多目录项目管理
cmake之旅(4):静态库与动态库
cmake之旅(5):函数、宏与 .cmake 模块
cmake之旅(6):查找和使用第三方库
cmake之旅(7):编译选项与条件编译
cmake之旅(8):Modern CMake 与 target 思维
cmake之旅(9):安装与导出
cmake之旅(10):自动化测试与 CTest
函数、宏与 .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;dARGN捕获了定义之外的额外参数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-Pscript.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-Phello.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的工作原理。