13、自定义命令与目标:add_custom_command、add_custom_target、生成源文件、构建后处理
说实话,CMake 的构建系统如果只用来管理编译和链接,那它跟普通的 Makefile 也没太大区别。真正让它变得灵活、强大的,是自定义命令和自定义目标这两个机制。我刚开始接触 CMake 时,觉得这俩东西有点绕,后来在项目中用顺手了,才发现它们简直是构建自动化的瑞士军刀。
这一章,我们就来聊聊怎么用 add_custom_command 和 add_custom_target 做那些「编译之外」的事情——比如自动生成源文件、构建后复制产物、或者跑个脚本做代码检查。
13.1 为什么需要自定义命令?
你想想看,一个典型的 C++ 项目,除了 .cpp 和 .h 文件,可能还有:
- 用 protobuf 生成的 .pb.cc 文件
- 用 Qt 的 uic/rcc 生成的界面文件
- 构建完成后需要打包成 .zip 或 .tar.gz
- 甚至是在编译前跑一个 Python 脚本生成版本头文件
这些都不是 CMake 内置支持的编译动作。但我们可以通过自定义命令,把它们「嵌入」到构建流程中,让 CMake 像管理普通源文件一样管理它们。
13.2 add_custom_command:给目标添加额外动作
add_custom_command 有两种常见用法:
- 生成源文件:在编译某个目标之前,先执行一个命令生成源文件
- 构建后处理:在目标构建完成后,执行一些收尾工作
13.2.1 生成源文件
这是最常见的场景。比如我们有一个代码生成器 codegen,它根据 schema.txt 生成 generated.cpp:
add_custom_command(
OUTPUT generated.cpp
COMMAND codegen schema.txt -o generated.cpp
DEPENDS schema.txt
COMMENT "Generating source file from schema..."
)
然后把这个 generated.cpp 加到可执行文件里:
add_executable(myapp main.cpp generated.cpp)
这样,CMake 会自动管理依赖:如果 schema.txt 没变,就不会重新运行 codegen。我在项目中遇到过好几次,有人把生成步骤写成一个脚本手动跑,结果经常忘记重新生成,导致编译错误。用自定义命令,这些烦恼就没了。
OUTPUT 指定的文件路径,最好是相对于当前 CMakeLists.txt 的路径,或者用 ${CMAKE_CURRENT_BINARY_DIR} 明确指定构建目录。
13.2.2 构建后处理
有时候我们希望在目标构建完成后,自动做一些事情,比如把生成的 .dll 复制到某个目录:
add_custom_command(
TARGET myapp
POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy $<TARGET_FILE:myapp> ${CMAKE_SOURCE_DIR}/deploy/
COMMENT "Copying executable to deploy directory..."
)
这里 POST_BUILD 表示在目标构建完成后执行。还有 PRE_BUILD 和 PRE_LINK,不过我个人用得最多的是 POST_BUILD。
PRE_BUILD 在 Visual Studio 生成器下行为有点特殊,它只在目标被重新构建时触发,而不是每次构建都触发。如果你需要「每次构建前都执行」,建议用 add_custom_target 配合依赖。
13.3 add_custom_target:创建独立的自定义目标
add_custom_target 和 add_custom_command 的区别在于:自定义目标没有输出文件,它只是一个「名字」,你可以把它当作一个构建任务来执行。
比如我们想定义一个 clean_all 目标,除了清理编译产物,还清理一些临时文件:
add_custom_target(clean_all
COMMAND ${CMAKE_COMMAND} --build . --target clean
COMMAND ${CMAKE_COMMAND} -E remove_directory ${CMAKE_BINARY_DIR}/temp
COMMENT "Cleaning all artifacts..."
)
然后你可以这样运行:
cmake --build . --target clean_all
自定义目标经常和自定义命令配合使用。比如我们想定义一个 docs 目标,用来生成文档:
add_custom_target(docs
COMMAND doxygen Doxyfile
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
COMMENT "Generating documentation..."
)
嗯,这里要注意:add_custom_target 默认总是会被执行(除非你用 ALL 关键字让它成为默认构建的一部分)。如果你希望它只在依赖变化时才执行,可以结合 add_custom_command 和 add_dependencies。
13.4 实战:自动生成版本头文件
让我给你展示一个我实际项目中用过的例子。我们需要在每次构建时,自动生成一个 version.h,里面包含 Git 的 commit hash 和构建时间。
首先,写一个生成命令:
set(VERSION_HEADER ${CMAKE_BINARY_DIR}/generated/version.h)
add_custom_command(
OUTPUT ${VERSION_HEADER}
COMMAND ${CMAKE_COMMAND} -E echo "// Auto-generated" > ${VERSION_HEADER}
COMMAND ${CMAKE_COMMAND} -E echo "#define GIT_COMMIT_HASH \"$(git rev-parse HEAD)\"" >> ${VERSION_HEADER}
COMMAND ${CMAKE_COMMAND} -E echo "#define BUILD_TIME \"${CMAKE_TIMESTAMP}\"" >> ${VERSION_HEADER}
COMMENT "Generating version header..."
)
然后创建一个自定义目标,依赖这个头文件:
add_custom_target(generate_version DEPENDS ${VERSION_HEADER})
最后,让我们的主目标依赖这个生成目标:
add_executable(myapp main.cpp)
add_dependencies(myapp generate_version)
target_include_directories(myapp PRIVATE ${CMAKE_BINARY_DIR}/generated)
这样,每次构建时,CMake 都会检查是否需要重新生成 version.h。如果 Git commit 没变,就不会重复生成,节省时间。
$(git rev-parse HEAD) 这种 shell 语法,结果发现 CMake 的 COMMAND 默认不会通过 shell 执行。后来我改用 ${CMAKE_COMMAND} -E env 或者直接调用 git 可执行文件,才搞定。记住:跨平台时,尽量用 CMake 的 -E 命令,或者显式指定 shell。
13.5 知识体系图
下面这张图帮你理清自定义命令和自定义目标的关系:
13.6 常见模式与技巧
最后,分享几个我在项目中常用的模式:
| 场景 | 推荐做法 | 说明 |
|---|---|---|
| 代码生成 | add_custom_command(OUTPUT ...) |
生成的文件加入 add_executable 或 add_library |
| 构建后复制 | add_custom_command(TARGET ... POST_BUILD) |
用 ${CMAKE_COMMAND} -E copy 保证跨平台 |
| 运行测试 | add_custom_target(test_all ...) |
结合 ctest 或自定义脚本 |
| 生成文档 | add_custom_target(docs ...) |
用 WORKING_DIRECTORY 指定工作目录 |
| 清理临时文件 | add_custom_target(clean_extra ...) |
依赖 clean 目标,再删额外文件 |
cmake/ 目录下,用 include() 引入。这样主 CMakeLists.txt 不会太臃肿,也方便复用。
好了,自定义命令和自定义目标就聊到这里。说白了,它们就是 CMake 留给我们的「后门」,让我们能把任何构建步骤都纳入 CMake 的依赖管理体系中。用好了,你的构建系统会变得非常优雅、自动化。