12、自定义命令与目标:add_custom_command与add_custom_target

说实话,CMake 最让我着迷的地方,就是它不只是个「构建系统生成器」。你完全可以把它当成一个轻量级的自动化脚本框架来用。我早期做嵌入式项目时,经常需要在编译前生成一些配置文件,或者在链接后自动运行测试脚本。那时候我还在用 shell 脚本硬搞,后来发现 CMake 自带的 add_custom_commandadd_custom_target 就能完美解决这些问题。

这一章,我们就来聊聊这两个命令。说白了,它们就是 CMake 里的「瑞士军刀」—— 让你在构建流程的任意阶段插入自定义操作。

12.1 add_custom_command:在构建流程中「插一脚」

add_custom_command 有两种使用场景:一种是给某个目标(比如可执行文件或库)添加额外的构建步骤;另一种是纯粹为了生成某个文件。我个人习惯把前者叫做「目标附加命令」,后者叫做「文件生成命令」。

12.1.1 目标附加命令

这种用法最常见。比如你编译完一个程序后,想自动把生成的二进制文件复制到某个目录:

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}/deploy/
    COMMENT "Copying my_app to deploy directory"
)

这里 POST_BUILD 表示在目标构建完成后执行。还有 PRE_BUILDPRE_LINK,分别对应构建前和链接前。我在项目中遇到过一个问题:PRE_BUILD 在 Visual Studio 生成器下行为有点特殊,它只在目标被标记为「需要重新构建」时才触发。所以如果你想让命令每次都跑,建议用 PRE_LINK 或者干脆用 add_custom_target

注意: PRE_BUILD 在 Visual Studio 和 Xcode 生成器下的行为不一致。跨平台项目请谨慎使用,优先考虑 PRE_LINKPOST_BUILD

12.1.2 文件生成命令

这种用法更灵活。你告诉 CMake:「嘿,我需要这个文件,你帮我生成它。」CMake 会检查文件是否存在,如果不存在或者依赖文件有更新,就执行你指定的命令。

add_custom_command(
    OUTPUT generated_config.h
    COMMAND ${CMAKE_COMMAND} -E echo "#define VERSION 1.0" > generated_config.h
    DEPENDS config_template.txt
    COMMENT "Generating config header"
)

add_executable(my_app main.cpp generated_config.h)

嗯,这里要注意:generated_config.h 必须出现在某个目标的源文件列表中,或者被另一个 add_custom_commandDEPENDS 引用,否则 CMake 不会自动执行这个命令。我曾经踩过这个坑,折腾了半天才发现生成的代码根本没跑。

12.2 add_custom_target:没有「实体」的目标

有时候你并不想生成某个文件,只是想执行一段脚本。比如运行测试、清理临时文件、或者调用外部工具。这时候 add_custom_target 就派上用场了。

它创建的是一个「虚拟目标」—— 没有输出文件,也不会被默认构建。你需要显式调用它:

add_custom_target(
    run_tests
    COMMAND ${CMAKE_CTEST_COMMAND} --output-on-failure
    WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
    COMMENT "Running all tests"
)

然后你可以用 cmake --build . --target run_tests 来触发它。你想想看,这比写一堆 shell 脚本优雅多了吧?

小技巧: 如果你想让自定义目标默认参与构建,可以加上 ALL 关键字:add_custom_target(run_tests ALL ...)。这样每次构建都会执行它。

12.3 在构建过程中执行脚本

CMake 提供了 ${CMAKE_COMMAND} -E 来执行一些常用操作,比如复制文件、创建目录、删除文件等。但如果你需要更复杂的逻辑,比如调用 Python 脚本或者运行外部工具,直接在 COMMAND 里写就行。

add_custom_command(
    TARGET my_app POST_BUILD
    COMMAND python3 ${CMAKE_SOURCE_DIR}/scripts/post_process.py $<TARGET_FILE:my_app>
    COMMENT "Post-processing binary"
)

我个人习惯把脚本放在项目根目录下的 scripts/ 文件夹里,然后用 ${CMAKE_SOURCE_DIR} 引用。这样项目结构清晰,别人一看就知道哪些是构建脚本。

这里有个坑:不同操作系统对脚本的解释器路径要求不一样。Windows 上可能没有 python3,而是 python 或者需要完整路径。我建议用 find_program 先找到解释器:

find_program(PYTHON3 python3)
if(NOT PYTHON3)
    find_program(PYTHON3 python)
endif()

add_custom_command(
    TARGET my_app POST_BUILD
    COMMAND ${PYTHON3} ${CMAKE_SOURCE_DIR}/scripts/post_process.py $<TARGET_FILE:my_app>
)

12.4 生成源代码:让构建自动化起来

这是我最喜欢的功能之一。很多项目需要在构建时动态生成代码,比如根据配置文件生成 C++ 头文件、从接口定义生成桩代码、或者把版本信息注入到源码中。

来看一个实际例子:自动生成版本头文件。

set(VERSION_MAJOR 1)
set(VERSION_MINOR 0)
set(VERSION_PATCH ${CMAKE_SOURCE_DIR}/version.txt)

add_custom_command(
    OUTPUT ${CMAKE_BINARY_DIR}/version.h
    COMMAND ${CMAKE_COMMAND} -E echo "#define VERSION_MAJOR ${VERSION_MAJOR}" > ${CMAKE_BINARY_DIR}/version.h
    COMMAND ${CMAKE_COMMAND} -E echo "#define VERSION_MINOR ${VERSION_MINOR}" >> ${CMAKE_BINARY_DIR}/version.h
    COMMAND ${CMAKE_COMMAND} -E echo "#define VERSION_PATCH \\\"${VERSION_PATCH}\\\"" >> ${CMAKE_BINARY_DIR}/version.h
    COMMENT "Generating version.h"
)

add_executable(my_app main.cpp ${CMAKE_BINARY_DIR}/version.h)

这样每次构建时,如果 version.txt 变了,CMake 会自动重新生成 version.h,然后重新编译依赖它的源文件。完全自动化,不需要手动修改任何东西。

核心原则: 生成的文件一定要放在 CMAKE_BINARY_DIR(构建目录)下,不要污染源码目录。这是 CMake 的「out-of-source」构建哲学,也是我一直坚持的最佳实践。

12.5 知识体系图

下面这张图帮你理清 add_custom_commandadd_custom_target 的关系,以及它们在构建流程中的位置:

自定义命令与目标知识体系 CMake 自定义构建 add_custom_command add_custom_target TARGET 模式 PRE_BUILD / PRE_LINK / POST_BUILD OUTPUT 模式 生成文件 / 依赖追踪 虚拟目标 无输出文件 ALL 关键字 默认参与构建 典型应用场景 执行脚本 · 生成源代码 · 复制文件 · 运行测试 · 调用外部工具 最佳实践 输出到构建目录 · 使用 find_program 定位解释器 · 跨平台兼容

12.6 避坑指南与个人经验

最后,分享几个我这些年踩过的坑:

  • 路径问题: 所有路径尽量用 ${CMAKE_SOURCE_DIR}${CMAKE_BINARY_DIR} 拼接,不要写相对路径。不同生成器的工作目录不一样,很容易翻车。
  • 命令失败处理: 默认情况下,如果 COMMAND 返回非零退出码,构建会失败。如果你希望忽略错误,可以加上 COMMAND_EXPAND_LISTS 或者用 || true 包裹命令。
  • 生成器表达式:COMMAND 里可以使用生成器表达式,比如 $<TARGET_FILE:my_app>。但要注意,有些生成器表达式只在特定阶段可用,比如 $<CONFIG>POST_BUILD 里是没问题的。
  • 并行构建: 如果你有多个 add_custom_command 生成同一个文件,CMake 会报错。确保每个 OUTPUT 是唯一的。
我的习惯: 每次写自定义命令时,我都会先手动在终端里跑一遍命令,确认它能正常工作。然后再写到 CMakeLists.txt 里。这样可以避免很多「为什么构建失败了」的困惑。

好了,这一章的内容就到这里。自定义命令和目标是你从「普通 CMake 用户」进阶到「CMake 高手」的必经之路。多用几次,你就会发现它的强大之处。


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