编译器前端简介

词法&语法分析基础

将文本转化为可以执行的程序一般需要词法分析、语法分析、语义分析和后端处理等步骤。如非学习从头开始写这些工具其实非常浪费时间,所以一般使用现成的工具生成语法解析代码

本文所用的部分参考资料:

开源工具:lex/yacc、flex/bison、python PLYANTLR4Boost Spirit

基础概念

  • 语言: 一个语言就是一个句子集合( a set of sentences) , 用 L 表示, 任何由句子组成的集合都可以被称为一个语言
  • 编译: 编译就是给定两个句子集合 Ls ( 源语言) 和 Lo ( 目标语言) 以及一个句子 ss , 判断 ss 是否属于 Ls , 以及在 Lo 中寻找出一个句子 so , 其意义和 ss 相同

词法分析

处理语言的第一个组成部分是词法分析器(lexical analyzer、lexer 或者 scanner),词法分析将文本分割为单词(token)序列

词法分析可用的工具有 lex 和 flex ,后者是前者的开源增强版本。词法分析器相对简单,所以正式的编程语言一般都不会使用 lex 等工具

最简单的词法分析方式是直接扫描法,在扫描字符串的过程中判断与解析 token,这类方法实现简单但比较难扩展,而且效率也不高,部分 token 的判断需要多次扫描

正则表达式

绝大部分语言的词法分析工具是正则表达式,无论是 flex 等工具还是手写词法分析器,正则表达式一般都是基础

正则表达式的实现使用的是 FA,即有限状态自动机(Finate Automaton)

有限状态自动机( Finate Automaton) 是用来判断字符串( 句子) 是否和正则表达式匹配的假想机器, 它有一个字母表 Σ 、 一个状态集合 S , 一个转换函数 T , 当它处于某个状态时,若它读入了一个字符( 必须是字母表里的字符),则会根据当前状态和读入的字符自动转换到另一个状态,它有一个初始状态,还有一些所谓的接受状态
它的工作过程是:首先自动机处于初始状态,之后它开始读入字符串,每读入一个字符,它都根据当前状态和读入字符转换到下一状态,直到字符串结束,若此时自动机处于其接受状态,则表示该字符串被此自动机接受,即匹配成功

数学家们已经证明了:任何一个正则表达式都有一个等价的有限状态自动机,任何一个有限状态自动机也有一个等价的正则表达式

Flex 是一个快速词法分析生成器, 它可以将用户用正则表达式写的分词匹配模式构造成一个有限状态自动机(一个 C 函数)

单词类型

几乎所有语言都支持下面三种单词类型:

  • 标识符:变量名、函数名或者类名,为了简单,运算符在 Stone 语言中中也被看作标识符
    • [A-Z_a-z][A-Z_a-z0-9]*,普通的标识符
    • [A-Z_a-z][A-Z_a-z0-9]*|==|<=|>=|&&|\|\||\p{Punct} ,把符号也看作标识符。 \p{Punct} 匹配标点符号
  • 整型字面量:([0-9]+)
  • 字符串字面量:"(\\"|\\\\|\\n|[^"])*"

简单的词法分析过程

每次读取一行文本,通过正则表达式匹配并分类各单词从而形成单词( token) 数组。下面是截取 Stone Java 实现中的 Lexer 类部分代码,C++ 和 Java 中正则表达式的语法标准与实现不同,使用 C++ 实现时可以考虑分别匹配不同类型单词然后整合

理解下面代码需要先了解 java regex 工具中的 group 概念

public static String regexPat
    = "\\s*((//.*)|([0-9]+)|(\"(\\\\\"|\\\\\\\\|\\\\n|[^\"])*\")"
    + "|[A-Z_a-z][A-Z_a-z0-9]*|==|<=|>=|&&|\\|\\||\\p{Punct})?";

// 处理一行文本并根据匹配结果为不同的单词生成不同的 token 对象
protected void addToken(int lineNo, Matcher matcher) {
    String m = matcher.group(1);
    if (m != null) // if not a space
        if (matcher.group(2) == null) { // if not a comment
            Token token;
            if (matcher.group(3) != null)// if number
                token = new NumToken(lineNo, Integer.parseInt(m));
            else if (matcher.group(4) != null) // if string literal
                token = new StrToken(lineNo, toStringLiteral(m));
            else // if identifier
                token = new IdToken(lineNo, m);
            queue.add(token);
        }
}

语法分析

词法分析后需要判断这些单词的组合方式是否满足我们当初设定的语法要求,比如 54 ! b 这样的的组合是违反语法规则的

语法分析常用的工具有 yacc 和 bison,后者是前者的开源版本

  • 终结符/非终结符,词法分割中的最小单元,也是语法分析中的最小单元,例如一个整数,一个运算符等。表达式由终结符组成,其是可以分割的,故称为非终结符

抽象语法树(AST)

抽象语法树(Abstract Syntax Tree,AST)是表示程序结构的数据结构,构造语法树的过程称为语法分析

1+3*(4-1)+2 为例,语法分析后生成的语法树形如:

graph LR A[+] C[+] B[2] E[*] D[1] F[3] G[-] H[4] I[1] A --> B A --> C C --> D C --> E E --> F E --> G G --> H G --> I

依次求解子树的值可得整个表达式的值

上下文无关语法

一个程序就是一个句子( 字符串),语言就是一个句子集合。 那么如何准确的表示这个集合(语言)?—— 上下文无关语法( context-free grammar)

一个上下文无关语法 G 就是由一个终结符集合 T ,一个非终结符集合 N( N 和 T 不相交),一个产生式集合 P ,以及一个起始符号 S( S ∈ N) 组成。由语法 G 推导( 产生) 出来的所有的句子的集合称为 G 语言。 因此一个语法可以代表一个句子集合, 也就是一个语言

上下文本无关语法 G ( context-free grammar, CFG) : 一个 4 元组: (T, N, P, S) , 其中 T 为终结符集合, N 为非终结符集合, P 为产生式集合, S 为起始符号。 一个句子如果能从语法 G 的 S 推导得到, 可以直接称此句子由语法 G 推导得到, 也可称此句子符合这个语法, 或者说此句子属于 G 语言。 G语言( G language) 就是语法 G 推导出来的所有句子的集合, 有时也用 G 代表这个集合

产生式示例 1

注意递归的使用

S -> AB      /* 起始符 */
A -> aA | ε
B -> b | bB

S 为起始符号,余下的两条为生成式,可得当前语法能推导出的所有句子集合为:

A : { ε, a, aa, aaa, ... } /* ε 标识空语句 */
B : { b, bb, bbb, ... }
S : { b, bb, bbb, ..., ab, abb, ..., aab, aabb, ... }

产生式示例 2

Expr -> Expr op Expr | (Expr) | number
op   -> + - * /

导出的表达式示例:

Expr : { 123, 25 + 24, 78 - 34, 12 * ( 23 + 9 ), ... }

自顶向下分析

自顶向下分析就是从起始符号开始,不断的挑选出合适的产生式,将中间句子中的非终结符的展开,最终展开到给定的句子

比如使用上面产生式示例 1 ,结合自顶向下分析 aaab ,从 S 开始不断展开 S/A/B

上面产生式 1 的自顶向下分析流程如下:

Working-string Production
S S –> AB
AB A –> aA
aAB A –> aA
aaAB A –> aA
aaaAB A -> ε
aaaB B -> b
aaab ACCEPT

自底向上分析

自底向上分析的顺序和自顶向下分析的顺序刚好相反,从给定的句子开始,不断的挑选出合适的产生式,将中间句子中的子串折叠为非终结符,最终折叠到起始符号

产生式:

S –> AB
A –> Aa | ε
B –> b | bB

自底向上分析 aaab 流程

Working-string Production
aaab insert(ε)
εaaab A -> ε
Aaaab A -> Aa
Aaab A -> Aa
Aab A -> Aa
Ab B -> b
AB S ->AB
S ACCEPT

歧义消除

无论自底向上还是自顶向下,都会出现歧义:

  1. 所有产生式都不可应用
  2. 有多个产生式可以应用 ,此时需要使用回溯,试探性的选择一个产生式,看是否可用。 回溯分析一般都非常慢, 因此一般通过精心构造语法来避免回溯

左右递归

左递归( left recursive) 是指形如A -> Au这样的规则, 右递归( left recursive) 则是指形如 A -> uA 这样的规则。为避免回溯, 不宜将自顶向下分析法应用于含左递归的语法 , 这是由此方法的分析顺序决定的

巴科斯范式(BNF)

编译领域常用巴科斯范式(Backus-Naur Form,BNF)来描述语言的语法规则,BNF 与正则表达式很类似,但 BNF 对递归的支持更加丰富且 BNF 以单词为最小匹配单元(正则表达式则是字符)

BNF 示例(不同实现语法不同):

factor:    NUMBER | "(" expresion ")"
term:      factor { ("*" | "/") factor }
expresion: term { ("+" | "-") term }

BNF 的实现各有不同,下面对上面的 BNF 进行简单的解释

元符号 解释
{ pat } 表示模式 pat 至少重复 0 次
[ pat ] 与重复出现 0 次或 1 次的模式 pat 匹配
pat1 │ pat2 与 pat1 或者 pat2 匹配
() 将括号内视为一个完整的模式

如果定义顺序即优先级(优先级最高的在上面),那么 factor 的优先级是最高的,这其实很明显,数学表达式中,单一的数字的值即为其本身,括号括起来的内容要预先求值

term 可以认为是短语,是一个完整的组成,和括号包含的内容类似要优先求值

expression 表达式是 term 的组合

从上到下,下面是上面定义的超集,上面是下面定义的子集

Flex&Bison 简介

如上文介绍,Flex 是词法分析工具,Bison 是语法分析工具。Flex 将输入的文本流转化为 token 序列,Bison 分析这些 token 并基于逻辑(语法)进行组合。Flex 和 Bison 的详细用法可以参考书籍 《flex与bison(中文版)》 ,如果是工作中使用且没有历史包袱,推荐使用 ANTLR4

Flex&Bison 的特点

Flex

  • flex 使用正则表达式匹配 token,而部分字符串可以被多个正则匹配到,那么 Flex 就默认了以下两条规则
    1. 使用最大一口原则,一次匹配最长的串
    2. 优先匹配最先出现的模式
  • flex 默认从标准输入(stdin)获取数据,不过可以在启动程序是修改:yyin=fopen("file_path", "r")
  • flex 对运行时的正则表达式进行了优化,速度比普通的正则库要快
  • 默认 flex 生成的词法分析函数不可重入,即在没有同步的前提下不能用于多线程;可以在使用 Flex 生成 C 函数时使用 --rerntrant 命令,生成可重入的解析函数

Bison

  • 移进/归约,语法分析过程和正则类似,语法分析器从词法分析器获得一个 token 后会将 token 置入栈中,这个过程被称为移进(shift);新 token 入栈后会触发规则检测,如果栈中 token 序列满足我们定义的 BNF 规则,语法分析器会将符合规则的部分合并成对应的规则对象,比如 term 或者 expr,这个过程被称为归约(reduce)归约过程需要考虑优先级,例如 1+2*3 就不能简单的将 1+2 归约为 expression。触发归约时 bison 会执行相应的动作
    • 归约/归约冲突,同时可以进行多个归约,默认匹配前面的语法规则,要避免出现这种冲突
    • 移进/归约冲突,满足移进规则,同时满足归约规则,避免出现这种冲突

Flex&Bison 应用示例

Flex 输入文件格式为:

%{
Declarations /* 这部分内容是 C 代码,会被原样拷贝进lex.yy.c 文件中,当前内容可以没有*/
%}

Definitions /* 定义用于下面 Rules 中的宏,当前内容可以没有 */

%%
Rules  /* 规则定义,规则必须有 */
%%

User subroutines /* 用户 C 代码 */

引入 Flex 文件的 Makefile 示例

wc: lex.yy.c
	gcc -o $@ $<

lex.yy.c: word-spliter.l
	flex $<

Flex 实现单词计数命令 wc

下面是使用 flex 实现单词计数程序的示例

flex 程序(将文件保存为 fl.l):

/* flex 程序分为三个部分,用两个百分号进行分割 */
/* 第一部分包含声明与选项设置,%{ %} 之间的内容会被原样拷贝到生成的 C 代码中 */
%{
int chars = 0;
int words = 0;
int lines = 0; 
    
int yywrap(void){ return 1; } // 加上这行可以避免链接 fl 库
%}

/* 当前 flex 文件没有使用 Definitions */

%%
[a-zA-Z]+ {words++; chars += strlen(yytext);} /* 左侧模式右侧行为,匹配到的字符串会保存在全局变量 yytext 中 */
\n        {chars++; lines++;} /* 如果输入的字符串匹配正则表达式, 则执行右侧的 C 代码 */
.         {chars++;} 

%%
/* 第三部分是 C 代码,这部分不写也是可以的,-lfl 时会自动从 fl 中引入一个主函数 */
main(int argc, char **argv){
    yylex();
    printf("%d,%d,%d\n", lines, words, chars);
}

将 flex 程序转化为 c 代码:

flex fl.l         # 自动生成 lex.yy.c 文件
gcc lex.yy.c      # 生成可执行文件,这里不要用 g++,因为c/c++符号命名问题,使用 g++ 编译会失败
# gcc lex.yy.c -lfl # 如果flex 文件中没有定义 yywrap 函数,需要链接 fl 库
./a.out < fl.l    # 对 fl.l 文件中的单词进行计数,flex 对正则进行了 DFA 优化,速度非常快

Flex 实现简单的词法分析

%{
#include "token.h"  // token.h 见后
int cur_line_num = 1;
void init_scanner();
void lex_error(char* msg, int line);
%}

/* Definitions, note: \042 is '"' */
INTEGER             ([0-9]+)
UNTERM_STRING       (\042[^\042\n]*)
STRING              (\042[^\042\n]*\042)
IDENTIFIER          ([_a-zA-Z][_a-zA-Z0-9]*)
OPERATOR            ([+*-/%=,;!<>(){}])
SINGLE_COMMENT1     ("//"[^\n]*)
SINGLE_COMMENT2     ("#"[^\n]*)

%%

[\n]                { cur_line_num++;                       }
[ \t\r\a]+          { /* ignore all spaces */               }
{SINGLE_COMMENT1}   { /* skip for single line comment */    }
{SINGLE_COMMENT2}   { /* skip for single line commnet */    }

{OPERATOR}          { return yytext[0];         }   

"<="                { return T_Le;              }
">="                { return T_Ge;              }
"=="                { return T_Eq;              }
"!="                { return T_Ne;              }
"&&"                { return T_And;             }
"||"                { return T_Or;              }
"void"              { return T_Void;            }
"int"               { return T_Int;             }
"while"             { return T_While;           }
"if"                { return T_If;              }
"else"              { return T_Else;            }
"return"            { return T_Return;          }
"break"             { return T_Break;           }
"continue"          { return T_Continue;        }
"print"             { return T_Print;           }
"readint"           { return T_ReadInt;         }

{INTEGER}           { return T_IntConstant;     }
{STRING}            { return T_StringConstant;  }
{IDENTIFIER}        { return T_Identifier;      }

<<EOF>>             { return 0; }

{UNTERM_STRING}     { lex_error("Unterminated string constant", cur_line_num);  }
.                   { lex_error("Unrecognized character", cur_line_num);        }

%%

int main(int argc, char* argv[]) {
    int token;
    init_scanner();
    while (token = yylex()) {
        print_token(token);
        puts(yytext);
    }
    return 0;
}

void init_scanner() {
    printf("%-20s%s\n", "TOKEN-TYPE", "TOKEN-VALUE");
    printf("-------------------------------------------------\n");
}

void lex_error(char* msg, int line) {
    printf("\nError at line %-3d: %s\n\n", line, msg);
}

int yywrap(void) {
    return 1;
}

token.h

#ifndef TOKEN_H
#define TOKEN_H

typedef enum {
    T_Le = 256,  /* Flex 默认使用 ASCII 码标识 ASCII 字符,所以其他 token 的编码从 256 开始 */
    T_Ge, T_Eq, T_Ne, T_And, T_Or, T_IntConstant,
    T_StringConstant, T_Identifier, T_Void, T_Int, T_While,
    T_If, T_Else, T_Return, T_Break, T_Continue, T_Print,
    T_ReadInt
} TokenType;

static void print_token(int token) {
    static char* token_strs[] = {
        "T_Le", "T_Ge", "T_Eq", "T_Ne", "T_And", "T_Or", "T_IntConstant",
        "T_StringConstant", "T_Identifier", "T_Void", "T_Int", "T_While",
        "T_If", "T_Else", "T_Return", "T_Break", "T_Continue", "T_Print",
        "T_ReadInt"
    };

    if (token < 256) {
        printf("%-20c", token);
    } else {
        printf("%-20s", token_strs[token-256]);
    }
}

#endif

使用 Flex&Bison 实现计算器

Flex 词法解析

%{
#include <stdio.h>
#include "y.tab.h"
int yywrap(void) { return 1; }
%}

%%
"+"             return ADD;
"-"             return SUB;
"*"             return MUL;
"/"             return DIV;
"\n"            return CR;
([1-9][0-9]*)|0|([0-9]+\.[0-9]*) { 	
    double temp;
    sscanf(yytext, "%lf", &temp); /* 匹配到的原始字符串保存在全局变量 yytext 中 */
    yylval.double_value = temp; /* 解析出的值会存放在名为 yylval 的全局变量中,yylval 是 union */
    return DOUBLE_LITERAL;
}
[ \t] ;
. { fprintf(stderr, "lexical error.\n");  exit(1); }
%%
// c codes...

Bison 语法定义

%{
#include <stdio.h>
#include <stdlib.h>
#define YYDEBUG 1
%}
%union {
    int          int_value;
    double       double_value;
}
%token <double_value>      DOUBLE_LITERAL
%token ADD SUB MUL DIV CR
%type <double_value> expression term primary_expression

%%
line_list : line | line_list line ;
line : expression CR { printf(">>%lf\n", $1); }
expression : term | expression ADD term { $$ = $1 + $3; } | expression SUB term { $$ = $1 - $3; };
term : primary_expression | term MUL primary_expression { $$ = $1 * $3;}
       | term DIV primary_expression { $$ = $1 / $3; }; 
primary_expression : DOUBLE_LITERAL ; /* 一元表达式的形式,自动补全 { $$ = $1 } */                
%%

int yyerror(char const *str){
    extern char *yytext;
    fprintf(stderr, "parser error near %s\n", yytext);
    return 0;
}

int main(void){
    extern int yyparse(void);
    extern FILE *yyin;

    yyin = stdin;
    if (yyparse()) {
        fprintf(stderr, "Error ! Error ! Error !\n");
        exit(1);
    }
}

ANTLR 4 简介

很多语言使用 antlr 来生成编译器前端代码,antlr 相对于 flex&Bison 这类工具而言使用了更新的技术

antlr 是使用 Java 开发的,所以执行 antlr4 工具需要 java 环境,可以从这里下载 ANTLR 4.8 tool itself

比较好的入门资料是官方文档 Getting Started wit ANTLR v4

简单示例 C++

这里摘取官方文档中 windows 下的配置方式,我使用的 cmd 终端是 cmder,使用的命令行工具源自 git/bin,编译环境为 WSL

  1. 安装 Java 1.6 及以上版本

  2. 下载 antlr-4.7.1-complete.jar ,或者从这里下载

  3. 将 antlr-4.7.1-complete.jar 加入到环境变量中

    1. 长久有效的方法是直接将路径写入到 CLASSPATH 环境变量中

    2. 临时有效:SET CLASSPATH=.;./antlr-4.7.1-complete.jar;%CLASSPATH%

      1. linux :export CLASSPATH=".:./antlr-4.7.1-complete.jar:$CLASSPATH"

      2. 如果不想设置环境变量,那么在执行 java 命令时需要带上 jar 包的目录位置,例如

        javac -cp "./antlr-4.7.1-complete.jar" Hello*.java

  4. 执行命令:java org.antlr.v4.Tool %*,如果配置生效会输出下面内容,可以参考官方文档为命令设置别名

    ANTLR Parser Generator  Version 4.7.1
     -o ___              specify output directory where all output is generated
     -lib ___            specify location of grammars, tokens files
     -atn                generat......
    

hello world

定义语法规则,保存为 Hello.g4

// Define a grammar called Hello
grammar Hello;
r  : 'hello' ID ;         // match keyword hello followed by an identifier
ID : [a-z]+ ;             // match lower-case identifiers
WS : [ \t\r\n]+ -> skip ; // skip spaces, tabs, newlines

生成对应语言的解析代码

java  -Dfile.encoding=UTF8 -jar antlr-4.7.1-complete.jar -Dlanguage=Cpp Hello.g4

为了更方便的使用 ANTLR,可以先生成 Java 代码,然后做一些简单的测试,下面以上面的 Hello.g4 为例介绍下 ANTLR4 的 org.antlr.v4.gui.TestRig 工具

  1. 生成 java 代码:java -Dfile.encoding=UTF8 -jar antlr-4.7.1-complete.jar Hello.g4
  2. 编译生成 class 文件:java Hello*.java
  3. 执行命令:java -cp "./antlr-4.7.1-complete.jar" org.antlr.v4.gui.TestRig Hello r -tree
    1. 输入 hello world,换行(点击 Enter 键)
    2. windows 下需要输入 CTRL+z,linux 平台下输入 CTRL+D
    3. 再次点击换行键
  4. 重复执行 3 ,不过将命令中的 -tree 改为 -gui,ANTLR 将以图形界面的形式输出语法分析树;-tree 以 Lisp 文本的形式展示
    1. 其他常用参数解释
      1. -tokens,打印词法符号流;-ps file.ps ,以 PostScript 格式保存语法分析树结果;
      2. -trace 打印规则名称及进入和离开规则时的词法符号
      3. -encoding encodingname,指定输入文件的编码
      4. -diagnostics,开启解析过程中的调试信息输出,比如定义的规则有歧义
      5. -SLL,使用另外一种更快但功能相对较弱的解析策略
ANTLR4 语法简介
  • 语法规则以小写字母开始
  • 词法规则以大写字母开始
  • 使用 | 分割一个规则的若干备选分支,例如:stat: expr NEWLINE|NEWLINE;

使用 ANTLR 实现计算器

首先安装 antlr cpp 运行时环境,可以从这里下载

sudo apt-get install pkg-config
sudo apt-get install uuid-dev
mkdir build && mkdir run && cd build
# cmake 的最低版本为 2.8
cmake .. -DANTLR_JAR_LOCATION=./antlr-4.7.1-complete.jar -DWITH_DEMO=True
make
DESTDIR=/runtime/Cpp/run  # 设置安装目录
make install

可以从这里下载完整的 c++ 代码,我在 linux 下编译并执行成功,windows 编译失败,因为只是为了验证代码,所以并没有解决编译失败的问题

posted @ 2020-05-18 06:31  jiahu  阅读(1418)  评论(0编辑  收藏  举报