13、自定义命令与目标:add_custom_command、add_custom_target、生成源文件、构建后处理

说实话,CMake 的构建系统如果只用来管理编译和链接,那它跟普通的 Makefile 也没太大区别。真正让它变得灵活、强大的,是自定义命令和自定义目标这两个机制。我刚开始接触 CMake 时,觉得这俩东西有点绕,后来在项目中用顺手了,才发现它们简直是构建自动化的瑞士军刀。

这一章,我们就来聊聊怎么用 add_custom_commandadd_custom_target 做那些「编译之外」的事情——比如自动生成源文件、构建后复制产物、或者跑个脚本做代码检查。

13.1 为什么需要自定义命令?

你想想看,一个典型的 C++ 项目,除了 .cpp 和 .h 文件,可能还有:

  • 用 protobuf 生成的 .pb.cc 文件
  • 用 Qt 的 uic/rcc 生成的界面文件
  • 构建完成后需要打包成 .zip 或 .tar.gz
  • 甚至是在编译前跑一个 Python 脚本生成版本头文件

这些都不是 CMake 内置支持的编译动作。但我们可以通过自定义命令,把它们「嵌入」到构建流程中,让 CMake 像管理普通源文件一样管理它们。

核心思想: 自定义命令让 CMake 知道「什么时候该做什么事」,并且能正确处理依赖关系。

13.2 add_custom_command:给目标添加额外动作

add_custom_command 有两种常见用法:

  • 生成源文件:在编译某个目标之前,先执行一个命令生成源文件
  • 构建后处理:在目标构建完成后,执行一些收尾工作

13.2.1 生成源文件

这是最常见的场景。比如我们有一个代码生成器 codegen,它根据 schema.txt 生成 generated.cpp

add_custom_command(
    OUTPUT generated.cpp
    COMMAND codegen schema.txt -o generated.cpp
    DEPENDS schema.txt
    COMMENT "Generating source file from schema..."
)

然后把这个 generated.cpp 加到可执行文件里:

add_executable(myapp main.cpp generated.cpp)

这样,CMake 会自动管理依赖:如果 schema.txt 没变,就不会重新运行 codegen。我在项目中遇到过好几次,有人把生成步骤写成一个脚本手动跑,结果经常忘记重新生成,导致编译错误。用自定义命令,这些烦恼就没了。

小提示: OUTPUT 指定的文件路径,最好是相对于当前 CMakeLists.txt 的路径,或者用 ${CMAKE_CURRENT_BINARY_DIR} 明确指定构建目录。

13.2.2 构建后处理

有时候我们希望在目标构建完成后,自动做一些事情,比如把生成的 .dll 复制到某个目录:

add_custom_command(
    TARGET myapp
    POST_BUILD
    COMMAND ${CMAKE_COMMAND} -E copy $<TARGET_FILE:myapp> ${CMAKE_SOURCE_DIR}/deploy/
    COMMENT "Copying executable to deploy directory..."
)

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

注意: PRE_BUILD 在 Visual Studio 生成器下行为有点特殊,它只在目标被重新构建时触发,而不是每次构建都触发。如果你需要「每次构建前都执行」,建议用 add_custom_target 配合依赖。

13.3 add_custom_target:创建独立的自定义目标

add_custom_targetadd_custom_command 的区别在于:自定义目标没有输出文件,它只是一个「名字」,你可以把它当作一个构建任务来执行。

比如我们想定义一个 clean_all 目标,除了清理编译产物,还清理一些临时文件:

add_custom_target(clean_all
    COMMAND ${CMAKE_COMMAND} --build . --target clean
    COMMAND ${CMAKE_COMMAND} -E remove_directory ${CMAKE_BINARY_DIR}/temp
    COMMENT "Cleaning all artifacts..."
)

然后你可以这样运行:

cmake --build . --target clean_all

自定义目标经常和自定义命令配合使用。比如我们想定义一个 docs 目标,用来生成文档:

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

嗯,这里要注意:add_custom_target 默认总是会被执行(除非你用 ALL 关键字让它成为默认构建的一部分)。如果你希望它只在依赖变化时才执行,可以结合 add_custom_commandadd_dependencies

13.4 实战:自动生成版本头文件

让我给你展示一个我实际项目中用过的例子。我们需要在每次构建时,自动生成一个 version.h,里面包含 Git 的 commit hash 和构建时间。

首先,写一个生成命令:

set(VERSION_HEADER ${CMAKE_BINARY_DIR}/generated/version.h)

add_custom_command(
    OUTPUT ${VERSION_HEADER}
    COMMAND ${CMAKE_COMMAND} -E echo "// Auto-generated" > ${VERSION_HEADER}
    COMMAND ${CMAKE_COMMAND} -E echo "#define GIT_COMMIT_HASH \"$(git rev-parse HEAD)\"" >> ${VERSION_HEADER}
    COMMAND ${CMAKE_COMMAND} -E echo "#define BUILD_TIME \"${CMAKE_TIMESTAMP}\"" >> ${VERSION_HEADER}
    COMMENT "Generating version header..."
)

然后创建一个自定义目标,依赖这个头文件:

add_custom_target(generate_version DEPENDS ${VERSION_HEADER})

最后,让我们的主目标依赖这个生成目标:

add_executable(myapp main.cpp)
add_dependencies(myapp generate_version)
target_include_directories(myapp PRIVATE ${CMAKE_BINARY_DIR}/generated)

这样,每次构建时,CMake 都会检查是否需要重新生成 version.h。如果 Git commit 没变,就不会重复生成,节省时间。

避坑指南: 我曾经在 Windows 上用过 $(git rev-parse HEAD) 这种 shell 语法,结果发现 CMake 的 COMMAND 默认不会通过 shell 执行。后来我改用 ${CMAKE_COMMAND} -E env 或者直接调用 git 可执行文件,才搞定。记住:跨平台时,尽量用 CMake 的 -E 命令,或者显式指定 shell。

13.5 知识体系图

下面这张图帮你理清自定义命令和自定义目标的关系:

自定义命令与目标知识体系 add_custom_command 给目标添加额外动作 add_custom_target 创建独立的自定义目标 生成源文件 OUTPUT / DEPENDS 构建后处理 TARGET / POST_BUILD 独立任务 如:clean_all 依赖驱动 add_dependencies 核心原则:让 CMake 管理依赖,而不是手动控制 自定义命令有输出 → 可被依赖追踪 | 自定义目标无输出 → 总是执行

13.6 常见模式与技巧

最后,分享几个我在项目中常用的模式:

场景 推荐做法 说明
代码生成 add_custom_command(OUTPUT ...) 生成的文件加入 add_executableadd_library
构建后复制 add_custom_command(TARGET ... POST_BUILD) ${CMAKE_COMMAND} -E copy 保证跨平台
运行测试 add_custom_target(test_all ...) 结合 ctest 或自定义脚本
生成文档 add_custom_target(docs ...) WORKING_DIRECTORY 指定工作目录
清理临时文件 add_custom_target(clean_extra ...) 依赖 clean 目标,再删额外文件
个人习惯: 我通常把自定义命令和自定义目标放在单独的 cmake/ 目录下,用 include() 引入。这样主 CMakeLists.txt 不会太臃肿,也方便复用。

好了,自定义命令和自定义目标就聊到这里。说白了,它们就是 CMake 留给我们的「后门」,让我们能把任何构建步骤都纳入 CMake 的依赖管理体系中。用好了,你的构建系统会变得非常优雅、自动化。


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