如果你自己动手写过编译器,或者只是好奇地打开过 Rust、Swift 这类现代编译器的源码工程,你一定会被它们内部一层又一层的 IR 搞晕。Parser 先吐出一棵 AST,然后编译器内部又悄悄把它变成 HIR,再往下还有 TIR(Rust 里叫 MIR),最后才落到 LLVM IR,再折腾成机器码。新手的第一反应基本都是:这不多此一举吗?AST 不已经代表整个程序了吗?直接把 AST 翻译成机器码不就行了?
另一个让不少搞构建系统的同学很头疼的问题是:CI 里明明配了编译缓存,可为什么换台机器、换个工作目录、甚至只是 checkout 时间变了,缓存就疯狂失效?这背后牵扯的其实是“缓存用什么当 Key”的设计问题——是按文件路径和修改时间,还是按内容本身寻址。
这两个问题,一个在编译器前端,一个在构建系统,表面看毫不相干,但本质是同一个母题:一个语义多且杂的系统,如何用清晰的中间表示和可靠的寻址方式组织起来,才能既保证正确性,又保证效率和可维护性。这篇博文就把这两件事完全讲透:IR 为什么非要分 AST、HIR、TIR 三层,以及缓存为什么一定要按内容寻址。适合正在写编译器、研究编译器源码,或者维护大型构建系统的同学参考,我会尽量用大白话把设计动机拆开,同时给出可以直接落地的实践细节。
1. 三层 IR 到底分什么:语法、语义、实现三件事不能混
1.1 AST 只负责“源码长什么样”,不负责“源码在干什么”
AST(Abstract Syntax Tree,抽象语法树)是 parser 的产物。它的目标是忠实还原源码的语法结构,每个节点对应语言文法里的一条产生式。一个if是一个 IfExpr,一个for是一个 ForLoop,一个加法运算是一个 BinaryOp,括号、分号这类纯书写成分会被丢掉,但语法意义上的结构分毫不少。
问题也出在这:AST 太贴着源码了。它表达的是“代码看起来是什么样子”,而不是“代码实际上在做什么”。举个很简单的例子:
for i in 0..10 { a[i] += 2; }在 AST 里这就是一棵“循环树”,嵌套了条件判断、索引表达式、赋值操作。如果我想直接从这棵树生成机器码,我就要为for单独写一套完整的指令选择规则,为..区间语法写一套生成规则,再为+=写一套复合赋值规则。这还只是三四个语法结构,如果把闭包、模式匹配、async/await、泛型、解构赋值这种复杂语法全算上,前端要向指令选择层暴露的映射规则会膨胀到根本无法维护。
更麻烦的是语义信息的缺失。AST 上通常没有“这个变量的类型是什么”“这行代码属于哪个作用域”“这个表达式是左值还是右值”“这个值移动了几次”这类关键语义。你想做类型检查、所有权检查、生命周期分析,AST 给不了足够的支撑。它天生是给语法层面的工具用的——格式化、高亮、补全、重构、宏展开,这些场景需要的是“结构守恒”,而不是“语义简化”。
1.2 HIR 脱糖又带类型:让“代码在做什么”显形
HIR(High-level Intermediate Representation,高级中间表示)要解决的问题,就是补上 AST 缺的语义,把源码从“语法视角”翻译成“语义视角”。这一步的核心动作是脱糖(desugaring)。
什么叫脱糖?就是把语言里那些为了方便程序员书写而引入的高级语法,拆解成一组更小、更基础的语言原语。常见的脱糖包括:
for i in 0..10变成基于loop、break、continue和迭代器协议的结构;match/switch模式匹配变成一层层条件判断和绑定;a += b变成a = a + b;- 闭包变成“结构体 + 捕获字段 + 统一的调用方法”;
async/await变成显式的状态机结构;?操作符变成展开的match加提前返回。
脱糖之后,HIR 已经看不出原始语言的“糖”了,但它的好处是:语义种类大幅减少,后续所有分析只需要面对几十种基础节点,而不是面对几百种语法结构。同一时间,HIR 会做类型化处理,给每个表达式和变量关联上类型信息,建立作用域链、符号表,甚至记录所有权和生命周期约束。
为什么这些工作在 HIR 做而不是 AST 做?因为 AST 的节点里塞满了语法糖,如果你在 AST 上直接做类型检查,你不但要理解for、match、闭包各自的语义规则,还要处理它们之间的组合情况;而脱糖后的 HIR,类型系统只需要处理几十种核心表达式,规则简单,组合爆炸的问题也缓解了。Rust 编译器就是在这里完成借用检查(borrow check)的,因为 HIR 已经能清晰表达变量的借用区间和数据流关系。
1.3 TIR 把控制流和数据流彻底摆到台面上
TIR(low-level / target intermediate representation,面向目标的低级中间表示)是离机器最近的一层。它和 HIR 最大的区别在于表达方式:HIR 还是树形结构,保留了if、loop这种结构化控制流;而 TIR 一定要显式地把控制流摊开成“基本块 + 跳转”的图结构(CFG,Control Flow Graph),把数据流摊开成“虚拟寄存器/临时变量”的显式读写。
为什么要这样?因为机器指令没有嵌套结构,只有顺序执行和跳转。你脑子里的“循环”在 CPU 看来就是一条条件跳转指令。让优化器理解“一个循环”和让优化器理解“一张控制流图里的环形结构”是两种不同难度的事。TIR 把一切结构化控制语句全部降级为 label、branch、jump,后续的数据流分析、活跃变量分析、寄存器分配,全部变成图算法,后端实现就干净了。
TIR 还要做一件很实际的事情:把高层的类型系统映射到具体的内存布局。比如一个Vec<T>,在 HIR 里是一个高级容器类型;到了 TIR,它必须显式表达成“数据指针 + 长度 + 容量”三个字段,以及堆分配、释放的调用。一个闭包,在 TIR 里必须是一个具体的结构体,字段就是捕获的变量。一个结构体字段访问,必须算好偏移量。这些都是实现层面才有的知识,和语言语义无关,放在这层正合适。
1.4 三层分工速查表
为了方便对照,我把三层各自的核心问题、输入输出和典型客户端整理成一张表:
| IR 层级 | 核心要回答的问题 | 主要输入 | 典型使用方 |
|---|---|---|---|
| AST | 源码的语法结构是什么 | 源码文本 | Parser、IDE、格式化器、宏系统 |
| HIR | 程序语义是什么、类型是什么、何时合法 | AST | 类型检查、借用检查、高级优化 |
| TIR | 如何组织控制流和数据流、内存怎么布局 | HIR | 优化器、指令选择、寄存器分配、代码生成 |
这张表值得贴在你编译器项目的 README 第一页。每次你犹豫“这个优化应该在哪个层做”,先问自己一个问题:它依赖的是语法结构、语义信息,还是实现细节?答案会直接把你导向正确的层级。
1.5 为什么不能一步到位:两个绕不开的现实原因
第一个原因是映射爆炸。AST 中每一种语法糖都可能需要多种指令级规则来处理,语法糖组合起来之后规则数量更是乘法爆炸。分层之后,每一层的“翻译”只针对一组有限的原语,复杂度线性可控。
第二个原因是分析困难。编译器优化在 AST 上做会发现到处都是“噪音”——括号、分号、无关的嵌套、为了语法舒服而引入的空壳节点。数据流分析、常量传播、死代码消除这些优化,在树形 AST 上描述起来非常别扭,但在 TIR 的 CFG 上就是标准的图遍历。就好比你写一篇文章先有提纲(AST),然后改成一套规范的段落结构(HIR),最后变成详细到每一句话的稿件(TIR),每一步都只处理自己这层该关心的东西,整个流水线才转得动。
2. 主流编译器都是怎么分层的:直接抄一份参考架构
2.1 rustc:从 AST 到 HIR、THIR、MIR 再到 LLVM IR
Rust 编译器 rustc 是分层 IR 的典型教科书。它的完整链路是:
源码 -> AST -> HIR -> THIR -> MIR -> LLVM IR -> 机器码AST 是 parser 的产物,宏展开也发生在这个阶段。HIR 负责脱糖和类型检查,cargo check阶段很多工作就在这里完成。THIR(Typed HIR)是带完整类型的 HIR,主要用于模式匹配的降级和借用检查。真正做大量优化的是 MIR,它是 Rust 自己的“TIR”,基于 CFG,做了借用检查的部分工作、常量传播、常量求值(const eval)、内联、以及生成 LLVM IR 前的各种变换。
rustc 选择在进入 LLVM 之前保留自己的中间层,一个重要原因是:Rust 的所有权模型、借用检查、模式匹配语义太特殊,直接往 LLVM IR 上映射会丢失太多语义信息,后续分析无从下手。MIR 让编译器既能完成精细的语言级检查,又不用过早绑死 LLVM IR 的细节。
想亲眼看看各层长什么样?用这几条命令就行:
# 输出 HIR rustc -Z unpretty=hir your_file.rs # 输出 MIR rustc -Z dump-mir=all your_file.rs # 输出 LLVM IR rustc --emit=llvm-ir your_file.rs实测下来,第一次看到 HIR 打印结果会非常震撼:for全部消失了,整个程序变成一堆loop、break、match和控制流大杂烩。那种“原来语言还能这样被拆解”的感觉,是理解 IR 分层的绝佳起点。
2.2 Swift:AST → SIL → LLVM IR,中间层承担安全大任
Swift 编译器也有自己的中间表示,叫 SIL(Swift Intermediate Language)。它的定位介于 HIR 和 TIR 之间,分为原始 SIL 和规范 SIL 两个阶段。原始 SIL 从 AST 生成,保留了大量高层语义;规范 SIL 经过一系列优化和语义调整后,再交给 LLVM。
SIL 比较有意思的设计是引入“所有权”(ownership)和“排他性”(exclusivity)检查,以及大量的内存安全验证。Swift 团队没有把这类语言级语义检查扛在 AST 上,而是设计了一层专门服务于这些检查的 IR。这进一步说明了一个趋势:语言越复杂,越需要舍得加中间层。
你可以用下面的命令查看 SIL:
swiftc -emit-silgen your_file.swift # 原始 SIL swiftc -emit-sil your_file.swift # 规范 SIL如果你正在设计的新语言有比较强的安全语义(比如生命周期、借用、并发约束),强烈建议参考 Swift 的做法,在 HIR 和真正的 TIR 之间留出一个专门做语言级分析的位置。
2.3 GCC、JVM、Go:不同取舍下的 IR 分层思路
GCC 的分层也很典型:AST 之后经过 GIMPLE(一种类似 HIR 的三地址码表示),再经过 RTL(Register Transfer Language,低层表示),然后才到汇编。GIMPLE 让 GCC 在语言无关层做了大量优化,RTL 则负责指令选择、寄存器分配、指令调度这些机器相关的事。
JVM/CLR 这边,Java 是 AST 到字节码再在 JIT 里做优化;C# 也是先编译成 IL,再在运行时 JIT 成机器码。它们的“字节码/IL”其实就是一个兼具平台无关语义和 VM 可执行性的中间层,分层思想一样,只是把编译时和运行时的工作分配方式不同。
Go 编译器则直接从 AST 生成 SSA 形式的中间表示,再一路优化到机器码。因为 Go 的语法相对克制、语义相对简单,它的中间层数量可以少一些。这告诉我们一个道理:层数不是越多越好,而是复杂度到哪儿,层就在哪儿。语言特性少,可以精简;特性一多,撑不住就得拆层。
2.4 给自己的语言定层级:三个判断标准
如果你在做一个新语言,不知道怎么定分层,可以参考这三个标准:
- 语言里有没有高级控制流或匹配能力?有
match、模式匹配、async/await、闭包这类特性,最好加一层 HIR 来做脱糖与语义检查。 - 目标平台是不是多样?需要生成 x86、ARM、Wasm 等多种后端,TIR 之后接一个通用后端(如 LLVM),由 TIR 层统一暴露给后端。
- 有没有需要跨函数或跨模块分析的语义?有所有权、借用、生命周期之类,就应该在 HIR 与 TIR 之间安排一个“中间层”专门做语言级分析,别把这类逻辑散落在代码生成阶段。
3. 实操推演:从 AST 往下走一遍 Lower,你就明白为什么需要三层
3.1 从一份“带 for 循环的小语言”开始
空谈不如动手。假设我在写一个极简单的语言,语法类似 C,支持变量、赋值、for循环和数组。它的 AST 节点定义大概长这样(用 Rust 伪代码):
enum Expr { Int(i64), Var(String), Assign(Box<Expr>, Box<Expr>), BinOp(BinOp, Box<Expr>, Box<Expr>), Index(Box<Expr>, Box<Expr>), } enum Stmt { Let(String, Box<Expr>), For(String, Box<Expr>, Box<Expr>, Box<Expr>, Vec<Stmt>), Assign(Box<Expr>, Box<Expr>), }这份 AST 忠实反映了语法:For节点里有变量名、起始表达式、结束表达式、步长表达式、循环体语句列表。如果我直接在 AST 上做代码生成,我需要为For编写“循环展开”逻辑、为BinOp编写指令选择、为Assign和Index组合编写地址计算。
看起来也没多少,对吧?因为这是玩具语言。真实语言的 AST 节点数量级在 100 到 200 个,加语法糖更是破 300。每个节点两两组合的翻译规则,能让你的代码生成器变成一团永远理不清的乱麻。这时候分层的价值就体现出来了。
3.2 从 AST 到 HIR:脱糖和类型化,把高级结构“踩扁”
HIR 的节点数量通常只有 AST 的三分之一到二分之一。在 HIR 阶段,我的小语言会把For脱糖成一组基础节点:
For(i, start, end, step, body) -> { let i = start; loop { if !(i < end) { break; } body; i = i + step; } }在真正的编译器里,HIR 还会顺带做几件事:
- 给每个表达式和变量关联
TypeId,建立类型表; - 建立作用域链和符号表,解析每个名字引用的是哪个声明;
- 如果这个语言有所有权语义,这里还要记录每个变量的移动和借用信息。
脱糖让 HIR 节点种类大幅下降,类型化则让后续分析不需要再回到 AST 去猜类型。你再看 HIR 打印结果时,会感到程序虽然“难看”了,但结构反而清晰了,所有的高级构造都不见了,剩下的都是可以系统处理的原语。
3.3 从 HIR 到 TIR:基本块、虚拟寄存器与显式内存布局
TIR 阶段我要把 HIR 的结构化控制流彻底转成 CFG。还是用刚才的for循环,它最终会变成一组基本块:
entry: i = start; goto check; check: if i < end goto body else goto exit; body: // 循环体语句 i = i + step; goto check; exit: // 循环后面的代码注意,这里已经没有loop、没有if嵌套,只有 label 和跳转。变量变成了虚拟寄存器,数组索引访问变成了显式的“取地址 + 偏移量计算 + 内存读写”。同时,类型系统层面的信息被降到“选择指令”需要的最低程度。如果目标机器是 64 位,整数可能是 64 位寄存器;如果存在 32 位,就换 32 位指令。TIR 的价值在于,它先把语言与机器之间剩下的“桥”搭好,后续指令选择只需要做局部匹配,不需要了解语言特性。
3.4 实际调试 IR 的“三板斧”
我平时写编译器,调试 IR 层基本靠三招:
第一招,dump 每一层。前端有问题看 AST dump,脱糖有问题看 HIR dump,代码生成有问题看 TIR/MIR dump,优化有问题看 LLVM IR dump。别凭脑子在大脑里模拟运行,打印出来的输出比任何日志都好使。rustc 的-Z unpretty=hir、-Z dump-mir=all,GCC 的-fdump-tree-all,LLVM 的-print-after-all,都是干这个用的。
第二招,做“极小化复现”。有时候某段代码在某一层表现异常,直接去翻那一层的 dump,找出最小的出错片段,单独写个小测试文件验证该层行为。这比在完整项目里打断点调试高效得多。
第三招,给每层 IR 写一个校验器。比如 HIR 校验器检查每个TypeId是否存在于类型表,TIR 校验器检查每个基本块的跳转目标是否真实存在、每个虚拟寄存器在某条路径上是否有定义。校验器跑一次就能暴露降级过程里被弄坏的语义,省下大量手工排查时间。我见过不少成熟编译器项目,IR 验证器代码量比优化器还大,原因就在这里——IR 层直接决定了后面所有分析的可信度。
4. 缓存为什么按内容寻址:不靠路径和时间,靠“我是什么”
4.1 传统缓存为什么总是失效:时间戳和路径都不靠谱
先从构建系统的通用做法说起。旧式构建工具(或者很多想偷懒自己做缓存的脚本)习惯用“文件路径 + 修改时间”作为缓存的 Key。代码长这样:
key = path(file) + ":" + mtime(file)这套逻辑在单机、串行构建、手动触发的情况下勉强能用,但一上大规模 CI 就问题百出:
- CI 每次从版本库 checkout 代码,所有文件的
mtime都是 checkout 那一刻,源文件内容明明没变,缓存却全部失效。 - 同一份源码,在不同分支、不同目录、不同机器上路径不一样,共享远程缓存时 Path 不同,Key 就不同,命中率趋近于零。
- 并行构建里,多个进程同时读一个文件的
mtime,文件系统的粒度不够,可能出现内容变了但时间戳没变的竞态。 - 你只想改一个注释就触发全部缓存失效,浪费多少编译时间,做过的人都懂。
根本问题是:路径和时间戳都不是“这份输入内容”的可靠标识。它们描述的是“这份文件放在哪里、何时被碰过”,而不是“这份文件到底是什么”。同内容不同位置,在路径寻址里就是两个完全不同的 Key。
4.2 内容寻址的核心思路:Key 由输入内容唯一决定
内容寻址(content-addressed)的做法完全不同。它先收集编译这个目标的全部输入(源文件内容、依赖的库产物、编译器版本、编译参数等),把整个输入集算成一个哈希值,作为缓存 Key。内容不变,哈希不变;内容变了哪怕一个字节,哈希就变了。
最简单的实现思路如下:
1. 获取所有输入文件的字节内容; 2. 分别计算每个文件的 SHA-256 哈希; 3. 把所有哈希和编译参数、编译器版本串成一个字符串; 4. 计算整体 SHA-256,得到最终缓存 Key; 5. 用该 Key 去缓存中查产物:命中直接返回,未命中则编译、写回缓存。这里有个细节值得注意:为什么不直接对整个输入目录做一次哈希?因为增量编译场景里,你不想每次编译都把整个目录重新读一遍。正确的做法是维护一份“输入清单”,对每个文件单独哈希,只有当文件被修改时才重新计算它的哈希,然后用组合哈希决定缓存是否命中。
Cargo 的做法大家可能更熟悉:它维护一个 fingerprint(指纹),里面包含 rustc 版本、编译参数、所有依赖产物的 fingerprint、源代码的哈希等。这个 fingerprint 本质上就是一个内容寻址的 Key。依赖链上的任何一层变化,都会通过哈希传导到最终产物,所以 Rust 的增量编译在 CI 里表现非常稳定。
4.3 链式哈希:为什么源文件哈希还不够
如果只对源文件算哈希,那么“源文件没变但依赖库变了”的情况就会假命中。比如一个 crate 依赖了另一个 crate,依赖 crate 内部改了实现但对外接口没变,源文件一字未改,若缓存 Key 只有本 crate 的源文件哈希,那下游 crate 会错误地复用旧产物——因为它的行为实际上可能受依赖实现的优化影响(尤其在内联场景里)。
解决办法就是链式哈希:构建目标时,把所有“输入”的哈希递归地组合起来。一个 crate 的 fingerprint 必须包括:
- 所有直接源文件的哈希;
- 所有依赖项的 fingerprint(而不是依赖项的路径或版本号);
- 编译器版本与编译参数哈希;
- 目标平台标识(OS、Arch、ABI);
- 相关环境变量(如
CARGO_CFG_*、RUSTFLAGS)的哈希。
这样,依赖图任何一环发生变化,都会传导到最终 Key。这也是 Bazel、Nix、BuildKit 这些现代构建系统的核心思想:整个构建图是一张 DAG,每个节点的缓存 Key 是它所有输入的哈希之和。
4.4 内容寻址与时间戳/路径寻址的对比
| 寻址方式 | Key 的形式 | 优点 | 缺点 | 典型系统 |
|---|---|---|---|---|
| 路径 + 时间戳 | file path + mtime | 实现简单,直观 | 路径变更、mtime 粒度、Checkout 抖动都会导致失效 | 早期 Make、手动脚本 |
| 内容哈希 + 参数 | SHA-256(源文件 + 依赖指纹 + 参数) | 与位置无关、与时间无关,可远程共享 | 需要维护输入清单,实现稍复杂 | Cargo、Bazel、sccache、Nix |
| 纯内容哈希 | SHA-256(文件内容) | 简单,去重效果好 | 缺少参数和依赖信息,不易直接用于编译缓存 | 对象存储、Git 对象库 |
从表中能看出,内容寻址并不是玄学,它的本质是把“缓存命中”建立在一个可验证的数学关系上:相同输入得到相同输出,输入变了输出就会变。接下来所有问题都变成了:输入集到底怎么界定。
5. 内容寻址缓存实践:关键细节、坑与排查实录
5.1 输入全集:Key 里究竟要放哪些东西
内容寻址缓存最大的坑,就是“Key 里漏了输入”。最常见的场景:
- 编译 A 文件时实际读了一个头文件 B,但头文件 B 没有进入 Key;
- 改了编译参数、优化级别或目标架构,但 Key 没变;
- 编译器版本升级了,但 Key 还是旧版本算出来的;
- 代码生成器、构建脚本、环境变量变了,却没有影响 Key。
排查方法说来也简单:先记录编译进程真正打开过的所有文件。Linux 上可以用strace -f -e trace=openat抓取,macOS 可以用fs_usage,再对比“实际读到的输入集”和“缓存 Key 里的输入集”,很容易就能找出漏网之鱼。
以 sccache 为例,它在编译前会通过编译器的“依赖列表”机制(如-MD)收集所有被 include 的头文件,再加上编译参数、编译器二进制本身的哈希、目标平台等信息,组合成缓存 Key。这就保证了每次编译的输入都被完整覆盖。
对于自研构建系统,一个稳妥做法是:把“环境指纹”也纳入 Key。所谓环境指纹,就是编译器二进制路径的哈希、构建工具的版本、相关工具链组件的版本、甚至关键环境变量的哈希。虽然相比只看源代码会牺牲一点命中率,但能显著减少假命中带来的诡异行为。
5.2 缓存粒度与存储:细粒度更灵活,粗粒度更省事
缓存粒度的选择直接影响命中率。按“整个可执行文件”缓存,粒度最粗,命中率最低——改一个源文件,整个可执行文件的 Key 全变。按“编译单元(每个 .c/.rs 文件)”缓存,命中率大幅提升,因为大部分文件没变,它们的单元产物可以直接复用。Cargo 走的就是这条路线:以 crate 和增量单元为粒度。
更大规模的系统可以做到按函数或按模块缓存。LLVM 的 ThinLTO 缓存就支持按函数粒度缓存优化结果,因为内联和跨模块优化可能让某些函数多次被处理。粒度越细,复用率越高,但缓存的元数据管理也越复杂。我的经验是:先按编译单元,等命中率不够了再细分成更小粒度,不要一上来就上最细的方案。
存储方面,内容寻址天然适合使用 Content Addressable Storage(CAS)。CAS 把内容哈希作为文件名,同一个文件无论被谁引用都只存一份,天然去重。本地用普通磁盘缓存目录,远程可以用对象存储加索引服务。Bazel 的远程执行协议里,缓存的元数据(执行结果、日志、退出码)和实际产物(文件)就分开存储,产物统一放进 CAS,元数据通过哈希引用它们。
5.3 常见故障与排查速查表
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 缓存命中但产物不对 | Key 里遗漏了实际输入(头文件、依赖、环境变量) | 用 strace 抓打开文件列表,对比 Key;关闭缓存复现并对比差异 |
| 一直命中不了 | Key 不稳定:包含绝对路径、时间戳、构建序号 | 打印两次 Key,diff 找出变化的字段;把绝对路径替换为相对路径或纯内容哈希 |
| 远程缓存大量失效 | 远程缓存存储损坏、权限问题、网络超时 | 检查远程存储服务日志,测试单个条目的拉取;给条目加校验和验证 |
| 缓存膨胀严重 | 缺少引用计数或 GC 策略 | 记录每个条目被命中的时间,定期清理最久未使用的条目;或基于引用计数删除 |
| 并行构建互相覆盖 | 缓存写回没有原子性保护 | 写临时文件再 rename 成最终内容哈希路径;同 Key 写同一路径用原子操作 |
5.4 提升命中率的几个实用技巧
第一,保证 Key 是“可打印的”。编译时最好加一个参数可以输出最终生成的缓存 Key,比如--print-cache-key。调试时对比两次构建的 Key,一眼就能看出哪个字段变了,比瞎猜快得多。
第二,把临时路径和绝对路径排除在输入之外。有些编译器会在产物里记录绝对路径(比如调试信息),如果你连调试信息一起做缓存,路径变了 Key 一定会变。解决办法是在二进制产物里用-fdebug-prefix-map之类的参数抹掉绝对路径,或者单独缓存不带调试信息的产物。
第三,环境变量只白名单,不要全量入 Key。整个env全量哈希会让缓存命中率骤降,因为环境变量经常包含动态值(PID、随机数、时间戳)。只把真正影响编译的变量(如PATH、CFLAGS、RUSTFLAGS、目标平台相关配置)选出来入 Key,既安全又高效。
第四,写入缓存时做一次端到端验证。编译完成后,把产物试运行一遍,确认退出码、stderr、关键输出文件存在,再写回缓存。宁可漏写入也不要写入错误产物,否则后面几万人都在复用你的 Bug。
6. 我在实际项目里的体会
分层 IR 这套体系,我一开始也觉得是“过度设计”,直到自己给一个有闭包、模式匹配和些许所有权语义的小语言写后端,才发现不做 HIR 脱糖,类型检查的规则会膨胀到自己都认不出来;不做 TIR 的 CFG 化,优化逻辑根本没法用算法描述。三层之间的界限不是一开始拍脑袋定的,而是在写掉几千行代码、经历过几次“这层改一下那层也跟着崩”之后,才慢慢清晰起来。
缓存这条线给我的启发更大。凡是能用内容定义的,就不要用位置和时间来定义。这个原则不只是编译缓存,任何追求可复现性的系统——CI 缓存、镜像构建、依赖锁定、远程分布式任务——都适用。内容寻址不一定能让一切变快,但它能让“缓存到底有没有生效”这个问题变得可以验证、可以推理,而不是靠玄学。
最后再分享一个小技巧:不管你是写编译器还是搭构建系统,给自己的 IR 层和缓存 Key 都加上“导出调试信息”的能力。这两样东西在出问题的时候,比任何监控面板都管用。把 IR dump 和缓存 Key 打印出来,和身边的人一起对着看,很多理不清的 Bug 就在这种“对着真实中间状态看”的过程里被找出来了。踩过几次坑之后你就会明白,编译器开发里最贵的从来不是 CPU 时间,而是“看不见中间态”时反复试错的时间。