13、自定义命令:add_custom_command 与 add_custom_target。如何集成代码生成器、协议缓冲区编译器或其他外部工具。

说实话,CMake 最让我觉得「这工具真能打」的功能,就是自定义命令。你想想看,一个构建系统如果只能编译 .cpp 文件,那也太局限了。实际项目里,我们经常要跑代码生成器、编译 protobuf、调用脚本生成头文件……这些活儿,CMake 都能管起来。

我最早接触这个功能,是因为一个项目里要用 protobuf。那时候我还傻乎乎地手动跑 protoc,然后手动把生成的 .pb.h 和 .pb.cc 拷到工程里。结果有一次忘了重新生成,调试了一整天,最后发现是 proto 文件和生成的文件对不上。嗯,从那以后,我就老老实实用 CMake 来自动化这件事了。

13.1 两个核心命令:add_custom_command 和 add_custom_target

这两个命令长得像,但用途完全不同。我习惯这么记:

  • add_custom_command:用来生成某个文件。它告诉 CMake「这个文件是怎么来的」。
  • add_custom_target:用来执行某个动作。它没有输出文件,只是跑一个命令。

说白了,前者是「生产文件」,后者是「执行任务」。你可以在一个 target 里同时用它们。

add_custom_command 的两种形式

第一种是「文件生成器」形式。你告诉 CMake:这个 .cpp 文件是由某个命令生成的。CMake 会帮你管理依赖——如果输入文件变了,它会自动重新生成。

add_custom_command(
    OUTPUT generated.cpp
    COMMAND python3 ${CMAKE_SOURCE_DIR}/scripts/codegen.py
        --input ${CMAKE_SOURCE_DIR}/schema/input.txt
        --output generated.cpp
    DEPENDS ${CMAKE_SOURCE_DIR}/schema/input.txt
    COMMENT "Generating generated.cpp from input.txt"
)

第二种是「事件钩子」形式。它不生成文件,而是在某个 target 构建之前或之后执行。比如你想在链接之前跑个脚本检查符号表:

add_custom_command(
    TARGET myapp
    PRE_LINK
    COMMAND ${CMAKE_COMMAND} -E echo "Linking myapp..."
)

我个人很少用 PRE_LINK 和 POST_LINK,因为大多数场景用文件生成器形式更清晰。但如果你需要做「构建后清理」或者「构建前检查」,这两个钩子还是很有用的。

add_custom_target 的用法

add_custom_target 不产生文件,它只是定义一个「目标」。你可以用它来跑测试、打包、部署等操作。

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

注意,add_custom_target 默认总是过时的(always out of date)。也就是说,每次你 make run_tests,它都会执行。这跟 add_custom_command 不一样——后者只有在输入文件变化时才重新执行。

关键区别:add_custom_command 是「按需执行」,add_custom_target 是「每次执行」。如果你想让自定义命令也每次都跑,可以把它作为 add_custom_target 的依赖。

13.2 集成 protobuf 编译器:一个完整例子

我们来看一个实际项目里最常见的场景:用 protobuf。假设你有一个 proto 文件,需要生成 .pb.h 和 .pb.cc,然后编译进你的库。

# 找到 protobuf 包
find_package(Protobuf REQUIRED)

# 定义 proto 文件
set(PROTO_FILE ${CMAKE_SOURCE_DIR}/proto/message.proto)

# 生成 .pb.h 和 .pb.cc
add_custom_command(
    OUTPUT ${CMAKE_BINARY_DIR}/proto/message.pb.h
           ${CMAKE_BINARY_DIR}/proto/message.pb.cc
    COMMAND ${Protobuf_PROTOC_EXECUTABLE}
        --proto_path ${CMAKE_SOURCE_DIR}/proto
        --cpp_out ${CMAKE_BINARY_DIR}/proto
        ${PROTO_FILE}
    DEPENDS ${PROTO_FILE}
    COMMENT "Compiling protobuf: message.proto"
)

# 创建目标库
add_library(my_proto
    ${CMAKE_BINARY_DIR}/proto/message.pb.cc
    ${CMAKE_BINARY_DIR}/proto/message.pb.h
)

# 添加头文件路径
target_include_directories(my_proto
    PUBLIC ${CMAKE_BINARY_DIR}/proto
)

# 链接 protobuf 库
target_link_libraries(my_proto
    PUBLIC ${Protobuf_LIBRARIES}
)

这里有个坑:add_custom_command 生成的 .pb.h 和 .pb.cc 必须显式列在 add_library 的源文件列表里。CMake 不会自动发现它们。我曾经因为这个漏掉了一个文件,编译时提示找不到头文件,排查了半天才发现是源文件列表里没写全。

注意:add_custom_command 生成的输出文件,必须被某个 target 引用,否则 CMake 不会执行这个命令。如果你只是定义了命令但没有 target 使用它,那这个命令永远不会被触发。

13.3 集成代码生成器:更通用的模式

除了 protobuf,你还可以集成任何外部工具。比如我有个项目需要从 JSON 配置文件生成 C++ 结构体定义。我写了一个 Python 脚本,然后用 add_custom_command 来驱动它。

# 定义输入输出
set(CONFIG_JSON ${CMAKE_SOURCE_DIR}/config/app_config.json)
set(GENERATED_HPP ${CMAKE_BINARY_DIR}/generated/app_config.hpp)

# 生成命令
add_custom_command(
    OUTPUT ${GENERATED_HPP}
    COMMAND python3 ${CMAKE_SOURCE_DIR}/tools/json_to_cpp.py
        --input ${CONFIG_JSON}
        --output ${GENERATED_HPP}
        --namespace app
    DEPENDS ${CONFIG_JSON}
            ${CMAKE_SOURCE_DIR}/tools/json_to_cpp.py
    COMMENT "Generating app_config.hpp from JSON"
)

# 在库中使用生成的文件
add_library(config_reader
    ${GENERATED_HPP}
    src/config_reader.cpp
)

target_include_directories(config_reader
    PUBLIC ${CMAKE_BINARY_DIR}/generated
)

注意我把 Python 脚本本身也加到了 DEPENDS 里。这样如果脚本更新了,CMake 也会重新生成。这个细节很容易被忽略,但实际项目中脚本改动的频率并不低。

13.4 用 add_custom_target 做「一次性任务」

有时候你不需要生成文件,只是想跑个命令。比如清理临时文件、打包发布包、或者运行一个外部工具。

add_custom_target(clean_generated
    COMMAND ${CMAKE_COMMAND} -E remove_directory
        ${CMAKE_BINARY_DIR}/generated
    COMMENT "Cleaning all generated files..."
)

add_custom_target(package_release
    COMMAND ${CMAKE_COMMAND} --build . --target install
    COMMAND tar -czf release.tar.gz ${CMAKE_INSTALL_PREFIX}
    WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
    COMMENT "Creating release package..."
)

这里有个小技巧:用 ${CMAKE_COMMAND} -E 来执行跨平台的文件操作。比如 -E remove_directory 在 Linux 和 Windows 上都能工作,比直接写 rm -rf 或 rmdir 要安全得多。

13.5 依赖管理:别让 CMake 做无用功

add_custom_command 的 DEPENDS 参数非常关键。你告诉 CMake「这个输出文件依赖于这些输入文件」,CMake 就会自动跟踪文件的时间戳。如果输入没变,它就不会重新执行命令。

我见过不少项目把 DEPENDS 写得很随意,甚至不写。结果每次构建都重新生成,白白浪费时间。尤其是 protobuf 这种编译起来还挺慢的工具,不写 DEPENDS 简直是灾难。

建议:把生成脚本本身也加入 DEPENDS。这样你修改脚本后,CMake 会自动重新生成所有输出。否则你改了脚本,但 CMake 以为输出是最新的,就会跳过执行。

13.6 知识体系图

下面这张图展示了 add_custom_command 和 add_custom_target 在整个构建流程中的位置,以及它们如何与外部工具交互。

自定义命令与外部工具集成流程 输入文件 (.proto, .json, .txt) 外部工具 protoc / python / 脚本 输出文件 (.pb.h, .pb.cc, .hpp) add_custom_command add_library / add_executable add_custom_target 也可作为依赖 核心逻辑 输入变化 → 自动重新生成 输出被 target 引用 → 才执行 DEPENDS 管理依赖关系

13.7 避坑指南

最后,我总结几个实际项目中容易踩的坑:

  • 输出文件路径必须写绝对路径:add_custom_command 的 OUTPUT 最好用 ${CMAKE_BINARY_DIR}/xxx,不要用相对路径。CMake 对相对路径的处理在不同生成器下行为不一致。
  • DEPENDS 不要漏掉脚本本身:如果你改了生成脚本,但 DEPENDS 里没写,CMake 不会重新生成。我吃过这个亏,后来养成了习惯:所有参与生成的文件都加到 DEPENDS 里。
  • COMMAND 里的路径也要用绝对路径:尤其是外部工具的路径,用 ${Protobuf_PROTOC_EXECUTABLE} 而不是写死路径。这样换机器也能用。
  • add_custom_target 的 ALL 参数要慎用:如果你写了 add_custom_target(xxx ALL ...),那每次构建都会执行这个 target。除非你真的需要,否则别加 ALL。

小技巧:用 ${CMAKE_COMMAND} -E 来做跨平台的文件操作。比如复制、删除、创建目录等。这样你的 CMakeLists.txt 在 Linux、macOS、Windows 上都能跑。

好了,关于自定义命令和外部工具集成,就聊这么多。记住核心思路:用 add_custom_command 生成文件,用 add_custom_target 执行任务,用 DEPENDS 管理依赖。把这三点用好,你的构建系统就能处理各种奇奇怪怪的外部工具了。

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