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_BUILD 和 PRE_LINK。我整理了一张表:
| 事件类型 | 触发时机 | 典型用途 |
|---|---|---|
| PRE_BUILD | 目标开始编译之前 | 生成头文件、检查环境 |
| PRE_LINK | 编译完成、链接之前 | 修改目标文件、生成资源文件 |
| POST_BUILD | 链接完成之后 | 拷贝产物、打包、通知 |
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 知识体系一览
为了让你更直观地理解这些概念之间的关系,我画了一张图:
14.6 避坑指南与个人经验
最后,分享几个我这些年攒下来的教训:
- 路径问题: 自定义命令的工作目录默认是构建目录。如果你需要操作源目录的文件,记得用
${CMAKE_CURRENT_SOURCE_DIR}拼出绝对路径。我曾经因为路径写错,把构建目录里的临时文件删了,排查了半天。 - 跨平台兼容: 不要在命令里硬编码
cp、rm这些 shell 命令。用${CMAKE_COMMAND} -E copy这种 CMake 内置命令,Windows 和 Linux 都能跑。 - 依赖链: 如果你用
add_custom_command生成文件,一定要确保这个文件被某个目标引用(比如加到源文件列表里),否则 CMake 可能不会执行这个命令。 - 调试技巧: 在命令后面加
COMMENT和VERBATIM,前者让你知道在跑什么,后者避免转义问题。我习惯每个自定义命令都加上VERBATIM,省心。
核心总结: 自定义目标和自定义命令是 CMake 构建系统的「外挂接口」。用好它们,你就能把任何自动化任务集成到构建流程里。记住:add_custom_target 适合「一次性任务」,add_custom_command 适合「按需触发」。两者配合,威力无穷。