自定义命令与目标:add_custom_command()与add_custom_target()
说实话,CMake 里最容易被忽视但又最强大的功能,就是自定义命令和目标。我刚开始用 CMake 时,总觉得它就是个「自动生成 Makefile 的工具」。直到有一次我需要自动生成协议缓冲区代码,才发现原来 CMake 能做的事情远不止编译链接。
今天我们就来聊聊,怎么用 add_custom_command() 和 add_custom_target() 来扩展构建流程。说白了,就是让 CMake 帮你跑任何你想要的命令——生成代码、复制文件、运行脚本,甚至发个通知都行。
add_custom_command():给构建流程加料
这个命令有两种用法,我分别叫它「生成文件型」和「事件触发型」。前者用来生成源文件,后者用来在构建前后执行操作。
生成源文件:让代码自己写自己
我在做网络库时,需要根据一个 IDL 文件生成 C++ 代码。手动跑生成器?太原始了。用 add_custom_command() 可以自动搞定:
add_custom_command(
OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/generated/messages.pb.h
${CMAKE_CURRENT_BINARY_DIR}/generated/messages.pb.cc
COMMAND protoc --cpp_out=${CMAKE_CURRENT_BINARY_DIR}/generated
${CMAKE_CURRENT_SOURCE_DIR}/proto/messages.proto
DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/proto/messages.proto
COMMENT "Generating protobuf source files..."
)
这里的关键是 OUTPUT 和 DEPENDS。CMake 会检查输出文件是否存在,以及依赖是否更新。如果 proto 文件没变,生成步骤就不会重复执行。嗯,这就是增量构建的精髓。
CMAKE_CURRENT_BINARY_DIR 下,别跟源码混在一起。否则 make clean 都清不干净,你想想看多难受。
构建后操作:收尾工作自动化
有时候编译完了,还需要做点别的事。比如把生成的库文件复制到某个目录,或者运行测试。这时候用 PRE_BUILD、PRE_LINK、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 executable to deploy directory..."
)
我曾经在项目里用这个功能,在构建完成后自动把 DLL 复制到测试目录。省去了手动拷贝的麻烦,也避免了「哎呀我忘了复制新版本」的尴尬。
add_custom_target():没有输出的目标
这个命令创建的目标,本身不产生任何文件。它存在的意义就是「执行一组命令」。我常用它来做代码格式化、文档生成、或者运行测试套件。
add_custom_target(
format
COMMAND clang-format -i
${CMAKE_CURRENT_SOURCE_DIR}/src/*.cpp
${CMAKE_CURRENT_SOURCE_DIR}/include/*.h
COMMENT "Formatting source code with clang-format..."
)
运行 make format 就能一键格式化所有代码。团队里统一代码风格,这招特别好使。
make target_name。我刚开始用的时候,加了个自定义目标以为它会自动跑,结果等了半天没反应……
依赖图定制:把碎片拼成蓝图
CMake 的依赖图,说白了就是告诉构建系统「谁先谁后」。自定义命令和目标可以让你自由地编织这张网。
举个例子,假设我们有这样的流程:
- 从 IDL 生成 C++ 代码
- 编译生成的代码
- 运行测试
用 CMake 表达就是:
# 第一步:生成代码
add_custom_command(
OUTPUT generated.cpp
COMMAND python generator.py input.idl generated.cpp
DEPENDS input.idl generator.py
)
# 第二步:编译
add_library(my_lib generated.cpp)
# 第三步:测试目标
add_custom_target(
run_tests
COMMAND ./run_tests.sh
DEPENDS my_lib
)
这里 run_tests 依赖 my_lib,而 my_lib 又依赖 generated.cpp。CMake 会自动推导出完整的依赖链。你只需要 make run_tests,一切自动完成。
实战:一个完整的代码生成示例
让我给你看一个我在实际项目中用过的模式。这个项目需要根据 JSON 配置文件生成 C++ 枚举类:
# 生成枚举头文件
add_custom_command(
OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/generated/enums.h
COMMAND python ${CMAKE_CURRENT_SOURCE_DIR}/tools/gen_enum.py
${CMAKE_CURRENT_SOURCE_DIR}/config/enums.json
${CMAKE_CURRENT_BINARY_DIR}/generated/enums.h
DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/tools/gen_enum.py
${CMAKE_CURRENT_SOURCE_DIR}/config/enums.json
COMMENT "Generating enum header from JSON config..."
)
# 编译使用生成文件的目标
add_library(config_reader
config_reader.cpp
${CMAKE_CURRENT_BINARY_DIR}/generated/enums.h
)
# 确保生成步骤在编译前执行
target_include_directories(config_reader
PRIVATE ${CMAKE_CURRENT_BINARY_DIR}/generated
)
# 一键重新生成所有代码
add_custom_target(
regenerate
COMMAND ${CMAKE_COMMAND} -E remove
${CMAKE_CURRENT_BINARY_DIR}/generated/enums.h
COMMAND ${CMAKE_COMMAND} --build ${CMAKE_BINARY_DIR}
COMMENT "Force regenerating all generated files..."
)
这个模式的好处是:
- 增量构建:只有 JSON 或生成器变了才重新生成
- 可追溯:依赖关系清晰,不会漏掉任何步骤
- 可重复:任何机器上都能复现同样的构建
依赖图可视化
下面这张图展示了我们刚才讨论的依赖关系。你可以看到自定义命令和目标如何融入整个构建流程:
避坑指南
这些年我用自定义命令踩过不少坑,分享几个给你:
- 路径问题:自定义命令的工作目录默认是构建目录。如果你需要引用源文件,一定要用
${CMAKE_CURRENT_SOURCE_DIR}绝对路径。我曾经因为相对路径问题,在 CI 上折腾了一整天。 - 跨平台命令:别直接写
cp、rm这些 shell 命令。用${CMAKE_COMMAND} -E copy和${CMAKE_COMMAND} -E remove,CMake 会帮你处理跨平台差异。 - 依赖声明:生成文件的依赖一定要写全。漏掉一个依赖,就可能出现「改了配置文件但没重新生成代码」的诡异问题。
- 并行构建:自定义命令默认是支持并行的。但如果你的命令有副作用(比如写同一个文件),记得用
JOB_POOLS或者加锁。
好了,关于自定义命令和目标就聊这么多。记住一点:构建流程越复杂,越需要用 CMake 来管理。别想着「手动跑一下就好」,自动化才是工程化的基石。