第一次撞上这个报错是在一个周五下午。项目用的是 CMake + Ninja,配置阶段一切正常,cmake --build .刚跑起来不到两秒就崩了,终端里孤零零甩了一行:
ninja: error: build.ninja:1180: multiple outputs aren't (yet?) supported说真的,这可能是 Ninja 那句报错里最有“求生欲”的文本了——(yet?)带问号,官方自己都没把话说完。它的大致含义是:构建脚本里有一条规则生成了多个输出文件,但当前这种写法(或者当前版本的 Ninja)不支持这么干。这篇内容就是把遇到这个报错的人需要知道的东西一次性讲透:它为什么会发生、在什么场景下高发、以及在 CMake、手写 build.ninja、CI 等不同环境里应该怎么处理最稳妥。
1. 先认识这个报错:Ninja 构建模型中的多输出规则
1.1 Ninja 是什么,为什么生成器都爱用它
Ninja 是一个极简构建系统,核心卖点就一个字:快。它和 Make 最大的区别是设计哲学完全不同:Make 自带一堆隐式规则、自动推导、内置函数,试图让使用者直接手写构建规则;而 Ninja 刻意不做复杂推导,它假定build.ninja完全由更上层的生成器(CMake、Meson、GN 等)产出,所以自己可以写得非常朴素,启动速度、解析速度、并行调度效率都做到了极致。
这种“上层生成器写规则,Ninja 只负责执行”的模式在大型 C/C++ 项目里非常吃香,尤其是 Chromium、LLVM 这类动辄几万源文件的项目。你在 Linux 上跑cmake -G Ninja,本质上是让 CMake 根据你的CMakeLists.txt生成一份build.ninja,然后 Ninja 按这份文件把编译任务拆成一个个并行 job 跑完。
理解了这一点,你就能明白这个报错为什么让人头疼:Ninja 报错,问题往往不一定在 Ninja 本身,而在生成它的 CMakeLists,或者你用某些工具导出的构建描述文件上。
1.2 rule 的多输出语法:合法但有限制
Ninja 的构建规则由两部分组成:rule定义“怎么把输入变成输出”,build语句声明“哪个输入对应哪个输出”。一个build语句写多个输出,在语法层面是完全合法的,比如下面这段:
rule gen command = python gen.py $in $out build generated.cpp generated.h: gen template.dat$out会被展开成generated.cpp generated.h,生成脚本一次性吐出两个文件。这是非常典型的代码生成场景:一个脚本根据一份模板,同时生成源文件和头文件。
但是,Ninja 虽然允许这种多输出语法,却有一条隐含红线:rule 上不能同时挂某些“辅助机制”。这些机制包括:
depfile:指定依赖文件路径,Ninja 通过它读取精确的头文件依赖deps:指定依赖模式(gcc/msvc),让 Ninja 直接从编译器输出解析依赖rspfile:响应文件,用于绕过命令行长度限制
一旦多输出和这些机制组合出现,Ninja 就会在解析阶段直接报错,而不是等构建开始后才翻车。这正是build.ninja:1180: multiple outputs aren't (yet?) supported这句话的由来。
1.3 真正触发报错的开关:depfile 与多输出的冲突
为什么 depfile 和多输出水火不容?这里得稍微掰开讲一下原理。
depfile(或deps)是 Ninja 用来做“头文件级精确依赖”的机制。规则执行后,编译器会额外产出一个.d文件,里面写着“本次编译实际用到的所有头文件”。有了这份清单,当你修改了一个头文件,Ninja 就能精确判断哪些目标需要重建,而不是简单粗暴地全部重编。
问题在于:这一整套机制隐含一个假设——一条规则只有一个主输出。当一条build语句同时输出a.h和a.cpp,又在 rule 里写了depfile = $out.d,$out展开后变成了a.h a.cpp,于是 depfile 路径就变成了a.h a.cpp.d——这显然不是编译器会产出的文件名,也没法解析。Ninja 在解析阶段看到这种组合,直接判定为“不支持”,干脆拒绝往下走。
报错文本里的(yet?)就是官方对这件事的表态:他们知道这个缺口,但设计上始终没找到优雅的解法。Ninja 的哲学本来就是“构建文件由生成器管理,生成器应该避免写出这种歧义组合”,所以宁可让它报错,也不做模糊处理。
2. 拿到报错别慌:三步定位 build.ninja 的问题行
2.1 还原现场:先看报错行附近到底写了什么
报错里的1180是build.ninja里的行号,说明 Ninja 是在解析阶段失败的,根本没走到构建那一步。第一件事就是把那行附近的内容拉出来看:
sed -n '1170,1190p' build.ninja如果你嫌行号不好数,也可以加个行号前缀:
awk 'NR>=1175 && NR<=1185{print NR": "$0}' build.ninja窗口最好多带几行上下文,因为错误可能在第 1180 行的build语句,也可能在这条语句上面的rule定义里。把 rule 和 build 放在一起看,问题基本就清楚了。我那次看到的场景长这样:
rule generate_headers command = python gen.py $in $out depfile = $out.d deps = gcc build generated.h generated.cpp: generate_headers config.yaml一眼就能确诊:rule 里挂着depfile和deps,build 语句却给了两个输出。
2.2 判断问题类型:多输出、重复输出还是规则冲突
从实际踩坑经历来看,报这种错的现场可以分成几类,你可以按下面的顺序快速定位自己的情况:
- 形态 A:rule 带
depfile/deps,build 带多输出。这是最经典的来源,基本就是 1.3 节说的冲突,直接看 rule 定义就能确认。 - 形态 B:Ninja 版本太旧,不认“多输出”这种写法。老版本对构建语法支持不完整,也会抛同样的错误文本。确认方法很简单,跑
ninja --version看版本号,低于 1.9 的就要警惕了。 - 形态 C:构建描述文件不是 CMake 生成的,而是某些脚本或工具导出的,其中混入了不合法的多输出结构。手动写的
build.ninja偶尔也会踩中,但相对少见。
定位的时候有个小技巧:用grep -n "output" -A 5 -B 5 build.ninja直接搜“输出”相关关键字,或者在编辑器里带行号打开文件快速跳转。Ninja 报错给的行号通常是很准的,不用怀疑它在骗你。
2.3 和两个容易混淆的报错做区分
排查这个报错的时候,很多人在网上搜索,容易把另外两个相似报错混进来,这里顺手帮大家理一下:
| 报错文本 | 真实含义 | 触发场景 |
|---|---|---|
multiple outputs aren't (yet?) supported | 单条规则输出多文件,且与 depfile/deps 等机制组合,被 Ninja 判定为不支持 | 多输出 + depfile、旧版本 Ninja |
multiple rules generate ... | 多个规则声称生成同一个输出文件,构建图冲突 | 两个 build 语句写了同一个输出路径 |
ninja: error: loading 'build.ninja': ... | 文件本身格式错误、路径缺失或编码问题 | 手写文件抽风,或 CMake 生成被中断 |
这三个错误表面看起来都带 “build.ninja” 和行号,但排查方向完全不同。第一个聚焦 rule 定义,第二个聚焦输出路径重复,第三个则是文件层级的读取出问题。先分清是哪一类,能省下大量瞎折腾的时间。
3. 四个解决方案:从临时绕过到彻底修复
3.1 先升级 Ninja 版本:最便宜的一步
遇到这种报错,我建议的第一动作永远是看一眼 Ninja 版本:
ninja --version如果你用的版本偏老,比如 Ubuntu 18.04 自带的 1.8.2、20.04 自带的 1.9.0,升级到新版本很可能直接就解决了。Ninja 虽然整体设计没大变,但对多输出 + 构建图边界情况的处理一直在补。
- Ubuntu / Debian:系统自带的可能不是最新版,建议从 GitHub releases 下载官方二进制,或加工具链源。简单省事的话先试
apt install ninja-build,看版本够不够用。 - macOS:
brew install ninja拿到的版本通常比较新。 - Windows:官方直接给
ninja-win.zip,解压扔进 PATH 就行。如果用 Python 生态,pip install ninja也可以,装完记得确认 PATH 里哪个 ninja 生效。 - 如果项目有 CI:优先统一到一个已知没问题的版本,别让开发机和新版、CI 和老版互相打架。
升级完重新跑一次原来的构建命令不用重新生成,Ninja 会直接重新解析build.ninja,如果报错消失,说明就是版本兼容问题。这种情况下就不用动 CMakeLists 了,省心。
3.2 临时改 build.ninja:应急但不治本
有时候项目正在赶工期,不能立刻改 CMakeLists 再重新生成,那就需要一个“先让编译过去”的应急手段。直接手改build.ninja是可行的,但要记住一个前提:build.ninja是生成物,只要你重新跑一次 CMake,所有手改都会消失。所以只适合现场急救,不适合作为长期方案。
应急改法之一是“拆多输出为多次调用”:
build generated.h: generate_headers config.yaml build generated.cpp: generate_headers config.yaml但注意,这样写会让生成脚本被执行两次。如果脚本不是天然的幂等操作(比如它会清空目录再生成,一次执行清掉了另一次的输出),就会踩出新坑。所以更稳妥的临时法是把次要输出“挂”到主输出上,借助 phony 别名:
build generated.cpp: generate_headers config.yaml build generated.h: phony generated.cpp这样 Ninja 实际只会跑一次生成命令,但generated.h也成为了一个可被其他规则依赖的合法目标。这个方案保留的“主输出”必须是脚本真实会生成的文件,而 phony 目标本身不产生文件,只是构建图里的一个别名节点。
3.3 CMake 项目里根治:拆分 custom command
如果你用的是 CMake,问题的根源通常出在add_custom_command上。最常见的翻车写法是这样:
add_custom_command( OUTPUT generated.h generated.cpp COMMAND python ${CMAKE_SOURCE_DIR}/tools/gen.py ${CMAKE_CURRENT_BINARY_DIR}/generated.h ${CMAKE_CURRENT_BINARY_DIR}/generated.cpp DEPENDS config.yaml DEPFILE generated.d )OUTPUT给了两个文件,又加了DEPFILE,CMake 生成的build.ninja里就会带上 depfile 相关的 rule,正好踩中 Ninja 的限制。
针对这种情况,有几个根治方向:
方向一:去掉 DEPFILE。如果生成脚本本身只依赖DEPENDS里列出的输入文件,根本不需要 depfile 做额外的精确依赖。把DEPFILE那一行删掉,让多输出保持最简单形态,Ninja 就能接受了。这是最省事的方案。
方向二:把多输出拆成多个 custom command。一个命令只产出一个文件,命令之间通过DEPENDS串联:
add_custom_command( OUTPUT generated.h COMMAND python gen.py --header generated.h DEPENDS config.yaml ) add_custom_command( OUTPUT generated.cpp COMMAND python gen.py --source generated.cpp DEPENDS generated.h )代价是生成脚本可能被调用两次,如果生成过程比较重(比如每次要解析大模板),这个成本需要考虑。通常我会让脚本支持只生成一个文件,或者自带缓存。
方向三:只留一个主输出,其余进 BYPRODUCTS。BYPRODUCTS从 CMake 3.2 开始可用,它表示“这个命令顺带会生成的文件”。CMake 在生成构建图时会把主OUTPUT作为主要驱动目标,BYPRODUCTS作为附加输出参与依赖计算。但要注意:主输出文件必须是命令真实会生成的,如果脚本没有生成它,Ninja 会认为目标缺失导致错误。所以如果脚本坚持一次写两个文件,可以额外 touch 一个 stamp 文件当主输出:
add_custom_command( OUTPUT generated.stamp COMMAND python gen.py generated.h generated.cpp COMMAND ${CMAKE_COMMAND} -E touch generated.stamp BYPRODUCTS generated.h generated.cpp DEPENDS config.yaml )这样既保留了“一次生成两个文件”的高效,又让 Ninja 只围绕一个主输出做构建判断,绕开了多输出限制。
3.4 手写 ninja 文件时的替代设计:主输出 + phony
如果你维护的是手写的build.ninja(比如嵌入式项目、游戏管线等场景),设计生成规则时有几个原则可以提前规避这类问题:
原则一:能不挂 depfile 就不挂。自定义生成脚本的输入依赖往往在build语句里显式写出来就够了。只有当你确实需要追踪“脚本内部再包含的头文件”时才有必要上 depfile,而这种场景建议重新审视设计。
原则二:用 phony 做聚合层。让真实的规则只生成一个主文件,所有实际需要“那一堆输出文件”的目标都依赖这个主文件,或者依赖包了一层的 phony:
rule generate command = python gen.py generated.h generated.cpp build generated.stamp: generate command = python gen.py generated.h generated.cpp && touch generated.stamp build generated.h: phony generated.stamp build generated.cpp: phony generated.stamp写这套的时候,关键是理解 Ninja 的依赖传播:其他规则如果依赖generated.h,Ninja 会顺着 phony 链找到generated.stamp,在generated.stamp缺失或过时时先执行生成命令,然后认为generated.h也就绪了。
原则三:手动改build.ninja应急后,一定要在项目里留文档。我见过太多人改了生成文件又不记录,两周后同事重新生成,又踩同一个坑,然后一脸懵地来问你。任何非常规操作都值得写进 README 或 Makefile 注释里。
4. 常见问题与避坑经验
4.1 踩坑速查表
把这次排查过程中常见的组合整理成一张表,方便你按图索骥:
| 场景 | 现象 | 直接原因 | 最优处理 |
|---|---|---|---|
| CMake + add_custom_command | 多输出 + DEPFILE | depfile 绑定多个输出,Ninja 解析拒绝 | 去掉 DEPFILE,或拆命令 |
| 老版本 Ninja | 同样的错误,升级后消失 | 语法支持欠缺 | 升级到 1.10+ |
| 手写 build.ninja | rule 里 $out 被展开成多个路径 | 命令内部依赖 $out 做文件名推导 | 显式传参,不依赖 $out 的隐式展开 |
| 两个 build 语句写同一输出 | multiple rules generate | 构建图冲突 | 删除重复规则,同名输出只留一处 |
| 生成脚本有副作用 | 拆命令后重复执行,产物被清掉 | 脚本不是幂等的 | 采用 stamp 主输出方案 |
4.2 调试 Ninja 构建图的两个实用技巧
排查这类构建问题,光看报错文本往往不够,Ninja 自带的一些调试手段能帮你大幅缩小范围。
技巧一:用ninja -t targets看构建图全貌。-t是 Ninja 的 tool 模式,ninja -t targets all会把当前build.ninja里所有目标列出来。如果你怀疑是某个目标被多个规则重复生成,这个命令能帮你快速发现冲突。针对某个具体文件,还可以用ninja -t query 目标路径查看它的生成规则和依赖关系。
技巧二:用ninja -d explain -n理解 Ninja 的判断逻辑。-d explain会让 Ninja 打印“为什么重新构建/为什么跳过”的决策过程,-n是先不实际执行。组合起来就是干跑一遍,看它在想什么。比如它可能会告诉你某个目标“missing”或“dirty”,这样你就知道构建图哪里剪不断理还乱。
另外强烈建议在调试时加-v参数,让 Ninja 打印每个实际执行命令。很多多输出问题其实出在$out展开不符合预期上——因为你以为它只传了第一个输出,实际上它把所有输出都拼在命令行里了。看到真实命令,你会少很多猜测。
4.3 一句实战心得
在我后来维护项目时,给自己定了一条硬规矩:自定义生成命令只允许产出一个主产物,其他文件一律当副产物,如果脚本实在做不到,就额外生成一个 stamp 文件当主产物。这个习惯让 CMake、Meson、Ninja 各生成器都少踩了很多坑。
顺带一提,这类构建问题看着吓人,其实都是设计问题,不是能力问题。Ninja 那句(yet?)说到底是在提醒你:绕开它也许比等它更新更靠谱。如果你的项目同样被这个报错卡过,希望这份排查清单能帮你把时间省下来,早点回家。