Intro
什么是编译器(What is a Compiler)
- 编译器是将Source Language(源语言)翻译为Target Language(目标语言)的程序
编译器通常较大,大概是10000至1000000行代码
如何学习 (How to Learn)
理论部分
- Techniques
- Data Structures
- Algoirthms
实验部分
- 实现SysY Compiler
参考书是虎书,使用Tiger语言,考试的时候要求能够读懂
模块与接口 (Modules and Interfaces)
使用多个模块和接口
- 多个阶段使用多个接口进行连接,接口描述了多个阶段(接口)时间如何交换信息
- 之所以将编译器划分为多个模块(阶段)是为了提高效率,也方便了模块的复用
各个阶段(章节梗概)
Lexical Analysis 词法分析
Overview
编译过程
- 首先分析源语言,将其分解,最后用另外一种方法将其重组
在第一个分析阶段,又包含以下步骤:
- 词法分析(Lexical Analysis):将源文件分解为词素(tokens)
- 语法分析(Syntactic Analysis):分析程序的结构
- 语义分析(Semantic Analysis):进行类型检查(比如赋值),将值与定义联系起来
lexical analysis
大致过程是:
- 读入一串字符
- (使用一个函数getToken()来获取token)生成一串记号(tokens)(流)
- 与此同时去除空格和注释
注意我们接下来使用形式化方法来研究
词元(Lexical Token)
概念
token
- token就是一个字符串序列
- token可能是无限的,但是token的类型是有限的
Token Example
- 给定一个程序:
float match0(char *s) /* find a zero */
{ if (!strncmp(s, "0.0", 3))
return 0.;
}- 词法分析的结果:返回了以下token流:
FLOAT ID(match0) LPAREN CHAR STAR ID(s)
RPAREN LBRACE IF LPAREN BANG
ID(strncmp) LPAREN ID(s) COMMA STRING(0.0)
COMMA NUM(3) RPAREN RPAREN RETURN
REAL(0.0) SEMI RBRACE EOF- 有些token与它的值相关,比如identifier和literal,保留词并不是identifier token
non-tokens
- non-tokens并不会进入词法分析阶段,而是被编译器忽略掉,它们包括:
词法规则
- 我们知道了如何表示一个token,但是还是需要知道如何将输入的源语言转换为token流,这个任务由词法分析器实现
想要实现一个词法分析器,就首先要明白转换的规则:
- 使用自然语言描述,过于繁琐
- 使用正则表达式描述词法规则,更加易读,还可以转为DFA进行实现:
正则表达式(Regular Expression)
相关概念
- 有关DFA和RE请见TCS的笔记
Regular Expression由以下规则定义
- Atomic:空串 ε 对应语言 L(ε) = ∅, 单个字符 a 对应语言 L(a) = {a}(最小单元)
Composite: (|相当于∪)
Alternation:
Concatenation:
Repetitions:
- 计算优先级:
- 所有的复杂的正则表达式都是通过组合这些基本元素得到的(Kleene Closure)
有自动机可以接受的语言是正则的
引入简写
简写
- 将连接符号和空串符号ε 忽略,比如(a|)=(a|ε),a·b=ab
- 定义运算优先级:见定义,比如ab|c=(a · b)|c
- 中括号:[abcd]=(a|b|c|d)
- 连续字符:[b-g]=[bcdefg];[b-gM-Qkr]=[bcdefgMNOPQRkr]
- 问号和加号:M?=(M|ε);M+=(M · M*)
- 句号.:除了newline以外的任意一个字符
- 特殊符号:双引号:字面意思上的字符串,比如“abc”=“abc”
词法规范必须是完善的
现在我们试着给出一些词法规则,以及对应的正则表达式:
Token Example
注意
- 我们的规则必须是完善的,这意味着我们的规则必须对所有的输入都能有所回应,并且对于同一个输入不能存在两个同时成立的输出
- 遗憾的是,上面的例子中的词法规则并没有做到这一点,由此引出二义性问题
- 解决二义性问题:如何分辨if是identifier还是保留词?if9是identifier还是保留词?
两个规则
引入规则解决二义性问题 最长匹配:每次将输入中最长的,能够匹配以上规则的子串作为token,这样if9就会优先级比if高
规则优先级:对于每个子串,规定如果先匹配了IF,那就直接返回IF,不再匹配ID,这样“if”优先被视为保留词
有限自动机(Finite automata)
定义有限自动机 Finite automata
有限自动机
- 使用5组符号(K,Σ,δ,s,F)表示自动机
- K:有限的状态
- Σ:字母表
- s ∈ K:初始状态
- F ⊆ K:最终状态
- δ:K x Σ -> k的转移函数(给定当前状态和输入,确定下一个状态)
初始状态和最终状态可以是同一个
- 使用状态图表示自动机

- 使用一个纸带,上面是输入的字符串,每次取出第一个字符,根据当前状态和字符查找转移函数,就能够控制自动机的状态
- 带箭头的:为初始状态(q0)
- 两个环的:最终状态(q2)
- 箭头上的数字:表示转移条件(读到的字符)
- 将多条有一个字符的边合并为一条有多个字符的边
确定性有限自动机 DFA (Deterministic Finite Automata)
- 特点:每一步的转移都是确定的,执行逻辑:输入一个字符串,从初始状态,每次取出第一个字符,根据当前状态和字符查找转移函数,得到下一个状态,直到字符串为空
表示当前状态 configuration
- C = (q,ω),q为当前状态,ω为当前剩余的字符串
表示状态的转移 Yield
可一步转移到:yields in one step
- 记为
- 即从当前状态再读取一个字符,可以转移到下一个状态
可转移到:yields
- 记为
自动机接受 Accepts字符串
- 当我们从初始状态s开始,经过由字符串ω 规定的转移路径,最终可能到达最终状态q,也可能到达非最终状态
- 即可以从初始状态s经过由字符串ω 规定的转移路径,最终转到最终状态q,且剩余的字符串为空
- 否则,如果没有边可以走,或者停在了non-final状态,则认为拒绝 Reject该字符串
自动机对应的语言
,也就是说,M accept L的条件有两个:
- M接受所有L(M)中的字符串
- 此外,M不接受L(M)以外的字符串
- 接受语言的唯一性:很好证明
将正则表达式转为DFA
- 暂时不介绍具体规则,详细规则见NFA:
下图给出了6种rule对应的DFA
图 2 6种rule对应的DFA 
合并后一个DFA可以识别多种token,合并方法见后
图 3 合并DFA,每一个final state都有对应的token类型 
注意可以注意到,上面的DFA似乎并没有体现最长匹配和优先级的特性,也没有实现我们规则的完备性,因此我们需要进一步优化,并且,我们还没有给出正则表达式转DFA的具体方法,之后会给出
DFA实现
使用表实现DFA,构建一个状态转移矩阵,特殊状态为0(“dead” state),代表没有这个转移边
图 4 状态转移矩阵实现DFA 
- 通过新增一个状态0,我们解决了输入不合规的问题
DFA解决最长匹配问题
- 下面解决最长匹配问题:如何辨别“if8”和“if “?
- 使用两个变量:last final:记录目前遇到的最近的final state;Position of last final:记录遇到last final时字符在输入中的位置
DFA运行中,我们即使遇到了final state也不停下,而是更新两个变量,直到遇到不匹配的字符(即进入了dead state,遇到了错误),那么回退到上一次匹配的地方(Position of last final),返回上一次匹配的结果(last final),继续运行
黑框内为当前读的字符,红框内为上一个final state
正则表达式中存在空字符(e-transition),但是DFA中没有对应的状态,因此需要特殊处理,我们首先要引入NFA,然后通过最小化将其转为DFA,于是之前的转化图变为以下的样子:
非确定性有限自动机(Non-Deterministic Finite Automata)
概念:NFA
- 特点:每一步的转移可能不确定,即使得到了当前状态和下一个接收的symbol,也不一定能确定下一个状态
NFA和DFA的区别:
- 同一个状态,同一个条件,可以有多个转移方案:对于一个输入,NFA 可以有多种路线(分支路线),有些会被接受,有些会被拒绝,但只要有一种路线能够接受,就认为 NFA 接受该输入,(反之所有的都拒绝了,则认为NFA不接受该输入)
因此,我们不使用函数δ而是大写的Δ来表示状态的转移,这种转移不是一个函数而是一个关系,称为transation relation:
- 上式中读入的字符可以是空字符e,意味着可以有 e-transition,即不消耗字符的转移
“一种理解方式:NFA 可以猜测该往哪里转移,且总能猜对,NFA只是纯数学的定义,但是不妨碍我们理解其概念,比如使用并行的思想来理解NFA的概念。 “
将正则表达式转为NFA
正则表达式由各种字符和操作符构成,而NFA由状态和转移边构成,我们厘清两者之间的关系,以及状态图的操作规则:
图 5 正则表达式转NFA的规则 
通过以上的规则,我们可以顺利地将之前的6个rule转化为一个NFA,具体的过程省略
图 6 上图展示了4个rule合并后对应的NFA,每个NFA的tail合并入一个新的start state 
NFA转DFA
在计算理论中,我们已经学会通过等价状态来将NFA转为DFA,具体过程如下:
NFA转DFA(TCS中的方法)
转换思路:首先,DFA本身就满足NFA的性质,这里只介绍从NFA到DFA的转换方法:
- NFA 接收一个字符,会有多个转移方案,所有可达的下一状态合在一起的集合构成 DFA 的一个状态,如此我们通过模拟所有的可能保证确定性
- 即 DFA 的状态是 NFA 的状态的幂集,结束状态是包含 NFA 的结束状态的 DFA 状态
- e-transition 也要考虑,且不算在字符数里
具体的转换方法:
- 定义
- 即E(q)是q可以通过e-transition 到达的状态集合(ε闭包)
- 解释:将NFA的状态合并为DFA的状态,在确定下一步时,对于当前状态(这个状态包含了一个或多个NFA状态)中的每一个NFA状态,根据输入和NFA的转移函数Δ ,找到对应的下一个NFA状态,再合并所有的NFA状态
在本节课中学习的具体过程为:
NFA转DFA(具体方法)
获取闭包:
- S:初始状态
- edge(s,ε):NFA状态s通过e-transition能够到达的状态集合(为了减少搜索的开销,可以事先计算所有NFA状态接收任意字符能够达到的状态集合,那么实际算法中查表edge(s,c)即可)
T:S的e-closure
Algorithm:计算S的闭包 T ← S
repeati
T` ← T
T <- T` ∪ (edge(T,ε))d
until T = T`- 这种算法类似于一个DFS/BFS遍历,将所有能不消耗字符串到达的状态都加入到T中 由于只有有限个NFA状态,所以也只会有有限多个DFA状态,以上的算法一定能够停止
计算获取接收某个字符后的状态:
- d:当前的DFA状态,包含数个NFA状态
- c:读到的字符
- DFAedge(d,c):d状态消耗字符c能够到达的下一个DFA状态,它也是NFA状态的集合(相当于一个状态转移函数)
- (1)动态遍历DFA状态中的所有NFA状态s,(2)计算其接收c后可能到达的NFA状态集合的闭包,(3)再合并所有状态,得到DFA状态d的接收c后可能到达的状态集合
连续接收字符的过程
Algorithm:连续接收字符 d ← closure({}) 代表了初始的DFA状态
for i from 1 to k doi
d ← DFAedge(d,)d- 每一次做这种赋值,就代表了将DFAedge(d,)作为一个新的DFA状态加入到d中,同时标记一条新的标签为c_i的边
使用伪代码表示构建过程:
- states[]用于存储DFA的状态,下标为DFA状态编号,元素为DFA状态(NFA状态的集合)
- trans[][]用于存储DFA的转移边,下标为DFA状态的编号+字符,元素为DFA状态的编号
- p:当前的DFA状态的下标,用于计数可达状态的数量
Algorithm:构建过程 states[0] ← {};
states[1] ← closure({s1 })
p ← 1; j ← 0
while j ≤ pi
foreach c ∈ $i
e ← DFAedge(states[j], c) 对于每一个字符,计算对应的下一个DFA状态
if e = states[i] for some i ≤ p 重复了,说明已存在该状态i
then trans[ j, c] ← i 记录转移边d
else p ← p + 1 新状态,加入状态集,记录转移边i
states[ p] ← e
trans[ j, c] ← pdd
j ← j + 1
注意如果DFA包含一个及以上的NFA终止状态,那么我们认为该DFA到达终止状态,到达最终状态时,这个DFA中可能包含多个NFA状态,此时我们根据优先级确定输出的token类型
练习题


DFA的简化
- 为了简化状态转移矩阵,我们可以合并一些等价的状态,获得最小化的DFA
等价状态
状态等价 2 states s1 and s2 are equivalent:
- the machine starting in s1 accepts the same language as the machine starting in s2
- 也就是说从s1和s2开始的两个DFA接收相同的语言
刚刚获得的DFA
图 9 在刚刚获得的DFA中,{11,12,13}和{10,11,13,15}是等价的,{5,6,8,15}和{6,7,8}也是等价的 
如何判定状态等价
等价状态的充分条件
充分条件:s1 and s2 are equivalent if they are:
- both final or both nonfinal and,
- for any symbol c, trans[s1, c] = trans[s2, c];
当然,上面的条件并不能覆盖所有的情况,我们需要其他的更加general的方法
从反面出发:
可区分(distinguish)
- We say that string x distinguishes state s from state t if exactly one of the states reached from s and t by following the path with label x is a fianl state.
- State s is distinguishable from state t if there is some string that distinguishes them.
- 也就是说,如果状态s接收字符串x而t不接收,那么s和t是可区分的
DFA最小化算法
通过判定不等价得到所有等价状态的集合
- base case:使用空串区分所有终止/非终止状态
- inductive step:对于两个可区分状态s和t,如果它们接受同一个字符a,得到两个状态s‘和t‘,那么s和t也是可区分的
- 就这样,我们能够将所有可区分的状态分组,组内的状态都是等价的,最后每一组选出一个代表,重建DFA
伪代码:
Algorithm:DFA最小化 let ∏new = ∏
for each group G in ∏:i
partition G into subgroups such that:i
two states s and t are in the same subgroup iff:
for all input symbols a, states s and t have transitions on a to states in the same group of ∏d
replace G in ∏new by the set of all subgroups formed
If ∏new = ∏:i
∏final = ∏
breakd
elsei
∏ = ∏new- 简而言之,对于每一个group,对于每个字符输入,检查其中每一个状态的跳转,如果跳转到不同的group就说明group中出了“叛徒”,有状态是非等价的
- 最后,从∏final的每个group中选择一个代表,重构DFA
化简DFA

注意 标识不同token的final state是不等价的,因此在初始化时,除了将final states和non-final states划分,也需要将识别不同token的final state单独分组,即∏={K-F ,F1,F2,…,Fk}
词法分析器的生成器(Lex)
概念
- Lex是一个Lexer的生成器,给定词法的正则表达式,就能够写出一个识别正则表达式的C程序,这个程序能够读取输入流,生成token流,并输出token流
The lexical analyzer output by Lex contains C source code defining a procedure yylex
- operates like a getToken procedure
- a table-driven implementation of a DFA corresponding to the regular expressions of the input file
- The most popular version of Lex is called flex (Fast Lex)
代码分析
- 假设待解析的正则表达式存储在为a.l文件中
Parsing 语法分析
Overview
语法分析
- 语法(Syntax)就是words组合形成phrase/clause/sentences的规则

作用
- 生成语法分析树,规定运算方向就可以基于parse tree对表达式进行求值

构造语法分析器
- 描述语法:上下文无关文法(context-free grammar, CFG)
- 构造基于自顶向下(Top-down)/自底向上(Bottom-up)分析的语法分析器
上下文无关文法(Context-free grammar)
概念
- Parser接收lexer生成的token流,然而并非所有的token都是程序的一部分,首先Parser需要区分合法和非法的程序
- 正则语言是最弱的形式化语言,正则语言无法识别递归结构,然而递归结构在编程语言中非常常见,我们就需要更加强大的语言描述程序的结构
Context-free grammars由以下规则定义(TCS中的定义)
A context-free grammar is a 4-tuple
- ,从非终结符得到一串新的字符,这个规则称为产生式
比如匹配括号
- S -> (S)
- S -> ε
- 方便起见,更多使用箭头来表示关系,比如
- 一步(derive in one step):
- 多步:(derive in multiple steps):
Context-free Language
- grammar G 生成的语言
- 同样也可以说G generates L(G)
- CFG生成的语言之所以称为上下文无关语言,是因为在替换时只需要关心左边,不用关心其上下文
- 如果生成规则右边没有终止符号,这就意味着字符串的生成永远不会停止,对应的L = ∅
语法分析器的生成规则
- 为了判定字符串的合法性,我们引入推导Derivation的概念
推导
- A derivation: start with the start symbol S, then repeatedly replace any non-terminal by one of its right-hand sides
- 从起始符号出发,不断应用产生式得到一串字符串的过程,这些字符中并不一定全部都是终止符号
- 最左推导(leftmost derivation):每次选择最左边的非终结符号进行推导
- 最右推导(rightmost derivation):每次选择最右边的非终结符号进行推导
四则运算的推导
- E -> E * E
- E -> E / E
- E -> E + E
- E -> E - E
- E -> id
- E -> num
- E -> (E)
使用分析树表示推导过程
一个Parse Tree有以下特征:
Parse Tree
A parse tree has
- Terminals at the leaves
- Non-terminals at the interior nodes
- An in-order traversal of the leaves is the original input,可以用于恢复原始的输入字符串
- The parse tree shows the association of operations, the input string does not.
正如词法规则的二义性,当对于某个字符串,parse tree有两种生成方式时,我们说这个grammar是ambiguous有歧义(二义)的,比如 ,可以先乘后加,也可以先加后乘,对应两种结构的parse tree
注意 一定是Parse Tree有两种,而不是推导有两种
- 分析树还暗含了计算的顺序(结合性),我们计算的时候自然而然会优先计算比较低的节点
- 如果生成是唯一的,那么对应的parse tree就是唯一的
歧义
- 之前我们已经展示了,在有二义性的grammar中,同一个字符串可以被解析为两种parse tree(虽然它们都可以被恢复为同样的原始字符串)
而为了将有二义性的文法转换为没有二义性的文法,提出的方法:
- 为了实现优先级,引入新的非终结符号,让乘法只在加法之后产生,非终结符的内部形成了优先级,(左结合)让左边的加法先于右边出现
- 为了实现左结合,一旦出现了加减乘除,希望乘除运算离根节点最远,而全是加法时,左边的加离根节点最远
四则运算的推导
- 引入了T(term),只有加法全部产生了才会乘法产生
- 同理,引入了F(factor),只有乘法全部产生了才会有id和num产生
对应的parse tree:
如何保护了优先级?
- 保证了乘除法离根节点最远,而加减法离根节点最近
如何保证了结合性?
- E -> E + T,而不是E -> T + E,因为产生式右边的E是在最左边的,因此每一次都生成的是最右边的加号,这意味着最左边的加号离根节点最远
- 这种语法形式称为左递归,因为每次左边的非终结符不变,我们在之后还需要消除左递归
文件结束符(EOF)
- $称为文件结束符,它是所有输入的最后一个符号
- 新增规则:E -> E$
总结 光知道语法规则是不够的,为了获得Parse Tree,我们还需要能够根据(语法规则+输入串)构建Parse Tree的算法(机器),即Parser:
- Universal Parser:任意的CFG都能生成分析器,然而非常低效,因为经常需要回溯
- Top-Down Parser:从根节点向叶子结点构建
- Bottom-Up Parser:从叶子结点向根结点构建
- 以上三种Parser,都是从左往右读输入串,一次读取一个token,
- 虽然以上的三种parser都只能构建grammar的子集,但是对于我们所用的现代编程语言而言已经够用了
- 一般手工生成LL grammar,机器生成LR grammar(相关概念见后)
从上到下分析(Top-Down Parsing)
Top-Down Parsing的过程就是为输入寻找最左推导的过程
从一个例子出发
寻找最左推导
grammar:
- S -> if E then S else S
- S -> begin S L
- S -> print E
- L -> end
- L -> ; S L
- E -> num = num
- string:begin print num = num end
最左推导:
- 读到begin,选第2条
- 读到print,选第3条
- 读到num,选第6条
- 读到end,选第4条
递归下降分析(Recursive-descent parsing)
- 这是Top-Down分析的一种基本方法,适合手写,但是需要回溯来判定
每当需要匹配一个非终结符的时候,都可能有多个分支情况,也就是说,一个非终结符有多条产生式,解决方法是:为每一个非终结符构造一个匹配函数,让它来解决生成式的选择问题,对于每一条生成式,都会根据读入token的类型来递归调用匹配函数:
递归下降分析
grammar:
- S -> if E then S else S
- S -> begin S L
- S -> print E
- L -> end
- L -> ; S L
- E -> num = num
L():
void L(void) { switch(tok) { // 读入token的类型 case END: eat(END); break; // 读入终结符 case SEMI: eat(SEMI); S(); L(); break; // 读入分号, 递归调用S, 再递归调用L default: error(); } }
生成Recursive-descent parser的方法:
递归下降分析
Represent the token
- 具体来说,就是枚举(enum) token: {IF, THEN, ELSE, BEGIN, END, PRINT, SEMI,
NUM, EQ};
为读取和识别token构造辅助函数
// call lexer extern enum token getToken(void); // store the next token enum token tok; void advance() {tok=getToken();} // consume the next token and get the new one void eat(enum token t) {if (tok==t) advance(); else error();}为每个非终结符构造函数
void S(void) { switch(tok) { case IF: eat(IF); E(); eat(THEN); S(); eat(ELSE); S(); break; case BEGIN: eat(BEGIN); S(); L(); break; case PRINT: eat(PRINT); E(); break; default: error(); } } void L(void) { switch(tok) { case END: eat(END); break; case SEMI: eat(SEMI); S(); L(); break; default: error(); } } void E(void) { eat(NUM); eat(EQ); eat(NUM); }
注意
- 上面的CFG,所有产生式最左边都是terminal,而且每条生成式的terminal都不一样,因此对于每一个token类型,其switch的case都是唯一的,然而这并不是很现实:如果一个非终结符有多个产生式(比如加一条
S -> S L),并且可以产生同一条终结符,读到的时候该选择哪一条生成式呢?
第一种方法:回溯方法
- 随便找可行的产生式,匹配就行,不匹配就回溯
- 代价太高,在产品级的编译器中很少使用
- 另一种就是接下来要介绍的预测分析
预测分析(Predictive Parsing)
- 核心思想:在递归下降分析的基础上,读取token时额外向前读取k个token
Predictive Parsing(LL(k) parsing)
基本思想
- 在选择生成式的时候,对于一个非终结符,它能够生成的所有字符串中,前k个要和当前输入的前k个token匹配,如果匹配成功,就选择该产生式
特点
- 只能够处理称为LL(k)的文法(can parse LL(k) grammars): Left-to-right parse; Leftmost-derivation; k symbol lookahead
- 如果此时的文法有多种生成方式(也就是仍然有多条产生式可选),说明不是LL(k)文法!需要通过重写文法,将其转为predictive parsing能够处理的文法
第一步:对于一个非终结符,获取其所有可能生成的字符串的第一个token
为了形式化的描述,我们需要引入几个新概念:
First() and Follow() set
- γ,β,σ:a string of terminal and nonterminal symbols
- t:a terminal symbol
- X:a non-terminal symbol
- S:the start symbol
FIRST(γ):if γ ->* tβ,then t ∈ First(γ)
- γ 可以生成一个由t开始的字符串
- FIRST(γ) 是所有这样的t的集合
Follow(X):if S->* β Xt σ,then t ∈ Follow(X)
- 从起始符号开始,如果能够生成一个字符串,其中t紧跟着X,则t属于Follow(X)
- Follow(X) 是所有这样的t的集合
推论
- 很明显,If the derivation contains XYZt where Y and Z both can derive ε, t ∈Follow(X)
- 这就意味着,计算Follow(X)的时候,我们还要查看后续的非终结符是否可以生成空串
找到First(X)和Follow(X)的算法:
找到First(X)和Follow(X)的算法
- 使用递归手段获得First(X):
Base case:
- 如果X为终结符,则First(X)={X}
- 如果X为非终结符,则First(X)首先初始化为空集
inductive step:
如果X -> Y1 Y2… Yn,则First(X) = First(X) ∪ First(Y1 Y2… Yn) = First(X) ∪ F1 ∪ F2… ∪ Fn,其中
- F1 = First(Y1)
- F2 = First(Y2) if Y1 ->* ε Otherwise, F2 = ∅
- F3 = First(Y3) if Y1Y2 ->* ε Otherwise, F3 = ∅
- …
- Fn = First(Yn) if Y1Y2… Yn-1 ->* ε Otherwise, Fn = ∅
- 首先我们很容易看出,First(Y1)包含于First(X),因为Y1是X的第一个符号
- 其次,如果Y1可以生成空串,那么该情况下将其忽略,X生成的第一个non-terminal可以生成自Y2,Y3…Yn,需要把First(Y2)加入First(X)中,以此类推,如果Y2能够生成空串,就把First(Y3)加入First(X)中…
- 使用类似手段获得Follow(X):
Base case:
- 初始化Follow(X)={}
Inductive step:
- For any strings α , β , if Y -> αXβ , then Follow(X) = Follow(X) ∪ First(β),这一步需要我们检查产生式的右边部分
- For any strings α , β , if Y -> αXβ and β -> ε, Follow(X) = Follow(X) ∪ Follow(Y),同样,如果X后面的符号可以生成空串,就要查看生成式左边(LHS)的Follow()了
从上面可以看出,知道一个非终结符是否能够产生空串十分重要,为了判定,我们引入新的概念:
Nullable Symbols
- Nullable(X)=True if X->ε
使用迭代算法获得Nullable(X):
获得Nullable for each symbol X:
i
Nullable(x)=Falsed
repeati
for each productionX->Y1Y2…Yk:i
if Nullable(Yi)=True for 1<=i<=k:i
Nullable(X)=Trueddd
until Nullable did not change in this iteration- 以上的算法一定可停机,因为单调有上界
伪代码:
伪代码 下面是PPT中获得First/Follow/Nullable的算法,这是一个3个set一起算的算法,实际上我们会按照Nullable->First->Follow的顺序分开计算:
计算First/Follow/Nullable Initialize FIRST and FOLLOW to all empty sets, and nullable to all false.
for each terminal symbol Zi
FIRST[Z]←{Z}d
repeati
for each production X →Y1 Y2 ···Yk:i
for each i from 1 to k, each j from i +1 to k:i
if all the Yi are nullablei
then nullable[X]←trued
if Y1 ···Yi−1 are all nullablei
then FIRST[X]←FIRST[X] ∪ FIRST[Yi]d
if Yi+1 ···Yk are all nullablei
then FOLLOW[Yi]←FOLLOW[Yi] ∪ FOLLOW[X]d
if Yi+1 ···Yj−1 are all nullablei
then FOLLOW[Yi ]←FOLLOW[Yi] ∪ FIRST[Yj ]ddd
until FIRST, FOLLOW, and nullable did not change in this iteration下面用一个具体的例子来说明获得First(X)和Follow(X)的过程:
获得First(X)和Follow(X)的过程
Grammar:
- Z -> X Y Z
- Z -> d
- Y -> c
- Y ->
- X -> a
- X -> Y
获取NUllable(X):
- 第一次遍历:Y可空
- 第二次遍历:X可空
- 第三次遍历:没有变化
确定了Nullable(X):
Nullable First Follow Z False Y True X True
获得First(X):
观察Z的First:
- 很明显,Z的First包含d
- 检查X,X的First包含a,X->Y,Y可空,需要检查Y
- 检查Y,Y的First包含c,Y可空,需要检查Z,但是没有意义了
- XY的First都已经确定
于是我们确定了First:
Nullable First Follow Z False {d,a,c} Y True {c} X True {a,c}
First内部产生的,因此我们需要观察的是生成式左边,而Follow是外部的,因此需要观察生成式右边
获得Follow(X):
观察第一条,得到:
- First(Y)中的都属于Follow(X)
- X可以为空,因此First(Z)中的都属于Follow(X)
- First(Z)中的都属于Follow(Y)
- 观察后续几条,没有发现新的
总结:
Nullable First Follow Z False {d,a,c} {} Y True {c} {d,a,c} X True {a,c} {d,a,c}
第二步:确定选择产生式的条件:
对于某个非终结符X,如果它的某个产生式X->γ 满足一下任意一条,那么就可以选择该产生式:
- 下一个token在First(γ)中
- 下一个token在Follow(X)中,并且γ ->* ε
在前面的例子中,我们已经得到了下表:
Nullable First Follow Z False {d,a,c} {} Y True {c} {d,a,c} X True {a,c} {d,a,c} 以及以下生成式:
- Z -> X Y Z
- Z -> d
- Y -> c
- Y ->
- X -> a
- X -> Y
下面开始检查:
对于Z:
读到a
- 选择Z -> X Y Z,First(XYZ)={d,a,c},因此可以选择该产生式
- 选择Z -> d,First(d)={d},忽略
读到d
- 选择Z -> X Y Z,First(XYZ)={d,a,c},因此可以选择该产生式
- 选择Z -> d,First(d)={d},因此可以选择
读到c
- 选择Z -> X Y Z,First(XYZ)={d,a,c},因此可以选择该产生式
- 选择Z -> d,First(d)={d},忽略
对于Y:
读到c
- 选择Y -> c,First(c)={c},因此可以选择
- 选择Y ->,查看Follow(Y),Follow(Y)={d,a,c},因此可以选择
读到d
- 选择Y -> c,First(c)={c},忽略
- 选择Y ->,查看Follow(Y),Follow(Y)={d,a,c},因此可以选择
读到a
- 选择Y -> c,First(c)={c},忽略
- 选择Y ->,查看Follow(Y),Follow(Y)={d,a,c},因此可以选择
对于X:
读到a
- 选择X -> a,First(a)={a},因此可以选择
- 选择X -> Y,Y可产生空串,查看Folllow(X)={a,c,d},因此可以选择
读到c
- 选择X -> a,First(a)={a},忽略
- 选择X -> Y,Y可产生空串,查看Folllow(X)={a,c,d},因此可以选择
读到d
- 选择X -> a,First(a)={a},忽略
- 选择X -> Y,Y可产生空串,查看Folllow(X)={a,c,d},因此可以选择
第三步:获得Parsing table
- 基于上面得到的规律,我们可以构造一张表,用于描述当前非终结符在读取到下一个token时的选择:
- 如果在实际匹配的时候获得的结果是一个Empty entry,说明发生了语法错误
- 如果每一个格子都有小于等于1个enrty,那么称其为LL(1)文法(Left-to-right parse, left-most derivation, 1 symbol lookahead)
- 类似的,LL(2)文法的lookahead token为aa,ab,ba,bb,ac,ca…
注意 任何有二义性的文法都不是LL(K)文法
- 更多的例子见PPT
通过重写文法修复漏洞
第一个问题:左递归
左递归
左递归的产生式:
- E -> E + T
- E -> T
左递归一定会导致LL(1)的parsing table中出现重复的项(上一个部分的例子中也出现了这样的情况),因为:
- E -> E + T:First(E+T) ⊆First(E)
- First(E) ⊆First(E+T)
- 因此 First(E) = First(E+T)
- E -> T:First(T) ⊆First(T)
- 因此if a ∈ First(T), then a ∈ First(E) and a ∈ First(E+T),读到a的时候,规约E既可以选择E->E+T,也可以选择E->T,因此会导致冲突
- 总而言之,Top-down parsing 无法解决左递归问题,如果想要消除左递归,可以通过以下方法重写:
重写文法消除左递归
- 使用右递归代替左递归:
注意 上面的这种形式称为立即左递归,是一个最简单的左递归,意思是生成式右边有A,对于非立即左递归,形如A Aa,经过了多次的推导得到右边,是更加一般的递归,消除这种递归需要其他的方法
第二个问题:Left Factoring
左因子化
- 之前也提到过:如果两条产生式产生的第一个token是相同的,那么LL(1)文法的parsing table就会包含2个entry,这会导致无法区分哪个entry是正确的
- 为了解决这个问题,同样需要重写文法:
处理语法错误
- 错误:体现为空白的entry
下面是几种出错时的应对策略:
- 打印错误信息,插入想要的token直接返回(将其当作已经匹配了),问题在于可能无法停机
- 替换token
- 删除之后的token,直到匹配到一个可接受的token
总结
- 至此我们学习了构建LL(K) parser(Predictive Parsing),为一些简单的CFG构建parser,如果构建失败,说明这并不是一个LL(K)文法,需要进行一些修改,比如左因子化,消除左递归等等
- LL(k)文法向前看k个token就必须选择一个产生式,超过这个长度就选择不了,接下来介绍一些更加强大的构建方式
自底向上分析(Bottom-Up Parsing)
概念
Bottom-Up Parsing
- 基本思想:从下往上构建语法分析树,从叶子结点向根结点构建
- shift-reduce parsing:Bottom-Up Parsing的通用框架
- LR 语法:shift-reduce parser所能够识别/构建的最大的集合
- LR(k)(Left-to-right parse + Rightmost derivation + k-token lookahead) parsing:LR语法的子集,识别的时候向前看k个token
- LR(k) parsing的强大之处在于,将选择产生式的时机推迟到所有右边对应的input token都被看到的时候
- LALR parsing:LR(k) parsing的变体,大多数使用的parser(Yacc,bison)都是基于该算法的变体
从一个例子出发
LR(1) Parsing
文法
- E -> T + E
- E -> T
- T -> int * T
- T -> int
- 输入串: int * int + int
Top-down parsing:
与Top-down parsing这样将起始符号展开(derive)的过程不同,Bottom-up parsing是将原来的字符串不断收缩(reduce)为起始符号的过程
- 上面的过程可以视为最右推导的逆过程
通过以上的对比我们不难看出,Top-down和Bottom-up parsing:
- 都从左往右进行解析
- Reduce将右手边(RHS)替换为左手边(LHS),Derive将左手边(LHS)替换为右手边(RHS),直到reduce到开始符号
使用DFA描述Parser
在像上面那样使用多行的字符串表示规约过程的基础上,我们引入点号“.”来标记当前读入的位置:
- 点号左边的字符串是已经读入的经过规约的字符串,包含terminal和non-terminal符号
- 右边的字符串是还需要读入的字符串,包含terminal符号
- 然后,我们将点号两边的字符串分成左右两列:
注意
- 上面的表格记录了Parser运行的过程,下面我们详细描述一下Parser的行为
描述/构造一个LR(0) Parser
- 从两个方面描述一个LR parser:
状态(又称为LR(0) item):一般来说,parser需要记录以下内容,表示当前状态:
- position in current input (what input to read next)
- a stack of terminal & non-terminal symbols representing the “parse so far”
行为:parser有以下几种行为:
- shift: 读入token,表现为:将下一个token push到栈顶
reduce R:进行规约,将栈顶的几个元素reduce为一个非终结符(将产生式右边的符号替换为左边),表现为:将栈顶对应的符号全部弹出,然后压入左边符号
- top of stack should match RHS of rule R(e.g.,X->ABC)
- pop the RHS from the top of stack(e.g.,pop CBA)
- push the LHS onto the stack(e.g.,push X)
- accept: 接受输入串,表现为:shift $ and can reduce what remains on stack to start symbol
- error
- 下面我们详细描述一下Parser读入字符时的状态转移和行为
确定Parser的状态转移
为了方便理解,我们先从LR(0)(不需要lookahead,仅凭当前栈的状态就可以决定要shift还是reduce)开始分析,但是在此之前,我们需要使用一个新的方式来表示规约过程:
使用状态图表示Parser的状态转移
包含以下内容:
状态:使用方框框住,内容为LR(0)item,代表当前的一个状态,使用记号A->α.β 表示,前半部分是已经处理的,后半部分是期望读取的
- 注意,A->αβ是一条产生式
- 初始状态:S’->.S$
- 箭头:表示当前状态读取一个字符后转移到下一个状态
- 通过以上的方法,我们能够从CFG构造出一个NFA,并将其转为DFA,从而得到一个状态图
- LR(1)的高明之处在于:在构建的过程中限制了lookahead的数量,因此可以避免很多不必要的冲突
下面通过一个例子来说明LR(0)的状态图的构建过程:
LR(0) Parsing
- 首先,通过文法构建NFA
图 13 LR(0) Parsing 
上图解析:
- 从S’开始:如果读到S,则转移至一个接受状态,点号移到S右边,代表已经解析完
- 查看当前点号后面的字符S,从S到(S)和a各有一个e-transition,将其加入NFA
- 以此类推,读入终结符/非终结符的时候,移动点号代表已经读入,并将其加入NFA
- 与此同时,检查点号右边的字符是否产生新的状态,如果有则构造一个e-transition,并将其加入NFA
这样我们得到了一个NFA,既可以使用传统手段将其转为DFA,也可以一次性将DFA画出来:
从第一项开始,在脑子里做epsilon-closure,可以直接写出项集
图 14 LR(0) Parsing 
使用伪代码描述状态转移图的构建过程:
LR(0) Parsing伪代码
符号:
- I:a set of items(项集),表示一个state
- X:a symbol (terminal or non-terminal)
- T:the set of states
- E:the set of (shift or goto) edges
计算闭包:
Closure repeat
i
for any item A → α.Xβ in Ii
for any production X → γi
I ← I ∪ {X → .γ}ddd
until I does not change.
return I当前状态为I,下一个是X,计算下一个状态,即Goto(I, X)
Goto J ← ∅
for any item A → α.Xβ in Ii
add A → αX.β to Jd
return Closure(J)获得DFA状态图的过程:
DFA T ← {Closure({S’ → .S$})}
E ← ∅
repeati
for each state I in Ti
for each item A → α.Xβ in Ii
let J be Goto(I, X)
T ← T ∪ {J}
E ← E ∪ {I → J}ddd
until E and T did not change in this iteration
确定Parser的行为
- 对于某个状态的Parser,在读入一个符号后,除了转移状态,还会有额外的行为,我们需要描述这些行为 实际上,我们将状态转移和其他行为一起描述了
首先给定生成式:
- S’ -> S $
- S -> ( L )
- S -> x
- L -> S
- L -> L , S
获得DFA
图 15 LR(0) Parsing 
行为描述:
LR(0) Parser的行为
使用自然语言描述:
Shift(n):
- Advance input one token;
- push n on stack.
Reduce(k):
- Pop stack as many times as the number of symbols on the RHS of rule k;
- Let X be the LHS symbol of rule k;
- In the state now on top of stack, look up X to get “goto n”;
- Push n on top of stack.
- (在下面的示例中,reduce和goto将分开描述,以更好展示parsing过程)
Accept:
- Stop parsing, report success.
Error:
- Stop parsing, report failure.
规定Parser的行为:
- 起始状态,空栈,一定是shift
- 栈顶为终结符,若item的点号到达最后(即读完),进行reduce
- 栈顶为终结符,若item的点号没有到达最后,仍然shift
- reduce后栈顶必然为非终结符,进行goto
- 栈顶为非终结符,若item的点号到达最后(即读完),进行reduce
下面是之前的例子:
注意 可以合并符号栈和状态栈,因为push/pop符号的时候,会push/pop相同数量的状态,push/pop都代表了状态的转移
构建Parsing Table
- 除了使用自然语言描述Parser的行为,同样可以使用多维数组表示,来方便实现:
上面的table既可以根据DFA图进行构建,也可以使用形式化的表示法进行构建:
Table Look up top stack state and input symbol to get action;
If action is:i
Shift:i
For edge labeled with terminal t and from state i to state n:没读完,读到终结符i
T[i, t] = sn (shift n)dd
Goto:i
For edge labeled with non-terminal X and from state i to state n:没读完,读到非终结符i
T[i, X] = gn (goton n)dd
Reduce:i
For item in a state i with dot at the end(e.g.,X->A…C.):读完i
T[i, each terminal] = rk (reduce k) k is the index of this productiondd
Accept:i
For each state I containing S’->S.$:i
T[i, $] = accept从上表可以看出LR(0)的特征:每个状态要么是shift,要么是reduce,同一行不会同时出现s和r
- 对于LR(0),这里用不到symbol stack,因为LR(0)仅凭当前状态就能够决定是shift还是reduce,然而具体的状态还是需要读的
- 上面描述了LR parsing的一种通用方式
Simple LR(SLR) Parsing
如果shift和reduce同时出现在一个entry中,我们就说发生了shift-reduce冲突,这个语法不是LR(0)的,此时可以使用SLR方法解决部分冲突
Simple LR Parsing
基本思想:
- 只有当输入的符号属于FOLLOW(LHS)的时候才进行reduce,否则都进行shift
伪代码:
SLR I:the row
X:the column
A → α: the production
R ← {}
for each state I in Ti
for each item A → α. in Ii
for each token X in FOLLOW(A)i
R ← R ∪ {(I, X, A → α)}- 构建Parsing Table的时候,检查每一个项集中的Item,只有Item左边的FOLLOW()中的符号在被读入的时候才进行reduce操作
下面是一个例子:
图 16 Simple LR Parsing 
图 17 Simple LR Parsing 
注意
- 然而,SLR并不能完全解决Shift-Reduce冲突,如果下一个token既在FOLLOW(LHS)中又在FOLLOW(RHS)中,同样无法决定是shift还是reduce
LR(1) Parsing
在SLR的基础上,LR(1)是一种更加强大的方法:
- LR(0)只根据当前状态决定shift/reduce
- SLR:根据当前状态+根据下一个token是否在FOLLOW set中决定shift/reduce
- LR(1):加入更多的信息:
LR(1) Parsing
基本思想:
- 将整个推导过程纳入考虑,辨别读入的终结符是否能够出现在整个产生式的RHS中
方法
修改LR(1) item定义:在LR(0) item的基础上,增加一个lookahead symbol,将这个symbol记在整个产生式的右边:
- 这里的一个item代表α 在stack的顶部,βx可以生成input的head,也就是说,下一个输入的token应该在First(βx)中(β可以是空的)
在LR(0)基础上,我们需要修改伪代码来实现算法:
LR(1)(start:closure((S′→.S$,?)) SLR/LR(0)(start:closure((S′→.S$)) Closure repeat
i
for any item (A → α.Xβ, z) in Ii
for any production X → γi
for any w ∈ FIRST(βz)i
I ← I ∪ {(X → .γ, w)}dddd
until I does not change.
return I- 对于当前项集中的每一条满足非终结符X在点号后面的产生式,检查X,并将它后面生成的First()作为lookahead,构造新的state加入项集,这样就实现了lookahead symbol的传递
- 起始符号(S′→.S$,?)中的问号代表可以是任意的符号
Closure repeat
i
for any item A → α.Xβ in Ii
for any production X → γi
I ← I union {X → .γ}ddd
until I does not change.
return IGoto J ← ∅
for any item (A → α.Xβ, z) in Ii
add (A → αX.β, z) to Jd
return Closure(J)Goto J ← ∅
for any item A → α.Xβ in Ii
add A → αX.β to Jd
return Closure(J)DFA R ← {}
for each state I in Ti
for each item (A → α., z) in Ii
R ← R ∪ {(I, z, A → α)}dd- 在I状态下,如果z是I中某个item的lookahead symbol,则将A → α加入R,说明状态I下读到z后可以进行reduce操作
- 这里的z肯定在FOLLOW(A)中,但是这并不是reduce的充分条件而是必要条件,z还包含了之前的产生式的条件
DFA R ← {}
for each state I in Ti
for each item A → α. in Ii
for each token X in FOLLOW(A)i
R ← R ∪ {(I, X, A → α)}ddd
下面通过一个例子来说明:
图 18 LR(1) Parsing 
先找起始状态的闭包:
S’ → .S$,?
- First($?) = {$},结合第1,2条产生式得到:
- S → .V=E,$
S → .E,$
- 查看V,First(=E$)={=},结合第4,5条产生式得到:
- V → .x,=
V → .*E,=
- 接着查看E,First($)={$},结合第3条产生式得到:
E → .V,$
- 由于lookahead symbol不同,item即使拥有相同的产生式,它们也代表不同的状态,因此还需要继续检查V:First($)={$},结合第4,5条产生式得到:
- V → .x,$
V → .*E,$
将它们与前面得到的合并,就获得了起始符号的闭包:
- S’ → .S$,?
- S → .V=E,$
- S → .E,$
- V → .x,{=,$}
- V → .*E,{=,$}
- E → .V,$
如法炮制,就可以获得状态图以及Parsing Table:
LALR(1) Parsing
LR(1)的parsing table过大,因为grammar+symbol的组合非常多,因此考虑将相似的状态进行合并,这就是Look-Ahead LR(1)(LALR(1)) Parsing
LALR(1) Parsing
基本思想:
- 两个相似状态合并,合并条件为:
- 两个states的item,除了lookahead sets外都相同
继续前面的例子:
找到等价的状态:
图 19 LALR(1) Parsing 
合并状态并重写Parsing Table:
图 20 LALR(1) Parsing 
- 对于某些语法,原本没有reduce-reduce冲突的LR(1)table在修改为LALR(1)后,可能会出现reduce-reduce冲突,实际上并没有什么影响
- 有关LR(1)和LALR(1)的区别,更多见作业题
实践中使用LR Parsing
下面是几种语法之间的关系:
图 21 几种语法之间的关系 
以下是PPT中的一个例子:
if-else语句的匹配问题
Most programming languages have grammar rules such as:
- S → if E then S else S
- S → if E then S
- S → other
下面有一串输入:if a then if b then s1 else s2,它有2种匹配:
- if a then { if b then s1 else s2 }
- if a then { if b then s1 } else s2
- In most programming languages, an else must match the most recent possible then, so interpretation (1) is correct.
下面的产生式会导致shift-reduce冲突:
- S → if E then S else S
- S → if E then S
- S → other
One solution:The ambiguity can be eliminated by introducing auxiliary non-terminals M(for matched if)and U(for unmatched if)
- S → M
- S → U
- M → if E then M else M
- M → other
- U → if E then S
- U → if E then M else U
M : all then are matched
U : some then is unmatched
This then is unmatched
This then is matched
-Another solution:
- Leave the grammar unchanged. In constructing the parsing table, this conflict should be resolved by shifting(prefer interpretation (1))
- Caution:Most shift-reduce conflicts, and probably all reduce-reduce conflicts,are symptoms of an ill-specified grammar, and they should be resolved by eliminating ambiguities.
实现Parser (Implementation)
手写Parser
- 从刚刚我们所学,我们已经可以手写表驱动的LL(k)/LR(k)Parser,现在我们看看使用自动化来实现Parser
- 使用Parser generator构建Parser更加的通用,鲁棒性更好,虽然效率有时不如手写的Parser,但是已经足够便利了
Yacc
基础语法
接下来学习Yacc,这是一种基于LALR(1)的Parser generator,它可以自动生成Parser代码,并且支持C/C++语言,我们实验课用到的Bison原理与其类似
Yacc
- Input: a specification file (usually with a suffix .y)
- Output: an output file consisting of C source code for the parser (usually with a suffix tab.c)
- A Yacc specification file has the basic format
{definitions} %% {rules} %% {auxiliary routines}接下来通过一个例子学习Yacc:
Grammar:
exp → exp addop term | term addop → + | - term → term mulop factor | factor mulop → * factor → ( exp ) | number对应的Yacc文件:
接着前面说的,Yacc的Lexer会在读入一个token的时候将其Value Push进Value Stack,以3*4为例:
Parsing过程
下面是Stack的变化:
yyval的类型问题:
- 读到不同类型的token时,如何处理yyval?
第一种方法:使用%union关键字在.y文件中直接声明,不同类型的变量占用同一块数据空间,根据需要将数据解析为对应的数据类型
%union { double val; char op;}- 使用上面的方式修改Rule部分:
- 第二种方法:定义一个新的类型,然后将YYSTYLE定义为该类型,然而必须手动定义
#define YYSTYPE ASTNode
Embedded Actions
- 之前就提到过,可以在进行产生式选择的同时执行Action Code,这就是Embedded Actions,它可以使得choice和code同时执行
有一些code需要在rule被完全识别之前就执行,比如:
decl→ type var-list type→ int | float var-list→ var-list, id | id- 当decl展开的时候,就需要记录type,以实现将id和type绑定,否则等到reduce的时候,无法记录id对应的类型
embedded action语法:
冲突
Yacc会报告shift-reduce和reduce-reduce冲突,下面是一般情况下的解决方法:
- 解决shift-reduce冲突:使用shift
- 解决reduce-reduce冲突:选择率先出现的rule进行reduce
- 但是,所有的这类冲突都是严重的问题,都应该通过重写文法消除
一个特例:将语义分析延后
算数表达式与布尔表达式
- 在编程语言中,我们通常会有算数表达式(arithmetic expression)和布尔表达式(boolean expression),我们希望两者不会混用,基于下面的语法:
%{ //declarations of yylex and yyerror %} %token ID ASSIGN PLUS MINUS AND EQUAL %start stm %left OR %left AND %left PLUS %% stm : ID ASSIGN ae | ID ASSIGN be be : be OR be | be AND be | ae EQUAL ae | ID ae : ae PLUS ae | ID- 其中算数表达式结合更加紧密,因此不能出现布尔表达式与算数表达式的加法,问题在于,Parser解析到ID ASSIGN的时候无法确定后面是ae还是be,因此会出现reduce-reduce冲突
- 对此我们的解决方法是:在语法分析阶段不进行纠错,因为这是一个语义错误而不是语法错误,我们将这个错误延续到语义检查阶段
优先级指令
考虑下面两种文法:
- 左边的文法并不是很自然,很难懂,但是是正确的,而右边的文法看起来很清楚,但是有二义性,我们能不能获得既自然又遵循优先级和二义性的文法呢?
有的,Yacc通过一些自带的precedence directives (disambiguating rules)来强制规定优先级和结合性来确保以上的条件
Yacc Precedence
Yacc通过多种precedence directives来解决这个问题:
解决方法:
通过优先级声明判定优先级
- 通过事先声明优先级,我们就能够在Parsing的时候决定是reduce还是shift,以及reduce哪一条:
在选择shift/reduce的时候:
- 查看shift对应的下一个token,记录下一个token的优先级
- 然后再看reduce对应的rule的RHS的最右边的token(最后出现的终结符),这个token的优先级就是这个rule的优先级
- 如果rule的优先级更高,选择reduce,否则选择shift
如果两者优先级相同,则查看优先级类型:
- %left:reduce
- %right:shift
- %nonassoc:error
以前面的情况为例:
表示负数
- 为了parse −6*8为(-6)*8而不是-(6*8),需要通过在rule后面添加额外的%prec标识符来指定优先级
%{ // declarations of yylex and yyerror %} %token INT PLUS MINUS TIMES UMINUS %start exp %left PLUS MINUS %left TIMES %left UMINUS %% exp : INT | exp PLUS exp | exp MINUS exp | exp TIMES exp | MINUS exp %prec UMINUS- %prec UMINUS说明这一条Rule的优先级与UMINUS这个符号相同,因此它给了前面的rule最高的优先级
然而UMINUS永远不会被lexer返回,这只是我们为了规定优先级凭空捏造的占位符
错误恢复(Error Recovery)
关于错误恢复
- 目标:检测所有可能的错误,而不是遇到第一个就停下来
主要介绍2种错误恢复的技巧:
- 局部错误恢复(Local Error Recovery)
- 全局错误修复(Global Error Repair)
局部错误恢复(Local Error Recovery)
Local Error Recovery
- 主要思想:通过调整检测到错误时的parse stack和input string来回溯状态
- 具体方法:使用一个error符号(terminal)和synchronizing token来控制错误分析的流程
比如,我们原先的Grammar是这样的:
exp → ID exp → exp + exp exp → ( exps ) exps → exp exps → exps ; exp加入新的终结符error以及生成规则,规则中相关的token就是synchonizing token:
exp → ( error ) exps → error ; expsynchonizing token:遇到错误时,将当前token移到下一个token,直到找到可以恢复的地方,在例子中,分析左括号里面的东西时,如果遇到错误,就跳到对应的右括号;分析分号前面的东西时,如果遇到错误,就跳到下一个分号,具体的行为如下:
- 到达错误状态,此时stack并未push错误的token
- 持续从stack中pop value,直到可以将error push进stack,于是将error push进stack
- 持续从input token中删除token,直到可以将下一个token(lookahead token) push进stack,于是将下一个token push进stack
下面是一个例子:
图 22 Local Error Recovery 
这种方法可能导致正常语法中不可能出现的语义问题,特别是有副作用的操作:
grammar如下:
statements: statements exp SEMICOLON | statements error SEMICOLON | /* empty */ exp : increment exp decrement | ID increment: LPAREN {nest=nest+1;} decrement: RPAREN {nest=nest-1;}- 如果发生错误的时候,恰好已经读入了左括号,而没有读入对应的右括号,进行pop操作会pop已经读入的左括号,对token的discard操作又会丢弃将要读的右括号,导致decrement的Action Code得不到执行,nest最终不为0
- 解决方法之一:避免使用有副作用的语义操作
全局错误修复(Global Error Repair)
对于某些错误而言,使用局部修复的代价过大,而通过修改个别token就能够修复错误(最终还是要报错),这种方法称为全局错误修复(Global Error Repair)
Global Error Repair
- 主要思想:找到最小的插入/删除操作的集合,使得输入string变成一个正确的状态
- 特点:即使错误并没有发生在刚刚读取的地方,而是在之前的某个位置,也能恢复到正常的状态
具体方法(以Burker-Fisher算法为例):
维护2个stack和一个queue:
- current stack:当前Parser状态
- old stack:k步前的Parser状态
- queue:前k个token
每次有新的token进行shift:
- 将当前token压入current stack
- queue末尾的token出队,并将其压入old stack
- 对2个stack进行reduce(如果有)
- 报错时:不对报错的地方进行计算,对queue中的k个token,尝试所有可能的插入/删除操作,期望找到一个正确的状态,在old stack基础上进行reparse
为了判断修复方式是否是最优的,需要检查:
- 修复后在当前位置不再报错
- 在之后的r(通常为3或4)个token后也不再报错,就认为是足够好
- 本质上是一个暴力搜索的过程
- 比较当前队列后续搜索的长度和上一个队列后续搜索的长度,如果多parser r个token,就接受新的队列
下面是PPT中的例子:
图 23 Global Error Repair 
Abstract Syntax 抽象语法
语义动作(Semantic Action)
编译器的作用
之前我们已经提到,编译器在Parsing阶段分析了语法的正确性,下一步就是进行语义分析了,然而在这之前,还有一个步骤,就是将原来的语法树修改为抽象语法树(Abstract Syntax Tree,AST),这一步支持了之后的两个阶段:
- 语义分析
- 生成中间表示(Intermediate Representation,IR)
但是在抽象语法树之前,我们先介绍语义动作(Semantic Action),它使得Parser能够有更强大的作用,以支持之后的分析
注意
接下来从两方面介绍:
- 递归下降中的语义动作
- Yacc中的语义动作
递归下降分析
什么是语义动作:
Semantic Action
- 对于一个递归下降分析器而言,它的语义动作就是Parsing function的返回值,或者Parsing function的副作用,或者两者都是
下面是一个例子:
递归下降分析中的语义动作
语法如下:
- S → E $
- E → T E′
- E′ → + T E′
- E′ →− T E′
- E′ →
- T → F T′
- T′ →* F T′
- T′ → / F T′
- T′ →
- F → id
- F → num
- F → (E)
假设现在我们需要构建F的function来处理F的产生式,本来我们的函数并不需要有返回值,但是为了支持语义动作,和AST的构建,我们需要返回一个值,比如:
除了返回值意外,下面的AssignStmt虽然没有返回值,但也对一个变量进行了修改,展示了语义动作副作用的例子:
- S->id:=num
实际上,对于每一个终结符和非终结符,我们都需要将一个Type类型的值与之相联系,用于表示该符号在源语言(而不是实现Compiler使用的语言)中的类型:
enum token {EOF, ID, NUM, PLUS, MINUS, ...}; union tokenval {string id; int num; ...}; enum token tok; union tokenval tokval; //assume a lookup table mapping identifiers to integers int lookup(string id) { ... } int F_follow[] = { PLUS, TIMES, RPAREN, EOF, -1 }; int F(void) {switch (tok) { case ID: { int i=lookup(tokval.id); advance(); return i;} case NUM: { int i=tokval.num; advance(); return i; } case LPAREN: { eat(LPAREN); int i = E(); eatOrSkipTo(RPAREN, F_follow); return i;} case EOF: default: printf("expected ID, NUM, or left-paren"); skipto(F_follow);return 0; }}对右递归的处理:
将左递归改为右递归后:
- E′ → + T E′
- E′ → − T E′
- T′ → * F T′
- T′ → / F T′
比如说,如何处理T/T‘的语义值?
- 注意到,T’其实只是一个中间量,它只能由T第一次产生,我们不妨使用T‘的函数计算T的语义值:
T的函数:
int T(void) {switch (tok) { case ID: case NUM: case LPAREN: return Tprime(F()); default: print("expected ID, NUM, or left-paren"); skipto(T_follow); return 0; }}T’的函数:
int Tprime(int a) {switch (tok) { case TIMES: eat(TIMES); return Tprime(a*F()); case PLUS: case RPAREN: case EOF: return a; default: ... }}- Tprime的作用,接受T的左操作数,计算T的右操作数,将两者计算获得T的语义值返回给T/上一级T’
在Yacc中实现语义动作
- 原理见下一节
实现的核心在于:
- 在维护符号栈/状态栈的同时,维护一个语义(Value)栈,每次发生reduce操作的时候,对应的语义动作就会被同步执行:
图 24 Yacc中的语义动作 
抽象语法树(Abstract Parse Tree)
关于抽象语法树
- 在上一章我们已经学会了使用Yacc来构造一个Parser,实际上,我们已经可以在Parser的语义分析阶段构建出一个完整的Compiler了,只不过这个Compiler目前还有很多的缺点,比如难以读取与维护、必须先声明再调用等等(进行线性的Parsing时,无法调用还未声明的函数)
- 为了提高模块化程度,我们需要将语法分析(Syntax Analysis)和语义分析(Semantic Analysis)分离开来,为了方便之后的语义分析,我们需要在语法分析阶段构造一个抽象语法树,语法树保留了程序的结构,是语法分析与后续分析之间的一个接口(Interface)
具象语法树(Concrete Parse Tree)
为了构建语法树,我们最容易想到的就是基于语法分析阶段,树中每一个叶子结点对应一个token,内部节点对应Parsing过程中的一条产生式(非终结符)
Concrete Parse Tree
- 严格按照语法规则产生的树:
从上面的例子我们就可以看出,具象语法树其实并不是很方便,原因如下:
- 具象语法树有许多冗余的信息,比如上面的括号(根据结合性已经可以确定优先级了),以及不必要的推导过程(语义分析不需要知道E是怎么推导到2的)
- 希望只保留最关键的信息,将语法分析与后续分析进行解耦合
抽象语法树(Abstract Parse Tree)
抽象语法树剔除了具象语法树中的冗余部分,只保留了最重要的信息
注意
- 抽象语法只是为了方便之后的分析,它本身并不能取代具象语法进行Parsing
实现AST
- 首先,我们需要为每一个非终结符定义自己的节点数据结构,为每一个产生式构建一个union变量:
typedef struct A_exp_ *A_exp;
struct A_exp_ {
enum {A_numExp, A_plusExp, A_timesExp} kind;
union {
int num;
struct {A_exp left; A_exp right;} plus;
struct {A_exp left; A_exp right;} times;
} u;
};
A_exp A_NumExp(int num);
A_exp A_PlusExp(A_exp left, A_exp right);
A_exp A_TimesExp(A_exp left, A_exp right);- 为每一条产生式构建一个构造函数:
A_exp A_PlusExp(A_exp left, A_exp right) {
A_exp e = checked_malloc(sizeof(*e));
e->kind = A_plusExp;
e->u.plus.left = left;
e->u.plus.right = right;
return e;
}以表达式2+3*4为例,构建AST的过程如下:
e1 = A_NumExp(2); e2 = A_NumExp(3); e3 = A_NumExp(4); e4 = A_TimesExp(e2, e3); e5 = A_PlusExp(e1, e4);同样,我们可以借助Yacc的Action Code来进行自动化构建:
%left PLUS %left TIMES %% exp : NUM {$$=A_NumExp($1);} | exp PLUS exp {$$=A_PlusExp($1, $3);} | exp TIMES exp {$$=A_TimesExp($1, $3);}在reduce的时候构建相应的节点:
- shift并reduce 2:e1 = A_NumExp(2);
- shift加号
- shift并reduce 3:e2 = A_NumExp(3);
- shift乘号
- shift并reduce 4:e3 = A_NumExp(4);
- reduce 乘法表达式:e4 = A_TimesExp(e2, e3);
- reduce 加法表达式:e5 = A_PlusExp(e1, e4);
维护token位置信息
- 有一些compiler的词法分析/语法分析/语义分析是同步进行的,因此出现了语义错误的时候可以立即停止并报告当前token位置
而使用了AST的compiler将三者分离,如果语义分析阶段出现错误将无法报告错误位置,因此需要为每一个节点添加在源文件中的位置信息:
- 第一种做法(Bison),添加额外的Stack,记录每一个token的位置(行数、列数)
- 第二种做法(Yacc),为每一个AST节点添加额外的成员(non-terminal,其语义为token位置),parsing的时候使用全局变量记录token位置,在新建节点的时候将全局变量作为参数传入:
%{ extern A_OpExp (A_exp,A_binop,A_exp,position); %} %union { int num; string id; position pos;....}; %type <pos> pos pos: { $$ = EM_tokpos; } exp: exp PLUS pos exp {$$= A_OpExp($1, A_plus, $4, $3); }
Semantic Analysis 语义分析
Overview
仅仅使用CFG是无法帮助我们构建一个编译器的:
CFG的局限性
考虑下面的文法和program:
除此之外,有很多事情,都是语法分析器无法做到的,而这些性质都需要交给语义分析以及后续的检查完成:
语义分析
语义分析主要有两个大的方面:
通过AST检查程序的静态属性,包括:
- 名称的作用域/可见性(保证变量使用前先声明..)
- 变量/函数/表达式类型的正确性
- 将AST翻译为更加简单的形式,为之后生成机器码做准备
符号表(Symbol Table)
概念
- 语义分析通过在遍历AST的过程中,维护一个称为符号表的结构实现
符号表
概念:Binding,将一个名称和对应的信息相绑定,本质是一个映射,比如:
- 概念:environment,环境是binding的集合,可以理解为context,在一个环境中,a可以映射为一个整数,而在另一个环境中,a可以映射为一个字符串
- 概念:symbol table,实现environment的一种数据结构
下面是一个例子:
符号表
从上面的例子我们可以看出symbol table需要支持以下的interface:
- insert:将一个binding插入到当前的环境中
- lookup:在当前的环境中查找一个binding
- beginScope:进入一个新的环境
- endScope:退出一个环境,回到上一个环境
多个符号表共存的情况
符号表的实现
Imperative style:命令形式的符号表
命令式符号表
- 特点:只能在一张表中进行查找
- 进入scope后,在同一张table中进行修改
- 退出scope时,使用回退undo操作退出scope,这要求维护额外的信息,包括一个undo stack
- 实现目标:高效查找
实现方法:Hash Table + Link List
插入链表的第一个位置,删除也是删除第一个位置(表头),因此insert和pop的工作原理类似于一个stack
图 25 Symbol Table Insert 
Functional style:函数式符号表
函数式符号表
- 通过新建符号表区分作用域,因此回退比较简单,只需要删除table就可以
Hash table实现
- 通过复制哈希数组来实现,每次进入新的scope都需要复制整一个哈希表,开销较大,尤其是symbol较多的情况
二叉树实现
- 使用一个二叉树代表一个symbol table,每一个节点的key是一个名称,而value是一个binding,通过指针指向下一个节点,因此可以实现O(log n)的查找时间
实现方法:
比如:
图 26 Binary Tree Symbol Table 
符号表的优化
- 在进行查找的时候,我们需要使用string compare进行比较,这会增加查找的开销,下面介绍优化方法:
定义symbol数据类型:
symbol
- 每一个symbol类型的变量都与一个整数相关联,在哈希表中就是哈希值(symbol的指针),之后就可以通过这个整数代表该symbol
- 这样可以使用整数比较来代替strcmp函数,提高效率
- 本质上,将binding替换为symbol???
symbol省在了哪里?
Tiger编译器中的符号表
下面是Tiger语言中symbol table(使用命令式+哈希表)的实现:
tiger语言集成了hash的接口:TAB模块
// make a new S_Table S_table S_empty(void) { return TAB_empty(); } // insert a binding void S_enter(S_table t, S_symbol sym, void *value){ TAB_enter(t,sym,value); } // look up a symbol void *S_look(S_table t, S_symbol sym) { return TAB_look(t,sym); }至于symbol table的interface,Tiger使用了破坏性更新(destructive-update)的环境,以下是代码:
类型检查(Type Checking)
总结
- 类型检查需要回答下面3个问题:
- 如何定义合法的表达式,包括:int,string,nil,array of int
- 如何定义类型等价
- 如何确定类型检查的规则
Tiger语言中的Type
- 两种基本类型(primitve types):int,string
- 衍生类型(constructed types):records, arrays from other types (primitive, record, or array)
Tiger语言中的Type
实现方法:定义Ty_ty节点:
- 对于递归定义的type类型(比如:type list = {first: int, rest: list}),编译器并不会第一时间给出定义,而是先创建一个占位符,将它的Name加入table,定义完成后再替换掉占位符
定义类型的等价
定义类型的等价
- 名字等价(name equivalence,NE):T1 and T2 are equivalent iff T1 and T2 are identical type names defined by the exact same type declaration.
- 结构等价(structural equivalence,SE):T1 and T2 are equivalent iff T1 and T2 are composed of the same constructors applied in the same order.
Tiger使用NE:
名字等价
Tiger的名称空间和环境
Tiger的名称空间
- 所谓名称空间(namespace),存储了名字到定义的映射
Tiger有两个名称空间,分别是Type的名称空间,和Functions/Variables的名称空间,因此相同名称的符号既可以是Type,也可以是Variable/Function
let type a = int var a := 1 in ... end由于Tiger有两个名称空间,自然而然需要维护两个环境:
- Type environment:将type symbol映射为type object
- Value environment:将variable symbol映射为type object,将function symbol映射为struct {Ty_tyList formals, Ty_ty result;}
- 根据语法上下文确定是type还是value
注意
- 存储类型对象而不是名字,因为是名字等价,不是类型名称,而是检查类型对象
实现:
typedef struct E_enventry_ *E_enventry; struct E_enventry_ { enum {E_varEntry, E_funEntry} kind; union { struct {Ty_ty ty;} var; struct {Ty_tyList formals; Ty_ty result;} fun; } u; }; E_enventry E_VarEntry(Ty_ty ty); E_enventry E_FunEntry(Ty_tyList formals, Ty_ty result); S_table E_base_tenv(void); /* Ty_ty environment */ S_table E_base_venv(void); /* E_enventry environment */- 实际上就是父类+子类的关系
Tiger的类型检查
Semant模块负责进行语义检查,它主要实现了4个function:
- 由于整个Tiger程序都可以视为一个表达式,我们可以使用transExp接受AST(的root)来遍历AST,进行类型检查(同时生成IR,这里只介绍类型检查)
类型检查的过程:
- 返回值:翻译后的struct expty {Tr_exp exp; Ty_ty ty;};
类型检查的过程:
以a+b为例:
- 先递归调用左右子树的transExp,得到左右子树的结果
- 然后比较左右子树的类型是否相同
- 如果类型相同,返回类型为左右子树的类型
- 如果类型不同,报错
Tiger不支持类型转换
Activation Records 活动记录
- 感觉这一块和OS的内容有点相似
Overview
对于compiler writer而言,程序在执行的时候运行在自己的逻辑地址空间,这个空间大致包含data和program两个部分,一个典型的布局如下:
- 我们知道,每次函数f被调用,就会产生f的一个activation,等到函数返回的时候被销毁
- 为了维护这一个功能,我们使用Stack,这是因为(简单的)函数调用的行为符合LIFO的规律
概念:Activation Record
Activation Record
- Procedure:这门课中的Program/Function都称为Procedure,Procedure call又被称为 activations of procedures
- control stack:用于控制Procedure的调用与返回的stack
- 当Procedure被调用的时候,对应的空间会被push入stack,stack会增加一定的大小
- 当Procedure终止的时候,对应的空间会被pop出stack,stack会回退一定的大小
- 每一个live activation都在control stack中有一个activation record,下面将其称为栈帧(Stack Frame)
栈帧(Stack Frame)
重新理解stack:
Stack Pointer
- 将stack视为一个巨大的array,stack pointer指向当前的位置
- 栈指针约束了当前stack的大小,栈指针内的空间是已经被allocate的,而栈指针外的空间是空闲的(在这里,runtime stack从高地址向低地址增长)
栈帧与栈帧的布局
Stack Frame
- 栈帧用于控制Procedure的调用与返回,同时担任了caller和callee之间的联系功能
一个典型的栈帧布局如下:
以上就是栈帧的结构,下面我们一一介绍栈帧是如何工作的:
- 如何实现栈的增长(栈指针)
- 如何保存Procedure的信息(Register)
- 如何传递参数(incoming arguments, outgoing arguments)
- 如何从callee返回(return address)
- 函数如何访问其他栈帧(static link)
栈指针(Stack Pointer)
函数调用时:
- 将老FP保存在frame中
- FP = SP
- SP = SP - size of frame
f退出时:
- SP = FP
- 取回老FP
如果f的frame size固定,那么改变更简单,FP只要变成SP+size即可,否则,需要由compiler计算
寄存器(Register)
- caller-save register VS callee-save register:已经学过很多遍,就不再赘述了
参数传递(incoming arguments, outgoing arguments)
- Tiger语言中,函数的参数通过写入frame来传递,而不是通过寄存器
然而,当参数过多时,频繁的memory读取可能造成traffic拥挤,因此一种优化就是使用部分寄存器传递参数
使用寄存器传递参数
void f(int a) { int z = ... h(z); ... int t = a + 2; ... }- 假设f通过寄存器r1传递参数,如果r1现在正在用,就需要进行caller save,将r1保存到自己的stack中
更进一步的优化:
- 如果之后用不到r1,就直接覆写
- 叶子结点不需要将incoming arguments保存在栈帧中,而是直接传递给callee(不知道什么意思)
- 使用跨procedure的寄存器分配技术(详情可参考体系结构)
- 使用更加先进的寄存器分配算法(比如renaming)减少memory traffic
返回地址(Return Address)
- 非叶子程序需要将返回地址写入stack,然而叶子程序无所谓
总结
- 参数传递的方式:Frame-resident variables
现代的大多数架构都使用寄存器来传递函数参数/返回地址/函数返回值等等,只有在以下情况中才会将Value写入memory:
- the variable will be passed by reference, so it must have a memory address (e.g., & in the C language);
- the variable is accessed by a procedure nested inside the current one;
- the value is too big to fit into a single register;
- the variable is an array, for which address arithmetic is necessary to extract components;
- the register holding the variable is needed for a specific purpose, such as parameter passing (as described above);
- or there are so many local variables and temporary values that they won’t all fit in registers, in which case some of them are “spilled” into the frame.
escape variable:
Escape Variable
A variable escapes if :
- it is passed by reference,
- its address is taken (e.g., using C’s & operator),
- or it is accessed from a nested function
静态链接(Static Link)
基于逐层访问的静态链接
对于支持嵌套定义函数的语言(比如Pascal,ML,Tiger),在函数内部定义的函数可以使用外层函数的变量,这就意味着进行函数调用时该内层的函数能够访问外层栈帧以获得变量值,为了实现这一点,我们引入static link:
Static Link
- 假设函数定义如下:
type tree = {key: string, left: tree, right: tree} function prettyprint(tree: tree) : string = let var output := “” function write(s: string) = output := concat(output,s) function show(n: int, t: tree) = let function indent(s: string) = (for i := 1 to n do write(“ ”)); output := concat(output, s); write("\n")) in if t=nil then indent(".") else (indent(t.key)); show(n+1, t.left); show(n+1, t.right)) end in show(0, tree); output end可以看出,write需要访问output;indent需要访问访问output,为了实现这一点,引入static link:
Static Link
- 当函数被调用时,通过栈帧传递一个指向最近的栈帧的指针(frame pointer,FP),称为static link(也就是说,static link是frame中的Value,它的值是一个FP)
- callee可以通过栈帧获取上一个栈帧的内容
- 基于static link构成链表将栈帧串联起来,可以实现访问所有深度的栈帧
在上面的例子中:
实际上,static link机制的实现可视为传递的时候有一个隐形参数,将自身link也传递给了callee:
优势与缺点:
- 优点:在传递参数的时候额外的开支较小,实现比较简单
- 缺点:深度为m的函数想要访问深度为n的变量(n<m),需要“爬升”m-n层栈帧,如果相差深度过大,会导致间接访问的开销过大,尤其是函数嵌套多层或者递归调用的情况
使用Display代替Static Link
除了Static Link以外,还可以使用Display方法实现Block结构:
Display
- 一个全局的数组,存储了指向frame的指针
每一个函数都有一个静态的嵌套深度,比如:
main 1 prettyprint 2 write 3 show 3 indent 4- 每个函数的depth在编译的时候就已经确定了
访问Display数组的下标i,对应深度为i的、最近访问的procedure frame,比如前面的例子中,调用indent的时候,d[2]->prettyprint,d[3]->show,d[4]->indent
注意 我推测这种规则只适用于理想情况,即不考虑多线程,不会同时存在深度相同的进程
Lambda lifting
当g调用f的时候,将它的变量视为参数传递给f:
Lambda lifting 下面的两段代码使用了lambda-lifting:
higher-order functions
Tiger中的Frames(Frames in Tiger)
Interface
下面是Tiger语言的Interface:
/* frame.h */ typedef struct F_frame_ *F_frame; typedef struct F_access_ *F_access; typedef struct F_accessList_ *F_accessList; struct F_accessList_ {F_access head; F_accessList tail;}; F_frame F_newFrame(Temp_label name, U_boolList formals); Temp_label F_name(F_frame f); F_accessList F_formals(F_frame f); F_access F_allocLocal(F_frame f, bool escape);这些定义给出了基本的功能要求,具体的实现由具体的机器架构实现(类似的概念我们在OS中也学过(VFS))
局部变量(Local Variables)
有一些变量保存在frame中,有些保存在register中
- 如果要在frame中创建新的local variable,需要调用F_access F_allocLocal(F_frame f, bool escape)
- 传入参数escape表示是否escape,True返回InFrame access,否则返回InReg access
生命周期
- 在处理过程中,遇到变量声明时,allocLocal会被调用,将新的空间与名称v相关联
- 遇到end(closing brace)的时候,将空间与名称解除绑定,但是空间还是保留下来,这就意味着,每一个声明的变量都有一个单独的“frame slot”
- 优化存储:使用尽可能少的寄存器表示临时变量
Temporaries and Labels
- The compiler’s semantic analysis phase will want to choose registers for parameters and local variables, and choose machine-code addresses for procedure bodies.
- But it is too early to determine them exactly.
- Idea: use “abstract registers” and “abstract addresses”
- Temporary: a value that is temporarily held in a register
- Label: some machine-language location whose exact address is yet to be determined
- Temps : abstract names for local variables
- Labels: abstract names for static memory addresses
/* temp.h */
typedef struct Temp_temp_ *Temp_temp;
Temp_temp Temp_newtemp(void);
typedef S_symbol Temp_label;
Temp_label Temp_newlabel(void);
Temp_label Temp_namedlabel(string name);
string Temp_labelstring(Temp_label s);
typedef struct Temp_tempList_ *Temp_tempList;
struct Temp_tempList_ {Temp_temp head; Temp_tempList tail;}
Temp_tempList Temp_TempList(Temp_temp head, Temp_tempList tail);
typedef struct Temp_labelList_ *Temp_labelList;
struct Temp_labelList_{Temp_label head; Temp_labelList tail;}
Temp_labelList Temp_LabelList(Temp_label head, Temp_labelList tail);Translate Layer
- The Tiger compiler will have two layers of abstraction between semantic analysis and frame-layout details:
- The frame.h and temp.h interfaces provide machine-independent views of memory-resident and register-resident variables.
- We need not care where variables are exactly stored.
The Translate module augments this by handling the notion of nested scopes (via static links), providing the interface translate.h to the Semant module.
- translate层的作用:管理局部变量和静态函数
- 为每一个formal/local/function维护Tr_level
接口描述:
/* translate.h */ typedef struct Tr_access_ *Tr_access; typedef ... Tr_accessList ... Tr_accessList Tr_AccessList(Tr_access head, Tr_accessList tail); Tr_level Tr_outermost(void); Tr_level Tr_newLevel(Tr_level parent, Temp_label name, U_boolList formals); Tr_accessList Tr_formals(Tr_level level); Tr_access Tr_allocLocal(Tr_level level, bool escape); /* new versions of VarEntry and FunEntry */ struct E_enventry_ { enum {E_varEntry, E_funEntry} kind; union { struct {Tr_access access; Ty_ty ty;} var; struct {Tr_level level; Temp_label label; Ty_tyList formals; Ty_ty result;} fun; } u; }; E_enventry E_VarEntry(Tr_access access, Ty_ty ty); E_enventry E_FunEntry(Tr_level level, Temp_label label, Ty_tyList formals, Ty_ty result); /* inside translate.c */ struct Tr_access_ {Tr_level level; F_access access;};注意 Tr_level存储在environments中
静态链接
- 将其视为parameter,使用寄存器将静态链接传递并存入frame
跟踪level
/* translate.h */ typedef struct Tr_access_ *Tr_access; typedef ... Tr_accessList ... Tr_accessList Tr_AccessList(Tr_access head, Tr_accessList tail); Tr_level Tr_outermost(void); Tr_level Tr_newLevel(Tr_level parent, Temp_label name, U_boolList formals); Tr_accessList Tr_formals(Tr_level level); Tr_access Tr_allocLocal(Tr_level level, bool escape);- Tr_outermost:返回最外层的level,该level是Tiger主程序的位置,它还包括了所有的library functions,这些function并没有frame或者formal parameter list
Intermediate Code 中间语言
Overview
概念:中间表示(IR)
Intermediate Representation
- 如果直接将AST翻译为机器语言,那么每一个源语言都要为不同架构的机器语言进行翻译的适配,而通过增加一个中间层,将AST转为IR,可以将不同架构的机器语言统一为IR,然后再进行翻译
- IR是一种抽象的机器语言,它并不依赖于具体的机器,而是依赖于抽象的指令集
- 不同的编译器采用不同形式的IR,比如Tiger Compiler使用了IR树
- 一个编译器可以使用多层IR作为中间表示
- 下面介绍IR树的实现
IR树(IR tree)
概念
帮助我们生成目标代码,一个好的IR需要:
- 方便从AST生成
- 方便翻译为机器语言
- 清晰简单的含义,便于优化程序
问题在于:AST中有一些结构能够表达复杂操作(比如数组访问),如果将其转为机器码非常复杂,另一方面,在机器架构中也有一些指令能够执行非常复杂的操作(比如x86汇编),但是AST中的操作并不是能够和机器语言中的指令一一对应,因此需要进行转换:
- 首先,将AST中复杂指令转为极简指令
- 然后,将极简指令转为机器码复杂指令
IR tree节点
接下来我们将使用以下的IR树节点:
将AST转为IRT(AST to IR tree)
概念
- 输入:抽象语法表达式(A_xx开头)
- 输出:IR tree(T_xx开头)
翻译表达式
IR树的表达式分为3种:
- 有返回值的
- 没有返回值的(比如statement)
- 返回值为bool的表达式(表示为一个条件跳转)
C语言定义翻译所需的函数
typedef struct Tr_exp_ *Tr_exp; struct Cx { patchList trues; patchList falses; T_stm stm;}; struct Tr_exp_ { enum { Tr_ex, Tr_nx, Tr_cx } kind; union { T_exp ex; T_stm nx; struct Cx cx; } u; }; static Tr_exp Tr_Ex ( T_exp ex); static Tr_exp Tr_Nx ( T_exp nx); static Tr_exp Tr_Cx ( patchList trues, patchList falses,T_stm stm);- kind类型为ex:对应AST中的T_exp
- kind类型为nx:对应AST中的T_stm
- kind类型为cx:条件表达式,可能导致跳转,使用新的节点定义
前面的两种表达式可以通过之前我们说明的节点类型轻松推导出来,重点为Cx表达式,下面是一个例子:
Cx Example
为了翻译“a>b|c<d”,需要画出其IR树,先将其分解,然后拼接:
- 先翻译a>b:使用的节点为CJUMP(o,e1,e2,t,f),这5个参数我们只知道前3个,不知道为真的时候跳转到哪里,就先使用nullt占位,为假的时候,需要比较c和d,先假设跳转到z:
- 再翻译c<d,同样不知道为真/伪的时候跳转的地方,都先占位
- z是一个label,表示这个地方是比较c和d的地方
- 使用SEQ将statement将3个statement串起来:
翻译成C语言就是:
Temp_label z = Temp_newlabel ( ); T_stm s1 = T_Seq(T_Cjump(T_gt,a,b, NULLt, z), T_Seq (T_Label (z), T_Cjump (T_lt,c,d, NULLt, NULLf )));
前面的例子中,我们并不知道a>b为真或c<b为真假时的跳转地址,所以使用了占位符暂时替代了那些位置,为了方便之后能够替换占位的destination,使用patch list技术,使用true/false patch list追踪所有待填充的占位符,等到真实的地址确定后,再将Nullt/Nullf替换为真正的地址(backpatching)
- 前面已经注意到,我们专门为Cx定义了单独的节点,这个数据结构除了表达式指针外,还有指向patchlist的指针:
Patch List
定义:
typedef struct patchList_ * patchList; struct patchList_ { Temp_label *head; patchList tail; }; static patchList patchList(Temp_label *head, patchList tail);- 在前面的例子中,得到IR树节点(SEQ节点)后,我们再将它和patchlist打包为Tr_Cx节点:
patchList trues = PatchList( &s1->u.SEQ.left->u.CJUMP.true, PatchList(&s1->u.SEQ.right->u.SEQ.right->u.CJUMP.true, NULL)); patchList falses = PatchList( &s1->u.SEQ.right->u.SEQ.right->U.CJUMP.false, NULL); Tr_exp e1 = Tr_Cx (trues, falses, s1);
表达式的转换
有时候我们需要将一个表达式转为等价的其他类型表达式,比如:
Expression Conversion
- flag:=(a>b)|(c<d)需要将Cx转为Ex,类似于一个强制类型转换
为了实现类型转换,我们为每种转换目标表达式类型都定义一个转换函数:
Expression Conversion
3种表达式的转换函数:
static T_exp unEx(Tr_exp e); static T_stm unNx(Tr_exp e); static struct Cx unCx(Tr_exp e);- 传入的参数类型为Tr_exp,说明传入的表达式类型可以是任意类型,即,传入任意类型表达式节点,可以返回指定类型的表达式节点
这样,前面的Tiger代码可以翻译为:
e = Tr_Cx (trues, falses, stm) MOVE (TEMP(flag), unEx(e))下面来实现函数,以unEx为例:
执行过程为:
简单变量 Simple Variable
如果在栈帧中声明了一个变量v,我们需要将它转为IR tree:
- 这个变量需要通过MEM来访问,MEM需要的地址通过当前的栈指针和常数k相加得到
假设机器上的数据都是一个word大小,word大小根据机器架构而定
这一段没听课不知道什么意思:
The interface between Semant and Translate
- The Semant module should not contain any direct reference the Tree or Frame module
- Any manipulation of IR trees should be done by Translate
- Semant passes the access of x (obtained from Tr_allocLocal)and the level of the function in which x is used.
- Tr_Exp Tr_simpleVar (Tr_Access, Tr_Level);
方便起见,将其简写为:
- 删除了BINOP,直接将fp与k相加
为了获取fp和word size,特地在栈帧中存储这两个值:
/* frame.h */ ...... Temp_temp F_FP(void); extern const int F_wordSize; T_exp F_Exp(F_access acc, T_exp framePtr); ......匹配到F_access节点,时,Translate函数调用T_Exp将F_access转为IR节点:
数组变量
在Pascal中,数组赋值语句会将整个数组,包括里面的内容进行复制;在C中,数组变量是常量,无法进行赋值,然而可以将指针常量赋值给指针变量,Tiger(/Java/ML)语言中,数组变量以指针的形式运作,可以赋值给数组变量
Tiger中的数组变量
- 使用t_a[n] of i来构建数组
let type intArray = array of int var a:= intArray[12] of 0 var b:= intArray[12] of 7 in a:= b end- t_a是array type的名称
- n是元素个数
- i是每个元素的初值
- 上面的例子中,先定义一个数组类型:intArray,然后创建2个数组
- 赋值后,a指向b的第一个元素7,自己原来的12个元素被丢弃
- Record类型的值也类似,并不会进行深拷贝,只是指针赋值
- 在C语言中,这种赋值不合法;在Pascal中,a指向的地址空间不变,但是地址空间存储的值变成了7
现在我们能够理解为什么所有变量都是一个字长了,因为不会进行深拷贝
Structured L-values
左值与右值
L-value and R-value
左值既可以出现在赋值语句的左边,也可以出现在右边
- 出现在右边时,代表了一个可以被赋值的地址
- 而右值只能出现在右边,它的地址无法被赋值
标量
Scalar
- 标量指的是没有结构的变量,比如int a,float b,char c
- Tiger变量都是标量,因为只有一个组成部分,包括左值,array,record,后面两者都是指针
C/Pascal语言中的strut,被称为结构化的左值,为了翻译结构化的左值,需要修改MEM表达式,加上size信息:
- T_exp T_Mem(T_exp, int size);
- Mem(+(TEMP fp,CONST kn), S)
- S 表示将要访问/存储的物体大小
处理数组元素和记录域
给定数组基地址和偏移量,可以计算出数组元素的地址
Array Element Address
访问a[i]:对应地址为(i-l) × s + a
- a:数组基地址
- i:数组下标
- l:数组的下界
- s:数组元素大小
- 实际上,如果a是全局的,a - s × l 可以在编译的时候就计算好,而不需要在运行时计算
- 同样的,为了访问record中的成员,需要获得成员的相对偏移量
之前提到,当array subscript expression作为左值时,我们并不需要它的值,而是它的地址,在翻译为IR树的时候,就不能加上MEM层;反之,如果作为右值,将其套上MEM层即可:
Pascal语言中:
- Tiger语言中:所有的record/array value都以指针的形式存在,这就意味着,因此为了获得数组的首地址,我们需要访问该array的指针指向的地址,来获得数组的基地址,然后再加上偏移量,访问数组元素的语句翻译为IR tree如下:
注意
- 在运算中,左值并不能套上MEM,因为是要向它的地址写入
- 而为了方便,我们在构建IR树的时候都套上MEM,等到实际使用的时候根据它在表达式中的位置确定是store还是load
算数
Tiger中的算数表达式有以下特点:
算数表达式
- 每一个算符对应一个Tree operator
- 没有单目运算符,使用0-n表示-n,使用0xorn表示逻辑取反
- 没有浮点数
条件表达式
使用Cx节点表示一个比较操作的结果:
- 翻译x<5:
stm = CJUMP (LT, x, CONST(5), NULLt, NULLf) trues = {t} falses = {f}处理if-else表达式:
首先假设有一个语句为
if e1 then e2 else e3,以下是一个可以实现它的方法:这种方法的问题在于,如果e2,e3都是“statement”,UnEX会被调用,但是最好还是使用特殊的方法特别处理;而如果e2和e3都是Cx表达式,unEX会生成很多jump/label,造成浪费
case Tr_cx: { Temp_temp r = Temp_newtemp( ); Temp_label t = Temp_newlabel( ), f= Temp_newlabel( ); doPatch(e->u.cx.trues, t); doPatch(e->u.cx.falses, f); return T_Eseq(T_move(T_Temp(r),T_Const(1)), T_Eseq(e->u.cx.stm, T_Eseq(T_Label(f), T_Eseq(T_Move(T_Temp(r), T_Const(0)), T_Eseq(T_Label(t), T_Temp(r)))))); }
Example
- 翻译
if x<5 then a>b else 0 - 结果:
SEQ(s1(z,f), SEQ(LABEL Z, s2(t,f))) - tree:
- 为什么不用一个大的JUMP节点? - 避免层级过多
while循环
一个典型的while表达式结构为:
while test:
i
if not(condition) goto done
body
goto testd
done:为了处理循环体中的break语句,我们向transExp结构中添加称为break的成员变量,用于跟踪最近的enclosing loop对应的done label,当遇到break的时候,就使用JUMP跳转至break对应的label
for表达式
一个典型的for表达式结构为:
for for i:= lo to hi
do body实际上,我们可以使用let+while表达式来实现for表达式:
for let var i := lo
i
var limit := hid
in while i <= limiti
do (body; i:= i+1)d
end注意
- 上面的实现方式有一个问题,那就是如果limit = maxint,在第四行执行i:= i+1时,i会溢出
于是保险一点的方法就是不使用while:
for if lo > hi goto done
i := lo
limit := hi
test:i
body
if i >= limit goto done
i := i+1
goto testd
done:
函数调用
翻译函数调用较为简单,但是需要注意将static link作为一个参数传递给被调用的函数:
CALL(NAME lf, [sl, e1, e2, ..., en])- lf:函数f的标签
- sl: static link
- e1,e2,…,en: 参数列表
翻译声明
- 调用transDec函数会在frame data结构中产生副作用
变量声明
- 对于在函数体中声明的变量,需要为其在frame中保留一个位置(FP+offset)
除此以外,还需要:
- 更新value/type environment
- 如果进行了初始化操作,那么transDec函数需要为该变量添加一个赋值语句,这个赋值需要在body(let…in…end)之前执行
函数声明
在frame中,调用transDec会产生一片新的区域,其结构如下:
其他
- If transDec is applied to function and type declarations, the result will be a “no-op” expression, such as EXP(CONST(0))
Canonical IR 规范中间表示(Basic Block&Trace)
Overview
首先我们回顾一下之前所学:
总结
我们已经将AST转为IR,接下来将学习如何将IR转为汇编指令,然而这两者之间还有一个标准化的过程:
有了IR,理论上我们能将其翻译为大部分的汇编指令,当然,前提是IR代码操作和汇编指令是一一对应的;然而,当前IR的形式(树结构)会导致一些问题:mismatch和性能问题
目前IR的缺陷
在if语句中,我们通常会使用两个跳转标签,但是我们更喜欢一个跳转,也就是:在进行条件计算后,马上就是false label,因此之用一个跳转指令:
ESEQ的返回值严重依赖于执行顺序:
- 回顾ESEQ:ESEQ(s,e):连接语句和表达式,先执行s获得副作用,再计算e,e的结果就是ESEQ的值
- 当ESEQ与其他的表达式相邻时,子树的运算顺序就至关重要:
- 在上面的IR树中,如果s中的语句改变的e2的值,那么就会改变原来的BINOP的结果,CALL节点也有相似的问题
- 我们的目标是:可以任意改变一个表达式子式的运算顺序,但是不影响最终的运算结果
- 当使用函数调用作为另外一个函数的参数时(比如
CALL(f, [e1, CALL(g, [e2, ...])])),如果使用固定的一套寄存器(比如r1,r2,…,rn)来传递参数,那么会导致寄存器的覆写(e1,写入r1,作为f的参数,然而马上将r1覆写为e2,作为g的参数)
现在我们将通过三个步骤消除以上的问题:
- 通过重写tree,得到一个规范化的树(Canonical IR),消除SEQ/ESEQ节点,这会解决问题2和3
- 将IR分为多个基本块,并调整顺序,使得中间没有跳转和标签
- 将基本块按顺序分组为traces,使得CJUMP后面立即跟上false label,并删除一些jump,这会解决问题1
第一步:重写IR树(Rewriting IR Tree)
canonical tree
概念
规范化的IR树由以下性质定义
- 不包含ESEQ和SEQ节点
- CALL节点的父节点要么是EXP要么是MOVE(TEMP t,…)
由此,我们可以推导出以下性质:
性质
- 每一个canonical tree都是包含最多一个statement node(root node),其他的都是expression node
- CALL node的子节点不可能也是CALL node
- 推论:CALL node的父节点一定是root node
- 推论:最多只有一个CALL node
下面我们将通过一系列操作将普通IR,转化为规范化的IR树
注意
- 在这个部分,我们将把IR的树形结构转为线性结构,线性结构自然地说明了各个statement之间执行的顺序,而IR树需要通过SEQ/ESEQ节点来连接语句以维持顺序,重写IR树本质上就是通过分析原先IR树中statement/expression的依赖关系,重新排列statement的执行顺序,使得不需要借助ESEQ/SEQ节点规定执行顺序
消除ESEQ
基本思想:将ESEQ不断向上提,直到能够变为SEQ节点:
两个例子
- 包含两个ESEQ的情况
- 包含BINOP的情况:关键是保存计算执行顺序
- 结果:只有根节点可能是ESEQ节点,其他节点只可能是SEQ节点或者其他节点
具体实现方式:
- 提取ESEQ(s,e)中的s
- 找到ESEQ的父节点,将其重写为ESEQ
在某些情况中,s不能够先执行,因此需要将表达式的值保存至临时变量然后执行s:
- 当然,如果s不改变e1的值,我们还是能够使用中间的方式重写,下面来说明能够这样做的条件:
可交换commute
s和e1可交换的条件:
- 不进行额外的io操作
- s使用的寄存器/内存空间不会被e1使用
注意 有些条件无法在编译的时候检测(这是一个不可停机问题),因此会使用commute函数进行保守估计
commute函数:
- commute(s,e) = true if s and e definitely do commute
- commute(s,e) = false otherwise
commute函数描述:
expression statement 常数 任意statement 任意expression 空statement - 其余都认为不可交换
实现:
static bool isNop(T_stm x) { return x->kind == T_EXP && x->u.EXP->kind == T_CONST; } static bool commute(T_stm x, T_exp y){ return isNop(x) || y->kind == T_NAME || y->kind == T_CONST; }
一般重写规则
- 对于每一个statement/expression节点,找到他的子表达式集合,递归将其重写为ESEQ-clean version
- 根据s/e的关系将其重新组合
举例
- 给定一个子表达式集合[e1, e2, ESEQ(s, e3)]
- 将语句s提取出来,然后更新集合:
将CALL节点移动至顶部
- 之前提到,CALL节点可以作为一个子表达式,比如BINOP(PLUS, CALL(…), CALL(…)),然而,每个函数调用都将其返回值存储在同一个寄存器中(TEMP(RV)),因此第二个CALL会抹去第一个CALL的返回值
解决方式很简单,那就是将CALL的返回值存储到一个临时变量中,然后将这个临时变量(ESEQ)作为参数传递给父节点:
- CALL(f, args) -> ESEQ(MOVE(TEMP t, CALL(f, args)), TEMP t)
消除SEQ节点
- 执行了之前的重写后,我们将会得到一个树,这个树中所有的SEQ节点都会靠近根节点
- 接下来我们将SEQ节点全部移动到右子树上,很简单:
- 于是轻而易举地提取得到一个线性的statement序列s1,s2,s3,s4,…,sn,每个statement都不包含SEQ/ESEQ节点
总结
- 下面的表总结了重写IR树的规则:
- 不知不觉间,我们发现可以将IR的树形结构转为代码类的线性结构,这为之后转为代码提供了条件
第二步:划分基本块(Dividing into Basic Blocks)
- 这一部分我们的任务是让CJUMP后面立即跟上false label,并删除一些jump
为了分析程序的跳转情况,我们需要分析程序的控制流:
控制流
- 控制流(Control Flow)是指令的序列,它忽略寄存器/内存的值,忽略算数计算,只关心指令的执行顺序
- 我们无法预测实际程序的执行情况,无法画出完全准确的控制流,我们只能根据代码的性质,将“连接得更紧”的指令分组
基本块
概念:
基本块 Basic Block
基本块是一段按顺序执行的指令:它由以下性质定义:
- 第一个语句一定是label
- 最后一个语句一定是jump/cjump
- 中间没有label/jump/cjump
- 这就意味着无法从中间跳进/跳出
LABELXX ... # no LABLEs, JUMPs, CJUMPs JUMP/ CJUMP
将程序分割为基本块
我们可以通过以下方式将程序分割为若干个基本块:
- 从头到尾扫描
- 遇到label就分割,创建新的block
- 遇到jump/cjump就结束,并创建新的block
为了保证basic block能够随意打乱,需要为缺少label/jump的block添加一个label和一个jump
- 如果遇到新的label前没有新的jump/cjump,就为它创建一个jump,跳转至新扫描的label
- 如果遇到jump/cjump后面没有跟label,就为新创建一个label
下面是一个例子:
基本块
第三步:轨迹 Traces(真正解决cjump问题)
- 在得到一系列基本块的基础上,通过重排基本块,可以让CJUMP后立即跟上false label,同时让jump后面立即跟上跳转地址,从而删除一些jump
轨迹
轨迹 Trace
- 轨迹是一系列基本块,它反映了程序实际执行过程中的顺序
- 轨迹可以包含label/jump
- 轨迹可以重叠
优化目标:最小化trace的covering set:
- 每个block只出现在一个trace中
- 每个trace不包含loop
获得最小化的trace集合
- 进行DFS,从一个节点(basic block)开始,进行DFS,直到后续的节点都被标记,结束得到一个trace,查看没有被标记的节点新建trace
DFS Put all basic blocks into Q
while Q is not empty do i
start a new trace T
pop the head element of Q
while b is not marked i
mark b;append b to the end of T
for each successor of b do i
if the successor is not marked do i
b <- successor d d d
end the current Trace
优化算法
为了简化后续的一些操作,Tiger编译器会进行一些小修改:
- 对于CJUMP紧接着false label:不变
- 对于CJUMP紧接着true label:交换true和false label,然后将条件取反
对于CJUMP没有紧接着任意一个label:新建一个false label(lf’),将CJUMP重写为:
进一步的优化:经常使用的一串指令应该只占用一个trace,不要出现在多个trace中,这有助于减少jump次数:
一个例子:下面哪一段代码最好?
- 加粗意为不需要
Abstract Assembly Code 抽象汇编指令
Overview
- 在获得了Canonical IR之后,我们需要进行Instruction Selection,将IR tree转为汇编指令
通常来说,IR节点表示的是极简的操作,而汇编语言表示的是较为复杂的操作,这个操作可能包含了数个IR节点,比如:
该章节介绍Instruction Selection阶段:找到合适的汇编指令用于实现IR树
注意
- 针对树形IR(我们学的)和线性IR有不同的转化方法,我们学习的是前一种
树型与指令集(Tree Pattern and Instruction Set)
树型
为了将IR树转为汇编指令,我们需要找到每种指令对应的IR节点结构,这种结构称为树型:
树型 tree pattern
- 树型指的是汇编指令对应的IR树结构
- 所谓Instruction Selection,就是使用(不重叠的)树型覆盖整棵IR树(Tiling),可以类比用不同形状的“瓦片”覆盖一块“房顶”
树型与指令集的对应
我们这节课使用Jouette指令集,它的指令集对应的树型如下:
注意
- r0寄存器一直储存0
- 有些指令对应多种树型
- 可以注意到,上面的树型中并没有出现SEQ和ESEQ,这是因为我们在上一步已经将其消除了
算法(Matching Algorithm)
覆盖算法
下面的例子解释了将树型与指令匹配的过程:
一个例子:将树型与指令匹配
- 给定指令
a[i] := x,其IR树如下: 下面的给出了两种不同的划分,它们都是合法的:
图 27 不同划分 
- 1,3,7都是临时寄存器,不对应任何指令
当然,也可以将其划分到每个节点对应一条指令:
ADDI r1 ← r0 + a ADD r1 ← fp + r1 LOAD r1 ← M[r1 + 0] ADDI r2 ← r0 + 4 MUL r2 ← ri × r2 ADD r1 ← r1 + r2 ADDI r2 ← r0 + x ADD r2 ← fp + r2 LOAD r2 ← M[r2 + 0] STORE M[r1 + 0] ← r2
- 给定指令
然而这种划分方式并没有意义,我们想要的划分力求使用最少的Tile将所有节点覆盖,在其基础上定义3种优化目标:
- Best tiling最佳覆盖:对应最小的cost,对于单发射延迟相同的机器而言,意味着拥有最少的指令数量
- (Global)Optimum tiling(全局)最优覆盖:cost 最低,全局最优必然局部最优
- (Local)Optimal tiling(局部)最优覆盖:无法通过合并方式获得更小的cost(合并前的cost必然更高)
- 接下来介绍几种覆盖算法
Maximal Munch
概念
Maximal Munch
基本思想:
- 假设更大的tile有更好的性能
- 使用贪心算法,自顶向下遍历IR树,选择当前最大的可匹配的树型
- 对所有子树重复该过程,直到IR树被完全覆盖
- 如果两个树型大小相同,那就选择任意一个
- 这种贪心思想实现的是局部最优而非全局最优
举例:
一个例子:Maximal Munch
- 给定指令
a[i] := x,其IR树如下:
从根节点开始,使用最大的匹配MOVEM
左子树,从加号开始,匹配ADD
- 左子树,从MEM开始,匹配带CONST的LOAD
- 右子树,从乘号开始,匹配MUL
- 右子树,从加号开始,匹配带CONST的ADDI
- 使用不同的颜色标记不同的tile,结果如下:
- 给定指令
实现方法:使用两个递归函数,按照tile优先级匹配子句
- munchStm: 匹配一个语句
- munchExp: 匹配一个表达式
Dynamic proigramming
为了获得全局最优解,可以使用暴力搜索,遍历所有的可能,但是效率较低,可能有重复计算,使用动态规划方法可以根据子节点的cost获得当前的最优cost,从而避免重复计算
Dynamic programming
基本思想:根据每一棵子树的最优覆盖找到整棵树的最优覆盖
方法:自底向上进行遍历
- Base case:叶子节点的覆盖
Inductive step:
- 对于每个节点,遍历所有合法的覆盖
对于每一种覆盖,计算cost:
- 其中是该覆盖的cost,f(t)是该覆盖下子树的cost
- 选择cost最小的覆盖作为当前节点的覆盖
- Goal:得到根节点的cost,就是整棵树的cost
举例
一个例子:Dynamic programming
- 给定指令
M[1+2],其IR树如下: - 对于两个CONST节点,两者都只有一种选择:CONST=1
对于+节点,有3种选择:
- +(e1,e2),cost = 1(自己的)+cost(e1)+cost(e2) = 3
- +(CONST,e1),cost = 1(自己的)+cost(e1) = 2
- +(e1,CONST),cost = 1(自己的)+cost(e1) = 2
对于MEM节点,有3种选择:
- MEM(e1),cost = 1(自己的)+cost(e1) = 3
- MEM(+(e1,CONST)),cost = 1(自己的)+cost(e1) = 2
- MEM(+(CONST,e1)),cost = 1(自己的)+cost(e1) = 2
- 因此最小的cost为2
- 给定指令
指令流出
- 在tiling阶段,为了记录每个节点对应的cost,使用记号(c,r)标记节点,c代表cost,r代表使用的rule
指令流出(Emission):Emission(node) =
- 检查其(c,r),获得r对应的tile
- 对于tile中的每一个子树,递归调用Emission(child)
- 根据r翻译对应指令
一个例子:Emission
- 对于前面的例子:
- 先检查MEM节点,对应的rule为10,递归检查树型对应的子树(CONST 1)
- 子树CONST 1对应的rule为8,它没有子树,可以执行流出,翻译为
ADDI r1 ← r0 + 1 - 子树流出完毕,可以流出MEM节点,对应的指令为
LOAD r1 ← M[r1 + 2]
- 实现细节:fast matching,不仅要匹配根节点,还要匹配有标号的叶子节点,通过决策树可以加速匹配
算法复杂度
以上两种算法的复杂度如下:
算法复杂度
假设:
- 匹配成功的tile,平均含有K个(labeled)非叶子结点
- 输入的树共包含N个节点
- 为了确定一个树型是否匹配,最多需要检查K‘个结点
- 对于每个节点,平均有T’个匹配的树
- 当使用决策树时,平均使用(K‘+T’)即可确定最优的tile
maximal munch:
- 平均共有N/K个tile
- 每个tile匹配的平均时间为O(K‘+T’)
- 复杂度为O(N(K‘+T’)/K)
dynamic programming:
- 平均共有N个tile
- 每个tile匹配的平均时间为O(K‘+T’)
- 复杂度为O(N(K‘+T’))
- K、K‘、T’都是常量,因此两种算法都是线性复杂度
CISC指令集(CISC Instruction Set)
有关CISC指令集
与RISC的区别
问题与解决
- 寄存器较少:把问题丢给编译器的register allocator
- 寄存器种类多:使用额外的移动指令(见汇编语言x86汇编)
- 2-address instructions:通过额外的移动指令
- 作为内存的操作数:
add [ebp - 8], ecx使用中间寄存器暂存 side effects:
r2 ← M[r1];的副作用为:r1 ← r1 + 4,有3种解决方式:- 忽略
- 不使用Tree pattern而是DAG pattern
- 增加机制,抵消副作用
Tiger实现(Tiger Implementation)
- 懒得写了
Liveness Analysis 活跃分析
Overview
我们初步将IR树转为了抽象汇编指令,这种汇编指令的特点是:假设有无穷多的寄存器可用,但是在实际情况中,不可能有无穷多的寄存器可用,因此需要通过活跃分析确定哪些临时变量可以放在同一个寄存器中,关键在于寻找冲突的临时变量
如果两个变量不同时“活跃”,那么它们可以被放在同一个寄存器中,比如:
a=b+1; return a;- 上面的a和b不会被同时使用,那么它们可以被放在同一个寄存器中
- 需要分析IR程序的活跃性:未来要被用到的变量,就是活跃的,接下来我们将使用数据流图分析一个变量的活跃性
流程图分析(Dataflow Graph Analysis)
下面我们使用以下的程序作为例子:
a <- 0 L1 : b <- a+1 c <- c+b a <- b*2 if a < N goto L1 return c它对应的数据流(dataflow)图如下:
图 28 数据流图 
描述数据流图
注意
- 活跃性分析其实是一个dataflow problem
定义边和节点:
out-edges/in-edges/pred/succ
对于一个节点而言,它的:
- out-edges:连接它和后继节点的边集合
- in-edges:连接它和前继节点的边集合
- pred[n]:n节点所有的前继节点
- succ[n]:n节点所有的后继节点
对于我们用的例子:
举个例子
- 节点5的出边:5->6,5->2
- succ[5]:{2,6}
- 节点2的入边:5->2,1->2
- pred[2]:{1,5}
定义Use和Def
Use/Def
以动作(action)的角度:
- 对一个(临时)变量的赋值(assign)定义(define)了这个变量
- 赋值语句(或者其他语句,比如函数调用,逻辑表达式)中变量在等号右边的出现使用(use)了这个变量
以实体(entity)的角度:
- 对于一个变量而言,它的def集合是所有定义它的节点集合
- 对于一个节点而言,它的def集合是它定义的所有变量集合
- use的定义类似
对于我们的例子而言:
举例
- def(3) = {c}
- def(a) = {1,4}
- use(3) = {c,b}
- use(a) = {2,5}
定义活跃Live
活跃Live
变量V在边e上活跃的条件:有一条有方向的路径,这条路径从当前边e开始,到任意一个use(v)节点结束,并且途中没有经过def(v)节点
注意
上面的性质说明:
- 这一个变量等一下要用到
- 这一个变量暂时不会被重新定义
定义Live-in和Live-out:
Live-in/Live-out
- Live-in:对于一个节点n和一个变量v而言,v在n的所有in-edge都live
- Live-out:v在n的所有out-edge都live
在此基础上,定义n的in/out集合:
- in[n]:所有在n节点live-in的变量集合
- out[n]:所有在n节点live-out的变量集合
活跃性推导
- 对于一个图而言,它的活跃性信息(liveness information,由in[n]和out[n])可以使用use和def信息推导出
推导规则:
活跃性推导规则
- Rule 1: ∀ m ∈ succ[n], if a ∈ in[m] , then a ∈ out[n]
- Rule 2: if a ∈ use[n], then a ∈ in[n]
- Rule 3: if a ∈ out[n] and a ∉ def[n],then a ∈ in[n]
下面是一个例子:
举例
数据流图:
图 29 数据流图 
- 假设in[m1] = {d},in[m2] = {e}
那么:
- out[n] = {d,e}
- in[n] = {b,d,e}
- out[p] = {b,d,e}
- in[p] = {c} ∪ ({b,d,e}-{b}) = {c,d,e}
基于以上的规则,我们可以得到下面的Dataflow equation用于计算活跃性信息:
- 意义:入边活跃的两个可能性:使用了变量;没定义,但是在出边活跃
- 意义:通过分析后继节点入边的活跃性,获得前继节点出边的活跃性
下面的算法可以通过iteration找到等式解
Liveness for each n
i
in[n] ←{}; out[n] ←{}d
repeati
for each ni
in′[n] ← in[n];
out′[n] ← out[n]
in[n] ← use[n] ∪ (out[n] − def[n]) 更新in: Rule2+3
out[n] ← in[s] 更新out: Rule1dd
until in′[n] = in[n] and out′[n] = out[n] for all n 迭代直到不再变化对于前面的例子:
in/out的生成
图 30 数据流图 
注意
- 当使用迭代方法解决数据流等式时,计算顺序应该遵守“flow”的顺序,由于前继节点的信息部分依赖于后继节点,每次沿着数据流动的逆方向遍历所有节点通常可以使得结果收敛更快
- “Liveness flows backward along control-flow arrows, and from “out” to “in”, so should the computation.”
时间复杂度分析
- 对于一个大小为N的程序,其最多包含N个节点和N个变量
- 每个set- union操作的时间复杂度为O(N)
- 每个iteration的时间复杂度为O(N^2)
- 由于in和out集合的大小加起来不超过,因此最多循环次
- 因此总的时间复杂度为O(N^4)(最坏情况,实际上在O(N)和O(N^2)之间)
一些讨论(Discussions)
改进
- 对于那些“一根筋到底”的路径,可以将其合并,作为一个基本块分析
- 有一些变量的生命周期很短,因此可以只在变量的信息要被用到的时候才进行数据流计算
实现
表示in/out set:
实现in/out
操作:
- union
- subtraction
第一种实现:bit arrays(密集)
- 假设程序中有N个变量,一个变量大小为Kbit
- 那么bit map大小为Nbits
- 使用or操作实现union
第二种实现:sorted lists(稀疏)
- 使用totally ordered key进行分类
- 使用merging操作进行union
静态/动态活跃性(Static/Dynamic liveness)
对于一个跳转语句,程序并不能提前知道跳转结果,但是有一些程序可以通过分析得知其跳转结果,比如下面的例子:
图 31 例子 
可惜,这个问题无法从根本上解决,因为它可以使用停机问题进行规约:
停机问题 There is no program H that takes as input any program P and input X and (without infinite-looping) returns true if P(X) halts and false if P(X) infinite-loops
引理 No program H’(P, L) can tell, for any program P and label L in P, whether the label L is ever reached on an execution of P
证明:
- 假设存在H‘
构造H:
- 对于任意输入P,X,如果停机就goto L,否则goto L’
- 由于H’是停机的,因此H’(P,L) 可归约为 H(P,L)
- 因此不存在H’
上面的证明说明没有一个算法可以预测任意的跳转,但是确实可以构造一些算法来预测部分的情况:
dynamic/static liveness
- Dynamic liveness: a variable a is dynamically live at node n if some execution of the program goes from n to a use of a without going through any definition of a.
- Static liveness: a variable a is statically live at node n if there is some path of control-flow edges from n to some use of a that does not go though a definition of a.
- 如果a是动态活跃的,那么它也是静态活跃的
Register Allocation 寄存器分配
Overview
由于读写寄存器的速度大于读写内存,因此我们最好使用寄存器存储信息,然而寄存器的数量并不是无限的,我么在上个阶段(IR生成)获得的中间表示忽略了这一点,因此在寄存器分配阶段,我们需要使用实际的寄存器名称替代临时寄存器名称
register allocator
- 寄存器分配,将多个临时寄存器变量分配给k个机器寄存器
- 对于溢出(无法使用寄存器存储)的变量,需要最小化读写内存的次数
分配算法:
- 图染色算法(Graph coloring):老的编译器,包括GCC,Hotspot,Open64,本节课介绍的算法
- 线性扫描算法(Linear Scan):LLVM,,ART等新的编译器使用
冲突图(Interference Graphs)
概念
活跃性分析最重要的应用之一就是寄存器分配:
互斥/冲突 Interference
- 假设我们有临时变量a,b,c…以及寄存器r1,r2,r3…
阻止a和b被分配至同一个寄存器的条件称为interference(互斥),它有两种原因:
- 重叠的活跃范围
- a被不能使用r1的指令生成,那么a和r1互斥
表示冲突情况
- 第一种表示方法:矩阵,互斥的格子标上x
第二种表示方法:无向图,节点表示变量,边表示互斥
MOVE操作的特殊情况
下面展示了MOVE操作的特殊情况:
添加新的互斥边的算法为:
- 对于nonmove的节点n,它定义了变量a,out[n] = {b1,b2,…,bj},添加互斥边(a,b1),…,(a,bj)
- 对于move的节点n,它定义了变量a := c,out[n] = {b1,b2,…,bk},添加互斥边(a,b1),…,(a,bk),如果bi不等于c
染色算法(Coloring Algorithm)
之前在离散数学这门课中已经接触过染色问题,其描述如下:
染色问题
- 确定代码的互斥图(interference graph)
- 对于每个节点,给它分配一个颜色,要求一条边不能连接两个具有相同颜色的节点
- 染色问题是NP完全问题,因此无法在多项式时间内解决,但是有一些近似算法,下面介绍一种线性复杂度的算法,其步骤包括:Build,Simplify,Spill,Select
Build
- 每个节点代表一个临时变量
- 一条边(t1,t2)代表一对不能同时分配给同一个寄存器的临时变量
Simplify
我们提出以下的定理,用于简化图:
定理
- 假设图G包含一个节点m,节点m的邻居数量小于K(K是寄存器数量)
- 假设移除m得到的图为G’
- 如果G’可以被染色,那么G可以被染色
证明:
- 假设G’可以被染色,变成G只需要加上m
- 这包括添加k’条边,k’<K
- 有k种颜色可选,去掉k’个颜色后,仍然有颜色供m选择,因此m可以被添加
基于以上的规则,我们可以构建一个递归(stack-based)算法
- 对于G,重复移除degree小于k的节点,每次移除的时候将其push入栈
- 每次删除节点都会降低邻居的degree,使得化简的机会更大
下面是一个例子:
Simplify例子
初始状态:k = 2
Stack 图 32 初始状态,图中虚线表示已经被移除 
递归移除K-colorable节点(这里只有m节点),然后将其加入栈中,直到G只包含significant degree(degree >= k)的节点
Stack m 图 33 simplify后状态,图中虚线表示已经被移除 
Spill
- 在上面的例子中我们就能发现,将所有k-colorable的节点都移除后,图中剩余的节点可能有一些节点的degree大于等于k,这称为Spill(溢出)
- 处理这些节点,我们可以将其存入内存中,但是实际上有一些变量是可以存到寄存器中的,其原理就在于,并不是所有的邻居都是不同的颜色,如果有相同颜色的邻居,就有可能有剩余的颜色可选
为了实现上面的功能,我们提出乐观染色:
Optimistic coloring
- 乐观估计:溢出的节点并不会与其他节点冲突,因此可以被移除并加入栈中
potential spill:
乐观染色例子
接着之前的例子,状态:k = 2
Stack m 图 34 simplify后状态,图中虚线表示已经被移除 
随机选择一个spill节点,将其删除,这里选择b,然后就可以继续之前的化简工作了,一次删除d,a,c,得到以下的最终状态:
Stack c a d b m 图 35 注意,b节点需要标记为potential spill节点 
注意 spill还分为actual spill和potential spill,前者才是真正溢出的节点
Select
- 首先,根据之前得到的栈以及graph,每次从栈中取出一个节点,染色后放回
遇到potential spill节点,可以根据周围邻居的情况确定能否染色:
- 对于邻居颜色种类少于k的,可以染色
- 如果邻居颜色种类仍然大于等于k,将其标记为actual spill,将其忽略,然后继续染色,结束后重写程序(使用内存进行存储,使用到的寄存器生命周期更短,但是冲突情况也会改变),重新执行allcation步骤
接着之前的例子:
乐观染色例子
假设一共有绿和蓝两种颜色:
Stack b m 图 36 遇到b之前,正常填充 
之前将b标记为了potential spill,查看邻居,发现两个邻居的颜色相同,那么还是可以将其染成绿色
Stack 图 37 染色完成 
然而对于下面的情况,无法进行染色,发生了actual spill
Stack d b m 图 38 无法给d染色 
此时跳过d,继续处理其余的节点:
Stack 图 39 最终状态 
总结
下面的图简单展示了simplify的过程:
图 40 化简过程 
Coalescing
基本思想
对于MOVE指令而言,如果它在冲突图中对应的两个节点(临时寄存器)并不冲突,那么可以消除MOVE,合并两个节点
假设MOVE t3, t2不冲突,那么就可以将其合并:
图 41 合并t3,t2 
- 可能可以提升可着色性(t1和t4的邻居更少了)
- 合并节点可能带来问题:,合并后的节点可能有更多的邻居,这样原本k-colorable的节点可能不再满足,为了解决这个问题,下面介绍两种策略,争取只进行“安全/保守”的合并
Briggs
算法:
Briggs
- 尝试合并节点a和b
- 查看新节点的邻居
- 如果degree大于等于k的邻居节点个数小于k,就可以合并
- 其原理在于,经过simplify的图只包含degree大于等于k的节点,邻居小于k说明可以被simplify删除
George
算法:
George
- 查看a的每一个邻居t
- 如果对于每个t而言,t要么已经与b互斥,要么b的degree小于k(insignificant degree),那么就可以将其合并
其原理在于,对于t而言:
- 同时与ab互斥,说明合并ab后不会增加t的degree
- degree小于k,说明它本来就可以通过simplify删除
注意
- 助教老师在期末复习时提到:

- 同学们好,也有同学对这页 PPT 里 a & e 为什么不能使用 George 法则进行合并有疑问。这里统一答复一下。
- 省流:从理论上看,其实可以使用课本上给出的 George 法则对 a & e 进行 coalescing,只不过 coalesce 后仍然无法继续进行 coloring,因此需要进入 actual spill 流程。而在具体实现中, George 只用于一边是 precolored,另一边不是 precolored 的情况,并且使用 George 法则时,下面这页 PPT 中的 b 需要对应 precolored,a 对应 non precolored node。

- 虎书在第 11 章介绍寄存器分配的 coalesce 时,由于还没介绍到 precolored,顺序比较混乱,因此单看这一节(11.2)其实对 George 的描述并不是原文的描述。在 11.4 节讲解图染色算法实现时,有如下表述:

- 也就是说,在实现时其实对于两边都 non-precolored 的情况,使用 Briggs;而有一边为 precolored 时使用 George。然而,在课本使用的伪代码(即 George 等人,Iterated Register Coalescing,1996 年)中,其实只使用了考虑了 precolored node 版本的 George 法则进行合并。在 Iterated Register Coalescing 这篇论文的实现细节部分,考虑了 precolored node 后对 Coalesce 条件的表述如下:

- 而对应的伪代码中也是这么做的:

- 是的,尽管论文前文中也说了使用 Briggs 法则进行 coalescing,但在那个部分还不涉及 precolored node。而在实现细节中考虑 precolored node 后,才真正给出了 George 法则的具体表述。如果遵循论文中的伪代码来实现,实际上并没有用到 Briggs 法则来 Coalescing。(论文内容与具体实现有些许不一致其实挺常见的,有参与科研的同学或许深有体会)这里同样给出 Briggs 等人,Improvements to Graph Coloring Register Allocation,1994 年论文中提出的 Coalesce 条件:

- 由于不涉及 precolor node,此处与虎书上表述一致。 其实虎书这一章节的行文顺序和 George 等人的论文 Iterated Register Coalescing 基本一致,只是合并了 Briggs 法则的内容。可能虎书作者出于某些考虑将 George 法则中关于 precolored 的相关部分删去并与 Briggs 法则一起介绍,然而 George 法则在实现时只用于包含 precolored node 的情况。 不过,即使使用课本上的 George 法则对 a & e 合并,最后仍然需要进入 actual spill 阶段,只不过虎书对于这部分没有详细展开,直接快进到进行 actual spill 阶段。
- 同学们好,之前这里对伪代码的解释有点问题。在实现中也是在有一边是precolored时使用George,两边都是non-precolored时使用Briggs。并且在使用George时,是对Non precolored node的邻居来判断是否符合条件。
总结
下面这张图总结了目前所学的allocation过程:
图 42 Allocation过程 
- 例子:
预先着色节点(Precolored Nodes)
概念
有一些寄存器有专门的用途,比如:
- argument-registers
- frame pointer
- return-value-register
对于这些寄存器,我们使用永远绑定的某个临时变量代表,在染色时也有特殊的称谓:
precolored nodes
- precolored nodes: 预先着色的节点
- 对于一种颜色而言,其precolored node只能有一个
- 所有precolored nodes都互相冲突
在分配寄存器时保护precolored nodes
- 首先,不能将其简化,也不能使用spill
- 其次,必须让它的活跃范围尽可能短
一个例子
假设r7是一个callee-save register:
- t231承接r7,如果该程序内部对寄存器的需求很大,那么t231会spill,否则r7和t231会合并,消除MOVE操作
caller/callee-save的选择
对于一个局部变量而言:
- 如果它在任何进程调用中都不活跃,那么就使用caller-saved register储存
- 如果它在一些进程调用中活跃,那么就使用callee-saved register储存
其原因在于:如果x在一个进程调用中是活跃的
- 那么它与所有caller-save(precolored)register冲突
- 它与所有新创建给callee-saved register的temporaries冲突
- 会发生spill
- 最终一般的spill算法会将degree较高但是不怎么活跃的node spill
总结
- 使用染色算法解决寄存器分配问题:
Garbage Collection 垃圾收集
Overview
在activation records章节,我们学习了内存的一种典型布局:
Code,可执行的代码 Static,静态数据 Stack,动态数据 ↓
Free Space
↑Heap,动态数据 为了可持续利用内存空间,我们需要动态分配和回收内存(C/C++中叫malloc/free),传统的古法管理容易导致内存泄露,double free,use after free等问题,我们需要一种自动管理栈帧的方法
Garbage collection
- 垃圾收集就是将已分配但是不再使用的存储回收的过程
- 该过程不需要特意调用来执行
- 该过程应该由runtime system(比如OS)来执行,而不是编译器来执行
在定义中我们提到了Garbage,现在我们定义它:
Garbage
- Garbage: 已分配但是不再使用的存储
理想情况下,not-dynamically-live的record就是garbage,但是我们无法确定一个具体object的情况,因此使用一个近似算法:
- Heap-allocated records that are not reachable by any chain of pointers from program variables are garbage
- Conservative: not reachable -> garbage
一个object x是reachable的,当且仅当:
- 一个寄存器存储了指向x的指针
- 一个reachable的object y存储了指向x的指针
实际情况下,我们可以为内存分配情况画一个有向图:
图 43 内存分配图A node n is reachable if there is a path of directed edges r(a root) -> ... -> n 
Mark-and-Sweep Collection
算法描述
使用freelist标记空闲的内存空间
算法
第一阶段:Mark
- 对于每一个root节点运行DFS
- 标记所有reachable的节点
第二阶段:Sweep
- 遍历所有节点
- 回收所有未标记的节点,加入freelist
- 最后取消所有mark
复杂度计算:
- 假设H:heap size,R:reachable Data
Time of GC
- Mark: DFS takes time proportional to R
- Sweep: takes time proportional to H.
- Total time:
- GC replenish the freelist with H-R words
Amortized cost
- If R is closed to H, the cost is very High
优化DFS算法
- 传统的DFS算法使用了递归,在极端情况(一长条长度为H的list)下,需要为DFS创建长度为H的activation records,这样十分的浪费
优化一:使用stack:
function DFS(x) if x is a pointer and record x is not marked mark record x t ← 1 stack[t] ← x // push the start of DFS on stack while t > 0 x ← stack[t]; t ← t – 1 // pop an item from the stack for each field fi of record x if x. fi is a pointer and record x.fi is not marked mark x. fi t ← t + 1; stack[t] ← x. fi- 这样我们就不使用activation records而是word(pointer)来记录,更省空间,但是stack大小可能和我们要分析的Heap大小一样了!
优化二:Pointer Reversal
该方法直接在graph中进行操作,而不是使用stack
function DFS(x) if x is a pointer and record x is not marked t ← nil mark x; done[x] ← 0 while true i ← done[x] if i < # of fields in record x y ← x. fi if y is a pointer and record y is not marked x. fi ← t; t ← x; x ← y mark x; done[x] ← 0 else done[x] ← i + 1 else y ← x; x ← t if x = nil then return i ← done[x] t ← x. fi; x. fi ← y done[x] ← i + 1 •done: how many fields in each record have been processed •t: the top of the stack当遇到新的record x时:
- Mark x
- 在DFS(x.fi)前,反转指向x.fi的指针,使其指向x(pointer reversal)
- 当DFS无法继续进行时,那么通过这个反向指针回到上一层,然后将方向恢复原样
优缺点
- GC期间没有移动对象
- 可以处理环
- 在GC期间,其余的execution必须暂停,结束后才能恢复
可能导致heap中的fragmentation:
fragmentation
- 分为Internal/External fragmentation
Reference Counts
可以类比文件系统中的Reference Count
主要思想
- Mark-and-sweep需要等到memory耗尽,才开始回收,而Reference Counts可以在程序运行时回收内存
Reference Counts
- 对于每一个record,使用一个counter来记录指向它的指针数量
- 当counter为0时,说明没有指向它的指针,可以回收
- 每次有指针指向一个record时,counter加1
算法
维护counter
当p被存入x.fi时:
- p的counter加1
- x.fi之前指向的counter减1
如果counter为0
- 将其加入freelist
- 对于所有它指向的record,counter减1
优缺点
- 缩短了object变为垃圾到被回收的时间,提高了利用率
- 无需暂停程序执行即可实现回收
缺陷:无法处理环,比如:
环的例子
- 处理到最后剩下一个cycle没有被回收




此外,Reference Counts需要在assign类型的指令执行时更新以检测回收条件,这会导致原先的代码量成倍增加:
Copying Collection
基本思想
使用两块内存,分别称为from/to-space,首先,程序使用from-space,当其空间耗尽时,就运行之前提到的Mark操作,将所有reachable的records复制到to-space中,然后交换from/to-space的角色,此时原先的from-space变得unreachable
图 44 Copying Collection 
- 这种方式不会造成fragmentation
Pointer forwarding
注意
- 在实现Copy时,我们需要将所有的地址从from-space替换为to-space,这就意味着我们需要追踪所有指针指向的地方
- 假设B.fi指向A,那么在复制时,我们需要将B.fi指向A的to-space
为了实现这一点,我们在record中添加一个forwarding pointer,指向to-space的地址
Pointer forwarding
- 下面的伪代码展示了Forwarding的过程
function Forward(p) if p points to from-space then if p. f1 points to to-space then return p. f1 else for each field fi of p next. fi ← p. fi p. f1 ← next next ← next + size of record p return p. f1 else return p
Cheney’s Algorithm
基本思想:不使用DFS而是BFS
Cheney's Algorithm
- 使用BFS获得所有reachable的record
基础仍然是copying,将to-space分为三个部分:
- 3个pointer:start,scan,next,将space分为3个部分
- copied部分:已经复制过
- copied and scanned部分:已经复制和扫描过,意味着该record中所有的pointer都被处理了
- empty部分:未使用
图 45 Cheney's Algorithm 
伪代码
scan ← next ← beginning of to-space for each root r r ← Forward(r) while scan < next for each field fi of record at scan scan.fi ← Forward(scan.fi) scan ← scan+ size of record at scan
下面是一个例子:
cheney例子
图 46 cheney初始状态 
混合算法
- 在使用虚拟内存的计算机系统中,使用BFS进行的copying通常局部性不是很好,这就意味着a指向b,但是ab间隔过远
- 而使用DFS的copying通常局部性很好,但是依赖于pointer-reversal,性能较差
一种混合算法,部分使用DFS,部分使用BFS,其基本思想就是一般情况下使用BFS,但是进行copy的时候查看是否有临近的子节点可以copy
优缺点
Pros:
- Simplicity: no stack or pointer reversal required
- Run-time proportional to #reachable_objects
- Leave free space contiguous
- Automatic compaction eliminates fragmentation
Cons:
- Half of memory is wasted
- Poor locality (at least for the Cheney’s algorithm)
- Precise type information required (Pointer or not)