12604 字
63 分钟
从零开始的编译原理速成
2025-12-02

从零开始的编译原理速成#

妈妈我真的要学不完了呜呜呜呜

参考适用教材:《编译原理(第三版)》 陈意云、张昱 编著

Chapter 1 引论#

编译的各个阶段:

词法分析 \rightarrow 语法分析 \rightarrow 语义分析 \rightarrow 中间代码生成 \rightarrow 优化 \rightarrow 目标代码生成

  1. 词法分析:将源程序转换为记号流

    字符流 \rightarrow 记号流

    • 如果是标识符,则形成 <id, name> 的形式

    • 如果是数字,则形成 <num, value> 的形式

    • 如果是运算符,则形成 <op> 的形式

    词法分析的过程中不能发现语法、语义、逻辑错误。

  2. 语法分析:将记号流转换为语法树

    记号流 \rightarrow 语法树

    检查源程序是否符合语法规则

    常常在语法分析过程中进行中间代码生成

  3. 语义分析:理解源程序的语义

    语义分析的一个重要部分是类型检查

    常常在语义分析过程中进行目标代码生成

遍(趟):是指编译程序在编译时刻把源程序或源程序的等价物(中间程序)从头到尾扫描一遍并转换成另一紧邻的等价物的全过程。

  • 前端:词法分析、语法分析、语义分析、中间代码生成

    源程序 \rightarrow 中间代码

    依赖于源语言,独立于目标机器。

  • 后端:优化、目标代码生成

    中间代码 \rightarrow 目标代码

    依赖于目标机器,独立于源语言。

好处:提高开发编译器的效率

Chapter 2 词法分析#

有时候会有 <LITERAL, "value"> 的形式,表示字符串常量。

在词法分析中,可以发现一些简单的错误,比如标识符不合法、数字不合法等。

词法记号#

串和语言#

字母表:符号的有限非空集合,通常用 Σ\Sigma 表示。

串:字母表上有限个符号的有限序列,如 abc,ε\varepsilon(空串)。

语言:字母表上的串的集合,如 {abc, ab, a, ε\varepsilon},\emptyset

串的运算#

  • 连接:将两个串连接在一起,记为 s1s2s_1 s_2

  • 幂:将一个串重复连接多次,记为 sns^n

语言的运算#

  • 和:两个语言的并集,记为 L1L2L_1 \cup L_2

  • 连接:两个语言的连接,记为 L1L2L_1 L_2

  • 幂:一个语言的幂,记为 LnL^n

  • 闭包:一个语言的闭包,记为 LL^*

  • 正闭包:一个语言的正闭包,记为 L+L^+。有 L+=LLL^+ = L L^*

正则表达式#

正则表达式:按照一组定义规则,由较简单的正则表达式构成的。每个正则表达式表示一个语言 L(r)L(r)

正则表达式用来表示简单的语言,叫做正规集。

正则表达式中:

  • ε\varepsilon \rightarrow {ε\varepsilon}

  • aa \rightarrow {aa}

  • (r1)(r2)(r_1) | (r_2) \rightarrow L(r1)L(r2)L(r_1) \cup L(r_2)

  • (r1)(r2)(r_1)(r_2) \rightarrow L(r1)L(r2)L(r_1) L(r_2)

  • (r)(r)^* \rightarrow L(r)L(r)^*

  • (r)(r) \rightarrow L(r)L(r)

运算符优先级:* > 连接 > |

((a)(b))(c)((a)(b)*)|(c) 可以简记为 abcab*|c

通过正则表达式,可以完成字符流到记号流的转换。

显然正则表达式可以很轻松的转化为 NFA。

具体可以参考消结规则。

有限自动机#

NFA 的定义#

NFA 是一个五元组 (S,Σ,move,s,F)(S, \Sigma, move, s, F),其中:

  • SS:状态的有限集合

  • Σ\Sigma:输入符号的有限集合(字母表)

  • movemove:状态转移函数,S×(Σ{ε})P(S)S \times (\Sigma \cup \{\varepsilon\}) \rightarrow P(S)

  • ss:唯一的初始状态,sSs \in S

  • FF:接受状态的集合,FSF \subseteq S

注意到 NFA 与 DFA 的区别只在于 movemove 函数:

  • NFA 的状态转移函数允许有 ε\varepsilon 转移

  • NFA 的状态转移函数的值是状态的集合,而不是单个状

即 DFA 的 movemove 函数为 S×ΣSS \times \Sigma \rightarrow S

正则表达式可以作为 NFA 中的某个元素存在,此时记改正则表达式为 rr 的 NFA 为 N(r)N(r)

NFA 转 DFA#

NFA 可以通过子集构造法转换为等价的 DFA。

  1. 初始状态:在只走 ε\varepsilon 转移的情况下,从 NFA 的初始状态 ss 出发,得到的状态集合,作为 DFA 的初始状态。

  2. 状态转换:对于 DFA 的每个状态 TT 和每个输入符号 aa,计算 NFA 中从状态集合 TT 中每个元素出发经过输入符号 aa 以及任意个 ε\varepsilon 转移能到达的所有状态的集合(不包含每个元素自身),作为新的 DFA 状态。

  3. 接受状态:DFA 的接受状态为包含 NFA 接受状态的所有状态集合。

通过子集构造法,可以将 NFA 转换为等价的 DFA,从而实现正则表达式到 DFA 的转换。

DFA 的化简#

DFA 的化简是将一个 DFA 转换为状态数最少的等价 DFA 的过程。

化简步骤:

  1. 去除不可达状态:从初始状态出发,找出所有可达状态,删除不可达状态。

  2. 合并等价状态:将等价状态合并为一个状态。等价状态是指对于任意输入字符串,两个状态经过相同的输入字符串后,最终到达的状态要么都是接受状态,要么都是非接受状态。

  3. 重命名状态:为化简后的 DFA 中的状态重新命名,使其更易读。

具体在实现中,首先去除不可达状态,这是显然的。

然后可以通过迭代的方法来合并等价状态:

  • 初始划分:将状态集合划分为接受状态集合和非接受状态集合。

  • 迭代划分:对于每个划分中的状态,对于每个输入符号,检查其转移到的状态是否在同一划分中。如果不在同一划分中,则将该状态划分为不同的子集。如果没有对应的转移,则视为转移到一个特殊的“死状态”,该死状态与其他状态不等价,但是都转移到死状态的状态是等价的。

  • 终止条件:当划分不再变化时,停止迭代。

最后,重命名状态,使其更易读。重命名后的状态的转移关系与原 DFA 保持一致。

Chapter 3 语法分析#

语法分析器,简称分析器(parser)。

将记号流转换为语法树。

文法#

我们在此给出乔姆斯基文法类型:

文法 GG 是一个四元组 (VT,VN,S,P)(V_T, V_N, S, P),其中:

  • VTV_T:终结符的有限集合(记号集合)

  • VNV_N:非终结符的有限集合,且有 VTVN=V_T \cap V_N = \emptyset

  • SS:开始符号,SVNS \in V_N

  • PP:产生式的有限集合,形式为 AαA \rightarrow \alpha,其中 AVNA \in V_Nα(VNVT)\alpha \in (V_N \cup V_T)^*

以下对文法 GG 进行分类:

  • 如果对于文法 GG,如果其每个产生式 αβ\alpha \rightarrow \beta 均满足约束 α(VNVT)\alpha \in (V_N \cup V_T)^* 且至少含有一个非终结符,而 β(VNVT)\beta \in (V_N \cup V_T)^* 则为 0 型文法。 0 型文法也称为短语文法,其能力相当于图灵机。

  • 0 型文法的基础上,若每个非 SεS \rightarrow \varepsilon 产生式 αβ\alpha \rightarrow \beta 均满足约束 αβ| \alpha | \leq | \beta | 其中 α|\alpha|α\alpha 中符号的个数,且若存在 SεS \rightarrow \varepsilonSS 不出现在其他产生式的右部,则为 1 型文法。

    1 型文法也称为上下文相关文法,其能力相当于线性有界自动机。

  • 0 型文法的基础上,若每个产生式 AβA \rightarrow \beta 均满足约束 AVNA \in V_Nβ(VNVT)\beta \in (V_N \cup V_T)^* 则为 2 型文法。

    2 型文法也称为上下文无关文法,其能力相当于下推自动机。

  • 0 型文法的基础上,若每个产生式均为 AaBA \rightarrow aBAaA \rightarrow a 的形式,或者每个产生式均为 ABaA \rightarrow BaAaA \rightarrow a 的形式,其中 A,BVNA, B \in V_NaVT{ε}a \in V_T \cup \{\varepsilon\},则为 3 型文法。

    3 型文法等价于正则表达式,其能力相当于有限自动机。

上下文无关文法是编译原理中最常用的文法类型。

我们常采用以下约定表示文法:

  1. 下列符号为终结符

    • 小写字母 a、b、c、…,表示具体的记号

    • 运算符号 +、-、*、/、=、;、(、) 等

    • 关键字 if、else、while、return 等

    • 特殊符号,如 idid(标识符)、numnum(数字常量)等

    • 标点符号,如逗号、句号等

  2. 下列符号为非终结符

    • 字母表前面的大写字母 A、B、C、…,表示语法类别

    • 具体的语法类别名称,如 Expr(表达式)、Stmt(语句)、Decl(声明)等

    • 开始符号,通常用 S 表示

  3. 字母表后面的大写字母,代表文法符号,即终结符或者非终结符。

  4. 字母表后面的小写字母,代表终结符号串。

  5. 小写希腊字母,如 α\alphaβ\betaγ\gamma,代表文法符号串。

推导#

推导采用符号     \implies 表示,表示通过应用某个产生式将一个符号串中的某个非终结符替换为对应的产生式右部,从而得到另一个符号串的过程。

我们用     \implies^* 表示零次或多次推导,用     +\implies^+ 表示一次或多次推导。

如果两个文法可以生成相同的语言,则称它们是等价的。

如果 S    αS \implies^* \alphaα\alpha 可能含有终结符和非终结符,则称 α\alpha 是文法 GG 的一个句型。而如果 α\alpha 仅含有终结符,则称 α\alpha 是文法 GG 的一个句子。

故而,文法 GG 生成的语言 L(G)L(G) 定义为:

L(G)={wS    w,wVT}L(G) = \{ w | S \implies^* w, w \in V_T^* \}

即,文法 GG 生成的语言 L(G)L(G) 是所有可以从开始符号 SS 推导得到的句子的集合。

可以定义最左推导,即每次推导都选择符号串中最左边的非终结符进行替换,记为     lm\implies_{lm}。类似地,可以定义最右推导,记为     rm\implies_{rm}

最左/最右推导只是两种不同的生成分析树的方式,而无法通过分析树来确定是最左还是最右推导。

分析树#

分析树(Parse Tree):是一种树形结构,用于表示句子的语法结构。分析树的根节点是开始符号,叶节点是句子中的终结符,内部节点是非终结符。

分析树的构建过程:

  1. 从根节点开始,标记为开始符号 SS

  2. 根据推导过程,逐步展开非终结符节点,直到所有叶节点都是终结符。

  3. 叶节点按照句子中的顺序排列,形成完整的句子。

分析树的用途:

  • 帮助理解句子的语法结构

  • 为后续的语义分析和代码生成提供基础

  • 用于错误检测和恢复

二义性文法#

二义性文法:如果一个句子可以有多于一种不同的分析树,则该文法是二义性的。

例如,考虑以下文法:

E -> E + E
E -> E * E
E -> ( E )
E -> id

对于句子 id + id * id,可以有两种不同的分析树:

  1. 先进行加法运算,再进行乘法运算:
E
/|\
E + E
/ \
id E
/|\
E * E
/ \
id id
  1. 先进行乘法运算,再进行加法运算:
E
/|\
E * E
/ \
E id
/|\
E + E
/ \
id id

二义性文法的问题在于它会导致解析器无法确定正确的语法结构,从而影响后续的语义分析和代码生成。

可以采取重写文法、引入优先级和结合性规则等方法来消除二义性。

重写文法#

通过引入新的非终结符和调整产生式的结构,可以消除二义性。例如,可以将上述二义性文法重写为:

T -> T * F | F
F -> ( E ) | id

这样,乘法运算的优先级高于加法运算,从而消除了二义性。

引入优先级和结合性规则#

通过定义运算符的优先级和结合性,可以指导解析器选择正确的语法结构。例如,可以规定乘法运算的优先级高于加法运算,并且两者都是左结合的。

通过这些方法,可以有效地消除二义性文法,提高解析器的准确性和效率。

自上而下分析#

自上而下分析:对任何输⼊串,试图⽤⼀切可能的办法,从⽂法开始符号(根结点)出发,⾃上⽽下,从左到右地为输⼊串建⽴分析树。或者说,为输⼊串寻找最左推导。

在消除左递归和提取左公因子之后,便可以使用自上而下分析法来解析输入串。

消除左递归#

如果文法中存在推导 A    +AαA \implies^+ A \alpha,则称该文法具有左递归。

可以通过重写产生式来消除直接左递归:

对于产生式 AAαβA \rightarrow A \alpha | \beta,可以重写为:

AβAA \rightarrow \beta A^{'} AαAεA^{'} \rightarrow \alpha A^{'} | \varepsilon

可以理解为,原本 AA 可以推导出任意多个 α\alpha,现在通过引入新的非终结符 AA^{'} 来表示这种重复的结构。

注意到此处 ε\varepsilon 不可省略。

对于间接左递归,则需要先进行消除直接左递归,再进行重写:

对于产生式 ABαA \rightarrow B \alphaBAβγB \rightarrow A \beta | \gamma,可以重写为:

AγαAA \rightarrow \gamma \alpha A^{'} AβαAεA^{'} \rightarrow \beta \alpha A^{'} | \varepsilon

提取左公因子#

如果文法中存在多个产生式 Aαβ1αβ2A \rightarrow \alpha \beta_1 | \alpha \beta_2,则称该文法具有左公因子。

可以通过提取左公因子来重写产生式:

AαAA \rightarrow \alpha A^{'} Aβ1β2A^{'} \rightarrow \beta_1 | \beta_2

LL(1) 文法#

LL(1) 文法:是一种适合自上而下分析的文法类型。LL(1) 文法要求在每一步推导中,能够根据当前输入符号和栈顶非终结符唯一确定下一步的产生式。

我们首先需要定义两个集合:first 集和 follow 集。

  • first 集:对于任意符号串 α\alpha,first(α\alpha) 是能够出现在 α\alpha 推导出的字符串开头的终结符的集合。如果 α\alpha 能够推导出 ε\varepsilon,则 ε\varepsilon 也包含在 first(α\alpha) 中。

    计算 first 集的规则如下:

    1. 如果 XX 是终结符,则 first(XX) = {XX}。

    2. 如果 XX 是非终结符,且存在产生式 XY1Y2...YkX \rightarrow Y_1 Y_2 ... Y_k,则将 first(Y1Y_1) 中的所有非 ε\varepsilon 符号加入 first(XX)。

      • 如果 first(Y1Y_1) 包含 ε\varepsilon,则继续将 first(Y2Y_2) 中的所有非 ε\varepsilon 符号加入 first(XX),以此类推,直到遇到一个不包含 ε\varepsilon 的 first 集,或者处理完所有 YiY_i
      • 如果所有 YiY_i 的 first 集都包含 ε\varepsilon,则将 ε\varepsilon 加入 first(XX)。
    3. 如果存在产生式 XεX \rightarrow \varepsilon,则将 ε\varepsilon 加入 first(XX)。

  • follow 集:对于任意非终结符 AA,follow(AA) 是能够出现在某个句子中 AA 后面的终结符的集合。如果 AA 能够出现在句子的末尾,则 \ (输入结束符)也包含在follow((输入结束符)也包含在 follow(A$) 中。

    计算 follow 集的规则如下:

    1. \ 加入follow( 加入 follow(S),其中),其中 S$ 是开始符号。

    2. 如果存在产生式 BαAβB \rightarrow \alpha A \beta,则将 first(β\beta) 中的所有非 ε\varepsilon 符号加入 follow(AA)。如果 first(β\beta) 包含 ε\varepsilon,则将 follow(BB) 中的所有符号也加入 follow(AA)。

    3. 如果存在产生式 BαAB \rightarrow \alpha A,则将 follow(BB) 中的所有符号加入 follow(AA)。

然后我们其实可以定义 select 集:

  • select 集:对于任意产生式 AαA \rightarrow \alpha,select(AαA \rightarrow \alpha) 是能够触发该产生式的输入符号的集合。

    计算 select 集的规则如下:

    1. 如果 ε\varepsilon \notin first(α\alpha),则 select(AαA \rightarrow \alpha) = first(α\alpha)。

    2. 如果 ε\varepsilon \in first(α\alpha),则 select(AαA \rightarrow \alpha) = (first(α\alpha) - {ε\varepsilon}) \cup follow(AA)。

感性理解即,select 集表示在分析过程中,当栈顶非终结符为 AA,若 AA 能推导出 α\alpha,则下一个终结符必须在 first(α\alpha) - {ε\varepsilon} 中;若 α\alpha 还能推导出 ε\varepsilon,则下一个终结符还可以在 follow(AA) 中。

LL(1) 文法判定的充要条件是 select(AαA \rightarrow \alpha) \cap select(AβA \rightarrow \beta) = \emptyset

递归下降预测分析#

递归下降预测分析:是一种基于 LL(1) 文法的自上而下分析方法。它使用一组递归函数来模拟分析栈的操作,每个非终结符对应一个递归函数。

具体的,对于每个非终结符 AA,定义一个递归函数 parse_A()parse\_A()。在该函数中,根据当前输入符号和 AA 的产生式的 select 集,选择合适的产生式进行推导。

伪代码示例:

function parse_A():
switch (current_input_symbol):
case in select(A -> alpha):
// 推导 A -> alpha
for each symbol in alpha:
if symbol is terminal:
match(symbol)
else:
call parse_symbol()
break
case in select(A -> beta):
// 推导 A -> beta
...
break
...
default:
error("Unexpected input symbol")

非递归预测分析表驱动法#

非递归预测分析表驱动法:是一种基于 LL(1) 文法的自上而下分析方法。它使用一个分析栈和一个预测分析表来指导分析过程。

预测分析表:是一个二维表格,行表示非终结符,列表示终结符。表格中的每个单元格包含对应非终结符和终结符的产生式。

以下为示例:

假设有如下文法和 SELECT 集:

产生式 ii产生式 PiP_iSELECT(Pi)\text{SELECT}(P_i)
1ETEE \rightarrow T E'{ id, ( }
2E+TEE' \rightarrow + T E'{ + }
3EϵE' \rightarrow \epsilon{ ), $ }
4TFTT \rightarrow F T'{ id, ( }
5TFTT' \rightarrow * F T'{ * }
6TϵT' \rightarrow \epsilon{ +, ), $ }
7FidF \rightarrow \text{id}{ id }
8F(E)F \rightarrow ( E ){ ( }

根据 SELECT 集来构建分析表 MM

非终结符id+*()$
EEETEE \rightarrow T E' (1)ETEE \rightarrow T E' (1)
EE'E+TEE' \rightarrow + T E' (2)EϵE' \rightarrow \epsilon (3)EϵE' \rightarrow \epsilon (3)
TTTFTT \rightarrow F T' (4)TFTT \rightarrow F T' (4)
TT'TϵT' \rightarrow \epsilon (6)TFTT' \rightarrow * F T' (5)TϵT' \rightarrow \epsilon (6)TϵT' \rightarrow \epsilon (6)
FFFidF \rightarrow \text{id} (7)F(E)F \rightarrow ( E ) (8)

用表分析输入串:id + id * id $

  1. 初始化
  • 栈: [ $ , E ] (\$$ 是栈底, E$ 是开始符号在栈顶)

  • 输入: id + id * id $ (指向 id)

  1. 分析
步骤当前输入M[栈顶,a]M[\text{栈顶}, a]动作
0[ $, E ]idM[E,id]M[E, \text{id}] = ETEE \rightarrow T E'非终结符展开
1[ $, E', T ]idM[T,id]M[T, \text{id}] = TFTT \rightarrow F T'非终结符展开
2[ $, E', T', F ]idM[F,id]M[F, \text{id}] = FidF \rightarrow \text{id}非终结符展开
3[ $, E', T', id ]id栈顶\text{栈顶}=id, 输入\text{输入}=id匹配 \rightarrow 弹出栈, 移进输入
4[ $, E', T' ]+M[T,+]M[T', +] = TϵT' \rightarrow \epsilon非终结符展开 (ϵ\epsilon 即不压入任何东西)
5[ $, E' ]+M[E,+]M[E', +] = E+TEE' \rightarrow + T E'非终结符展开 (注意要逆序压栈 E,T,+E', T, +)
6[ $, E', T, + ]+栈顶\text{栈顶}=+, 输入\text{输入}=+匹配 \rightarrow 弹出栈, 移进输入
7[ $, E', T ]idM[T,id]M[T, \text{id}] = TFTT \rightarrow F T'非终结符展开
8[ $, E', T', F ]idM[F,id]M[F, \text{id}] = FidF \rightarrow \text{id}非终结符展开
9[ $, E', T', id ]id栈顶\text{栈顶}=id, 输入\text{输入}=id匹配 \rightarrow 弹出栈, 移进输入
10[ $, E', T' ]*M[T,]M[T', *] = TFTT' \rightarrow * F T'非终结符展开 (注意要逆序压栈 T,F,T', F, *)
11[ $, E', T', F, * ]*栈顶\text{栈顶}=*, 输入\text{输入}=*匹配 \rightarrow 弹出栈, 移进输入
12[ $, E', T', F ]idM[F,id]M[F, \text{id}] = FidF \rightarrow \text{id}非终结符展开
13[ $, E', T', id ]id栈顶\text{栈顶}=id, 输入\text{输入}=id匹配 \rightarrow 弹出栈, 移进输入
14[ $, E', T' ]$M[T', \]==T’ \rightarrow \epsilon$非终结符展开
15[ $, E' ]$M[E', \]==E’ \rightarrow \epsilon$非终结符展开
16[ $ ]$栈顶\text{栈顶}=$, 输入\text{输入}=$分析成功

自下而上分析#

自下而上分析:对任何输⼊串,试图⽤⼀切可能的办法,从输⼊串的记号开始,⾃下⽽上,从左到右地为输⼊串建⽴分析树。或者说,为输⼊串寻找最右推导。

规约#

规约(Reduction):是自下而上分析中的一个基本操作。它是将输入串中的一部分符号替换为对应的非终结符,从而逐步构建分析树的过程。

在一步规约中,选择输入串中的一个子串,该子串与某个产生式的右部匹配,然后将该子串替换为该产生式的左部非终结符。

本质上,规约是推导的逆过程。在推导中,我们从非终结符出发,通过应用产生式将其替换为终结符串;而在规约中,我们从终结符串出发,通过应用产生式将其替换为非终结符。

句柄#

句柄(Handle):是在自下而上分析中,当前输入串中可以进行规约的最右侧子串。换句话说,句柄是句型的一个子串,它与某个产生式的右部匹配,并且在当前分析状态下,可以被规约为该产生式的左部非终结符。

句柄的性质:

  1. 句柄的右侧仅含有终结符。

  2. 若文法二义,则句柄可能不唯一。

例:

考虑以下文法:

AβA \rightarrow \beta

αβω\alpha \beta \omega 的句柄为 β\beta,注意不是 βω\beta \omega(句柄必须是产生式的右部)。

用栈实现移进-规约分析#

分析器的基本动作有两种:移进(Shift)和规约(Reduce)。实际还可能会有接受(Accept)和出错(Error)两种动作。

移进:将当前输入符号移入栈中。

规约:将栈顶的若干符号根据某个产生式进行规约。

接受:当栈中只剩下开始符号,且输入串已全部处理完毕时,表示分析成功。

出错:当无法进行移进或规约时,表示分析失败。

移进-规约分析的基本步骤:

  1. 初始化:将栈初始化为空,输入串指向第一个符号。

  2. 分析循环:

    1. 检查栈顶符号和当前输入符号,决定进行移进还是规约。

    2. 如果可以规约,则执行规约操作,将栈顶的若干符号替换为对应的非终结符。

    3. 如果不能规约,则执行移进操作,将当前输入符号移入栈中,并将输入指针向后移动一位。

    4. 如果栈中只剩下开始符号,且输入串已全部处理完毕,则执行接受操作,分析成功。

    5. 如果无法进行移进或规约,则执行出错操作,分析失败。

  3. 重复步骤 2,直到分析成功或失败。

在每一步中的动作,都是基于当前栈顶符号和输入符号来决定的下一步操作。

移进-规约分析的冲突#

在移进-规约分析中,可能会遇到两种类型的冲突:移进-规约冲突和规约-规约冲突。

  1. 移进-规约冲突(Shift-Reduce Conflict):当分析器在某个状态下,既可以进行移进操作,也可以进行规约操作时,就会发生移进-规约冲突。这种冲突通常发生在文法中存在二义性或者优先级不明确的情况下。

例如,考虑以下文法:

E -> E + E
E -> E * E
E -> id

对于输入串 id + id * id,在分析过程中,当栈顶为 E + E 时,分析器既可以选择移进下一个符号 *,也可以选择规约 E + EE。这就导致了移进-规约冲突。

  1. 规约-规约冲突(Reduce-Reduce Conflict):当分析器在某个状态下,存在多个产生式可以用于规约时,就会发生规约-规约冲突。这种冲突通常发生在文法中存在二义性或者产生式重叠的情况下。

例如,考虑以下文法:

S -> A
S -> B
A -> a
B -> a

对于输入串 a,在分析过程中,当栈顶为 a 时,分析器既可以选择规约 aA,也可以选择规约 aB。这就导致了规约-规约冲突。

为了解决这些冲突,可以采取以下方法:

  • 优化文法:通过重写文法,消除二义性和重叠的产生式。

  • 引入优先级和结合性规则:为运算符定义优先级和结合性,指导分析器选择正确的操作。

  • 使用更强大的分析方法:如 LR 分析器,可以处理更广泛的文法类型,减少冲突的发生。

LR 分析器#

在发生移进-规约冲突和规约-规约冲突时,朴素的移进-规约分析器无法继续进行分析。而部分 LR 分析器可以通过构建更复杂的状态机来解决这些冲突,解决的能力根据 LR(0)、SLR(1)、LALR(1)、LR(1) 等分析器的不同类型而有所不同。

具体的,LR 分析器通过构建一个状态机来表示分析过程中的各种状态。每个状态包含一个项目集,表示在该状态下可能的分析情况。通过分析输入符号和栈顶符号,LR 分析器可以决定下一步的操作。

活前缀#

活前缀(Viable Prefix):是在自下而上分析中,当前栈中的符号串,它是某个右句型的前缀,并且可以通过一系列规约操作从输入串中得到。该前缀不超过最右句柄,最多包含最右句柄。

LR(0) 项目#

LR(0) 项目:是文法产生式的一种扩展形式,用于表示分析器在某个状态下的分析情况。LR(0) 项目由一个产生式和一个点(·)组成,点表示当前分析的位置。

例如,考虑产生式 AXYZA \rightarrow XYZ,则对应的 LR(0) 项目有以下几种形式:

  • AXYZA \rightarrow \cdot XYZ:表示还没有分析任何符号。

  • AXYZA \rightarrow X \cdot YZ:表示已经分析了符号 XX,正在分析符号 YY

  • AXYZA \rightarrow XY \cdot Z:表示已经分析了符号 XXYY,正在分析符号 ZZ

  • AXYZA \rightarrow XYZ \cdot:表示已经分析完了符号 XXYYZZ

如果产生式为 AεA \rightarrow \varepsilon,则对应的 LR(0) 项目为 AA \rightarrow \cdot

项目分为四类:

  • 规约项目:点的后继符号为空。

  • 移进项目:点的后继符号为终结符。

  • 待约项目:点的后继符号为非终结符。

  • 接受项目(唯一):项目左部为拓广文法的开始符号的规约项目。

LR(0) 项目集规范族#

LR(0) 项目集规范族:是 LR(0) 分析器的核心概念,用于表示分析器在不同状态下的分析情况。LR(0) 项目集规范族由一组 LR(0) 项目集组成,每个项目集表示分析器在某个状态下可能的分析情况。

构建 LR(0) 项目集规范族的步骤:

  1. 拓广文法:从文法的开始符号出发,构建初始项目集 I0I_0,包含项目 SSS' \rightarrow \cdot S,其中 SS' 是新的开始符号。

  2. 闭包操作:对于每个项目集 II,执行闭包操作,添加所有可能的项目。对于每个项目 AαBβA \rightarrow \alpha \cdot B \beta,如果 BB 是非终结符,则将所有产生式 BγB \rightarrow \gamma 添加到项目集中,形式为 BγB \rightarrow \cdot \gamma

  3. 转移操作:对于每个项目集 II 和每个符号 XX(终结符或非终结符),计算转移项目集 goto(I,X)goto(I, X)。对于每个项目 AαXβA \rightarrow \alpha \cdot X \beta,将其转换为 AαXβA \rightarrow \alpha X \cdot \beta 并添加到新的项目集中。

  4. 重复步骤 2 和 3,直到不再有新的项目集产生。

通过构建 LR(0) 项目集规范族,可以为 LR(0) 分析器提供状态机的基础,从而实现对输入串的分析和处理。

SLR(1) 分析器#

SLR(1) 分析器:是一种基于 LR(0) 项目集规范族的自下而上分析器。它通过引入 follow 集来解决 LR(0) 分析器中的移进-规约冲突,从而能够处理更广泛的文法类型。

SLR(1) 分析器的构建步骤:

  1. 构建 LR(0) 项目集规范族:按照前述方法构建 LR(0) 项目集规范族。

  2. 计算 follow 集:对于文法中的每个非终结符,计算其 follow 集。

  3. 构建分析表:对于每个项目集 II 和每个项目 AαA \rightarrow \alpha \cdot(规约项目),如果 AA 不是拓广文法的开始符号,则对于每个终结符 aa 在 follow(AA) 中,将规约操作 AαA \rightarrow \alpha 添加到分析表的对应位置。

  4. 处理移进操作:对于每个项目集 II 和每个项目 AαaβA \rightarrow \alpha \cdot a \beta(移进项目),将移进操作添加到分析表的对应位置。

通过引入 follow 集,SLR(1) 分析器能够更准确地决定何时进行规约操作,从而减少移进-规约冲突的发生,提高分析器的能力。

LR(1) 分析器#

LR(1) 分析器:是一种更强大的自下而上分析器,它通过引入向前看符号(lookahead symbol)来解决移进-规约冲突和规约-规约冲突,从而能够处理更广泛的文法类型。

LR(1) 分析器的构建步骤:

  1. 构建 LR(1) 项目集规范族:与 LR(0) 项目类似,但每个项目还包含一个向前看符号。对于每个项目 AαBβ,aA \rightarrow \alpha \cdot B \beta, a,如果 BB 是非终结符,则将所有产生式 BγB \rightarrow \gamma 添加到项目集中,形式为 Bγ,bB \rightarrow \cdot \gamma, b,其中 bb 属于 first(βa\beta a)。
  • 关于 first(βa\beta a) 的计算:

    1. 如果 β\beta 能推导出 ε\varepsilon,则 first(βa\beta a) = first(β\beta) - {ε\varepsilon} \cup { aa }。

    2. 如果 β\beta 不能推导出 ε\varepsilon,则 first(βa\beta a) = first(β\beta)。

  1. 构建分析表:对于每个项目集 II 和每个项目 AαA \rightarrow \alpha \cdot(规约项目),对于每个向前看符号 aa,将规约操作 AαA \rightarrow \alpha 添加到分析表的对应位置。对于每个项目 AαaβA \rightarrow \alpha \cdot a \beta(移进项目),将移进操作添加到分析表的对应位置。

通过引入向前看符号,LR(1) 分析器能够更准确地决定何时进行规约操作,从而进一步减少冲突的发生,提高分析器的能力。

LALR(1) 分析器#

LALR(1) 分析器:是一种介于 SLR(1) 和 LR(1) 之间的自下而上分析器。它通过合并具有相同核心的 LR(1) 项目集来减少状态数量,从而在保持较高分析能力的同时,提高分析器的效率。

LALR(1) 分析器的构建步骤:

  1. 构建 LR(1) 项目集规范族:按照前述方法构建 LR(1) 项目集规范族。

  2. 合并项目集:对于具有相同核心(即忽略向前看符号后相同)的 LR(1) 项目集,将它们合并为一个 LALR(1) 项目集。合并后的项目集包含所有原始项目集的项目,但向前看符号为它们的并集。

  3. 构建分析表:与 LR(1) 分析器类似,但使用合并后的 LALR(1) 项目集来构建分析表。

通过合并项目集,LALR(1) 分析器能够显著减少状态数量,从而提高分析器的效率,同时仍然能够处理大部分 LR(1) 文法。

但是,LALR(1) 分析器由于合并项目集,可能会引入规约-规约冲突,因此在某些情况下,其分析能力可能略低于 LR(1) 分析器。

语义分析#

语义分析:是在语法分析之后,对源程序的语义进行检查和处理的阶段。其主要任务是确保程序的语义正确性,并为后续的中间代码生成和优化提供必要的信息。

属性文法#

属性文法:是在上下文无关文法的基础上,增加了属性和语义规则的文法。属性用于描述语法符号的语义信息,语义规则用于定义如何计算这些属性。

属性分为两类:

  • 综合属性(Synthesized Attributes):是从子节点传递到父节点的属性。它们用于描述由子节点的信息计算得到的父节点的信息。

  • 继承属性(Inherited Attributes):是从父节点或者兄弟节点传递到子节点的属性。它们用于描述由父节点或者兄弟节点的信息传递给子节点的信息。

每个产生式都可以关联一组语义规则,用于计算相关属性。例如,考虑以下产生式:

E -> E1 + T

可以定义以下语义规则:

E.val = E1.val + T.val

这里,E.val 是综合属性,表示表达式 E 的值;E1.valT.val 分别表示子表达式 E1T 的值。

终结符只有综合属性,且这些综合属性常由词法分析器提供。

非终结符可以有综合属性和继承属性。

文法开始符号没有继承属性,除非另外加以说明。

文法符号的综合属性集和继承属性集的交集应为空。

语法制导翻译#

语法制导翻译(Syntax-Directed Translation):是利用属性文法进行源程序翻译的一种方法。它通过在语法分析过程中计算属性,并根据这些属性生成中间代码或目标代码。

语法制导翻译的基本步骤:

  1. 定义属性和语义规则:为文法中的每个产生式定义相关的属性和语义规则。

  2. 构建属性计算顺序:确定在语法分析过程中计算属性的顺序,确保在计算某个属性之前,其所依赖的属性已经被计算。

  3. 生成中间代码:在计算属性的过程中,根据语义规则生成相应的中间代码。

例如,考虑以下产生式和语义规则:

E -> E1 + T
E.val = E1.val + T.val

在语法分析过程中,当遇到产生式 E -> E1 + T 时,可以根据语义规则计算 E.val,并生成相应的中间代码,如:

temp = E1.val + T.val
E.val = temp

通过语法制导翻译,可以在语法分析的同时完成源程序的翻译工作,提高编译器的效率和准确性。

语义规则的计算方法#

语义规则的计算方法:是指在属性依赖图中,按照依赖关系计算属性的具体方法。根据属性的类型(综合属性或继承属性),可以采用不同的计算方法。

分析树方法:通过构建分析树,按照从叶节点到根节点的顺序计算综合属性,或者按照从根节点到叶节点的顺序计算继承属性。

基于规则的方法:静态确定语义规则的计算次序。

忽略规则的方法:事先确定属性的计算策略(如边分析边计算),那么语义规则的设计必须符合所选分析方法的限制。

注释分析树#

注释分析树:是在分析树的基础上,增加了属性和语义规则的信息。每个节点不仅表示语法符号,还包含与该符号相关的属性和计算这些属性的语义规则。

在注释分析树中,每个节点包含以下信息:

  • 语法符号:表示该节点对应的语法符号(终结符或非终结符)。

  • 属性:表示与该语法符号相关的属性(综合属性和继承属性)。

  • 语义规则:表示用于计算属性的语义规则。

属性依赖图#

属性依赖图:是用于表示属性之间依赖关系的有向图。在属性依赖图中,节点表示属性,边表示属性之间的依赖关系。

构建属性依赖图的步骤:

  1. 为每个属性创建一个节点。

  2. 根据语义规则,添加有向边表示属性之间的依赖关系。如果属性 AA 的计算依赖于属性 BB,则添加一条从节点 BB 到节点 AA 的有向边。

属性计算顺序#

属性计算顺序:是指在属性依赖图中,按照依赖关系计算属性的顺序。为了确保在计算某个属性之前,其所依赖的属性已经被计算,需要确定一个合适的计算顺序。

属性计算顺序的确定方法:

  1. 构造输入的分析树

  2. 构造属性依赖图

  3. 对结点进行拓扑排序

  4. 按拓扑排序的次序计算属性

抽象语法树#

抽象语法树(Abstract Syntax Tree,AST):是一种简化的语法树,用于表示源程序的结构和语义信息。与分析树不同,抽象语法树省略了某些语法细节,只保留与程序语义相关的信息。

通常将语义动作(包括 AST 构造函数)嵌入到文法中,并在语法分析过程中同步执行。

S 属性定义的自下而上计算#

S 属性:只有综合属性的属性文法称为 S 属性文法。

S 属性定义的自下而上计算:是一种属性计算方法,适用于综合属性。它通过在语法分析过程中,从叶节点向根节点逐步计算属性。

具体步骤:

  1. 在语法分析过程中,当遇到一个产生式 AαA \rightarrow \alpha 时,首先确保所有子节点 α\alpha 的属性已经被计算。

  2. 根据该产生式的语义规则,计算父节点 AA 的属性,通常是通过子节点的属性进行计算。

  3. 将计算得到的属性赋值给父节点 AA

设计递归下降编译器的方法#

设计递归下降编译器的方法:是一种基于 LL(1) 文法的自上而下编译器设计方法。它通过定义一组递归函数来实现语法分析和属性计算。

  1. 对每个非终结符 A 构造一个函数过程,对 A 的每个继承属性设置一个形式参数函数的返回值为 A 的综合属性(作为记录,或指向记录的一个指针,记录中有若干域,每个属性对应一个域)。为了简单,假设每个非终结只有一个综合属性 A 对应的函数过程中,为出现在 A 的产生式中的每一个文法符号的每一个属性都设置一个局部变量。

  2. 在函数过程中,根据 A 的每个产生式的 SELECT 集,编写选择语句(如 switch 语句),以选择适当的产生式进行推导。

  3. 每个产生式对应的程序代码中,按照从左到右的次序,对于单词符号(终结符)、非终结符和语义动作分别作以下工作:

    1. 对于带有综合属性 x 的终结符 X ,把 x 的值存入为 X.x 设置的变量中。然后产生一个匹配 X 的调用,并继续读入一个输入符号。

    2. 对于每个非终结符 B,产生赋值语句 c = B(b1, b2, ..., bk),其中,b1, b2, ..., bk 是为 B 的继承属性设置的变量,c 是为 B 的综合属性设置的变量。

    3. 对于语义动作,把动作的代码抄进分析器中,用代表属性的变量来代替对属性的每一次引用。

L 属性的定义#

如果每个产生式 AX1X2...XnA \rightarrow X_1 X_2 ... X_n 的每条语义规则计算的属性是 A 的综合属性,或者计算的是某个 XiX_i 的继承属性,并且该继承属性只依赖于 A 的继承属性和 X1,X2,...,Xi1X_1, X_2, ..., X_{i-1} 的属性,则称该属性文法为 L 属性文法。

翻译方案#

翻译方案:是在文法产生式中嵌入语义动作的一种方法。语义动作通常用大括号 {} 包围,表示在语法分析过程中执行的操作。

例如,考虑以下产生式和嵌入的语义动作:

E -> E1 + T { E.val = E1.val + T.val }

在这个例子中,语义动作 { E.val = E1.val + T.val } 表示在分析过程中,当应用该产生式时,计算表达式 E 的值。

本质上,翻译方案是对语法制导定义的一种实现性改进或具体化。

L 属性定义的自上而下计算#

当只需要综合属性时:为每一个语义规则建立一个包含赋值的动作,并把这个动作放在相应的产生式右边的末尾。

如果既有综合属性又有继承属性,在建立翻译模式时就必须保证:

  1. 产生式右边的符号的继承属性必须在先于这个符号的动作中计算出来。

  2. 一个动作不能引用这个动作右边的符号的综合属性。

  3. 产生式左边非终结符的综合属性只有在它所引用的所有属性都计算出来以后才能计算。计算这种属性的动作通常可放在产生式右端的末尾。

可以感性理解:非终结符为函数,则其继承属性为函数的参数,综合属性为函数的返回值。S 属性定义的自下而上本质上就是 AST 的后序遍历,而 L 属性定义的自上而下就涉及到函数的递归问题,为了不要发生无限递归,我们就需要消除左递归。

通过改写文法用综合属性代替继承属性#

通过改写文法用综合属性代替继承属性:是一种将 L 属性文法转换为 S 属性文法的方法。该方法通过引入新的非终结符和产生式,将继承属性转换为综合属性,从而简化属性计算过程。

一个常见的例子是 Pascal 语言中的类型声明。假设有以下产生式:

D -> L : T
T -> type
L -> L, id | id

该产生式中的 L 非终结符有一个继承属性 type,用于传递类型信息。我们可以改写文法,将继承属性转换为综合属性:

D -> id L
L -> , id L | : T
T -> type

在改写后的文法中,L 非终结符的类型信息作为综合属性传递,从而避免了继承属性的使用。通过这种方法,可以将 L 属性文法转换为 S 属性文法,简化属性计算过程。

L 属性定义的自下而上计算#

在自下而上计算中,我们常使用一个辅助栈来存储继承属性的值。在规约操作时,将继承属性从栈中弹出,并将其传递给相应的非终结符。

对于不需要进行属性计算的翻译方案,可以新建一个语法记号并让其产生 ε\varepsilon,然后把不需要计算属性的动作放在这个新记号的产生式中。

对于需要进行属性计算的翻译方案,我们可以考虑确定栈中所需要的继承属性的位置,然后在规约时直接可以从栈中取出这些属性进行计算。

需要注意的情况是,有时候可能会出现栈中所需要的继承属性位置不唯一的情况,这时可以通过在翻译方案中增加一些辅助记号来解决这个问题,从而确保每个继承属性的位置是唯一确定的。

例如,考虑以下产生式:

A -> B D
A -> B C D

假设 D 有一个继承属性 type,D.type = B.type。

  • 在规约 A -> B D 时,栈中 B 的位置是唯一的,可以直接取出 B.type 进行计算。

  • 在规约 A -> B C D 时,D.type 仍然等于 B.type,但此时无法唯一确定 B 的位置。

为了解决这个问题,我们可以引入一个新的辅助记号 X,使文法变为:

A -> B D
A -> B C X D
X -> ε

这样,在规约 A -> B C X D 时,X 的位置是唯一的,可以通过 X 来间接确定 B 的位置,从而正确计算 D.type。

Chapter 6 运行时存储空间的组织和管理#

两个概念#

过程的活动:是指过程执行的一次具体实例,包括该过程的参数、局部变量、返回地址等信息。

活动记录:是用于存储过程活动信息的数据结构。它包含了过程执行所需的各种信息,如参数、局部变量、返回地址等。

活动的生存期:是指过程活动从开始到结束的时间段。在此期间,活动记录在运行时存储空间中占据一定的空间。

  • 过程可以是递归的

  • 一个过程可以对应多个活动

通俗理解:

  • 函数的定义代码段是一个过程

  • 调用一次函数是一次活动

  • 活动记录就是函数运行时的栈空间栈帧

  • 活动的生存期就是一次函数运行的生命周期

过程#

过程(Procedure):是一组执行特定任务的语句集合。过程可以接受参数,执行操作,并返回结果。过程的定义通常包括过程名、参数列表和过程体。

大概包括:过程定义、过程调用、形式参数、实在参数、活动的生存期等内容

此处的过程调用是指调用点 / 调用指令。

名字的作用域#

名字的作用域(Scope of a Name):是指在程序中一个名字(变量、函数等)可以被访问和使用的范围。作用域决定了名字的可见性和生命周期。

感性理解可以理解为变量的作用域。

即使一个名字在程序中只声明一次,该名字在程序运行时也可能表示不同的数据对象。

注意到作用域和生存期是两个概念,作用域指的是什么时候可以访问一个名字,而生存期指的是什么时候一个数据对象存在于内存中。

名字(变量)的作用域和绑定#

名字到存储单元的绑定:

  • 环境把名字映射到左值(存储单元),而状态把左值映射到右值(值)。

  • 赋值改变状态,但不改变环境。

  • 如果环境将名字 x 映射到存储单元 s,就说 x 被绑定到 s

静态概念动态对应
过程的定义过程的活动
名字的声明名字的绑定
声明的作用域绑定的生存期

活动记录#

活动记录(Activation Record):是用于存储过程活动信息的数据结构。它包含了过程执行所需的各种信息,如参数、局部变量、返回地址等。

被编译程序每次运行时,编译器从操作系统获得一块存储区(内存)。其内容包括:

  • 编译后的目标代码(可执行程序.exe)

  • 数据对象(各种静态变量和动态变量)

  • 用于管理过程活动的控制栈(活动记录)

被编译程序每次运行时,编译器从操作系统获得一块存储区(内存)。其空间安排大概为:

内存
静态数据
\downarrow
空闲内存
\uparrow

一般的活动记录的布局:

栈帧
临时数据
局部数据
机器状态
访问链
控制链
返回值
实在参数

局部数据的安排#

  • 字节是可编址内存的最小单位。

  • 一个过程所声明的局部变量,按这些变量声明时出现的次序,在活动记录的局部数据区中依次分配空间。

  • 局部数据的地址可以用相对于某个位置(本过程对应的活动记录的起始位置)的偏移来表示。

  • 数据对象的存储安排深受目标机器寻址方式的影响,存在对齐问题。例如,要求整数(int,long)的相对地址可以被 44 整除。

  • 由于对齐而引起的无用空间称为衬垫空白区。

这里涉及结构体的对齐问题,结构体的对齐方式通常是按照其最大成员的对齐方式进行对齐。

程序块#

程序块(Block):是指一组语句的集合,这些语句在逻辑上被组织在一起。程序块可以包含变量声明、控制结构、函数调用等。

程序块的作用域通常是从块的开始到块的结束。在程序块内声明的变量只能在该块内访问。

分配策略#

分配策略:是指在运行时存储空间中为数据对象分配内存的策略。常见的分配策略包括静态分配、堆式分配和栈式分配。

静态分配#

静态分配:

  • 名字在程序被编译时绑定到存储单元,不需要运行时的任何支持。

  • 绑定的生存期是程序的整个运行时间。

  • 控制再次进入该过程时,局部变量的值和控制上一次离开时的一样。

  • 每个活动记录的大小是固定的。

  • 过程调用时保存信息的地址在编译时也是已知的。

静态分配的限制:

  • 递归过程不被允许

  • 数据对象的长度和它在内存中位置,必须是在编译时可以知道的

  • 数据结构不能动态建立

栈式分配#

静态分配:

  • 主要用于管理过程的活动记录。

  • 局部变量的生存期是过程活动的时间。

  • 控制进入该过程时,局部变量绑定到存储单元,过程活动结束后,局部变量的空间释放。

活动树:是表示程序中过程调用关系的树状结构。每个节点表示一个过程活动,边表示过程调用关系。描绘控制进入和离开活动的方式。

运行栈:把控制栈中的信息拓广到包括过程活动所需的所有局部信息(即活动记录)

调用序列#

调用序列(Call Sequence):是指在程序执行过程中,过程调用和返回的顺序。调用序列反映了程序的控制流和过程间的调用关系。

过程调用序列:过程调用时执行的分配活动记录,把信息填入它的域中,使被调用过程可以开始执行的代码

过程返回序列:被调用过程返回时执行的恢复机器状态,释放被调用过程活动记录,使调用过程能够继续执行的代码

从零开始的编译原理速成
https://blog.farewe1ll.top/posts/从零开始的编译原理速成/
作者
Farewe1ll 山竹
发布于
2025-12-02
许可协议
CC BY-NC-SA 4.0