11. 生成器表达式进阶:平台与编译器检测、目标属性查询、链接与路径转换

生成器表达式,说白了就是 CMake 里的「三目运算符」—— 但它能干的事远不止条件判断。我个人觉得,这是 CMake 里最被低估的特性之一。很多项目写了一大堆 if(WIN32)if(MSVC),其实用生成器表达式一行就能搞定,而且更干净、更跨平台。

这一节我们深入四个方向:平台与编译器检测目标属性查询链接相关表达式路径转换。每个我都会结合项目里的实际坑点来讲。

11.1 平台与编译器检测表达式

先看最常用的:$<PLATFORM_ID>$<CXX_COMPILER_ID>

# 只在 Windows 上添加一个编译定义
target_compile_definitions(myapp PRIVATE
    $<$<PLATFORM_ID:Windows>:MY_WIN32_PLATFORM>
)

# 只在 MSVC 下启用特定警告
target_compile_options(myapp PRIVATE
    $<$<CXX_COMPILER_ID:MSVC>:/W4>
)

这里有个细节:$<PLATFORM_ID:Windows> 本身是一个布尔表达式,外面再套一层 $<...:value> 就是「如果条件为真,则输出 value」。两层嵌套,别写反了。

我的习惯: 把平台检测和编译器检测分开写,不要混在一起。比如不要写 $<$<PLATFORM_ID:Windows>:$<CXX_COMPILER_ID:MSVC>:xxx>,可读性太差。拆成两行,或者用 AND 组合。

还有几个我常用的:

  • $<CXX_COMPILER_GCC> — 是不是 GCC 家族(包括 MinGW)
  • $<CXX_COMPILER_CLANG> — 是不是 Clang
  • $<COMPILE_LANGUAGE> — 当前编译的语言(C、CXX、CUDA 等)

举个例子,混合 C 和 C++ 的项目里,我经常这样用:

target_compile_options(mylib PRIVATE
    $<$<COMPILE_LANGUAGE:CXX>:-fno-exceptions>
)

这样 C 文件不受影响,C++ 文件才加这个 flag。干净利落。

11.2 目标属性查询表达式

这个功能我是在做大型重构时才真正用上的。以前我总用变量存路径,后来发现用 $<TARGET_PROPERTY:...> 更可靠。

# 查询目标的输出名称
add_executable(myapp main.cpp)
message("Output name: $<TARGET_PROPERTY:myapp,OUTPUT_NAME>")

# 查询目标的包含目录
target_include_directories(myapp PRIVATE src)
message("Include dirs: $<TARGET_PROPERTY:myapp,INCLUDE_DIRECTORIES>")

注意:生成器表达式在 message() 里不会展开,只有在生成构建系统时才会求值。所以上面这段代码在 cmake --build 阶段才能看到实际值。调试时可以用 file(GENERATE) 输出到文件查看。

我曾经踩过的坑:$<TARGET_PROPERTY:target,INTERFACE_INCLUDE_DIRECTORIES> 去查一个没有设置该属性的目标,结果返回空字符串,导致后续路径拼接出错。后来我养成了先检查属性是否存在的习惯:$<TARGET_EXISTS:target> 配合使用。

常用的目标属性查询:

表达式说明
$<TARGET_PROPERTY:target,prop>查询目标属性值
$<TARGET_EXISTS:target>目标是否存在(布尔)
$<TARGET_NAME_IF_EXISTS:target>存在则返回目标名,否则空
$<TARGET_FILE:target>目标生成的文件路径(全路径)
$<TARGET_FILE_NAME:target>仅文件名
$<TARGET_FILE_DIR:target>仅目录

11.3 链接相关表达式

链接表达式是生成器表达式里最「动态」的部分。我刚开始用的时候,总觉得它像魔法,后来理解了原理,才发现它其实很朴素。

核心是 $<LINK_ONLY:...>$<LINK_LIBRARY:feature,lib>

# 只在链接阶段添加一个库,不传播编译选项
target_link_libraries(myapp PRIVATE
    $<LINK_ONLY:some_static_lib>
)

# 指定链接方式(CMake 3.24+)
target_link_libraries(myapp PRIVATE
    $<LINK_LIBRARY:WHOLE_ARCHIVE,my_static_lib>
)

为什么要用 LINK_ONLY?举个例子:你有一个静态库,它只提供符号,不需要它的头文件路径。如果用普通的 target_link_libraries,CMake 会自动传播它的 INTERFACE_INCLUDE_DIRECTORIES。用 LINK_ONLY 可以切断这种传播。

我的经验: 在嵌入式项目里,经常需要把某个静态库整个链接进来(WHOLE_ARCHIVE)。以前我手动加链接器 flag,不同编译器写法还不一样。用 $<LINK_LIBRARY:WHOLE_ARCHIVE,...> 之后,CMake 自动处理了 GCC、Clang、MSVC 的差异。省心不少。

还有 $<LINK_GROUP:feature,lib1,lib2>,用于处理链接组(比如 --start-group / --end-group)。这个我用的不多,但在处理循环依赖时确实有用。

11.4 路径转换表达式

路径转换,说白了就是让路径在不同上下文之间「说人话」。比如源码里的相对路径,到了构建目录就变了;或者 Windows 的 \ 和 Linux 的 / 混用。

CMake 提供了几个路径相关的生成器表达式:

# 相对路径转绝对路径
set(MY_PATH "src/foo/bar")
target_compile_definitions(myapp PRIVATE
    MY_PATH="$<PATH:ABSOLUTE,${MY_PATH}>"
)

# 路径分隔符统一
target_compile_definitions(myapp PRIVATE
    MY_PATH="$<PATH:NATIVE_PATH,${MY_PATH}>"
)

注意:$<PATH:...> 系列表达式是 CMake 3.20 才引入的。如果你的项目需要兼容旧版本,可以用 file(TO_CMAKE_PATH)file(TO_NATIVE_PATH) 在配置阶段处理。

我建议: 如果你在生成器表达式里拼接路径,尽量用 $<JOIN:...> 而不是直接字符串拼接。比如 $<JOIN:$<TARGET_FILE_DIR:myapp>,$<TARGET_FILE_NAME:myapp>,>$<TARGET_FILE_DIR:myapp>/$<TARGET_FILE_NAME:myapp> 更安全,因为 JOIN 会自动处理分隔符。

还有一个我常用的:$<SHELL_PATH:...>。在交叉编译时,目标平台的路径格式可能和主机不同。这个表达式会把路径转换成目标平台 shell 能识别的格式。

# 在 Windows 主机上为 Linux 目标生成路径
target_compile_definitions(myapp PRIVATE
    DATA_DIR="$<SHELL_PATH:/opt/data>"
)
# 输出:/opt/data (即使在 Windows 主机上)

知识体系图

下面这张图概括了生成器表达式进阶的四个方向及其关系:

生成器表达式进阶 平台与编译器检测 $<PLATFORM_ID:Windows> $<CXX_COMPILER_ID:MSVC> $<COMPILE_LANGUAGE:CXX> $<CXX_COMPILER_GCC> 目标属性查询 $<TARGET_PROPERTY:target,prop> $<TARGET_EXISTS:target> $<TARGET_FILE:target> $<TARGET_FILE_DIR:target> 链接相关表达式 $<LINK_ONLY:lib> $<LINK_LIBRARY:feature,lib> 路径转换表达式 $<PATH:ABSOLUTE,path> $<SHELL_PATH:path>

避坑总结

最后,把我这些年用生成器表达式踩过的坑列一下:

  • 嵌套层数别太多:超过三层嵌套,代码基本没法维护。我见过有人写五层嵌套的生成器表达式,调试时差点崩溃。
  • 注意求值时机:生成器表达式在配置阶段不会展开,message() 里看不到。用 file(GENERATE) 输出到文件调试。
  • 版本兼容性$<LINK_LIBRARY> 需要 CMake 3.24+,$<PATH> 需要 3.20+。如果你的项目要支持旧版本,记得加 cmake_minimum_required 检查。
  • 引号问题:生成器表达式里如果包含空格或特殊字符,记得用双引号包起来。比如 "$<TARGET_FILE:myapp>"

生成器表达式用好了,CMakeLists.txt 会变得非常简洁。我个人现在写新项目,几乎不用 if(WIN32) 这种老式写法了。生成器表达式 + 目标属性,基本能覆盖 90% 的平台差异场景。

一句话总结: 生成器表达式是 CMake 的「编译期计算」能力。它让构建逻辑从配置阶段延迟到生成阶段,从而获得更准确的平台、编译器、目标信息。用好它,你的 CMake 代码会更有「现代感」。

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