32. 8.4 自定义构建规则

引言:当标准图纸不够用时

在前面的章节中,我们的 CMake “施工队长”已经熟练掌握了一套标准化的施工流程:看蓝图(Target)、运材料(Source)、调工艺(Compile/Link Options)、搞质检(CTest)。无论是本地盖楼还是海外工程(交叉编译),队长都能按章办事。

但现实世界的工程项目从来都不是完全标准化的。有时候,你需要在浇筑混凝土前预埋传感器(生成配置头文件);有时候,你必须在楼房封顶后自动运行验收脚本(构建后处理);还有的时候,你的墙体装饰板并不是从供应商那里买来的,而是需要在工地现场用 3D 打印机床(代码生成器)根据设计图当场”打印”出来的。

这些非标准、定制化的构建步骤,就是 CMake 的自定义构建规则(Custom Build Rules)大显身手的舞台。本节中,我们将学习如何给施工队下发”特殊工艺单”,让 CMake 在正确的时间、以正确的依赖关系执行你指定的任意命令。

add_custom_command:给施工队下发”特殊工艺单”

CMake 中自定义构建规则的核心指令是 add_custom_command()。它有两种截然不同的工作模式,很多初学者容易混淆,我们必须先理清边界:

模式一:生成文件(OUTPUT)

这是最常见的用法:你告诉 CMake,某个(些)输出文件需要通过执行特定命令来生成。只要有人在构建中需要这些文件,CMake 就会自动执行对应的命令。

# 定义一条生成规则:用 Python 脚本根据 data.txt 生成 generated.cpp
add_custom_command(
    OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/generated.cpp
    COMMAND python3
    ARGS ${CMAKE_CURRENT_SOURCE_DIR}/scripts/gen.py
         --input ${CMAKE_CURRENT_SOURCE_DIR}/data.txt
         --output ${CMAKE_CURRENT_BINARY_DIR}/generated.cpp
    DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/data.txt
            ${CMAKE_CURRENT_SOURCE_DIR}/scripts/gen.py
    COMMENT "Generating C++ source from data.txt..."
    VERBATIM
)

这条指令本身不会让命令立即执行。它只是在 CMake 的内部”施工手册”上登记了一条规则:”如果 generated.cpp 不存在,或者 data.txt/gen.py 比它新,就运行这条命令重新生成。”

要让这个规则真正被触发,生成的文件必须被某个目标(Target)依赖。最直接的方式是把输出文件加入某个可执行文件或库的源文件列表:

add_executable(my_app
    main.cpp
    utils.cpp
    ${CMAKE_CURRENT_BINARY_DIR}/generated.cpp  # 关键:让目标依赖生成文件
)

常见陷阱:如果你不把 generated.cpp 加入目标源文件,或者不让目标通过其他方式依赖它,CMake 会认为没人需要这个文件,从而跳过生成步骤,导致编译器报错找不到源文件。

模式二:附加到目标(TARGET)

第二种模式不是生成新文件,而是给某个已存在的目标”挂接”额外的命令,在构建的特定时机执行:

add_custom_command(
    TARGET my_app
    POST_BUILD
    COMMAND ${CMAKE_COMMAND} -E copy
            $
            ${CMAKE_BINARY_DIR}/deploy/my_app.exe
    COMMENT "Deploying binary to output directory..."
)

这条规则的意思是:在 my_app 这个Target构建完成之后,自动把生成的可执行文件复制到部署目录。

构建时机的选择:PRE_BUILD、PRE_LINK 与 POST_BUILD

当使用 TARGET 模式时,CMake 提供了三个时机选项。理解它们的差异,就像理解”浇筑前检查钢筋”、”封顶前验收水电”和”交房后打扫卫生”的区别:

PRE_BUILD(浇筑前)

  • 在目标的其他规则开始编译之前执行。
  • 注意:对于 Visual Studio 生成器,这是目标级自定义构建步骤的标准行为;而对于 Makefile/Ninja 生成器,PRE_BUILD 会被视为 PRE_LINK 处理,因为 Makefile 的粒度不支持真正的编译前钩子。
  • 适用场景:生成需要在编译前就必须存在的源代码、配置头文件。

PRE_LINK(封顶前)

  • 在目标的所有源文件编译完成之后链接开始之前执行。
  • 适用场景:对编译生成的目标文件(.o / .obj)进行后处理、打包静态库前的合并操作等。

POST_BUILD(交房后)

  • 在目标完全构建成功之后执行。
  • 适用场景:复制输出文件、运行签名工具、执行自动测试、剥离符号表(strip)等。

选择正确的时机至关重要。如果你把”生成源代码”放到了 POST_BUILD,那就好比房子都盖完了才开始打印墙体——为时已晚。

输出文件依赖管理:别让”隐形工人”掉队

自定义构建规则最容易翻车的地方,不是命令写错,而是依赖关系没理清。CMake 的增量构建机制高度依赖准确的依赖声明。

显式依赖(DEPENDS)

OUTPUT 模式下,DEPENDS 后面的文件列表告诉 CMake:如果这些东西变了,就必须重新执行命令。这包括输入数据文件、代码生成脚本、甚至是第三方工具的可执行文件本身。

add_custom_command(
    OUTPUT ${GEN_OUT}/parser.cpp
    COMMAND antlr4 -Dlanguage=Cpp -visitor -no-listener
            ${CMAKE_CURRENT_SOURCE_DIR}/grammar/MyGrammar.g4
            -o ${GEN_OUT}
    DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/grammar/MyGrammar.g4
    COMMENT "Generating ANTLR C++ parser..."
)

如果你修改了 MyGrammar.g4,CMake 会在下次构建时自动重新生成 parser.cpp。如果你忘记写 DEPENDS,CMake 不会察觉语法文件的变化,你将链接一份过时的生成代码。

隐式依赖(IMPLICIT_DEPENDS)

对于某些生成器(主要是 Makefile),你可以使用 IMPLICIT_DEPENDS 让 CMake 借助编译器的头文件扫描能力,自动追踪生成文件内部的 #include 依赖。不过,在现代 CMake 配合 Ninja 的使用场景中,这已较少见,更推荐的方式是显式声明所有直接输入

跨目标依赖:让”下游施工队”等”上游”完工

假设 libA 需要用到 generated.cpp,而 generated.cpp 又依赖另一个自定义目标 codegen 产生的中间结果。CMake 提供了两种方式建立这种跨目标依赖:

  1. 源文件级传递:把生成文件直接加入 libA 的源文件列表,CMake 会自动推导文件级依赖。
  2. 目标级传递:使用 add_dependencies(libA codegen),强制 libA 等待 codegen 完成后才开始构建。
add_custom_target(codegen DEPENDS ${GEN_OUT}/generated.cpp)

add_library(libA STATIC
    libA.cpp
    ${GEN_OUT}/generated.cpp
)

# 确保 codegen 目标先完成
add_dependencies(libA codegen)

关键点:add_dependencies() 连接的是目标与目标之间的顺序,而 DEPENDS 连接的是命令与文件之间的新鲜度。两者结合,才能构建出健壮的工作流。

add_custom_target:创建”专项施工小组”

如果说普通的 add_executable()add_library() 是施工队里的”土建组”和”钢结构组”,那么 add_custom_target() 就是一支专门执行特殊任务的机动小组——比如”环境清理组”、”文档生成组”、”自动化部署组”。

这种目标有几个显著特征:

  • 默认不构建:它不会被 cmake --build . 默认构建,除非你显式指定它,或者把它加入默认目标(ALL)。
  • 不必然产生输出文件:它可以只是运行一组 shell 命令。
  • 总是被认为”过时”:默认情况下,每次你显式构建它,命令都会执行(除非你通过其他机制管理其输出)。
# 创建一个"文档生成"目标
add_custom_target(docs
    COMMAND doxygen ${CMAKE_SOURCE_DIR}/Doxyfile
    WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
    COMMENT "Generating API documentation with Doxygen..."
    VERBATIM
)

# 创建一个每次都会执行的"清理临时文件"目标
add_custom_target(clean-temp
    COMMAND ${CMAKE_COMMAND} -E remove_directory ${CMAKE_BINARY_DIR}/temp
    COMMENT "Cleaning temporary files..."
)

# 把 docs 加入默认构建(可选)
# add_custom_target(docs ALL ...)

执行 cmake --build . --target docs 就能单独触发文档生成,而不会影响主工程的编译。

构建事件与钩子:在关键节点”插旗”

在大型项目中,你可能需要在构建流程的特定节点插入审计、校验或通知逻辑。结合 add_custom_command(TARGET ...)add_custom_target(),你可以实现复杂的构建事件编排。

例如,一个完整的”签名与校验”钩子:

add_executable(secure_app main.cpp)

# 链接完成后对二进制进行签名
add_custom_command(
    TARGET secure_app
    POST_BUILD
    COMMAND signtool sign /f ${CERT_FILE} /p ${CERT_PASS}
            $
    COMMENT "Signing release binary..."
)

# 签名完成后计算哈希
add_custom_command(
    TARGET secure_app
    POST_BUILD
    COMMAND ${CMAKE_COMMAND} -E echo
            "SHA256=$$(${CMAKE_COMMAND} -E sha256sum $)"
            > ${CMAKE_BINARY_DIR}/secure_app.sha256
    COMMENT "Generating checksum..."
)

注意:同一个目标可以挂载多条 POST_BUILD 命令,CMake 会按照它们在 CMakeLists.txt 中出现的顺序依次执行

代码生成工作流:与 Protobuf、Thrift、ANTLR 共舞

自定义构建规则最具实战价值的场景莫过于代码生成(Code Generation)。现代 C++ 项目中,协议定义、RPC 接口或语法解析器往往通过声明式语言描述,再由工具生成 C++ 源代码。CMake 需要把这条”设计图 → 机床 → 预制件”的流水线无缝接入构建系统。

Protocol Buffers 集成实战

假设我们有一个 message.proto 文件,需要生成 C++ 的 .pb.cc.pb.h

find_package(Protobuf REQUIRED)

# 手动定义生成规则(适用于需要精细控制的场景)
set(PROTO_SRC ${CMAKE_CURRENT_SOURCE_DIR}/message.proto)
set(PROTO_GEN_DIR ${CMAKE_CURRENT_BINARY_DIR}/generated)

file(MAKE_DIRECTORY ${PROTO_GEN_DIR})

add_custom_command(
    OUTPUT ${PROTO_GEN_DIR}/message.pb.cc
           ${PROTO_GEN_DIR}/message.pb.h
    COMMAND protobuf::protoc
    ARGS --cpp_out=${PROTO_GEN_DIR}
         -I ${CMAKE_CURRENT_SOURCE_DIR}
         ${PROTO_SRC}
    DEPENDS ${PROTO_SRC}
    COMMENT "Generating Protocol Buffers C++ files..."
)

add_library(proto_msgs STATIC
    ${PROTO_GEN_DIR}/message.pb.cc
    ${PROTO_GEN_DIR}/message.pb.h
)

target_include_directories(proto_msgs
    PUBLIC
        ${PROTO_GEN_DIR}
        ${Protobuf_INCLUDE_DIRS}
)

target_link_libraries(proto_msgs
    PUBLIC
        protobuf::libprotobuf
)

在这个例子中,protoc 就是工地现场的”3D 打印机”,add_custom_command 是它的操作手册,而 proto_msgs 这个静态库则是使用”打印件”的最终建筑模块。

Thrift 集成模式

Thrift 的集成模式与 Protobuf 几乎一致,核心都是”声明输入文件 → 定义生成命令 → 把产物加入目标”:

set(THRIFT_FILE ${CMAKE_CURRENT_SOURCE_DIR}/service.thrift)
set(THRIFT_GEN_DIR ${CMAKE_CURRENT_BINARY_DIR}/thrift)

add_custom_command(
    OUTPUT ${THRIFT_GEN_DIR}/Service.cpp
           ${THRIFT_GEN_DIR}/Service.h
    COMMAND thrift::thrift
    ARGS --gen cpp -out ${THRIFT_GEN_DIR} ${THRIFT_FILE}
    DEPENDS ${THRIFT_FILE}
    COMMENT "Generating Thrift service stubs..."
)

add_library(thrift_service STATIC ${THRIFT_GEN_DIR}/Service.cpp)
target_include_directories(thrift_service PUBLIC ${THRIFT_GEN_DIR})

ANTLR 语法解析器生成

ANTLR 的语法文件(.g4)通常会一次性生成多个文件(Parser、Lexer、Visitor、Listener)。你需要在 OUTPUT 中把它们全部列出,否则 CMake 可能会因为某个”预期外”的输出文件已存在而跳过命令执行(因为 CMake 只检查它知道的输出文件的新鲜度)。

set(GRAMMAR ${CMAKE_CURRENT_SOURCE_DIR}/Expr.g4)
set(ANTLR_OUT ${CMAKE_CURRENT_BINARY_DIR}/antlr4)

add_custom_command(
    OUTPUT
        ${ANTLR_OUT}/ExprLexer.cpp
        ${ANTLR_OUT}/ExprLexer.h
        ${ANTLR_OUT}/ExprParser.cpp
        ${ANTLR_OUT}/ExprParser.h
        ${ANTLR_OUT}/ExprVisitor.cpp
        ${ANTLR_OUT}/ExprVisitor.h
    COMMAND antlr4
    ARGS -Dlanguage=Cpp -visitor -no-listener
         -package mylang
         -o ${ANTLR_OUT}
         ${GRAMMAR}
    DEPENDS ${GRAMMAR}
    COMMENT "Generating ANTLR4 C++ parser from Expr.g4..."
)

代码生成的黄金法则

  1. 生成目录放在构建目录:永远把生成文件输出到 ${CMAKE_CURRENT_BINARY_DIR} 下的子目录,不要把生成产物污染源码树(Out-of-source 原则)。
  2. 完整列出所有输出:漏写一个输出文件会导致 CMake 的增量构建逻辑出错。
  3. 显式依赖输入文件:任何可能影响生成结果的文件(包括 .proto、.thrift、.g4,甚至代码生成模板本身)都要写入 DEPENDS
  4. 让 IDE 认识生成文件:把生成文件加入库/可执行文件的源文件列表,这样 CLion、VS Code、Visual Studio 才能在项目树中显示它们,支持跳转和调试。

配置时执行 vs 构建时执行:别在错误的时间”打印”

最后必须澄清一个极易混淆的概念:CMake 中有两个命令长得有点像,但执行时机天差地别:

  • execute_process():在 CMake 配置阶段(运行 cmake 命令时)立即执行。适合探测系统环境、运行版本查询脚本。
  • add_custom_command():在 构建阶段(运行 cmake --buildmake/ninja 时)按需执行。适合代码生成、构建后处理。

如果你用 execute_process() 去调用 protoc 生成代码,那么只有在重新运行 cmake 时才会更新生成文件;如果只是修改了 .proto 然后运行 make,生成文件不会自动更新。这就是为什么代码生成必须使用 add_custom_command 的原因。

小结

本节中,我们的 CMake “施工队长”学会了如何在标准化流程之外处理”特殊工艺”:

  • 使用 add_custom_command(OUTPUT ...) 定义文件生成规则,把代码生成工具无缝接入构建流水线。
  • 使用 add_custom_command(TARGET ... PRE_BUILD/PRE_LINK/POST_BUILD) 在构建的关键节点插入钩子。
  • 使用 add_custom_target() 创建不依赖默认构建流程的专项任务。
  • 通过精细的 DEPENDSadd_dependencies() 管理显式与隐式依赖,确保增量构建正确。
  • 掌握了 Protobuf、Thrift、ANTLR 等主流代码生成工具的 CMake 集成范式。

至此,第八章”交叉编译与高级构建配置”的全部内容已经学完。从下一章开始,我们将走进 IDE 与编辑器的深度集成世界,看看 CMake 如何在开发者每天面对的代码编辑器中高效工作。

请登录后发表评论

    没有回复内容

正在唤醒异次元光景……