news 2026/4/27 5:53:22

cmake之旅(5)

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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_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超出定义参数之外的额外参数
ARGV0ARGV1按位置访问各个参数
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捕获了定义之外的额外参数cd,这在编写灵活的宏时非常有用。

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 的对比

对比项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++ 中的#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_variablesCMakePrintHelpers模块提供的便捷函数,比手动写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_executableadd_librarytarget_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 的代码复用机制:用functionmacro封装逻辑,用.cmake文件组织模块,用include引入模块,用CMAKE_MODULE_PATH管理模块搜索路径,用cmake_parse_arguments实现优雅的命名参数,还了解了脚本模式cmake -P

现在我们已经掌握了.cmake文件的基础用法。在下一篇中,我们将看到.cmake文件最重要的应用场景之一——查找第三方库。你有没有想过,当你在 CMakeLists.txt 中写find_package(OpenCV)的时候,CMake 是怎么找到 OpenCV 的?它去哪里找?找到之后又做了什么?

下一篇——cmake之旅(6):查找和使用第三方库,我们来揭开find_package的工作原理。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/4/27 5:52:52

PPIO上线GLM-5.1:面向8小时级长程任务的开源SOTA模型

今天,PPIO 上线 GLM-5.1。GLM-5.1 是智谱新一代的旗舰级智能体工程模型,其编码能力比上一代产品显著增强。GLM-5.1 在 SWE-Bench Pro 测试中取得了最先进的性能,并在 NL2Repo(代码库生成)和 Terminal-Bench 2.0&#x…

作者头像 李华
网站建设 2026/4/27 5:49:40

企业为什么会从“直接调模型“走向“统一 Token 网关“?

很多团队做大模型应用时,第一步都是直接接模型 API。这很正常。因为在项目早期,业务范围小、调用量有限、参与团队也不多,最重要的是先验证:- 模型能不能接 - 效果能不能跑通 - 用户会不会用 - 场景值不值得继续做所以"直接调…

作者头像 李华
网站建设 2026/4/11 10:13:08

Steam成就管理器终极指南:如何轻松管理你的游戏成就

Steam成就管理器终极指南:如何轻松管理你的游戏成就 【免费下载链接】SteamAchievementManager A manager for game achievements in Steam. 项目地址: https://gitcode.com/gh_mirrors/st/SteamAchievementManager 你是否曾经因为游戏bug而错失本该获得的成…

作者头像 李华
网站建设 2026/4/11 10:11:44

LingBot-Depth部署案例:边缘AI盒子(如Lantern、Neuralet)适配记录

LingBot-Depth部署案例:边缘AI盒子(如Lantern、Neuralet)适配记录 1. 项目背景与价值 LingBot-Depth是一个基于深度掩码建模的空间感知模型,专门用于将不完整的深度传感器数据转换为高质量的度量级3D测量。这个技术在实际应用中…

作者头像 李华
网站建设 2026/4/11 10:10:48

终极指南:zenodo_get深度解析与高效科研数据下载实战

终极指南:zenodo_get深度解析与高效科研数据下载实战 【免费下载链接】zenodo_get Zenodo_get: Downloader for Zenodo records 项目地址: https://gitcode.com/gh_mirrors/ze/zenodo_get 在科研数据管理领域,zenodo_get作为专业的Zenodo记录下载…

作者头像 李华