14、CMake中的自定义目标:add_custom_target()、add_custom_command()、构建后事件与预构建事件

说实话,CMake 的构建系统虽然强大,但总有那么些时候,你需要「跳出框架」干点私活。比如生成一份文档、跑个代码检查脚本、或者把编译好的固件自动拷贝到某个目录。这时候,add_custom_target()add_custom_command() 就是你的瑞士军刀。

我最早接触这两个命令,是在一个嵌入式项目里。每次编译完都要手动把 .bin 文件拖到烧录工具里,烦得不行。后来我花了一下午研究自定义目标,从此解放双手。嗯,今天就把这些经验掰开揉碎讲给你听。

14.1 自定义目标 vs 自定义命令:先搞清楚谁是谁

很多新手容易把这两个搞混。我建议你先记住一句话:自定义目标是「做什么」,自定义命令是「怎么做」

特性 add_custom_target() add_custom_command()
本质 创建一个新的构建目标(类似可执行文件目标) 为已有目标或文件附加一个动作
是否生成产物 不一定,通常用于执行动作 通常生成一个或多个输出文件
依赖关系 可以依赖其他目标 可以被其他目标依赖
典型用途 运行测试、打包、清理 代码生成、文件转换、编译后处理

说白了,add_custom_target 就像一个「空壳子」,你往里面塞命令它就执行。而 add_custom_command 更像是「触发器」,当某个文件需要更新时,它才跑起来。

14.2 add_custom_target():创建你的专属任务

先看个最简单的例子。假设你想在构建完成后,打印一句「构建完成」:

add_custom_target(print_done
    COMMAND ${CMAKE_COMMAND} -E echo "构建完成!"
    COMMENT "打印构建完成信息"
)

然后你运行 make print_done 或者 cmake --build . --target print_done,就能看到输出。注意,这个目标默认不会自动执行,除非你把它加到其他目标的依赖里。

我在项目中经常用这种方式来跑代码格式化工具。比如:

add_custom_target(format_code
    COMMAND clang-format -i ${CMAKE_SOURCE_DIR}/src/*.cpp
    COMMENT "格式化所有源代码"
)

这样团队里每个人都能用同一个命令格式化代码,再也不用争论「你的大括号换行了」这种问题了。

小技巧: 使用 ALL 关键字可以让自定义目标每次都自动执行。比如 add_custom_target(clean_all ALL ...),但慎用,小心拖慢构建速度。

14.3 add_custom_command():精准触发,按需执行

这个命令有两种用法,我分别讲。

14.3.1 为文件生成添加命令

最常见的场景是:你有一个输入文件,需要经过某个工具处理,生成一个输出文件。比如用 protobuf 生成 C++ 代码:

add_custom_command(
    OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/message.pb.cc
    COMMAND protoc --cpp_out=${CMAKE_CURRENT_BINARY_DIR}
                   ${CMAKE_CURRENT_SOURCE_DIR}/message.proto
    DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/message.proto
    COMMENT "生成 protobuf 代码"
)

add_executable(my_app main.cpp ${CMAKE_CURRENT_BINARY_DIR}/message.pb.cc)

这里的关键是:只有当 message.proto 发生变化时,protoc 才会重新运行。CMake 会通过比较时间戳来判断是否需要重新生成。我曾经踩过一个坑——忘记写 DEPENDS,结果每次构建都重新生成,编译时间直接翻倍。

14.3.2 为目标添加构建后事件

另一种用法是给已有的目标(比如可执行文件)附加一个动作。这就是所谓的「构建后事件」:

add_custom_command(TARGET my_app POST_BUILD
    COMMAND ${CMAKE_COMMAND} -E copy
            $<TARGET_FILE:my_app>
            ${CMAKE_SOURCE_DIR}/deploy/
    COMMENT "拷贝可执行文件到部署目录"
)

你看,POST_BUILD 表示在 my_app 构建完成后执行。类似的还有 PRE_BUILDPRE_LINK。我整理了一张表:

事件类型 触发时机 典型用途
PRE_BUILD 目标开始编译之前 生成头文件、检查环境
PRE_LINK 编译完成、链接之前 修改目标文件、生成资源文件
POST_BUILD 链接完成之后 拷贝产物、打包、通知
注意: PRE_BUILD 在 Visual Studio 生成器上行为有点特殊——它可能不会在每次构建时都执行。如果你需要「每次构建前都跑」的逻辑,建议用 add_custom_target 配合 ALL 和依赖关系来实现。

14.4 实战:用自定义目标实现自动化打包

光说不练假把式。我拿一个真实项目举例。当时我需要把编译好的固件、配置文件、文档打包成一个 zip 文件,还要带上版本号。

# 获取 Git 版本号
execute_process(
    COMMAND git describe --tags --always
    OUTPUT_VARIABLE GIT_VERSION
    OUTPUT_STRIP_TRAILING_WHITESPACE
)

# 打包目标
add_custom_target(package_firmware
    COMMAND ${CMAKE_COMMAND} -E make_directory
            ${CMAKE_BINARY_DIR}/package
    COMMAND ${CMAKE_COMMAND} -E copy
            $<TARGET_FILE:firmware>
            ${CMAKE_BINARY_DIR}/package/firmware_${GIT_VERSION}.bin
    COMMAND ${CMAKE_COMMAND} -E tar "cf"
            ${CMAKE_BINARY_DIR}/firmware_${GIT_VERSION}.zip
            --format=zip
            ${CMAKE_BINARY_DIR}/package/
    COMMENT "打包固件 v${GIT_VERSION}"
    DEPENDS firmware
)

这里有几个要点:

  • execute_process 在 CMake 配置阶段获取版本号
  • 用生成器表达式 $<TARGET_FILE:firmware> 获取产物路径
  • DEPENDS firmware 确保固件先编译好

运行 cmake --build . --target package_firmware,一切自动完成。我曾经把这个脚本分享给同事,他看完说:「原来 CMake 还能这么玩?」

14.5 知识体系一览

为了让你更直观地理解这些概念之间的关系,我画了一张图:

CMake 自定义构建体系 add_custom_target() add_custom_command() 独立目标,手动或依赖触发 常用:格式化、测试、打包 文件级:OUTPUT/DEPENDS 驱动 目标级:PRE_BUILD/PRE_LINK/POST_BUILD 核心原则:目标管「做什么」,命令管「怎么做」

14.6 避坑指南与个人经验

最后,分享几个我这些年攒下来的教训:

  • 路径问题: 自定义命令的工作目录默认是构建目录。如果你需要操作源目录的文件,记得用 ${CMAKE_CURRENT_SOURCE_DIR} 拼出绝对路径。我曾经因为路径写错,把构建目录里的临时文件删了,排查了半天。
  • 跨平台兼容: 不要在命令里硬编码 cprm 这些 shell 命令。用 ${CMAKE_COMMAND} -E copy 这种 CMake 内置命令,Windows 和 Linux 都能跑。
  • 依赖链: 如果你用 add_custom_command 生成文件,一定要确保这个文件被某个目标引用(比如加到源文件列表里),否则 CMake 可能不会执行这个命令。
  • 调试技巧: 在命令后面加 COMMENTVERBATIM,前者让你知道在跑什么,后者避免转义问题。我习惯每个自定义命令都加上 VERBATIM,省心。

核心总结: 自定义目标和自定义命令是 CMake 构建系统的「外挂接口」。用好它们,你就能把任何自动化任务集成到构建流程里。记住:add_custom_target 适合「一次性任务」,add_custom_command 适合「按需触发」。两者配合,威力无穷。

公众号:蓝海资料掘金营,微信 deep3321