编译原理语法分析器_编译原理踩点

基础及前言

编译器由 analysis 分析(也称为前端部分)、synthesis 综合(也称为后端部分)两部分构成。编译器执行的各 phase 步骤如下:

  1. 词法分析 lexical analysis:将字符流源程序解析成 token 词法单元。这一部分将收集源程序的变量信息,并存入 symbol table 符号表。
  2. 语法分析 syntax analysis:构建词法单元的语法结构 —— syntax tree 语法树。
  3. 语义分析 semantic analysis:检测源程序是否符合语义,如类型检查、类型转换。
  4. 中间代码生成:根据语法树生成容易生成、且容易翻译到目标机器上的语言,如 three-address code 三地址代码。
  5. 代码优化:该步骤试图改进中间代码,以便生成更短或性能更好的中间代码。
  6. 代码生成:根据中间代码生成目标机器语言,这阶段会分配寄存器。

有些编译器按逻辑构成逐步组织;有些编译器围绕中间表示组织,一个前端对应多个后端,一个后端对应多个前端。

本文限于整理编译原理龙书中的第一、第二两章,即下文只有编译器前端部分。在龙书中,计算机科学家使用数学方法分析问题的思路使人惊叹,此外,龙书对于编译器这个复杂命题的精要描述具有一定典范性:

  1. 抽象化表达,从简单示例入手,概括出主要处理流程,屏蔽或使用术语指代复杂的程序实现,在专题论述时再详细深入
  2. 越益精深时,理念越重,龙书前两章先说语法分析,再说词法分析,没有取正向演绎的路数。通过源码反译容易流于琐碎,也会正向叙述流程

f9725715715cc6d2a5e61259bfd5ed89.png

词法分析

词法分析的目的在于将源程序语句解析成词法单元串,词法单元包含名字和属性。有如下两个简单的示例:

  • 语句:21 + 37 + 92;

经词法分析后:<+><+> num 整型常量,21 指 num 的属性-数值。

  • 语句:count = count + increment;

经词法分析后:<=><+> id 标识符,"count" 指 id 的属性-词素。

简易的词法分析包含如下流程:

  1. 逐个读取字符,组成词,以数值型或字符型分别对待
  2. 若为数值型,直接构成 Num 词法单元(无需存入符号表,源程序中无法再次使用)
  3. 若为字符型,区分它是保留字还是标识符,构成 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; }
}

以上是简易的词法分析器实现(问题提取和简化):

  1. 不考虑代码中存在多个作用域 scope(即语句块),这种情况一般通过为每个语句块构造符号表解决
  2. 符号表只考虑存储词素和它的类型,不考虑存储存储位置、数据类型等(这些成为词法单元的属性)
  3. 作为简单示例的需要,保留字仅考虑 true, false;数值也不考虑浮点类型
  4. 没有多预读一个字符,以区分 > 与 >= 等

符号表

符号表存储着保留字。若读取的字符串匹配保留字,就返回符号表中的词法单元;若不匹配,则作为标识符存入符号表中,以符号表的引用进行后续操作。符号表可使用 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 巴克斯范式,简称文法)用于描述程序设计语言语法的表示方法。文法包含:

  1. terminal 终结符号集合:终结符号不可再分割,表现为词法单元。
  2. nonterminal 非终结符号集合:非终结符号可以通过产生式替换。
  3. production 产生式集合:指定非终结符号可以具有怎样的推导规则。
  4. 指定一个非终结符号作为产生式的开始符号

以 stmt → if ( expr ) stmt else stmt 产生式表示条件语句。→ 箭头指 “可以具有如下形式”。一个产生式左侧即产生式的开始符号;右侧可以用 | 连接多个形式。

词法单元和终结符号的关系:词法单元的名字即终结符号,词法单元的属性即通过名字在符号表中存储的信息。词法单元的属性不是文法的组成部分。通常将词法单元和终结符号当做同义词。

语法分析树的构成:以产生式为依据,开始符号作为根节点,产生式中的非终结符号作为叶子节点,终结符号或 ε 作为内部节点。语法分析树底层的叶子节点,依自左至右顺序构成的终结符号串,即根节点-开始符号的推导。文法的二义性指,一个文法有多棵语法分析树能够推导出同一个终结符号串,比如不指定 +、- 优先级的算数语句。9-5+2 既可以是 (9-5)+2;又可以是 9-(5+2)。因此需要在文法设置 +、- 运算符的左结合性,以及 *、/ 运算符的优先级。

8c4409e83c459c1f395c38bef3e3df36.png

语法分析的目的就是为终结符号串构建一颗语法分析树因为词法单元与终结符号一一对应,也就是说,词法分析输出的词法单元串,作为语法分析的输入,最终输出为语法分析树

语法分析

为一个终结符号串作词法分析的方法有两类:自顶向下或自底向上。自顶向下可以构造出高效的语法分析器,自底向上可以处理更多种文法和翻译方案。虽然语法分析器有构造出语法分析树的能力,但是它一般不会实际构造出语法分析树。

匹配文法

假如有段程序语句匹配如下文法:

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') }

05d741150e007ee29ea3c4b68afa4911.png

语法分析时的编码实现(上一节匹配文法中的伪代码)直接来自于产生式。若产生式为左递归形式,如 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 表达式等。在抽象语法树中,这些辅助符号都是不需要的。因此语法分析树也被称为具体语法树。

519cf73cbe07356a558181611586395a.png

对于 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 + ":");
  }
}

三地址码最终会转变成汇编语言。

e99bfef50122431d4be926154eeaee56.png
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值