引言:当标准图纸不够用时
在前面的章节中,我们的 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 提供了两种方式建立这种跨目标依赖:
- 源文件级传递:把生成文件直接加入
libA的源文件列表,CMake 会自动推导文件级依赖。 - 目标级传递:使用
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..."
)
代码生成的黄金法则
- 生成目录放在构建目录:永远把生成文件输出到
${CMAKE_CURRENT_BINARY_DIR}下的子目录,不要把生成产物污染源码树(Out-of-source 原则)。 - 完整列出所有输出:漏写一个输出文件会导致 CMake 的增量构建逻辑出错。
- 显式依赖输入文件:任何可能影响生成结果的文件(包括 .proto、.thrift、.g4,甚至代码生成模板本身)都要写入
DEPENDS。 - 让 IDE 认识生成文件:把生成文件加入库/可执行文件的源文件列表,这样 CLion、VS Code、Visual Studio 才能在项目树中显示它们,支持跳转和调试。
配置时执行 vs 构建时执行:别在错误的时间”打印”
最后必须澄清一个极易混淆的概念:CMake 中有两个命令长得有点像,但执行时机天差地别:
execute_process():在 CMake 配置阶段(运行cmake命令时)立即执行。适合探测系统环境、运行版本查询脚本。add_custom_command():在 构建阶段(运行cmake --build或make/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()创建不依赖默认构建流程的专项任务。 - 通过精细的
DEPENDS和add_dependencies()管理显式与隐式依赖,确保增量构建正确。 - 掌握了 Protobuf、Thrift、ANTLR 等主流代码生成工具的 CMake 集成范式。
至此,第八章”交叉编译与高级构建配置”的全部内容已经学完。从下一章开始,我们将走进 IDE 与编辑器的深度集成世界,看看 CMake 如何在开发者每天面对的代码编辑器中高效工作。


没有回复内容