【笔记】编译原理
cheating sheet
- 句型 ∈(VT∪VN)∗,句子 ∈V∗T
- 分析树
- 叶结点既可以是非终结符,也可以是终结符。从左到右排列叶节点得到的符号串称为是这棵树的产出或边缘
- 短语:句型的分析树中的每一棵子树的边缘
- 直接短语:某个只有父子两代结点的子树的边缘;一定是某产生式的右部
- 词法分析阶段的错误处理
查找已扫描字符串中最后一个对应于某终态的字符- 如果找到了,将该字符与其前面的字符识别成一个单词,然后将输入指针退回到该字符,扫描器重新回到初始状态,继续识别下一个单词;
- 如果没找到,则确定出错,采用错误恢复策略
“恐慌模式(panic mode)”恢复:从剩余输入中不断删除开头字符,直到能够在剩余输入的开头发现一个正确的字符为止
- 最左推导,即总是替换句型的最左非终结符,最左句型
- 最右推导——规范推导,最左归约-规范归约,规范句型
- S_ 文法,简单的确定性文法,形如:每个产生式的右部都以终结符开始,同一非终结符的各个候选式的首终结符不同,不含 ε 产生式
- q_文法,每个产生式的右部或为 ε,或以终结符开始;具有相同左部的产生式有不相交的可选集
- 串首终结符 FIRST(α)
可以从文法符号串 α 推导出的所有串首终结符(为第一个符号且为终结符)的集合 - 非终结符的后继符号集 FOLLOW(A)
可能在某个句型中紧跟在非终结符 A 后边的终结符 a 的集合 - 产生式的可选集 SELECT(A→α)
可以选用产生式 A→α 进行推导时对应的输入符号的集合 - LL(1) 文法
当且仅当文法 G 的同一非终结符的各个产生式的可选集互不相交
预测分析法
非递归,初始时栈为S$]
;输入缓冲区w$
- 预测分析表:二维表,以 当前非终结符 和 当前输入符号 为表项,值为 产生式
- 预测分析中的错误检测和恢复
- 栈顶终结符和当前输入符号不匹配:直接弹出栈顶的终结符
- 栈顶非终结符与当前输入符号不匹配:
- M[A, a] 是空,忽略输入符号 a
- M[A, a] 是 synch,弹出栈顶的非终结符 A
- \n
- 最左归约-规范归约,规范句型
- LR 文法:最大的、可以构造出相应移入-归约语法分析器的文法类
其中 L 表示对输入进行从左到右扫描、R 表示反向构造最右推导序列;一般 LR 默认指 LR(1) - 移入-归约分析,初始时状态栈
[s0
,符号栈(也叫分析栈)[$
,输入串w$
- 移入:将当前输入符号移到栈顶
- 归约:被归约的符号串的右端必然处于栈顶。语法分析器在栈中确定这个串的左端,并决定用哪个非终结符来替换这个串
- 分析表,考察当前状态栈顶 s,输入缓冲区首字符 a
- 动作表 ACTION[s, a],执行移入或归约:
- 移入 sx:将符号 a、状态 x 压入栈
- 归约 rx:用第 x 个产生式 A→β 进行归约——同时使状态栈和符号栈的 |β| 个出栈,将 A 压入符号栈,并访问 GOTO
- 转移表 GOTO[s, A]:将表项的状态压入状态栈
- 动作表 ACTION[s, a],执行移入或归约:
- 每次归约的符号串(某个产生式的右部)称为“句柄”
- (整个串的)规范句型 = 符号栈 + 剩余输入串,也就是一些子树 + 待输入的一些叶子
(而符号栈是已输入字符串的规范句型) - “分析栈不能越过规范句型的句柄”,因为自动机上遇到句柄就得归约了
- 规范句型的活前缀:不含句柄右侧任意符号的规范句型的前缀
“不含句柄右侧任意符号的规范句型”就是分析栈
也就是分析栈的任意前缀 - “LR自动机中从初始状态开始的每一条路径对应一个规范句型活前缀”,好理解的
- LR(0),A→α⋅
- A=S′,即此项目是 S′→S⋅,接受:ACTION[i,$]=acc
- 否则,对任意 a∈VT∪{$} 都写上 ACTION[i,a]=rj,j 是 A→α 这个产生式的编号
- SLR,A→α⋅
- A=S′,...
- 否则,对任意 a∈FOLLOW(A) 都写上 ACTION[i,a]=rj,j 是 A→α 这个产生式的编号
- LR(1)
- 称项目 [A→α⋅Bβ,a] 与 [B→⋅γ,b] 等价,若 B→γ∈P,且 b∈FIRST(βa)
- [A→α⋅,a]⟹ACTION[i,a]=rj
- LALR
如果除展望符外,两个 LR(1) 项目集是相同的,则称这两个 LR(1) 项目集是同心的
合并同心项,不会产生移进-归约冲突,但可能产生归约-归约冲突 - 分析能力:LR(0) < SLR < LALR < LR(1)
自动机大小:LR(0) = SLR ≈ LALR < LR(1) - LR 分析中的错误恢复:恐慌模式、短语层次
- 语法制导定义 SDD,是对 CFG(形如 A→β)的推广
- 将文法符号和一个语义属性集合关联
- 综合属性
非终结符,子结点或本身
终结符,可以具有综合属性 - 继承属性
非终结符,父结点/兄弟结点或本身
终结符,没有继承属性
- 综合属性
- 产生式和一组语义规则相关联
语义规则为调用动作的,称为 “副作用”
依赖图:由语义规则导出,描述分析树中结点的每个属性间依赖关系的有向图 - SDD 的有用子类:S-属性定义 S-SDD;L-属性定义 L-SDD
- 将文法符号和一个语义属性集合关联
- S-SDD:仅使用 综合属性,可以 自底向上
- L-SDD,若它的每个属性:
- 要么是 综合属性
- 要么是 继承属性,且继承自 父亲/先序兄弟节点/自身
- 语法制导翻译方案 SDT:产生式右部中嵌入了程序片段(称为语义动作)的 CFG
实现:- LR 分析(总是归约)+ S-SDD(只继承)
将每个语义动作都放在产生式的最后 - LL 分析(递归下降分析,可以递归,也可以栈维护非递归)+ L-SDD
- LR 分析(总是归约)+ S-SDD(只继承)
- LL + L-SDD,自顶向下
- 非递归的预测分析,开同步栈,符号栈初始为
T Tsyn $]
在符号栈中 A 的底下放一个符号 Asyn,代表 A 的综合属性
在同步栈的对应位置存储对应的值
每次出栈前执行动作,将结果的值存到需要其的那些动作的槽里(top±p) - 递归的预测分析
- 非递归的预测分析,开同步栈,符号栈初始为
- LR + L-SDD,自底向上
- 和 LL+L-SDD 的 SDT 的区别就是,将其修改成使得所有语义动作都位于产生式末尾(因为总是归约)
做法:插入 M,M→ε + 末尾动作
- 和 LL+L-SDD 的 SDT 的区别就是,将其修改成使得所有语义动作都位于产生式末尾(因为总是归约)
- 声明语句翻译,全局 t, w 记录基础类型,用给数组的框框 []...;
T id;
:enter(id.lexeme, T.type, offset), offset += T.width - 赋值
lookup(name) 返回 name 对应的地址 addr
newtemp() 生成一个新的临时变量 t,返回 t 的地址
gen(code) - 数组引用
L.type:L 做为数组元素的类型,可逐层剥离出嵌套的子类型 .elem
L.offset:指示一个临时变量,该临时变量用于累加公式中的 i×width,从而计算数组元素的偏移量
L.array:初始数组在符号表的地址(可调用 L.array.type.elem 得到第一层内的数组类型),从 id 那直接一路继承上来;用于最后生成语句gen(L.array '[' L.offset ']' '=' 'E.addr')
- 控制语句
画出控制流图
newlabel(), label()
为了填写每个 goto 语句的目的地址,在进入 B 或 S 前必须定义好 B.true, B.false, S.next(但是在进入时可能还没有值,所以代码生成完还得回过头填一遍;而回填就是把这个过程也涵盖进去);选择性定义 B/S.begin(若有自下而上的箭头)
记得控制流中可能需要插入 goto 语句 - 布尔表达式 B
已有 B.true, B.false,在底层布尔表达式(true, false, 判断式)生成 goto,其他情况继续递归,递归前赋予其 true, false - 停一下,你想想上面的 true/false/next/begin 这些,只保证声明好就递归下放到叶节点生成的 goto,所以不得不生成代码完再填一遍;现在我们考虑从父亲处收集这些在叶节点生成 goto 代码并统一填写——这就是回填
- 回填,使用 list 从递归的节点中收集待填空的跳转指令
- makelist(i):i 为某个跳转指令的标号 (布尔表达式、控制流的 goto,还是两个地方)
- merge(p1,p2)
- backpatch(p,i):回填
- nextquad:全局变量,存储下一条生成语句的标号
- 可以插入 M 以存储该处指令标号,有 M→ε
- 可以插入 N 以在该处生成一个跳转指令,有 N→ε
- 反正,你只要知道回填是从儿子那收集待填跳转指令,就知道该怎么写了
- \n
- 程序每次执行该过程,称为一次“活动”,分配的连续存储区称为“活动记录”
活动记录一般形式(按记录的栈底到栈顶的顺序)- 实参
- 返回值
- 控制链(存放调用者的 top_sp)
- 访问链(“静态链”,上一级嵌套定义的最近活动记录)
- 保存的机器状态:通常包括返回地址和一些寄存器中的内容(注意返回地址和控制链的 top_sp 区分)
- 局部数据:在该过程中声明的数据
- 临时变量:比如表达式求值过程中产生的临时变量
- 调用/返回序列
- 调用序列
- 调用者:修改被调用者:参数;返回地址(程序计数器的值)放入机器状态字段;top_sp 放到控制链;
然后 top_sp 指向被调用者局部数据开始的位置 - 被调用者:保存寄存器值和其它状态信息
- 调用者:修改被调用者:参数;返回地址(程序计数器的值)放入机器状态字段;top_sp 放到控制链;
- 返回序列
- 被调用者:修改自身返回值;使用控制链、机器状态字段中的信息,恢复 top_sp 和其它寄存器;
跳转到由调用者放在机器状态字段中的返回地址 - 调用者:使用被调用者返回值字段
- 被调用者:修改自身返回值;使用控制链、机器状态字段中的信息,恢复 top_sp 和其它寄存器;
- 访问链,指向其直接定义者的、在活动记录栈里最近的活动
- Display 表,对每个嵌套深度 i,d[i] 按建立的先后顺序、维护嵌套深度为 i 的过程的活动记录
- 符号表,组织方式
- 基本属性,直接存放在符号表中
如种属、类型、地址(偏移地址 offset)、扩展属性指针 - 扩展属性
- 基本属性,直接存放在符号表中
- 对于多个过程,分别建立符号表
嵌套定义的,外围过程的符号表里有指向若干内部过程的序列,内部过程有指针指向外围过程
为每个符号表维护表项的宽度之和 - /n
- 基本块划分 + 流图
- 优化:机器无关/相关——针对中间/目标代码;局部/全局——基本块内/外
- 基本块内优化,DAG,只有计算出口活跃变量的部分有用
a[j]=y,创建一个[]=
结点,3 个子结点 a,j,y,没有定值变量表,而且杀死所有已经建立的、其值依赖于 a 的结点——不能再获得任何定值变量,因为从现在起 a 里的东西已经不确定了 - 删除公共子表达式、删除无用代码、代码移动、强度削弱
- 基本块内优化,DAG,只有计算出口活跃变量的部分有用
- 数据流分析
- 到达定值分析
某个定值语句 d 能不能到达某个基本块 B 的开头:d ∈? IN[B]- 正向传函,倒着扫一遍基本块,当前的语句 d:u=∗ 如果还没被杀死,就将 d 加入 gen,然后杀死在场所有 d′:u=∗ 的语句——都将它们加入 kill;当前 d 已被杀死就跳过
- 前驱取并,去掉自己 kill 掉的再加上 gen
- 01 串做,做时可以标注一下当前哪个 OUT 变了
- 引用(的)定值链,ud
循环不变计算,检测变量是否未经定值就被引用,常量传播
- 活跃变量分析
某个基本块 B 结尾出去后,能不能到达一个对变量 x 的引用:x ∈? OUT[B]- 逆向传函,正着扫一遍,对于当前 a=b+∗,如果 b 没见过,就放入 use;如果 a 没见过,就放入 def;特别地若 a=b,显然是放入 use
- 后继取并,去掉自己 def 掉的再加上 use
- 注意从后继来,就是找出边
- 定值(的)引用链,du
删除无用赋值,为基本块分配寄存器
- 可用表达式分析
称某条代码的表达式可用,如果到它的所有来时之路上都计算过这个表达式,且从那时到现在没有对表达式的值进行修改(对表达式变量的定值)- 正向传函,初始 e_gen,e_kill 都为空,正着扫一遍:每次看 z=x op y,将 x op y 加入 gen,然后把 z 标注在 kill 上并划掉 gen 里所有带 z 的(可能就把刚加入的 z op y 划掉了),重复下去...扫完后,对于 kill 里标注的每个 z,将所有带 z 的表达式中除了还在 gen 的,都放入 kill
- 初始除 了ENTRY 的 OUT 都为表达式全集
前驱取交,去掉 e_kill 加上 e_gen - 删除全局公共子表达式,复制传播
- 支配结点
- 正向传函,加上自个就行
- 前驱取交
- 识别自然循环,找回边 n→d,d dom n,再反向找所有在 d 之后能到达 u 的
- 到达定值分析
- 全局优化
- 删除全局公共子表达式
前置:可用表达式
对每个可用表达式,往前找到那些相同表达式的代码点,用同一个临时变量记一下,在这里使用它 - 删除复制语句
前置:可用表达式,定值-引用链(活跃变量)
站在当前的复制语句 x=y,- 往后定位,用 du 链查找后面所有用到 x 的地方,对于它们:
- 往前看:x=y 得是可用的—— y 在 x=y 这句之后没被重新定值
- 都满足的话,du 链的所有对 x 的引用,替换成 y;然后删去 x=y
- (部分满足的话应该可以部分替换,但别删掉那些 x=y 就行)
- 代码移动
前置:引用-定值链(到达定值),自然循环
先找到自然循环
在循环里头找循环不变计算——通过 ud 链看看这个语句的定值点是不是在循环外头、或者常数;重复做
代码外提:将这些循环不变计算,移动到前置首节点(需要满足支配、定值、引用条件)
综上,人工操作可以:- 这个循环内,首先找的语句所属的块,它得是所有出口节点的必经之路(支配条件);
- 然后检测这个块里头的那个语句 x=∗:∗ 只能出现在循环外头或者为常数(循环不变计算,可能连锁反应)
- x 不能在循环内的其他地方被定值(定值条件)
- x=∗ 得是循环里头所有对 x 的引用的必经之路(引用条件)
- 作用于归纳变量的强度削弱
前置:循环不变计算信息(到达定值+自然循环),到达定值- 检测归纳变量
- 基础归纳变量看 j=j+* 的 ud 链——在循环里头除了它自己就没有定值 j,而且 * 是循环不变计算
- 归纳变量,看 ud 链,在循环里头就定值了一次,而且是从基础归纳变量来的,k(j, c, d)
- 强度削弱
根据归纳变量信息,新建临时变量代替,这个你看 图片 吧懒得描述了
- 检测归纳变量
- 归纳变量删除
前置:归纳变量(前面的一堆东西)+ 活跃变量
归纳变量那边可能替换出复制语句,那就考虑可能复制语句删除
尽量用临时变量代替,包括测试语句;如果基础归纳变量不活跃了,扔掉
- 删除全局公共子表达式
1. 绪论
编译器结构、词法单元形式
编译器的 T 形图
编译是将一个语言 S 的文本,通过语言 I 的编译程序,输出一个语言 T 的文本,且两个文本能够完成同样的功能(相同的输入,得到相同的输出);我们不妨记作 [S,I,T] 吧
基本组合(见图右上):左边 [L1,A,L2] 和下面的 [A,B,C],等价得到右边的 [L1,C,L2],含义为,一个底层语言为 A 的编译程序可以将语言 L1 的文本输出为语言 L2 的文本;而通过编译器 [A,B,C] 将程序 A 编译成的 C 语言程序,同样能够完成 L1 到 L2 的任务,故得到编译器 [L1,C,L2]。一般来说,我们需要真正最底层的语言是这台机子本身能执行的机器语言(即图中 B=C)
- 自展:在同一台机器上实现不同语言的编译器(见图右)
- 编译器的移植:将一台机器上运行的编译器进行处理,构造出在另一台机器上可以运行的编译器
给定 [L,A,A],求 [L,B,B](见图右下,[L,B,B] 没画出来,猜猜在哪?)
2. 语言及其文法
2.1 基本概念
- 串:有穷符号序列
- 字母表:有穷符号集合
- 字符串连接
- 字母表的乘积:集合元素连接
- (对串/字母表的)幂:多次连接/乘积
- 闭包:无穷次幂的集合
2.2 文法
- VT:终结符集合,也称为 token
表示:排在前面的小写字母,如 a,b,c;运算符,如 +,∗ 等;标点符号,如括号、逗号等;数字 0,1,…,9;粗体字符串,如 id、if 等 - VN:非终结符集合
表示:排在前面的大写字母,如 A,B,C;字母 S 通常表示开始符号;小写、斜体的名字,如 exp、stmt 等;代表程序构造的大写字母,如 E(表达式)、T(项)、F(因子) - VT∩VN=ϕ
- 其他约定的表示:
文法符号 VT∪VN:排在后面的大写字母 X,Y,Z
文法符号串(包括空串,(VT∪VN)∗):α,β,γ
终结符号串(包括空串):排在后面的小写字母 u,v,w,…,z
除非特别说明,第一个产生式的左部就是开始符号 - P:产生式 α→β 的集合
- S∈VN:开始符号
2.3 语言
- 推导 ⇒,⇒+,⇒∗
最左推导:总是替换最左非终结符 - 句型 ∈(VT∪VN)∗,句子 ∈V∗T
- 文法 G 生成的语言,记为 L(G),即
- 语言的运算:并、连接、幂、闭包
2.4 文法分类
逐级包含(忽略 1 型不能包含 ε-产生式)
- 0 型文法 (Type-0 Grammar)
无限制文法 (Unrestricted Grammar) / 短语结构文法 (Phrase Structure Grammar, PSG)
∀α→β∈P,α 中至少包含1个非终结符 - 1 型文法 (Type-1 Grammar)
上下文有关文法 (Context-Sensitive Grammar, CSG)
一般形式:α1Aα2→α1βα2,A∈VN,β≠ε
于是满足 ∀α→β∈P,|α|≤|β|,β≠ε
显然不包含 ε-产生式 - 2 型文法 (Type-2 Grammar)
上下文无关文法 (Context-Free Grammar, CFG)
一般形式:A→β,A∈VN - 3 型文法 (Type-3 Grammar) - 正则语言
正则文法 (Regular Grammar, RG),包括左线性文法和右线性文法
右线性 (Right Linear) 文法:A→wB 或 A→w
左线性 (Left Linear) 文法:A→Bw 或 A→w
正则表达式:描述正则语言的更紧凑的表示方法
2.5 CFG 语法分析树
分析树:
- 根节点的标号为文法开始符号
- 内部结点表示对一个产生式 A→β 的应用,该结点的标号是此产生式左部 A,该结点的子结点的标号从左到右构成了产生式的右部 β
- 叶结点既可以是非终结符,也可以是终结符。从左到右排列叶节点得到的符号串称为是这棵树的产出 (yield) 或边缘 (frontier)
相关概念
- 短语 (phrase):句型的分析树中的每一棵子树的边缘,称为该句型的一个短语
- 直接短语 (immediate phrase):某个子树且只有父子两代结点、的边缘;一定是某产生式的右部
- 二义性文法 (Ambiguous Grammar):如果一个文法可以为某个句子生成多棵分析树
对于任意一个上下文无关文法,不存在一个算法,判定它是否为二义性的;但能给出一组充分条件,满足这组充分条件的文法是无二义性的
满足,肯定无二义性;不满足,也未必就是有二义性的
3. 词法分析
3.1 单词的描述
考虑在正则文法/正则表达式 (RE) 的基础上来刻画单词
正则定义 (Regular Definition):是具有如下形式的定义序列:d1→r1,d2→r2,…,dn→rn
其中每个 di 都是一个新符号,它们都不在字母表 Σ 中,而且各不相同;每个 ri 是字母表 Σ∪{d1,d2,…,di−1} 上的正则表达式 RE
简单地理解,d 就是便于给一些 RE 命名
比如整型或浮点型(2, 2.15, 2.15E+3, 2.15E-3, 2.15E3, 2E-3):
不过这个能表示前导零?
3.2 单词的识别
又是你,自动机😭
有穷自动机 FA
转换图:节点 - 有穷的状态集合,初始状态、若干终止状态;有向边 - 对于输入 a 进行状态 p→q 的转换
接收语言:输入串 x 从初始状态到达终止状态称为被接收;FA M 接收的串的集合称为其定义/接收的语言 L(M)
最长子串匹配原则 (Longest String Matching Principle):当输入串的多个前缀与一个或多个模式(经过一个转换序列到达某个终止状态)匹配时,总是选择最长的前缀进行匹配;也就是说,在到达某个终态之后,只要输入带上还有符号,DFA 就继续前进,以便寻找尽可能长的匹配
确定的有穷自动机 DFA
定义为
其中
- S:有穷状态集
- Σ:输入符号集合;假设 ε 不是 Σ 中的元素
- δ:S×Σ→S;∀s∈S,a∈Σ,δ(s,a) 表示从状态 s 出发,沿着标记为 a 的边所能到达的状态
- s0∈S:初始状态
- F⊆S:接收状态/终止状态集合
// 输入以 eof 结尾的字符串 x, nextChar() 返回 x 的下一个字符
s = s0, c = nextChar();
while (c != eof) s = move(s, c), c = nextChar();
if (s in F) return "yes"; else return "no"
非确定的有穷自动机 NFA
定义为
转换函数 δ:S×Σ→2S,δ(s,a) 表示所能到达的状态集合(故可为空集)
带有“ε-边”的 ε-NFA
定义为
转换函数 δ:S×(Σ∪{ε})→2S
正则文法 RG、DFA, NFA, ε-NFA 等价性:
可以识别相同的语言
具体来说,以 DFA, NFA 为例,对任何 NFA N,存在定义同一语言的 DFA D;反之亦然
正则表达式 RE → 有穷自动机 FA
- RE → ε-NFA
实际操作时都是直接盯着产生式设计的;递推构造方法: - ε 闭包
计算 ε-closure ε 闭包,也就是根据 ε 边的可达性得到的若干最大集合,具体来说,需要计算:
move(T,a):能够从集合 T 中的某个 NFA 状态 s 出发通过标号为 a 的转换到达的 NFA 状态的集合——遍历即可
ε-closure(s):能够从状态 s 出发只通过 ε 转换到达的 NFA 状态集合
ε-closure(T):∪s∈Tε-closure(s) - ε-NFA → DFA(建立在 ε 闭包上的 NFA → DFA)
子集构造法,形式化地说,对于 ε-NFA E=(SE,Σ,δE,s0,FE),构造 DFA D=(SD,Σ,δD,ε-closure(s0),FD),且满足:- SD=2SE 或 SD={S∈2SE|S=ε-closure(S)}
- FD={S|S∈SD,S∩FE≠∅}
- ∀S∈SD,a∈Σ,δD(S,a)=ε-closure(∪p∈SδE(p,a))=ε-closure(move(S,a))
初始化:ε-closure(s_0) 是 S_D 中的唯一状态,且它未加标记 while (S_D 中有未标记状态 T) { 给 T 加上标记; for (每个输入符号 a) { U = ε-closure(move(T, a)); if (U 不在 S_D) 中 { U 加入 S_D,且不做标记; } tran_D[T, a] = U; } }
3.3 词法分析阶段的错误处理
错误:单词拼写错误(0x3G、1.05e),非法字符
词法错误检测:如果当前状态+输入符号在转换表中为空,且不是终止状态,则调用错误处理程序:
错误处理:查找已扫描字符串中最后一个对应于某终态的字符
- 如果找到了,将该字符与其前面的字符识别成一个单词,然后将输入指针退回到该字符,扫描器重新回到初始状态,继续识别下一个单词;
- 如果没找到,则确定出错,采用错误恢复策略
“恐慌模式(panic mode)”恢复:从剩余输入中不断删除开头字符,直到能够在剩余输入的开头发现一个正确的字符为止
3.4 词法分析器生成工具 Lex
4. 语法分析
语法分析的任务:根据给定的文法,从左向右扫描,识别输入句子的各个成分,从而构造出句子的分析树
4.1 自顶向下分析 (Top-Down Parsing)
根到叶节点——从文法开始符号 S 推导 出词串 w
推导的每一步,都需要做两个选择:替换当前句型中的哪个非终结符;用该非终结符的哪个候选式进行替换
(i) 替换哪个非终结符
自顶向下分析采用最左推导,即总是替换句型的最左非终结符;S⇒∗lmα,称 α 为最左句型
(而最右推导称为规范推导,这是因为其逆过程——自底向上采用最左归约-规范归约)
通用做法:递归下降分析,当前输入符号 a,关于非终结符的函数 S()
void S() {
选择一个 S 的产生式 S → X1 X2 … Xn;
for (i, 1, n) { // 最左推导
if (Xi 非终结符) Xi();
else if (Xi == 当前输入符号 a) a = GetNextToken();
else Error();
}
}
问题:左递归,存在推导 A⇒+Aα
- 消除直接左递归 A→Aα:
形如 A→Aα1|Aα2|⋯|Aαn|β1|β2|⋯|βm,其中 α≠ε,β 不以 A 开头
转化成 A→β1A′|β2A′|⋯|βmA′,A′→α1A′|α2A′|⋯|αnA′|ε - 消除间接左递归:
输入不含循环推导 A⇒+A 和 ε-产生式的文法 G,其非终结符为 A1,⋯,An
从 1 到 n 遍历,对于当前的 Ai,1≤i≤n,遍历 Aj, j=1,…,i−1,将每个形如 Ai→Ajγ 的产生式,替换为产生式组 Ai→δ1γ|δ2γ|⋯|δkγ,其中 Aj→δ1|δ2|⋯|δk 是所有的 Aj 产生式;
也就是说,原本你有若干产生式 Aj→δ1|δ2|⋯|δk,Ai→Ajγ
现在它们变成了 Aj→δ1|δ2|⋯|δk,Ai→δ1γ|δ2γ|⋯|δkγ
然后消除 Ai 产生式的直接左递归
(ii) 用哪个候选式替换
首先我们至少需要消除显式的二义性(自己捏的词,就是显然会产生二义性的情况,而且不得不处理;这里说的显然,是相对于 LL(1) 定义中不那么显然的“可选集不交”):
指存在两个相同前缀的产生式右部;因为在后续的 LL(1) 中总是看最左非终结符和符号;课件称此为“改写产生式来推迟决定,以便等待获得足够多的输入信息”:
提取左公因子 (Left Factoring),具体做法:
对于每个非终结符 A,找出它的两个或多个选项的最长公共前缀 α。如果 α≠ε,将所有 A-产生式 A→αβ1|αβ2|⋯|αβn|γ1|γ2|⋯|γm 替换为 A→αA′|γ1|γ2|⋯|γm,A′→β1|β2|⋯|βn
但是还是可能存在不确定性,导致回溯——使用 预测分析法 - LL(1) 文法
4.2 预测分析法
确定的递归下降分析,不需要回溯;在输入中向前看特定个数(通常一个)符号来选择正确的 A-产生式
如何保证唯一性?比如我们可以使用
S_ 文法 简单的确定性文法,形如:每个产生式的右部都以终结符开始,同一非终结符的各个候选式的首终结符不同,不含 ε 产生式
q_文法,每个产生式的右部或为 ε,或以终结符开始;具有相同左部的产生式有不相交的可选集
显然这些是确定性的文法,但是太理想化了😥 还是看看远处的 LL(1) 文法吧家人们
4.2.1 LL(1) 文法
"LL(1)":从左开始扫描,最左推导,观察当前最左未被匹配字符 (k=1)
过程:从文法开始符号出发,在每一步推导过程中根据当前句型的最左非终结符 A 和当前输入符号 a,选择唯一的正确的 A-产生式
引入几个相关于 LL(1) 文法的概念:
- 串首终结符 FIRST(α)
可以从文法符号串 α 推导出的所有串首终结符(为第一个符号且为终结符)的集合:
∀α∈(VT∪VN)+,FIRST(α)={a|α⇒∗aβ,a∈VT,β∈(VT∪VN)∗}
特别地,若 α⇒∗ε,则 ε∈FIRST(α)
算法:- 对于终结符 a∈VT,FIRST(a)={a}
- 对于非终结符 X→Y1⋯Yk,∀a∈VT,若 ∃i,a∈FIRST(Yi) 且 Y1⋯Yi−1⇒∗ε,则 a∈FIRST(X);若 Y1⋯Yk⇒∗ε 或 X→ε,则 ε∈FIRST(X)
- 对于符号串 α=X1X2⋯Xk,则将 FIRST(X1)/{ε} 加入 FIRST(α);若 ε∈FIRST(X1),则将 FIRST(X2)/{ε} 加入 FIRST(α);若又有 ε∈FIRST(X2)... 以此类推;
最后若 ε∈FIRST(Xi),i=1,⋯,k,则 ε∈FIRST(α)
- 非终结符的后继符号集 FOLLOW(A)
可能在某个句型中紧跟在非终结符 A 后边的终结符 a 的集合:
FOLLOW(A)={a|S⇒∗αAaβ, a∈VT, α,β∈(VT∪VN)∗}
算法(不断应用下面三个规则,直至不再更新任何 FOLLOW):- 结束符 $∈FOLLOW(S)
- 若 A→αBβ,则 (FIRST(β)/{ε})⊆FOLLOW(B)
- 若 A→αB 或 A→αBβ,ε∈FIRST(β),则 FOLLOW(A)⊆FOLLOW(B)
- 在推导过程中,当前选择使用 A→ε 的条件:
- 当前终结符 A 与当前输入符 a 不匹配
- 存在 A→ε
- a 可以跟在 A 后面,即 a∈FOLLOW(A)
- 产生式的可选集 SELECT(A→α)
可以选用产生式 A→α 进行推导时对应的输入符号的集合:
若 ε∉FIRST(α),则 SELECT(A→α)=FIRST(α)
若 ε∈FIRST(α),则 SELECT(A→α)=(FIRST(α)−{ε})∪FOLLOW(A)(若 α⇒∗ε,自然就是 FOLLOW(A) 了)
现在回到 LL(1) 文法:
定义 文法 G 是 LL(1) 的,当且仅当 G 的同一非终结符的各个产生式的可选集互不相交
也就是任意两个具有相同左部 A 的产生式 A→α|β,满足 SELECT(A→α)∩SELECT(A→β)=∅
或者更具体地,满足下面的条件:
- 不存在终结符 a 使得 α 和 β 都能够推导出以 a 开头的串:FIRST(α)∩FIRST(β)=∅
- α 和 β 至多有一个能推导出 ε,且(选其一并推导出 ε 导致串首终结符变为 FOLLOW(A),不能与另一个的串首终结符有交)
- 如果 β⇒∗ε,则 FIRST(α)∩FOLLOW(A)=∅
- 如果 α⇒∗ε,则 FIRST(β)∩FOLLOW(A)=∅
预测分析表:二维表,以 当前非终结符 和 当前输入符号 为表项,值为 产生式;根据该表即可确定地递归下降分析(从而 LL(1) 文法是无二义性的)
得出该表的前提是计算出 SELECT(α→β)
4.2.2 递归的预测分析法
根据预测分析表
对于当前非终结符 A,当前字符 Token,调用以 A 命名的函数 A(Token)
且有 A→α1|α2|⋯|αn
main() {
设置 Token = 第一个字符;
S(Token);
if (Token ≠ $) Error();
}
// 为所有非终结符 A 都编写一个函数
A(Token) {
// 实际实现时,直接写成 if (Token == * || ...)
if (Token in SELECT(A → alpha_1)) code_1;
else if (Token in SELECT(A → alpha_2)) code_2;
...
else if (Token in SELECT(A → alpha_n)) code_n;
else Error();
}
code_
:假设选取 αi=X1X2⋯Xk,则代入 αi,过程依然是从左到右匹配/解释
// alpha_i = [X_1, X_2, ..., X_k],右部非 ε
for (j = 1 to k) {
if (X_j in V_T) {
if (X_j == Token) GetNextToken();
else Error();
} else { // Xj in V_N
X_j(Token);
}
}
// aplha_i = [ε]
{
if (Token not in SELECT(A → ε)) Error();
}
// 如果已知产生式,实际书写时,直接根据产生式形式把循环语句展开成条件语句的串联
// 比如 A → a <B> c
if (Token != 'a') Error();
GetNextToken();
B(Token);
if (Token != 'c') Error();
GetNextToken();
// 比如 A → ε
if (Token not in SELECT(A → ε)) Error();
4.2.3 非递归的预测分析法
显式地维护一个栈结构,而不是通过递归调用的方式隐式地维护栈。这样的语法分析器可以模拟最左推导过程
(预测分析)表驱动的预测分析法:
输入串 w 和文法的预测分析表 M[];输出 w 的最左推导,若 w∈L(G)
维护栈,初始为 S$];输入缓冲区 w$
设置输入指针 ip 使它指向 w 的第一个符号;
X = stack.top(); // X = S
while (X ≠ $) { // 栈非空
a = *ip; // ip 所指向的符号 a
if (X == a) stack.pop(), ip++;
else if (X in V_T) Error();
else if (M[X, a] 报错) Error();
else if (M[X,a] = X → Y_1 Y_2 … Y_k) {
输出产生式 X → Y_1 Y_2 … Y_k;
stack.pop(); // 弹出栈顶符号;
将 Y_k, Y_k-1, …, Y_1 依次压入栈中,即 Y_1 位于栈顶
}
X = stack.top(); // 令 X = 栈顶符号
}
递归、非递归对比:
程序规模:递归要为每个非终结符都编写一个函数,规模较大;非递归主体程序规模小,需要载入分析表,总体更小
预测分析法 - 例题
预测分析法过程:
- (构造文法)
- 改造文法:消除左递归、消除二义性(比如提取左公因子)、消除回溯(见下)
- 求每个变量的 FIRST 集和 FOLLOW 集,从而求得每个候选式的 SELECT 集
- 检查是不是 LL(1) 文法:同一非终结符的各个产生式的可选集互不相交;若是,构造预测分析表
- 对于递归的预测分析,根据预测分析表为每一个非终结符编写一个过程;对于非递归的预测分析,实现表驱动的预测分析算法
例题:
推导:id+id∗id
假设有文法,开始符号 E:
首先——消除左递归
如果需要的话,还得提取左公因子
然后——求出 FIRST,FOLLOW(求 FOLLOW 时不断应用上述的三条规则,直到不再更新)
关于 FOLLOW,给之后的我提示:
总之,对于 rule 2,总是看各个产生式相邻两个非终结符;对于 rule 3,总是将各个产生式左部的集合加入其右部的最后一个非终结符的集合,以及若最后一个可推出空、则加入倒数第二个的,以此类推(4)⟹(FIRST(E′)/{ε})⊆FOLLOW(T)rule 2(6)⟹(FIRST(T′)/{ε})⊆FOLLOW(F)r2(4)⟹FOLLOW(E)⊆FOLLOW(E′)r3(4),E′→ε⟹FOLLOW(E)⊆FOLLOW(T)r3(5),E′→ε⟹FOLLOW(E′)⊆FOLLOW(T)r3(6)⟹FOLLOW(T)⊆FOLLOW(T′)r3(6),T′→ε⟹FOLLOW(T)⊆FOLLOW(F)r3(7),T′→ε⟹FOLLOW(T′)⊆FOLLOW(F)r3
计算 SELECT:
判断其是否为 LL(1) 文法:同一非终结符的各个产生式的可选集互不相交,也就是观察 SELECT——满足!
则可以写出预测分析表:它就是按非终结符和输入符号为表项,值为产生式
非递归推导:
4.2.4 预测分析中的错误检测
错误分为两种情况:
- 栈顶的终结符和当前输入符号不匹配
- 栈顶的非终结符和当前输入符号不匹配(在预测分析表对应项中的信息为空)
错误恢复:恐慌模式 (Panic Mode)
设计者为每个非终结符 A 选定同步词法单元 (synchronizing token) 集合,其为终结符集合;若当前输入符号对应表项为空,且属于 synch 集合,则弹出栈顶非终结符(相当于执行了 A→ε)
集合的选取应该使得语法分析器能从实际遇到的错误中快速恢复;例如可以把 FOLLOW(A) 中的所有终结符放入非终结符 A 的同步记号集合
综上,总结恢复方法如下:
- 栈顶终结符和当前输入符号不匹配:直接弹出栈顶的终结符
- 栈顶非终结符与当前输入符号不匹配:
- M[A,a] 是空,忽略输入符号 a
- M[A,a] 是 synch,弹出栈顶的非终结符 A
以上例为例,将 FOLLOW 加入 synch,预测分析表修改为:
例如对于栈 E$,输入 +id∗id$,初始直接忽略 +
4.3 自底向上的分析
将输入串 w 归约为文法开始符号 S——最左归约(从而反向构造句子的最右推导;最左归约-规范归约,最右推导-规范推导,相关句型-规范句型)
框架:移入-归约分析 (Shift-deeppinkuce Parsing)
——有点像俄罗斯方块
维护一个符号栈,初始仅有 $;剩余输入初始为输入串;定义动作:
- 移入:将当前输入符号移到栈顶
- 归约:被归约的符号串的右端必然处于栈顶。语法分析器在栈中确定这个串的左端,并决定用哪个非终结符来替换这个串
每次归约的符号串称为“句柄” - 接收:若栈中包含了开始符号且输入缓冲区为空,宣布语法分析过程成功完成
- 报错:发现一个语法错误,并调用错误恢复子例程
移入-归约分析过程:每次选择执行“移入”或“归约”,直到发生“报错”或者“接收”。
移入-归约分析遇到的问题有:i) 归约-归约冲突;ii) 移入-归约冲突
4.4 LR 分析法
LR 文法 (Knuth, 1963) 是最大的、可以构造出相应移入-归约语法分析器的文法类;其中 L 表示对输入进行从左到右扫描、R 表示反向构造最右推导序列;一般 LR 默认指 LR(1)
4.4.0 LR 分析器工作过程
这里的自动机和下面提到的形式化定义是同一个,更底层的原理应该是语法分析树
假设我们已经根据语法得到了分析器:主要是 LR 分析表,那么这个分析器的工作过程是:
用“状态”控制句柄的识别;具体来说,维护下推自动机,状态栈对应自动机的当前状态,符号栈对应自动机的栈:
- 输入缓冲区
- 状态栈、符号栈:同步进出
- 分析表,考察当前状态栈顶 s,输入缓冲区首字符 a
- 动作表 ACTION[s,a],执行移入或归约:
- 移入 sx:将符号 a、状态 x 压入栈
- 归约 rx:用第 x 个产生式 A→β 进行归约——同时使状态栈和符号栈的 |β| 个出栈,将 A 压入符号栈,并访问 GOTO
- 转移表 GOTO[s,A]:将表项的状态压入状态栈
- 动作表 ACTION[s,a],执行移入或归约:
形式化地说:
- 初始化:s0$a1a2…an$
- 对于当前格局:s0s1…sm$X1…Xmaiai+1…an$
- 若 ACTION[sm,ai]=sx,则变为s0s1…smx$X1…Xmaiai+1…an$
- 若 ACTION[sm,ai]=rx,此编号为 x 的产生式为 A→Xm−k+1⋯Xm,则变为s0s1…sm−k$X1…Xm−kAaiai+1…an$接着,若 GOTO[sm−k,A]=y,则变为s0s1…sm−ky$X1…Xm−kAaiai+1…an$
- 若 ACTION[sm,ai]=acc,那么分析成功
- 若 ACTION[sm,ai]=err,那么语法错误
- 若 ACTION[sm,ai]=sx,则变为
状态栈 stk 中为初始状态 s0, 输入缓冲区中为 w$;
令 a 为 w$ 的第一个符号;
while (1) {
s = stk.top();
if (ACTION[s, a] == sx) {
stk.push(x); // x 压入栈
a = next(a); // a 指向下一个符号
} else if (ACTION[s, a] == A → β) {
从栈中弹出 │β│ 个符号;
stk.push(GOTO[x, A]); // GOTO[t, A] 入栈
输出产生式 A → β;
} else if (ACTION[s, a] == acc) break; // 接收
else 调用错误恢复例程;
}
至于如何构造给定文法的 LR 分析表,我们有 LR(0) 分析、SLR 分析、LR(1) 分析、LALR 分析
4.4.1 LR(0) 分析
增广文法
向文法增加新的开始符号 S′ 和产生式 S′→S
目的是使得文法开始符号仅出现在一个产生式的左边,从而使得分析器只有一个接受状态
LR(0) 项目
项目:右部标有圆点 ⋅ 的产生式 A→α1⋅α2
特别地,A→ε 的项目只有 A→⋅
项目描述句柄识别的状态,也就是用 ⋅ 标记其左侧“已识别”的部分;
根据 ⋅ 后为终结符/非终结符/空,分为 移进/待约/归约项目
后继项目 (Successive Item):圆点的位置只相差一个符号的产生式
例如,称 A→α⋅Xβ 的后继项目是 A→αX⋅β
等价 LR(0) 项目:称项目 A→α⋅Bβ,B→⋅γ 等价,若 B→γ∈P
LR(0) 项目集闭包 (Closure of Item Sets):等价的项目组成的项目集 I,称为项目集闭包,每个项目集闭包对应着自动机的一个状态;(实际上,可能存在一个项目属于多个闭包;闭包的产生是从起始闭包通过后继关系生长出去的)
由定义可见,对于等价项目 A→α⋅Bβ,B→⋅γ
沿自动机向下,这两个项目同时到达 A→αB⋅β,B→γ⋅
由后者归约,在自动机上同时回退得到 A→αγ⋅β,这就是自动机的归约方式
LR(0) 自动机
LR 自动机是以项目集闭包为状态、GOTO 为出边得到的;在当前节点,根据 移进/待约 项目沿出边转移,或者根据 归约项目 进行归约并沿归约符号回退到之前某个节点;重复执行
自动机定义为
- 起始节点 I0=CLOSURE({S′→⋅S})
- 转移边—— GOTO 函数,项目集 I 对应于文法符号 X 的后继项目集闭包GOTO(I,X)=CLOSURE({A→αX⋅β|A→α⋅Xβ∈I})
- 状态集合——规范 LR(0) 项集族,为起始节点 I0 以 GOTO 生长的所有闭包节点C={I0}∪{I|∃J∈C,X∈VN∪VT,I=GOTO(J,X)}
- 接受集合 F={CLOSURE({S′→S⋅})}
LR(0) 分析表-构造 (ACTION/GOTO)
对于每个状态 i,Ii∈C:
- A→α⋅aβ∈Ii,则移入 a 并转移到 Ij=GOTO[Ii,a]:ACTION[i,a]=sj
- A→α⋅Bβ∈Ii,转移到 Ij=GOTO[Ii,B]:GOTO[i,B]=j
- A→α⋅∈Ii
- A=S′,即此项目是 S′→S⋅,接受:ACTION[i,$]=acc
- 否则,对任意 a∈VT∪{$} 都写上 ACTION[i,a]=rj,j 是 A→α 这个产生式的编号
(这里的 a 只是表示说此时总是要归约,归约过程并不会用到 a 的信息——所以叫做 LR(0);之后的改进比如 SLR, LR(1),会考虑这个 a 对于某个归约或移入是否合法)
回顾:执行 ACTION[i,a]=rj,且编号为 j 的产生式为 A→α,则在符号栈和状态栈上同时弹出 |α| 个(自动机上回退),然后把 A 压入符号栈(归约,A 就是自动机上转移出边的符号),然后执行 GOTO[stack.top(),A]=y,将 y 加入状态栈(自动机上沿出边 A 进行一步转移到状态 y)
- 没有定义的所有条目都设置为 err
LR(0) 文法:如果 LR(0) 分析表中没有语法分析动作冲突
LR(0) 分析过程的冲突
移进-归约冲突:当前项目集闭包同时有“移进/待约”和“归约”项目
归约-归约冲突:当前项目集闭包同时有多个“归约”项目
个人理解:LR 自动机-符号栈-语法分析树
个人理解,首先自动机这个概念,逻辑上并不是归附于某种语法树的结构;相反,自动机是独立的概念,它的底层逻辑就是项目、项目集闭包这些;而语法分析树才是自动机的结果,因为每次自动机执行归约操作,都能够用树结构来刻画它
那么尝试说明“自动机运行时,是如何顺带产生一棵分析树的”
已知自动机,考虑分析树的产生过程
- 当我们最终得到一个分析树,并回顾树上每个节点被确定存在的时刻时,其符合后序遍历,原因见 2
- 对于中间某个时刻,树的已确定节点必定构成最终分析树的若干独立完整子树。因为若我们以子树为元素建立栈(称为“子树栈”),初始为空,每次执行移入 / 归约动作,分别对应向栈顶加入一个叶节点(赋予移入终结符)/ 将栈顶若干子树出栈,新建节点(赋予归约产生式的左部非终结符)作为这些子树的父节点,将新子树入栈;
若仅以子树的赋予符号为元素时,这个栈就是符号栈;记符号栈内当前符号串为 α- 对于当前时刻,树的已确定节点中的所有叶子节点的符号,顺序对应整个字符串已移入 / 识别的前缀
- 对于当前时刻,对应自动机某个状态,有:
- 状态内所有项目
Ssome→αsome⋅Asomeβsome,⋯⋅ 后为非终结符 - (1)Ssome→αsome⋅asomeβsome,⋯⋅ 后为终结符或 ε - (2)Asome→⋅Asomeβsome,Asome→⋅asomeβsome,⋯由 1,3 等价关系导出 - (3)
- 状态内所有项目(除了归约项目),根据出边的符号 Asome,asome 转移到新的状态
- 状态内所有项目在 ⋅ 左侧的符号串 αsome,ε,均为 α 的后缀(可归纳证明)
- 自动机运行 和 分析树产生 的对应关系
- 若当前选择移入 a,则
- 在符号栈上压入 a
- 在子树栈上压入叶子节点 a
- 在自动机上沿 a 走到 Ssome→αsomea⋅βsome 的闭包的状态
- 若当前选择归约 A→X1X2⋯Xm⋅,则
- 在符号栈上顺序弹出符号 Xm,Xm−1⋯,X1,并压入 A
- 在子树栈上顺序弹出子树 Xm,Xm−1⋯,X1,并新建节点 A 为这些子树的父节点,并压入新子树
- 在自动机上沿 Xm,Xm−1⋯,X1 回退到 A→⋅X1X2⋯Xm,Ssome→αsome⋅Aβsome 所在状态,再沿 A 走到 Ssome→αsomeA⋅βsome 的闭包的状态
4.4.2 SLR 分析
通过观察 FOLLOW 集可能可以消除冲突:当前项目集闭包 I,含有 m 个“移进”和 n 个“归约”(剩下的形如 A→α⋅Bβ 那都是当完成归约 B→γ⋅ 后退回到这个状态、紧接着从 B 出边走出去的):
若集合 {a1,a2,…,am} 和 FOLLOW(B1),FOLLOW(B2),…,FOLLOW(Bn) 两两不相交,则可以消除冲突;设 a 是下一个输入符号:
- 若 a∈{a1,a2,…,am},则移进 a
- 若 a∈FOLLOW(Bi),则用 Bi→γi 归约
- 否则 err
SLR 分析表-构造
和 LR(0) 只有标黄处不同,因为多考虑了信息
思想就是,首先你要想归约 A→α,你的当前输入符号 a 肯定得属于归约非终结符 A 的 FOLLOW 集,不然就白冲突了;这样造完表后,再判断有没有冲突
对于每个状态 i,Ii∈C:
- A→α⋅aβ∈Ii,则移入 a 并转移到 Ij=GOTO[Ii,a]:ACTION[i,a]=sj
- A→α⋅Bβ∈Ii,转移到 Ij=GOTO[Ii,B]:GOTO[i,B]=j
- A→α⋅∈Ii
- A=S′,即此项目是 S′→S⋅,接受:ACTION[i,$]=acc
- 否则,对任意 a∈FOLLOW(A),ACTION[i,a]=rj,j 是 A→α 的状态编号
- 没有定义的所有条目都设置为 err
SLR 文法:如果给定文法的 SLR 分析表中不存在有冲突的动作
LR(1) 分析
SLR 使用的 FOLLOW 集合和下一个字符不交,只是不产生冲突的必要条件
注意到对于使用产生式 A→α 归约,在不同的使用位置,A 的后继符集合是 FOLLOW(A) 的子集;从而引入 LR(k):
LR(1) 项目
形如 [A→α⋅β,a] 的项
其中 a 是一个终结符(包括 $,视为特殊的终结符);表示在当前状态下,A 后面必须紧跟的终结符,称为该项的展望符 (lookahead),显然它属于 FOLLOW(A)
展望符只在 β=ε 时产生作用,此时 [A→α⋅,a] 项只有在下一个输入符号等于 a 时才可以按照 A→α 进行归约
等价 LR(1) 项目:称项目 [A→α⋅Bβ,a] 与 [B→⋅γ,b] 等价,若 B→γ∈P,且 b∈FIRST(βa)(这个递推关系是比较显然的,你理解一下)
当 β⇒+ε 时,此时 b=a 称为继承的后继符,否则叫自生的后继符
LR(1) 项目集闭包:等价的 LR(1) 项目集合
LR(1) 自动机
结合当前输入符号和展望符,匹配才能执行
自动机定义为
- I0=CLOSURE({[S′→⋅S,$]})
- 转移边—— GOTO 函数GOTO(I,X)=CLOSURE({[A→αX⋅β,a]|[A→α⋅Xβ,a]∈I})
- 状态集合—— LR(1) 项集族C={I0}∪{I|∃J∈C,X∈VN∪VT,I=GOTO(J,X)}
- F={CLOSURE({[S′→S⋅,$]})}
LR(1) 自动机在形态上相比 LR(0),会根据后继符集合的不同,将原始的 LR(0) 状态分裂成不同的 LR(1) 状态
LR(1) 分析表-构造
除了归约,其他和之前都一样
对于归约项 [A→α⋅,a]∈Ii 且 A≠S′,只对下一个字符与展望符 a 匹配时归约:ACTION[i,a]=rj
LR(1) 文法:如果 LR(1) 分析表中没有语法分析动作冲突
4.4.4 LALR 分析
对于不冲突的 LR(1):
LR(1) 的状态数往往会远大于 LR(0);提升效率且不产生冲突时,考虑合并 LR(1) 的状态
定义:如果除展望符外,两个 LR(1) 项目集是相同的,则称这两个 LR(1) 项目集是同心的(见上图 LR(1) 的每一对相同颜色框)
过程:将同心的 LR(1) 项集合并为一个,然后根据合并后得到的项集族构造语法分析表
合并同心项集不会产生移进-归约冲突——因为合并前每个项集自身不具有移进-归约冲突,且只有同心时才合并
但是可能会产生归约-归约冲突,因为同心的两个项目集,它们的展望符不同,可能恰好互换了下,然后合并时就产生冲突了
LALR 分析法可能会作多余的归约动作,但绝不会作错误的移进操作;对于错误的语句,可能会推迟错误发现(?)
LALR (1) 文法:如果分析表中没有语法分析动作冲突
分析能力:LR(0) < SLR < LALR < LR(1)
大小:LR(0) = SLR ≈ LALR < LR(1)
4.4.5 二义性文法的 LR 分析
虽然二义性文法都不是 LR 的,但是可以引入 优先级和结合性 来使用 LR 分析
4.4.6 LR 分析中的错误处理
错误:当 LR 分析器在查询语法分析动作表,发现一个报错条目
错误恢复策略
- 恐慌模式错误恢复
- 短语层次错误恢复
5 语法制导翻译
考虑语义分析——为 CFG 中的文法符号设置语义属性;在语法分析树上,语义属性值用与文法符号所在产生式(语法规则)相关联的语义规则来计算
语义规则同语法规则(产生式)相联系,涉及概念:
- 语法制导定义 (Syntax-Directed Definitions, SDD)
- 语法制导翻译方案 (Syntax-Directed Translation Scheme, SDT)
5.1 语法制导定义 SDD
语法制导定义 SDD,是对 CFG 的推广,具体而言,对于 CFG 的每个:
- 文法符号,即语法分析树上的一个节点 N 携带的符号 X,和一个 语义属性集合 相关联
其中某个属性 a 表示为 X.a,分为:- 综合属性 (synthesized attribute)
非终结符 A 的综合属性只能通过 N 的 子结点 或 N 本身 的属性值来定义
终结符可以具有综合属性,值为词法分析器提供的词法值;因此没有对应语义规则 - 继承属性 (inherited attribute)
非终结符 A 的继承属性只能通过 N 的 父结点/兄弟结点 或 N 本身 的属性值来定义
终结符没有继承属性
- 综合属性 (synthesized attribute)
- 产生式,和一组 语义规则 相关联
通过产生式关联的语义规则,在分析树上计算属性值
语义规则为调用动作的,称为 “副作用”;对应节点属性称为 “虚属性”
一个没有副作用的 SDD 称为属性文法
语义规则通过 SDT 实现,见下
注释分析树 (Annotated parse tree):每个节点都写有属性值的分析树
SDD 求值顺序
依赖图:由语义规则导出,描述分析树中结点的每个属性间依赖关系的有向图
- 分析树中每个 X.a 都对应着依赖图中的一个结点
- 如果属性 X.a 的值依赖于属性 Y.b 的值,则建立 Y.b→X.a 有向边;
特别地,对于来自自己的属性,画一个自环
依赖图可解的条件为其为 DAG,用得到的拓扑序列来计算
具体而言,序列 X1.a1,X2.a2,⋯,若有边 Xi.ai→Xj.aj,则有 i<j
解决多元环:存在 SDD 的有用子类,能保证其语法树均为 DAG,而且还能和自顶向下、自底向上一起实现:
- S-属性定义 (S-Attributed Definitions, S-SDD)
- L-属性定义 (L-Attributed Definitions, L-SDD)
5.2 S-SDD,L-SDD
- S-SDD ⊆ L-SDD
- S-属性定义,S-SDD:仅使用 综合属性,可以 自底向上
- L-属性定义,L-SDD,若它的每个属性:
- 要么是 综合属性
- 要么是 继承属性,且满足继承自 父亲/先序兄弟节点/自身,具体来说:
假设产生式为 A→X1X2⋯Xn,符号 Xi 仅具有继承属性,则其依赖:- A 的 继承属性(否则二元环)
- X1,X2,⋯,Xi−1 的属性(向左依赖)
- Xi 本身的属性
5.3 语法制导翻译方案 SDT
语法制导翻译方案 (SDT),是在产生式右部中嵌入了程序片段(称为语义动作)的 CFG,表示各属性计算的时机,以 {}
括起
SDT 实现方法:LR 分析 + S-SDD、LL 分析 + L-SDD
LR 分析+S-SDD 实现 SDT(见 5.5)
- S-SDD 转换为 SDT:将每个语义动作都放在产生式的最后
- 实现 SDT:LR 分析过程中,当归约发生时执行相应的语义动作
为栈增加综合属性值字段,对栈操作时顺带更新即可
LL 分析+L-SDD 实现 SDT(见 5.4)
回顾:LL 使用递归下降分析,可以递归,也可以栈维护非递归;这里一般指 LL(1)
- L-SDD 转换为 SDT:
- 将计算某个非终结符号 A 的继承属性的动作插入到产生式右部中紧靠在 A 的本次出现之前的位置上
- 将计算一个产生式 左部符号 的 综合属性 的动作放置在这个产生式右部的 最右端
- 实现 SDT:有三种
- 在非递归的预测分析过程中进行(见 5.4.1)
- 在递归的预测分析过程中进行(见 5.4.2)
- 在 LR 分析过程中进行
5.4 LL + L-SDD 的自顶向下翻译
LL(1) 预测分析法,包括递归和非递归的方法
如图,从语法分析树上(包括动作虚属性)看,继承属性在调用之前插入继承动作,综合属性动作插在这个产生式的最后;按先序遍历
5.4.1 非递归的预测分析
回顾非递归的自顶向下:维护符号栈 AX1X2⋯Xn$] 和输入串 w$,每次根据栈顶字符 A 和当前输入字符 a:若 A 为终结符且为 a,则抵消,否则查表运用产生式 A→Y1⋯Ym 替换栈顶为 Y1⋯YmX1X2⋯Xn$],然后继续
现在维护新的同步运行的栈,用于保存属性值或动作;对每个符号 A:
- 若 A 为终结符,则直接在同步栈的同样位置存储其值(由词法分析得到,归为综合属性)
- 若 A 为非终结符,则
- 在同步栈的同样位置存储其继承属性值
- 在符号栈中 A 的底下放一个符号 Asyn,代表 A 的综合属性,并在同步栈的对应位置存储其综合属性值
在符号栈内,还可能有 Action(或以指针 {a} 表示)代表一些动作,并在同步栈的相同位置保存具体的内容(或其指针)
对初始状态,栈为 T,Tsyn,$]
每次对于栈顶元素:
- 若其为非终结符 S,当前栈为 S,Ssyn,⋯,$],则由 SDT S→A1{a1}A2{a2}⋯Am{am},在栈中替换 S:S,Ssyn,⋯,$]⟹A1,{a1},A2,{a2},⋯,Am,{am},Ssyn,⋯,$]
- 若其为终结符 a,则已获得综合属性值,当前即将出栈,则出栈前将 a 的属性值存到栈里需要 a 的属性值的那些动作 {a} 的槽里(形如 top±p,事先计算好)
- 若其为动作 {a},则对槽里的值执行计算动作,然后将值送给 top±p 的位置(事先计算好),然后出栈;
比如对于上面的 {am},Ssyn,⋯,若 {am} 是计算综合属性值 Ssyn 的动作,则算完后将结果放入 top-1 位置(见下图第一行到第二行) - 若其为综合属性 Ssyn,则和终结符一样,出栈前将其值存到需要其的那些动作的槽里(形如 top±p,事先计算好,可根据产生式计算,例如下图第二行到第三行)
上面提到的将 符号的值 送入 动作所在位置 都是事先计算好的的代码,怎么算呢?
注意:先执行语义动作(top 未变),再更新符号栈(改变 top)
对每一个 SDT 语句,考察其内部每一个符号 A 和该语句内需要 A 的值的动作,判断 A 为 top 时,这些动作在栈的什么位置(注意,计算时对于非终结符 S,需要计算 S,Ssyn 两个!)
比如 SDT T→F{T′.val=F.val+T.val}T′
对于 T,出栈 前 将其值送入 top+2 位置(T 出栈后 F,Fsyn,{a},T′,T′syn 入栈,从右往左是 top,top+1,...)
对于 F,出栈前将其值送入 top-1 位置
5.4.2 递归的预测分析
这玩意就直接从语法树来看就行了,比非递归直观的多
沿用递归下降分析,为每个非终结符 A 构造的函数:A 的每个继承属性对应该函数的一个形参(回顾:L-SDD 要求继承自父亲或之前的兄弟节点,也就是 dfs 序小于其的节点,所以在调用时已知,自然可以作为形参传入),函数的返回值是 A 的综合属性值
在函数内,对每个产生式右部文法符号的每个属性都设置一个局部变量,用于后续使用,比如传入函数、写入动作的计算表达式
5.5 L-SDD 的自底向上翻译
我们在 5.4 讨论的是 LL 分析为基础的 L-SDD 的自顶向下翻译
如果我们考虑使用 LR 分析——自底向上时,我们总是归约——这意味在代码中我们只能在归约出一个产生式后才能执行动作,而不能将动作插入产生式。
所以现在我们得等价修改这个 SDT,使得所有语义动作都位于产生式末尾,从而能够在 LR 语法分析过程中计算
- 首先由 5.4 已经完成 L-SDD 转换为 SDT(在非终结符之前放置语义动作来计算其继承属性,在产生式后端放置语义动作计算综合属性)
- 现在对于每个内嵌的语义动作 {a},向文法中引入一个“标记非终结符” Mi 来替换它;每个这样的语义动作都有一个不同的标记,并且对于任意一个标记 M 都有一个产生式 M→ε
- 假设 M 对应的 {a} 所在的产生式为 A→α{a}β,替换后为 A→αMβ;且修改 a 为 a′,将 a′ 关联到 M→ε 末尾
- 修改:动作 a 需要的 A,α 中符号的任何属性,作为 M 的继承属性进行复制
- 遵循 a 中的方法计算各个属性,但是将计算得到的这些属性作为 M 的综合属性
- 从代码实现上,也需要考虑对于语义动作,其所需的属性值在栈的哪个位置
例如:T′→∗F{T′1.inh=T′.inh×F.val}T′1{T′.syn=T′1.syn}
用 N 替换后:
T′→∗F N T′1{T′.syn=T′1.syn}N→ε{N.i1=T′.inh; 继承属性N.i2=F.val; 继承属性N.s=N.i1×N.i2 综合属性代码其实就是(注意在 LR 中我们习惯符号栈底在左):stk[top+1].T1′.inh=stk[T′.inh的位置,相关产生式未写出].T′.inh×stk[top].F.val;top=top+1}
从代码上看,和 L-SDD 的非递归几乎一致,无非是加入了标记非终结符而已、把代码转移过去而已
再次强调:在符号栈执行 N→ε(top+1,将 N 入栈) 前,先执行其动作语句
6 中间代码生成
三地址指令、四元式表示
6.1 声明语句
过程:收集标识符的类型等,并赋予其一个相对地址,保存在符号表记录中
类型表达式 Type Expressions
包括:
- 基本类型,如 int,real,char,type_error,void
- 为类型表达式命名的类型名
- 类型构造符 作用于 类型表达式
- 数组:array(I,T),I 为整数,T 为类型表达式,下同;表示 I 个 T
int[3]
:array(3,int)
int[4][3]
:array(4,array(3,int)) - 指针:pointer(T)
- 笛卡尔乘积构造符 ×,将每个名字和每个类型关联;函数构造符 →,记录构造符 record
- 例如
record((name×array(8,char))×(score×integer))array(50,stype)pointer(stype)struct stype { char[8] name; int score; }; stype[50] table; stype* p;
- 数组:array(I,T),I 为整数,T 为类型表达式,下同;表示 I 个 T
局部变量分配
- 类型的宽度 width:该类型运行时所需存储单元数,从类型表达式可知;
- 符号表:表项为 (name,type,offset),符号名,类型,相对地址
用函数 enter(name,type,offset) 创建新表项 - 在 SDT 实现过程中,在动作里插入相应代码
- 先序遍历(可以在 LL(1) 时顺便做)
- 维护全局变量 offset,存储下一个可用的偏移地址,被初始符号的产生式的动作赋值为 0
- 维护全局变量 t,w,存储当前声明语句对应的 基本类型 的 type,width,用于计算其声明的符号的总宽度
- 为树上每个符号(比如
int[3]
),维护其综合属性 type,width - 在语义动作中,可能有:
- 完成一个声明块(如
, ;
)后,符号表 enter 增加新表项 - 执行完 enter 后,更新 offset 更新
- 识别出类型后,更新 t,w
- 某个符号的综合属性 ←t,w 或特定的 type,width 的表达式
- 完成一个声明块(如
6.2 赋值语句
6.2.1 简单赋值语句
过程:为若干表达式求值语句,生成对应的三地址码的指令序列(即指令都为三地址 addr1, addr2, addr3 进行某个基本运算并赋值,如 t1=t2+#1)
- 对于表达式 E,需要维护其综合属性:
- code:若干三地址码,对于儿子的 code 用 || 连接;用 gen(code) 生成新的 code
- addr:地址,该地址用于存放变量、子表达式或表达式本身的(临时变量)值
- 为代码块 S,维护其代码 S.code
- 函数
- lookup(name)=lookup(id.lexeme):查询符号表(6.1 完成)返回 name 对应的地址 addr
- newtemp():生成一个新的临时变量 t,返回 t 的地址
- gen(code):生成三地址指令 code;并将其添加到已生成的指令序列后(“增量翻译”)
- 对变量符号本身,调用 lookup 查
- 对中间表达式,用 newtemp 新建
- 在识别出赋值语句后,调用 gen 生成代码
如下,其实就是实验三中对赋值语句的操作;
为 E 新建地址 addr,相当于实验中的为Exp
建立t1 = newtemp()
注意对于 id 需要在表中查找其地址 addr=lookup(id.lexeme) 而不是新建
6.2.2 数组引用
将数组引用翻译成三地址码的问题
考虑数组某个位置 a[i1][i2]…[ik],记 w1 为 a[i1] 宽度,w2 为 a[i1][i2] 宽度...其对应 type(a)=arr(i1,arr(i2,arr(⋯arr(ik,int)))) 逐层的 width:
数组元素 a[i1][i2]…[ik] 的相对地址是:
SDT,数组符号 L,表达式 E
其中为 L 维护综合属性:
- L.type:L 生成的数组元素的类型;如下图,从底层
a[3]
向上传递,逐层剥离出嵌套的子类型 .elem - L.offset:指示一个临时变量,该临时变量用于累加公式中的 ij×wj 项,从而计算数组元素的偏移量
- L.array:数组名在符号表的地址,可以用于查找此数组名的基础类型 .type、基础类型的宽度 .type.width 等
最后的赋值语句直接写成 L.array[L.offset]=E.addr
6.3 控制语句
6.3.1 控制流语句
指令标号:对每条三地址指令,用其标号(就是序号)标识之,用于跳转
编写 SDT:看图写话
- 计算继承属性,一般在进入 B 或 S 前执行声明(和赋值):
- 为布尔表达式维护以下两者,且必须在调用前就已经声明好:
- (必须)B.true:地址,存放布尔表达式 B 为真时控制流转向的指令标号
- (必须)B.false:地址,存放布尔表达式 B 为假时控制流转向的指令标号
- 为代码块维护:
- (必须)S.next:地址,存放紧跟在 S 代码之后执行的指令的标号;一般继承得到,必须在调用前就已经声明好
- (选择)S.begin:地址,代码开头,一般定义 newlabel() 完直接绑定 label()
- 为布尔表达式维护以下两者,且必须在调用前就已经声明好:
- 设定跳转点的函数:组合技
- newlabel():生成一个用于存放指令标号的新的临时变量 L,返回其地址,提前为代码占坑,用于之后回填
- label(L):将下一条三地址指令的标号存放到变量 L 地址中
- 画出代码块的控制流
- 代码结构图中非终结符对应的方框顶部若有导入箭头,调用 label() 函数
- 上一个代码框执行完不顺序执行下一个代码框时,生成一条显式跳转指令——插入 goto 语句
- 有自下而上的箭头时,设置对应 begin 用于 goto 跳转
6.3.2 布尔表达式
将 &&
、||
、!
翻译成跳转指令,通过跳转位置体现其含义;
由 6.3.1,已经得到当前布尔表达式的 B.true,B.false
此外针对如下冗余 goto 的情况,我们可以考虑精简上述 STD,将左边优化成右边
具体做法需要引入 fall 做为地址的一种值
1: if a < b goto 3 1: if False a < b goto 11
2: goto 11
3: some code 2: some code
6.4 回填
思想:每个跳转指令都有一个跳转地址;生成一个跳转指令时,先将其放入某个列表;同一列表内的指令具有相同目的跳转地址;等到能够确定正确的目标标号时,才去填充这些指令的目标标号
函数和全局变量:
- makelist(i):i 为某个跳转指令的标号,创建新列表并放入 i,返回指向列表的指针
- merge(p1,p2):将 p1 和 p2 指向的列表进行合并,返回合并列表的指针
- backpatch(p,i):回填,将 i 作为目标标号插入到 p 所指列表中的各指令中
- nextquad:全局变量,存储下一条生成语句的标号
- 可以插入 M 以存储该处指令标号,有 M→ε{M.quad=nextquad;}
- 可以插入 N 以在该处生成一个跳转指令,有 N→ε{N.nextlist=makelist(nextquad);gen(‘goto _′);}
构造方法:同样地画出代码块的控制流图,同样地去考虑每一个箭头:能填的填、能合并地合并
布尔表达式
为每个布尔表达式 B 都要维护综合属性:
- B.truelist:以 B 为真为条件跳转的跳转指令的标号 的序列
- B.falselist:以 B 为假为条件跳转的跳转指令的标号 的序列
- 构造 SDT
- 对于基础的布尔表达式 B,生成 goto 指令并生成初始的 truelist,falselist 存放之
- 对于布尔表达式的与或非,其 truelist,falselist 继承子语句的,顺带考虑可能的回填
控制语句
为每个控制语句 S 生成综合属性:
- S.nextlist:以 S 代码后的指令标号(S.next)为目的的跳转指令的标号 的序列
- 构造 SDT
- 根据流图来就行了,见下图
6.5 Switch 语句
6.6 过程调用
7 运行存储分配
7.1 存储组织
存储分配策略
- 静态存储分配:在编译时刻就可以确定大小的数据对象,在编译时刻就为它们分配存储空间
要尽可能多的进行静态分配,这些对象的地址可以被编译到目标代码中 - 动态存储分配:运行时刻,动态地分配数据对象的存储空间
- 栈式存储分配
- 堆式存储分配
活动记录
编译器通常以过程(或函数、方法)为单位分配存储空间
程序每次执行该过程,称为一次“活动”,分配的连续存储区称为“活动记录”
活动记录一般形式(按记录的栈底到栈顶的顺序)
- 实参:调用过程提供给被调用过程的参数
- 返回值:被调用过程返回给调用过程的值
- 控制链:指向调用者的活动记录
- 访问链:用来访问存放于其它活动记录中的非局部数据(静态链)
- 保存的机器状态:通常包括返回地址和一些寄存器中的内容
- 局部数据:在该过程中声明的数据
- 临时变量:比如表达式求值过程中产生的临时变量
7.2 静态存储分配
对于某些过程中的名字,这些名字的存储地址可以被编译到目标代码中
过程每次执行时,它的名字都绑定到同样的存储单元
7.3 栈式存储分配
维护活动记录的栈:当一个过程被调用时,该过程的活动记录被压入栈;当过程结束时,该活动记录被弹出栈
活动树
用来刻画运行时进入/离开各个活动的情况
每个结点对应于一个活动;根节点为 main
过程的活动;子结点对应于这次活动调用的各个过程的活动
活动记录的位置设计
- 在调用者和被调用者之间传递的值(参数、返回值)一般被放在被调用者的活动记录的开始位置,尽可能地靠近调用者的活动记录
- 固定长度的项(控制连、访问链、机器状态字)被放置在中间位置
- 在早期不知道大小的项(临时变量)被放置在活动记录的尾部
- 栈顶指针寄存器 top_sp 指向活动记录中局部数据开始的位置,以该位置作为基地址
调用、返回序列
过程调用和返回,需要管理活动记录栈,保存或恢复机器状态;
由调用者和被调用者分别执行一部分代码实现
- 调用序列(实现调用的代码段)
- 调用者:
计算实际参数的值;放入被调用者的对应字段
将返回地址(程序计数器的值)放到被调用者的机器状态字段中:将原来的 top_sp 值放到被调用者的控制链中
然后,增加 top_sp 的值,使其指向被调用者局部数据开始的位置 - 被调用者:
保存寄存器值和其它状态信息
初始化其局部数据并开始执行
- 调用者:
- 返回序列
- 被调用者:
将返回值放到与参数相邻的位置
使用控制链、机器状态字段中的信息,恢复 top_sp 和其它寄存器,然后跳转到由调用者放在机器状态字段中的返回地址 - 调用者:
调用者(仍然知道返回值相对于当前 top_sp 的位置)使用该返回值
- 被调用者:
变长数据的存储分配
在编译时刻不能确定大小的对象:将被分配在堆区;或者如果是过程的局部对象(只作用于这个过程,退出过程后不再使用),也可以分配在运行时刻栈中(可避免垃圾回收、减小开销)
7.4 非局部数据的访问
考虑一种语法:过程嵌套声明,也就是过程的声明具有嵌套关系,下文的“嵌套”都是指声明的嵌套,而非运行时过程调用者与被调用者的关系
非局部数据,不属于当前过程声明的局部数据:全局数据、外围过程定义的数据(即过程嵌套声明这种)
访问非局部数据:访问链(静态链)、display 表(嵌套层次显示表)
访问链
静态作用域规则:只要过程 b 的声明嵌套在过程 a 的声明中,过程 b 就可以访问过程 a 中声明的对象
访问链:指针,建立在相互嵌套的过程的活动记录之间,使得内嵌的过程可以访问外层过程中声明的对象;
建立访问链:
注意,声明的嵌套,和运行时调用产生的活动树,是两个概念
访问链指向其直接定义者的、在活动记录栈里最近的活动
具体而言:假设嵌套深度为 nx 的过程 x 调用嵌套深度为 ny 的过程 y,记作 x→y
- nx<ny 的情况(外层调用内层),根据语法,y 一定是直接被 x 定义的,必有 ny=nx+1
则在 调用代码序列 中增加一个步骤:在 y 的访问链中放置一个指向 x 的活动记录的指针 - nx=ny(同层调用),两者访问链相同,直接赋值即可
- nx>ny(内层调用外层),则顺着 x 的访问链找到直接定义 y 的那个即可——沿着链从 x 走 nx−ny+1 步就是
Display 表
是一个指针数组 d
对每个嵌套深度 i,d[i] 维护该深度下最新建立的过程的活动记录(的地址)(在运行栈中可能存在多个嵌套深度为 i 的过程的活动记录)
如果要访问某个嵌套深度为 i 的非局部名字 x,只要从 d[i] 指向的记录开始,沿着访问链找到第一个定义 x 的过程的活动记录
维护 display 表:
- 每个活动的调用、返回时都需要更新
- 调用某个嵌套深度为 i 的函数时,其访问链指向 d[i],d[i] 再指向此函数的地址
- 退出某个嵌套深度为 i 的函数前,将 d[i] 赋为此函数的访问链指向的地址
7.5 参数传递
7.6 符号表
存放标识符的属性信息,具体可能包括:
- 名称 (Name)
- 种属 (Kind),变量、数组、过程等,不同种属要存的属性不一样
- 类型 (Type),整型、实型、字符等
- 存储位置、长度
- 作用域
- 参数和返回值信息(对于过程、函数等)
单个符号表的组织方式:
- 基本属性,直接存放在符号表中
如种属、类型、地址(偏移地址 offset)、扩展属性指针 - 扩展属性,动态申请内存
对于多个过程,分别建立符号表,组织方式:
- 需要考虑过程间的嵌套关系(作用域信息,对于 c 语义,即花括号括出的代码块)、重名问题
- 对于嵌套定义,外围过程的符号表里有指向若干内部过程的序列,内部过程有指针指向外围过程(见下图的箭头)
- 为每个符号表维护表项的宽度之和(见下图每个表的右上角)
符号表建立(对于嵌套/被嵌套,用相互指针实现)
- 声明语句,若允许嵌套声明,则进入嵌套过程时,应暂时挂起外围过程的声明处理
- 函数:
- mktable(pre)
创建新表,返回新符号表的指针,传入外围过程的符号表指针 pre - addwidth(table,width)
将 table 指向的符号表中所有表项的宽度之和 width 记录在符号表的表头 - enter(table,name,type,offset)
在 table 指向的符号表中,为名字 name 建立一个新表项 - enterproc(table,name,newtable)
在 table 指向的符号表中,为过程 name 建立一条记录,newtable 指向过程 name 的符号表
- mktable(pre)
- 建立的同时,同步维护符号表指针栈 tblptr、对应偏移地址栈 offset(里面的每个元素,都是某个过程的当前偏移地址,tblptr 和 offset 是一一对应的;对于每个过程,内部局部变量的 offset 都是相对过程开头的位置,都是从 0 开始的;过程地址+offset 才是实际运行时的变量地址)
操作:
- 声明语句,对于遇到的某个标识符声明:查 + 填
先在本层的符号表里查询;若查到,报重复错误
否则登记新表项 - 执行语句:查
从该层符号表开始,查找符号信息;未找到则向指向外围的过程符号表查
display 表示
- display 表:记录下各块所在的层号,沿着该表可以找到当前正在分析的块的各个外层
- btab 表:块表,对每个块,成对记录 (lastpar,last),lastpar 指向本过程体中最后一个形参在 nametab 中的位置、last 指向本过程体中最后一个名字在 nametab 中的位置(两者都类似前向星)
- nametab 表:记录 name + link,记录符号名,link 指向同一过程体中定义的上一个名字在 nametab 中的位置(每个过程体在 nametab 中登记的第一个名字的 link 为 0)
8 代码优化
8.1 流图
基本块 (Basic Block),是最大的一组总是一起执行连续三地址指令序列,满足:
- 控制流只能从基本块的第一条指令进入该块
- 除了基本块的最后一条指令,控制流在离开基本块之前不会跳转或者停机
基本块划分方法:找到指令序列里的所有首指令,首指令左闭右开就是一个基本块;首指令:
- 指令序列的第一个三地址指令,是一个首指令
- 任意一个转移指令的目标指令,是一个首指令
- 紧跟在一个转移指令之后的指令,是一个首指令
流图 (Flow Graphs)
- 流图的每个结点是一个基本块
- 从基本块 B 到基本块 C 之间有一条边(称 B 是 C 的前驱 (predecessor),C 是 B 的后继(successor)),当且仅当基本块 C 的第一条指令可能紧跟在 B 的最后一条指令之后执行,可能情况为:
- 存在一条从 B 的结尾跳转到 C 的开头的跳转指令
- 按照原来的三地址指令序列中的顺序,C 紧跟在 B 之后,且 B 的结尾不存在无条件跳转指令
- 构造方法,按顺序找就行了
8.2 优化的分类
分类
- 针对中间代码:机器无关优化
- 针对目标代码:机器相关优化
- 单个基本块范围内的优化:局部代码优化
- 面向多个基本块的优化:全局代码优化
具体方法:
8.2.1 删除公共子表达式
对于某个表达式 x op y,先前已被计算过,且到现在其值不变,则称此次出现的表达式为“公共子表达式”
上一次出现在同一个基本块内的,称为局部公共子表达式;基本块之外,则称全局公共子表达式
- 对中间变量的临时赋值语句 t1=Exp,t2=Exp
删除该公共子表达式 t2=Exp,用前者表达式的左部 t1,代替此表达式左部 t2 在代码中的位置 - 对于不是临时赋值语句的(一般来说在代码优化前,都是这种情况)
为 Exp 新建临时变量赋值语句 u=Exp,并在之后使用 u 代替 Exp
8.2.2 删除无用代码
- 复制传播
- 复制语句:形如 x=y
- 常用的公共子表达式消除算法和其它一些优化算法会引入一些复制语句
- 在复制语句 x=y 之后尽可能地用 y 代替 x
- 复制传播给删除无用代码带来机会
无用代码/死代码 Dead-Code:其计算结果永远不会被使用的语句
- 常量传播/常量合并
- 如果在编译时刻推导出一个表达式的值是常量,就可以使用该常量来替代这个表达式
- 给删除无用代码带来机会
8.2.3 代码移动
处理那些对于某个循环,不管循环执行多少次都得到相同结果的表达式(循环不变计算,loop-invariant computation)
在进入循环之前就对它们求值
8.2.4 强度削弱
用较快的操作代替较慢的操作
- 归纳变量
对于一个变量 x,如果存在常数 c 使得每次 x 被赋值时,它的值总变化 c
比如循环内的、计算式和迭代次数有关的变量(以及若干这种的“步调一致”的变量,可将它们删为一个)
8.3 基本块的优化
局部优化技术,首先把一个基本块转换成为一个有向无环图
创建这么个 DAG 的过程,就是在模拟基本块内的计算情况,并用图结构(边+点+点上的运算符,每个节点都隐式地存储一个计算结果值)存储计算过程;至于定值变量表,它只是用来表示某个变量当前就等于某个节点的值而已
构建完成后,可以获知:
- 哪些变量的值在该基本块中赋值前被引用过:
在 DAG 中创建了叶结点的那些变量 - 确定哪些语句计算的值可以在基本块外被引用:
在 DAG 构造过程中创建的节点 N 的、在 DAG 构造结束时那些仍然是 N 的定值变量
转换方法:
- 基本块中每个语句,形如 x=y op z,都对应到(满射)某个标号为 op 的节点 N
- 每个节点 N 代表一个公共子表达式,它为某些变量定值:附加一个定值变量表,表中维护若干变量,表示这些变量的最后一次定值行为,是被此节点运算得到的
- 对应某个节点 N 的若干语句 s,⋯,都形如 ∗=y op z;N 的子节点是基本块中在 s 之前、最后一个对 s 所使用的运算分量进行定值的语句对应的结点。如果 s 的某个运算分量在基本块内没有在 s 之前被定值,则这个运算分量对应的子结点就是代表该运算分量初始值的叶结点(为区别起见,叶节点的定值变量表中的变量加上下脚标 0)
- 在为语句 x=y op z 构造结点 N 的时候,如果 x 已经在某结点 M 的定值变量表中,则从 M 的定值变量表中删除变量 x
然后查看是否存在健全(未被删除的)的 (y,op,z) 二叉结构,如果存在就把 x 加到这个已有的定值变量表里,否则新建一个节点 - 对于形如 x=a[i] 同理用二叉结构做,把 a 当成一个变量
- 而对于为数组元素赋值情况,形如 a[j]=y 的三地址指令,创建一个运算符为“ []= ”的结点,这个结点有 3 个子结点 a,j,y
- 该结点没有定值变量表
- 该结点的创建将杀死所有已经建立的、其值依赖于 a 的结点(因为此时这个节点内含的值已经不再确定)
杀死定义为,一个被杀死的结点不能再获得任何定值变量,即它不可能成为一个公共子表达式
转换后,对每个具有若干定值变量的节点,构造一个三地址语句来计算其中某个变量的值,且
- 倾向于把计算得到的结果赋给一个在基本块出口处活跃的变量(也就是在基本块外部被引用,如果没有全局活跃变量的信息作为依据,就要假设所有变量都在基本块出口处活跃,但是不包含编译器为处理表达式而生成的临时变量)
- 如果结点有多个附加的活跃变量,就必须引入复制语句,以便给每一个变量都赋予正确的值
用这个 DAG,对代码块内进行优化:
思路:只需要保证走出这个代码块的符号正常就行了,剩下里头的其他运算就扔掉
而走出这个代码块的符号,计算方法就是过一遍这个 DAG
- 删除根节点(无出边)中不是活跃的符号(活跃信息通过其他方式得来,删除此符号指的是删掉对这个符号定值的语句)
- 合并同个定值变量表的符号,保留其中一个即可,删掉剩下的
- 常量合并
8.4 数据流分析
获取有关数据如何沿着程序执行路径流动的相关信息
“把每个程序点和一个数据流值关联起来”
- 程序点:流图基本块中的位置,包括——
第一个语句之前、两个相邻语句之间、最后一个语句之后 - 如果有一个从基本块 B1 到基本块 B2 的边,那么 B2 的第一个语句之前的程序点可能紧跟在 B1 的最后一个语句的程序点之后
此外就是同个基本块内相邻的程序点 - 数据流:类似于各个变量的值域信息的集合,在之后讨论中会有具体含义
- 数据流分析的应用:
- 到达定值分析
- 活跃变量分析
- 可用表达式分析
- 数据流分析的思路:
- 语句 s 的数据流模式:
- IN[s]: 语句 s 之前的数据流值
- OUT[s]: 语句 s 之后的数据流值
- fs:语句 s 的传递函数 (transfer function),分别考虑前向和后向
前向:OUT[s]=fs(IN[s])
后向:IN[s]=fs(OUT[s])
- 语句之间的数据流模式:
- 基本块内:s1,…,si,si+1,⋯,sn:IN[si+1]=OUT[si]
- 基本块 B 的数据流模式
- IN[B]=IN[s1]
OUT[B]=OUT[sn] - fB:基本块 B 的传递函数
前向:OUT[B]=fB(IN[B]),fB=fsn⋅…⋅fs2⋅fs1
逆向:IN[B]=fB(OUT[B]),fB=fs1⋅fs2⋅…⋅fsn
- IN[B]=IN[s1]
- 语句 s 的数据流模式:
8.4.1 到达定值分析
定值
变量 x 的定值是(可能)将一个值赋给 x 的语句
到达定值
如果存在一条从紧跟在 x 的定值 d 后面的点到达某一程序点 p 的路径,而且在此路径上 d 没有被“杀死”(如果在此路径上有对变量 x 的其它定值 d′,则称定值 d 被定值 d′ “杀死”了),则称定值 d 到达程序点 p
即,在点 p 处使用的 x 的值可能就是由 d 最后赋予的
数据流——定值的集合
某个定值语句 d 能不能到达某个基本块 B 的开头:d∈?IN[B]
- 传递函数:
- 语句的传函,对于定值 d:u=v op w
- 该语句“生成”了一个对变量 u 的定值 d,并“杀死”了程序中其他所有对 u 的定值
- 传递函数 fd(x)=gend∪(x−killd)
gend={d} 由语句 d 生成的定值的集合
killd 由语句 d 杀死的定值的集合(程序中其他所有其它对 u 的定值)
- 基本块 B 的传函
- 传函 fB(x)=genB∪(x−killB)
killB=kill1∪kill2∪…∪killn
genB=genn∪(genn−1−killn)∪(genn−2−killn−1−killn)∪…∪(gen1−kill2−kill3−⋯−killn)
(前者生成的被后者杀死)说白了就是倒着扫一遍基本块,当前的语句 d:u=∗ 如果还没被杀死,就将 d 加入 gen,然后杀死在场所有 d′:u=∗ 的语句——都将它们加入 kill;当前 d 已被杀死就跳过
- 传函 fB(x)=genB∪(x−killB)
- 语句的传函,对于定值 d:u=v op w
- 求解数据流
- 数据流定义为
IN[B]:到达流图中基本块 B 的入口处的定值的集合
OUT[B]:到达流图中基本块 B 的出口处的定值的集合 - 方程:
OUT[ENTRY]=∅
IN[B]=∪P是B的一个前驱OUT[P](B≠ENTRY)
OUT[B]=fB(IN[B])=genB∪(IN[B]−killB) - 算法:不断迭代更新,直到某一次更新完后所有基本块的 OUT 不再变化
人工操作时,可以标注一下当前哪个 OUT 变了,如果变了才需要重新计算其后继;用 01 串表示比较方便——先把所有前继的串做一次或,然后扣掉 kill,再或上 gen
- 数据流定义为
引用-定值链(ud 链),应用
引用的、定值集合
列表,对于某个变量的每一次引用,到达该引用的所有定值 都在该列表中
- 如果块 B 中变量 a 的引用之前有 a 的定值
则该引用的 ud 链中只有 a 的最后一次定值 - 如果块 B 中变量 a 的引用之前没有 a 的定值
则该引用的 ud 链就是 IN[B] 中对 a 的定值的集合 - 应用:
- 循环不变计算的检测
如果循环中含有赋值 x=y+z,而 y,z 所有可能的定值都在循环外面(包括 y,z 是常数),那么 y+z 就是循环不变计算 - 检测变量是否未经定值就被引用
其实就是引用-定值链为空的情况
或者在所有的基本块之前、ENTRY 之后增加一个基本块,在里头对所有变量进行一次定值,然后对它们进行到达定值分析:如果某个定值在某个引用程序点还没被杀死,就说明存在未经定值就被引用 - 常量传播
如果对变量 x 的某次使用只有一个定值可以到达,并且该定值把一个常量赋给 x,那么可以简单地把 x 替换为该常量
- 循环不变计算的检测
8.4.2 活跃变量分析
考虑每个定值在之后是否被使用——不被使用的定值就是无用的
活跃变量
对于变量 x 和程序点 p,如果在流图中沿着从 p 开始的某条路径会引用变量 x 在 p 点的值(在这条路径上没被杀死),则称变量 x 在点 p 是活跃的,否则不活跃
数据流——活跃变量集合
某个基本块 B 结尾出去后,能不能到达一个对变量 x 的引用:x∈?OUT[B]
- 传递函数:
- 思路:一个基本块引用了传入的变量 a:在当前基本块内,存在一个最早的对 a 的引用,且在此之前没有对 a 的定值
- 考虑逆向数据流 IN[B]=fB(OUT[B])
fB(x)=useB∪(x−defB)
useB:在基本块 B 中引用,并且引用前在 B 中没有被定值的变量集合
defB:在基本块 B 中定值,并且定值前在 B 中没有被引用的变量集合这两者的计算,说白了就是顺着扫一遍,对于当前 a=b+∗,如果 b 没见过,就放入 use;如果 a 没见过,就放入 def;特别地若 a=b,显然是放入 use
- 求解数据流
- 此时数据流为:
IN[B]:在基本块 B 的入口处的活跃变量集合
OUT[B]:在基本块 B 的出口处的活跃变量集合 - 方程
IN[EXIT]=∅
OUT[B]=∪S是B的一个后继IN[S](B≠EXIT)
IN[B]=fB(OUT[B])=useB∪(OUT[B]−defB) - 算法:反复迭代,直到所有的 IN 不再变化
注意下图标红的两个箭头,B4 的 OUT 是从这俩的 IN 来的
- 此时数据流为:
定值-引用链(du 链),应用
定值的、引用集合
列表,对于变量 x 的一个定值 d,该定值所有能够到达的引用 u 的集合
- 如果 B 中 x 的定值 d 之后有 x 的第一个定值 d′
则 d 和 d′ 之间 x 的所有引用构成 d 的 du 链 - 如果 B 中 x 的定值 d 之后没有 x 的新的定值
则 B 中 d 之后 x 的所有引用、以及 OUT[B] 中 x 的所有引用构成 d 的 du 链 - 应用:
- 删除无用赋值
无用赋值:如果 x 在点 p 的定值,在基本块内所有后继点都不被引用,且在所在基本块出口之后又是不活跃的 - 为基本块分配寄存器
如果所有寄存器都被占用,并且还需要申请一个寄存器,则应该考虑使用已经存放了死亡值的寄存器,因为这个值不需要保存到内存
如果一个值在基本块结尾处是死的,就不必在结尾处保存这个值
- 删除无用赋值
8.4.3 可用表达式分析
可用表达式
直观理解,点 p 的 x op y,已经在之前被计算过,不需要重新计算;则称之前计算过的表达式在 p 这里是可用的
定义:如果从流图的首节点到达程序点 p 的每条路径都会对表达式 x op y 进行计算,并且从最后一个这样的计算到点 p 之间没有再次对 x 或 y 定值(从而保证表达式值不改变),那么表达式 x op y 在点 p 是可用的
数据流——表达式集合
一个表达式 x op y,在基本块的出口处是否可用——此时再算一次,还是不是原来的值?
-
传递函数
- 思路,对于基本块 B:
- 若 B 中有对 x op y 进行计算,且之后没有重新定值 x 或 y,则称 B 生成表达式 x op y
- 若 B 对 x 或者 y(可能)进行了定值,且以后没有重新计算 x op y,则称 B 杀死表达式 x op y
- fB(x)=e_genB∪(x−e_killB)
- e_genB:基本块 B 所生成的可用表达式的集合
- e_killB:基本块 B 所杀死的 U 中的可用表达式的集合
U:所有出现在程序中一个或多个语句的右部的表达式的全集
计算:
e_gen,e_kill 都初始化为空集,顺序扫描基本块的每个语句 z=x op y:- 把 x op y 从 e_killB 中删除、并加入到 e_genB
- 再从 e_genB 中删除和 z 相关的表达式
- 将 U 中所有和 z 相关的表达式、加入到 e_killB
- 特别地,若 z=x(或 y),执行 2, 3 会将 1 生成的 z op y 杀死)
- 特别地,若 z=x,不认为单个 x 是表达式,但是照样杀死带 z 的那些表达式
人工操作时,初始 e_gen,e_kill 都为空,扫一遍:每次看 z=x op y,将 x op y 加入 gen,然后把 z 标注在 kill 上并划掉 gen 里所有带 z 的(可能就把刚加入的 z op y 划掉了),重复下去...扫完后,对于 kill 里标注的每个 z,将所有带 z 的表达式中去掉还在 gen 的,放入 kill
如下图:B5:gen={}, kill={};
b=a+b:{a+b}, {b};
e=c-a:{a+b, c-a}, {b, e};
最后:{c-a}, {所有带 b 或 e 的表达式}B6:gen={}, kill={};
a=b *d:{b *d}, {a};
b=a-d:{b *d, a-d}, {a, b}
最后:{a-d},
- 思路,对于基本块 B:
-
求解数据流
- 数据流:
IN[B]:在 B 的入口处可用的 U 中的表达式集合
OUT[B]:在 B 的出口处可用的 U 中的表达式集合 - 方程
OUT[ENTRY]=∅
OUT[B]=U,B≠ENTRY
IN[B]=∩P是B的一个前驱OUT[P](B≠ENTRY)
OUT[B]=fB(IN[B])=e_genB∪(IN[B]−e_killB)
(注意是初始 OUT 都为 U!且是前继求交集!IN 里头先扣掉 kill,再加上 gen,得到的结果替换掉之前的 OUT) - 算法:不断迭代,直到 OUT 都不再变化
- 数据流:
应用:
- 消除全局公共子表达式
- 复制传播
在 x 的引用点 u 可以用 y 代替 x,条件为:语句 x=y 在引用点 u 处可用
即,从流图的首节点到达 u 的每条路径都存在复制语句 x=y,并且从最后一条复制语句 x=y 到点 u 之间没有再次对 x 或 y 定值
8.5 流图中的循环
支配结点 (Dominators)
从基本块上看,如果从流图的入口结点到结点 n 的每条路径都经过结点 d,则称结点 d 支配结点 n,记为 d dom n
显然,每个结点都支配它自己
显然,对任意两个节点,只可能存在单向的支配关系或不存在支配关系;
支配树:每个结点只支配它和它的后代结点。计算出每个节点的支配集合,并建树(似乎每个节点的父亲,是支配它的点集中,能够支配这个点集的那个节点)
数据流——支配节点集合
- 数据流为:
IN[B]:在基本块 B 入口处的、除了 B 外、支配 B 的结点集合
OUT[B]:在基本块 B 出口处的、支配 B 的结点集合 - 传递函数方程
OUT[ENTRY]={ENTRY}
IN[B]=∩P是B的一个前驱OUT[P](B≠ENTRY)
OUT[B]=fB(IN[B])=IN[B]∪B(B≠ENTRY) - 算法:除了 ENTRY 外每个基本块节点 B,OUT[B] 初值都赋为结点集全集 N;迭代,每次对除了 ENTRY 外的节点都执行传函方程,直到某一次执行后所有的 OUT 都不改变
人工实现:按这个方式直接做,可以通过定义验证
识别自然循环
- 回边
如果存在从结点 n 到 d 的有向边 n→d,且 d dom n,那么这条边称为回边 - 自然循环
一种易于优化的循环形式,具体定义为:- 有唯一的入口结点,称为首结点 (header)
可见首结点支配循环中的所有结点 - 循环中至少有一条返回首结点的路径,否则无法构成循环
- 有唯一的入口结点,称为首结点 (header)
- 识别自然循环:
- 给定一个回边 n→d,该回边的自然循环为:d,加上所有可以“不经过 d 而到达 n 的结点”。d 为该循环的首结点
算法:根据反边从 n 开始,找到所有直接或间接的 n 的前驱节点(注意 d 始终不在栈内)这么做的原理:根据定义,从 ENTRY 到达 n 必须经过 d,且前驱节点可以不通过 d 到达 n,那么 d 必然支配这个前驱节点,否则矛盾
- 自然循环的性质:
如果两个自然循环的首结点不相同,则这两个循环要么互不相交,要么一个完全包含/嵌入在另外一个里面
而对于首结点相同的两个自然循环,我们将它们合并
- 给定一个回边 n→d,该回边的自然循环为:d,加上所有可以“不经过 d 而到达 n 的结点”。d 为该循环的首结点
8.6 全局优化
- 删除全局公共子表达式
- 删除复制语句
- 代码移动
- 作用于递归变量的强度削弱
- 删除递归变量
8.6.1 删除全局公共子表达式 (8.2.1)
- 前置:可用表达式
如果从流图的首节点到达程序点 p 的每条路径都会对表达式 x op y 进行计算,并且从最后一个这样的计算到点 p 之间没有再次对 x 或 y 定值(从而保证表达式值不改变),那么表达式 x op y 在点 p 是可用的 - 前置:公共子表达式
对于某个表达式 x op y,先前已被计算过,且到现在其值不变,则称此次出现的表达式为“公共子表达式”
上一次出现在同一个基本块内的,称为局部公共子表达式;基本块之外,则称全局公共子表达式
思想:为每个表达式可用的位置,将前面所有路径上的可用表达式都用同一个临时变量记起来(这样就不用管表达式是从哪个路径来的),在这里使用这个临时变量
算法:
- 输入:带有可用表达式信息的流图
- 输出:修正后的流图
- 对于语句 s:z=x op y,如果 x op y 在 s 之前可用,那么执行:
- 从 s 逆向搜索,但不穿过任何计算了 x op y 的块,从而找到所有离 s 最近的计算了 x op y 的语句
- 建立新的临时变量 u
- 把步骤 1 中找到的语句 w=x op y,用下列语句代替:
u=x op y,w=u - 用 s:z=u 替代 s:z=x op y
8.6.2 删除复制语句
- 前置:可用表达式——这里只考虑复制语句的可用表达式
- 前置:定值-引用链(du 链)——定值的引用集合
列表,对于变量 x 的一个定值 d,该定值所有能够到达的引用 u 的集合
思想:对于复制语句 s:x=y,如果在 x 的所有引用点都可以用对 y 的引用代替对 x 的引用(复制传播),那么可以删除复制语句 x=y
条件是:该复制语句 s:x=y 在 u 点“可用”
算法:
- 输入:流图、du 链、各基本块 B 入口处的可用复制语句集合(IN[B]——可用表达式)
- 输出:修改后的流图
- 对于每个复制语句 x=y(考虑是否删除它),执行:
- 根据 du 链找出此定值 x=y 所能够到达的那些对 x 的引用
- 判断是否对于每个这样的引用、以及包含这个引用的基本块 B,x=y 都在 IN[B] 中,并且 B 中该引用的前面没有 x 或者 y 的定值
- 若满足第 2 步的条件,删除 x=y,且把步骤 1 中找到的对 x 的引用用 y 代替
8.6.3 代码移动
- 前置:引用-定值链(ud 链)——引用的定值集合
列表,对于变量的每一次引用,到达该引用的所有定值 都在该列表中 - 前置:自然循环
- 有唯一的入口结点——首结点,其支配循环中的所有结点
- 循环中至少有一条返回首结点的路径
检测循环不变计算语句
定义为,该语句的各运算分量或者是常数,或者其所有定值点都在循环 L 外部
- 输入:循环 L,每个三地址指令的 ud 链
- 输出:L 的循环不变计算语句
- 算法
- 重复执行如下步骤,直到某次没有新的语句可标记为“不变”为止
- 将下面这样的语句标记为“不变”:先前没有被标记过,且
- 各运算分量或者是常数;
- 或者,其所有定值点都在循环 L 外部;
- 或者,只有一个到达定值,该定值是循环中已经被标记为“不变”的语句
代码外提
- 前置首结点 (preheader)
- 创建一个称为“前置首结点”的新块,循环不变计算将被移至前置首结点
- 前置首结点的唯一后继是首结点,并且原来从循环 L 外到达 L 首结点的边,都改成进入前置首结点
- 从循环 L 里面到达首结点的边不变
- 定义循环的“出口节点”:若循环内的这个节点,存在出边到达循环外的节点
- 循环不变计算移动的条件,语句 s:x=y op z ,应同时满足:
- 支配条件:s 所在的基本块是 循环所有出口结点 的支配结点
从这个出口节点走出循环时,由于 s 所在的基本块本来不是必经之路、但因为代码外提变成了必经之路,从而产生错误 - 定值条件:循环中没有其它语句对 x 赋值
不然就不叫不变了 - 引用条件:循环中对 x 的引用,仅由 s 到达
比如循环内在 s 之前使用了对 x 的引用,则提出 s 会使第一次循环时产生错误(本来第一次引用的 x 是计算 x=y op z 之前的值)
- 支配条件:s 所在的基本块是 循环所有出口结点 的支配结点
- 算法
- 输入:循环 L、ud 链、支配结点信息
- 输出:修改后的循环
- 方法:
- 寻找循环不变计算(见上)
- 检查这个循环不变计算是否满足上面的三个条件(支配、定值、引用)
- 按照循环不变计算找出的次序,把所找到的满足上述条件的循环不变计算外提到前置首结点中
如果循环不变计算语句中,有分量在循环中定值,只有将定值点外提后,该循环不变计算才可以外提
人工操作:对这个循环,首先找到一个块,它得是所有出口节点的必经之路(支配条件);然后检测这个块里头的一个语句 x=∗:
- ∗ 只能出现在循环外头或者为常数(循环不变计算,可能连锁反应)
- x 不能在循环内的其他地方被定值(定值条件)
- x=∗ 得是循环里头所有对 x 的引用的必经之路(引用条件)
8.6.4 作用于归纳变量的强度削弱
归纳变量
- 称变量 x 为归纳变量,如果存在一个正的或负的常量 c,使得每次 x 被赋值时,它的值总是增加 c
- 基本归纳变量:如果循环 L 中的变量 i 只有形如 i=i+c 的定值(c 是常量),则称 i 为循环 L 的基本归纳变量
- 归纳变量:如果 j=c×i+d,其中 i 是基本归纳变量,c,d 是常量,则 j 也是一个归纳变量(每次变化 c×ci),称 j 属于 i 族
特别地,称基本归纳变量 i 属于它自己的族 - 于是,每个归纳变量都关联一个三元组
例如 j=c×i+d,与 j 相关联的三元组是 (i,c,d)
i 本身关联于 (i,1,0)
检测归纳变量
- 输入:带有循环不变计算信息和到达定值信息的循环 L
- 输出:一组归纳变量
- 算法
- 先找出所有基本归纳变量;用到循环不变计算信息。与每个基本归纳变量 i 相关联的三元组是 (i,1,0)
- 寻找 L 中只有一次定值的变量 k,形如 k=c′j+d′,c′,d′ 是常量,j 是基本的或非基本的归纳变量
- 如果 j 是基本归纳变量,那么 k 属于 j 族,对应三元组为 (j,c′,d′)
- 如果 j 不是基本归纳变量,假设其三元组为 (i,c,d);则 k 的三元组为 (i,c′c,c′d+d′);
此时我们还要求(从而保证 j 三元组中的 i 就等于 k 定值时的 i):- 循环 L 中对 j 的唯一定值和对 k 的定值之间没有对 i 的定值
- 循环 L 外没有 j 的定值可以到达 k
作用于归纳变量的强度削弱
- 输入:带有到达定值信息和已计算出的归纳变量族的循环 L
- 输出:修改后的循环
- 算法:对于每个基本归纳变量 i,对其族中的每个归纳变量 j:(i,c,d),执行下列步骤:
- 建立新的临时变量 t。如果变量 j1 和 j2 具有相同的三元组,则只为它们建立一个新变量
- 在前置首结点(上文代码移动提到过)的末尾,添加语句 t=c∗i,t=t+d,使得在循环开始前,就有 t=c∗i+d=j
- 在 L 中紧跟定值 i=i+n 之后,添加 t=t+c∗n。将 t 放入 i 族,其三元组为 (i,c,d)
- 用 j=t 代替对 j 的赋值
8.6.5 归纳变量的删除
- 见上图,对于在 8.6.4 强度削弱算法中引入的复制语句 j=t,如果在归纳变量 j 的所有引用点都可以用对 t 的引用代替对 j 的引用,并且 j 在循环的出口处不活跃,则可以删除复制语句 j=t
- 此外,有些归纳变量的作用只是用于测试(判断语句)。如果可以用对其它归纳变量的测试代替对这种归纳变量的测试,那么可以删除这种归纳变量
- 尽量使用归纳变量(临时变量)替换基本归纳变量,大概是因为临时变量总是存在寄存器或者读取比较快,而基本归纳变量就不一定了
- 对于仅用于测试的基本归纳变量 i,取 i 族的某个归纳变量 j 替换之(假设 c>0)
- 比如 (relop i x B) 变成 (relop j (cx+d) B),尽量用简单的 c,d,比如 c=1 或 d=0
- 比如 (relop i1 i2 B) 变成 (relop j1 j2 B),若 j1(i1,c,d),j2(i2,c,d)
- 此时如果归纳变量 i 不再被引用,那么可以删除和它相关的指令
9 代码生成
9.1 代码生成器的主要任务
- 指令选择:中间表示 (IR) 语句 → 目标机指令
- 寄存器分配(allocation)和指派(assignment):
把哪个值放在哪个寄存器中 - 指令排序:按照什么顺序来安排指令的执行
9.2 设定:简单目标机模型
三地址机器模型,设定:
- 具有加载、保存、运算、跳转等操作
- 内存按字节寻址
- n 个通用寄存器 R0,R1,⋯,Rn−1
- 假设所有的运算分量都是整数
- 指令之间可能有一个标号(用于指令寻址)
指令类型:
- 加载指令
LD dst, addr
例如LD r, x
,LD r1, r2
- 保存指令
ST x, r
- 运算指令
OP dst, src1, src2
- 无条件跳转指令
BR L
- 条件跳转指令
Bcond r, L
寻址模式
- 变量名
a
值为 a 的值 a(r)
,a
是一个变量,寄存器r
中存放的是一个地址
值为 content (a + contents(r) )
比如数组访问:a
是数组的基地址,r
中存放数组元素的偏移地址c(r)
,c
是一个整数,寄存器r
中存放的是一个地址
值为 content (c + contents(r) )
比如用于沿指针取值*r
在寄存器r
的内容、所表示的位置上、存放一个内存位置
值为 content (content (content(r) ) )*c(r)
在寄存器r
中内容加上c
后、所表示的位置上、存放一个内存位置
值为 content (content (c + content(r) ) )#c
:立即数
9.3 指令选择
分别考虑每种三地址语句对应的指令
- 运算语句
例如 x=y−zLD R1 , y // R1 = y LD R2 , z // R2 = z SUB R1 , R1 , R2 // R1 = R1 - R2 ST x , R1 // x = R1
- 数组寻址
假设 a 是一个实数数组,每个实数占 8 个字节
三地址语句:- b=a[i] 对应指令
LD R1 , i // R1 = i MUL R1 , R1 , 8 // R1=R1 * 8 LD R2 , a(R1) // R2=contents (a + contents(R1) ) ST b , R2 // b = R2
- a[i]=b
- b=a[i] 对应指令
- 指针寻址
- x=∗p
LD R1 , p // R1 = p LD R2 , 0(R1) // R2 = contents (0 + contents(R1) ) ST x , R2 // x = R2
- ∗p=y
- x=∗p
- 条件跳转
- if x<y goto L
LD R1 , x // R1 = x LD R2 , y // R2 = y SUB R1 , R1 , R2 // R1 = R1 - R2 BLTZ R1 , M // if R1 < 0 jumpto M
- if x<y goto L
- 过程调用和返回
- 使用静态内存分配
- 为每个调用函数 callee 在地址空间中指定一个“静态区”,存储其“活动记录”,起始位置为 callee.staticArea;callee 的目标代码在代码区中的起始位置为 callee.codeArea
- call callee
ST callee.staticArea, #here+20 // 将返回地址保存到 callee 的活动记录在静态区中的起始位置 BR callee.codeArea // 跳转到目标代码在代码区中的起始位置)
- return
BR *callee.staticArea
- 使用栈式内存分配
- 在栈上存储活动记录;对递归栈的每一个函数的活动记录,在栈上依次为连续一块,且返回地址存在该块的最底下,其他东西往上叠就是了;用
SP
指向当前函数的记录的最底部 - call callee
// 跨过调用者的活动记录,来到栈顶,也就是被调用者活动记录的第一行 ADD SP, SP, #caller.recordsize // 将跳转代码位置 here 的下一条语句地址 #here+16 存入被调用者活动记录的第一行,作为返回地址 ST 0(SP), #here+16 // 跳转 BR callee.codeArea
- return
// 被调用者,跳转到被调用者活动记录的第一行存储的、返回地址 `BR *0(SP)` // 调用者 `SUB SP, SP, #caller.recordsize`
- 在栈上存储活动记录;对递归栈的每一个函数的活动记录,在栈上依次为连续一块,且返回地址存在该块的最底下,其他东西往上叠就是了;用
- 使用静态内存分配
9.4 寄存器选择
对每个形如 x=y op z 的三地址指令 I,执行如下动作
- 调用函数 getReg(I) 来为 x,y,z 选择寄存器,把这些寄存器称为 Rx,Ry,Rz
getReg(I) 具体不表 - 如果 Ry 中存放的不是 y,则生成指令
LD Ry, y′
,其中y′
是存放y
的内存位置之一,对 z 类似的 - 生成目标指令
OP Rx, Ry, Rz
实现上述功能,需要维护:
- 寄存器描述符 (register descriptor)
为每个寄存器,记录当前存放的是 哪些 变量的值(“哪些”:比如对 x=y 的优化会用到) - 地址描述符 (address descriptor)
为每个名字,记录当前值存放在哪些位置
该位置可能是寄存器、栈单元、内存地址或者是它们的某个集合
这些信息可以存放在该变量名对应的符号表条目中 - 维护方式:
-
当代码生成算法生成 加载、保存和其他指令 时,它必须同时更新寄存器和地址描述符
总之就是把之前有的现在没有的、之前没有的现在有的给改正过来LD R, x
:- 修改
R
的寄存器描述符,使之只包含x
- 修改
x
的地址描述符,把R
作为新增位置加入到x
的位置集合中 - 从任何不同于
x
的地址描述符中删除R
- 修改
OP Rx, Ry, Rz
:...ST x, R
:...- 特别地,对于中间代码 x=y,若其生成代码
LD Ry y
:...
-
基本块的收尾处理——抛弃临时变量,保存活跃变量
在基本块结束之前,基本块中使用的变量可能仅存放在某个寄存器中:- 如果这个变量是一个只在基本块内部使用的临时变量,当基本块结束时,可以忘记这些临时变量的值并假设这些寄存器是空的
- 对于一个在基本块的出口处可能活跃的变量 x,如果它的地址描述符表明它的值没有存放在 x 的内存位置上,则生成指令
ST x, R
(R
是在基本块结尾处存放x
值的寄存器)
-
9.5 窥孔优化
- 窥孔 (peephole) 是程序上的一个小的滑动窗口
- 窥孔优化:优化时,检查目标指令的一个滑动窗口/窥孔,并且只要有可能就在窥孔内用更快或更短的指令来替换窗口中的指令序列
- 也可以在中间代码生成之后直接应用窥孔优化来提高中间表示形式的质量
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· winform 绘制太阳,地球,月球 运作规律
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· AI与.NET技术实操系列(五):向量存储与相似性搜索在 .NET 中的实现
· 超详细:普通电脑也行Windows部署deepseek R1训练数据并当服务器共享给他人