自定义命令与目标:add_custom_command()与add_custom_target()

说实话,CMake 里最容易被忽视但又最强大的功能,就是自定义命令和目标。我刚开始用 CMake 时,总觉得它就是个「自动生成 Makefile 的工具」。直到有一次我需要自动生成协议缓冲区代码,才发现原来 CMake 能做的事情远不止编译链接。

今天我们就来聊聊,怎么用 add_custom_command()add_custom_target() 来扩展构建流程。说白了,就是让 CMake 帮你跑任何你想要的命令——生成代码、复制文件、运行脚本,甚至发个通知都行。

add_custom_command():给构建流程加料

这个命令有两种用法,我分别叫它「生成文件型」和「事件触发型」。前者用来生成源文件,后者用来在构建前后执行操作。

生成源文件:让代码自己写自己

我在做网络库时,需要根据一个 IDL 文件生成 C++ 代码。手动跑生成器?太原始了。用 add_custom_command() 可以自动搞定:

add_custom_command(
    OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/generated/messages.pb.h
           ${CMAKE_CURRENT_BINARY_DIR}/generated/messages.pb.cc
    COMMAND protoc --cpp_out=${CMAKE_CURRENT_BINARY_DIR}/generated
            ${CMAKE_CURRENT_SOURCE_DIR}/proto/messages.proto
    DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/proto/messages.proto
    COMMENT "Generating protobuf source files..."
)

这里的关键是 OUTPUTDEPENDS。CMake 会检查输出文件是否存在,以及依赖是否更新。如果 proto 文件没变,生成步骤就不会重复执行。嗯,这就是增量构建的精髓。

我的习惯:生成的文件一定要放到 CMAKE_CURRENT_BINARY_DIR 下,别跟源码混在一起。否则 make clean 都清不干净,你想想看多难受。

构建后操作:收尾工作自动化

有时候编译完了,还需要做点别的事。比如把生成的库文件复制到某个目录,或者运行测试。这时候用 PRE_BUILDPRE_LINKPOST_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..."
)

我曾经在项目里用这个功能,在构建完成后自动把 DLL 复制到测试目录。省去了手动拷贝的麻烦,也避免了「哎呀我忘了复制新版本」的尴尬。

add_custom_target():没有输出的目标

这个命令创建的目标,本身不产生任何文件。它存在的意义就是「执行一组命令」。我常用它来做代码格式化、文档生成、或者运行测试套件。

add_custom_target(
    format
    COMMAND clang-format -i
            ${CMAKE_CURRENT_SOURCE_DIR}/src/*.cpp
            ${CMAKE_CURRENT_SOURCE_DIR}/include/*.h
    COMMENT "Formatting source code with clang-format..."
)

运行 make format 就能一键格式化所有代码。团队里统一代码风格,这招特别好使。

注意:自定义目标默认不会自动执行。你得显式指定它作为其他目标的依赖,或者手动 make target_name。我刚开始用的时候,加了个自定义目标以为它会自动跑,结果等了半天没反应……

依赖图定制:把碎片拼成蓝图

CMake 的依赖图,说白了就是告诉构建系统「谁先谁后」。自定义命令和目标可以让你自由地编织这张网。

举个例子,假设我们有这样的流程:

  1. 从 IDL 生成 C++ 代码
  2. 编译生成的代码
  3. 运行测试

用 CMake 表达就是:

# 第一步:生成代码
add_custom_command(
    OUTPUT generated.cpp
    COMMAND python generator.py input.idl generated.cpp
    DEPENDS input.idl generator.py
)

# 第二步:编译
add_library(my_lib generated.cpp)

# 第三步:测试目标
add_custom_target(
    run_tests
    COMMAND ./run_tests.sh
    DEPENDS my_lib
)

这里 run_tests 依赖 my_lib,而 my_lib 又依赖 generated.cpp。CMake 会自动推导出完整的依赖链。你只需要 make run_tests,一切自动完成。

核心思想:自定义命令定义「怎么做」,自定义目标定义「做什么」。两者配合,就能构建出任意复杂的构建流程。

实战:一个完整的代码生成示例

让我给你看一个我在实际项目中用过的模式。这个项目需要根据 JSON 配置文件生成 C++ 枚举类:

# 生成枚举头文件
add_custom_command(
    OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/generated/enums.h
    COMMAND python ${CMAKE_CURRENT_SOURCE_DIR}/tools/gen_enum.py
            ${CMAKE_CURRENT_SOURCE_DIR}/config/enums.json
            ${CMAKE_CURRENT_BINARY_DIR}/generated/enums.h
    DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/tools/gen_enum.py
            ${CMAKE_CURRENT_SOURCE_DIR}/config/enums.json
    COMMENT "Generating enum header from JSON config..."
)

# 编译使用生成文件的目标
add_library(config_reader
    config_reader.cpp
    ${CMAKE_CURRENT_BINARY_DIR}/generated/enums.h
)

# 确保生成步骤在编译前执行
target_include_directories(config_reader
    PRIVATE ${CMAKE_CURRENT_BINARY_DIR}/generated
)

# 一键重新生成所有代码
add_custom_target(
    regenerate
    COMMAND ${CMAKE_COMMAND} -E remove
            ${CMAKE_CURRENT_BINARY_DIR}/generated/enums.h
    COMMAND ${CMAKE_COMMAND} --build ${CMAKE_BINARY_DIR}
    COMMENT "Force regenerating all generated files..."
)

这个模式的好处是:

  • 增量构建:只有 JSON 或生成器变了才重新生成
  • 可追溯:依赖关系清晰,不会漏掉任何步骤
  • 可重复:任何机器上都能复现同样的构建

依赖图可视化

下面这张图展示了我们刚才讨论的依赖关系。你可以看到自定义命令和目标如何融入整个构建流程:

自定义命令与目标依赖图 input.idl generator.py config/enums.json add_custom_command() 生成代码 add_custom_command() 生成枚举头文件 generated.cpp generated/enums.h my_lib config_reader add_custom_target() regenerate 源文件 自定义命令 生成文件 库目标 自定义目标

避坑指南

这些年我用自定义命令踩过不少坑,分享几个给你:

  • 路径问题:自定义命令的工作目录默认是构建目录。如果你需要引用源文件,一定要用 ${CMAKE_CURRENT_SOURCE_DIR} 绝对路径。我曾经因为相对路径问题,在 CI 上折腾了一整天。
  • 跨平台命令:别直接写 cprm 这些 shell 命令。用 ${CMAKE_COMMAND} -E copy${CMAKE_COMMAND} -E remove,CMake 会帮你处理跨平台差异。
  • 依赖声明:生成文件的依赖一定要写全。漏掉一个依赖,就可能出现「改了配置文件但没重新生成代码」的诡异问题。
  • 并行构建:自定义命令默认是支持并行的。但如果你的命令有副作用(比如写同一个文件),记得用 JOB_POOLS 或者加锁。
总结一下:自定义命令和目标,是 CMake 构建系统的「瑞士军刀」。它们让你能做的事情远远超出「编译 C++ 代码」这个范畴。我个人觉得,掌握这两个命令,才算真正开始玩转 CMake。

好了,关于自定义命令和目标就聊这么多。记住一点:构建流程越复杂,越需要用 CMake 来管理。别想着「手动跑一下就好」,自动化才是工程化的基石。


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