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

说实话,CMake 的构建系统虽然强大,但总有一些「标准流程搞不定」的活儿。比如:编译前自动生成一堆配置文件、编译后把产物打包成安装包、或者调用一个外部脚本做代码检查。这时候,add_custom_commandadd_custom_target 就是你的救星。

我个人习惯把这两个命令看作是 CMake 的「瑞士军刀」。它们能让你在构建流程中插入任意操作——只要你的操作系统能执行,CMake 就能帮你调度。

17.1 核心概念:命令 vs 目标

先搞清楚这两个东西的区别,不然容易用混。

特性 add_custom_command add_custom_target
本质 附加到已有目标上的「钩子」 一个独立的新目标
触发方式 依赖的目标被构建时自动执行 显式指定构建它(如 make my_target
输出文件 通常有输出文件(OUTPUT 参数) 可以没有输出文件(ALL 参数控制是否默认构建)
典型用途 生成源文件、构建后处理 运行测试、打包、代码检查

嗯,这里要注意:add_custom_command 有两种形式——一种是附加到目标(TARGET),另一种是生成文件(OUTPUT)。前者在目标构建前后执行,后者在某个文件需要更新时执行。别搞混了。

17.2 生成源文件:add_custom_command 的 OUTPUT 形式

我在项目中遇到过最典型的场景:用 protobuf 编译 .proto 文件。你不想每次手动执行 protoc,而是希望 CMake 自动检测 .proto 文件变化并重新生成 .pb.h 和 .pb.cc。

代码长这样:

set(PROTO_FILE "${CMAKE_CURRENT_SOURCE_DIR}/message.proto")
set(GENERATED_SRC "${CMAKE_CURRENT_BINARY_DIR}/message.pb.cc")
set(GENERATED_HDR "${CMAKE_CURRENT_BINARY_DIR}/message.pb.h")

add_custom_command(
    OUTPUT ${GENERATED_SRC} ${GENERATED_HDR}
    COMMAND protoc --cpp_out=${CMAKE_CURRENT_BINARY_DIR}
                   -I${CMAKE_CURRENT_SOURCE_DIR}
                   ${PROTO_FILE}
    DEPENDS ${PROTO_FILE}
    COMMENT "Generating protobuf source files..."
)

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

这里的关键是 OUTPUTDEPENDS。CMake 会检查 OUTPUT 文件是否存在,以及 DEPENDS 是否比 OUTPUT 更新。如果 message.proto 变了,CMake 会自动重新运行 protoc。说白了,这就是 CMake 版的「增量构建」。

小技巧: 我建议把生成的文件放在 CMAKE_CURRENT_BINARY_DIR 而不是源目录。这样 make clean 能清理干净,也不会污染源码树。

17.3 构建后操作:add_custom_command 的 TARGET 形式

构建完成后想干点啥?比如把可执行文件复制到某个目录、或者运行一个测试脚本。用 PRE_BUILDPRE_LINKPOST_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 executable to deploy directory..."
)

注意这里的 $<TARGET_FILE:my_app> 是生成器表达式,它会在构建时被替换成实际的目标文件路径。我个人很喜欢用生成器表达式,它比硬编码路径灵活得多。

我曾经踩过一个坑:PRE_BUILD 在 Visual Studio 生成器下行为有点奇怪——它实际上是在「依赖检查之前」执行,而不是在「编译之前」。如果你需要严格在编译前执行某些操作,建议用 PRE_LINK 或者单独写一个自定义目标。

17.4 独立目标:add_custom_target

有时候你不需要生成任何文件,只是想定义一个「动作」。比如运行单元测试、打包、或者代码格式化。

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

add_custom_target(
    format_code
    COMMAND clang-format -i ${CMAKE_SOURCE_DIR}/src/*.cpp
    COMMENT "Formatting source code with clang-format..."
)

默认情况下,自定义目标不会自动构建。你需要显式执行 make run_testsninja format_code。如果你希望它每次构建都运行,可以加上 ALL 关键字:

add_custom_target(
    check ALL
    COMMAND ${CMAKE_COMMAND} -E echo "Build completed!"
)

嗯,但我不建议滥用 ALL。想想看,如果每个自定义目标都默认构建,你的构建时间会越来越长。只有那些真正「每次构建都必须做」的操作才加 ALL

17.5 依赖链:让自定义目标与普通目标协作

自定义目标可以依赖普通目标,普通目标也可以依赖自定义目标。用 add_dependencies 来建立关系。

add_custom_target(generate_config
    COMMAND python3 ${CMAKE_SOURCE_DIR}/tools/gen_config.py
    COMMENT "Generating configuration header..."
)

add_executable(my_app main.cpp)
add_dependencies(my_app generate_config)

这样,构建 my_app 之前一定会先执行 generate_config 目标。我在实际项目中经常用这个模式:先运行代码生成器,再编译生成的代码。

注意: add_dependencies 只在同一个 CMakeLists.txt 或通过 add_subdirectory 引入的目录中有效。跨目录的依赖需要确保目标名称全局唯一。

17.6 实用技巧与避坑指南

  • 使用 CMake 内置命令: ${CMAKE_COMMAND} -E 提供了很多跨平台工具,比如 copyremoveechotar。尽量用它们而不是直接调用系统命令,这样你的 CMakeLists.txt 在 Windows 和 Linux 上都能跑。
  • 生成器表达式是你的朋友:COMMAND 中使用 $<...> 可以获取目标路径、编译选项等。比如 $<TARGET_FILE_DIR:my_app> 获取目标所在目录。
  • 避免硬编码路径:${CMAKE_CURRENT_SOURCE_DIR}${CMAKE_CURRENT_BINARY_DIR} 代替绝对路径。
  • 调试技巧: 如果自定义命令不执行,先检查 OUTPUT 文件是否存在。CMake 认为输出文件已存在且依赖没变,就不会重新执行。

我曾经犯过一个低级错误:在 add_custom_commandCOMMAND 里写了一个带空格的路径,没有用引号括起来。结果在 Windows 上路径被拆成了两个参数,命令直接失败。记住:COMMAND 里的参数要像在 shell 中一样处理,空格和特殊字符要小心。

17.7 知识体系总览

下面这张图帮你理清本章的核心逻辑:

自定义命令与目标知识体系 CMake 自定义构建 add_custom_command add_custom_target OUTPUT 形式 TARGET 形式 独立目标 依赖管理 生成源文件 代码生成器 构建后复制 构建后测试 运行测试 打包发布 代码格式化 核心原则:增量构建、跨平台、避免硬编码

这张图把本章的知识点串起来了。左边是 add_custom_command 的两种形式,右边是 add_custom_target 的典型用途。底部那条「核心原则」是我多年实践总结出来的——你写自定义命令时,时刻想着这三点,基本不会出大错。

本章要点:
  • add_custom_command 用于「生成文件」或「附加到目标」,适合自动化构建步骤。
  • add_custom_target 用于「定义独立动作」,适合测试、打包、代码检查。
  • OUTPUTDEPENDS 实现增量构建,避免重复劳动。
  • 生成器表达式 $<...> 让路径和配置更灵活。
  • 优先使用 CMake 内置命令,保证跨平台兼容性。

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