做编译原理课程的人,十有八九都会在中间代码生成这一章卡一下。前面词法分析、语法分析再难,好歹是在"读",到了这一章突然变成"写",要自己设计一套中间表示把语法树翻译过去,很多同学就是从这儿开始掉队的。这篇东西我按自己带着学生做课程实验、也实际写过小编译器的经验来写,不谈虚的,直接讲中间代码生成里最核心的几件事:为什么要有中间表示、三地址码怎么回事、表达式和控制流怎么翻译、回填怎么做,最后聊聊SSA和工程里真实的中间表示长什么样。
1. 为什么要有中间代码:从AST往前走这一步的真正价值
中间代码生成在整个编译流程里的位置,前面是语义分析产出的抽象语法树,后面是代码优化和目标代码生成。很多人第一次学到这儿会问一句:语法树都分析出来了,直接翻译成汇编不行吗?非要中间插一层,不是脱裤子放屁吗?
1.1 前端后端解耦是中间代码存在的根本理由
这个问题的答案,得从编译器的工程架构说起。你写一个C语言编译器,总希望它能同时输出x86、ARM、RISC-V几个平台的机器码吧?如果语法分析做完直接生成汇编,那每支持一个新平台,整个编译器的后半段全要重写,词法分析、语法分析这些跟平台无关的工作也得跟着遭殃。
中间代码这一层,本质上就是把"分析"和"生成"切开的一道缝。前端只负责把源代码变成与机器无关的中间表示,后端只负责把中间表示变成某个具体平台的机器码。这样换平台的时候前端完全不用动,换语言的时候只要新语言的前端能产出同一种中间表示,后端也完全不用动。LLVM能支持那么多语言和那么多后端,靠的就是LLVM IR这层中间表示。
类比一下就是:前端是设计院画图纸,后端是施工队干活,中间代码就是那张标准化的施工图。设计师不用懂混凝土标号,施工队也不用懂方案构思,只要图纸是同一套规范,谁跟谁都能对接。
1.2 中间代码给优化留出了真正的操作空间
还有一个理由跟优化有关。AST是很高层的结构,它保留了源代码里的语法信息,比如循环、分支这些都是现成的节点。但高级语法结构对优化器来说并不友好——优化器想在"变量赋值""算术运算""跳转"这种最基础的粒度上做文章,而不是在WhileStatement这种节点级别上。
三地址码这种扁平的指令序列,每条指令只干一件简单的事,数据流分析做起来才顺手。比如常量传播,在中间代码上就是扫一遍把t2 = t1 + 1里的t1替换成已知常量,放到AST上你得自己在语法树里爬来爬去找赋值表达式节点,累都累死了。
记住这个观点:中间代码是为优化器准备的,不是给人类看的。所有中间表示的设计出发点,都是"怎么让机器分析和变换起来最省事"。
2. 三地址码与四元式:最朴素的中间表示长什么样
三地址码是整个中间代码生成这一章的基石概念。名字听着玄乎,其实特别直白:一条指令里最多出现三个地址(两个操作数加一个结果),比如t = a + b里的a、b、t就是三个地址。
2.1 从一段C语段到三地址码:先找找感觉
拿最经典的例子开刀,把c = a + b * 2翻译成三地址码,第一反应是照着运算符优先级老老实实来:
t1 = b * 2 t2 = a + t1 c = t2这就是三地址码的核心形态。t1、t2是编译器临时生成的变量,用来保存中间结果。注意一个细节:翻译的顺序是先右子树后左子树,这跟我们做语法树的后序遍历完全一致。所以从AST生成三地址码,本质就是一次带上"临时变量分配"的后序遍历。
再看个更复杂点的表达式,比如带下标的数组访问a[i] = 2 * a[i] + 1:
t1 = i * 4 // 计算偏移量,int占4字节 t2 = a + t1 // 算出元素的地址 t3 = *t2 // 取a[i]的值 t4 = 2 * t3 // 2 * a[i] t5 = t4 + 1 // 加1 t6 = a + t1 // 重新计算a[i]的地址 *t6 = t5 // 写回这里暴露了一个非常现实的问题:同一个表达式a[i]的地址被算了两遍。如果用户写的是a[i] = 2 * a[i] + 1,编译器真的会蠢到算两次吗?在没做优化的朴素翻译里,真的会。这个问题靠的就是后面代码优化阶段的公共子表达式删除(CSE)来解决,三地址码这种"每条指令只做一件事"的格式,让CSE这类优化做起来特别方便——因为它一眼就能看到a + t1被重复计算了。
2.2 四元式、三元式和间接三元式的取舍
三地址码落实到具体数据结构上,最常见的是四元式,也是国内教材讲得最多的形式。四元式就是四个字段:(op, arg1, arg2, result)。上面那段数组访问的代码用四元式表示就是:
( *, i, 4, t1 ) ( +, a, t1, t2 ) ( =*, t2, _, t3 ) ( *, 2, t3, t4 ) ( +, t4, 1, t5 ) ( +, a, t1, t6 ) ( =, t5, _, t7 )op是操作符,arg1和arg2是两个操作数,result是结果存放位置。那个=*是我用来表示"取值"运算的写法,不同教材记法不一样,有的写作[]=或者load,看习惯就行。
四元式最大的好处是修改容易——每条指令都是独立的四元组,想插入、删除、移动一条指令,操作一个数组元素或者链表节点就行。代价是临时变量多得吓人,比如上面那个例子里的t1到t7。
三元式把i操作数换成指针,直接引用另一条指令的结果,省了临时变量,但移动指令的时候所有引用它的地方都得跟着改,优化器用起来很痛苦。所以工程上四元式更流行,LLVM IR本质上也是一种广义的四元式。
3. 表达式与赋值语句的翻译:细节全藏在寻址和类型转换里
表达式翻译是整个中间代码生成里最"机械"的部分,逻辑上就是个树的后序遍历。但在实际写代码的时候,会碰到几个教科书上容易一笔带过、实际却会让你卡半天的细节。
3.1 临时变量的命名与管理规则
临时变量叫t1、t2还是tmp_a都无所谓,关键是怎么保证不重名。最土的办法是维护一个全局计数器,每生成一个临时变量就加一。但有个坑:如果你把t1这种名字直接用作目标平台上的寄存器或者栈变量名,一旦用户源代码里也定义了同名变量,就撞车了。
工程上的做法是给编译器内部符号加前缀或者在符号表里单独建一个命名空间。比如编译器生成的临时变量统一叫%t1,因为%在C语言里不是合法标识符,永远不会跟用户变量冲突。JVM字节码里的临时变量干脆没有名字,直接用$0、$1这样的槽位编号,从根源上杜绝了撞车。
另一个细节是临时变量能不能复用。朴素实现是每条计算结果都用新临时变量,这样代码是对的,但临时变量会爆炸。稍微聪明点的做法是DFS遍历表达式树时,算完t1之后如果t1不再被需要,下一个结果可以继续写进t1。这就是寄存器分配里"活跃变量分析"的雏形,优化阶段会专门做,初学阶段不用自己折腾,老老实实递增计数器就行。
3.2 类型不一致时的自动转换指令
C语言里写int i; double d; x = i + d;,如果x是double,翻译的时候就必须在中间代码里显式插入类型转换指令。不会真的直接生成t2 = i + d——因为CPU的整数加法指令和浮点加法指令根本不是一回事。
翻译的实际流程是:
t1 = int_to_double(i) // 把int提升成double t2 = t1 + d // 浮点加法 x = t2这就是语义分析阶段已经算好的"类型合一"结果在中间代码生成阶段落地。不同语言隐式转换规则差别很大,C的整型提升、Java的数值拓宽、Python那种全是对象的模型,到了类型转换这一块都会体现在中间代码里。
这块有个常见实验坑:如果你实现的编译器支持int和float混合运算,一定要在中间代码里区分+(int)和+(float)两种指令,不能图省事都用同一个+。否则后面优化阶段跟汇编生成阶段根本不知道这条加法是整数加还是浮点加。
3.3 数组寻址的偏移量乘法
数组访问a[i]翻译成地址计算时,i必须乘以元素大小。很多第一次写编译器的人会忘记这一步,直接生成t2 = a + i,然后跑到第三天突然发现int a[10]的输出完全不对。
元素大小怎么来?语义分析阶段符号表里已经存了数组元素的类型信息,生成中间代码时从符号表里查一下元素类型,乘上该类型占用的字节数就行。如果是结构体数组或者二维数组,偏移计算会更复杂,但本质还是"每个维度的下标乘以对应维度的跨度,最后加起来"。
这里有个性能优化的小技巧:如果数组元素大小是2的幂,比如4、8、16,偏移量乘法i * 4可以直接优化成移位i << 2,这个优化在中间代码层就能做,不用等到汇编阶段。
4. 控制流语句的翻译:标号、跳转和语句的嵌套
控制流语句翻译的核心挑战,在于把嵌套的结构化语句翻译成扁平的跳转指令序列。如果你只处理单条if-else或者单个while,那很简单,但一旦语句嵌套起来,跳转目标标号的生成和管理就会变得棘手。
4.1 if-else 和 while 的翻译模板
先看最基础的if (E) S1 else S2。它的跳转结构长这样:
计算E的值 if E为假 goto L_false <S1的代码> goto L_next L_false: <S2的代码> L_next:这里有两个关键点。第一,条件跳转的指令必须是一个独立的语句,也就是说要先计算好条件表达式E的值,存进临时变量,再根据临时变量是否为0跳转。第二,goto L_next这条跳转是怎么来的?如果你不写它,S1执行完之后会直接掉进S2的代码块,逻辑就错了。
再来个while (E) S的翻译模板:
L_begin: 计算E的值 if E为假 goto L_end <S的代码> goto L_begin L_end:这种模板在课堂练习里玩玩绝对没问题,但真实编译器里通常会做一个优化——把条件的跳转指令反过来用,减少不必要的跳转指令。比如if (i < 10)不再翻译成"取反跳转",而是直接生成if i >= 10 goto L_false。翻译的时候需要知道i < 10的对偶比较是i >= 10,这就要根据运算符类型查表了。
4.2 嵌套语句的标号编号问题
模板很简单,但嵌套起来就麻烦了。先看这个:
if (x > 0) while (y < 10) y = y + 1;翻译过程中会涉及多层语句块的跳转标号管理。你手工在纸上推演没问题,但写代码的时候就知道,每个语句翻译都需要知道自己该往哪里跳、从哪里接着跳。
经典的做法是给每个语句的翻译函数传两个额外的参数:next(这个语句执行完之后跳到哪里)和break_continue(break和continue的目标标号),返回值是它生成的代码序列需要留下的"未完成跳转链"。这样从外层往内层递归翻译时,外层把内层需要的break目标值传进去,内层翻译完再把剩下的未完成跳转目标交给外层回填。
用递归下降的思路来写控制流翻译,比一口气生成完整跳转逻辑要清晰得多,这也是为什么很多人的课程设计里中间代码生成都用递归下降而不是用YACC——YACC做语法分析很爽,但往语义动作里塞翻译代码很容易把动作顺序搞乱。
4.3 短路求值在控制流语句里的自然体现
C语言里&&和||是短路求值的,a && b在a为假时不会计算b。这个语义在翻译if (a && b)时是天然要求跳转的:
if a == 0 goto L_false if b == 0 goto L_false goto L_true L_false: 条件为假的代码 L_true: 条件为真的代码这跟上一节布尔表达式的翻译是配套的。如果你实现的编译器把&&翻译成"先算出结果再判断真假"(也就是不短路),那严格来说语义就已经错了——因为a && b在a为假时要求b根本不被求值,不短路等于改变了程序行为。
注意:
&&的短路求值不是优化,而是语言语义的一部分。同理,if (p != NULL && p->value > 0)这种代码在非短路语义下会直接崩溃,所以翻译的时候千万别图省事把布尔表达式先整体算值。
5. 布尔表达式的回填技术:链条式管理待定跳转目标
控制流翻译里最绕的一个点,就是布尔表达式的回填。很多同学在中间代码生成这一章第一次接触"回填"这个概念,觉得玄乎,其实就是先把跳转指令生成好,但目标地址暂时空着,等条件算出来之后再回头把地址补上。
5.1 为什么不能一次就把跳转目标全定下来
假设你要翻译if (a > b && c < d) S,这个条件下面的S还没翻译,你怎么知道条件为真时该跳到哪?所以翻译布尔表达式时,你只能先给每条跳转指令分配好位置,但跳转目标用"待定"标记着。真正往回填的时候,是等S的代码生成完之后才知道真分支的目标地址。
这个"先创建未完成跳转、最后回头填地址"的机制就叫回填。为了知道"哪些指令需要回填",每翻译完一个布尔表达式,你要记录两条链子——真链(条件为真时跳转的所有指令列表)和假链(条件为假时跳转的所有指令列表)。
5.2 手工实现回填的一个简单思路
用一个链表存"待回填指令列表",每个节点记着指令序号和一个指向跳转目标的占位符。翻译布尔表达式E1 && E2时:
- 翻译E1,生成
if E1 == 0 goto ?,这个?放进E1的假链。 - 翻译E2,同样生成跳转指令,跳转目标待定。
- 合并E1和E2的真链,假链则保留两者各自的部分。
- 等上层语句(比如
if)知道真/假分支的真实地址后,遍历这些链子,把占位符替换成真实标号。
实现的时候别忘了,链表里的每个节点必须能定位到具体的跳转指令,不然回填的时候不知道该改哪条指令的字段。四元式的result字段直接用指令序号,回填就是往四元式数组的第几个元素里写目标地址,数据结构选对了会省很多事。
5.3 回填过程的一个完整例子
拿a > b && c < d做真链和假链演示。假设翻译顺序如下:
(104) if a > b goto ____ ; 真链,目标待定 (105) goto ____ ; 假链,目标待定(条件整体为假时到此) (106) if c < d goto ____ ; 真链,目标待定 (107) goto ____ ; 假链,目标待定&&的结果是"两个条件都为真才为真",所以整体真链是104和106两条,整体假链是从105和107两条分别跳出的链。等if语句翻译完,知道了真分支的目标是L_true,假分支目标是L_false,就遍历真链把104和106的目标都改成L_true,遍历假链把105和107的目标都改成L_false。
这个机制的巧妙之处在于,"链"是可以合并、可以拆分的,嵌套表达式翻译时真链假链不断增长,但每条跳转指令只属于一条链,不会搞混。
6. 从三地址码到SSA:现代编译器的中间表示进化史
到这里,你已经掌握了经典教材里的中间代码生成全流程——三地址码、四元式、回填。但如果你去读LLVM的文档或者GCC的内核代码,会发现现实世界的中间表示已经往前走了一大步。
6.1 静态单赋值形式(SSA)到底好在哪
静态单赋值(Static Single Assignment, SSA)的核心约束特别简单:每个变量只能被赋值一次。如果要给同一个变量多次赋值,就不断地"创造新版本":
// 普通三地址码 x = 1 x = x + 2 y = x * 3 // SSA形式 x0 = 1 x1 = x0 + 2 y0 = x1 * 3光看这个例子,你可能觉得SSA只是把变量改了个名。但它的威力在于,当程序有控制流分支时,一个变量在不同分支里可能被赋予不同值,汇合之后到底取哪个?这时候就需要phi函数(也叫φ函数)登场:
L_entry: if cond goto L_a else goto L_b L_a: v1 = 10 goto L_join L_b: v2 = 20 goto L_join L_join: v3 = phi(v1, v2) // 从L_a来取v1,从L_b来取v2有了SSA和phi函数,很多优化的正确性判定就变得极其简单。比如死代码删除,普通三地址码里你得做活跃变量分析才能判断一个赋值有没有被使用,SSA里一个值如果从来没被引用,删掉就行——因为每个变量只有一次定义,引用关系一目了然。
说实话,本科编译原理课程能把经典三地址码和回填掌握好,就已经很扎实了。SSA可以作为印象分去了解,但不必在课程设计里硬上,不然一个学期下来可能光在折腾phi函数的插入问题上。
6.2 真实世界里的三种常见中间表示
工程里的中间表示不只是"四元式"一种形态,不同编译器选了不同路线:
| 中间表示 | 代表编译器 | 特点 |
|---|---|---|
| 线性指令序列(三地址码风格) | 经典教材、某些Java编译器 | 直观、容易翻译,但优化信息少 |
| 树形/图结构(如AST直接优化) | 某些脚本语言实现 | 方便做高层优化,但低层优化不顺手 |
| 基于栈的字节码 | JVM、Python的bytecode | 指令紧凑、解释器实现简单,但要进行数据流分析比较别扭 |
GCC走的是"GIMPLE"路线,一种把表达式拆到极简的三地址码,LLVM走的是SSA形式的IR,JVM走的是栈式字节码。有意思的是,栈式字节码跟前两种都不太一样——它的指令没有显式操作数,全靠栈顶数据来运算。比如i = a + b在JVM字节码里是:
iload_1 ; 把本地变量1(a)压栈 iload_2 ; 把本地变量2(b)压栈 iadd ; 弹出两个值相加,结果压栈 istore_3 ; 弹出栈顶值,存入本地变量3(i)这种设计让字节码非常紧凑,解释器也简单,但做优化的时候就需要先把栈式指令"还原"成类似三地址码的形式。这就是为什么Java的JIT编译器内部其实也是先做一次"stack-to-register"转换,再进入优化流程。
7. 实验里最容易翻车的三个细节
最后分享几个在课程实验和实际写编译器过程中踩过的坑,每一个都花了我不少时间才定位到。
7.1 标号命名空间必须独立管理
生成标号时如果直接叫L1、L2这种名字,建议配合一个全局计数器。但更隐蔽的问题是:if语句的真分支和假分支里如果各自包含另一个if,内层if生成的标号可能与外层冲突。我见过有同学用if_1、while_2这种手工命名方式,一旦嵌套层数变多就乱套了。
最稳妥的做法是维护一个全局整数label_counter,每次需要新标号就label_counter++,然后生成一个在源代码层面不可能出现的名字,比如__L%d。临时变量同理。
7.2 四元式的结构别拘泥于"四个字段"
教科书上四元式是(op, arg1, arg2, result),但实际用的时候你会发现总有指令用不满四个字段。比如无条件跳转goto L,实际上只有op和result两个字段;取值运算t3 = *t2需要一个"间接寻址"标志;函数调用call foo需要参数的个数信息——这时候result字段塞的就不是变量名,而是一个参数数量。
灵活处理的方式是:设计结构体加一个extra字段或者attr标记位。我建议你在一开始设计数据结构时就留好扩展位,不然写到函数调用那一周,天天都在改前面的数据结构定义。
7.3 翻译完一定要做"手推验证"
中间代码生成这个阶段,几乎没有调试器能直接帮你检查"翻译得对不对"。我个人的土办法是:找几段覆盖各种语法结构的测试用例,把手推的中间代码写在纸上(或者注释里),然后跑程序比对自己的输出。
比如测短路求值,就用if (a != 0 && b / a > 1)这种用例——如果生成的中间代码不短路,运行时就会除零崩溃。测回填就用嵌套的if-else if-else if,确保每个分支的跳转目标都是对的。
写编译器这件事,中间代码生成部分可以说是"最像程序员日常写业务代码"的一个阶段——它没有特别玄的算法,就是一套又一套的规则翻译,但规则一多,边界条件就多,边界条件一多,就需要足够细心的测试来兜底。把这一章啃下来,你对"程序是怎么被计算机理解的"这件事的理解,会比前五章加起来都要深一截。