☰
CMake与Ninja多输出错误:从报错到根治的完整指南
2026/9/29 15:39:58 网站建设 项目流程

深夜十一点半,我在服务器上准备把最后一个模块编出来。CMake配置阶段顺得不像话,cmake -S . -B build -G Ninja连个warning都没给我,正当我伸手去拿咖啡时,cmake --build build -j16怼回来一行错误:

ninja: error: build.ninja:1180: multiple outputs aren't (yet?) supported

通常 configure 一步不报错、build 一步才报错的场景都是最磨人的——它不是语法错误,不是缺依赖,而是 Ninja 在图构建阶段发现了与你预期不一致的地方。这个报错对于用 CMake + Ninja 组合的老手也未必天天见,一旦碰到,很多人第一反应是上网搜,然后发现中文资料少得可怜,只知道"多输出不支持",却不知道这五个字背后到底限制了什么、怎么绕过、怎么根治。这篇文章我会从报错现场、Ninja 的底层逻辑、三类常见的"多输出制造机"、到完整的排查链路和最终修复方案一次讲透,争取你看完不只是能绕过它,还能在下次写生成脚本时从源头避开。

1. 配置好端端的,为什么一编译就翻车

1.1 报错场景复盘

我之前在做一个带大量代码生成环节的 C++ 工程项目,底层协议基于 FlatBuffers,CMakeLists 里有好几个自定义命令负责调用flatc生成头文件和源文件。项目整体结构没动过,只是升级了一个底层库、把构建目录删掉重新配置了一遍,就出现了上面的报错。

刚开始我有点懵:重新配置后的CMakeCache.txt是新的,build.ninja也是新生成的,为什么会突然冒出"多输出"?我甚至怀疑是不是库里自带的 CMake 脚本在某个if分支里偷偷改了生成器逻辑。后来才意识到,之前我一直在用Unix Makefiles生成器开发,这次因为想加速构建切到了 Ninja,两种生成器对自定义命令的容忍度完全不同。换句话说:不是构建坏了,是 Ninja 从一开始就不认这种写法,只是之前没给它机会发现。

这个报错最反常的地方在于:configure 阶段完全正常,cmake --generate也能把build.ninja完整写出来,偏偏到了 Ninja 真正加载这个 manifest、把它转换成内部依赖图的时候才炸。原因很简单——CMake 只是"写文本",Ninja 才是"读文本并校验语义"的那一方。

1.2 遇到这种报错,第一件事不是改代码

我的建议是,先别急着动 CMakeLists。把报错里的行号当作最有价值的信息。build.ninja:1180说明第 1180 行定义的那条构建语句就是罪魁祸首。打开构建目录:

sed -n '1170,1200p' build/build.ninja

你会看到类似这样的内容:

build CMakeFiles/gen.dir/schema.h CMakeFiles/gen.dir/schema.cpp: CUSTOM_COMMAND /usr/local/bin/flatc ...

一个build语句后面跟了两个输出文件。在 Ninja 的语法层面,这其实是合法的,你能把 manifest 解析成功,所以 CMake 不会报错;但到了图构建阶段,Ninja 会直接拒绝这种 edge。搞清楚这一点,接下来就好办了:你要修的不是这个build.ninja,而是生成它的 CMakeLists。

2. 为什么 Ninja 对"多输出"如此抗拒:依赖图的底层逻辑

2.1 Ninja 的核心模型:一条边对应一个输出

Ninja 把一次构建建模成一个有向无环图(DAG)。图的节点是文件,图的边是"用某条命令从若干输入生成一个输出"的构建步骤。每条 edge 可以有很多输入(inputs),但只能有一个输出(output)。这是 Ninja 设计者明确做过的取舍。

我一开始以为这只是 Ninja 功能没做完,毕竟错误信息里还带了个(yet?),一副"以后可能支持"的口气。实际上是这个(yet?)在 Ninja 源码里躺了很多年,因为支持多输出会从根本上动摇它做增量判断的基石。Evan Martin 在设计文档里反复强调过一个原则:Ninja 的使命是快,省掉一切可能让构建系统变慢的抽象能力。多输出就是一种会让"判断是否需要重建"变得模糊的能力,所以它宁可让你编译失败,也不给你一个语义不清晰的图。

为了验证这一点,我直接去翻了 Ninja 的源码。在manifest_parser.cc里,解析器是允许build后面跟多个output的,但在graph.cc的Edge::Evaluate或者 state 加载流程里,只要发现edge->outputs_.size() > 1,就直接返回错误:

multiple outputs aren't (yet?) supported

这个错误不是一个语法错误,而是一个语义错误——Ninja 在读 manifest 时构建状态图,每发现一条多输出边就中止。

2.2 多输出会给增量判断带来什么样的混乱

要理解为什么 Ninja 这么倔,可以做一个思想实验。假设允许这样写:

build out1.php out2.php out3.php: RunCodeGen input.data

命令RunCodeGen执行一次,同时产生三个文件。那么问题来了:增量构建时,Ninja 该拿哪个文件的时间戳去判断这条边是否需要重新执行?

如果检查所有输出,只要其中一个文件比输入旧,就重跑命令——那命令一跑,三个文件全部更新,但另外两个原本已经是最新的文件也会被摸一遍,脏检查逻辑就没法做精确记录了。如果只检查第一个输出,那第二个文件被别的东西覆盖了,Ninja 根本无从得知。更麻烦的是,多个边缘可能共享同一个输出文件,这个输出文件又可能由不同的命令产生——整个 DAG 的确定性就崩了。

相比之下,GNU Make 对"一条规则的多个目标"也并没有真正优雅的处理。Make 的做法是把多目标拆成多条"目标-配方"边,理论上每个目标单独判断。但在实践中,如果这些目标各自带前置条件,Make 经常会在并行模式下重复执行同一个配方,或者在.PHONY和中间文件上产生诡异的依赖传播。Ninja 的态度很明确:与其让你在不够可靠的机制上构建复杂项目,不如直接对你喊停。

所以,别指望 Ninja"未来"会突然支持多输出。这种设计限制早就被社区的多个提案讨论过,但一直没有进入主分支,因为引入了它,Ninja 最大的卖点——"一个可以信任且可以被极端并行化调度的构建图"——就会变成一句空话。

3. 最常见的三种"多输出制造机":从手写 manifest 到第三方库

3.1 第一类:手写 build.ninja 时随手写出的多输出

这一种最直接,也最容易被发现。有些团队为了快速构建而不想引入 CMake、Meson 这类元构建系统,会直接手写build.ninja。写法上很容易就写成这样:

rule gen command = python gen.py --out $out build gen_a.h gen_b.h: gen input.proto

语法没错,但 Ninja 在图加载时当场就拒绝。这种场景下你的选择只有两个:要么改成单输出 + stamp 方案,要么用 Meson 这类自带"多输出自动转 stamp"能力的元构建系统。如果你在做类似bootstrap脚本,也要注意:Ninja 并不是一个"多写几个文件名就能多产出"的工具,它要的是每一条边在依赖图中有一张唯一的身份证——这个身份证就是一个输出文件。

3.2 第二类:CMake 的 add_custom_command 声明多个 OUTPUT

这是大多数普通项目会遇到的情况。举个例子,很多项目里都有类似 FlatBuffers、Protocol Buffers 这类代码生成器,一次命令同时生成.h和.cpp。很多人想当然地这么写:

add_custom_command( OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/schema.h ${CMAKE_CURRENT_BINARY_DIR}/schema.cpp COMMAND ${FLATC} --cpp ${CMAKE_CURRENT_SOURCE_DIR}/schema.fbs COMMENT "Generating FlatBuffers C++ sources" )

在Unix Makefiles生成器下,这套代码能正常工作;换成 Ninja,某些 CMake 版本会在生成build.ninja时把这条命令包装成内部 stamp,从而幸免;但如果你用的 CMake 版本不够新、或者这条命令出现在某些复杂的if分支 / 自定义 toolchain 里,CMake 就可能在build.ninja里直接生成一个两条输出同挂一条命令的裸规则,于是你就撞上了标题里的错误。

判断依据很简单:打开build.ninja找schema.h那一行。如果它和schema.cpp一起出现在同一个build语句的冒号左边,那就是没有被 CMake 的自动 stamp 机制接管。这种情况与其说是 CMake 的 bug,不如说是"CMake 为 Ninja 生成规则时对多输出的处理策略因版本而异"。

3.3 第三类:第三方库或工具链脚本注入的裸规则

最让人头疼的是这种——你明明在自己代码里搜不到任何多输出,可build.ninja里就是有几个文件被挂在了同一条 edge 上。这种坑往往来自你用FetchContent、add_subdirectory或包管理器引入的第三方模块,它们的 CMakeLists 是为 Make/Ninja 之外的设计不足的生成器写的,或者它们通过set(CMAKE_GENERATOR_RULES)之类的手段直接干预了生成器输出。

我印象比较深的一次,是某个依赖了 Conan 工具链的项目,Conan 注入了一段自定义命令,用来在配置阶段生成一整套构建脚本,OUTPUT一口气给了 6 个文件。在 CI 的 Ubuntu + Make 环境里一切正常,本地切到 Ninja 就立刻复现炸机。那一次排查花了一个多小时,因为问题根本不在我们自己的 CMakeLists 里——你只能一层层往FetchContent下面翻。

不论哪种类型,修复思路都是相通的,只是定位路径不同。下面我按实际排查顺序讲一遍完整链路。

4. 完整排查链路:从行号到根源的翻牌过程

4.1 第一步:确认报错行号对应的 edge

拿到build.ninja:1180之后,先别管1180是不是唯一凶手,Ninja 每次只会报它遇到的第一个非法 edge。把整个build.ninja里所有build语句的冒号左边数量扫一遍:

awk -F: '/^build /{n=gsub(/ /, " ", $1); if ($1 ~ / /) print NR": "$0}' build/build.ninja

这条命令对每个build行,检查冒号左边(也就是 $1)是否含有多个空格分隔的文件名,如果有,就打印行号和整条语句。这样能把你当前 manifest 里所有多输出候选一次性列出来。

4.2 第二步:从 edge 反查 CMake 定义

如果发现违法 edge 长这样:

build CMakeFiles/_generated.dir/generated_core.h CMakeFiles/_generated.dir/generated_core.cpp: CUSTOM_COMMAND ...

记住这两个输出文件名,然后去 CMakeLists 里搜。你可以用:

grep -rn "generated_core" --include="CMakeLists.txt" --include="*.cmake" .

把所有出现的位置都列出来。这里有个容易踩的细节:generated_core.h可能只是一个中间变量名的一部分,CMake 里经常用set(GEN_FILES a.h b.h)然后传给add_custom_command。所以 grep 时要连变量一起看。

4.3 第三步:判断这条命令能不能拆开

这一步是选择修复方案的关键。在把问题推到某个add_custom_command之后,我一般会问自己三个问题:

  • 这个命令是"一次执行、固定产出 N 个文件",还是"可以分别调用、各自产出独立文件"?
  • 这些产出文件是不是都被真正的编译单元引用?
  • 如果下一次调用命令时某个文件没变,命令重跑会不会造成破坏?

我碰到 FlatBuffers 时第一个问题就否定了——flatc --cpp就是一次产出.h和.cpp,没法优雅地拆成两条独立命令(硬要拆也能,但会让命令重复执行、甚至要在不同参数下各跑一遍,很蠢)。这时候不需要再多想,直接上 stamp 方案。

4.4 第四步:确认修复后不会留下隐藏的时间戳陷阱

很多人修完多输出之后,增量构建就"看起来正常了",结果改一次.fbs,改完发现头文件更新了但依赖头文件的某些目标没被触发,于是来来回回 clean。这就是 stamp 方案没做彻底的表现——我在下一章会专门把几个陷阱点列出来。

5. 修复方案:从 stamp 锚定到拆分成独立目标

5.1 首选方案:用一个 stamp 文件做锚点

stamp 方案的核心思想是:既然 Ninja 只认单输出,那我们就给这条命令一个"唯一的、可被图中节点唯一标识"的输出,即 stamp 文件。命令完成后的最后一步,用touch更新 stamp 的时间戳。所有下游依赖都挂在 stamp 上,真实产物则作为"副作用文件"存在于构建目录中。

add_custom_command( OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/flatbuffers.stamp COMMAND ${FLATC} --cpp -o ${CMAKE_CURRENT_BINARY_DIR} ${CMAKE_CURRENT_SOURCE_DIR}/schema.fbs COMMAND ${CMAKE_COMMAND} -E touch ${CMAKE_CURRENT_BINARY_DIR}/flatbuffers.stamp DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/schema.fbs COMMENT "Generating FlatBuffers headers and sources" ) add_custom_target(FlatBuffersGeneration DEPENDS ${CMAKE_CURRENT_BINARY_DIR}/flatbuffers.stamp)

然后在真正使用这些产物的目标上挂依赖:

add_library(engine_core ${SOURCES}) add_dependencies(engine_core FlatBuffersGeneration) target_include_directories(engine_core PRIVATE ${CMAKE_CURRENT_BINARY_DIR})

这样做的核心逻辑是:Ninja 的节点是flatbuffers.stamp,它判断"是否需要重建"时只需检查 stamp 和它的依赖(schema.fbs)的时间戳。只要.fbs比 stamp 新,Ninja 就会重新执行flatc,然后重新 touch stamp,下游目标也会被正确触发。

这个方案还有一个额外好处:如果你构建目录里有很多个这样的自定义命令,它们之间可以互相通过 stamp 串成链条,而不会因为文件名太多产生歧义。比如后续又来了一个脚本依赖schema.h 生成后再生成别的文件,你就让那个脚本依赖flatbuffers.stamp即可。

5.2 stamp 方案的三个隐蔽陷阱

先提醒你,stamp 方案写完之后一定要做三件事:

第一,命令执行顺序必须保证所有真实产物都在 touch 之前生成。如果你把touch放在flatc前面,或者脚本里某个分支会把touch提前执行,那么 stamp 会比产物新,下次构建时 Ninja 会认为任务已完成,而产物其实可能是旧版本的。最稳妥的做法是把touch放在COMMAND的最后一行,作为对整个流程的收尾。

第二,下游依赖必须挂在 stamp 上,而不是直接依赖真实产物文件。有人会想"我反正知道 schema.h 会生成,那就直接把 schema.h 挂在目标上",这样 Ninja 会尝试在图中查找能生成schema.h的 edge——虽然是存在的(真的产物文件),但你要确保这个 edge 是"唯一输出 schema.h 的那条边"。在 stamp 方案里,生成这条边的是flatc命令的一部分,而不是一条显式声明了OUTPUT schema.h的独立 edge,Ninja 可能无法正确把它建立为节点。说得直白点:Ninja 只认图里声明过的节点,擅自引用不在图中出现的产物,会让增量判断失真。

第三,如果项目里某个模块有多个消费方,不要给每个消费方各定义一个 custom target。你应该只定义一个 target(比如FlatBuffersGeneration),让所有消费方都add_dependencies到它。否则可能出现两个 target 同时依赖同一个 stamp,而其中一个在构建时删除了 stamp 文件再重建,另一个虽然在并行构建中不会直接被触发,但在下一次增量构建时会因为 stamp 缺失而重新执行,造成重复生成。我实际测试中遇到过这种"并行构建时没炸,第二次构建却炸了"的情况,原因就出在 target 定义冗余。

5.3 备选方案:命令可以拆分的话,拆成多段

如果那个多输出命令本身可拆,比如某个脚本支持--output-header和--output-source两个入口,你可以这么写:

add_custom_command( OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/schema.h COMMAND ${GENSCRIPT} --header -o ${CMAKE_CURRENT_BINARY_DIR} DEPENDS input.file ) add_custom_command( OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/schema.cpp COMMAND ${GENSCRIPT} --source -o ${CMAKE_CURRENT_BINARY_DIR} DEPENDS input.file )

这样图中的两个节点schema.h和schema.cpp各自有唯一的 edge,Ninja 可以正常追踪。代价是脚本会被执行两次,如果脚本本身有较大开销,或者中间要读同一个临时文件,效率会很差。而且如果脚本本质上是"一条命令不可分割",强行拆成两条只会让状态更乱。所以在第一章节我就强调过:选择方案之前,先判断命令的可拆分性。

5.4 下策:换生成器,什么时候能用、代价是什么

实在修复不了的紧急情况,可以暂时换回 Make 类生成器:

cmake -S . -B build-make -G "Unix Makefiles" cmake --build build-make -j16

GNU Make 对多目标规则的容忍度确实比 Ninja 高,但它不会解决根本问题,只是把多输出转换成了 Make 的"多目标同配方"模式。这种模式在 Make 里也不是完全可靠的,尤其当你使用-j并行构建、配合.NOTPARALLEL或中间文件规则时,会产生不少让人摸不着头脑的编译错误。所以我只把它当作出货前的应急手段,不推荐在任何长期维护的项目里这样用。

还有一个"非常便宜的尝试"值得先做:如果你确认多输出是从自己的add_custom_command来的,并且 CMake 版本比较老,先升级 CMake 并重新 configure。CMake 3.20 之后对 Ninja 生成器中多输出的 stamp 自动包装处理明显更完善,很多项目只是升级一下 CMake 就消失了这个报错。

5.5 修复后的验证方法

修复完,不要只在干净构建里跑一次就算完。我的标准验证流程是这样:

  1. 删除整个构建目录,重新 configure + build,确认能完整编译通过;
  2. 修改触发生成的输入文件(比如schema.fbs),再次 build,确认只有依赖它目标重建;
  3. 跑一次cmake --build build -j$(nproc),用全核并行构建验证没有竞态;
  4. 再修改一次没有任何关联的源文件,确认不会触发自定义命令,确保 stamp 的时间戳逻辑没有产生"误触发"。

如果这四步都通过,说明修复是稳的。我自己第一次修复时只做了第 1 步,结果第 2 步就暴露了 touch 位置不对的问题,差点以为 stamp 方案无效。

6. 这类报错给我留下的三个后遗症:更严谨的构建脚本习惯

6.1 写 add_custom_command 之前,先数一数命令会产出几个文件

现在我在 Review 别人代码的时候,凡是看到OUTPUT后面跟了两个以上文件,第一反应就是追问一句:"这条命令在 Ninja 下跑过吗?"如果没跑过,那就按前面说的流程处理。这不是洁癖,而是 Ninja 作为默认构建引擎已经成为大量 C++ 项目的标配,一个隐性多输出规则会让所有切到 Ninja 的同事当场吃瘪。

OUTPUT里不仅要注意显式的文件名,还要注意生成器表达式和变量展开。比如你写OUTPUT ${gen_headers},而gen_headers是一个 list,那么实际生成 manifest 时,这条命令就会变成多输出。这个问题在 CMake 配置阶段很难发现,因为gen_headers变量在if分支里可能只在一个分支被填充了多个元素。

6.2 依赖关系的挂载,能挂 stamp 就不挂产物文件

这是一个习惯问题。很多 CMake 新手遇到代码生成,脑子里第一反应是"把生成头文件加到 include 目录,再把头文件挂到 target 上"。这在 Make 时代勉强能跑,Ninja 时代就容易踩到各种 "suspicious dependency" 之类的隐藏问题。我现在会默认所有代码生成器输出一个 stamp,然后用一个add_custom_target包装,让 target 之间通过依赖这个 target 来串联。虽然多写几行,但稳定性和可读性都明显更好。

6.3 构建问题不只盯着自己的代码,也要学会翻"第三方注入的规则"

回看这次踩坑,最大的教训是:报错发生在build.ninja,但根源未必在你的 CMakeLists。排查时第一直觉不应该是"我之前没问题啊",而应该先想到"生成这个 build.ninja 的所有输入都变了什么"。升级一个库、换一个包管理器、改一个 generator,都可能导致之前被掩盖的规则问题浮出水面。把这个思维刻进习惯之后,我现在定位构建问题比以前快得多——先看 manifest,再翻规则,最后动逻辑,而不是一头扎进代码里找不到北。

最后分享一个很小但很实用的技巧:如果你手头有多个生成规则要重构,别一次性把 CMakeLists 全改了。只改导致报错的那一个,重新构建确认通过后,再用awk脚本扫描一次 build.ninja,确认没有其他多输出 edge 还藏在角落里。那次我就是改了 flatbuffers 这条之后,又扫出来一个第三方库的明细规则,幸好提前发现了。Ninja 给你的错误信息一向很"话少",但它其实已经把所有罪犯都按顺序列在位了,只是等你把注意力放到对的源头。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询