写这几个优化之前我先把话放在前头复制传播、常数折叠、常数传播这三兄弟单拎出来每一个都不难但它们在编译器前端到后端之间承上启下的作用很多初学者是低估了的。我自己最开始写教学编译器的时候总觉得“优化”是后端寄存器分配、指令选择这些重活的事情前面几个小优化不过是在 IR 上做点文字替换。直到真正动手实现、统计它们对后续优化产生的连锁反应才意识到这一层化简做得好不好直接决定后面所有优化能捡到多少肉。这篇文章就围绕这三个“前期优化”展开适合正在学编译原理、或者自己手写 IR 优化通道的人看。我会把每个优化的原理、实现思路、安全边界讲透再演示三个优化串起来的效果最后整理一些我实际调试中踩过的坑。不需要你有多深的编译器基础但至少得知道什么是基本块、什么是数据流分析——如果你已经知道 IR 有 SSA 形式那读起来会更顺手。1. 为什么说它们是“前期优化”这三者通常被归为机器无关优化跑在目标指令生成之前也就是中端 IR 阶段。所谓“前期”是因为它们的共同目标就一句话在还不涉及寄存器分配、指令调度这些后端问题的时候先把代码里的冗余信息清掉让程序的数据流图更短、更干净。1.1 三个优化各自解决什么问题先拆开看。常数折叠做的事情最简单——编译期直接算出能算的表达式结果。例如代码里写了a 3 * 4那直接折叠成a 12无需生成任何乘法指令。它处理的是“字面上的常数表达式”不关心变量在运行时是什么值。常数传播就往前多走了一步它通过数据流分析判断某个变量在某个程序点上是不是恒定等于某个常数。如果是就把后面用到这个变量的地方替换成这个常数。典型场景是x 42; y x 1;常数传播分析后知道x在那里就是 42于是把y x 1改写为y 42 1。注意这还没完改写之后又给了常数折叠机会最终变成y 43。复制传播则处理另一种情况代码里有一串变量到变量的直接赋值比如b a; c b 1。复制传播会把c b 1中的b直接替换成a即c a 1。如果替换之后b再也没有人用了那条b a就成了死代码可以被后续的死代码消除流程清理掉。我习惯用一句话概括这三个优化的分工常数折叠是“局部手算”常数传播是“全局带信”复制传播是“顺手把中间的传话人干掉”。1.2 三者组合的放大效应单独看任何一个优化收益都极其有限。但把它们串起来效果完全不同。我举个例子。假设 IR 里有这么一串x 100; y x; z y 8;如果只做常数传播会发现x恒为 100于是把所有用x的地方替换成 100得到x 100; y 100; z y 8;y还是个未知数吗不其实y也被赋了常数值 100继续传播就能把z y 8里的y换成 100。但如果你先做复制传播把z y 8里的y换成x得到z x 8再配合常数传播把x换掉最后常数折叠算出z 108。两种顺序都能到终点但路径长度不同。在实际优化流水线里编译器通常会对这三者做若干次迭代复制传播开道常数传播跟上常数折叠收尾然后死代码消除来处理那些被“架空”的复制语句。一轮不够就多来几轮直到数据流图不再变化。1.3 在编译器整体流程中的位置聊完整体思路还得把它放回编译器的坐标系里。一般编译器的流程是三段式前端负责词法、语法、语义分析生成中间表示中端跑各种目标无关优化后端把 IR 映射成目标指令做寄存器分配、指令调度。前端刚生成的未经优化的 IR往往带着大量原始痕迹冗余的临时变量、反复赋值的常数、层层包装的复制语句。这些痕迹不清理后端的寄存器分配会把这些变量都当成需要分配寄存器的对象导致寄存器压力暴涨指令选择也会因为表达式太长而很难匹配到好的指令模板。所以“前期优化”这个名字名副其实——它们是给后端铺路的。材料里的杂质不筛掉后面做精细加工是事倍功半的。2. 常数折叠最朴素的表达式化简常数折叠是所有优化里最好理解、也最好实现的一个但别因为它简单就掉以轻心。它的安全边界、触发点、与编译器其他模块的协作方式里头的弯弯绕不少。2.1 基本思想与实现场景常数折叠的本质是把编译期已知的运算提前完成。它在任意一级 IR 上都能做AST 上可以三地址码上可以SSA 形式的 IR 上也可以。理论上讲凡是操作数全部是常数的表达式都可以尝试折叠。最常见的触发场景有这么几类字面量运算3 4、7 * 8这类编译期就能算出结果的算术表达式。布尔表达式合并true (x 0)如果true 部分可以归约条件表达式就变短了。数组下标计算arr[2 * 4]折叠成arr[8]省掉一次乘法还可能为后续的地址计算提供便利。常量字符串拼接某些语言允许hello world编译期可以直接生成helloworld的常量。位运算归约x 0xff中0xff是常数但x不是这种情况通常不折叠但如果整个表达式的操作数全是常数(1 4) | 0x0f这类还是可以直接算。实现方式也不复杂遍历 IR 指令时检查每个操作如果所有源操作数都是编译期常数就直接计算结果、更新目标操作数并把当前指令标记为可删除。关键实现时要用一张“常数结果表”来记录折叠后的值因为折叠后的结果可能复用到多处。伪代码大概是这个思路def fold_expression(op, lhs_val, rhs_val): if not (is_constant(lhs_val) and is_constant(rhs_val)): return None # 无法折叠 if op ADD: return lhs_val rhs_val if op MUL: return lhs_val * rhs_val # 更多 op2.2 折叠的安全边界这里必须强调折叠不是无脑把所有操作数都是常数的表达式都算掉。有几个场景是必须谨慎的。第一是浮点运算的舍入问题。0.1 0.2在编译期以编译器的浮点格式算一遍和在运行时由目标机器的浮点单元算一遍结果在某些极端场景下可能不完全一致。好在现代 IEEE 754 标准的实现已经相当统一大部分编译器默认对浮点常量表达式做折叠但如果你写了-ffast-math之外的严格控制选项某些编译器会刻意避免折叠浮点运算以防止“优化后结果和未优化结果不同”的诡异现象。我在自己实现的玩具编译器里浮点运算一律不折保守但安全。第二是溢出行为的语义变化。无符号整数溢出在 C 里是定义好的回绕行为有符号溢出则是未定义行为。如果你的中间表示不区分有符号、无符号折叠INT_MAX 1就可能遇到麻烦编译器算出来的常数是INT_MIN二进制回绕但运行时如果按有符号算术执行在目标机器上可能触发不同的溢出陷阱。第三是可能有副作用的表达式。这是新手最容易踩的坑。考虑f(3) 4f虽然参数是常数但调用本身可能有副作用不能把f(3)折叠成某个常数。常数折叠只能处理那些“纯表达式”——没有函数调用、没有内存读取或者至少你确认读取位置没有副作用。我把可折叠的条件总结成三条操作数必须都是编译期常数运算必须是无副作用的纯函数结果的值必须是良定义的、不会因为折叠而改变语义。2.3 实操演示一个简单常量表达式的折叠过程用一个简单例子演示完整流程。假设原始 IR 是%0 add i32 3, 4 %1 mul i32 %0, 2第一步遍历到%0 add i32 3, 4发现两个操作数都是常数计算3 4 7把%0的改写成%0 7原add指令标记删除。第二步遍历到%1 mul i32 %0, 2通过跟踪发现%0已经是常数 7于是把%1的源操作数替换成7和2继续折叠得到%1 14。最终 IR 就一句%1 14这一步做完原本两条算术指令全部消失。但请注意我上面流程里的“通过跟踪发现%0已经是常数”——这就是常数传播在做的事。常数折叠能访问到的“常数”一部分来自字面量更多部分来自常数传播替它挖掘出来的信息。这也是我不建议新手一上来就实现一个“万能折叠器”的原因先跑通常数传播再叠加上折叠整体效果好得多。3. 常数传播让常数“跑起来”常数传播是这三个优化里的重头戏也是我第一次写数据流分析算法的启蒙案例。它讲的是一个很朴素的问题一个变量可能在很多地方被赋值但如果所有到达某个使用点的赋值路径都把它赋成同一个常数那这个使用点就可以放心地用这个常数替换。3.1 单向数据流分析的基本模型常数传播属于前向数据流分析问题——信息从程序的入口向出口方向流动。每个程序点都关联一个“抽象状态”记录每个变量的当前常量信息。为了对“是否常数”这件事建模文献里常用三值格lattice来描述undef未定义或者尚未确定值表示可能没被赋值过也可能是信息不足。constant(c)确定等于常数c。notconstant也常叫bottom不是一个常数。初学的时候我总觉得这个格是玄学换成大白话说就是某个变量在某个程序点上要么我知道它等于几要么我知道它“不可能是固定值”要么我暂时还没跑到这里、不知道。数据流分析就是一个不断把“不知道”变成“知道”的迭代过程。合并规则也很直观如果多条路径到达同一个程序点且某个变量在路径 A 上是常数 1、在路径 B 上是常数 1那合并后它还是常数 1如果路径 A 上是 1、路径 B 上是 2那合并后就是“不是常数”。这就是所谓 meet交操作常数c与常数c相交为c任何常数与另一个常数相交都变成notconstantundef与任何值相交都等于那个值。undef的处理是整个算法里最难讲清楚、也是最容易实现错的地方。如果某个变量在一条路径上还没被赋值undef但在另一条路径上是常数 2合并结果应该是常数 2而不是 notconstant。这就是“乐观”语义在起作用——先把所有变量当作未定义然后在可证的情况下逐步提升为常数。3.2 两种经典实现路径常数传播的实现大致分两个流派。第一个流派是经典数据流分析先做到达定值reaching definitions分析拿到每个使用点对应的定值集合然后循环迭代如果某个定值是x c且 c 是常数且它是到达该使用点的唯一定义就把该使用点替换为常数 c否则标记该变量在该点为非常数。这个方案直观不需要控制流图的特殊结构但一个根本弱点是到达定值分析本身比较粗变量和定值之间是“集合关系”一旦某个变量在一个程序点有超过一个“可能定值”就必须保守地退化为非常数哪怕那些定值实际上来自不可达分支。第二个流派是 SSA 形式下的稀疏传播你可以叫它稀疏简单常数传播或者用更朴素的说法“只跟踪每个变量一个版本的传播”。SSA 形式下每个变量只有一次赋值所以变量天然只有一个“版本”。常数传播就变成了一个带权图上的工作列表传播问题从常数的 def 出发沿着 use 链传播遇到phi节点时把每个 incoming 的常数做 meet 合并如果某个变量的所有 incoming 都收敛到同一个常数就确定它是常数否则标记为非常数。SSA 的好处是剔除了大量“这可能是那个定义、也可能是另一个定义”的集合推理传播路径被压缩到极致效率和精确度都更高。我不会在这里给出完整的 SSA 构建代码因为那本身就是一门大学问。但如果你的 IR 就是 SSA常数传播的实现其实是相当清爽的def sparse_constant_propagation(cfg): # 初始化把每条指令加入工作列表 worklist list(cfg.all_instructions()) lattice {instr: BOTTOM for instr in cfg.all_instructions()} while worklist: instr worklist.pop() # 对每条指令根据操作数和当前格子信息计算其可能的值 new_val evaluate_instruction(instr, lattice) if new_val ! lattice[instr]: lattice[instr] merge(lattice[instr], new_val) # 如果发生了变化就把所有使用这条指令结果的地方重新加回工作列表 for user in instr.users(): worklist.append(user) return lattice这个循环看起来简单但对初学者的劝告是merge和evaluate_instruction里的细节才能决定正确性。phi节点怎么处理、undef怎么参与运算、call指令的结果怎么处理通常直接保守标记为非常数这些都是分分钟能埋坑的位置。3.3 实操演示与边界问题下面用一个简单的例子演示 SSA 下的常数传播。假设有如下 SSA IRentry: %a add i32 2, 3 ; a 5 br label %loop loop: %i phi i32 [ 0, %entry ], [ %i.next, %loop ] %b add i32 %a, %i %i.next add i32 %i, 1 %cond icmp slt i32 %i.next, 10 br i1 %cond, label %loop, label %exit exit: ret i32 %b%a是2 3既能直接折叠成 5也是常数传播的起点。%i在phi里有两个 incomingentry 路径贡献 0loop 路径贡献%i.next。第一次迭代时%i.next还是 undef所以%i乐观地取值为 0接着传播到%b得到%b 5 0 5%i.next算出后为 1回到phi时%i的 inflowing 变成 0 和 1meet 的结果是“不是常数”于是%b的传播也终止在非常数状态。最后实际产生的替换是%a被替换为常数 5%b %a %i被替换为%b 5 %i然后交给常数折叠。%i没有被传播掉因为循环里它确实会变。这个例子暴露出边界问题常数传播的精确度受限于分支结构。循环、phi、不可达代码都会让传播变复杂。你在实现时一定要问自己几个问题函数参数能当作常数吗通常不能除非你做了过程间分析。内存加载load结果能当作常数吗一般不能除非你能证明那块内存本轮没有被写。volatile 变量显然是传播的禁区编译器不能假定它保持不变。这个优化有个很经典的平衡点传播得太大胆可能把运行时才变化的值当成常数导致语义错误传播得太保守则很多机会发现不了。真正的答案不是越激进越好而是一定要遵循“只有数据流证明它不变我才敢替换”的纪律。4. 复制传播不起眼的“接线工”比起常数传播复制传播在教材里存在感不高但它带来的化简效果同样重要。我刚学编译器优化时甚至觉得复制传播有点蠢把b a这种赋值里的a替到后面去用这不就是变量替换吗后来在实现中才意识到这项优化是“化简数据流图”的利器。4.1 复制传播在做什么复制传播的核心任务是把形式为x y的复制语句的信息传播到后续对x的使用点上去。也就是说如果一个程序点上x一定等于y那么后续用到x的地方可以改写为使用y。举个例子a b; c a 1;复制传播把c a 1里的a替换成b得到a b; c b 1;替换之后如果a没有被再次使用那条a b就变成死代码交给 DCE死代码消除一删了之c b 1;为什么要费这个劲看似多了一个中间变量又少了一个有何收益答案是复制的存在不仅在指令层面多占用寄存器还会斩断编译器对值的追踪能力。设想b本身是一个常数或者是一个复杂表达式的计算结果如果没有复制传播后续优化要对a做常数传播、对b做传播结果要跑两遍而复制传播先把“同值不同名”的多个变量收敛成一个后续所有优化只需要围绕一个名字展开计算和分析的量都降下来了。4.2 实现要点与数据流分析在非 SSA 的 IR 上做复制传播需要做可用复制分析available copies analysis。这是一种前向数据流分析在程序每个点记录当前成立的那些复制关系x y。遇到未经过重新定义的指令时复制关系继续保持遇到给x重新赋值的指令就把以x为左值的复制关系移除遇到给y重新赋值的指令把以y为右值的复制关系也移除遇到函数调用通常需要保守地使所有涉及被调用函数可能修改的变量的复制关系失效。有了可用复制集合后传播过程就顺理成章在每个使用x的点查询可用复制集合里是否存在x y如果存在就把x替换为y。但这里有一个细节很容易忽略考虑多路径汇合时如果一条路径上x y成立另一条路径上y已经被重新赋值或者x被重新赋值那汇合处这个复制关系就不成立了。所以可用复制分析的交meet操作是集合交集——只有所有路径都成立的复制关系才能使用。如果 IR 是 SSA 形式复制传播可以被简化为一个几乎线性的过程因为 SSA 下每个变量只有一次赋值若定义是一条复制指令%x %y那%x的所有使用点都可以直接替换成%y然后把那条定义指令标记为死代码。不需要额外的数据流迭代。这也是为什么现代编译器在 SSA 上实现复制传播时看起来非常简单。4.3 注意事项与限制复制传播虽然简单有些坑必须反复提。第一是别名问题。如果 IR 里有指针操作x y这条复制本身不涉及内存但后续代码中可能有p x; *p 42;这样的操作它会隐式地修改x的值这时复制关系x y必须失效。别名分析做不好复制传播就会把不该替换的地方替换掉生成错误代码。第二是 phi 节点与复制传播的交互。在 SSA 里%x phi [%w, %bb1], [%v, %bb2]这种节点如果所有 incoming 都来自同一条复制关系的结果那么%x可能也能被替换。但处理不好容易在 loop 里导致传播范围爆炸。第三是不要把复制传播和死代码消除耦合太深。偶尔我在工程里见过同学写优化通道复制传播一边替换一边顺手删掉“看起来没用了的复制指令”这很危险。因为你以为没用的复制指令可能在别的路径、别的优化尚未执行前还有定义性作用——比如它可能被某个 phi 节点引用或者它自身带有 side effect有些自定义 OP 指令即便结果是复制也可能有性能影响但那是另一回事。正确做法是先做复制传播的替换把定义标记为“可删除”但不真正删除等 DCE 作为一个独立通道在后续运行时统一消灭所有不可达、无用的代码。记住一个纪律一个优化通道最好只做一件事替换归替换删除归删除。把多个职责混在一个 pass 里短期写着爽长期改起来就是噩梦。5. 三个优化如何协同实战代码效果对比前面分别讲了三个优化的实现与边界现在把它们串起来用一个完整的小案例展示整个优化链的演进。这也是我在调试自己的编译实验时最常用的一个验证方式。5.1 一个综合示例的完整优化链我用一段简化过的三地址码来演示尽量贴近实际 IR 的感觉。假设初始代码是a 10; b a; c b 2; d c * 3; return d;先看如果不做任何优化后端要为它分配多少临时变量、生成多少指令。理想状态下我们希望最终变成return 36;现在开始逐步过流程。5.2 输出IR的变化过程第一轮先跑复制传播。发现b a是一条复制指令而b随后只在c b 2里用到所以把那个b替换成aa 10; b a; // 现在无人使用 b 了 c a 2; d c * 3; return d;第二轮跑常数传播。a的定值是常数 10可以传播到所有使用点。注意虽然b a中a的使用也符合传播条件但它在复制传播后已经没有使用者了所以要不要替换都行a 10; b 10; // 可能被上面复制传播清掉了留着也无妨之后 DCE 删 c a 2; // 替换成 c 10 2 d c * 3; // d 的使用 c等下一轮传播 return d;第三轮常数折叠。c 10 2折叠成c 12a 10; c 12; d c * 3; return d;第四轮常数传播再次发挥作用把c 12传播到d c * 3a 10; c 12; d 12 * 3; return d;第五轮常数折叠把12 * 3折叠成36return 36;到这一步前面那些赋值指令全部可以交给 DCE 清掉。最终的 IR 空前简洁。我用表格记录一下每一轮后的活跃指令数看起来更直观阶段活跃指令数说明初始 IR5 条含一处冗余复制执行复制传播4 条1 条可删复制把 b 的引用替换为 a执行常数传播4 条1 条可删复制把 a、c 的值传递到使用点执行常数折叠4 条1 条可删复制c 被折叠为 12d 的表达式未折叠再次传播与折叠3 条d 被折叠为 36DCE 清理1 条return 36实际编译器的 pass pipeline 通常不一定是严格按这个顺序跑一轮就收工而是多轮迭代但逻辑是一样的。你可以在自己的 IR 工具里加一个统计函数打印每个 pass 执行前后“可删除指令数”的变化一眼就能看到协同效果。5.3 实测性能影响与建议的优化顺序从上面的例子可以总结出一个实战推荐顺序复制传播先行把同值不同名的变量收敛常数传播跟上把能确定的常量值带到使用点常数折叠收尾把传播过来的常量和字面量一起算掉DCE 断后清掉所有无用的复制和赋值重复以上轮次直到 IR 没有显著变化。为什么要反复迭代因为优化之间存在“释放机会”的关系。复制传播解锁常数传播的机会常数传播又给常数折叠提供原料折叠之后往往又冒出来新的死代码和新的可复制关系。现实中一个合格的优化 pipeline 不会只跑一遍而是一个固定点迭代fixed-point iteration。我个人的倾向是在自己的编译实验项目里先跑 2~3 轮“传播折叠DCE”的组合再进入后端。跑 3 轮以上收益就开始边际递减了除非代码里有特别复杂的嵌套结构。6. 常见问题与排查技巧实录写编译器实现最难的不是算法本身而是调试。下面这些坑都是我实际撞过的整理成问题速查表给你省点时间。6.1 优化没生效先查这几件事优化跑完发现 IR 一点变化没有或者只优化了一部分。这时按以下顺序排查命中率很高检查数据流分析的初值。很多“什么都没发生”的问题根源是把 meet 操作的对象写反了或者初值设错了。常数传播是乐观的初值应为 undef最乐观而如果是可用复制分析这种 must 分析初值应为“全集”所有复制关系都成立后续逐步收缩。检查是否误把函数调用当成了无副作用。如果 IR 里函数调用后的变量没有被正确标记为非常数所有的传播都会卡在函数边界。最简单的办法是先保守遇到调用就把所有相关内存变量全部标记为非常数后面再做更精细的分析。检查是否漏了 phi 节点的数据处理。SSA IR 中如果 phi 的 incoming 值没有参与 meet循环中的常数传播就断了。这个问题在调试时很难直接观察我建议写一个断言所有 phi 节点在数据流迭代中必须至少被访问一次。检查复制传播是否误删了活跃复制。如果 DCE 还没跑就把复制指令直接删了后续再使用它的地方就崩了。我前文特地强调过替换归替换删除归删除。如果你的 pass 把职责耦合在一起出了这种问题拆开重写反而是最快的解法。6.2 调试优化器的实用方法调试编译器优化和调试普通应用完全不一样你不能打断点看“运行到这一步的值”因为一个 IR 指令的执行语义需要你先把整个解释器或者编译流程跑起来。我的经验是提前准备好下面几件工具IR 转储dump工具。每个 pass 执行前后都可以打印一份可读的 IR 文本。用 diff 对比优化前后的变化能最快定位到哪条指令没被处理。一个极简的解释器。给 IR 做一个按语义解释执行的小型解释器优化前后运行结果应该完全一致。如果出现不一致就拿到了一个极好的测试用例。随机生成器或手工构造的坏 Case 库。我建议把输入控制在十个基本块以内专门覆盖单分支、双分支、循环、phi 汇聚、同值不同名、浮点运算不折叠等场景。统计计数器。在每个 pass 里嵌入一个“成功替换次数”的计数器优化流水跑完输出一份统计报告。这个能帮你快速判断哪个 pass 的执行效率差哪个 pass 根本没跑出效果。这里有个经验之谈常数值传播的结果如果与分析结果对不上十有八九是 meet 操作的顺序写反了——在常数格上undef是最大值notconstant是最小值constant(c)在中间。你如果把 meet 实现成了“取最大值”而不是“取下确界”整个分析就会变成“乐观得没边”把非常数的变量也传播成常数那问题就严重了。6.3 容易踩的坑速查表问题现象可能原因排查/修复思路优化后 IR 比优化前还多指令复制传播替换后没有标记原复制为可删给 DCE 提供可删指令列表由 DCE 统一删除循环里的常数没传播出来phi 的 incoming 值没有 meet 合并检查 phi 处理逻辑确保所有 incoming 参与 meet两个不同的常数被 meet 成同一个常数常数格的 meet 操作写反复查 meet 函数const(c1)meetconst(c2)应为notconstantc1≠c2浮点表达式优化后结果变了编译器折叠了浮点运算默认关闭浮点折叠除非显式允许复制传播把别名相关的内存值替换错了没有失效被指针修改的复制关系引入保守的别名失效或在遇到 store 时清空相关复制集合折叠把有副作用的表达式也算掉了折叠条件没有排除 call / load折叠前检查“纯表达式”属性这张表是我亲手踩过的浓缩版。别人的坑可能不同但方向大抵一致。7. 实操心得与扩展方向到这里三个优化的主干内容都讲完了。最后聊几句我自己的体会。第一点是这三个优化虽然是“前期优化”但在教学编译器里把它们实现好能帮你打通对数据流分析的整体理解。常数传播是“信息流动”的入门复制传播是“值同构”的入门常数折叠则是“树的化简”。三者中手里的任意一个都能作为跳板延伸到更多高级优化死代码消除建立在活跃性分析上强度削减建立在表达式化简的能力上全局寄存器分配则需要精确的活跃区间信息。第二点是顺序的重要性远比单个优化的高级程度重要。我在自己做的一个模拟项目里把一份 200 行左右的三地址码喂给优化通道。第一版我直接写了个庞大复杂的常数传播效果普通后来我改成先复制传播、再常数传播、再折叠、再 DCE 的几轮迭代同样的代码在第三轮时已经比第一版少了大概两成左右的指令。排序对了简单的优化能顶半个高级优化。第三点是这些优化真正的战场在 SSA 形式下。如果你的 IR 还没转 SSA建议尽早考虑迁移。因为复制传播、常数传播在 SSA 下都能写成接近线性时间的算法调试时看 IR 也清晰得多。非 SSA IR 上做这些优化不仅慢还容易在偏路径上出错。最后再分享一个调试小技巧每次跑完一轮优化把优化前后的 IR 分别喂给同一个极简解释器比对最终返回值。只要返回值不一致就立刻把分析范围缩小到第一个返回值出现差异的指令上。这个做法我在每次优化改动之后都会跑一遍比任何单元测试都管用。这三个优化看似基础却是编译器中端优化的灵魂骨架。能把这几个环节做得干净利落后端优化才有足够的空间施展拳脚。如果你也在写自己的编译实验不妨按这个顺序去实现一遍跑完一轮看到 IR 从冗长变简练的那一刻你会觉得前面调数据流的辛苦都值了。