
前阵子在给团队自研的新语言写编译器从设计文档到第一版可跑通的demo中间反复推翻重来最后定了两个关键技术决策一是把中间表示拆成 AST、HIR、TIR 三层二是把编译缓存改成按内容寻址。这两个决策说起来轻巧实际做完之后整个编译器的架构清晰度和增量编译效率完全是两个档次。这篇就围绕编译器开发里这俩话题展开把为什么这样设计和落地时的细节一起讲透给正在折腾新语言编译器的同学一个可以对照的参考。1. 为什么新语言编译器不能只靠 AST 一条路走到到底很多人刚开始写编译器思路很直接词法、语法分析出 AST然后就顺着 AST 去生成代码。做点玩具语言没问题但一旦牵扯到类型推导、闭包、泛型、模式匹配这些现代语言特性AST“一把梭”会撞墙撞得非常惨。我在早期原型阶段就是这么干的最后被迫把中间表示拆层这才把整个编译器的复杂度理顺。1.1 AST 是树形语法快照不是优化舞台AST 全称是 Abstract Syntax Tree它本质上是一棵记录“源代码长什么样”的树。每个节点对应一个语法结构比如表达式节点、循环节点、赋值节点。它的价值在于忠实还原程序员写的语法结构方便做语法引导、格式化、lint、重构之类的工具。但它不适合直接承载编译优化和代码生成原因有两点核心第一AST 保留了太多语法糖。语言设计者为了让代码好写往往会提供for、while、for-in、.map()、?、??这些语义等价但写法完全不同的语法。这些语法糖在 AST 里是各自独立的节点。如果优化器要识别“循环遍历数组并累加”这个模式就得同时匹配好几种不同的 AST 节点形状模式匹配代码会爆炸。而且后面每加一种语法糖所有优化 pass 都得跟着改一遍维护成本高得吓人。第二AST 的嵌套结构天然不适合做数据流分析。做优化需要把代码看成一个控制流图CFG需要知道变量从哪里定义、经过哪些路径、在哪里使用。AST 是一棵深层嵌套的树循环、分支、跳转通通以嵌套形式存在你很难直接在上面做活跃变量分析、死代码消除、常量传播。我一开始试着在 AST 上写语义分析勉强能跑但写控制流分析的时候发现每遇到一个if就要递归展开子树处理break和continue更是处处要维护上下文栈代码越写越像一坨“语法遍历器”而根本不是优化器。所以直白点说AST 是给语法层面准备的快照不是给优化和代码生成准备的舞台。1.2 直接从 AST 生成代码会产生哪些连锁问题如果强行从 AST 直接生成目标代码问题会集中爆发在两层第一层是类型信息与布局信息缺失。现代语言普遍有强类型比如u8、i32、结构体、联合体这些类型最终要映射到目标机器的寄存器和内存布局。AST 里虽然后面挂类型但一套代码生成逻辑要同时在“看语法结构”和“算内存布局”两个抽象层次之间反复横跳。函数参数该走寄存器还是栈、返回值是引用还是复制、异常怎么展开这些全是 ABI 层面的细节和源码语法没有直接关系。硬要把它们揉进 AST 的遍历逻辑里代码生成模块会变成一个无法维护的巨型 switch。第二层是优化 pass 无处安放。编译器优化的前置条件是“代码已经被规范化”比如三地址码、SSA 形式、显式的控制流边。AST 没有这些你连a b c这种表达式是左结合还是右结合都得靠运算符优先级推导更不用说做公共子表达式消除、循环不变量外提这类经典优化了。我早期试过在 AST 上用最简单的模式匹配做简化结果连x 0这种规则都要写好几层递归判断效率低还容易漏后来全部推翻老老实实分层。1.3 三层 IR 的本质是“职责分离”解决这些问题的标准做法是引入中间表示IR并且不是一层而是两层或三层。新语言编译器把中间表示拆成 AST、HIR、TIR 三层本质上是做了一个职责分离AST 负责保留源码的语法形状供前端工具使用HIRHigh-level IR负责保留语言语义剥离语法糖让语义分析和优化在规范结构上进行TIRLow-level / Target IR负责暴露机器相关细节让代码生成、寄存器分配、指令选择在一个贴近底层的 IR 上完成。每一层只处理自己那个抽象级别的问题通过 lowering降级相连。这样每个 pass 的逻辑都是“规则清晰、专注做一件事”而不是在多层抽象之间来回横跳。后面我会详细讲 HIR 和 TIR 各自长什么样以及切分点怎么定。2. HIR 与 TIR 到底切在哪个位置才合理拆层并不难难的是每一层的边界画在哪里。切得太靠上低层 pass 还是会被语言特性拖累切得太靠下前端工具链又要被迫处理底层细节。我的实践结论是HIR 的边界是“保留语义去语法糖”TIR 的边界是“显式机器事实”。2.1 HIR保留语义撕掉语法糖HIR 是 AST 下层、TIR 上层的中间表示。它最基本的目标是把语法层面的千变万化折叠成一组语义等价但结构统一的节点。举个例子假设你的新语言有for i in 0..10、while、loop三种循环。AST 里它们是三种节点而 HIR 里可以统一成一种循环节点包含初始化块、条件块、更新块、循环体四个子结构。for-in会被 lowering 成迭代器 条件判断 步进更新while会被规整成同样的四段结构。这样一来HIR 上做循环相关的分析比如循环不变量外提就只需要处理一种节点形态规则写一遍就通吃所有语法糖。HIR 还会做另外几件很重要的事把运算符重载、隐式类型转换显式化。源头的a b在 HIR 里可能变成__add__(a, b)或者int_add(a, b)类型信息被明确挂在节点上而不是靠查字典。把模式匹配和解构展开成判断 取字段的序列。let (x, y) point这种写法在 HIR 里会变成显式的别名绑定不需要再处理嵌套模式节点。把闭包捕获的环境显式化。语言层面的“闭包能访问外层变量”在 HIR 里通常被转换成显式的环境结构体和捕获字段方便后面做逃逸分析和内存管理。我自己的实现习惯是让 HIR 保留一个“类语法树”的形状但每个节点都带类型信息且不允许出现任何语法糖节点。这样的好处是语义分析、借用检查、类型降级这些 pass 可以全部在 HIR 上做逻辑写起来很顺手又不会被底层的跳转和 phi 节点干扰。2.2 TIR把机器事实摆到桌面TIR 是再往下一层的中间表示。它在设计上要做的事情是把控制流显式化、把内存布局显式化、把调用约定显式化。控制流显式化是指 HIR 里的循环、分支、函数体到 TIR 都变成由基本块basic block组成的控制流图。基本块之间用跳转边相连条件跳转的目标是明确的基本块标签。SSA 形式下还必须处理 phi 节点用来表示从不同前驱块流入同一块时值的来源。这里代码生成和优化只需要盯着 CFG 上的节点和边不用再关心源码里的if/else语法。内存布局显式化是指结构体字段偏移、数组元素大小、对齐方式、栈帧布局在 TIR 里都变成可见的量。变量不再是一个“名字”而是一个虚拟寄存器编号或者栈槽位。所有加载和存储、指针运算都落到具体的内存位置。调用约定显式化是指在 TIR 层能看出一个函数调用走的是平台 ABI还是内部自定义的快速调用约定。参数分配到哪些寄存器、哪些走栈返回值怎么传TIR 都有明确的表示。我用一个简单示例说明这三层 IR 在编译器中处理同一段代码时的形态差异。假设源码是let sum 0; for i in 0..10 { sum sum arr[i]; }AST 里是一个Let节点和一个ForIn节点arr[i]是带下标的访问表达式sum是符号引用HIR 里ForIn变成循环规范结构包含i 0初始化、i 10条件、i i 1更新、循环体内部是sum sum arr[i]的赋值表达式TIR 里这段代码变成若干基本块入口块、条件判断块、循环体块、退出块arr[i]变成一个显式的 load 指令从地址base i * elem_size读取i的更新变成add指令循环条件变成icmpbr组合。同样一段代码三个抽象级别看得清清楚楚。优化器在 TIR 上做指令调度、寄存器分配才能看到真正的并行机会和依赖关系。2.3 分层后每个 Pass 都变得单纯了分层最直接的好处体现在 pass 的写法和调试上。HIR 上做的借用检查不会看到跳转指令TIR 上做的寄存器分配不会看到for-in的语法糖。每个 pass 的输入输出都是同一层 IR 的节点测试用例可以用该 IR 的原生结构来构造而不需要每次从源码重新 parse。这种“层间只通过 lowering 相连”的结构还带来一个工程上的好处并行编译流程更好画。一个 crate 或 module 的前端分析lexing parsing lowering to HIR互不依赖时可以并行HIR 层面的检查pass只依赖本模块的 HIRTIR 上的优化和代码生成可以按函数粒度拆分任务。整个流水线像流水车间一样每道工序只消费上一道工序的产物。有一次我在 TIR 上做针对目标 CPU 的指令选择时发现某个优化 pass 需要的循环信息在 TIR 上已经被拆散了。当时差点想直接“从 HIR 拿数据”。但后来还是忍住了在 TIR 的循环头部块上加了一个loop_metadata注释标记这是原循环的入口。这种做法既没有破坏分层也让 TIR pass 能拿到必要的高层信息是拆层过程中很实用的一个手段。3. 编译缓存为什么按内容寻址而不是按路径和时间戳IR 分层解决的是“编译过程内部的复杂度”而缓存解决的是“编译过程的重复劳动”。新语言编译器的缓存设计我强烈建议直接采用按内容寻址content-addressed而不是传统的路径 时间戳方案。这背后的逻辑和分布式系统里内容寻址存储CAS的初衷是一样的用内容本身作为身份标识彻底摆脱路径、时间这些外部状态带来的不确定性。3.1 传统文件缓存在新语言编译器面前的困境传统构建系统比如最简单的 Makefile判断“文件是否需要重新编译”靠的是文件修改时间。如果.c的 mtime 比.o新就重编。这个方案在小项目里凑合能用但新语言编译器场景下问题很大第一mtime 不能真正反映内容变化。从版本控制里切分支、把整棵源码树复制到另一台机器或者用工具统一 touch 了文件mtime 会变但内容可能没有实质变化。这种“假更新”会触发全量重编非常浪费。反过来某些场景下内容确实变了但 mtime 因各种原因没更新缓存就会错误命中产出过期产物这是更严重的安全事故。第二路径是一个脆弱的外部标识。两个工程目录可能包含完全相同的源文件内容但路径不同。用路径做缓存 key这两份内容相同的输入就无法共享缓存。更麻烦的是路径和编译选项纠缠时比如同一个文件按 debug 和 release 两个选项编译产物不同如果缓存 key 只记路径不记选项就会出现花了大力气缓存出来的产物根本不敢复用的情况。第三依赖跟踪难以做到精确。源文件改了但它对应的 HIR、TIR、目标文件应该全部失效还是部分失效传统方案是按时间戳比较改一个头文件可能倒逼几百个.c文件重编。而新语言编译器有多级 IR理论上可以做到更细粒度的增量失效前提是缓存机制本身支持。我当时很早就决定不用 mtime就是因为踩过一次坑某个模块的源文件内容没变只是它依赖的一个头文件重新生成过mtime 更新结果整个模块被重编同时改了一个无关目录的格式又导致另一个模块误命中缓存。这种“时间戳缓存”难以解释的随机行为在真实开发里极其搞人心态。3.2 内容寻址缓存的核心逻辑与实现思路按内容寻址的缓存核心逻辑非常简单输入内容的哈希就是缓存里的唯一标识。假设一个编译单元输入是source.xx编译时先计算源文件的哈希比如用 BLAKE3 或 xxHash 算出一段 128 位摘要a1b2c3d4...然后去缓存目录找.cache/a1b2c3d4.../output.o。存在就直接用不存在才真正编译并把这个输出产物写到这个哈希目录下。整个过程不需要关心文件路径不需要关心 mtime只需要保证内容相同的输入哈希一定相同内容不同的输入哈希尽量不同。但实际不能只对源文件做哈希。一个编译单元的最终产物受到多种因素影响源文件内容、编译选项、依赖模块的 HIR 摘要、编译器版本、目标平台、环境变量、标准库版本等。所以正确的做法是构造一个缓存 key 结构体把这些信息全部序列化然后整体算哈希。一个可用的 key 可能长这样CacheKey { source_hash: u128, // 主源文件内容哈希 options_hash: u128, // 所有影响代码生成的编译选项哈希 dep_hashes: Vecu128, // 依赖模块的HIR哈希按依赖顺序排序 compiler_version: u64, // 编译器自身版本号 target_triple: String, // 目标平台 env_hash: u128, // 影响行为的环境变量/宏定义 }算哈希时把这些字段按固定顺序拼成字节流再用 xxHash64 或 BLAKE3 得到最终 key。这个 key 既是缓存目录名也是缓存索引。读到缓存后还要做一层校验比如重新读取文件长度和一个快速 checksum防止磁盘部分损坏导致的内容污染。有人可能会问如果只对“最终产物”做缓存source .o但中间 HIR/TIR 变化的依赖又会导致失效怎么办答案是内容寻址的粒度要下沉。不能只缓存最终.o而是把每级 lowering 的产物都缓存下来。比如source - HIR的结果按source_hash缓存HIR - TIR的结果按hir_hash缓存TIR - obj的结果按tir_hash options缓存。这样依赖链上的每一层都有独立的缓存项某层没变它以下的所有层就都能复用。我用这个方案做增量编译改一个函数的实现往往只重新生成该函数所在的模块依赖它的其他模块因为在依赖哈希这一层就已经命中缓存完全不需要重新 lowering 和优化编译时间从原来的几十秒被压到一两秒体感非常明显。3.3 缓存粒度怎么选文件级、函数级还是 Pass 级内容寻址缓存具体做到多细是值得权衡的设计决策。这里有三种常见粒度文件级一整个源文件为一个缓存单元最简单实现成本低但粒度粗。文件里改一行整个文件的 HIR/TIR/目标文件全部重算。函数级把 HIR/TIR 按函数切块每个函数的 IR 独立缓存。改一个函数只需要重编这一个函数的 TIR其他函数还能复用。粒度很好但需要处理跨函数依赖和去重实现复杂一些。Pass 级不仅按函数还按 pass 边界缓存。比如 const propagation 的结果缓存一份之后任何用到该函数的 pass 都从缓存读。这个粒度最细但缓存开销大、失效判断复杂除非编译器用户量极大否则收益往往抵不过复杂度。我的建议是新语言编译器第一款稳定版本先做函数级 文件级混合。具体做法是source - HIR走文件级缓存因为前端解析通常不是瓶颈HIR - TIR - obj走函数级缓存因为优化和代码生成是重头函数是天然并行单元。等你有明确的性能 profile再决定是不是要把某个高频 pass 单独缓存。如果一开始就上 pass 级缓存你会被两件事拖死一是 pass 输入输出可能产生循环依赖LLVM 里每个 pass 都依赖上一 pass 的结果缓存 key 计算非常棘手二是 debug 时经常要“关掉缓存”才能复现 pass 里的 bug而 pass 级缓存恰恰会引入大量隐式状态调试难度直线上升。4. 实际落地时绕不开的坑与建议设计归设计真正把 AST、HIR、TIR 三层和内容寻址缓存落地到编译器工程里会遇到很多文档里不会写的问题。下面这几条是我最近踩坑后的复盘每一条都值回票价。4.1 层与层之间别急着“开天窗”分层最忌讳的事就是为了图省事在低层 IR 的 pass 里直接访问高层 IR 的数据。比如在 TIR 优化里调用 HIR 的类型信息或者在 HIR 检查 pass 里判断 AST 的语法糖结构。短期看能省几行代码长期看会把两层之间的边界搞得面目全非。一旦后续重构a pass 依赖的高层结构变了维护者根本不知道哪些低层 pass 还在默默调用它。我的经验是任何跨层数据访问都要显式地“降级”到本层可用的形式再挂在节点上。比如 TIR 需要知道某个 load 节点对应的原始源码位置就在 lowering 成 TIR 时显式维护一个source_map把 TIR 指令映射到 HIR 节点再到 AST 节点。该 pass 永远只查 source_map不直接翻 HIR。实际操作的边界判定我会问自己一个问题如果去掉更高层的 IR只用这一层的数据结构我这个 pass 还能不能正确实现如果答案是不能说明这一层的数据结构设计得不够完整需要把缺失的信息显式地补充到这一层里而不是偷偷摸摸地去读上一层。4.2 缓存 Key 必须把环境也算进去按内容寻址最大的坑就是自以为 key 已经覆盖了所有影响产物的因素结果漏掉一个环境变量缓存返回了错误的旧产物。这不是危言耸听我实际遇到过一次编译选项里有一个宏定义控制是否开启某个特性当时 key 里只放了 main source 的哈希和编译器版本忘了把这个宏定义加进去。结果两个不同宏配置的编译任务共享同一个缓存把错误产物带了出来排查了很久才发现是缓存污染。所以设计缓存 key 时有几条铁律所有影响目标代码生成的编译选项必须进入 key。包括优化级别、目标 CPU、是否开启 debug info、sanitizer 开关、宏定义、链接标识等环境变量、配置文件中凡是可能影响代码生成和 IR 降级的都要列进 key 的字段。如果你无法穷举就做一个“白名单 版本号”机制每次白名单规则变化给缓存 namespace 整体提升一个 version防止旧缓存污染编译器自身的代码在开发过程中会频繁变更所以compiler_version不能用语义化版本号而要用构建哈希或 git commit hash。否则你改了编译器内部逻辑产物变了但缓存 key 没变旧的缓存会被错误复用开发期尤其危险。我在项目里直接用 git commit hash 加一个构建时冗余标记作为compiler_version。这样每次代码变更后缓存自然全部失效不会出现“改完编译器还用旧缓存”——这种错误在开发期极难定位因为报错位置和你改动的地方看起来毫无关联。4.3 小步快跑的落地顺序分层与内容寻址缓存都是重决策落地时建议分阶段来别想着一步到位。第一步先把 AST - HIR - TIR 的前半段跑通也就是能正确 lowering不急着做大量优化 pass。这个时候先验证分层上的数据结构稳定确保语义信息能完整从 HIR 流向 TIR。HIR 上的节点不要急着追求极简宁可多保留一些冗余字段后续再收敛。第二步在 TIR 上接代码生成确保能产出可运行的目标文件和二进制。这时候类型布局、调用约定这些 TIR 层的东西会真正暴露问题比写设计文档要真实得多。改 TIR 数据结构是必然的所以第一版不要做太大规模的重构留出变更空间。第三步HIR 和 TIR 各自跑几个“骨架 pass”比如死代码消除、常量折叠。这些 pass 的规模不大但能验证 IR 结构和 lowering 工具链是否顺滑。等这些 pass 都能稳定工作再逐步增加优化。第四步做内容寻址缓存。先做文件级再做函数级。缓存方案就应该放在所有基本功能能跑通之后否则前期 IR 结构频繁调整会让缓存 key 频繁变化形成大量无效的缓存写入反而拖慢开发。最后一步对缓存的命中率和增量编译效果做监控。我会在 CI 日志里输出“本次构建缓存命中数 / 未命中数 / 总任务数”这几个指标。如果命中率长期偏低我会去看是不是 key 设计得过于敏感比如 compiler_version 每次变都会导致全量失效那么可以考虑在稳定的 release 构建里用距离上次稳定版本的重构增量来索引而不是纯 commit hash。4.4 内容寻址缓存别忘了“清理策略”按内容寻址的缓存目录会无限制增长因为每次哈希不同都会生成一个新目录旧目录永远不会被覆盖。如果你不清理本地磁盘迟早会被撑爆。解决办法是加一个基于 LRU 或最近访问时间的清理任务每次构建结束扫描缓存根目录下的条目凡是最近 7 天都没有被读取的直接删除同时设置全局缓存上限比如 10 GB超过上限就按最后访问时间倒序删除直到降到 8 GB 为止。还有一个小细节清理任务不能和正在进行的构建并发删除。否则构建任务刚写入的缓存项可能被清理线程误删导致下次构建重算。我在项目里用一个简单文件锁来串行化清理和构建实测没有遇到并发问题。另外强烈建议给缓存目录设置独立的临时写目录。比如写入时先写.cache/tmp/random全部写完再 rename 到最终哈希目录。这样即使构建中途崩溃不会留下半截文件污染缓存。这个经验来自一次本地断电事故缓存目录里出现了一个只有一半数据的目标文件后续构建读它直接崩溃排查半天才发现是被写坏的旧缓存。一点真实体会回到你最初的问题新语言编译器为什么要分 AST、HIR、TIR 三层。我的理解是编译器的本质是“把人类友好的语法翻译成机器可执行的指令”这中间天然存在巨大的抽象鸿沟而分层就是用一个可控的斜坡去弥合这个鸿沟。AST 管语法HIR 管语义TIR 管机器层与层之间通过 lowering 连接每一层都只做自己那个抽象级别的事。缓存按内容寻址则是让重复劳动真正变成“只会发生一次”的机制用内容作为身份的锚点彻底摆脱路径和时间戳这些外部变量的干扰。这两个设计并不是什么高深理论而是我在真实项目里反复踩坑之后沉淀下来的实践。如果你也在写新语言编译器或者打算给现有语言加一套编译缓存我的建议是从 AST/HIR/TIR 的分层先入手哪怕一开始只做两层也先把边界想清楚然后在第二版再引入内容寻址缓存。做出来的编译器会少很多头疼时刻增量编译的体验也能变得非常流畅。