依赖发现:Depfile 与 Deps Log
C/C++ 编译有个根本难题:一个 .c 文件依赖哪些头文件,编译前并不知道——它取决于 #include 链,要等预处理器实际跑一遍才清楚。这些”发现出来的”依赖叫隐式依赖,是构建正确性的关键:改了某个深层头文件,所有间接 include 它的目标都得重建。
本文讲 Ninja 怎么采集、压缩、回填这些隐式依赖。这也是 Ninja 增量构建快的核心机密之一。前置阅读:增量构建。
1. 两种采集机制
手册(doc/manual.asciidoc:771)描述了两条路径:
deps = gcc:命令里让编译器写出 Makefile 语法的.ddepfile(gcc -MD -MF $out.d ...),Ninja 编译后立刻解析它,然后删掉这个临时文件。deps = msvc:解析 MSVC 编译器/showIncludes打到 stdout 的头文件行,无需 depfile;msvc_deps_prefix指定那行的前缀(默认英文Note: including file:)。
为什么不直接留着 depfile 反复用?手册(doc/manual.asciidoc:771)说得很直白:大项目里(尤其 Windows 文件系统慢)启动时加载成千上万个 .d 文件太慢。Ninja 的做法是命令一跑完就处理依赖,并存进紧凑的二进制 .ninja_deps,下次启动一次读完。
2. Depfile 解析
depfile 解析器同样由 re2c 生成:depfile_parser.in.cc(源)→ depfile_parser.cc(生成,首行 /* Generated by re2c */)。
接口 DepfileParser::Parse(depfile_parser.h:35)有个重要约定:输入必须 NUL 结尾,解析会就地改写 content,结果 outs_/ins_ 里的 StringPiece 直接指向它(depfile_parser.h:32)——零拷贝。
解析逻辑(depfile_parser.in.cc:47)处理的是编译器实际产出的 Makefile 子集(不是完整 GNU Make 语法),关键是反斜杠转义规则(注释见 depfile_parser.in.cc:27):
| 输入 | 含义 |
|---|---|
\ | 转义空格(路径里的空格) |
\# | 转义 # |
\: | 转义冒号 |
$$ | 字面 $ |
\ + 换行 | 续行 |
2N+1 个 \ + 空格 | N 个 \ + 真空格 |
冒号前是目标(进 outs_),冒号后是依赖(进 ins_),都做去重(depfile_parser.in.cc:186)。没有目标且输入非空时报 “expected ‘:’ in depfile”。
3. Deps Log(.ninja_deps)二进制格式
这是 Ninja 最有特色的设计之一。deps_log.h 顶部有一大段设计说明(deps_log.h:29)值得一读,要点:
- 两个约束:(1) 构建过程中要能作为流追加写入(构建可能中途被打断);(2) 启动时一次性全读。
- 放弃索引格式:”考虑过带索引的格式,但实现太复杂;如果文件不大,启动时整读是可接受的”(deps_log.h:33)。
- 字符串驻留压缩:Chrome 的依赖文本累加达 90MB,但约 4 万个唯一路径只有 2MB 唯一字符串(deps_log.h:40)。所以路径只存一次、用密集整数 id 引用。
- 后写覆盖:”若两条记录引用同一输出,后者生效,于是更新只需追加;偶尔跑一次重整去掉死记录”(deps_log.h:65)。
3.1 二进制布局
签名 # ninjadeps\n(12 字节)+ 版本号 int32(值 4,兼作字节序标记),共 16 字节头(deps_log.cc:36),之后是追加的记录流。每条记录以 4 字节小端长度字开头,最高位区分类型:
文件:
+-----------------------------+
| "# ninjadeps\n" (12 字节) | 签名
| version int32 (4 字节) | = 4
+-----------------------------+
| record | record | record ...| 追加的记录流
+-----------------------------+
每条记录开头 4 字节小端长度字:
bit 31 (最高位) = 记录类型: 1 = 依赖记录, 0 = 路径记录
bits 0..30 = 负载字节数 (<= kMaxRecordSize)
路径记录 (最高位 = 0):
+----------------------------------------------------+
| size (4B, 最高位 0) |
| 路径字节 (path_size) |
| 0..3 字节 NUL 补齐到 4 字节对齐 |
| checksum (4B) = ~id (期望记录序号的反码, |
| 用于检测多进程并发写入冲突) |
+----------------------------------------------------+
依赖记录 (最高位 = 1): 全是 4 字节整数
+----------------------------------------------------+
| size (4B, 最高位 1) |
| [0] 输出路径 id (int32) |
| [1] 输出 mtime 低 32 位 (uint32) |
| [2] 输出 mtime 高 32 位 (uint32) |
| [3..] 输入路径 id, 输入路径 id, ... (int32) |
+----------------------------------------------------+
负载 size = 4 * (1 + 2 + node_count)
3.2 写入
OpenForWrite(deps_log.cc:51)惰性打开,需要时先触发Recompact。OpenForWriteIfNeeded(deps_log.cc:424)以"ab"追加打开,用setvbuf设一个能容纳整条记录的缓冲并每条 flush,保证记录不会被写一半。RecordId(deps_log.cc:390)写路径记录:把路径补齐到 4 字节,写 size、路径、补齐、再写checksum = ~(unsigned)id,给节点分配 id。RecordDeps(deps_log.cc:63)写依赖记录:给缺 id 的节点先RecordId;若依赖未变则跳过;否则size |= 0x80000000标记为依赖记录,写出输出 id、mtime 低/高 32 位、各输入 id。
3.3 读取与恢复
Load(deps_log.cc:154)顺序 fread 到栈缓冲(char buf[kMaxRecordSize + 1],无 mmap)。校验签名和版本(!=4 则删除日志、触发重建)。主循环读每条记录的 size,is_deps = (size>>31),按类型解析。路径记录会校验 ~checksum == 期望 id,依赖记录会校验每个输入 id 都是已知节点。若中途读坏,把文件截断到最后一条完整记录并以警告报出(deps_log.cc:273)——这就是”构建被打断也不会损坏日志”的恢复机制。
Deps 结构(deps_log.h:80)很简单:TimeStamp mtime; int node_count; Node** nodes;。GetDeps(Node*)(deps_log.cc:305)按 node->id() 索引返回。重整 Recompact(deps_log.cc:326)只保留活条目(IsDepsEntryLiveFor:节点有 in_edge 且规则带非空 deps,deps_log.cc:369)。
4. 回填进构建图:ImplicitDepLoader
采集到的依赖要变成构建图里的隐式输入,才能参与 脏检查。这由 ImplicitDepLoader(graph.h:286)完成。
LoadDeps(graph.cc:632)分流:规则有 deps 绑定 → LoadDepsFromLog(从 .ninja_deps);否则有 depfile 绑定 → LoadDepFile(直接读 .d)。
LoadDepFile(graph.cc:656):读 depfile(缺失时返回 false 但 err 为空 → 当作”需重建”),DepfileParser解析,校验主输出与edge->outputs_[0]匹配,然后ProcessDepfileDeps。ProcessDepfileDeps(graph.cc:721):每个输入CanonicalizePath→state_->GetNode→ 填入预留槽位 +AddOutEdge。LoadDepsFromLog(graph.cc:740):取deps_log_->GetDeps(output),做 §3.3 的 mtime 时效检查(增量构建 §5.2),有效则插入节点。
4.1 插入位置维持三段不变量
回填的隐式依赖必须插在”隐式段之后、order-only 段之前”,否则 构建图模型 §2.1 的下标算法就错了。LoadDepsFromLog 的插入(graph.cc:766):
edge->inputs_.insert(edge->inputs_.end() - edge->order_only_deps_,
nodes, nodes + node_count); // 插在 order-only 尾段之前
edge->implicit_deps_ += node_count;
PreallocateSpace(graph.cc:775)用同样的技巧预留空槽。回填后这些节点就是普通隐式输入:某个头文件 mtime 比产物新,就会通过标准 mtime 比较让产物变脏。
5. 依赖采集的落盘时机
在 构建执行流程 §5 里提过,FinishCommand 命令跑完后第一步就是 ExtractDeps(build.cc:1003):
msvc:CLParser::Parse解析/showIncludes,结果state_->GetNode成节点。gcc:读 depfile,DepfileParser::Parse,每个输入 canonicalize +GetNode,然后删除.d(除非-d keepdepfile)。
抽取出的节点连同输出的新 mtime 一起 RecordDeps 落进 .ninja_deps(build.cc:987),deps_mtime = Stat(output)(build.cc:991)正是后续时效检查的比对基准。
6. clparser:解析 MSVC /showIncludes
CLParser(clparser.h:25)专门处理 MSVC 输出。
FilterShowIncludes(clparser.cc:44):默认前缀"Note: including file: ";命中则剥前缀和前导空格返回路径。IsSystemInclude(clparser.cc:61):路径含 “program files” 或 “microsoft visual studio” 的系统头被丢弃,缩小依赖规模。Parse(clparser.cc:80):逐行过滤,命中的归一化后进includes_,其余(编译器正常输出)追加到filtered_output以便仍展示给用户。
7. missingdeps 工具:揪出未声明依赖
-t missingdeps(missing_deps.cc)用来发现”用了但没在构建图里声明路径”的依赖——这类隐患会导致偶发的构建不一致。
MissingDependencyScanner::ProcessNode(missing_deps.cc:78)递归处理节点,对带 deps 的边取 deps log、对带 depfile 的边用一个特殊的 NodeStoringImplicitDepLoader(missing_deps.cc:32,只收集不改图)。ProcessNodeDeps(missing_deps.cc:111)对每个发现的依赖找其生成边,若 !PathExistsBetween(generator, consumer)(missing_deps.cc:174,带记忆化的可达性判断)就报告一处缺失依赖。build.ninja 本身被特殊豁免(视为全局重配依赖)。
8. 小结:行号速查
| 主题 | 位置 |
|---|---|
| 设计说明(90MB→2MB) | deps_log.h:29 |
| depfile 接口(就地改写) | depfile_parser.h:35 |
| depfile 解析逻辑 | depfile_parser.in.cc:47 |
| deps log 头/常量 | deps_log.cc:36 |
RecordDeps / RecordId | deps_log.cc:63 / deps_log.cc:390 |
Load(恢复/截断) | deps_log.cc:154(:273) |
Deps 结构 / GetDeps | deps_log.h:80 / deps_log.cc:305 |
LoadDepsFromLog(时效检查) | graph.cc:740(:756) |
| 回填插入点 | graph.cc:766 |
ExtractDeps / RecordDeps 调用 | build.cc:1003 / build.cc:987 |
CLParser | clparser.cc:79 |
missingdeps | missing_deps.cc:78 |
下一篇 子进程与并行调度 讲命令到底是怎么并行跑起来的,以及动态依赖 dyndep。