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 在整个构建流程中的位置,以及它们如何与外部工具交互。
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 管理依赖。把这三点用好,你的构建系统就能处理各种奇奇怪怪的外部工具了。