基础及前言
编译器由 analysis 分析(也称为前端部分)、synthesis 综合(也称为后端部分)两部分构成。编译器执行的各 phase 步骤如下:
- 词法分析 lexical analysis:将字符流源程序解析成 token 词法单元。这一部分将收集源程序的变量信息,并存入 symbol table 符号表。
- 语法分析 syntax analysis:构建词法单元的语法结构 —— syntax tree 语法树。
- 语义分析 semantic analysis:检测源程序是否符合语义,如类型检查、类型转换。
- 中间代码生成:根据语法树生成容易生成、且容易翻译到目标机器上的语言,如 three-address code 三地址代码。
- 代码优化:该步骤试图改进中间代码,以便生成更短或性能更好的中间代码。
- 代码生成:根据中间代码生成目标机器语言,这阶段会分配寄存器。
有些编译器按逻辑构成逐步组织;有些编译器围绕中间表示组织,一个前端对应多个后端,一个后端对应多个前端。
本文限于整理编译原理龙书中的第一、第二两章,即下文只有编译器前端部分。在龙书中,计算机科学家使用数学方法分析问题的思路使人惊叹,此外,龙书对于编译器这个复杂命题的精要描述具有一定典范性:
- 抽象化表达,从简单示例入手,概括出主要处理流程,屏蔽或使用术语指代复杂的程序实现,在专题论述时再详细深入
- 越益精深时,理念越重,龙书前两章先说语法分析,再说词法分析,没有取正向演绎的路数。通过源码反译容易流于琐碎,也会正向叙述流程
词法分析
词法分析的目的在于将源程序语句解析成词法单元串,词法单元包含名字和属性。有如下两个简单的示例:
- 语句:21 + 37 + 92;
经词法分析后:<+><+> num 整型常量,21 指 num 的属性-数值。
- 语句:count = count + increment;
经词法分析后:<=><+> id 标识符,"count" 指 id 的属性-词素。
简易的词法分析包含如下流程:
- 逐个读取字符,组成词,以数值型或字符型分别对待
- 若为数值型,直接构成 Num 词法单元(无需存入符号表,源程序中无法再次使用)
- 若为字符型,区分它是保留字还是标识符,构成 Word 词法单元(与符号表打交道)
public class Lexer {
public int line = 1;
private char peek = '';
private HashTable words = new HashTable();// 符号表
void reserve(Word t){ words.put(t.lexeme, t); }
public Lexer(){
reserve(new Word(Tag.TRUE, "true"));// 保留字
reserve(new Word(Tag.FALSE, "false"));
}
public Token scan(){
for ( ; ; peek = (char)System.in.read()){
if (peek == '' || peek == 't') continue;
else if (peek == 'n') line = line + 1;
else break;
}
if (Character.isDigit(peek)){// 处理数值型
int v = 0;
do {
v = 10*v + Character.digit(peek, 10);
peek = (char)System.in.read();
} while(Character.isDigit(peek));
return new Num(v);
}
if (Character.isLetter(peek)){// 处理字符型
StringBuffer b = new StringBuffer();
do {
b.append(peek);
peek = (char)System.in.read();
} while(Character.isLetterOrDigit(peek));
String s = b.toString();
Word w = (Word) words.get(s);
if (w != null) return w;
w = new Word(Tag.ID, s);
words.put(s, w);// 如程序中存在多个同名变量,这段代码就需要层级上下文了
return w;
}
Token t = new Token(peek);// 将当前字符作为词法单元返回,如 +, - 号等
peek = '';
return t;
}
}
public class Token {// 词法单元基类
public final int tag;// 词法单元类型
public Token(int t){
tag = t;
}
}
public class Num extends Token {// 数值型词法单元类
public final int value;// 数值
public Num(int v){ super(Tag.NUM); value = v; }
}
public class Word extends Token {// 字符型词法单元类
public final int lexeme;// 词素,构成一个词法单元的输入字符序列
public Num(int t, String s){ super(t); lexeme = s; }
}
以上是简易的词法分析器实现(问题提取和简化):
- 不考虑代码中存在多个作用域 scope(即语句块),这种情况一般通过为每个语句块构造符号表解决
- 符号表只考虑存储词素和它的类型,不考虑存储存储位置、数据类型等(这些成为词法单元的属性)
- 作为简单示例的需要,保留字仅考虑 true, false;数值也不考虑浮点类型
- 没有多预读一个字符,以区分 > 与 >= 等
符号表
符号表存储着保留字。若读取的字符串匹配保留字,就返回符号表中的词法单元;若不匹配,则作为标识符存入符号表中,以符号表的引用进行后续操作。符号表可使用 HashTable 构建(java 环境)。
符号表条目适合由语法分析器创建。多层语句块 { int x; char y; { bool y; x; y; } x; y; } 经语法分析后,生成 {{ x: int; y: bool; } x: int; y: char; }。与块对应的符号表以栈形式存储,当前语句块的符号表存在栈顶,下方是其父级语句块。语句块构成嵌套结构时,符号表也需要链接起来,使内嵌符号表指向外围符号表,譬如作用域链。程序执行到某个语句块,环境指针会相应地指向那个语句块对应的符号表。实现上,编译器会为每个语句块创建环境对象,符号表作为环境对象属性,实际链接起来的是环境对象,不是符号表。环境指针的变更就在于程序执行到内嵌语句块时,根据外围环境对象创建新的环境对象。“符号表链接起来”、“环境指针指向内嵌符号表”,这样的描述屏蔽了程序实现,精省的表达抽象。
public class Env {
private Hashtable table;// 符号表
protected Env prev;// 持有外围 Env
public Env(Env p){
table = new Hashtable();
prev = p;
}
public void put(String s, Symbol sym){
table.put(s, sym);
}
public Symbol get(String s){
for(Env e = this; e != null; e = e.prev){
Symbol found = (Symbol)(e.table.get(s));
if (found != null) return found;
}
return null;
}
}
上下文无关文法
上下文无关文法(即 Backus-Naur Form 巴克斯范式,简称文法)用于描述程序设计语言语法的表示方法。文法包含:
- terminal 终结符号集合:终结符号不可再分割,表现为词法单元。
- nonterminal 非终结符号集合:非终结符号可以通过产生式替换。
- production 产生式集合:指定非终结符号可以具有怎样的推导规则。
- 指定一个非终结符号作为产生式的开始符号。
以 stmt → if ( expr ) stmt else stmt 产生式表示条件语句。→ 箭头指 “可以具有如下形式”。一个产生式左侧即产生式的开始符号;右侧可以用 | 连接多个形式。
词法单元和终结符号的关系:词法单元的名字即终结符号,词法单元的属性即通过名字在符号表中存储的信息。词法单元的属性不是文法的组成部分。通常将词法单元和终结符号当做同义词。
语法分析树的构成:以产生式为依据,开始符号作为根节点,产生式中的非终结符号作为叶子节点,终结符号或 ε 作为内部节点。语法分析树底层的叶子节点,依自左至右顺序构成的终结符号串,即根节点-开始符号的推导。文法的二义性指,一个文法有多棵语法分析树能够推导出同一个终结符号串,比如不指定 +、- 优先级的算数语句。9-5+2 既可以是 (9-5)+2;又可以是 9-(5+2)。因此需要在文法设置 +、- 运算符的左结合性,以及 *、/ 运算符的优先级。
语法分析的目的就是为终结符号串构建一颗语法分析树。因为词法单元与终结符号一一对应,也就是说,词法分析输出的词法单元串,作为语法分析的输入,最终输出为语法分析树。
语法分析
为一个终结符号串作词法分析的方法有两类:自顶向下或自底向上。自顶向下可以构造出高效的语法分析器,自底向上可以处理更多种文法和翻译方案。虽然语法分析器有构造出语法分析树的能力,但是它一般不会实际构造出语法分析树。
匹配文法
假如有段程序语句匹配如下文法:
stmt → expr;
| if ( expr ) stmt
| for ( optexpr; optexpr; optexpr; ) stmt
| other optexpr → expr | ε
首先需要校验程序语句是否匹配这些产生式。除了控制语句、表达式外,产生式通常以特定的终结符号起始(或者说可用的文法是,对于任何非终结符号,它的各个产生式的首个起始符号不能相重)。因此,为程序语句拣选产生式或者为非终结符号拣选产生式体,都可以采用使用“逐步尝试法”,即逐个使用产生式推导语法分析树,若不合适,则回溯并选择另一个产生式。实现上可以采用条件语句或其他。词法单元串在这个时候会转变成 program、stmt、expr 等非终结符号构成的语法分析树,其下包含与产生式对应的终结符号节点。以下是自顶向下的递归下降预测法伪代码实现:
void stmt() {
switch( lookahead ){// 向前看符号
case expr:
match(expr);match(";");break;
case if:
match(if); match("(");match(expr);match(")");stmt();break;
case for:
match(for); match("(");optexpr();match(";");optexpr();match(";");
optexpr();match(")");stmt();break;
case other:
match(other);break;
default:
report("syatax error");// 语法错误
}
}
void optexpr(){
if (lookahead == expr) match(expr);
}
// 向前看符号 lookahead 是否匹配 t,若匹配,取下一个
void match(termianl t){
if (lookahead == t) lookahead = nextTermianal;
else report("syatax error");
}
翻译方案
以上为程序语句找到了匹配的产生式,文法的真正作用是将语义动作附加到语法分析树上(终结符号后面),这被称为翻译方案。附加语义动作的方式有两种:前序遍历,在进入节点时添加语义动作;后序遍历,在离开节点时添加语义动作。
考虑如下文法:
expr → expr + term { print('+') } 表示语义动作
| expr - term { print('+') }
| term
term → 0 { print('0') }
| 1 { print('1') }
...
| 9 { print('9') }
语法分析时的编码实现(上一节匹配文法中的伪代码)直接来自于产生式。若产生式为左递归形式,如 A → Aα 形式(即 expr → expr + term 等),编码时就会陷入无限循环。这时可以将左递归形式改写成右递归形式,即转换成 A → γR, R → αR | βR | ε 形式。对于 expr → expr + term,α = + term { print("+") }, γ = term。则产生式的新形式为:
expr → term + rest
rest → + term { print('+') } rest
| - term { print('-') } rest
| ε
term → 0 { print('0') }
| 1 { print('1') }
...
| 9 { print('9') }
此时的编码实现即为:
void expr() {
term();rest();
}
void rest(){
while(true){
if ( lookahead == "+" ){
match("+");term();print("+");continue;// print("+") 代表语义动作
} else if ( lookahead == "-" ){
match("-");term();print("-");continue;
}
break;
}
}
void term(){
if (Character.isDigit(lookahead)){
t = lookahead; match(lookahead); print(t);
}
else report("syatax error");
}
注:在处理算数表达式时,人们容易理解中缀表达式,计算机却不便于处理中缀表达式,容易处理的是前缀表达式或后缀表达式。因此在处理中缀表达式时,编译器会将其翻译成后缀表达式。参考 前、中、后缀表达式。
抽象语法树
经过上文的翻译方案,我们容易得到三地址代码,也容易得到一棵抽象语法树(通常编译器不会真的去构建一棵语法分析树)。
抽象语法树中的内部节点代表一个运算符,该节点的子节点代表这个运算符的运算分量。抽象语法树和语法分析树的差别在于:抽象语法树的内部节点代表程序构造;语法分析树的内部节点代表非终结符号,有些能表示程序的构造,有些是各式辅助符号,比如 term 项、factor 银子或 expr 表达式等。在抽象语法树中,这些辅助符号都是不需要的。因此语法分析树也被称为具体语法树。
对于 while (expr) stmt,编译器通常会构造 While 父节点,其下子节点为 Expr 及 Stmt。以下是常见语法树节点:
program → block { return block.n; }
block → '{' stmts '}' { block.n = stmts.n; }
stmts → stmts stmt { stmts.n = new Seq(stmts.n, stmt.n); }
| ε { stmts.n = null; }
stmt → expr; { stmt.n = new Eval(expr.n); }
| if ( expr ) stmt { stmt.n = new If(expr.n, stmt.n); }
| while ( expr ) stmt { stmt.n = new While(expr.n, stmt.n); }
| do stmt while ( expr ); { stmt.n = new Do(stmt.n, expr.n); }
| block { stmts.n = block.n; }
expr → rel = expr { expr.n = new Assign('=', rel.n, expr.n); } 等号运算符 Assign
| rel { expr.n = rel.n; }
rel → rel < add { rel.n = new Rel('<', rel.n, add.n) } 比较运算符 Rel
| rel <= add { rel.n = new Rel('≤', rel.n, add.n) }
| add { rel.n = add.n; }
add → add + term { add.n = new Op('+', add, term.n); } 算术运算符 Op
| term { add.n = term.n; }
term → term * factor { term .n = new Op('*', term.n, factor.n); }
| factor { term.n = factor.n; }
factor → ( expr ) { factor.n = expr.n; }
| num { factor.n = new Num(num.value); }
三地址码
三地址码由一组类似于汇编语言的指令组成,每个指令拥有三个运算分量(每个运算分量都像一个寄存器)。
对于 if ( expr ) stmt 条件语句,会生成如下三地址码:
ifFalse x goto L 执行标记为 L 的指令
ifTrue x goto L
goto L
以下是伪代码实现:
class If extends Stmt {// 按产生式,If 继承 Stmt 语句
Expr E; Stmt S;
// x 可以是 Assign, Rel, Op 或其他 Expr 实例
// 通过 newlabel 给 after 一个唯一的新标号
public If(Expr x, Stmt y){ E = x; S = y; after = newlabel(); }
// 抽象语法树构建完成后,调用 gen 生成三地址码
publich void gen(){
Expr n = E.rvalue();// 计算表达式的右值,左值适用于 id 标识符或赋值语句
emit("ifFalse" + n.toString() + "goto" + after);
S.gen();
emit(after + ":");
}
}
三地址码最终会转变成汇编语言。