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 超出定义参数之外的额外参数
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 的对比

对比项 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_variablesCMakePrintHelpers 模块提供的便捷函数,比手动写 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_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 的工作原理。

posted @ 2026-04-11 11:52  m晴朗  阅读(19)  评论(0)    收藏  举报