编译原理的课程进度走到第八章,很多人的感受会从“前面几章虽然难但好歹看得懂”变成“这章看完感觉哪里都飘”。词法分析有正则表达式和有穷自动机,语法分析有LL(1)、LR(1)分析表,到了语义分析,突然没有一个通用的算法框架能套了,取而代之的是一堆概念:属性文法、综合属性、继承属性、语法制导翻译、中间代码、回填、符号表、类型检查。说实话,我第一次学的时候也懵了很久,后来才发现,这一章其实是在回答一个很朴素的问题:语法分析器确认了一串代码“长得对”,但编译器怎么知道它“用起来对”?本文就是围绕这个问题展开的第八章读书笔记。
这篇笔记适合正在学编译原理的本科生、准备考研复试或找工作面试的人,以及那些想搞清楚“AST之后编译器到底在干什么”的自学者。我会把这一章的知识点重新组织成能串起来的逻辑链:为什么需要语义分析、用属性文法怎么表达语义、分析结果输出成什么样的中间代码、典型语句怎么翻译、符号表和类型检查在中间扮演什么角色,最后把考试面试最容易混淆的概念一起理一遍。
1. 为什么词法分析之后的“语义分析”常常让人卡壳
1.1 从“句子合法”到“句子有意义”:编译器的工作升级
词法分析阶段,编译器做的事情是把源代码切分成 token,判断的是“字符序列是否符合词法规则”,比如int、123、while这些词能不能被识别出来。语法分析阶段,编译器根据上下文无关文法构建语法树,判断的是“token 序列是否符合文法产生式”,比如while (x < 10) x = x + 1;在语法结构上是不是一条合法的 while 语句。
但语法结构合法不代表语义正确。举一个特别经典的例子:
int x = "hello" + 3;这段代码在语法上完全没问题,它是一个声明语句加一个赋值表达式。可是你要是让编译器就这么放过去,运行时就会出大问题:把字符串和整数相加,在任何静态类型语言里都是违法的。类似的还有:使用未声明的变量、函数调用时实参个数和形参不匹配、跳转语句跳到了一个不存在的标签、return 语句在返回 int 的函数里返回了数组。这些错误统统不属于语法错误,而属于静态语义错误。
语义分析的本质,就是编译器从语法分析得到的那棵语法树出发,把“这段话在结构上成立”升级为“这段话在当前的语言规则下有意义”。它是编译器第一个真正需要“理解”代码含义的阶段。也正因为要做“理解”,它没法像词法、语法分析那样,有现成的自动机或分析表可以套,必须借助一套新的描述工具,那就是属性文法和语法制导翻译。
1.2 语义分析要落地的三件事
从功能上看,语义分析阶段至少要做三件事。
第一件事是静态语义检查。包括变量是否声明、类型是否匹配、运算符的操作数类型是否正确、控制流目标是否存在、函数调用参数是否匹配等。这一步的目标是把能在编译期发现的错误尽量拦下来,而不是等到程序运行到那里才崩溃。
第二件事是建立并维护符号表。符号表是语义分析的“账本”,每个标识符的名字、类型、作用域、存储位置都在里面登记。声明语句的意义,就是把新名字写进符号表;引用语句的意义,就是从符号表里查出这个名字对应的信息,并检查使用方式是否符合声明。
第三件事是生成中间代码。语义分析的结果不能只停留在脑子里的“检查通过”,它要向下游交付一个编译器能继续操作的产物。最常见的产物是三地址码这类中间表示。后续的代码优化和目标代码生成都基于这份中间代码继续做。
理解了这三件事,再看整章的内容就顺了:属性文法是描述语义规则的形式化工具,符号表是语义检查的信息基础,中间代码是语义分析的输出。后面四个部分基本就是在展开这三件事怎么做。
2. 属性文法:把语义塞进文法里的标准办法
2.1 属性:挂在文法符号上的信息
属性文法的思路很朴素:既然语法分析阶段已经有了一棵语法树,那就允许你在语法树的每个节点上挂一些“属性”,再用一组语义规则来规定这些属性怎么计算。
一个典型的属性文法由三部分组成:文法符号的属性集合、属性计算所需的语义规则、以及一组条件谓词。为了理解方便,可以把它当成“文法产生式后面附带的小程序”。比如一个简单的算术表达式文法:
E -> E + T E -> T T -> num如果我们要表达“E 的类型和值”,就可以给 E 挂一个type属性、一个val属性,给 T 也挂上同样的属性。然后规定:
E -> E1 + T E.type = if E1.type == int and T.type == int then int else error E.val = E1.val + T.val这些挂在文法符号上的属性,加上计算属性的规则,就构成了一套属性文法。语法分析器在归约出一个产生式的时候,顺手执行它后面的语义规则,属性的值就在语法树构建的过程中被算出来了。这是“语法制导”这个说法的来历——翻译过程由语法分析过程来驱动。
2.2 综合属性与继承属性:两条信息流
属性按计算时信息的来源方向分两类,这两类是整个语义分析的基础,必须分清楚。
综合属性是从子节点向父节点传递的属性。它的计算只依赖该节点的子节点属性和自身属性。最典型的就是表达式的类型和值:E -> E1 + T中E.type由E1.type和T.type综合而来,E.val由两个子节点的值综合而来。实际上,我们在做自底向上语法分析时,如果只需要综合属性,那么完全可以在 LR 分析器归约时同步计算属性,不需要额外的扫描。S-属性定义(只含有综合属性的语法制导定义)就是利用了这个特性。
继承属性是从父节点或兄弟节点向子节点传递的属性。它允许信息从上下文中流入子树。举一个更生活化的例子:下面的声明语句
D -> T id如果T的type属性是int,那么id的类型信息应该从T继承过来。也就是说id.type = T.type,其中id.type是一个继承属性,它依赖的是左兄弟节点的综合属性。
继承属性需要在本节点从左到右、从上到下的信息环境中计算,不能简单地在自底向上的归约过程中直接算出所有值。因此 L-属性定义(综合属性加上限制性继承属性的定义)就被设计出来了:它保证属性的值可以在一次深度优先的语法树遍历中从左到右计算出来,这正好兼容自顶向下的递归下降分析和许多自底向上的分析框架。
理解两条信息流之后,做题和写翻译方案的时候就能有一个判断基准:如果某个属性要由语法树中位于当前节点下方的信息算出,那就是综合属性;如果必须由上层或左边兄弟节点的信息算出,那就是继承属性。
2.3 从属性文法到语法制导翻译方案
属性文法描述的是“属性之间的关系”,但在实际实现编译器的时候,我们还需要把语义动作具体放在什么位置执行。这就需要一个更偏向“操作”的形式,也就是语法制导翻译方案(SDT)。
SDT 的表示方法很直观,就是在产生式的右部适当位置插入语义动作。比如:
E -> E1 + T { E.val = E1.val + T.val; }花括号里的动作可以在分析到对应位置时执行。根据动作放置位置的不同,有时需要在自顶向下分析中提前计算某些继承属性,有时在自底向上归约时执行综合属性的计算。
我的体会是,属性文法回答的是“语义规则是什么”,SDT 回答的是“语义动作在什么时候做”。很多初学者混淆这两个概念,考试时经常被问区别,记住一句话就够了:属性文法是“规则描述”,SDT 是“执行方案”。
3. 中间代码:语义分析的“输出口”
3.1 为什么要先翻译成中间表示
如果不做中间代码,编译器的前端直接生成目标机器的汇编或机器代码,会遇到两个现实问题。
第一个问题是可移植性。一个语言的编译器往往要支持多个目标平台,比如 x86、ARM、RISC-V。如果前端直接生成 x86 汇编,换一个平台就要把整个前端重写一遍。引入中间代码之后,前端只需要生成一份和具体机器无关的中间表示,后端针对每个平台写一个中间代码到目标代码的翻译器。前端做一次,后端做多份,工作量大大降低。这是很多教科书里明确提到的动机,实际工程里的确也是这么做的。
第二个问题是优化。一套成熟的中间表示可以统一承载各种编译优化,比如常量折叠、公共子表达式消除、死代码删除。如果直接在汇编级别上做,优化算法就得为每种目标机的指令集重写一套,维护成本不可想象。
所以中间代码本质上是一个“前后端解耦”的接口。这就像供应链里的标准化零件:不同供应商(前端)生产出规格统一的零件(中间代码),不同组装线(后端)都能把这种零件装进自己的产品。
3.2 三地址码与四元式:最实用的两种形式
中间代码的形式不止一种,国内教材最爱考的有三地址码、四元式、三元式、间接三元式、DAG。
三地址码的核心特征是一个赋值语句右边最多有一个运算符:
t1 = b * c t2 = t1 + d a = t2每条三地址指令最多涉及三个地址(两个操作数加一个结果),所以叫“三地址码”。它的指令类型一般包括:赋值指令x = y、算术指令x = y op z、一元指令x = op y、无条件跳转goto L、条件跳转if x relop y goto L、数组操作x = y[i]、x[i] = y、指针操作x = &y、x = *y、过程调用param x、call p, n、返回return y,等等。
四元式是三地址码的一种具体存储表示,结构是(op, arg1, arg2, result)。比如t1 = b * c写成四元式就是(*, b, c, t1)。优点在于每条指令的结果都显式放在 result 字段里,移动代码时不需要担心隐式依赖,非常适合后续优化。
三元式用位置编号引用前面的运算结果,比如第 i 条指令的结果就通过编号 i 来引用,于是临时变量被省略了,节省了存储,但如果优化时移动指令,编号就乱了,很不方便。间接三元式加了一张间接码表解决移动问题。这一块教材通常直接用大表格对比,我把常见形式整理在下面。
| 中间代码形式 | 结果表示 | 主要优点 | 主要缺点 |
|---|---|---|---|
| 三地址码 | 用临时变量名表示 | 直观、适合优化 | 临时变量多 |
| 四元式 | result 字段显式存放 | 适合优化和代码生成 | 显式 result 稍冗长 |
| 三元式 | 用指令编号引用 | 省临时变量 | 指令移动困难 |
| 间接三元式 | 间接码表+三元式表 | 兼具省空间和可移动性 | 结构稍复杂 |
| DAG | 图结构 | 能发现公共子表达式 | 不适合直接线性输出 |
3.3 DAG与抽象语法树:做题时容易绕晕的另一个分支
抽象语法树其实就是语法树的精简版,去掉了括号、分号等只用来辅助语法分析的符号,保留表达运算结构和层次的部分。它是很多编译器的前端最终产物,也可以看作一种中间表示。
DAG(有向无环图)和 AST 的区别在于公共子表达式的表示。如果同一个子表达式在 AST 中出现两次,它就有两个完全相同的子树;而在 DAG 中,子树只建一次,多个父节点共享它。比如a = b + c; d = b + c;,AST 里会有两个b + c的子树,DAG 里只有一份,第二条语句直接引用第一份。这个特点让 DAG 天然适合做局部公共子表达式消除。
从语义分析的角度,我的建议是:不要把中间代码的每种形式都当成独立的知识点去背,它们解决的是同一个问题的不同变体——如何高效地表达“已经搞清楚的语义结果”。考试真正让你写的,九成是四元式,所以重点放在四元式生成上。DAG 和三元式理解优缺点即可。
4. 表达式、布尔语句和控制流语句的翻译实操
4.1 表达式与赋值语句:临时变量是怎么被造出来的
现在进入全章实操成分最重的部分。先从表达式翻译说起。
把表达式翻译成四元式,最标准的做法是给每个子表达式生成一个新的临时变量。比如a = b * c + d,翻译步骤是:
(*, b, c, t1) (+, t1, d, t2) (=, t2, _, a)其中t1、t2是临时变量。整个过程由语法制导定义驱动:
E -> E1 + T { E.addr = newtemp(); gen(+, E1.addr, T.addr, E.addr); } E -> E1 * T { E.addr = newtemp(); gen(*, E1.addr, T.addr, E.addr); } E -> num { E.addr = num.lexval; }核心就是newtemp()和gen()两个函数。newtemp()负责生成格式如t1、t2的唯一临时名字,gen()负责把四元式追加到指令数组末尾。写实验的时候,这两个函数就是两个简单的计数器加一个列表:
int tempCount = 0; List<Quad> quads = new ArrayList<>(); String newTemp() { return "t" + (++tempCount); } void gen(String op, String arg1, String arg2, String result) { quads.add(new Quad(op, arg1, arg2, result)); }这里有一个教科书里经常一笔带过、但实际写编译器时非常关键的细节:标识符的地址要经过符号表查找确认。也就是说,看到id节点的第一件事是lookup(id.name),如果查不到,说明这个变量未声明,直接报错,而不是先生成代码。把查表和代码生成的顺序搞反,是第一次手写解释器或翻译器时最常见的错误。
4.2 布尔表达式的短路翻译:差点除零的那次教训
布尔表达式的翻译有两种语义:一种是把布尔表达式当成数值表达式,算出 0 或 1;另一种是把布尔表达式用在条件跳转中,只关心它的真假出口。后面的更有用,因为几乎所有语言的if、while底层都需要跳转。
布尔表达式的跳转翻译核心是给每个布尔表达式 E 引入两个继承属性:E.true表示 E 为真时跳转到的标签,E.false表示 E 为假时跳转到的标签。翻译规则大致是:
E -> E1 or E2 E1.true = E.true E1.false = newlabel() E2.true = E.true E2.false = E.false由于or操作只要 E1 为真,整个表达式就为真,所以 E1 为真时直接跳 E.true;如果 E1 为假,并不能决定整个表达式的结果,需要继续算 E2,所以 E1.false 指向一个新标签,也就是 E2 代码的开头。
and表达式正好反过来:
E -> E1 and E2 E1.false = E.false E1.true = newlabel() E2.true = E.true E2.false = E.false这里体现的就是短路计算。如果 E1 为假,直接跳 E.false,不需要计算 E2。用一个大家都能秒懂的例子解释为什么需要短路:如果要翻译if (x != 0 && y / x > 1),如果编译器不短路,那么即使x等于 0,也会去计算y / x,直接除零崩溃。只要引入短路的跳转翻译,这个问题天然就被避免了。
翻译E -> E1 or E2时,关键代码是当 E1 归约完成后,要把一个goto E1.false的跳转指令发出去,再把 E2 的代码接在后面。这在具体实现时又牵扯到跳转指令的目标可能是后面才生成的标签,因此需要回填技术,也就是下一小节的内容。
4.3 控制流语句与回填技术:跳转目标先留白
生成跳转指令时最常见的问题是:跳转目标可能还没生成。比如翻译if (E) S1 else S2时,条件为假的跳转目标指向S2的第一条指令,但S2的代码在归约顺序上还没产生出来。这时候就不能像表达式四元式那样直接填目标地址,得先把“待回填”的跳转指令位置记下来,等目标标签真正生成的那一刻,再回头填进去。
这套机制叫回填(backpatching)。工程实现中通常配合几个基础函数:
makelist(i):创建只包含四元式编号 i 的链表;merge(p1, p2):把两个链表合并;backpatch(p, label):把链表 p 中所有指令的跳转目标填成 label。
每个带跳转的布尔表达式非终结符,其属性E.true和E.false就不再是标签本身,而是“由一串待回填指令编号组成的链表头”。等到语法分析过程推进到能够确定目标标签的位置,就调用backpatch。
举例来说,翻译while (x < 10) x = x + 1;的完整四元式流程大致是这样:
1. (>, x, 10, ??) // 条件是 x < 10,为真走循环体 2. (goto, _, _, ??) // 为假跳出循环 3. (+, x, 1, t1) 4. (=, t1, _, x) 5. (goto, _, _, 1) // 回到条件判断第 1 条四元式是条件跳转指令,当 x 小于 10 时跳到循环体(第 3 条),此时第 3 条的四元式编号还没确定,所以先留空;第 2 条是无条件跳转,跳到循环之后,它的目标在更后面才会知道。归约到整个 while 语句结构完整后,才能通过回填把这些空白的位置填上。
我自己写翻译器时,最痛苦的就是标签编号和指令编号混在一起。后来养成了一个习惯:标签和指令编号分别用两套计数器,标签用L1、L2,临时变量用t1、t2,指令位置用纯数字编号。这样在回填和调试指令列表时,一眼就能看出某个编号到底引用的是标签、变量还是指令,排查问题能省一半时间。
5. 符号表与类型检查:把“名字”和“类型”变成可用信息
5.1 栈式符号表:作用域管理的核心结构
符号表的功能一句话:建立标识符的声明和引用之间的联系。在语义分析阶段,碰到声明语句int x;就往符号表里登记一条,登记内容包括名字、类型、作用域、存储偏移量等;碰到引用x = 1;就到符号表里查,查到才能确认名字和类型,查不到就报“未声明变量”错误。
用什么数据结构组织符号表?最直接的是哈希表,按名字查找 O(1)。但哈希表只能解决同一个作用域内的查找问题,一旦出现嵌套作用域,同一个名字可能在不同层有不同含义,就必须引入作用域管理。
最经典的做法是栈式符号表。进入一个新的块作用域时,往符号表栈顶压入一个新表;退出块时弹栈。查找名字时从栈顶向下逐层查找,找到的第一个匹配项就是当前作用域的绑定。这完美匹配了词法作用域规则:内层名字遮蔽外层同名名字。
Deque<Map<String, Symbol>> scopeStack = new ArrayDeque<>(); void enterScope() { scopeStack.push(new HashMap<>()); } void exitScope() { scopeStack.pop(); } Symbol lookup(String name) { for (Map<String, Symbol> scope : scopeStack) { Symbol sym = scope.get(name); if (sym != null) { return sym; } } return null; }这里要特别留意一个坑:嵌套作用域的处理顺序。教科书里常考“编译期建立的作用域是静态作用域还是动态作用域”,答案是静态(词法)作用域,因为符号表的压栈和弹栈完全由代码的语法结构决定,跟程序运行时的调用关系无关。很多人第一次看到 C 语言里内层块可以遮蔽外层变量,会以为这是运行时行为,实际上这是纯粹的编译期行为。
5.2 类型检查:静态语义里最像“证明”的部分
类型检查是语义分析里最有“推理感”的部分,因为它的核心是一组推导规则。规则的基本形态是:如果前提条件满足,那么结论成立。比如乘法表达式的类型规则可以写成:
如果E1.type = int且E2.type = int,那么E1 * E2 .type = int。
如果E1.type = float或E2.type = float,运算结果一般要提升为float,还可能生成一条类型转换指令。
类型检查要做的事情,本质上就是从语法树出发,逐个节点应用类型规则,确认整个表达式树的类型信息是自洽的。如果某些类型的运算符需要特殊处理,比如数组下标必须是整数、函数调用的实参类型必须和形参一致、return 的表达式类型必须与函数返回类型匹配,这些检查都需要查符号表拿到函数、变量的类型信息之后才能完成。
还有一个常考概念是类型等价。判断两个类型是否相同,分两种策略:
- 名字等价:两个类型相同,当且仅当它们有相同的类型名。C 语言中
typedef int Length;之后,Length和int名字不同但存在兼容关系,而两个结构体即使内部成员完全一致,互相赋值也会被警告,这就是名字等价的体现。 - 结构等价:两个类型相同,当且仅当它们的内部结构相同。Pascal 的很多实现倾向结构等价。
考试爱问“C 语言采用哪种等价”,严格说 C 是名字等价为主、结构等价为辅的混合方案。实际编译器里,类型系统往往比教科书复杂得多,涉及递归类型、数组维度推断等,但只要理解了名字等价和结构等价这两个极端,复杂实例都能拆解。
6. 考试与面试中语义分析最容易混淆的几组概念
6.1 综合/继承属性、S/L属性:一张表理清
学完整个第八章,最后回头把最容易混淆的概念整理一遍。第一组是综合属性与继承属性,第二组是 S-属性定义与 L-属性定义,第三组是 SDD 与 SDT。
| 对比项 | 定义 | 计算方向 | 典型用途 |
|---|---|---|---|
| 综合属性 | 由子节点属性决定 | 自底向上 | 表达式类型、值 |
| 继承属性 | 由父节点和左兄弟属性决定 | 自顶向下或从左到右 | 声明中的类型信息传递 |
| S-属性定义 | 只含综合属性的 SDD | 自底向上可算 | 表达式求值 |
| L-属性定义 | 综合属性 + 受限的继承属性 | 深度优先左到右可算 | 声明与语句翻译 |
| SDD | 属性文法的形式化描述 | 定义层面 | 描述语义规则 |
| SDT | 语义动作嵌入产生式 | 执行层面 | 指导实际实现 |
一个特别容易考到的推理题:为什么 L-属性定义比 S-属性定义更适合自顶向下的预测分析?因为递归下降分析器是从左到右、自顶向下访问语法树的,它天然能利用“从左兄弟和父节点获得继承属性”这类信息流,而纯综合属性因为信息要等子节点都算完才能向上传递,某些场景下反而需要额外扫描。
反过来,为什么 S-属性定义正好适合 LR 分析器?因为 LR 分析器是自底向上的,归约动作发生在一个产生式的所有右部符号都被分析完的时刻,这时候子节点的综合属性都已经算好了,只要按照产生式的语义规则计算出父节点的综合属性即可,一切都正好对齐。
6.2 常考问法与回答思路
面试和笔试里,语义分析方向的题目通常围绕几个固定问题展开,提前准备一下很划算。
第一个高频问题是:语法制导翻译和属性文法之间的关系是什么?回答思路是:属性文法用于描述语义,它给文法符号加属性、给产生式加语义规则;语法制导翻译强调基于语法分析过程执行这些语义规则的动作。二者是描述能力与执行机制的关系。
第二个高频问题是:中间代码为什么要用三地址码而不是直接用树或汇编?回答思路分两层:相比树形表示,三地址码是线性序列,更接近目标代码,便于后续生成指令;相比汇编,三地址码与机器无关,便于跨平台移植和统一优化。从这个角度出发,还能引出四元式和三元式的比较。
第三个高频问题是:编译器的静态检查和动态检查有什么区别?静态检查发生在编译期,能查出类型不匹配、未声明变量等;动态检查发生在运行时,比如数组越界、除零检查。很多动态语言把大量检查推迟到运行时,因而更灵活但更慢;静态类型语言把检查尽量提前,让更多错误在运行前暴露。
第四个不是“问题”但经常以简答题出现的点是:符号表里到底存什么?别只答“名字和类型”,完整的答案应该包括:标识符名、种类(变量/常量/函数/类型)、数据类型、存储偏移量或大小、作用域信息、对其他表项的链接。函数还要存参数列表和返回类型。回答越具体,越能体现你真的读懂过实现。
第五个容易踩坑的辨析:布尔表达式的真/假出口和回填分别解决什么问题?真假出口解决的是“布尔表达式翻译成跳转指令时跳到哪里去”的语义问题;回填解决的是“跳转目标暂时未知时,如何组织待填地址”的实现问题。一个是设计目标,一个是实现手段。
这几组概念理完,再把前面的属性流、中间代码、翻译实例串一遍,整个第八章几乎就真的刻进脑子了。我自己的经验是,语义分析这章别死记硬背,拿一小段带 if、while、嵌套块、浮点运算的代码,亲手翻译成一串四元式,再对着翻译过程去理解符号表和回填,那些概念会从“名词”变成“工具”,后面学代码生成和优化也会顺手很多。