3. CMake进阶:变量与缓存、生成器表达式、自定义命令与目标、模块与函数

好,咱们继续往下走。上一章我们把CMake的基础语法和项目结构捋了一遍,说白了就是能跑起来了。但真实项目里,光能跑远远不够——你得能灵活控制,能跨平台适配,能自动化处理。这一章,我就带你看看CMake真正有威力的那部分。

3.1 变量与缓存:别让配置每次都重来

变量这东西,大家都会用。但CMake里的变量有个坑——默认只在当前CMakeLists.txt里生效,子目录看不到。我刚开始做跨平台项目时,就因为这个吃了亏。

核心区别:

  • 普通变量:set(MY_VAR "hello") —— 只在当前作用域有效
  • 缓存变量:set(MY_VAR "hello" CACHE STRING "描述") —— 全局有效,且会写入CMakeCache.txt

举个例子。你有个库需要用户指定安装路径:

set(MY_INSTALL_DIR "/usr/local" CACHE PATH "库的安装路径")
message("安装路径: ${MY_INSTALL_DIR}")

第一次运行cmake时,这个值会写入缓存。第二次再跑,除非你手动改,否则它不会变。嗯,这里要注意:如果你用FORCE关键字,每次都会覆盖缓存——我个人习惯只在初始化时用,别乱加。

小技巧:用cmake -D MY_VAR=xxx可以在命令行直接设置缓存变量,比改文件快多了。

3.2 生成器表达式:编译时的“if-else”

生成器表达式,说白了就是在生成构建系统时才求值的表达式。它不像普通变量在cmake配置阶段就展开,而是等到生成Makefile或Ninja文件时才计算。

为什么会需要这个?想想看:你同一个CMakeLists.txt,既要给Debug用,又要给Release用。Debug要加-g,Release要加-O2。用普通变量做不到,因为配置阶段还不知道你要生成哪种配置。

生成器表达式的语法是$<...>。常用的有:

# 只在Debug模式下添加编译选项
target_compile_options(myapp PRIVATE $<$<CONFIG:Debug>:-g>)

# 根据平台选择源文件
target_sources(myapp PRIVATE 
    $<$<PLATFORM_ID:Windows>:win_main.cpp>
    $<$<PLATFORM_ID:Linux>:linux_main.cpp>
)

我在项目中遇到过最典型的场景:同一个代码,Windows下需要链接ws2_32.lib,Linux下不需要。用生成器表达式,一行搞定:

target_link_libraries(myapp PRIVATE 
    $<$<PLATFORM_ID:Windows>:ws2_32>
)

注意:生成器表达式不能用在set()、message()等配置阶段执行的命令里。它只支持在target_xxx、install()、add_test()等生成阶段命令中使用。

3.3 自定义命令与目标:让构建流程更智能

有时候,你需要在构建过程中执行一些额外操作——比如生成代码、复制文件、运行脚本。这时候add_custom_command和add_custom_target就派上用场了。

3.3.1 add_custom_command:给目标加个“钩子”

最常见的用法是:在编译某个目标之前,先生成一些源文件。

# 用Python脚本生成配置头文件
add_custom_command(
    OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/config.h
    COMMAND python3 ${CMAKE_CURRENT_SOURCE_DIR}/gen_config.py
    DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/config_template.h.in
    COMMENT "生成config.h..."
)

# 把这个生成的文件加到目标里
target_sources(myapp PRIVATE ${CMAKE_CURRENT_BINARY_DIR}/config.h)

嗯,这里有个关键点:DEPENDS要写对。我曾经因为漏写了依赖,导致改了模板文件后,config.h没重新生成——排查了半天才发现。

3.3.2 add_custom_target:独立的任务

自定义目标不产生输出文件,它就是一个可执行的任务。比如你想加一个“清理所有生成文件”的命令:

add_custom_target(clean_all
    COMMAND ${CMAKE_COMMAND} -E remove_directory ${CMAKE_BINARY_DIR}/generated
    COMMAND ${CMAKE_COMMAND} -E echo "已清理所有生成文件"
)

运行方式:cmake --build . --target clean_all

实用场景:我经常用add_custom_target配合add_dependencies,让某个目标在构建前自动执行代码格式化、静态检查等任务。

3.4 模块与函数:告别重复代码

项目大了,你会发现很多CMake代码是重复的——每个子目录都要设置同样的编译选项、链接同样的库。这时候就该封装成函数或模块了。

3.4.1 函数:封装可复用的逻辑

function(add_qt_module target_name)
    # 设置Qt模块的通用编译选项
    set_target_properties(${target_name} PROPERTIES
        AUTOMOC ON
        AUTOUIC ON
        AUTORCC ON
    )
    
    # 链接Qt核心库
    target_link_libraries(${target_name} PRIVATE
        Qt5::Core
        Qt5::Widgets
    )
    
    # 设置包含路径
    target_include_directories(${target_name} PRIVATE
        ${CMAKE_CURRENT_SOURCE_DIR}
    )
endfunction()

# 使用
add_executable(myapp main.cpp)
add_qt_module(myapp)

注意:CMake函数里的变量默认是局部作用域的。如果你想修改外部变量,要用set(... PARENT_SCOPE)。

3.4.2 模块:把函数放到单独文件里

把上面那个函数保存到cmake/QtModule.cmake,然后在主CMakeLists.txt里:

list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake")
include(QtModule)

这样,所有子目录都能用这个函数了。我习惯把常用的编译选项、查找第三方库的逻辑都做成模块,一个项目下来,主文件清爽很多。

避坑指南:我曾经把模块文件放在项目根目录,结果不同分支合并时老是冲突。后来统一放到cmake/子目录,用CMAKE_MODULE_PATH管理,再也没出过问题。

知识体系总览

下面这张图,把本章的核心逻辑串起来了:

CMake进阶知识体系 变量与缓存 set() / CACHE / FORCE 生成器表达式 $<...> / CONFIG / PLATFORM_ID 自定义命令与目标 add_custom_command / target 模块与函数 function() / include() 跨平台可维护构建系统 Qt界面参数调优 AUTOMOC / AUTOUIC / AUTORCC 第三方库管理 find_package / 自定义模块 多平台适配 生成器表达式 / 条件编译 四个模块相互配合,构建出灵活、可维护的跨平台CMake项目

说白了,这一章的内容就是让你从“能用CMake”变成“用好CMake”。变量缓存解决配置持久化问题,生成器表达式解决平台差异,自定义命令解决自动化需求,模块和函数解决代码复用。这四个东西配合起来,你的构建系统才能真正做到一次编写,到处编译。

我个人觉得,学CMake进阶最忌讳的就是死记语法。你想想看,每个项目的情况都不一样,关键是理解什么时候该用哪个工具。遇到重复代码,就封装成函数;遇到平台差异,就用生成器表达式;遇到需要生成文件,就用自定义命令。这样思路就清晰了。

最后一个小建议:刚开始别追求完美。先把项目跑起来,遇到痛点再回头用这些进阶特性。我见过太多人一开始就搞复杂的模块结构,结果把自己绕进去了。

好,这一章就到这里。记住:工具是为人服务的,别为了用工具而用工具。