16、CMake函数与宏:function与macro的定义与区别,参数传递,返回值处理,模块化设计。
好,咱们今天聊点实在的。CMake 里的 function 和 macro,说白了就是帮你把重复的构建逻辑打包起来。我刚开始用 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 模块文件,我都会在文件头部写一段注释,说明这个模块提供了哪些函数、每个函数需要什么参数、会设置哪些变量。这样半年后自己回来看,或者同事接手,都能快速上手。
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