17、自定义命令与目标:add_custom_command、add_custom_target、生成源文件、构建后操作
说实话,CMake 的构建系统虽然强大,但总有一些「标准流程搞不定」的活儿。比如:编译前自动生成一堆配置文件、编译后把产物打包成安装包、或者调用一个外部脚本做代码检查。这时候,add_custom_command 和 add_custom_target 就是你的救星。
我个人习惯把这两个命令看作是 CMake 的「瑞士军刀」。它们能让你在构建流程中插入任意操作——只要你的操作系统能执行,CMake 就能帮你调度。
17.1 核心概念:命令 vs 目标
先搞清楚这两个东西的区别,不然容易用混。
| 特性 | add_custom_command | add_custom_target |
|---|---|---|
| 本质 | 附加到已有目标上的「钩子」 | 一个独立的新目标 |
| 触发方式 | 依赖的目标被构建时自动执行 | 显式指定构建它(如 make my_target) |
| 输出文件 | 通常有输出文件(OUTPUT 参数) | 可以没有输出文件(ALL 参数控制是否默认构建) |
| 典型用途 | 生成源文件、构建后处理 | 运行测试、打包、代码检查 |
嗯,这里要注意:add_custom_command 有两种形式——一种是附加到目标(TARGET),另一种是生成文件(OUTPUT)。前者在目标构建前后执行,后者在某个文件需要更新时执行。别搞混了。
17.2 生成源文件:add_custom_command 的 OUTPUT 形式
我在项目中遇到过最典型的场景:用 protobuf 编译 .proto 文件。你不想每次手动执行 protoc,而是希望 CMake 自动检测 .proto 文件变化并重新生成 .pb.h 和 .pb.cc。
代码长这样:
set(PROTO_FILE "${CMAKE_CURRENT_SOURCE_DIR}/message.proto")
set(GENERATED_SRC "${CMAKE_CURRENT_BINARY_DIR}/message.pb.cc")
set(GENERATED_HDR "${CMAKE_CURRENT_BINARY_DIR}/message.pb.h")
add_custom_command(
OUTPUT ${GENERATED_SRC} ${GENERATED_HDR}
COMMAND protoc --cpp_out=${CMAKE_CURRENT_BINARY_DIR}
-I${CMAKE_CURRENT_SOURCE_DIR}
${PROTO_FILE}
DEPENDS ${PROTO_FILE}
COMMENT "Generating protobuf source files..."
)
add_executable(my_app ${GENERATED_SRC} main.cpp)
target_include_directories(my_app PRIVATE ${CMAKE_CURRENT_BINARY_DIR})
这里的关键是 OUTPUT 和 DEPENDS。CMake 会检查 OUTPUT 文件是否存在,以及 DEPENDS 是否比 OUTPUT 更新。如果 message.proto 变了,CMake 会自动重新运行 protoc。说白了,这就是 CMake 版的「增量构建」。
CMAKE_CURRENT_BINARY_DIR 而不是源目录。这样 make clean 能清理干净,也不会污染源码树。
17.3 构建后操作:add_custom_command 的 TARGET 形式
构建完成后想干点啥?比如把可执行文件复制到某个目录、或者运行一个测试脚本。用 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..."
)
注意这里的 $<TARGET_FILE:my_app> 是生成器表达式,它会在构建时被替换成实际的目标文件路径。我个人很喜欢用生成器表达式,它比硬编码路径灵活得多。
我曾经踩过一个坑:PRE_BUILD 在 Visual Studio 生成器下行为有点奇怪——它实际上是在「依赖检查之前」执行,而不是在「编译之前」。如果你需要严格在编译前执行某些操作,建议用 PRE_LINK 或者单独写一个自定义目标。
17.4 独立目标:add_custom_target
有时候你不需要生成任何文件,只是想定义一个「动作」。比如运行单元测试、打包、或者代码格式化。
add_custom_target(
run_tests
COMMAND ${CMAKE_CTEST_COMMAND} --output-on-failure
WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
COMMENT "Running all tests..."
)
add_custom_target(
format_code
COMMAND clang-format -i ${CMAKE_SOURCE_DIR}/src/*.cpp
COMMENT "Formatting source code with clang-format..."
)
默认情况下,自定义目标不会自动构建。你需要显式执行 make run_tests 或 ninja format_code。如果你希望它每次构建都运行,可以加上 ALL 关键字:
add_custom_target(
check ALL
COMMAND ${CMAKE_COMMAND} -E echo "Build completed!"
)
嗯,但我不建议滥用 ALL。想想看,如果每个自定义目标都默认构建,你的构建时间会越来越长。只有那些真正「每次构建都必须做」的操作才加 ALL。
17.5 依赖链:让自定义目标与普通目标协作
自定义目标可以依赖普通目标,普通目标也可以依赖自定义目标。用 add_dependencies 来建立关系。
add_custom_target(generate_config
COMMAND python3 ${CMAKE_SOURCE_DIR}/tools/gen_config.py
COMMENT "Generating configuration header..."
)
add_executable(my_app main.cpp)
add_dependencies(my_app generate_config)
这样,构建 my_app 之前一定会先执行 generate_config 目标。我在实际项目中经常用这个模式:先运行代码生成器,再编译生成的代码。
add_dependencies 只在同一个 CMakeLists.txt 或通过 add_subdirectory 引入的目录中有效。跨目录的依赖需要确保目标名称全局唯一。
17.6 实用技巧与避坑指南
- 使用 CMake 内置命令:
${CMAKE_COMMAND} -E提供了很多跨平台工具,比如copy、remove、echo、tar。尽量用它们而不是直接调用系统命令,这样你的 CMakeLists.txt 在 Windows 和 Linux 上都能跑。 - 生成器表达式是你的朋友: 在
COMMAND中使用$<...>可以获取目标路径、编译选项等。比如$<TARGET_FILE_DIR:my_app>获取目标所在目录。 - 避免硬编码路径: 用
${CMAKE_CURRENT_SOURCE_DIR}和${CMAKE_CURRENT_BINARY_DIR}代替绝对路径。 - 调试技巧: 如果自定义命令不执行,先检查
OUTPUT文件是否存在。CMake 认为输出文件已存在且依赖没变,就不会重新执行。
我曾经犯过一个低级错误:在 add_custom_command 的 COMMAND 里写了一个带空格的路径,没有用引号括起来。结果在 Windows 上路径被拆成了两个参数,命令直接失败。记住:COMMAND 里的参数要像在 shell 中一样处理,空格和特殊字符要小心。
17.7 知识体系总览
下面这张图帮你理清本章的核心逻辑:
这张图把本章的知识点串起来了。左边是 add_custom_command 的两种形式,右边是 add_custom_target 的典型用途。底部那条「核心原则」是我多年实践总结出来的——你写自定义命令时,时刻想着这三点,基本不会出大错。
add_custom_command用于「生成文件」或「附加到目标」,适合自动化构建步骤。add_custom_target用于「定义独立动作」,适合测试、打包、代码检查。- 用
OUTPUT和DEPENDS实现增量构建,避免重复劳动。 - 生成器表达式
$<...>让路径和配置更灵活。 - 优先使用 CMake 内置命令,保证跨平台兼容性。