第39章:自定义命令:add_custom_command和add_custom_target执行任意命令

说实话,CMake 内置的编译、链接流程虽然强大,但总有它管不到的地方。比如你想在编译前自动生成一个版本头文件,或者编译后把产物打包成 tar.gz,又或者调用 Python 脚本做代码检查——这些「杂活」CMake 默认不会帮你干。

这时候就需要 add_custom_commandadd_custom_target 出场了。它们就像 CMake 里的「瑞士军刀」,让你能在构建流程中插入任意命令。我最早接触它们是为了解决一个跨平台代码生成的问题,当时折腾了好几天才摸清门道。今天咱们就把这两个命令彻底讲透。

39.1 两个命令的分工

先搞清楚它们各自是干什么的,别混了。

命令 核心作用 典型场景
add_custom_command 为某个目标或文件附加自定义命令 生成源文件、后处理产物
add_custom_target 创建一个没有编译产物的「虚拟目标」 运行测试、打包、代码格式化

说白了,add_custom_command 是「挂」在某个已有目标上的钩子,而 add_custom_target 是独立存在的任务。我个人的习惯是:如果这个命令跟某个具体目标(比如一个库或可执行文件)强相关,就用 add_custom_command;如果它是个独立操作(比如「打包全部」),就用 add_custom_target

39.2 add_custom_command 的两种用法

39.2.1 作为目标的 PRE_BUILD / PRE_LINK / POST_BUILD 事件

这是最常见的用法。你可以在某个目标构建之前、链接之前或构建之后执行命令。

add_executable(my_app main.cpp)

# 编译完成后,把产物复制到输出目录
add_custom_command(TARGET my_app POST_BUILD
    COMMAND ${CMAKE_COMMAND} -E copy
        $<TARGET_FILE:my_app>
        ${CMAKE_SOURCE_DIR}/output/
    COMMENT "Copying my_app to output directory"
)

这里我用了 ${CMAKE_COMMAND} -E copy,这是 CMake 自带的跨平台文件操作命令。为什么不用 cp?因为 Windows 上没有 cp。用 CMake 的 -E 模式可以保证跨平台兼容,这是我在一个 Windows/Linux 混合团队的项目里学到的教训。

三种触发时机:

  • PRE_BUILD:在目标编译之前执行(Visual Studio 生成器下有限制,慎用)
  • PRE_LINK:在编译之后、链接之前执行
  • POST_BUILD:在链接完成之后执行

嗯,这里要注意:PRE_BUILD 在 Makefile 生成器下其实等同于 PRE_LINK,只有 Visual Studio 生成器才真正区分。所以我一般只用 PRE_LINKPOST_BUILD,省得踩坑。

39.2.2 作为文件生成器

这种用法更高级——你告诉 CMake:「这个文件是由某个命令生成的」,CMake 会自动管理它的依赖关系。

# 声明 version.h 是由 generate_version.py 生成的
add_custom_command(OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/version.h
    COMMAND python3 ${CMAKE_CURRENT_SOURCE_DIR}/generate_version.py
        ${CMAKE_CURRENT_BINARY_DIR}/version.h
    DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/generate_version.py
    COMMENT "Generating version.h"
)

# 然后可以正常引用这个生成的文件
add_executable(my_app main.cpp ${CMAKE_CURRENT_BINARY_DIR}/version.h)
target_include_directories(my_app PRIVATE ${CMAKE_CURRENT_BINARY_DIR})

关键点在于 DEPENDS 参数。它告诉 CMake:如果 generate_version.py 变了,就要重新生成 version.h。我曾经在一个项目里忘了写 DEPENDS,结果每次改脚本都得手动清理构建目录,那叫一个痛苦。

39.3 add_custom_target:创建你的专属任务

有时候你只是想跑个命令,并不需要它产生任何「目标文件」。比如运行所有单元测试、清理临时文件、生成文档。这时候 add_custom_target 就是最佳选择。

# 创建一个名为 "format" 的自定义目标
add_custom_target(format
    COMMAND clang-format -i ${CMAKE_SOURCE_DIR}/src/*.cpp
    COMMAND clang-format -i ${CMAKE_SOURCE_DIR}/include/*.h
    COMMENT "Formatting source code with clang-format"
    WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
)

# 用法:cmake --build . --target format

注意,自定义目标默认不会被构建。你得显式指定 --target format 才会执行。如果你想让它随某个目标自动执行,可以用 add_dependencies 把它们关联起来。

add_dependencies(my_app format)  # 构建 my_app 之前先执行 format

不过我个人不太建议这么干——格式化代码通常是个手动操作,自动执行反而会拖慢编译速度。你想想看,每次改一行代码都要等 clang-format 跑一遍,多烦人。

39.4 核心参数详解

这两个命令共享很多参数,我把最常用的列出来:

参数 说明 示例
COMMAND 要执行的命令,可以写多个 COMMAND python3 script.py
COMMENT 执行时显示的消息 COMMENT "Running code generator..."
WORKING_DIRECTORY 命令的工作目录 WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
DEPENDS 依赖的文件或目标 DEPENDS script.py config.json
BYPRODUCTS 命令产生的副产品(用于 Ninja 生成器) BYPRODUCTS temp.log
MAIN_DEPENDENCY 主要依赖文件(用于文件生成器) MAIN_DEPENDENCY input.txt
注意:add_custom_command 中,DEPENDSMAIN_DEPENDENCY 的区别在于:MAIN_DEPENDENCY 指定的文件变化会触发重新生成,但不会影响输出文件的「身份」;而 DEPENDS 中的文件变化同样会触发重新生成。实际使用中,我一般只用 DEPENDS,除非有特殊需求。

39.5 实战:自动生成版本信息

咱们来一个完整的例子。假设你有一个项目,每次构建时都想把 Git 的 commit hash 和构建时间写入头文件。

# CMakeLists.txt
cmake_minimum_required(VERSION 3.16)
project(VersionDemo)

# 自定义命令:生成 version.h
set(VERSION_HEADER ${CMAKE_CURRENT_BINARY_DIR}/version.h)

add_custom_command(OUTPUT ${VERSION_HEADER}
    COMMAND ${CMAKE_COMMAND} -E echo_append "// Auto-generated\n" > ${VERSION_HEADER}
    COMMAND ${CMAKE_COMMAND} -E echo_append "#define GIT_COMMIT_HASH \"" >> ${VERSION_HEADER}
    COMMAND git rev-parse --short HEAD >> ${VERSION_HEADER}
    COMMAND ${CMAKE_COMMAND} -E echo_append "\"\n" >> ${VERSION_HEADER}
    COMMAND ${CMAKE_COMMAND} -E echo_append "#define BUILD_TIME \"" >> ${VERSION_HEADER}
    COMMAND ${CMAKE_COMMAND} -E echo_append "$<ISO_DATE:>" >> ${VERSION_HEADER}
    COMMAND ${CMAKE_COMMAND} -E echo_append "\"\n" >> ${VERSION_HEADER}
    DEPENDS ${CMAKE_SOURCE_DIR}/.git/HEAD
    COMMENT "Generating version.h"
)

add_executable(my_app main.cpp ${VERSION_HEADER})
target_include_directories(my_app PRIVATE ${CMAKE_CURRENT_BINARY_DIR})

这里有个小技巧:$<ISO_DATE:> 是 CMake 的生成器表达式,会在构建时展开为当前日期。而 git rev-parse --short HEAD 则获取当前的短 commit hash。

不过要注意,DEPENDS ${CMAKE_SOURCE_DIR}/.git/HEAD 只能检测到分支切换,如果只是新增提交(HEAD 内容不变),CMake 不会重新生成。更稳妥的做法是用 DEPENDS ${CMAKE_SOURCE_DIR}/.git/index,但这样每次文件变更都会触发重新生成,有点过头。我一般会在需要时手动清理构建目录,或者加一个 add_custom_target 来强制重新生成。

39.6 避坑指南

坑一:跨平台命令差异
不要在 COMMAND 里直接写 cprmmkdir 这些系统命令。用 ${CMAKE_COMMAND} -E 代替。CMake 的 -E 模式提供了 copyremovemake_directorytar 等常用操作,跨平台无压力。
坑二:生成器表达式求值时机
add_custom_command 中的 COMMAND 支持生成器表达式,但只在构建时求值。如果你在配置阶段就想展开,得用 file()execute_process()。我曾经混淆过这两个阶段,结果变量死活展开不了,排查了半天。
小技巧:调试自定义命令
如果自定义命令不按预期执行,可以在 COMMAND 前加 echo 来打印实际执行的命令。或者用 --verbose 参数运行构建:cmake --build . --verbose,这样能看到 CMake 实际调用的命令。

39.7 知识体系图

下面这张图帮你理清 add_custom_commandadd_custom_target 的关系与使用场景:

CMake 自定义命令体系 add_custom_command add_custom_target 目标事件 PRE_BUILD / POST_BUILD 文件生成器 OUTPUT + DEPENDS 独立任务 打包 / 格式化 / 测试 依赖挂载 add_dependencies 共同核心参数 • COMMAND — 要执行的命令(支持多条) • COMMENT — 构建时显示的消息 • WORKING_DIRECTORY — 命令执行目录 • DEPENDS — 依赖的文件或目标 • BYPRODUCTS — 副产品(Ninja 需要) • 生成器表达式支持

39.8 总结

add_custom_commandadd_custom_target 是 CMake 里最灵活的工具之一。它们让你能跳出「编译-链接」的框框,把任意脚本、工具、操作整合到构建流程中。

记住几个要点:

  • add_custom_command(TARGET ...) 给现有目标挂载钩子
  • add_custom_command(OUTPUT ...) 声明生成的文件
  • add_custom_target 创建独立任务
  • 始终用 ${CMAKE_COMMAND} -E 代替系统命令,保证跨平台
  • 别忘了 DEPENDS,否则增量构建会出问题

我在实际项目中,几乎每个 CMakeLists.txt 都会用到至少一个自定义命令。它们就像胶水一样,把各种工具粘合在一起。掌握了这两个命令,你的 CMake 技能才算真正进阶了。


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