cmake之旅(5)
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_PATH4.5 include 防止重复加载5 cmake_parse_arguments —— 高级参数解析6 CMake 内置模块7 脚本模式cmake -P8 本篇命令速查表9 总结与下一篇预告同系列文章cmake之旅(1):构建的过程cmake之旅(2):CMakeLists.txt 核心语法cmake之旅(3):多目录项目管理cmake之旅(4):静态库与动态库cmake之旅5):函数、宏与 .cmake 模块cmake之旅6查找和使用第三方库cmake之旅7编译选项与条件编译cmake之旅8Modern 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_SCOPEfunction(my_function) set(RESULT 计算结果 PARENT_SCOPE) endfunction() my_function() message(STATUS 读取: ${RESULT}) # 能读到计算结果2.3 function 与 macro 的对比对比项macrofunction作用域无独立作用域文本替换有独立作用域变量泄漏会泄漏到调用者作用域不会泄漏向外传递变量直接 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 中的#includeinclude(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.txtcmake_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.txtadd_calc_module(add_lib add.cpp)src/de/CMakeLists.txtadd_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 / FALSEARG_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_LIBDIRCMakePackageConfigHelpers辅助生成包配置文件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_LISTa;b;c -- CMAKE_CXX_STANDARD17cmake_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.cmake9 总结与下一篇预告这一篇我们学习了 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的工作原理。