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”变成“用好CMake”。变量缓存解决配置持久化问题,生成器表达式解决平台差异,自定义命令解决自动化需求,模块和函数解决代码复用。这四个东西配合起来,你的构建系统才能真正做到一次编写,到处编译。
我个人觉得,学CMake进阶最忌讳的就是死记语法。你想想看,每个项目的情况都不一样,关键是理解什么时候该用哪个工具。遇到重复代码,就封装成函数;遇到平台差异,就用生成器表达式;遇到需要生成文件,就用自定义命令。这样思路就清晰了。
最后一个小建议:刚开始别追求完美。先把项目跑起来,遇到痛点再回头用这些进阶特性。我见过太多人一开始就搞复杂的模块结构,结果把自己绕进去了。
好,这一章就到这里。记住:工具是为人服务的,别为了用工具而用工具。