22、自定义命令与目标:add_custom_command(),add_custom_target(),构建时执行脚本
说实话,CMake 的构建系统里,最让我觉得「这才是真正生产力」的功能,就是自定义命令和目标。你想想看,光靠编译 .cpp 文件、链接库,能做的事情其实很有限。真正复杂的项目,往往需要在构建过程中干点「私活」——比如生成代码、复制资源、运行脚本、打版本号……
我最早接触这个功能,是在做一个嵌入式项目的时候。那时候我们需要在编译前自动生成一个版本头文件,把 Git 的 commit hash 写进去。一开始我手动跑脚本,后来发现太容易忘了。嗯,从那以后,我就彻底爱上了 add_custom_command() 和 add_custom_target()。
add_custom_command():给构建流程加个「钩子」
这个命令说白了,就是告诉 CMake:「嘿,在某个时机,帮我跑一段命令。」它有两种常见用法:
- 作为文件生成器:当某个文件不存在,或者它的依赖文件发生变化时,自动执行命令来生成它。
- 作为目标的后置操作:在某个目标(比如一个库或可执行文件)构建完成后,额外执行一些脚本。
我个人习惯用第一种用法比较多。来看个例子:
add_custom_command(
OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/version.h
COMMAND ${CMAKE_COMMAND} -E echo "#define VERSION \"1.0.0\"" > ${CMAKE_CURRENT_BINARY_DIR}/version.h
DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/VERSION.txt
COMMENT "生成版本头文件"
)
这里的关键点是:OUTPUT 指定了生成的文件,DEPENDS 指定了依赖。如果 VERSION.txt 没变,CMake 就不会重复执行这个命令。这就是 CMake 的增量构建逻辑——它很聪明,不会每次都跑一遍。
核心要点:add_custom_command() 本身不产生一个「目标」,它只是给某个文件或某个目标附加了一个动作。你需要把它挂到某个目标上,它才会真正被执行。
add_custom_target():创建一个「虚拟目标」
有时候,我们并不想生成某个文件,而是想单纯地执行一个动作。比如:打包、清理、运行测试脚本。这时候就需要 add_custom_target() 了。
它创建的目标,没有输出文件,也没有编译器参与。它就是一个「名字」,你可以在命令行里 make my_target 或者 ninja my_target 来触发它。
add_custom_target(
run_tests
COMMAND ${CMAKE_CTEST_COMMAND} --output-on-failure
WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
COMMENT "运行所有测试"
)
你看,这个目标不产生任何 .o 或 .exe 文件,但它能帮你跑测试。我经常用它来做「一键部署」的入口。
小技巧:如果你想让自定义目标每次都执行(即使没有文件变化),可以加上 ALL 关键字。比如 add_custom_target(clean_all ALL ...),这样每次构建都会跑它。不过要小心,别把构建速度拖慢了。
构建时执行脚本:把脚本嵌入构建流程
你可能会问:「那我能不能在构建过程中跑一个 Python 脚本?或者 Shell 脚本?」当然可以。CMake 的 COMMAND 参数支持任何可执行程序。你只需要指定路径和参数即可。
我曾经在一个项目中,需要在每次链接之前,用 Python 脚本检查一下符号表。我是这么写的:
add_custom_command(
TARGET my_app
PRE_LINK
COMMAND python3 ${CMAKE_SOURCE_DIR}/scripts/check_symbols.py ${CMAKE_BINARY_DIR}/my_app
COMMENT "检查符号表..."
)
这里的 PRE_LINK 表示在链接之前执行。还有 PRE_BUILD(编译前)和 POST_BUILD(构建后)。这三个时机,基本覆盖了所有你需要「插一脚」的地方。
注意:PRE_BUILD 在有些生成器(比如 Visual Studio)上行为不太一样,它只会在目标被标记为「需要重新构建」时才触发。而 PRE_LINK 和 POST_BUILD 则更稳定。我个人建议,除非你很清楚自己在做什么,否则优先用 PRE_LINK 或 POST_BUILD。
知识体系图:自定义命令与目标的核心逻辑
下面这张图,帮你理清这两个命令的关系和适用场景:
实战对比:什么时候用哪个?
我整理了一个表格,方便你快速决策:
| 场景 | 推荐命令 | 原因 |
|---|---|---|
| 根据配置文件生成头文件 | add_custom_command(OUTPUT ...) |
有明确的输出文件,CMake 可以跟踪依赖 |
| 编译完成后复制 DLL 到输出目录 | add_custom_command(TARGET ... POST_BUILD) |
依赖具体目标,且需要在构建后执行 |
| 运行代码格式化工具 | add_custom_target() |
没有输出文件,只是一个动作 |
| 打包安装包 | add_custom_target() |
通常需要手动触发,不随构建自动执行 |
| 在链接前检查符号 | add_custom_command(TARGET ... PRE_LINK) |
需要精确的时机控制 |
避坑指南:我曾经踩过的坑
我曾经在一个跨平台项目里,用 add_custom_command() 调用了一个 Shell 脚本。在 Linux 上跑得好好的,到了 Windows 上就报错。为什么?因为 Windows 没有 Bash。后来我改用 ${CMAKE_COMMAND} -E 来执行跨平台命令,或者用 find_program() 先找到 Python 解释器,再调用 Python 脚本。
还有一个坑:如果你在 add_custom_command() 里用了相对路径,CMake 会把它解释为相对于当前源码目录(CMAKE_CURRENT_SOURCE_DIR)。但如果你在 add_custom_target() 里用相对路径,它可能相对于构建目录。嗯,这里要注意,最好统一用 ${CMAKE_CURRENT_SOURCE_DIR} 或 ${CMAKE_CURRENT_BINARY_DIR} 来显式指定路径。
我的习惯:所有自定义命令的 WORKING_DIRECTORY 都显式设置成 ${CMAKE_BINARY_DIR}。这样不管你在哪个目录下执行构建,行为都是一致的。省心。
总结一下
add_custom_command() 和 add_custom_target() 是 CMake 构建系统里最灵活的「瑞士军刀」。一个负责在特定时机执行动作,一个负责创建可触发的虚拟目标。两者配合使用,几乎可以覆盖所有构建时的自动化需求。
你想想看,从代码生成、资源复制,到测试运行、打包部署,这些原本需要手动操作的事情,现在都可以交给 CMake 自动完成。这才是现代构建系统该有的样子。