第三章:CMake进阶——变量与缓存、条件判断与循环、函数与宏定义、生成器表达式

各位好,欢迎来到第三章。

说实话,很多工程师用CMake,就停留在「set一个变量,add_executable一把梭」的阶段。但真正到了跨平台项目里,你会发现那套根本不够用。变量作用域乱窜、缓存值覆盖不掉、条件判断写成一团浆糊……嗯,这些坑我基本都踩过。

这一章,咱们就把CMake的进阶玩法掰开揉碎。你想想看,掌握了变量缓存、条件循环、函数宏定义、生成器表达式,你才算真正能驾驭CMake。

核心知识点一览:

  • 变量作用域与缓存机制
  • 条件判断与循环控制
  • 函数与宏定义的区别与实战
  • 生成器表达式的妙用
CMake进阶核心知识体系 CMake进阶 变量与缓存 条件判断与循环 函数与宏定义 生成器表达式 作用域 缓存变量 function macro —— 跨平台编译的四大支柱 ——

3.1 变量与缓存:别让作用域坑了你

CMake的变量,说白了就两种:普通变量和缓存变量。

普通变量只在当前CMakeLists.txt及其子目录生效。缓存变量则会被写入CMakeCache.txt,跨构建目录持久化。

我个人的习惯是:项目内部临时变量用普通变量,用户可配置的选项(比如开关、路径)用缓存变量。

小技巧:用 set(VAR value CACHE STRING "描述") 定义缓存变量。加个描述,别人看CMakeCache.txt时能明白这是干嘛的。

举个例子,我在一个嵌入式项目中需要让用户选择芯片型号:

set(CHIP_MODEL "STM32F407" CACHE STRING "选择芯片型号:STM32F407 / STM32H743 / STM32G031")

# 强制缓存变量优先于普通变量
if(DEFINED CACHE{CHIP_MODEL})
    message(STATUS "用户指定芯片: ${CHIP_MODEL}")
endif()

这里有个坑——缓存变量一旦设置,后续的set语句不会覆盖它。除非你加 FORCE 关键字。

警告:我曾经在调试一个CI构建时,发现改了CMakeLists.txt里的路径,但构建一直用旧路径。查了半天,原来是CMakeCache.txt里缓存了旧值。解决方案:要么删Cache文件,要么用 set(... CACHE STRING "" FORCE) 强制更新。

3.2 条件判断与循环:控制流的正确姿势

条件判断,CMake用的是 if() 家族。语法上跟C语言有点像,但细节上全是坑。

先看一个我踩过的坑:

set(MY_FLAG OFF)
if(MY_FLAG)
    message("这个分支不会执行")
endif()

为什么?因为CMake里 OFF、NO、FALSE、0、空字符串都算假。但 if(MY_FLAG) 判断的是变量是否定义,而不是值是否为真。正确写法:

if(${MY_FLAG})
    message("现在才会执行")
endif()

嗯,这里要注意:if() 里用 ${} 展开变量,才是比较值。否则比较的是变量名本身。

循环方面,CMake支持 foreach() 和 while()。我个人最常用的是 foreach() 遍历源文件列表:

set(SOURCES main.cpp utils.cpp driver.cpp)
foreach(SRC ${SOURCES})
    message("编译源文件: ${SRC}")
    # 可以在这里做自定义处理
endforeach()

还有一种 foreach(RANGE) 的用法,适合生成序号:

foreach(i RANGE 1 5)
    message("迭代次数: ${i}")
endforeach()

实战建议:在大型项目中,我习惯用 foreach() 配合 list() 操作来管理多个子目录的源文件。比手动一个个add_library清爽多了。

3.3 函数与宏定义:封装你的构建逻辑

函数和宏,都是用来封装重复代码的。但两者有本质区别:

特性 function() macro()
作用域 创建新作用域 在调用者作用域展开
变量修改 不影响外部变量(除非用PARENT_SCOPE) 直接修改调用者变量
返回值 通过set(PARENT_SCOPE)返回 直接修改外部变量
适用场景 需要隔离逻辑、避免副作用 需要修改调用者环境

我个人更推荐用函数。为什么?因为宏的「无作用域」特性,很容易造成变量污染。我在一个项目中用宏封装了一个添加编译选项的逻辑,结果宏内部不小心改了一个叫 SRC 的变量,导致外部循环直接崩了……

来看一个函数封装的例子:

function(add_static_library TARGET_NAME)
    set(SOURCES "")
    foreach(SRC ${ARGN})
        list(APPEND SOURCES ${SRC})
    endforeach()
    
    add_library(${TARGET_NAME} STATIC ${SOURCES})
    target_include_directories(${TARGET_NAME} PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
    
    message(STATUS "已添加静态库: ${TARGET_NAME}")
endfunction()

# 使用
add_static_library(mylib src/a.cpp src/b.cpp src/c.cpp)

这里 ARGN 是CMake内置变量,代表函数调用时传入的额外参数。用起来非常灵活。

小技巧:函数内部如果要修改外部变量,记得加 PARENT_SCOPE:set(MY_VAR "value" PARENT_SCOPE)。这是函数「返回值」的标准做法。

3.4 生成器表达式:构建时的魔法

生成器表达式(Generator Expressions)是CMake里最容易被忽视、但最强大的特性之一。它不是在配置阶段求值,而是在构建阶段(生成构建系统时)求值。

说白了,就是「根据不同的编译器、不同的配置、不同的目标平台,生成不同的编译参数」。

语法是 $<...> 的形式。我挑几个最常用的:

# 根据配置类型设置不同标志
target_compile_definitions(mytarget PRIVATE
    $<$<CONFIG:Debug>:DEBUG_MODE>
    $<$<CONFIG:Release>:NDEBUG>
)

# 根据编译器设置不同选项
target_compile_options(mytarget PRIVATE
    $<$<CXX_COMPILER_ID:MSVC>:/W4>
    $<$<CXX_COMPILER_ID:GNU>:-Wall -Wextra>
)

# 条件链接库
target_link_libraries(mytarget PRIVATE
    $<$<PLATFORM_ID:Windows>:ws2_32>
    $<$<PLATFORM_ID:Linux>:pthread>
)

我曾经在做一个跨平台网络库时,Windows需要链接ws2_32,Linux需要链接pthread,macOS什么都不用。用生成器表达式,一行搞定,不用写三个if-else块。

注意:生成器表达式不能用在 message() 里调试,因为它在配置阶段不执行。想调试的话,可以用 file(GENERATE ...) 输出到文件查看。

还有一个我常用的技巧——用生成器表达式处理编译选项的叠加:

# 只在Debug模式下启用ASAN
target_compile_options(mytarget PRIVATE
    $<$<CONFIG:Debug>:-fsanitize=address>
)
target_link_options(mytarget PRIVATE
    $<$<CONFIG:Debug>:-fsanitize=address>
)

这样Release构建完全不受影响,干净利落。

总结一下:

  • 变量缓存:记住作用域规则,缓存变量用CACHE关键字
  • 条件循环:if里用${}展开,foreach配合list管理源文件
  • 函数宏定义:优先用function,避免变量污染
  • 生成器表达式:配置无关的魔法,跨平台利器

这些技巧,我在实际项目中反复验证过。你只要用熟了,CMake就不再是「能跑就行」的工具,而是你跨平台编译的得力助手。

可可精品资料库