12、CMake高级特性:自定义命令、自定义目标、生成器表达式与compile_commands.json

说实话,CMake 用久了你会发现,它最强大的地方不是帮你编译几个源文件。真正让 CMake 从「好用」变成「不可或缺」的,是它那套高级特性。今天我就带你把这四个硬骨头啃下来:自定义命令自定义目标生成器表达式,还有那个让 IDE 和静态分析工具爱不释手的 compile_commands.json

12.1 自定义命令:add_custom_command

先问个问题:如果你的构建过程需要执行一个外部脚本、生成一份文档、或者跑一个代码生成器,CMake 能搞定吗?

能。靠的就是 add_custom_command

这个命令有两种用法:一种是为某个目标添加额外的构建步骤,另一种是生成一个文件。我个人更常用的是第二种——生成文件。

add_custom_command(
    OUTPUT generated.h
    COMMAND python3 ${CMAKE_SOURCE_DIR}/scripts/gen_header.py
    DEPENDS ${CMAKE_SOURCE_DIR}/scripts/gen_header.py
    COMMENT "Generating generated.h..."
)

你看,这个命令告诉 CMake:「嘿,generated.h 这个文件不是现成的,你得跑个 Python 脚本才能生成它。」然后你可以在 add_executable 里直接引用这个文件,CMake 会自动管理它的依赖关系。

我的经验:我在一个嵌入式项目中遇到过需要根据 Excel 表格生成 C 语言配置头文件的情况。当时就是用 add_custom_command 配合 Python 脚本搞定的。每次表格更新,CMake 检测到依赖变化,自动重新生成头文件。省了多少手动复制粘贴的功夫啊。

还有一种常见场景:编译后执行一些操作,比如复制库文件到指定目录。

add_custom_command(
    TARGET mylib POST_BUILD
    COMMAND ${CMAKE_COMMAND} -E copy $<TARGET_FILE:mylib> ${CMAKE_BINARY_DIR}/lib/
    COMMENT "Copying mylib to lib directory..."
)

这里 POST_BUILD 表示在目标构建完成后执行。还有 PRE_BUILDPRE_LINK,不过说实话,POST_BUILD 是我用得最多的。

注意:如果你用 OUTPUT 形式的 add_custom_command,一定要确保这个命令确实生成了那个文件。我曾经犯过一个错误:脚本执行成功了,但文件没写出来,结果后续编译直接报错。排查了半天才发现是脚本里路径写死了。

12.2 自定义目标:add_custom_target

有时候你不需要生成一个具体的文件,只是想定义一个「动作」——比如运行测试、打包文档、清理临时文件。这时候就该 add_custom_target 出场了。

add_custom_target(
    run_tests
    COMMAND ${CMAKE_CTEST_COMMAND} --output-on-failure
    DEPENDS myapp
    COMMENT "Running all tests..."
)

这个目标没有输出文件,它只是一个「标签」。你运行 make run_tests 的时候,CMake 会先确保 myapp 是最新的,然后执行测试命令。

自定义目标有个特点:它默认不会被构建。除非你显式指定它,或者用 ALL 关键字把它加到默认构建中。

add_custom_target(
    docs ALL
    COMMAND doxygen ${CMAKE_SOURCE_DIR}/Doxyfile
    COMMENT "Generating documentation..."
)

加了 ALL 之后,每次构建都会生成文档。嗯,不过我个人不太建议这么做——文档生成通常比较慢,按需触发更合理。

核心区别:

  • add_custom_command 生成文件,可以被其他目标依赖
  • add_custom_target 定义动作,没有输出文件
  • 两者经常配合使用:用 add_custom_target 驱动一组 add_custom_command

12.3 生成器表达式:Generator Expressions

生成器表达式是 CMake 里最容易被忽视、但最强大的特性之一。说白了,它就是一种「延迟求值」的机制——在 CMake 配置阶段不计算,等到生成构建系统的时候才计算。

语法很简单:$<...>。里面可以放条件判断、配置信息、目标属性等等。

target_compile_definitions(
    myapp PRIVATE
    $<$<CONFIG:Debug>:DEBUG_MODE>
    $<$<CONFIG:Release>:NDEBUG>
)

这段代码的意思是:如果是 Debug 配置,定义 DEBUG_MODE;如果是 Release 配置,定义 NDEBUG。你想想看,如果没有生成器表达式,你可能得写两个 target_compile_definitions,分别放在 if(CMAKE_BUILD_TYPE STREQUAL "Debug") 里面。多麻烦。

生成器表达式还能用在很多地方:

  • 条件链接库:$<$<PLATFORM_ID:Linux>:-lrt>
  • 获取目标属性:$<TARGET_FILE:mylib>
  • 布尔逻辑组合:$<$<AND:$<CONFIG:Debug>,$<PLATFORM_ID:Linux>>:SPECIAL_FLAG>

避坑指南:我曾经在 add_custom_commandCOMMAND 里直接用了生成器表达式,结果发现它根本没展开。后来才意识到:COMMAND 里的生成器表达式只在某些生成器(比如 Ninja)下才支持。Makefile 生成器就不行。所以如果你要跨平台,最好用 generator-expressions 只在 COMPILE_DEFINITIONSLINK_LIBRARIES 这些标准属性里用。

12.4 compile_commands.json

这个文件,说白了就是 CMake 给你的一份「编译命令清单」。它记录了每个源文件是怎么被编译的——编译器路径、编译选项、包含路径、宏定义,一清二楚。

怎么生成?简单:

cmake -DCMAKE_EXPORT_COMPILE_COMMANDS=ON ..

或者在你的 CMakeLists.txt 里设置:

set(CMAKE_EXPORT_COMPILE_COMMANDS ON)

然后你就会在构建目录里看到一个 compile_commands.json 文件。它的结构大概是这样的:

[
  {
    "directory": "/home/user/project/build",
    "command": "/usr/bin/g++ -c -I../include -DDEBUG -o main.o ../src/main.cpp",
    "file": "../src/main.cpp"
  },
  {
    "directory": "/home/user/project/build",
    "command": "/usr/bin/g++ -c -I../include -DDEBUG -o utils.o ../src/utils.cpp",
    "file": "../src/utils.cpp"
  }
]

这个文件有什么用?用处大了去了。

  • IDE 支持:VS Code、CLion、Qt Creator 都能读取这个文件,给你提供精准的代码补全和跳转
  • 静态分析:clang-tidy、cppcheck 可以直接用这个文件分析你的代码,不需要你手动配置包含路径
  • 代码索引:像 cquery、ccls 这些语言服务器,靠的就是 compile_commands.json 来理解你的项目结构

我的建议:不管项目大小,都把这个选项打开。它不会影响构建速度,但能让你在代码编辑和静态分析上省下大量时间。我接手过一个遗留项目,没有这个文件,IDE 里全是红色波浪线。打开之后,世界清净了。

12.5 知识体系总览

这四个特性之间的关系,我画了一张图帮你理一理:

CMake 高级特性知识体系 add_custom_command • 生成文件(OUTPUT 形式) • 附加构建步骤(TARGET 形式) • 支持 POST_BUILD / PRE_BUILD • 自动管理依赖关系 add_custom_target • 定义动作,无输出文件 • 默认不参与构建 • 可加 ALL 关键字 • 常用于测试、打包、文档 生成器表达式 • 延迟求值机制 • 条件编译定义 • 平台/配置判断 • 目标属性查询 compile_commands.json • 编译命令清单 • IDE 代码补全/跳转 • 静态分析工具支持 • 语言服务器协议 配合使用 驱动 输出 核心思路:用自定义命令和目标定义构建逻辑,用生成器表达式处理条件,用 compile_commands.json 对接工具链

这四个特性,每一个单独拿出来都能解决一类问题。但真正厉害的是把它们组合起来用。比如:用生成器表达式控制自定义命令的行为,然后把自定义命令的输出文件通过 compile_commands.json 暴露给 IDE。这样一来,你的构建系统就不再是「死」的,而是活的、智能的。

嗯,今天就先聊到这里。这些特性你用得越多,越能体会到 CMake 设计的精妙之处。


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