16、CMake函数与宏:function与macro的定义与区别,参数传递,返回值处理,模块化设计。

好,咱们今天聊点实在的。CMake 里的 functionmacro,说白了就是帮你把重复的构建逻辑打包起来。我刚开始用 CMake 的时候,总觉得写几个 set()target_link_libraries() 就完事了,直到项目越来越大,一个 CMakeLists.txt 写了上千行……嗯,那场面,谁看谁头疼。

今天我就把这两个东西掰开揉碎讲清楚。你想想看,写代码都知道要封装函数,写构建脚本也一样。咱们的目标是:让 CMakeLists.txt 像代码一样优雅

16.1 function 与 macro 的定义与区别

先看定义。语法上两者几乎一样:

# 定义一个宏
macro(my_macro arg1 arg2)
    message(STATUS "宏内部: arg1=${arg1}, arg2=${arg2}")
    set(result "来自宏的结果")
endmacro()

# 定义一个函数
function(my_function arg1 arg2)
    message(STATUS "函数内部: arg1=${arg1}, arg2=${arg2}")
    set(result "来自函数的结果" PARENT_SCOPE)
endfunction()

看起来差不多?但坑就在这里。我当年第一次用宏的时候,就踩了个大雷。

核心区别:作用域

特性 macro function
变量作用域 调用者的作用域 独立作用域
参数传递 文本替换(类似C宏) 值传递(类似C函数)
return() 不支持 支持
内部set变量 影响外部变量 不影响外部(需PARENT_SCOPE)
性能 略快(无函数调用开销) 正常

说白了,macro 就是文本替换。你写进去的代码,会被原封不动地粘贴到调用处。而 function 会创建一个新的变量作用域,像个真正的函数。

我曾经踩过的坑:在 macro 里用 set() 修改了一个变量,结果调用者那边的同名变量也被改了。排查了半天,才发现是 macro 的「副作用」。从那以后,我给自己定了个规矩:除非有特殊理由,否则一律用 function

16.2 参数传递的细节

CMake 的参数传递比你想的要灵活。我个人习惯用 命名参数 + 关键字参数 的方式,可读性最好。

function(add_sdk_library target_name)
    # 解析关键字参数
    set(options STATIC SHARED)
    set(oneValueArgs VERSION OUTPUT_NAME)
    set(multiValueArgs SOURCES DEPENDENCIES)
    
    cmake_parse_arguments(PARSE_ARGV 1
        "${options}" "${oneValueArgs}" "${multiValueArgs}"
    )
    
    # 创建库
    if(${_STATIC})
        add_library(${target_name} STATIC ${_SOURCES})
    elseif(${_SHARED})
        add_library(${target_name} SHARED ${_SOURCES})
    else()
        add_library(${target_name} ${_SOURCES})
    endif()
    
    # 设置版本
    if(_VERSION)
        set_target_properties(${target_name} PROPERTIES
            VERSION ${_VERSION}
            SOVERSION ${_VERSION}
        )
    endif()
    
    # 链接依赖
    if(_DEPENDENCIES)
        target_link_libraries(${target_name} PUBLIC ${_DEPENDENCIES})
    endif()
endfunction()

# 调用示例
add_sdk_library(my_lib
    STATIC
    SOURCES src/a.c src/b.c
    VERSION 1.2.3
    DEPENDENCIES core_lib utils_lib
)

你看,这样调用的时候,每个参数的含义一目了然。我在嵌入式项目里经常用这种模式,尤其是管理多个芯片平台的 SDK 库时,特别顺手。

小技巧:cmake_parse_arguments 时,注意 PARSE_ARGV 后面的数字。如果函数有固定参数,比如 target_name 是第一个参数,那关键字参数就从索引 1 开始解析。这个细节我一开始也搞错过。

16.3 返回值处理

CMake 函数没有真正的「返回值」概念。你想想看,构建系统本质上是在设置变量和创建目标,所以「返回值」通常通过两种方式实现:

方式一:通过变量返回(推荐)

function(get_compiler_flags output_var)
    if(CMAKE_C_COMPILER_ID MATCHES "GNU")
        set(${output_var} "-Wall -Wextra -Werror" PARENT_SCOPE)
    elseif(CMAKE_C_COMPILER_ID MATCHES "Clang")
        set(${output_var} "-Wall -Weverything" PARENT_SCOPE)
    else()
        set(${output_var} "" PARENT_SCOPE)
    endif()
endfunction()

# 调用
get_compiler_flags(MY_FLAGS)
message(STATUS "编译器标志: ${MY_FLAGS}")

方式二:通过全局属性返回

function(detect_board_type)
    if(EXISTS "/proc/device-tree/model")
        file(READ "/proc/device-tree/model" board_model)
        string(STRIP "${board_model}" board_model)
        set_property(GLOBAL PROPERTY CURRENT_BOARD "${board_model}")
    else()
        set_property(GLOBAL PROPERTY CURRENT_BOARD "unknown")
    endif()
endfunction()

# 调用
detect_board_type()
get_property(board GLOBAL PROPERTY CURRENT_BOARD)
message(STATUS "当前板型: ${board}")

我个人更倾向第一种方式。为什么?因为显式地传递变量名,调用者一眼就能看出这个函数会「输出」什么。全局属性虽然方便,但容易造成隐式依赖,调试起来比较头疼。

16.4 模块化设计实战

好了,理论说完了,咱们来点实战。下面是我在一个 STM32 项目中实际用过的模块化设计思路。

项目结构:

project/
├── CMakeLists.txt          # 顶层
├── cmake/
│   ├── toolchain.cmake     # 工具链
│   ├── modules/
│   │   ├── stm32_hal.cmake # HAL库配置
│   │   ├── freertos.cmake  # FreeRTOS配置
│   │   └── linker.cmake    # 链接脚本处理
│   └── utilities.cmake     # 通用工具函数
├── src/
└── include/

顶层 CMakeLists.txt:

cmake_minimum_required(VERSION 3.16)
project(stm32_firmware C CXX ASM)

# 加载模块
list(APPEND CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/cmake/modules")
include(utilities)
include(stm32_hal)
include(freertos)
include(linker)

# 创建目标
add_executable(${PROJECT_NAME}
    src/main.c
    src/gpio.c
    src/usart.c
)

# 应用模块配置
stm32_hal_configure(TARGET ${PROJECT_NAME} CHIP STM32F407)
freertos_configure(TARGET ${PROJECT_NAME} HEAP_TYPE 4)
linker_configure(TARGET ${PROJECT_NAME} SCRIPT stm32f407.ld)

模块示例:stm32_hal.cmake

function(stm32_hal_configure)
    set(options "")
    set(oneValueArgs TARGET CHIP)
    set(multiValueArgs "")
    cmake_parse_arguments(PARSE_ARGV 0
        "${options}" "${oneValueArgs}" "${multiValueArgs}"
    )
    
    if(NOT _TARGET)
        message(FATAL_ERROR "stm32_hal_configure: TARGET is required")
    endif()
    
    # 根据芯片型号设置编译选项
    if(_CHIP MATCHES "STM32F4")
        target_compile_definitions(${_TARGET} PRIVATE STM32F4xx)
        target_include_directories(${_TARGET} PRIVATE
            ${CMAKE_SOURCE_DIR}/Drivers/CMSIS/Device/ST/STM32F4xx/Include
        )
    elseif(_CHIP MATCHES "STM32L0")
        target_compile_definitions(${_TARGET} PRIVATE STM32L0xx)
        target_include_directories(${_TARGET} PRIVATE
            ${CMAKE_SOURCE_DIR}/Drivers/CMSIS/Device/ST/STM32L0xx/Include
        )
    else()
        message(WARNING "Unknown STM32 chip: ${_CHIP}")
    endif()
    
    # 添加HAL库源文件
    target_sources(${_TARGET} PRIVATE
        ${CMAKE_SOURCE_DIR}/Drivers/STM32xx_HAL_Driver/Src/stm32${_CHIP}_hal.c
        ${CMAKE_SOURCE_DIR}/Drivers/STM32xx_HAL_Driver/Src/stm32${_CHIP}_hal_gpio.c
        ${CMAKE_SOURCE_DIR}/Drivers/STM32xx_HAL_Driver/Src/stm32${_CHIP}_hal_rcc.c
    )
endfunction()

模块化设计的核心原则:

  • 单一职责:每个 .cmake 文件只做一件事
  • 显式依赖:用参数传递依赖,不要隐式依赖全局变量
  • 可复用:函数设计时考虑通用性,不要写死路径
  • 错误处理:关键参数缺失时,用 FATAL_ERROR 尽早报错

下面这张图,是我对 CMake 模块化设计的心得总结:

CMake 模块化设计架构 CMakeLists.txt stm32_hal.cmake freertos.cmake linker.cmake stm32_hal_configure() freertos_configure() linker_configure() get_compiler_flags() CMake 内置命令层 add_library() | target_include_directories() | set_property() | cmake_parse_arguments() 设计要点: • 顶层只做 include 和调用,不写具体逻辑 • 每个模块独立维护,可单独测试 • 函数命名统一前缀,避免命名冲突 • 用 cmake_parse_arguments 实现关键字参数

我的个人习惯:每个 .cmake 模块文件,我都会在文件头部写一段注释,说明这个模块提供了哪些函数、每个函数需要什么参数、会设置哪些变量。这样半年后自己回来看,或者同事接手,都能快速上手。

16.5 避坑指南

最后,分享几个我踩过的坑:

  • 宏里的 return 陷阱:macro 不支持 return(),如果你在 macro 里写了 return(),它会直接跳出整个文件!我因为这个 bug 排查了整整一个下午。
  • 变量名冲突:在 function 里用 set(var ...) 时,如果 var 和外部变量同名,记得用 PARENT_SCOPE 明确作用域。不然你以为改了,其实没改。
  • 参数列表过长:如果一个函数有超过 5 个参数,建议用 cmake_parse_arguments 改成关键字参数。否则调用的时候,参数顺序一错,全盘皆输。
  • include 顺序:如果模块之间有依赖关系,注意 include 的顺序。比如 utilities.cmake 里定义了通用函数,那它应该被最先 include。

好了,关于 CMake 的函数与宏,今天就聊这么多。记住一句话:能用 function 就别用 macro,能用关键字参数就别用位置参数。这样写出来的 CMake 脚本,你自己看着舒服,别人看着也明白。


公众号:蓝海资料掘金营,微信deep3321