23. 自定义命令与目标:add_custom_command、add_custom_target、生成文件、构建后操作

说实话,CMake 里最让我觉得「这才是构建系统该有的样子」的功能,就是自定义命令和目标。为什么?因为真实项目里,你不可能永远只编译 C++ 代码。你可能需要生成代码、复制资源、运行脚本、打压缩包……这些事,CMake 都能帮你管起来。

我最早接触这个功能,是在一个嵌入式项目里。我们需要在编译前自动生成一些配置文件,编译后再把固件拷贝到指定目录。当时我还在用 shell 脚本手动搞,后来发现 CMake 的 add_custom_commandadd_custom_target 直接把这些流程嵌进了构建系统。嗯,从那以后,我再也没写过单独的构建脚本。

23.1 add_custom_command:给构建流程加点「私活」

add_custom_command 有两种用法,我分别讲清楚。

23.1.1 生成文件型(OUTPUT)

这种用法,说白了就是「告诉 CMake:我要通过执行某个命令,生成一个文件」。CMake 会帮你做依赖追踪——如果输出文件不存在,或者输入文件变了,命令就会重新执行。

add_custom_command(
    OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/generated_config.h
    COMMAND ${CMAKE_COMMAND} -E echo "// Auto-generated" > ${CMAKE_CURRENT_BINARY_DIR}/generated_config.h
    COMMAND echo "#define VERSION \"1.0.0\"" >> ${CMAKE_CURRENT_BINARY_DIR}/generated_config.h
    DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/version.txt
    COMMENT "Generating config header..."
)

这里有个细节:COMMAND 可以写多条,它们会按顺序执行。我习惯把生成脚本写成一个单独的 Python 或 shell 文件,然后在 COMMAND 里调用它,这样更清晰。

我的习惯:生成的文件一定要放到 CMAKE_CURRENT_BINARY_DIR 里,别放到源码目录。否则 cmake --build . --clean-first 的时候,生成的文件不会被清理,容易出问题。

23.1.2 构建事件型(PRE_BUILD / PRE_LINK / POST_BUILD)

这种用法,是在某个目标构建的前后执行命令。我用的最多的是 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}/deploy/
    COMMENT "Copying my_app to deploy directory..."
)

注意看,这里用了生成器表达式 $<TARGET_FILE:my_app>。它会在构建时展开成目标文件的完整路径。我个人强烈建议:只要涉及目标路径,就用生成器表达式,别手写路径。手写路径在跨平台或者多配置生成器(比如 Visual Studio)下很容易翻车。

我曾经踩过的坑:在 Windows 上用 POST_BUILD 执行 copy 命令,结果路径里有空格,命令直接炸了。后来我改用 ${CMAKE_COMMAND} -E copy,CMake 自己会处理好路径转义。记住:跨平台时,用 ${CMAKE_COMMAND} -E 代替系统原生命令。

23.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。或者,你可以用 add_dependencies 把它挂到其他目标上。

你想想看,为什么叫「自定义目标」?因为它本质上就是一个名字,CMake 不会检查它有没有输出文件,每次构建都会执行它的命令。这和 add_custom_command 的「增量构建」逻辑完全不同。

23.3 生成文件与依赖追踪

这是很多人容易搞混的地方。我画个图帮你理清关系。

自定义命令与目标的依赖关系 version.txt (输入文件) add_custom_command (生成命令) generated_config.h (输出文件) DEPENDS OUTPUT add_custom_target (无输出文件) my_app (可执行目标) add_dependencies target_sources 文件节点 命令节点 目标节点

看到没?add_custom_command 生成的文件,可以被 target_sources 引用,这样 CMake 就知道:编译 my_app 之前,得先生成那个头文件。这就是依赖追踪的完整链条。

23.4 构建后操作:自动化收尾工作

构建完成后,你可能想干点啥?比如把产物打包、上传到服务器、或者发个通知。我一般用 POST_BUILD 事件来做这些。

add_executable(my_app main.cpp)

add_custom_command(
    TARGET my_app
    POST_BUILD
    COMMAND ${CMAKE_COMMAND} -E tar cf ${CMAKE_BINARY_DIR}/my_app.tar $<TARGET_FILE_DIR:my_app>/
    COMMAND ${CMAKE_COMMAND} -E echo "Package created: my_app.tar"
    COMMENT "Packaging my_app..."
)

这里用了 ${CMAKE_COMMAND} -E tar,CMake 自带的打包功能,跨平台没问题。如果你需要更复杂的打包逻辑,可以调用外部的脚本。

核心要点:
  • add_custom_commandOUTPUT 形式用于生成文件,支持增量构建。
  • add_custom_commandTARGET 形式用于在目标构建前后执行操作。
  • add_custom_target 用于执行一组命令,没有输出文件,每次都会执行。
  • 用生成器表达式(如 $<TARGET_FILE>)代替硬编码路径。
  • 跨平台命令用 ${CMAKE_COMMAND} -E 前缀。

23.5 实战:一个完整的代码生成流程

我拿一个真实项目里的例子给你看。我们需要从 YAML 文件生成 C++ 代码,然后编译进可执行文件。

# 第一步:生成代码
add_custom_command(
    OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/generated/config.cpp
           ${CMAKE_CURRENT_BINARY_DIR}/generated/config.h
    COMMAND python3 ${CMAKE_CURRENT_SOURCE_DIR}/tools/codegen.py
        --input ${CMAKE_CURRENT_SOURCE_DIR}/config.yaml
        --output ${CMAKE_CURRENT_BINARY_DIR}/generated/
    DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/config.yaml
            ${CMAKE_CURRENT_SOURCE_DIR}/tools/codegen.py
    COMMENT "Generating config code from YAML..."
)

# 第二步:把生成的文件加入目标
add_library(config STATIC
    ${CMAKE_CURRENT_BINARY_DIR}/generated/config.cpp
    ${CMAKE_CURRENT_BINARY_DIR}/generated/config.h
)

target_include_directories(config PUBLIC
    ${CMAKE_CURRENT_BINARY_DIR}/generated
)

# 第三步:构建后自动清理临时文件
add_custom_command(
    TARGET config
    POST_BUILD
    COMMAND ${CMAKE_COMMAND} -E remove -f
        ${CMAKE_CURRENT_BINARY_DIR}/generated/config.tmp
    COMMENT "Cleaning up temporary files..."
)

这个流程里,codegen.pyconfig.yaml 任何一个变了,CMake 都会重新生成代码。而且生成的文件在构建目录里,不会污染源码树。干净利落。

嗯,说到这,我想起一个细节:DEPENDS 里我加上了 codegen.py 本身。为什么?因为脚本改了,生成逻辑就变了,当然要重新生成。很多人只依赖输入数据文件,忘了依赖脚本本身,结果脚本改了构建还用的旧产物……我吃过这个亏。

避坑指南:如果你在 add_custom_command 里调用了外部脚本,记得把脚本本身也加到 DEPENDS 里。否则脚本改了,CMake 不会重新执行命令。

好了,自定义命令和目标这块,说白了就是让 CMake 帮你管理「编译之外的那些事」。用好它们,你的构建系统会变得非常灵活,而且自动化程度极高。我个人觉得,这是 CMake 里最值得花时间掌握的功能之一。