第39章:自定义命令:add_custom_command和add_custom_target执行任意命令
说实话,CMake 内置的编译、链接流程虽然强大,但总有它管不到的地方。比如你想在编译前自动生成一个版本头文件,或者编译后把产物打包成 tar.gz,又或者调用 Python 脚本做代码检查——这些「杂活」CMake 默认不会帮你干。
这时候就需要 add_custom_command 和 add_custom_target 出场了。它们就像 CMake 里的「瑞士军刀」,让你能在构建流程中插入任意命令。我最早接触它们是为了解决一个跨平台代码生成的问题,当时折腾了好几天才摸清门道。今天咱们就把这两个命令彻底讲透。
39.1 两个命令的分工
先搞清楚它们各自是干什么的,别混了。
| 命令 | 核心作用 | 典型场景 |
|---|---|---|
add_custom_command |
为某个目标或文件附加自定义命令 | 生成源文件、后处理产物 |
add_custom_target |
创建一个没有编译产物的「虚拟目标」 | 运行测试、打包、代码格式化 |
说白了,add_custom_command 是「挂」在某个已有目标上的钩子,而 add_custom_target 是独立存在的任务。我个人的习惯是:如果这个命令跟某个具体目标(比如一个库或可执行文件)强相关,就用 add_custom_command;如果它是个独立操作(比如「打包全部」),就用 add_custom_target。
39.2 add_custom_command 的两种用法
39.2.1 作为目标的 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}/output/
COMMENT "Copying my_app to output directory"
)
这里我用了 ${CMAKE_COMMAND} -E copy,这是 CMake 自带的跨平台文件操作命令。为什么不用 cp?因为 Windows 上没有 cp。用 CMake 的 -E 模式可以保证跨平台兼容,这是我在一个 Windows/Linux 混合团队的项目里学到的教训。
三种触发时机:
- PRE_BUILD:在目标编译之前执行(Visual Studio 生成器下有限制,慎用)
- PRE_LINK:在编译之后、链接之前执行
- POST_BUILD:在链接完成之后执行
嗯,这里要注意:PRE_BUILD 在 Makefile 生成器下其实等同于 PRE_LINK,只有 Visual Studio 生成器才真正区分。所以我一般只用 PRE_LINK 和 POST_BUILD,省得踩坑。
39.2.2 作为文件生成器
这种用法更高级——你告诉 CMake:「这个文件是由某个命令生成的」,CMake 会自动管理它的依赖关系。
# 声明 version.h 是由 generate_version.py 生成的
add_custom_command(OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/version.h
COMMAND python3 ${CMAKE_CURRENT_SOURCE_DIR}/generate_version.py
${CMAKE_CURRENT_BINARY_DIR}/version.h
DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/generate_version.py
COMMENT "Generating version.h"
)
# 然后可以正常引用这个生成的文件
add_executable(my_app main.cpp ${CMAKE_CURRENT_BINARY_DIR}/version.h)
target_include_directories(my_app PRIVATE ${CMAKE_CURRENT_BINARY_DIR})
关键点在于 DEPENDS 参数。它告诉 CMake:如果 generate_version.py 变了,就要重新生成 version.h。我曾经在一个项目里忘了写 DEPENDS,结果每次改脚本都得手动清理构建目录,那叫一个痛苦。
39.3 add_custom_target:创建你的专属任务
有时候你只是想跑个命令,并不需要它产生任何「目标文件」。比如运行所有单元测试、清理临时文件、生成文档。这时候 add_custom_target 就是最佳选择。
# 创建一个名为 "format" 的自定义目标
add_custom_target(format
COMMAND clang-format -i ${CMAKE_SOURCE_DIR}/src/*.cpp
COMMAND clang-format -i ${CMAKE_SOURCE_DIR}/include/*.h
COMMENT "Formatting source code with clang-format"
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
)
# 用法:cmake --build . --target format
注意,自定义目标默认不会被构建。你得显式指定 --target format 才会执行。如果你想让它随某个目标自动执行,可以用 add_dependencies 把它们关联起来。
add_dependencies(my_app format) # 构建 my_app 之前先执行 format
不过我个人不太建议这么干——格式化代码通常是个手动操作,自动执行反而会拖慢编译速度。你想想看,每次改一行代码都要等 clang-format 跑一遍,多烦人。
39.4 核心参数详解
这两个命令共享很多参数,我把最常用的列出来:
| 参数 | 说明 | 示例 |
|---|---|---|
COMMAND |
要执行的命令,可以写多个 | COMMAND python3 script.py |
COMMENT |
执行时显示的消息 | COMMENT "Running code generator..." |
WORKING_DIRECTORY |
命令的工作目录 | WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} |
DEPENDS |
依赖的文件或目标 | DEPENDS script.py config.json |
BYPRODUCTS |
命令产生的副产品(用于 Ninja 生成器) | BYPRODUCTS temp.log |
MAIN_DEPENDENCY |
主要依赖文件(用于文件生成器) | MAIN_DEPENDENCY input.txt |
add_custom_command 中,DEPENDS 和 MAIN_DEPENDENCY 的区别在于:MAIN_DEPENDENCY 指定的文件变化会触发重新生成,但不会影响输出文件的「身份」;而 DEPENDS 中的文件变化同样会触发重新生成。实际使用中,我一般只用 DEPENDS,除非有特殊需求。
39.5 实战:自动生成版本信息
咱们来一个完整的例子。假设你有一个项目,每次构建时都想把 Git 的 commit hash 和构建时间写入头文件。
# CMakeLists.txt
cmake_minimum_required(VERSION 3.16)
project(VersionDemo)
# 自定义命令:生成 version.h
set(VERSION_HEADER ${CMAKE_CURRENT_BINARY_DIR}/version.h)
add_custom_command(OUTPUT ${VERSION_HEADER}
COMMAND ${CMAKE_COMMAND} -E echo_append "// Auto-generated\n" > ${VERSION_HEADER}
COMMAND ${CMAKE_COMMAND} -E echo_append "#define GIT_COMMIT_HASH \"" >> ${VERSION_HEADER}
COMMAND git rev-parse --short HEAD >> ${VERSION_HEADER}
COMMAND ${CMAKE_COMMAND} -E echo_append "\"\n" >> ${VERSION_HEADER}
COMMAND ${CMAKE_COMMAND} -E echo_append "#define BUILD_TIME \"" >> ${VERSION_HEADER}
COMMAND ${CMAKE_COMMAND} -E echo_append "$<ISO_DATE:>" >> ${VERSION_HEADER}
COMMAND ${CMAKE_COMMAND} -E echo_append "\"\n" >> ${VERSION_HEADER}
DEPENDS ${CMAKE_SOURCE_DIR}/.git/HEAD
COMMENT "Generating version.h"
)
add_executable(my_app main.cpp ${VERSION_HEADER})
target_include_directories(my_app PRIVATE ${CMAKE_CURRENT_BINARY_DIR})
这里有个小技巧:$<ISO_DATE:> 是 CMake 的生成器表达式,会在构建时展开为当前日期。而 git rev-parse --short HEAD 则获取当前的短 commit hash。
不过要注意,DEPENDS ${CMAKE_SOURCE_DIR}/.git/HEAD 只能检测到分支切换,如果只是新增提交(HEAD 内容不变),CMake 不会重新生成。更稳妥的做法是用 DEPENDS ${CMAKE_SOURCE_DIR}/.git/index,但这样每次文件变更都会触发重新生成,有点过头。我一般会在需要时手动清理构建目录,或者加一个 add_custom_target 来强制重新生成。
39.6 避坑指南
不要在
COMMAND 里直接写 cp、rm、mkdir 这些系统命令。用 ${CMAKE_COMMAND} -E 代替。CMake 的 -E 模式提供了 copy、remove、make_directory、tar 等常用操作,跨平台无压力。
add_custom_command 中的 COMMAND 支持生成器表达式,但只在构建时求值。如果你在配置阶段就想展开,得用 file() 或 execute_process()。我曾经混淆过这两个阶段,结果变量死活展开不了,排查了半天。
如果自定义命令不按预期执行,可以在
COMMAND 前加 echo 来打印实际执行的命令。或者用 --verbose 参数运行构建:cmake --build . --verbose,这样能看到 CMake 实际调用的命令。
39.7 知识体系图
下面这张图帮你理清 add_custom_command 和 add_custom_target 的关系与使用场景:
39.8 总结
add_custom_command 和 add_custom_target 是 CMake 里最灵活的工具之一。它们让你能跳出「编译-链接」的框框,把任意脚本、工具、操作整合到构建流程中。
记住几个要点:
- 用
add_custom_command(TARGET ...)给现有目标挂载钩子 - 用
add_custom_command(OUTPUT ...)声明生成的文件 - 用
add_custom_target创建独立任务 - 始终用
${CMAKE_COMMAND} -E代替系统命令,保证跨平台 - 别忘了
DEPENDS,否则增量构建会出问题
我在实际项目中,几乎每个 CMakeLists.txt 都会用到至少一个自定义命令。它们就像胶水一样,把各种工具粘合在一起。掌握了这两个命令,你的 CMake 技能才算真正进阶了。
公众号:蓝海资料掘金营,微信deep3321