依赖发现:Depfile 与 Deps Log

C/C++ 编译有个根本难题:一个 .c 文件依赖哪些头文件,编译前并不知道——它取决于 #include 链,要等预处理器实际跑一遍才清楚。这些”发现出来的”依赖叫隐式依赖,是构建正确性的关键:改了某个深层头文件,所有间接 include 它的目标都得重建。

本文讲 Ninja 怎么采集、压缩、回填这些隐式依赖。这也是 Ninja 增量构建快的核心机密之一。前置阅读:增量构建

1. 两种采集机制

手册(doc/manual.asciidoc:771)描述了两条路径:

  • deps = gcc:命令里让编译器写出 Makefile 语法的 .d depfile(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::Parsedepfile_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 写入

  • OpenForWritedeps_log.cc:51)惰性打开,需要时先触发 RecompactOpenForWriteIfNeededdeps_log.cc:424)以 "ab" 追加打开,用 setvbuf 设一个能容纳整条记录的缓冲并每条 flush,保证记录不会被写一半。
  • RecordIddeps_log.cc:390)写路径记录:把路径补齐到 4 字节,写 size、路径、补齐、再写 checksum = ~(unsigned)id,给节点分配 id。
  • RecordDepsdeps_log.cc:63)写依赖记录:给缺 id 的节点先 RecordId;若依赖未变则跳过;否则 size |= 0x80000000 标记为依赖记录,写出输出 id、mtime 低/高 32 位、各输入 id。

3.3 读取与恢复

Loaddeps_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() 索引返回。重整 Recompactdeps_log.cc:326)只保留活条目(IsDepsEntryLiveFor:节点有 in_edge 且规则带非空 depsdeps_log.cc:369)。

4. 回填进构建图:ImplicitDepLoader

采集到的依赖要变成构建图里的隐式输入,才能参与 脏检查。这由 ImplicitDepLoadergraph.h:286)完成。

LoadDepsgraph.cc:632)分流:规则有 deps 绑定 → LoadDepsFromLog(从 .ninja_deps);否则有 depfile 绑定 → LoadDepFile(直接读 .d)。

  • LoadDepFilegraph.cc:656):读 depfile(缺失时返回 false 但 err 为空 → 当作”需重建”),DepfileParser 解析,校验主输出与 edge->outputs_[0] 匹配,然后 ProcessDepfileDeps
  • ProcessDepfileDepsgraph.cc:721):每个输入 CanonicalizePathstate_->GetNode → 填入预留槽位 + AddOutEdge
  • LoadDepsFromLoggraph.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;

PreallocateSpacegraph.cc:775)用同样的技巧预留空槽。回填后这些节点就是普通隐式输入:某个头文件 mtime 比产物新,就会通过标准 mtime 比较让产物变脏。

5. 依赖采集的落盘时机

构建执行流程 §5 里提过,FinishCommand 命令跑完后第一步就是 ExtractDepsbuild.cc:1003):

  • msvcCLParser::Parse 解析 /showIncludes,结果 state_->GetNode 成节点。
  • gcc:读 depfile,DepfileParser::Parse,每个输入 canonicalize + GetNode,然后删除 .d(除非 -d keepdepfile)。

抽取出的节点连同输出的新 mtime 一起 RecordDeps 落进 .ninja_depsbuild.cc:987),deps_mtime = Stat(output)build.cc:991)正是后续时效检查的比对基准。

6. clparser:解析 MSVC /showIncludes

CLParserclparser.h:25)专门处理 MSVC 输出。

  • FilterShowIncludesclparser.cc:44):默认前缀 "Note: including file: ";命中则剥前缀和前导空格返回路径。
  • IsSystemIncludeclparser.cc:61):路径含 “program files” 或 “microsoft visual studio” 的系统头被丢弃,缩小依赖规模。
  • Parseclparser.cc:80):逐行过滤,命中的归一化后进 includes_,其余(编译器正常输出)追加到 filtered_output 以便仍展示给用户。

7. missingdeps 工具:揪出未声明依赖

-t missingdepsmissing_deps.cc)用来发现”用了但没在构建图里声明路径”的依赖——这类隐患会导致偶发的构建不一致。

MissingDependencyScanner::ProcessNodemissing_deps.cc:78)递归处理节点,对带 deps 的边取 deps log、对带 depfile 的边用一个特殊的 NodeStoringImplicitDepLoadermissing_deps.cc:32,只收集不改图)。ProcessNodeDepsmissing_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。


本站访问量

This site uses Just the Docs, a documentation theme for Jekyll.