1. 从理论到实战:为什么我们需要中间代码生成?
如果你写过编译器,或者哪怕只是对编译原理有点兴趣,肯定都听过“中间代码”这个词。它听起来像个中间商,不生产代码,只是代码的搬运工。但恰恰是这个“搬运工”,是编译器设计中最精妙、最核心的环节之一。今天,我就想和你聊聊,如何把书本上那些抽象的语法制导定义(SDT)理论,一步步变成实实在在、可以运行的三地址码,并且用“回填”这个神技,解决控制流里那些让人头疼的跳转问题。
简单来说,中间代码生成就是编译器在“理解”了你的源代码(完成了词法分析和语法分析)之后,在把它变成机器码之前,先把它翻译成一种更简单、更规整的中间形式。你可以把它想象成建筑师的设计图。源代码是客户天马行空的想法(“我要一个带落地窗、开放式厨房、屋顶花园的房子”),目标机器码是最终的一砖一瓦。而中间代码,就是那张标准、规范、所有施工队都能看懂的工程图纸。它剥离了高级语言复杂的语法糖(比如 for 循环、++ 运算符),也还没绑定到特定CPU的古怪指令集(比如x86的复杂寻址模式),处于一个承上启下的完美位置。
那么,三地址码就是其中最常用的一种“图纸格式”。为什么叫“三地址”?因为它每条指令最多只涉及三个“地址”(可以理解为变量或常量)。比如 t1 = y + z,或者 if x < y goto L1。这种形式极度简单,几乎就是对着机器指令画瓢,但又保持了足够的独立性。我们设计SDT,本质上就是在定义一套规则:当语法分析器识别出“这是一个if语句”时,应该触发哪些“动作”,来生成对应的三地址码序列。这个过程,就是把我们对程序语义的理解,通过属性计算和代码生成,固化到编译器的血肉里。接下来,我们就从最基础的声明和赋值开始,看看这张“图纸”是怎么一笔笔画出来的。
2. 基石:声明与赋值语句的SDT设计与实现
让我们先搞定两块基石:变量声明和赋值语句。这是所有程序的基础,它们的翻译相对直接,但里面的一些设计思想,会贯穿整个中间代码生成过程。
2.1 声明语句:不仅仅是记个名字
声明语句的翻译任务很明确:分析每个标识符(ID)的种属(是简单变量、数组还是指针?)、类型(是int还是float?),然后在符号表里给它安个家——分配一个地址,并记录下所有必要信息。这里的关键是“类型表达式”,它不是int、float这样的简单名字,而是一个能描述类型所有细节的结构。
比如,int a[5][8]; 的类型表达式是 array(5, array(8, int))。这告诉我们:a是一个数组,它有5个元素,每个元素又是一个包含8个整数的数组。编译器需要根据这个结构,计算出a总共需要 5 * 8 * sizeof(int) 字节的内存空间,并在符号表里记下这些维度信息(这叫“内情向量”),以后访问 a[2][3] 的时候才能算出正确的地址。
为这种声明设计SDT,属性设计是灵魂。我们需要type属性来传递和组合类型表达式,需要width属性来计算类型占用的字节数。一个经典的坑是处理多维数组时信息的传递。看这个文法片段:T -> B C,其中B是基本类型(如int),C描述数组维度(如[5][8])。计算最终类型array(5, array(8, int))时,我们需要把int这个基本类型信息,一路向下传递到最内层的C,才能一层层组合回来。这通常需要一个继承属性(比如C.inh)或者一个全局临时变量来帮忙。
我写编译器时喜欢用一个全局变量elem_type来暂存当前数组元素的类型。当看到int时,就把elem_type设为int;当看到[5]时,就构造array(5, elem_type)作为新类型,并更新elem_type为这个新类型,以用于下一维。同时,用一个全局的offset计数器来分配地址:每声明一个变量,就把它的type.width加到offset上,实现连续分配。对应的SDT动作大概长这样:
D -> T id ; { enter(id.name, T.type, offset); offset = offset + T.width; }
enter函数就是把名字、类型和分配到的地址offset填进符号表。这一步做完,后续所有用到这个变量的地方,就都能从符号表里查到它的“门牌号”了。
2.2 赋值语句:生成三地址码的初体验
赋值语句的翻译,是我们第一次真正动手生成可执行的三地址码。任务很直观:对右边的表达式求值,然后把结果存到左边变量对应的地址里。
以一个简单的赋值 a = b + c * d 为例。我们首先要为表达式 b + c * d 生成求值代码。这里就引入了三地址码的核心特征:使用临时变量存放中间结果。翻译过程是递归的:
- 先翻译
c * d,生成t1 = c * d,结果存在临时变量t1里。 - 再翻译
b + t1,生成t2 = b + t1,结果存在t2里。 - 最后生成
a = t2。
在SDT里,我们为表达式E设计两个综合属性:E.addr(这个表达式值存放在哪里,可能是一个变量名如b,也可能是一个临时变量如t2)和E.code(生成的三地址码序列)。那么 E -> E1 + E2 的语义动作就可以写成:
E.addr = newtemp(); // 申请一个新临时变量,比如 t2
E.code = E1.code || E2.code || gen(E.addr ‘=‘ E1.addr ‘+’ E2.addr);
|| 表示代码序列的连接,gen()函数负责生成一条形式规整的三地址指令。
这里有个实践中马上会遇到的问题:增量翻译。如果严格按照上面这个方式,E.code会像滚雪球一样,不断复制子表达式的代码再拼接新的。如果表达式很深,这种复制开销会很大。我早期的实现就吃过这个亏。更好的办法是维护一个全局的代码列表(比如一个链表或数组),gen()函数不仅构造指令,还直接把它追加到全局列表末尾。这样,E.code属性甚至可以不需要,因为代码已经“流”到了最终的输出里。属性只需要关心addr这样的结果位置就行。这是把理论SDT落地时,第一个要做的性能优化。
2.3 数组引用:地址计算的艺术
当赋值语句遇到数组,比如 x = a[i][j],乐趣就来了。难点在于如何计算出 a[i][j] 在内存中的准确地址。对于声明为 int a[10][20] 的数组,元素 a[i][j] 的地址公式是:base_address + (i * 20 + j) * 4。其中 base_address 是数组a的起始地址(在声明时分配并存入符号表),20是第二维的宽度(sizeof(int[20])),4是int的宽度。
在SDT中,我们需要为非终结符L(代表数组引用,如a[i][j])设计几个关键属性:
L.array:数组的基地址(从符号表查得)。L.type:L所代表的子数组的类型。对于a[i][j],当L是a[i]时,L.type是array(20, int);当L是a[i][j]时,L.type是int。这个属性用于查询当前维度的宽度。L.offset:一个临时变量,用于累积计算出的偏移量(即(i*20 + j)*4这个值)。
对应的SDT动作需要逐步计算。对于 L -> L1 [E](增加一个维度):
- 获取当前维度的宽度
w = L1.type.element.width(从L1.type中提取元素类型的宽度)。 - 生成计算
E.addr * w的代码,结果存到新临时变量t中。 - 生成将
t累加到上一维偏移量L1.offset的代码,结果存到L.offset。 - 设置
L.array = L1.array(传递基地址),L.type = L1.type.element(类型向内层推进)。
最终,对于赋值 S -> L = E;,我们生成的三地址码类似于:gen(L.array ‘[‘ L.offset ‘]’ ‘=’ E.addr),这会被翻译成 a[offset] = x 这样的形式(其中 offset 是存放最终计算出的偏移量的临时变量)。这里要注意,L.offset 属性本身并不存储一个数值,而是存储了一个临时变量名(比如 t3),这个临时变量里才存放着运行时计算出的偏移值。这是语义属性(编译时计算)和三地址码(运行时执行)一个重要的区分点。
3. 控制流翻译:跳转指令与“未来地址”难题
赋值和表达式是顺序执行,而程序之所以灵动,在于它有选择(if-else)和循环(while)。翻译控制流语句的核心,在于正确生成条件跳转指令(if...goto...)。这里最大的挑战是:当你生成一条 goto L 指令时,标签 L 对应的目标代码地址可能还不知道在哪!
3.1 传统SDT方法:两遍扫描与继承属性
我们先看传统的SDT设计思路。以 if (B) then S1 else S2 为例。我们需要生成如下的代码结构:
... (B的求值代码) ...
if B为真 goto L_true
goto L_false
L_true:
... (S1的代码) ...
goto L_next
L_false:
... (S2的代码) ...
L_next:
... (后续代码) ...
问题来了:在翻译布尔表达式B的时候,我们就要生成 goto L_true 和 goto L_false,但此时 L_true 和 L_false 标签应该放在哪(即S1和S2代码的起始位置)还不知道呢!
传统的解决办法是使用继承属性。我们为布尔表达式B设计两个继承属性:B.true 和 B.false。在分析if语句时,我们提前为这两个位置创建好标签(比如L1, L2),但先不决定它们的具体值,只是把标签名作为属性值“继承”给B。B的翻译动作就生成 if ... goto B.true 和 goto B.false。然后,在翻译到S1和S2的实际位置时,再用 label(B.true) 和 label(B.false) 这样的动作把具体的指令地址“填”进去。
这个过程需要两遍处理:第一遍生成带有未解析标签的中间代码;第二遍遍历中间代码,把这些符号标签替换成实际的指令地址。这种方法概念清晰,但效率不高,而且因为B.true和B.false是继承属性(信息从父节点或左兄弟来),它通常要求使用LR分析等自底向上的分析方法,或者在语法树建立后进行一遍独立的属性计算遍历。
3.2 代码结构设计与SDT编写要点
设计控制流SDT,我有个习惯:先画流程图,再写产生式。把if、while语句期望生成的代码结构画出来,标清楚每个goto应该跳转到哪里。然后,再根据这个结构去设计属性和语义动作。
几个关键属性:
S.next:一个继承属性,表示语句S执行完后,应该跳转到的下一条指令的地址。对于if语句的then分支S1,它的S1.next就是整个if语句的出口L_next。B.true,B.false:如上所述,布尔表达式B为真/假时应跳转的地址。newlabel():一个函数,调用它会产生一个新的、唯一的临时标签名(如L1,L2),用于占位。
以 while (B) do S1 为例,它的代码结构应该是:
L_begin:
... (B的求值代码) ...
if B为真 goto L_body
goto L_next
L_body:
... (S1的代码) ...
goto L_begin
L_next:
... (后续代码) ...
对应的SDT设计就需要考虑:B.true 应该是 L_body,B.false 应该是 L_next,而 S1 执行完后需要跳回 L_begin。在语义动作中,我们需要在合适的位置调用 newlabel() 创建这些标签,并通过属性传递它们,最后在正确的位置用 label() 动作绑定实际地址。这个过程非常精细,一个属性传递错了,整个控制流就全乱了。
4. 回填技术:一劳永逸的地址解决方案
传统SDT的两遍处理和继承属性让人头疼。有没有办法一遍就搞定,而且还能用更简单的S-属性文法(所有属性都是综合属性)来实现呢?回填(Backpatching)技术就是答案。 这是我个人认为中间代码生成中最优雅、最实用的技术之一。
4.1 核心思想:用列表管理“待办事项”
回填的核心思想非常巧妙:既然生成goto指令时不知道目标地址,那我们就不写死地址,而是生成一个“空洞”,比如 goto _。同时,我们把所有跳转到同一个目标地址的“空洞”指令收集到一个列表里。等到后来我们知道了这个目标地址(比如,知道了then分支代码的起始位置),我们再一次性把这个地址“回填”到列表里所有指令的“空洞”中。
这就需要我们为布尔表达式B引入两个新的综合属性:
B.truelist:一个链表,里面存放了所有“当B为真时需要跳转,但目标地址尚未确定”的goto指令的编号。B.falselist:类似,存放所有“当B为假时需要跳转”的goto指令编号。
我们还需要三个辅助函数:
makelist(i):创建一个只包含指令i的新列表。merge(p1, p2):合并两个列表p1和p2。backpatch(p, i):把指令地址i,填到列表p中所有指令的目标地址字段。
4.2 布尔表达式的回填SDT
让我们看一个具体的例子,B -> E1 < E2。当翻译到这个关系表达式时,我们需要生成两条指令:
100: if E1.addr < E2.addr goto _
101: goto _
注意,goto _ 中的 _ 表示地址待定。此时,我们不知道真该跳去哪,假该跳去哪。所以,我们生成指令后,用 makelist 把它们的序号记录下来:
B.truelist = makelist(100); // 第100条指令需要回填真出口
B.falselist = makelist(101); // 第101条指令需要回填假出口
对于逻辑表达式,比如 B -> B1 or B2。如果B1为真,整个表达式就为真,所以B1为真时应跳转的目标,就是整个B为真时应跳转的目标。因此,B.truelist 应该合并 B1.truelist 和 B2.truelist。如果B1为假,我们需要去检查B2,所以B1为假时应跳转的目标,应该是B2代码的起点。我们通过插入一个标记非终结符M来记录B2代码的起始地址M.quad,然后回填B1.falselist到这个地址。B的假出口则直接继承B2的假出口。SDT如下:
B -> B1 or M B2 {
backpatch(B1.falselist, M.quad); // 把B1为假时跳转的指令,目标都设为B2的开始
B.truelist = merge(B1.truelist, B2.truelist);
B.falselist = B2.falselist;
}
M -> ε { M.quad = nextquad; } // 记录下一条指令的序号,即B2代码的开始
B -> B1 and B2 的设计思路是对称的,这里不再赘述。关键是,所有属性(truelist, falselist, quad)都是综合属性,计算过程自底向上,完美契合LR语法分析器的工作方式,可以在语法分析的同时一次性完成翻译,无需第二遍扫描。
4.3 控制流语句的回填SDT
将回填技术应用到if和while语句上,整个设计变得异常简洁。我们为语句S引入一个综合属性 S.nextlist,它也是一个列表,存放了所有在S内部生成的、需要跳转到S之后(即S的后继语句)的goto指令序号。
以 S -> if B then S1 else S2 为例:
- 我们需要在
S1的代码结束后,生成一条goto S.next指令,以跳过S2。这条指令的地址未知,我们生成它并将其序号放入一个新列表N.nextlist。 B为真时应跳转到S1的开始,我们用M1.quad记录S1的起始地址,然后backpatch(B.truelist, M1.quad)。B为假时应跳转到S2的开始,我们用M2.quad记录S2的起始地址,然后backpatch(B.falselist, M2.quad)。- 整个
S执行完后,需要跳转到后继语句的指令,可能来自S1.nextlist、S2.nextlist以及我们刚生成的N.nextlist。所以S.nextlist = merge(S1.nextlist, merge(N.nextlist, S2.nextlist))。
对应的SDT如下,结构清晰,逻辑严密:
S -> if B then M1 S1 N else M2 S2 {
backpatch(B.truelist, M1.quad);
backpatch(B.falselist, M2.quad);
S.nextlist = merge(merge(S1.nextlist, N.nextlist), S2.nextlist);
}
N -> ε { N.nextlist = makelist(nextquad); gen('goto _'); }
M1 -> ε { M1.quad = nextquad; }
M2 -> ε { M2.quad = nextquad; }
while循环和顺序语句的SDT可以依此类推。最终,当翻译到最外层的程序块时,其S.nextlist中的指令需要回填到一个表示“程序结束”的标签。至此,所有悬而未决的跳转地址都被填上,一份完整、可执行的三地址码就生成了。
5. 实战对比:传统SDT与回填SDT的差异与选择
纸上得来终觉浅,我们用一个实际的例子来感受两种方式的差异。考虑语句:if (a < b or c < d) then x = 1;。
使用传统SDT(两遍方法):
第一遍会生成类似下面的代码,其中L1, L2, L3都是占位符:
if a < b goto L1
goto L2
L1: goto L3
L2: if c < d goto L3
goto L4
L3: x = 1
L4: (后续代码)
注意到有很多冗余的goto(比如第3行的goto L3)。然后需要第二遍扫描,把L1, L2, L3, L4替换成实际的指令地址(比如100, 101, 102, 103...)。
使用回填技术(一遍方法): 在LR分析过程中,随着归约,动态生成代码并维护列表。最终直接生成:
100: if a < b goto 102
101: goto 103
102: x = 1
103: (后续代码)
可以看到,回填技术不仅一步到位,而且通过更智能的列表合并与回填,消除了不必要的goto指令(例如,它发现a<b为真时可以直接顺序执行x=1,而不需要先跳到一个标签再执行)。代码更简洁,效率也更高。
那么如何选择呢?
- 回填技术 优势明显:单遍扫描、生成代码优化(去除冗余跳转)、属性设计简单(全是综合属性)。它是现代编译器实践中的主流选择,尤其适合与LR系列分析器搭配。
- 传统SDT 更易于理解,继承属性的信息流方向与控制流直观对应。在 teaching compiler 或某些使用递归下降(LL分析)的编译器中,可能更容易实现。但当文法左递归或继承属性依赖右兄弟节点时,实现会变得复杂。
从我十年的项目经验来看,在实现一个严肃的编译器时,我会毫不犹豫地选择回填。它可能初学时会觉得有点“绕”,但一旦掌握,你就会发现它那种“延迟决策,统一处理”的思想,不仅优雅,而且强大。它把控制流翻译中最棘手的地址确定问题,化解为列表的创建、合并与回填这几个原子操作,极大地简化了编译器后端的逻辑。

1236

被折叠的 条评论
为什么被折叠?



