12、自定义命令与目标:add_custom_command与add_custom_target
说实话,CMake 最让我着迷的地方,就是它不只是个「构建系统生成器」。你完全可以把它当成一个轻量级的自动化脚本框架来用。我早期做嵌入式项目时,经常需要在编译前生成一些配置文件,或者在链接后自动运行测试脚本。那时候我还在用 shell 脚本硬搞,后来发现 CMake 自带的 add_custom_command 和 add_custom_target 就能完美解决这些问题。
这一章,我们就来聊聊这两个命令。说白了,它们就是 CMake 里的「瑞士军刀」—— 让你在构建流程的任意阶段插入自定义操作。
12.1 add_custom_command:在构建流程中「插一脚」
add_custom_command 有两种使用场景:一种是给某个目标(比如可执行文件或库)添加额外的构建步骤;另一种是纯粹为了生成某个文件。我个人习惯把前者叫做「目标附加命令」,后者叫做「文件生成命令」。
12.1.1 目标附加命令
这种用法最常见。比如你编译完一个程序后,想自动把生成的二进制文件复制到某个目录:
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 my_app to deploy directory"
)
这里 POST_BUILD 表示在目标构建完成后执行。还有 PRE_BUILD 和 PRE_LINK,分别对应构建前和链接前。我在项目中遇到过一个问题:PRE_BUILD 在 Visual Studio 生成器下行为有点特殊,它只在目标被标记为「需要重新构建」时才触发。所以如果你想让命令每次都跑,建议用 PRE_LINK 或者干脆用 add_custom_target。
PRE_BUILD 在 Visual Studio 和 Xcode 生成器下的行为不一致。跨平台项目请谨慎使用,优先考虑 PRE_LINK 或 POST_BUILD。
12.1.2 文件生成命令
这种用法更灵活。你告诉 CMake:「嘿,我需要这个文件,你帮我生成它。」CMake 会检查文件是否存在,如果不存在或者依赖文件有更新,就执行你指定的命令。
add_custom_command(
OUTPUT generated_config.h
COMMAND ${CMAKE_COMMAND} -E echo "#define VERSION 1.0" > generated_config.h
DEPENDS config_template.txt
COMMENT "Generating config header"
)
add_executable(my_app main.cpp generated_config.h)
嗯,这里要注意:generated_config.h 必须出现在某个目标的源文件列表中,或者被另一个 add_custom_command 的 DEPENDS 引用,否则 CMake 不会自动执行这个命令。我曾经踩过这个坑,折腾了半天才发现生成的代码根本没跑。
12.2 add_custom_target:没有「实体」的目标
有时候你并不想生成某个文件,只是想执行一段脚本。比如运行测试、清理临时文件、或者调用外部工具。这时候 add_custom_target 就派上用场了。
它创建的是一个「虚拟目标」—— 没有输出文件,也不会被默认构建。你需要显式调用它:
add_custom_target(
run_tests
COMMAND ${CMAKE_CTEST_COMMAND} --output-on-failure
WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
COMMENT "Running all tests"
)
然后你可以用 cmake --build . --target run_tests 来触发它。你想想看,这比写一堆 shell 脚本优雅多了吧?
ALL 关键字:add_custom_target(run_tests ALL ...)。这样每次构建都会执行它。
12.3 在构建过程中执行脚本
CMake 提供了 ${CMAKE_COMMAND} -E 来执行一些常用操作,比如复制文件、创建目录、删除文件等。但如果你需要更复杂的逻辑,比如调用 Python 脚本或者运行外部工具,直接在 COMMAND 里写就行。
add_custom_command(
TARGET my_app POST_BUILD
COMMAND python3 ${CMAKE_SOURCE_DIR}/scripts/post_process.py $<TARGET_FILE:my_app>
COMMENT "Post-processing binary"
)
我个人习惯把脚本放在项目根目录下的 scripts/ 文件夹里,然后用 ${CMAKE_SOURCE_DIR} 引用。这样项目结构清晰,别人一看就知道哪些是构建脚本。
这里有个坑:不同操作系统对脚本的解释器路径要求不一样。Windows 上可能没有 python3,而是 python 或者需要完整路径。我建议用 find_program 先找到解释器:
find_program(PYTHON3 python3)
if(NOT PYTHON3)
find_program(PYTHON3 python)
endif()
add_custom_command(
TARGET my_app POST_BUILD
COMMAND ${PYTHON3} ${CMAKE_SOURCE_DIR}/scripts/post_process.py $<TARGET_FILE:my_app>
)
12.4 生成源代码:让构建自动化起来
这是我最喜欢的功能之一。很多项目需要在构建时动态生成代码,比如根据配置文件生成 C++ 头文件、从接口定义生成桩代码、或者把版本信息注入到源码中。
来看一个实际例子:自动生成版本头文件。
set(VERSION_MAJOR 1)
set(VERSION_MINOR 0)
set(VERSION_PATCH ${CMAKE_SOURCE_DIR}/version.txt)
add_custom_command(
OUTPUT ${CMAKE_BINARY_DIR}/version.h
COMMAND ${CMAKE_COMMAND} -E echo "#define VERSION_MAJOR ${VERSION_MAJOR}" > ${CMAKE_BINARY_DIR}/version.h
COMMAND ${CMAKE_COMMAND} -E echo "#define VERSION_MINOR ${VERSION_MINOR}" >> ${CMAKE_BINARY_DIR}/version.h
COMMAND ${CMAKE_COMMAND} -E echo "#define VERSION_PATCH \\\"${VERSION_PATCH}\\\"" >> ${CMAKE_BINARY_DIR}/version.h
COMMENT "Generating version.h"
)
add_executable(my_app main.cpp ${CMAKE_BINARY_DIR}/version.h)
这样每次构建时,如果 version.txt 变了,CMake 会自动重新生成 version.h,然后重新编译依赖它的源文件。完全自动化,不需要手动修改任何东西。
CMAKE_BINARY_DIR(构建目录)下,不要污染源码目录。这是 CMake 的「out-of-source」构建哲学,也是我一直坚持的最佳实践。
12.5 知识体系图
下面这张图帮你理清 add_custom_command 和 add_custom_target 的关系,以及它们在构建流程中的位置:
12.6 避坑指南与个人经验
最后,分享几个我这些年踩过的坑:
- 路径问题: 所有路径尽量用
${CMAKE_SOURCE_DIR}或${CMAKE_BINARY_DIR}拼接,不要写相对路径。不同生成器的工作目录不一样,很容易翻车。 - 命令失败处理: 默认情况下,如果
COMMAND返回非零退出码,构建会失败。如果你希望忽略错误,可以加上COMMAND_EXPAND_LISTS或者用|| true包裹命令。 - 生成器表达式: 在
COMMAND里可以使用生成器表达式,比如$<TARGET_FILE:my_app>。但要注意,有些生成器表达式只在特定阶段可用,比如$<CONFIG>在POST_BUILD里是没问题的。 - 并行构建: 如果你有多个
add_custom_command生成同一个文件,CMake 会报错。确保每个OUTPUT是唯一的。
好了,这一章的内容就到这里。自定义命令和目标是你从「普通 CMake 用户」进阶到「CMake 高手」的必经之路。多用几次,你就会发现它的强大之处。
公众号:蓝海资料掘金营,微信deep3321