ustc_编译原理
编译器流程
前端理解程序,后端生成机器码
ppt里面的流程长这样
1 | |

详细流程
1+2+3
源语言只支持:
1 | |
目标机器是一台栈式计算机,只支持:
1 | |
现在来编译1+2+3
先词法分析,把字符拆成token然后识别这是什么词
然后构造语法树
+
/ \
+ 3
/ \
1 2
(1 + 2) + 3
然后后序遍历
push 1
push 2
add
push 3
add
如果开了代码优化就直接是6
note1
从源代码到最后的二进制文件中间要经历这样一个过程
front end ->IR ->back end
前端包含词法分析,语法分析,语义分析
词法分析把字符流拆成token并标上记号
语法分析把token组织成语法树,语法正确不代表程序合理
语义分析
主要检查:
- 变量是否声明
- 类型是否匹配
- 函数参数数量是否正确
- 返回值类型是否正确
- 标识符的作用域
- 是否重复定义
break是否位于循环中- 常量是否被修改
语义分析时通常会建立符号表记录变量函数等信息
前端合法后会生成中间表示(IR)
IR位于源码和最终文件之间
后端一般包括:
- 代码优化
- 指令选择
- 寄存器分配
- 指令调度
- 目标代码生成
词法分析
词法分析器把字符流转化成记号流(token流)

词法分析基础和手工构造实现
词法分析切成的token包含类型和原始文本(lexeme),实际是否包含lexeme得看类型

还有枚举类型
词法分析手工构造实现
词法分析器可以通过手工构造或词法分析生成器实现,用生成器不好控制细节,手工构造的分析器通常效率更高

转移图
状态圆圈 当前识别进度
箭头上的字符 转移条件
双圆圈 成功识别 Token
星号 多读字符,需要回退
自环 可以重复读取某类字符
关系运算符转移图


伪c
标识符转移图

c语言中的表示符以字母或下划线开头,后面跟0个或多个字母,下划线或数字
标识符和关键字
词法分析上来看,关键字是标识符的一部分

关键字表算法
词法分析器会先按照标识符规则读一遍,然后准备一个关键字表(哈希表H),读取完整字符串后查询
H = {
"if" → IF,
"else" → ELSE,
"while" → WHILE,
"return" → RETURN,
"int" → INT
}
对所有的标识符和关键字,先统一按标识符的转移图进行识别。识别完成后,进一步查表H看是否是关键字
词法分析器自动生成
程序员只要写出声明式的规范,其余操作由Lex/Flex等工具完成,自动生成词法分析器
华老师表示一个几百行的声明式规范经由Lex/Flex后能生成2k左右的词法分析器。声明式的规范就是正则表达式,中间流程大致上是从正则表达式转化成自动机,从自动机转化成表驱动的算法
正则表达式

字符集根据具体的语言来定,如果是c则是ascll,java则是unicode,这里的字符集可以看成算数表达式中基本的数字
用正则表达式表示标识符


语法糖(Syntactic Sugar)
它完全不提供任何新的功能。 也就是脱了这层糖衣,用语言本身最基础的语法,依然能实现一模一样的逻辑。对于编译器来说,在生成机器码之前,都会进行“解糖”(Desugaring)操作,把语法糖还原成最基础的底层结构

c或java所提供的程序设计的语法都可以看作是语法糖,即在汇编层面上对赋值 跳转这两个运算的一种封装。
这些更高层的抽象可以让程序员构造程序更简单,也更容易被人类理解跟维护
有限状态自动机(FA)
自动生成法

能识别给的字符串就输出yes,不能就输出no
例1

可以看出字符表里面的元素为a,b
S是状态集,{0,1,2}
q0,起始状态规定只有一个单向箭头指向的边为起始状态,即0号状态
F是终结状态的集合,约定在一个状态上画上双圈就表示接受状态,有的自动机里会有多个接受状态
转移函数如图,对于一个字符串s=“abab”,根据转移函数做状态转移
例2

在q0上读取a时的转移函数指向的是一个状态集,可以向下走回到q0也可以向右走到q1,即两个元素构成的集合
在q0上读到b时指向确定的q1,在q1上读到b时可以回到q0,也可以自身循环
这种就是非确定有限状态自动机->NFA
在所有状态上对于给定的字符,它的状态转移都是对应的单元素的一个集合,如例1就是确定有限状态自动机->DFA
注意到,对于s=”a”,这个NFA既可以接受也可以不接受。但其实只要能走到一个接受状态那么就叫这个串可以被接受
NFA的判断开销比较大(需要回溯,遍历等运算),实际运用中在一些情况下我们可以把NFA转化成等价的DFA
小结

德尔塔是转移函数,S是状态集,西格玛是输入的字符集,里面的乘号表示笛卡尔积
DFA中,对于任意一个西格玛中的字符最多有一个状态可以转移(),其转移后状态属于S
NFA中的比较复杂,对于一个字符有多个状态可以转移。对于所有状态来说它的转移字符也发生了变化,除了西格玛还有一个空转,即从一个状态转移到另一个状态不需要消耗s中的任何一个字符。我们可以看到其值域是s的幂集,即所有子集构成的集合(2的n次方)
DFA的实现例子

我们可以把DFA看成一个带边和节点有向图,不过这里的边上跟节点上是有信息的
DFA跟NFA
| 特点 | DFA | NFA |
|---|---|---|
| 当前活动状态 | 一个 | 一组 |
| 同一字符的下一状态 | 唯一 | 可以有多个 |
| 是否允许 ε 转移 | 不允许 | 允许 |
| 执行方式 | 直接查表 | 同时跟踪多条路径 |
| 构造难度 | 相对复杂 | 通常更容易 |
| 实现效率 | 通常更直接 | 需要维护状态集合 |
正则表达式(re)到非确定有限状态自动机
正则表达式负责描述 Token,NFA 和 DFA 负责真正识别 Token,生成器再把 DFA 变成词法分析器代码
标识符:[A-Za-z_][A-Za-z0-9_]*
整数: [0-9]+
空白符:[ \t\n]+
关键字:if、while、int
只是在描述什么样的字符串属于哪一种token
这些规则就是声明式规范
re先通过thompson算法转化成非确定有限状态自动机,然后用子集构造法转化成确定的DFA,
之后通过hopcrotf算法生成词法分析器代码
DFA的代码表示有不同方式,看实际需要
RE->NFA thompson算法
thompson算法基于数学归纳法
首先对RE的结构归纳,基本的RE直接构造(base case),复合的RE递归构造(inductive step)
递归算法基本上不超过100行c
基本的RE有两种
1.单个字符 (比如 a)
2.空字符 (ϵ, Epsilon)
ϵ 边在 NFA 里就相当于汇编里的 无条件跳转指令 (jmp)。不需要消耗任何输入字符,直接顺着指针跳到下一个状态
复合的RE有3种
拼接(正则表达式的连接),选择,闭包(AB,A|B,A*)
具体的thompson算法构造过程

注意到,对于正则e,前两个直接构造,后3个采用递归

前两种

把两个正则拼在一起

e1或e2
e1*表示0个或任意多个e1连在一起

从NFA到DFA
NFA写算法要用到回溯等运算,但DFA就不用考虑了

子集构造算法
DFA 中的每个状态,都是 NFA 状态集合的一个子集
假设 NFA 当前可能位于:
1 | |
那么在 DFA 中,不再分别记录三个状态,而是把整个集合起名为:
1 | |
以后 DFA 只需要记录:
1 | |
这样就把 NFA 的“多个可能状态”压成了 DFA 的“一个确定状态”

ε-closure:ε 闭包
ε-closure(T) 表示:
从状态集合 T出发,只沿着零条或多条 ε 边,能够到达的所有状态
例如:
1 | |
那么:
1 | |
ε 闭包可用深度优先或广度优先计算
move:字符转移
move(T,c)表示:
从集合 T中的任意状态出发,读取字符 c后能到达哪些状态。
例如:
1 | |
则:
1 | |
move 只走标有真实字符的边,不走 ε 边
假设 DFA 当前状态对应 NFA 状态集合 T,读取字符 c 后的新状态为:
U=ε-closure(move(T,c))
也就是:
1 | |
这里要用到一个工作表,worklist,worklist在实际实现的时候可以用队列
维护两个集合:
1 | |
初始化:
1 | |
从工作表取出一个集合 T,对字母表中的每个字符 c 计算:
1 | |
然后建立 DFA 转移:
1 | |
如果 U 是第一次出现:
1 | |
直到工作表为空
伪代码
1 | |
DFA的接受状态判断
假设 NFA 的接受状态是:
1 | |
某个 DFA 状态为:
1 | |
因为集合中包含 NFA 接受状态 n9,所以 D1 就是 DFA 接受状态
DFA 状态集合中只要包含至少一个 NFA 接受状态,它就是接受状态
例子

| DFA 状态 | 对应的 NFA 状态集合 | a |
b |
c |
接受 |
|---|---|---|---|---|---|
D0 |
{n0} |
D1 |
Ddead |
Ddead |
否 |
D1 |
{n1,n8,n6,n9,n2,n4} |
Ddead |
D2 |
D3 |
是 |
D2 |
{n3,n7,n6,n9,n2,n4} |
Ddead |
D2 |
D3 |
是 |
D3 |
{n5,n7,n6,n9,n2,n4} |
Ddead |
D2 |
D3 |
是 |
Ddead |
∅ |
Ddead |
Ddead |
Ddead |
否 |
算法为什么能够运行终止
假设 NFA 有 N个状态。
DFA 中的每个状态都是 NFA 状态集合的一个子集。
一个含有 N 个元素的集合最多有2的n次方个不同子集。
例如三个 NFA 状态:
1 | |
所有子集是:
1 | |
一共:
2的3次方=8
所以最坏情况下 DFA 可能有 2的n次方个状态。
但实际运用中很多子集根本不可达
ε闭包的实现-深度优先
选择一条路径一直向下走,走不动以后再返回,继续搜索其他路径

1 | |
对于一个集合,要从集合中的每个状态开始 DFS:
1 | |
ε闭包的实现-广度优先
基于队列概念,先搜索距离起点最近的所有状态,再搜索下一层状态
1 | |
DFS 和 BFS 的结果相同,只是遍历顺序不同
DFA最小化
子集构造把 NFA 转换成 DFA 后,得到的 DFA 往往存在一些“重复状态”。
DFA 最小化的目标是:
合并行为完全相同的状态,在不改变可识别语言的前提下,让状态数量最少
即先按接受与非接受状态划分,再不断用输入字符拆分集合,直到集合不再变化

我们用子集构造算法将NFA转化成DFA,可以看出q1,q2,q3为接受状态
注意到有一些状态可以相互合并

q1跟q4也可以合并

Hopcroft算法

等价类
假设 DFA 中有状态 p 和 q。
如果从它们出发,输入任意后续字符串,最终的接受或拒绝结果都一样,那么 p 和 q 等价
为什么先以接受,非接受状态做区分
假设:
1 | |
即使不再读取任何字符,二者结果已经不同:
1 | |
所以接受状态和非接受状态一定不等价。
因此最初先分成两组:
1 | |
如何拆分集合
假设一个分组中有:
1 | |
读取字符 a:
1 | |
那么 p 和 q 的未来行为不同,不能留在同一组。
要拆成:
1 | |
所以判断一个组能否拆分的原则是:
同一个组中的状态,读取同一个字符后,是否进入不同的分组
例1

先划分

注意到,无论是b或c,都无法区分开q1,q2,q3的不同性,状态转移后始终在A等价类中
故,q1,q2,q3可以被合并成q4
例2

注意到RE为fee|fie,也可以写成f(ee|ie)
先切分

N为非接受,A为接受
注意到A不接受字符,该集合不可分裂
看N时发现,当循环到e时,q2,q4都转化到集合A,所以e可以划分N

注意到集合S都能接受e,且转化都到了A,不可再划分
集合D中,循环到e时进入S集合,故可以划分成q0,q1

DFA的代码表示

转移表

行为字符,列为状态编码
对于a(b|c),可以编码成23的矩阵,M行,N列(ASCII为256)

其它表象为error项
可以看出这张表为上面DFA等价的表示

一个词法分析器还需要驱动代码
驱动代码负责读输入,然后根据这个表里的内容来回答给定的输入是否能被接受
驱动代码

有两个核心变量,state,stack
state为目前走到的状态
该算法由两个循环组成
第一个循环是当state不为error时读入一个输入,然后判断state是不是accept,我们知道有一些状态是接受状态,
如果是接受状态就清空stack,把这个state push到stack中,然后在这个table中查字符c可以转化state到什么地方去。这是第一个循环
第二个循环是当state不为accept时把stack上的值赋值给state并用rollback()指针回滚,返回上一个读到的字符
最长匹配

这里就是stack这个结构的作用了,当一个程序设计语言里有这两个关键字时(if,ifif),一个关键字是另一个关键字的前缀,一般规定是尽可能识别第二个

这里是ifif,ifii两个例子,其中ifii两次rollback
跳转表

nextToken函数中的goto是跳转函数
这里是对q0和q1分别写了判断逻辑,每段代码负责当前状态识别
跳转表的好处是不用维护这么大的数组,unicode的数组可能有6万多列,几百甚至几千个状态,开销过大
flex就是用了跳转表的实现方式
语法分析
先回忆一下编译器的前端

词法分析器接收源程序后输出记号流,记号流是编译器中的内部数据结构,记号流随后输送给语法分析器
语法分析器的任务

输入记号流和语言的语法规则后输出语法树,根据语法规则判断程序合法性
例1

语法错误处理
分析后会给出错误提示
例2
当编译通过时进行语法树构建

路线图

语法分析阶段内容
上下文无关文法(CFG)
历史背景:乔姆斯基文法体系

从3到0型文法,相互嵌套
词法分析用正则表达式描述token,但re不适合描述多层嵌套
例如:
1 | |
于是语法分析使用上下文无关文法context-free grammer


符号可以对应为:
1 | |
其中:
1 | |
例如生成“羊吃草”:
1 | |
最后得到:
1 | |
也就是:
1 | |
S → N V N
表示:
一个 S 可以被替换为 N V N
句子 = 名词 + 动词 + 名词
N → s | t | g | w
N 可以是 s、t、g、w 中的任意一个
|表示或者

每个βi是一个符号,终结或非终结
上下文无关文法是一个四元组:
G=(T,N,P,S)
四个部分分别是:
| 符号 | 含义 |
|---|---|
| T | 终结符集合 |
| N | 非终结符集合 |
| P | 产生式规则集合 |
| S | 开始符号 |
推导


可以推出的其中一个句子
最左推导和最右推导
最左推导:每次推导都选择最左侧的非终结符进行替换
最右与最左对称
语法分析要解决的问题
给定文法G和句子s,语法分析要回答的 问题:是否存在对句子s的推导

分析树与二义性
树的遍历
主要分为**深度优先遍历(DFS)和广度优先遍历(BFS)**两大类
一、深度优先遍历(DFS)
尽可能深地搜索树的分支,走到尽头再回溯。根据访问根节点的时机,又分为三种(对二叉树):
- 前序遍历(先序遍历)
顺序:根 → 左子树 → 右子树
特点:第一个访问的总是整棵树的根。常用于复制一棵树、输出目录结构等。 - 中序遍历
顺序:左子树 → 根 → 右子树
特点:仅对二叉树有意义。对于二叉搜索树,中序遍历得到的是有序序列。
普通树没有中序遍历。 - 后序遍历
顺序:左子树 → 右子树 → 根
特点:最后访问根节点。常用于删除树(先删子节点再删父节点)、计算文件夹大小等。
普通树(多叉树)的深度优先
对于非二叉树,通常只有:
- 先根遍历:先访问根,再依次递归遍历每一棵子树(效果类似前序)。
- 后根遍历:先依次递归遍历每一棵子树,再访问根(效果类似后序)。
实现方式上,递归最直观;非递归则需要显式使用栈来模拟递归过程。
二、广度优先遍历(BFS)
- 层序遍历
顺序:从根节点开始,从上到下、从左到右逐层访问。
实现通常借助队列:根入队,每出队一个节点就将其子节点依次入队,直到队列为空。
应用场景:求树的宽度、按层打印、最短路径问题等
推导与分析树

转化为树后,叶子节点表示句子

例子


注意到两个推导方式得到的句子是一样的,但数据结构不同
分析树的含义取决于树的后序遍历顺序,数据结构不同含义也不一样
上面是23,下面是35,考虑到优先级,前者是我们要的
显然这样的文法存在一些问题,从它推导出的句子可能有两个不同的含义
二义性文法

表达式文法的重写

通过 E、T、F 的层级体现优先级
重写为:
1 | |
这样:
*的优先级高于+;3+4*5只能解释为3+(4*5);3+4+5会按照左结合解释为(3+4)+5
自顶向下分析
算法思想
语法分析:给定文法 G和句子 s,回答 s是否能 够从 G推导出来?
基本算法思想:从 G的开始符号出发,随意推 导出某个句子 t,比较 t 和 s
若t==s,则回答“是”
若t!=s,则需要回溯
因为这是从开始符号出发推出句子,因此称为 自顶向下分析
对应于分析树自顶向下的构造顺序
例子

算法

tokens[]:存放所有词法单元,分析的目标就是看它们能否由文法推导出来。i:输入指针,指向下一个要匹配的终结符。stack:分析栈,栈顶在右端。栈中存放的是文法符号(终结符或非终结符),它表示“为了最终匹配输入,还需要看到的结构”。
while (stack != [])
只要栈非空,就不断尝试匹配或展开。如果栈已空且输入也刚好读完,分析成功;若栈空但输入还有剩余,则失败。
如果栈顶是一个终结符
t,就把它与当前输入符号tokens[i]比较。匹配成功:两者相同,说明推导出的终结符与输入一致。此时:
i++消耗掉这个输入符号pop()弹出栈顶终结符
匹配失败:说明当前推导路径无法与输入相符,必须回溯 (
backtrack())。
回溯会恢复到最近一个“选择点”(即某个非终结符展开时),换一条产生式右部重试栈顶为非终结符
T时,需要把它展开(用产生式替换),模拟一步最左推导。pop()先把栈顶的T弹出。push(the next right hand side of T)将T的下一个候选产生式右部压入栈中。
因为要支持回溯,T的所有产生式会按某种预设顺序逐个尝试。所谓 “next right hand side” 意味着:- 第一次遇到这个
T时,压入它的第一个产生式右部; - 如果之后因失败而回溯到此处,就尝试第二个产生式,依此类推;
- 如果所有产生式都试完仍失败,则向更早的选择点回溯。
- 第一次遇到这个
推入顺序:因为是自顶向下最左推导,产生式右部必须反向压栈,让左端符号出现在栈顶,这样才能优先展开。例如,产生式 T → A B C,实际应 push(C)、push(B)、push(A),使得 A 在栈顶
backtrack中如果所有可能推导路径都尝试完毕仍无法匹配,则报告语法错误
例子


可以发现,在匹配的过程中往往要用到回溯。这就给分析效率上鸭梨了
这就需要线性时间的算法( 递归下降分析算法 和LL(1)分析算法 )避免回溯
用“前看符号”避免回溯
我们可以用一个前看符号指导右边产生式的选择来避免回溯,与输入的串的规模成线性比例
前看符号指向当前还没有处理的下一个 Token
递归下降分析算法

它也叫预测分析,每个非终结符构造一个函数,并利用前看符号选择产生式 (分治法)
伪代码

递归下降的核心思想是:
每个非终结符对应一个分析函数。
例如:
1 | |
就写成:
1 | |
算法框架

分类讨论,遇到非终结符做递归调用,终结符做比较
算术表达式的递归下降分析


这里有两种可能路径,得回溯解决
消除左递归
原文法:
1 | |
不能直接写成:
1 | |
因为 parseE() 一进入就再次调用 parseE(),形成无限递归。
这叫左递归。
将:
1 | |
改写为:
1 | |
同理:
1 | |

1 | |
函数调用过程大致是:
1 | |
因此乘法在第二个 parseT() 中完成,自然得到:
1 | |
语法分析器自动生成
LL(1)算法

分析器架构

先回顾一下 自顶向下分析算法

显然我们要解决在什么情况下才能push correct 右部,如果能解决,backtrack也不需要了

LL(1)分析表
行为终结符,列为非终结符
根据前看符号,通过查表决定push哪个右部
FIRST集

推导出所有可能的开头终结符集合
first集不动点算法

由于 FIRST 集是有限集合,且每次迭代只增不减,最终一定会收敛到最小不动点
先把每一个非终结符的开始符号初始化空集
然后条件判断,只要有集合还在变化就持续循环(扫描产生式)
两个if判断,终结符和非终结符并入集合

推广到任意串上

构造LL(1)分析表

照着填
LL(1)分析表中的冲突

我们加上一条文法规则后发现对于w有两条产生式可以选
FIRST_S(β) ∩ FIRST_S(γ) ={} ,根据判别式可知4,5存在冲突
一般条件下LL(1)分析表构造

我们发现在这样一个文法下,Y可以推出为空,X也自然可以为空

当非终结符可以推出空串时,first集需要向后推
这时就需要引入NULLABLE集合
NULLABLE集合

可以直接推出空串或推出的都是非终结符且都属于NULLABLEJ集

与定义一致,可以看出这也是不动点算法
FIRST集合的完整计算公式

注意到,当右侧是非终结符时要将该非终结符也并入FIRST集合,并往后判断是否属于NULLABLE,直到某个Y不为NULLABLE
不动点算法

foreach (production p: N->β 1 … βn) 这一步是对产生式的遍历
例子

文法:
1 | |
第0轮(初始化)
1 | |
第1轮
① Z → d
1 | |
② Y → c
1 | |
③ Y → ε
1 | |
(ε不加入FIRST)
④ X → Y
因为:
1 | |
所以:
1 | |
且 Y 可空,所以:
1 | |
⑤ X → a
加入:
1 | |
第1轮结果:
| FIRST | |
|---|---|
| Z | {d} |
| Y | {c} |
| X | {a,c} |
第2轮
处理:
1 | |
从左到右:
X:
1 | |
加入 Z:
1 | |
因为 X 可空,继续看 Y:
1 | |
无变化。
最终 FIRST 集:
1 | |
NULLABLE:
1 | |
(因为 Y→ε,X→Y→ε)
FOLLOW集不动点算法

FOLLOW(A):表示在所有句型中,非终结符 A 后面可能出现的终结符集合
例如:
1 | |
那么:
1 | |
- 初始化:
1 | |
- 产生式:
1 | |
- 从右往左扫描
- 遇到终结符:
1 | |
- 遇到非终结符 M:
1 | |
- 如果 M 不能推出 ε:
1 | |
- 如果 M 能推出 ε:
1 | |
- 一直循环到 FOLLOW 不变化
例子

1 | |
从右往左:
Z
最后:
1 | |
Y
后面有 Z:
1 | |
X
后面有 YZ:
先:
1 | |
因为 Y 可空:
继续:
1 | |
合并:
1 | |
最终:
1 | |
计算FIRST_S集合

注意到M为非终结符时要往后穿

对于:
1 | |
从左往右看:
- 如果遇到终结符 a:
1 | |
- 如果遇到非终结符 M:
1 | |
- 如果 M 可以推出 ε:
继续看后面的符号。
- 如果 M 不能为空:
停止。
构造LL(1)分析表

可以发现这里存在冲突
LL(1)分析器

使用一个分析栈 stack 和 输入 token 序列 tokens,根据 LL(1)分析表决定如何展开非终结符
LL(1)分析冲突处理

产生式不唯一,还是需要回溯处理,我们可以通过对文法的处理来扩大LL(1)文法能应用的范围

注意到0跟2都存在左递归
消除左递归

把“自己调用自己”改成“尾递归循环”,将 A→Aα|β 改成 A→βA',A'→αA'|ε,使文法满足 LL(1) 分析要求
提取左公因子

LR(0)算法
LR(0) 是自底向上的语法分析算法,属于 LR 分析方法的一种。它通过构造 LR(0) 项目集族(DFA) 和 分析表(ACTION/GOTO表) 来完成移进-归约分析。
- L(Left):从左到右扫描输入串
- R(Rightmost):构造最右推导的逆过程(也就是自底向上归约)
- 0:不看任何向前搜索符(没有 lookahead)
LR(0)核心思想
例如文法:
1 | |
目标分析:
1 | |
LR(0) 不像 LL(1):
1 | |
而是:
1 | |
也就是:
不断找到可以归约的部分,把它变成非终结符

为了适应其要求,分析表中不能出现冲突,这就要涉及到文法的改写
自底向上分析算法

这种文法能分析的范围比LL(1)更大且往往不需要对文法进行特殊处理
算法思想

我们把产生式从左到右称作推导,从右到左叫归约
注意到做替换的符号总是位于当前最右侧位置上
点记号

生成一个逆序的最右推导

注意到问题的关键在于移进和归约的时机处理

构造 LR(0) 必须增加开始符号:
原:
1 | |
增加:
1 | |
方便判断接受。
最后:
1 | |
表示分析成功。

我们把加了点号的产生式称作项目,这样的整体叫项目集
注意到在图中1为起始状态,6为接受状态,是一个确定的自动机
这个文法只能推出xxy$串,$为文件描述符,读到文件描述符终止
LR(0)分析表

动作表里面是终结符,转移表里面是非终结符
s表示shift,移进
r是reduce,表示按第几条规则做归约,因为状态和字符是两两配对,所以要多弹一个数据进去
LR(0)分析算法

1是状态机的初始状态
栈顶状态是状态机的编号
分别对移进和归约两种合法情况分析
LR(0)分析表构造算法

相当于对 LR(0) 项目集 DFA 做一次广度优先搜索
先对S’ -> . S $求闭包,得到C0初始集合
Q是工作表,表示哪些还没计算过
goto函数表示对于集合c,读入一个终结符或非终结符能够到达哪一个项目集
set用来记录一共产生了多少不同的状态
goto和closure

closure函数:
如果一个项目点号右边紧跟着一个非终结符,那么就意味着“为了匹配这个非终结符,它的所有产生式都有可能接下来出现”。因此,要把这些产生式全部扩展并加入集合中
goto函数:
给定一个项目集合(状态)C 和输入符号 x,计算识别完 x 之后应该转移到的下一个状态
SLR分析算法
LR(0)分析

- L (Left-to-right):从左到右扫描输入源代码(程序)。
- R (Rightmost derivation):构造的是最右推导的逆过程(即进行最左归约)。
- 0 (0个前看符号):这是LR(0)的核心特征。在决定是否进行“归约”(即识别出一个语法成分)时,完全不看当前尚未读入的下一个输入符号
0前看:在 LR(0) 中,一个状态内部只要出现了**“归约项目”**(形如 A → α·,点号在末尾意味着已经完整匹配了产生式右部),分析器就会强制在该状态中进行归约,而不去看输入的下一个字符是谁。 这是导致它能力有限的最直接原因
LR(0)算法缺点

在 LR(0) 的状态中,只要圆点 • 跑到了产生式的最右边(表示完全匹配了右部),它就会立刻、无条件地执行归约动作。它根本不管接下来输入的是什么字符(0个前看符号)。虽然它最后确实能发现错误(不会漏掉错误),但报错的时机滞后了
- 移进-归约冲突 (Shift-Reduce Conflict):这是最常见的情况。在 LR(0) 的某个状态里,可能同时出现了两种情况:
- 可以继续读入下一个输入符(移进)。
- 当前栈顶已经匹配完一个产生式,可以立即执行归约。
- 因为没有前看符号,LR(0) 完全无法决定是该“前进”还是该“收网”。遇到这种状态,分析表的构造就直接失败了。
- 归约-归约冲突 (Reduce-Reduce Conflict):在同一个状态下,可能有两个不同的产生式都已经匹配完成(比如
A -> α•和B -> α•),机器完全不知道该用哪一条规则去归约。这也是冲突
SLR
和LR(0)分析算法基本步骤相同,仅区别于对归约的处理
对于状态 i 里的归约项目 X -> α ·,不能在所有的终结符下都进行归约。仅当当前输入符号 y 属于 FOLLOW(X) 集合时,才在 ACTION[i, y] 中填入归约动作

SLR 的本质是:
A→α⋅+当前输入符号∈FOLLOW(A)⟹归约
SLR冲突

状态 2:
- 项目
S -> L • = R表示:目前已经匹配到了L,下一个期待看到的输入符号是=,这是一个移进动作(Shift)。 - 项目
R -> L •表示:目前已经识别出L,可以将它归约成R(Reduce,使用R -> L规则)。
当语法分析器处于状态 2,且当前读入的输入符号是 = 时,它不知道该“移进 =”还是“归约成 R”,这就造成了冲突
LR(1)算法

SLR 使用整个非终结符的 FOLLOW 集决定归约,范围仍然太大;LR(1) 给每个项目单独记录一个前看符号
这里的“1”表示使用 1 个前看符号。
LR(1) 项目写成:
[A→α⋅β, a]
由两部分组成:
- A→α⋅β:LR(0) 项目,称为项目的“核心”;
- a:该项目自己的前看符号。
含义是:
- α 已经识别,位于分析栈顶部;
- 接下来需要识别 β;
- 完成整个产生式后,允许出现的符号是 a。
SLR 使用:
FOLLOW(R)={=,$}
FOLLOW(R) 汇总了 R 在整个文法中的所有情况,无法区分当前状态里的具体上下文
而 LR(1) 使用当前项目自己的前看符号:
[R→L⋅, $]
所以 LR(1) 更精确

LR(1) 的闭包 closure
LR(1) 和 LR(0) 的主要区别在闭包计算。
假设项目集中存在[A→α⋅Bβ, a]
圆点后面是非终结符 B,则对于每个产生式
B→γ 加入 [B→⋅γ, b]
其中b∈FIRST(βa)
不是简单地把原来的前看符号 a 传下去,而是计算FIRST(βa)
三种常见情况:
- β 以终结符
=开头:
FIRST(=Ra)={=}
新项目的前看符号就是 =。
- β 为空:
FIRST(a)={a}
直接把原来的前看符号传下去。
- β 可以推出空:
既要加入 FIRST(β) 中的终结符,也可能加入 a
LR(1)分析表构造
项目集构造完成后:
- A→α⋅aβ:在
a列填写移进; - A→α⋅Bβ:填写
GOTO; - [A→α⋅,a]:只在
a列填写归约; - [S′→S⋅,$]:在
$列填写accept
LR(1)项目集过多

有两个很像的状态:
I8:[R→L⋅,{=,$}]和I10:[R→L⋅,{$}]
把前看符号去掉,两者都是:
R→L⋅
但是 LR(1) 认为它们是不同状态,因为前看符号不同。
同样:
I5:[L→id⋅,{=,$}]I11:[L→id⋅,{$}]
也必须分开。
所以 LR(1) 的特点就是:
同一个 LR(0) 项目,因为所处上下文不同、前看符号不同,可能被拆成多个 LR(1) 状态。
这使 LR(1) 很精确,但项目集数量会膨胀
LALR分析算法
把“类似”的 LR(1) 项目集进行合并
例如:
I8=[R→L⋅,{=,$}]I10=[R→L⋅,{$}]
核心都是R→L⋅
所以 LALR 可以合并,前看符号取并集[R→L⋅,{=,$}]
同理I5+I11可以合并成[L→id⋅,{=,$}]
对二义性文法的处理
二义性文法无法使用LR分析算法分析
不过,有几类二义性文法很容易理解, 因此,在LR分析器的生成工具中可以对它们特殊处理
给出二义性文法:
E→E+E ∣ E∗E ∣ n
例如n+n∗n
有两种解释:
(n+n)∗n或者n+(n∗n)
所以文法本身有二义性。
即使使用 LR(1),也可能出现:
E→E+E⋅同时又有E→E⋅∗E
现在输入 *:
- 第一项:表达式已经完整,可以归约;
- 第二项:可以继续移进
*。
于是产生移进-归约冲突
这次不是 LR(1) 不够精确,而是文法本身就没说清楚到底应该怎么解释。
用优先级解决
我们人为规定∗ > +
也就是乘法优先级高于加法。
例如当前已经识别n + n
下一个符号是*
此时不要急着n+n⇒E
而应该移进 *:n + n * …
最终得到n+(n∗n)
下一个运算符优先级更高 → 移进。
反过来,如果已经识别n * n
下一个是+
那么先归约n∗n⇒E
因为 * 优先于 +。
结合性解决同优先级
例如:
n+n+n
两个 + 优先级相同。
如果规定 + 左结合:
(n+n)+n
那么识别完第一个 n+n,看到下一个 + 时归约
如果是右结合运算符,则通常选择移进
- 后面的运算符优先级更高 → 移进
- 后面的运算符优先级更低 → 归约
- 优先级相同且左结合 → 归约
- 优先级相同且右结合 → 移进
LR 系列主线:
LR(0)→SLR→LR(1)→LALR
LR(1)分析工具
语法分析器可以通过先前的算法手动构造,也可以通过工具自动生成,自动生成时只需要写声明式
YACC
Yacc:Yet Another Compiler-Compiler
它是“生成编译器中的语法分析器”的工具
GNU Bison 可以理解成 Yacc 的 GNU 实现/扩展版本
一个 Yacc 文件分三部分
- 声明部分
定义 Token、优先级、结合性,以及需要使用的变量或代码。
1 | |
- 语法规则部分
写上下文无关文法,并可以附加语义动作。
1 | |
- 用户代码部分
写普通 C 代码,比如错误处理、辅助函数等。
1 | |
语法制导翻译

在进行语法分析的同时,根据产生式执行对应的“语义动作”。
也就是从“判断语法对不对”,进一步变成“知道这段程序是什么意思、该做什么”
基本思想
普通产生式:
E→E+E
给它附加一个语义动作:
E→E1+E2{E.val=E1.val+E2.val}
Yacc 写法就是:
1 | |
其中:
$$:左边E的值$1:右边第一个E的值$2:+$3:右边第二个E的值
自底向上的 LR 分析中,在产生式“归约”的时候执行。
比如E→E+E
当分析器发现栈顶已经是:
1 | |
准备归约:
E+E⇒E
这时候执行:
1 | |
注意到必须先得到左右孩子的值,才能计算父节点:
1 | |
也就是:
7, 8, +, 9, +
这正是表达式树的后序计算顺序
LR分析中的语法制导翻译
例如:
1 | |
三个东西分别代表:
symbol:终结符/非终结符,比如Evalue:它的语义值,比如15state:LR 分析状态
因此以前 LR 分析只关心符号 + 状态
现在增加了语义值
归约时:
E+E→E
不仅完成语法上的归约,还顺便:
15+9→24
抽象语法树
抽象语法树(AST, Abstract Syntax Tree)可以理解为:
把“分析树”中为了语法分析而存在的多余信息删掉,只保留程序真正的语法结构

具体语法和抽象语法
具体语法是语法分析器使用的语法,必须适合于语法分析,如各种分隔符、消除左递归、提取左公因子,等等
抽象语法是用来表达语法结构的内部表示
现代编译器一般都采用抽象语法作为前端(词法语法分析)和后端(代码生成)的接口

抽象语法树的定义(c语言)
数据结构

构造函数

malloc 创建节点
↓
填写节点数据
↓
返回节点指针
构造 2 + 3 * 4
文法:
E -> n
| E + E
| E * E
e1 = Exp_Int_new(2);
e2 = Exp_Int_new(3);
e3 = Exp_Int_new(4);
e4 = Exp_Times_new(e2, e3);
e5 = Exp_Add_new(e1, e4);
完整 AST:
1 | |
所以 AST 实际上就是:
一个节点里面保存指向其他节点的指针,最终把所有节点连接成一棵树
树的遍历
后面的很多编译器操作,本质都是:
遍历 AST → 根据节点 kind 做不同事情。
比如:
pretty_print():重新输出代码numNodes():统计 AST 节点数量compile():把 AST 翻译成机器指令/中间代码
抽象语法树的自动生成
LR分析中生成抽象语法树
在语法动作中,加入生成语法树的代码片段
片段一般是语法树的“构造函数”
在产生式归约的时候,会自底向上构造整棵树,从叶子到根

假设文法:
1 | |
给每条产生式加上语义动作:
1 | |
$1、$2、$3 → 产生式右边各符号的值
$$ → 产生式左边 E 的值
例如:
1 | |
因此$$ = Exp_Add_new($1, $3);
表示用左边 E 的 AST 和右边 E 的 AST 创建一个新的 + 节点。
以2 + 3 * 4为例
LR 读到 2,之后归约E → n
执行$$ = Exp_Int_new(2);
产生2
读到 3,归约E → n
产生3
读到 4,归约产生4
当 LR 再执行E * E → E
对应语义动作$$ = Exp_Times_new($1, $3);
把之前已经构造好的 3、4 接起来:
1 | |
最后E + E → E
执行$$ = Exp_Add_new($1, $3);
得到:
1 | |
LR 负责决定什么时候用哪条产生式归约;语义动作负责在归约时把右部已有的 AST 节点组合成新的 AST 节点
源代码信息的保留和传播
AST 不能只保存“程序结构”,还要保存必要的源代码信息
抽象语法树是编译器前端和后端的接口
程序一旦被转换成抽象语法树,则源代码即被丢弃
后续的阶段只处理抽象语法树
所以抽象语法树必须编码足够多的源代码信息
例如,它必须编码每个语法结构在源代码中的位置 (文件、行号、列号等)或者获取程序的执行刨面。这样,后续的检查阶段才能精确的报错
所以 AST 节点通常还要记录:
1 | |
例如一个 + 节点:
1 | |
这样后面语义分析发现:
3 + true
类型不匹配,就能准确告诉你:
test.c:10:5: type mismatch
否则编译器只知道 AST 有问题,却不知道源代码哪里有问题
语义分析
前面语法分析解决的是:
“写法合不合语法?”
现在语义分析解决:
“虽然写法没问题,但这样写有没有意义?”
语义分析也称为类型检查 、上下文相关 分析,负责检查程序(抽象语法树)的上下文 相关的属性
抽象语法树通过语义分析器生成中间代码
这是具体语言相关的,典型的情况包括,变量在使用前先进行声明,每个表达式都有合适的类型,函数调用和函数的定义一致
变量是否声明 x = 10;
如果 x 从来没有定义:
1 | |
表达式类型是否正确 3 + true
可能是int + bool
类型不匹配。
函数调用与定义是否一致:
1 | |
函数需要一个int *
但调用时没给参数,所以语义错误
例子
1 | |
它的语法基本都能形成 AST,但语义存在大量错误
x += 4;x从未声明,属于使用未定义变量。p(23);p的类型是int*(整型指针),不是函数指针,不能当作函数调用。"hello" + "world";
两个字符串字面量(const char*)不能使用+相加,操作数类型非法。f() + 5;
有两个错误:
①f需要传入一个int*参数,这里没传参;
②f返回void,无法参与加法运算。break;break不在循环或switch结构中,非法。return;main函数的返回类型是int,空返回语句缺少返回值,类型不匹配
通过了语法分析 ≠ 程序就是合法的
AST 可能完全构造成功:
1 | |
语义检查
类型检查

- :两边必须是 INT,结果也是 INT
&& :两边必须是 BOOL,结果也是 BOOL

类型检查,本质上也是一次 AST 递归遍历
变量声明的处理

x 到底是 INT 还是 BOOL?
光看 x 节点不知道。
必须找到int x;
才知道x → INT
符号表 Symbol Table
编译器处理
int x;
bool y;
时建立
| 标识符 | 类型 |
|---|---|
x |
INT |
y |
BOOL |
语句的处理

遍历 AST,对每个语句节点检查类型是否匹配。赋值要求左右类型一致,printi要求表达式为int,printb要求表达式为bool
符号表
编译器用来记录“这个名字是谁、是什么类型、在哪个作用域”
符号表必须高效,程序中的变量规模一般很大
符号表的接口与结构

基本的新建、插入、查找功能
我们注意到符号表是典型的字典结构

为了高效,可以使用哈希表等数据结构来实现符号表,查找是O(1)时间
为了节约空间,也可以使用红黑树等平衡树,查找是O(lg N)时间
符号表的核心需求就是快速完成“名字 → 信息”的查询
符号表处理作用域
1 | |
| 语句 | 使用的变量 |
|---|---|
if中的 x = 6 |
if内部的局部 x |
else中的 x = 5 |
else内部的局部 x |
分支后的 x = 8 |
全局 x |
内层作用域中声明的名字,会暂时屏蔽外层作用域中的同名名字
全局 x 并没有被删除,只是在当前内层作用域中暂时无法通过名字 x 直接访问
if 里的 x 和 else 里的 x 也不是同一个变量,因为它们位于两个不同的代码块作用域中
符号表处理作用域主要有两种方法:
- 一张符号表
- 进入作用域:插入该作用域声明的变量。
- 查找变量:优先找到最近插入的同名变量。
- 退出作用域:删除该作用域插入的变量,使外层同名变量恢复可见。
- 符号表栈
- 每个作用域单独建立一张符号表。
- 进入作用域:新符号表入栈。
- 查找变量:从栈顶向栈底查找,最先找到的就是当前使用的变量。
- 退出作用域:弹出栈顶符号表。
核心规则:始终优先使用距离当前位置最近的声明
符号表处理名字空间
名字空间用于区分“名字相同,但类别不同”的符号。编译器通常为每类名字空间建立一张符号表。
以 C 语言为例:
1 | |
这里的 list 分别可以表示:
struct list:结构体标签*list:普通变量- 结构体中的
list:成员名 list::goto标签
它们虽然同名,但处于不同名字空间,因此不会冲突。
方法:
1 | |
作用域区分“不同位置的同类名字”,名字空间区分“不同类别的同名符号”
其它问题

类型相等与类型相容
类型检查经常需要判断:
1 | |
这里不一定要求两个类型完全相等,还可能允许:
- 类型转换
- 子类赋值给父类
- 结构相同的类型互相赋值
| 判断方式 | 判断依据 |
|---|---|
| 名字相等 | 是否来自同一个类型声明 |
| 结构相等 | 类型的内部结构是否相同 |

虽然 A 和 B 不是相同类型,但 B 继承自 A,B 是 A 的子类型
因此x = y;
通常合法,表示把子类对象赋给父类变量,也叫向上转型
严格来说,赋值检查通常判断的是类型相容,而不只是类型相等
类型相容可能包括:
- 完全相同的类型;
- 可以自动转换的类型;
- 子类到父类;
- 语言允许的其他转换。
错误诊断

代码翻译
现代编译器的语义分析模块除了检查程序是否合法,通常还会为中间代码生成做准备。
例如源程序:
1 | |
AST 大致是:
1 | |
语义分析遍历 AST 时会:
- 查找
x、a、b的符号信息; - 检查
a + b是否合法; - 确定表达式结果类型;
- 把类型、变量位置等信息标注到 AST;
- 生成或准备生成中间表示。
可能生成:
1 | |
因此类型检查和代码翻译都可以通过遍历 AST 完成,只是遍历时执行的操作不同
代码生成
代码生成的核心任务是把已经通过语义检查的程序,翻译成目标机器能够执行的代码


最简单的方式是直接遍历抽象语法树生成汇编代码
这种方式容易实现,但不方便优化和适配不同机器。
现代编译器通常加入中间表示:
1 | |
这样前端只需要生成统一的 IR,后端再把 IR 翻译到 x86、ARM、RISC-V 等不同架构
代码生成主要有两个任务:
- 给数据分配计算资源
决定程序中的数据放在哪里:
- 全局变量通常放在数据区;
- 局部变量通常放在栈或寄存器;
- 动态申请的数据放在堆;
- 临时结果尽量放在寄存器中。
- 给代码选择机器指令
用机器指令实现高级语言的操作:
源程序的代码:
- 表达式运算、语句、函数等
机器指令:
- 算术运算、比较、跳转、函数调用返回
用机器指令实现高层代码的语义 :
- 等价性
- 对机器指令集体系结构 (ISA)的熟悉
指令集体系结构 (ISA) 上的代码生成
栈式计算机
栈式计算机是一种以“操作数栈”为主要运算空间的计算机模型
- 栈式计算机在 20 世纪 70 年代比较流行。
- 真实的栈式处理器现在很少,主要原因是执行效率不如寄存器计算机。
- 但栈式代码生成非常简单,适合学习编译器的代码生成过程。
- 现代仍有很多栈式虚拟机,例如 JVM、Pascal P-code、PostScript
栈式计算机结构
| 部分 | 作用 |
|---|---|
| Memory | 保存程序中的变量 |
| Stack | 临时保存参与运算的操作数和结果 |
| ALU / 执行引擎 | 取出栈顶数据,执行加减乘除等指令 |
栈式计算机指令集

指令的语义
1 | |
变量的内存分配伪指令
栈式计算机只支持 int 类型,使用:
1 | |
为变量 x 分配一块内存空间
它是伪指令:只在程序加载时负责分配内存,不会像 push、add 一样真正参与运算
递归下降代码生成算法
从 C– 到 Stack
递归遍历 C– 程序的抽象语法树,同时生成 Stack 栈式计算机指令
C– 的简化语法
1 | |
语法树中的每类节点,对应一个递归函数:
1 | |
它们之间的调用关系是:
1 | |
所谓“递归下降”,就是从程序根节点 P 开始,根据语法树结构逐层调用对应函数

表达式的代码生成
Gen_E(e) 执行结束后,表达式 e 的值一定在栈顶。
1 | |
语句的代码生成

类型的代码生成
Stack 机器只支持一种数据类型:int。因此 C– 中的 int 和 bool 都翻译成 .int。
1 | |
变量声明的代码生成
声明语法是:
1 | |
表示每个声明后面还可以继续跟一个声明,最后通过空产生式 ε 结束。
代码生成算法:
1 | |
程序的代码生成
程序的语法结构:
1 | |
表示一个程序由两部分组成:
D:变量声明;S:可执行语句。
因此代码生成函数非常简单:
1 | |
也就是先生成变量声明 → 再生成可执行指令
寄存器计算机
寄存器计算机主要依靠寄存器保存数据并完成运算:
- 真实机器通常有 16、32 个或更多寄存器。
- 算术运算只能在寄存器之间进行。
- 内存不能直接参与计算,必须先
load到寄存器。 - 结果通过
store写回内存
Reg的结构
主要包含:
Memory:保存溢出到内存中的变量。Reg:寄存器,保存变量和临时计算结果。ALU:执行加减乘除。- 执行引擎:读取并执行指令。
先假设有无限多个寄存器,这些实际上是虚拟寄存器。后面再把虚拟寄存器映射到有限的物理寄存器,这个过程叫寄存器分配
指令集

| 指令 | 含义 |
|---|---|
movn n, r |
r = n,把常数放入寄存器 |
mov r1, r2 |
r2 = r1 |
load [x], r |
r = Memory[x] |
store r, [x] |
Memory[x] = r |
add r1, r2, r3 |
r3 = r1 + r2 |
sub r1, r2, r3 |
r3 = r1 - r2 |
times r1, r2, r3 |
r3 = r1 × r2 |
div r1, r2, r3 |
r3 = r1 ÷ r2 |
变量的寄存器分配伪指令
Reg机器只支持一种数据类型int,并且给变量x分配寄存器的伪指令是:
- .int x
- 在代码生成的阶段,假设Reg机器上有无限多个寄存器
- 因此每个声明变量和临时变量都会占用一个(虚拟)寄存器
- 把虚拟寄存器分配到物理寄存器的过程称为寄存器分配
递归下降代码生成算法
从C– 到Reg:

每个非终结符对应一个代码生成函数:
1 | |
只有 Gen_E()需要返回值,返回的不是计算结果本身,而是保存表达式结果的寄存器编号
文法:

表达式的代码生成
Gen_E(e) 返回后,表达式 e 的值保存在它返回的寄存器中。
1 | |
fresh()表示申请一个新的虚拟寄存器
非短路计算指:逻辑表达式两边都会执行,不会因为左边已经能确定结果而跳过右边。
例如:
1 | |
- 短路计算:左边是
false,整个表达式必然为false,所以不执行func()。 - 非短路计算:即使左边是
false,仍然会执行func()。
语句的代码生成
1 | |
Gen_E(e)生成表达式代码,并返回结果寄存器r。- 赋值语句把
r复制给变量。 - 输出语句直接输出
r
类型的代码生成
1 | |
Reg 机器只支持 int,所以:
int生成.intbool也生成.int,用1/0表示true/false
变量声明
文法:
1 | |
代码:
1 | |
例如:
1 | |
生成:
1 | |
遇到空产生式 ε 时,递归结束
程序
文法:
1 | |
代码:
1 | |
作用:从程序根节点开始,先处理全部变量声明,再处理程序语句。
调用关系:
1 | |
中间表示
中间表示(Intermediate Representation,IR)是编译器内部用于表示程序的一种形式。
整个编译过程可以理解为:
1 | |
中间代码

为什么要使用多种中间表示
主要有两个原因。
工程上的原因
把编译过程拆成多个阶段:
1 | |
每个阶段只负责一种转换,更容易实现、调试
程序分析和优化的需要
不同优化适合不同 IR。
例如:
- AST:适合高级语言结构分析;
- 三地址码:适合常量折叠、复制传播;
- CFG:适合循环分析、不可达代码删除;
- SSA:适合数据流分析和变量优化。
通用编译器语言

IR让编译器更易支持多语言,多平台
三地址码
三地址码是一种接近机器指令的中间表示。
典型形式:
1 | |
一条指令最多涉及三个地址:
x:保存结果;y:第一个操作数;z:第二个操作数。
例如:
1 | |
因此叫“三地址码”
三地址码基本思想
三地址码是一种中间表示,把复杂程序拆成简单指令。
例如:
1 | |
转换为:
1 | |
特点:
- 每条指令只进行一次基本运算;
- 使用临时变量保存中间结果;
if、while等结构被转换为条件跳转、无条件跳转和标号;- 形式接近机器指令,便于代码优化和生成汇编。
典型形式:
1 | |
只有最基本的控制流,没有各种控制结构,只有goto,call 等
所以三地址码可以看成是抽象的指令集(通用的RISC)
例子

这里的 x_1、x_2、x_3... 可以理解成临时变量/临时寄存器
其中 Cjmp** = Conditional Jump,条件跳转**。
1 | |
意思就是:
1 | |
三地址码的定义
一条三地址码语句 s 可以有以下形式:
1 | |
其中:
⊕:二元运算符,如+、-、*、<、&&。Θ:一元运算符,如-、!。x[y] = z:把z写入以x为基址、y为偏移的位置。x = y[v]:从以y为基址、v为偏移的位置读取数据。Cjmp(x1,L1,L2):x1为真跳到L1,否则跳到L2。
三地址码的数据结构
1 | |
加法指令:
1 | |
表示:
1 | |
关键点是:所有指令的第一个成员都是 kind。程序先检查 kind,再判断这究竟是加法、移动还是跳转指令
从c生成三地址码
整体过程:
1 | |
与之前直接生成 Stack 指令相比,现在增加了三地址码作为中间层,使后续优化和汇编生成更容易。
主要语法
1 | |
语句:
1 | |
表达式:
1 | |
对应的递归生成函数
1 | |
这些函数按照抽象语法树的结构递归遍历。
关键不变式是:
1 | |
递归下降代码分析算法
赋值语句
1 | |
x=e是赋值语句
Gen_E(e)先生成表达式e的三地址码。- 返回保存结果的临时变量
x1。 - 最后把
x1赋值给变量x
printi和printb分别输出int和bool值
函数调用和return语句
1 | |
遇到函数调用时,先把每个参数表达式算出来,再调用函数
1 | |
先计算返回表达式,再返回计算结果
if-else** 的代码生成**
1 | |
含义:
Gen_E(e):计算条件,结果保存在x。Cjmp(x,L1,L2):x为真跳到L1,否则跳到L2。Gen_SList(s1):生成真分支的语句。Gen_SList(s2):生成假分支的语句。- 两个分支完成后都跳到汇合点
L3
while循环
1 | |
三个标号的作用:
L1:判断循环条件。L2:执行循环体。L3:退出循环。
小结

控制流图 CFG( Control Flow Graph )
三地址码是线性排列的,程序分支关系隐藏在 Cjmp、jmp、Label 中,不够直观。
控制流图把这些跳转关系直接画成图
控制流图能够清楚显示程序结构,方便分析:
- 程序中是否存在循环;
- 某个基本块能否执行;
- 某行代码处变量可能是什么值;
- 后续的数据流分析和代码优化。
基本概念
基本块
基本块是连续执行的一组语句,具有两个特点:
- 不能从中间进入,只能从第一条进入。
- 不能从中间退出,只能从最后一条离开。
因此,跳转指令只能出现在基本块末尾
控制流图
控制流图是有向图:
1 | |
- ** 结点
V:基本块。 ** - ** 边
E:基本块之间可能发生的跳转**

控制流图的基本定义
控制流图可以看作组织得更精细的三地址码。
普通语句 S
1 | |
这些是基本块内部顺序执行的普通指令,不负责改变控制流。
跳转语句 J
1 | |
J 决定基本块执行完成后去哪里:
jmp L:跳到L。cjmp:根据条件选择L1或L2。return:退出函数。
基本块 B
1 | |
一个基本块由三部分组成:
1 | |
因此每个基本块:
- 开头有唯一的
Label; - 中间包含若干普通语句;
- 最后必须有一条跳转或返回指令。
函数与程序
1 | |
即:
- 一个函数由多个基本块组成;
- 一个程序由多个函数组成。
数据结构
1 | |
核心结构就是:
1 | |
如何生成控制流图
方法一:直接从抽象语法树生成
1 | |
适合控制结构比较规整的语言,例如只有标准的 if、while。
但遇到 C 语言的 goto 等非结构化跳转时,处理会比较复杂。
方法二:先生成三地址码
1 | |
优点:
- 三地址码已经把
if、while、goto统一成跳转指令; - 只需要根据
Label、Cjmp、Jmp、Return划分基本块; - 更适合 C 这类包含
goto的语言; - 阶段划分清楚,编译器更容易实现和维护
由三地址码生成控制流图算法
初始化:
1 | |
然后从头到尾扫描每条指令:
1 | |
处理规则:
- 遇到
Label L:设置当前基本块的入口标号。 - 遇到普通指令:加入当前基本块的语句列表。
- 遇到
Jmp、Cjmp、Return:设置块的结尾,将该块保存,然后创建新块
控制流图的基本操作
控制流图本质上是有向图,所以可以直接使用图论算法。
常见操作包括:
- DFS/BFS 遍历:判断哪些基本块可以从函数入口到达。
- 生成树:记录遍历过程中基本块之间的关系。
- 必经节点(支配结点):如果从函数入口到达块
B的所有路径都必须经过块A,那么A是B的必经节点。 - 拓扑序、逆拓扑序:确定分析基本块的处理顺序
死代码基本块删除
1 | |
执行到 continue 后,会直接跳回循环条件,因此后面的:
1 | |
永远无法执行

删除算法
1 | |
执行过程:
- 从控制流图的入口基本块开始 DFS。
- DFS 能访问到的基本块标记为
visited。 - 遍历所有基本块。
- 没有被访问的基本块就是不可达块,直接删除
数据流分析
控制流图描述的是“程序可能怎么执行”;数据流分析则进一步研究:
程序沿着这些控制流路径执行时,变量的值或定义可能怎样传播
优化的一般模式
- 程序分析
分析控制流、数据流、依赖关系等,获得程序的静态信息。 - 程序重写
根据分析结果安全地修改程序,例如常量传播、删除无用代码
原中间代码 → 程序分析 → 静态信息 → 程序重写 → 优化后的代码
静态保守

判断“某个变量的哪些赋值可能到达某个位置”的分析,叫作到达定义分析。
把变量替换成确定的常量,叫作常量传播

y可能是2或3,保守估计不能常量替换
数据流分析定义
数据流分析是通过静态分析程序代码,获得与程序数据相关的保守信息
必须保证程序分析的结果是安全的
根据优化的目标不同,需要进行的数据流分析也不同
到达定义分析
定义与使用
- 定义(def):给变量赋值。
- 使用(use):读取变量当前的值
1 | |
到达定义分析需要回答在语句 7 使用 y 时,前面的哪些 y 赋值可能到达这里
如果只有y = 3能到达就把a = y优化成a = 3
到达定义
对每个变量的使用点有哪些定义可以到达(即该变量的值是在哪儿赋值的)
定义 d: x = ... 能够到达位置 p,需要满足:
- 从定义
d到位置p存在一条控制流路径; - 这条路径中没有再次给
x赋值
数据流方程

1 | |
这里集合里记录的是定义语句编号,而不是变量的具体值
gen
1 | |
意思是当前语句自己产生了一个新的定义
比如 4: y = 6
那么gen[4] = {4}
因为执行第 4 条语句之后,第 4 条语句成为 y 的一个新定义
kill
1 | |
表示当前语句重新给 x 赋值,所以以前对 x 的其他定义都失效了
in,out
1 | |
上一句执行完还能存活的定义,就是这一句执行前能到达的定义
out,gen
1 | |
出去的定义 = 新产生的定义 + 进来以后没有被杀死的定义
从数据流方程到算法
在一个基本块里,语句是顺序执行的,所以“上一条语句的 out 集合”就是“下一条语句的 in 集合”
1 | |
基本块内部从前往后扫描,每条语句用 out = gen ∪ (in-kill) 更新到达定义集合,当前 out 直接作为下一条语句的 in
例子

顺序扫描:上一条 out → 下一条 in,再用 gen/kill 算新的 out
对于一般的控制流图
对于某条语句 s,它可能有多个前驱 p,所以不能再写:
1 | |
而要改成:
1 | |
也就是把所有前驱语句的 out 集合取并集,得到当前语句的 in。
然后 out 的计算不变:
1 | |
从数据流方程到不动点算法
如果控制流图里有循环,某个语句的 out 又可能反过来影响前面的 in,所以不能只扫描一遍
1 | |
foreach (predecessor p of s)
set ∪= out[p]
即合并所有前驱
1 | |
则:
1 | |
while循环
假设 CFG 有循环:
1 | |
第一次算 B 时,C 的信息可能还没算出来。
第一轮B 的 in 不完整
算完 C 后,C 又能回到 B:
1 | |
所以要重新算。
不断迭代直到所有 in/out 都不再变化
这就叫达到不动点(fixed point)
例子

活性分析
在代码生成的讨论中,我们曾假设目标机器有无限多个(虚拟)寄存器可用
编译器得判断哪些变量能共用同一个寄存器
寄存器分配的优化任务就需要进行活性分析
活跃变量
在程序的某个位置,如果变量当前保存的值在后面还可能被使用,并且使用前没有被重新定义,那么该变量在这个位置是活跃的
1 | |
注意到三个变量的活跃区间互不重叠:
1 | |
由于 a、b、c 不会同时活跃,因此它们可以交替使用同一个物理寄存器 r
1 | |
只有活跃区间发生重叠的变量,才不能分配到同一个寄存器
数据流方程
1 | |
对于任意一条语句 [d: s] ,首先计算两个集合
gen[d: s] = {x | 变量x在语句s中被使用}
kill[d: s] = {x | 变量x在语句s中被定义}
- gen:这条语句要读取哪些变量
- kill:这条语句会重新赋值哪些变量
| 语句 | gen(使用) | kill(定义) |
|---|---|---|
x = y + z |
{y,z} |
{x} |
z = z + x |
{z,x} |
{z} |
基本块内的后向数据流方程
对每条语句 s:
in[s]:执行语句s之前的活跃变量out[s]:执行语句s之后的活跃变量
基本块内部,下一条语句只有一个,因此out[si]=in[si+1]
而一条语句执行前的活跃变量为in[s]=gen[s]∪(out[s]−kill[s])
即执行前活跃的变量= 本条语句要使用的变量 ∪ 执行后仍然活跃、且没有被本条语句重新定义的变量
out[s] - kill[s]
表示从执行后的活跃变量中,删除本条语句重新定义的变量
例子
1 | |
从最后一条语句向前计算
| 语句 | gen |
kill |
out |
in |
|---|---|---|---|---|
4: return c |
{c} |
{} |
{} |
{c} |
3: c=b+3 |
{b} |
{c} |
{c} |
{b} |
2: b=a+2 |
{a} |
{b} |
{b} |
{a} |
1: a=1 |
{} |
{a} |
{a} |
{} |
活性分析是后向数据流分析,因为要判断变量现在的值以后还会不会使用
一般数据流方程

其中 succ[s] 表示语句 s 的所有直接后继
只要变量在任意一条后继路径中活跃,它在当前语句执行后就是活跃的
例子

1 | |
in[s]:执行语句s之前必须保留的变量。out[s]:执行语句s之后仍然需要的变量。gen/use:本语句使用的变量。kill/def:本语句重新定义的变量
因为程序中存在循环:
1 | |
计算出的活跃信息会沿着循环不断向前面的语句传播,因此一次计算不能得到最终结果
这里同样是用不动点算法,循环到集合不变为止
最终:
| 语句 | in |
out |
|---|---|---|
1: a=0 |
{c} |
{a,c} |
2: b=a+1 |
{a,c} |
{b,c} |
3: c=c+b |
{b,c} |
{b,c} |
4: a=b*2 |
{b,c} |
{a,c} |
5: a<N |
{a,c} |
{a,c} |
6: return c |
{c} |
{} |
干扰图

干扰图用于表示变量之间能不能共用寄存器,是一个无向图G=(V,E)
构造方法:
- 每个变量对应一个结点。
- 如果两个变量在某个程序点同时活跃,就在它们之间连边。
边表示:
两个变量的值必须同时保存,不能使用同一个寄存器
代码优化
优化的位置
代码优化并不是编译器中的单独一步,而是可以出现在多个阶段:
1 | |
例如:
- AST 阶段:常量折叠、删除恒假分支
- IR 阶段:公共子表达式消除、死代码删除
- 汇编阶段:寄存器分配、指令调度、窥孔优化
前面的控制流、数据流和活性分析,都是为了判断某种优化是否安全
基本概念
什么是代码优化
代码优化是对程序进行的一种语义保持的变换。
“语义保持”就是优化前后程序的可观察行为不能改变,包括:
- 输出结果相同
- 对外部文件、网络等操作相同
- 正常情况下产生相同的副作用
变换的目的是让程序能够比变换前更小、更快、cache行为更好、更节能等等
编译器从业者永不失业定理
图灵已经证明:
不存在一个算法,可以判断所有程序在任意输入下是否会停机

1 | |
如果存在“完美优化器”,它需要判断任意程序 p 是否与这个死循环等价,也就是判断 p 会不会终止。但停机问题已经被证明不可判定,因此不存在能够完美优化所有程序的编译器
困难性
不能保证优化总能产生“好”的结果
优化的顺序和组合很关键
很多优化问题是非确定的
优化的正确性论证很微妙
一个编译器拥有足够多且可靠的优化,就可以称为好编译器
编译器优化追求的是“安全、有效、覆盖常见情况”,而不是对所有程序都得到绝对最优结果
优化分类
前端优化
特点:局部、流不敏感,通常直接处理 AST。
“流不敏感”表示不考虑语句之间复杂的控制流关系,只观察当前表达式或语句。
常见优化:
- 常量折叠
- 代数化简
- 简单死代码删除
中期优化
特点:全局、流敏感,通常在 CFG 等中间表示上进行。
“流敏感”表示需要考虑语句的执行顺序、分支和循环。
常见优化:
- 常量传播
- 拷贝传播
- 死代码删除
- 公共子表达式消除
前面学的到达定义、活性分析主要服务于这一阶段。
后端优化
在汇编代码或机器相关的 IR 上进行,例如:
- 寄存器分配
- 指令调度
- 窥孔优化
前端优化
常量折叠
在编译期间直接计算常量表达式的结果
a = 3 + 5 ==> a = 8
if (true && false) … ==> if (false)
可以在整型、布尔型、浮点型等数据类型上进行
算法
1 | |
AST 为:
1 | |
发现左右节点都是常量,就把整个加法节点替换成数字节点 8
异常
不能只按数学规则计算,还要考虑数据类型、溢出和异常:
1 | |
- 对固定宽度无符号整数,通常回绕为
0 - 对其他类型或语言,结果可能不同
- 除零等表达式还可能产生异常
代数化简
利用代数规律,把表达式改写得更简单。
例如:
1 | |
还可以把开销较大的运算替换为较小的运算,这叫强度削弱:
1 | |
算法
1 | |
异常
数学等价不一定代表程序语义等价:
1 | |
不能随意变成:
1 | |
原因包括:
- 运算顺序发生变化
- 整数可能溢出
- 浮点运算存在精度误差
- 表达式可能包含副作用
所以代数化简必须根据具体语言和数据类型判断
死代码(不可达代码)删除
静态删除永远不可能执行的代码
在控制流图上也可以进行这些优化,但在早期做这些优化可以简化中后端
1 | |
可以直接优化为:
1 | |
算法
1 | |
中间表示上的优化
不同的 IR 会决定优化方法,例如:
- CFG(控制流图):表示基本块之间“可能跳到哪里”
- CDG(控制依赖图):表示一段代码是否执行依赖于哪个条件
- SSA(静态单赋值形式):规定每个变量只被赋值一次,方便数据流分析
- CPS(后续传递风格):把“程序下一步执行什么”显式表示出来
共同的特点是需要进行程序分析,优化是全局进行的,而不是局部
优化的一般模式
程序分析 → 得到静态信息 → 程序重写
第一步进行控制流、数据流等分析,得到安全、保守的信息;第二步根据这些信息修改程序
常量传播

算法
1 | |
拷贝传播

算法
1 | |
死代码删除

语句能够执行,但它产生的结果以后不再使用。
例如:
1 | |
如果活性分析得到:
1 | |
说明执行完 x = v 后,x 的值不再被使用,因此可以删除
算法
1 | |