参考编译器介绍

本次实验预备阶段主要学习参考了经典的Pascal编译器,现进行一定的介绍。

总体结构

课程组提供的Pascal编译器总体结构为词法分析、语法分析、错误处理、语义分析、代码生成和代码优化六个部分。

接口设计

该编译器并未进行模块化设计,而是将各函数放在一个文件下,在调用时设定形参实参进行数据的交互。主要函数如下:

  • nextch 读取下一个字符
  • test 检查符号合法性
  • error 打印错误信息
  • typ处理类型
    • typedeclaration处理自定义类型
  • constant处理常量
    • adjustscale处理实数
  • variabledeclaration处理普通变量
  • procdeclaration处理过程和函数
    • parameterlist 处理形参
  • statement处理各种语句
  • enter登记符号表
    • enterreal登记实常量表
    • enterarray登记数组符号表
    • enterblock登记分程序信息入分程序表
  • emit生成中间代码

文件组织

该Pascal编译器全部函数均放在一个文件下,抽象化层次化程度低,复杂程度较高。实际上,可以建立库函数文件(或者像面向对象设计一样建立类,分文件调用),以此降低文件组织的耦合程度。


编译器总体设计

总体结构

设计的编译器主要分为前端 Front 、中端 Middle 、后端 Back 三个部分。

其中,前端主要负责词法分析、语法分析部分,有词法分析器 Lexer 、语法分析器 Parser 。中端主要负责语义分析(符号表的构建与管理)与中间代码生成有语义分析器(符号管理器) Manager 、中间代码生成器 Visitor 、代码优化器 Opitimizer 。后端主要负责目标代码生成以及代码优化,有目标代码翻译器 Translator 、代码优化器 Opitimizer

整体的结构与流程设计如下所示:

编译器整体结构图

接口设计

前端:

  • content :未经任何处理的待翻译程序字符串;
  • tokens :经词法分析后获得的单词列表;
  • AST :经语法分析后获得的抽象语法树(Abstract Syntax Tree,下简称语法树)。

中端:

  • tables :经语义分析后获得的符号表项列表,其中列表第一项即是全局符号表项;
  • MCT :经中间代码生成后获得的中间代码树(Medium Code Tree)。

后端:

  • OCT :经目标代码翻译后获得的目标代码树(Object Code Tree);
  • optim :经代码优化后获得的目标代码树。

文件组织

整体文件组织如下:

1
2
3
4
5
├─ Front 前端处理器
├─ Middle 中端处理器
├─ Back 后端处理器
├─ CompileError 编译错误与错误处理器
└─ IO 编译器的输入输出

其中, Front 的文件组织如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
├─Lexer 词法分析
│ Lexer.java 词法分析器类
│ Token.java 单词类
└─Parser 语法分析
│ Parser.java 语法分析器类
└─Syntax 语法成分
│ Syntax.java 语法成分接口
├─Block 块
│ Block.java 块类
│ BlockItem.java 语句块项类
├─Const 常量
│ IntConst.java 数字常量类
│ CharConst.java 字符常量类
│ StringConst.java 字符串常量类
├─Decl 声明
│ Decl.java 声明类
│ ConstDecl.java 常量声明类
│ VarDecl.java 变量声明类
├─Def 定义
│ ConstDef.java 常量定义类
│ FuncDef.java 函数定义类
│ MainFuncDef.java 主函数定义类
│ VarDef.java 变量定义类
├─Expr 表达式
│ LOrExpr.java 逻辑或表达式类
│ LAndExpr.java 逻辑与表达式类
│ EqExpr.java 相等性表达式类
│ RelExpr.java 关系表达式
│ Expr.java 普通表达式类
│ ConstExpr.java 常量表达式类
│ AddExpr.java 加减表达式类
│ MulExpr.java 乘除模表达式类
│ UnaryExpr.java 一元表达式类
│ PrimaryExpr.java 基本表达式类
├─Param 参数
│ FuncFParams.java 函数形参表类
│ FuncFParam.java 函数形参
│ FuncRParams.java 函数实参表类
├─Reserved 保留元素
│ Number.java 数字类
│ Character.java 字符类
│ Ident.java 标识符类
│ ReservedWord.java 保留字类
│ UnaryOp.java 一元操作符类
├─Stmt 语句
│ Cond.java 条件类
│ ForStmt.java for语句类
│ Stmt.java 语句类
├─Type 类型
│ BType.java 数据类型类
│ FuncType.java 函数类型类
├─Unit 单元
│ CompUnit.java 编译单元类
└─Val 值
ConstInitVal.java 常量初值
InitVal.java 变量初值
LVal.java 左值表达式

Middle 的文件组织如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
├─Manager 符号管理
│ │ Manager.java 符号管理类
│ │ Table.java 符号表项类
│ │ TableViewer.java 符号表观察类
│ └─Symbols 符号
│ Symbol.java 符号类
│ ArraySymbol.java 数组符号类
│ FuncSymbol.java 函数符号类
│ VarSymbol.java 普通变量常量类
└─Visitor 中间代码生成
│ Visitor.java 中间代码生成器类
│ VisitorFeature.java 中间代码生成器特征类
└─LLVMIR 中间代码生成LLVMIR预备部分
├─TypeIR MCT节点类型
│ │ ValueTypeIR.java MCT节点类型类
│ ├─BasicType 基础类型
│ │ IntegerTypeIR.java int/char/bool数据类型类
│ │ LabelTypeIR.java 标签类
│ │ VoidTypeIR.java void数据类型类
│ └─CompType 复合类型
│ ArrayTypeIR.java 数组类型类
│ FunctionTypeIR.java 函数类型类
│ PointerTypeIR.java 指针类型类
└─ValueIR MCT节点
│ IR.java MCT节点接口
│ ValueIR.java MCT值类
├─Component 中间代码组成
│ BasicBlockIR.java 中间代码块类
│ FunctionIR.java 中间代码函数类
│ ModuleIR.java 中间代码模块类
├─Constant 中间代码常数
│ ConstantArrayIR.java 常数组类
│ ConstantIR.java 常数类
│ ConstantStringIR.java 常字符串类
├─Instruction 中间代码指令
│ │ InstructionIR.java 中间代码类
│ ├─calculate 计算指令
│ │ BinaryIR.java 二元计算类
│ │ BTypeTranIR.java 数据类型转换类
│ │ IcmpIR.java 比较类
│ ├─jump 跳转指令
│ │ BrIR.java 跳转类
│ │ RetIR.java 返回类
│ └─memory 内存指令
│ AllocateIR.java 申请内存类
│ CallIR.java 调用函数类
│ GetElementIR.java 计算数组元素地址类
│ LoadIR.java 加载类
│ StoreIR.java 存储类
└─Variable 中间代码变量
GlobalVariableIR.java 中间代码全局声明类
VariableIR.java 变量类

Back 的文件组织如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
├─Optimizer 代码优化
│ Optimizer.java 优化器类
└─Translator 目标代码生成
│ Translator.java 目标代码翻译器类
├─Mips 目标代码生成MIPS预备部分
│ │ Mips.java OCT节点类
│ ├─Component 目标代码组成
│ │ BlockMips.java 目标代码块类
│ │ FunctionMips.java 目标代码函数类
│ │ GlobalMips.java 目标代码全局声明类
│ │ ModuleMips.java 目标代码模块类
│ │ SymbolMips.java 目标代码符号类
│ └─Instruction 目标代码指令
│ │ InstrMips.java 目标代码指令类
│ ├─address 地址类指令
│ │ LwMips.java 加载字类
│ │ SwMips.java 存储字类
│ ├─assign 赋值类指令
│ │ LaMips.java 赋地址值类
│ │ LiMips.java 赋数值类
│ │ MfloMips.java 读lo寄存器值类
│ │ MfhiMips.java 读hi寄存器值类
│ │ MoveMips.java 赋寄存器值类
│ ├─binary 二元计算指令
│ │ AddiuMips.java 带立即数的无符号加法类
│ │ AdduMips.java 二元无符号加法类
│ │ SubuMips.java 二元无符号减法类
│ │ MultMips.java 二元有符号乘法类
│ │ DivMips.java 二元有符号除法类
│ │ SltMips.java 二元有符号比较类
│ ├─bitcalculate 位运算指令
│ │ AndIMips.java 带立即数的与运算类
│ │ SllMips.java 逻辑左移类
│ │ SraMips.java 算术右移类
│ │ SrlMips.java 逻辑右移类
│ ├─jump 跳转指令
│ │ BeqMips.java 相等时跳转立即数地址类
│ │ BneMips.java 不等时跳转立即数地址类
│ │ BgezMips.java 大于等于0时跳转立即数地址类
│ │ BlezMips.java 小于等于0时跳转立即数地址类
│ │ BgtzMips.java 大于0时跳转立即数地址类
│ │ BltzMips.java 小于0时跳转立即数地址类
│ │ JalMips.java 跳转立即数地址并链接类
│ │ JMips.java 跳转立即数地址类
│ │ JrMips.java 跳转寄存器地址类
│ └─system 系统指令
│ SyscallMips.java 系统调用类
└─Register 寄存器
Register.java 寄存器类
RegisterTable.java 寄存器表类

CompilerError 的文件组织如下:

1
2
CompileError.java 编译错误类
Judgement.java 错误处理类

IO 的文件组织如下:

1
2
Printer.java 输出解析类
Reader.java 输入解析类

词法分析设计

源程序读入后作为长字符串存储在编译器中。词法分析部分的主要任务是将源程序字符串转化为单词。

首先应该建立单词类 Token 。在单词中,应当包含该单词种类、所在行、单词内容。因此设计单词类 Token ,其中包含属性 tokenlineNumtype,用于存储必要信息。

在词法分析器 Lexer 中,读入源程序字符串,目标是生成单词列表。其中,部分单词是保留词,需要设置为相应类型。为防止无法分别标识符和部分保留词,首先应建立保留词表,以便分析出新单词后正确判断其类型。开始分析后,可以按照以下分类分析字符串:

  • 当前字符是数字:那么下一个单词是数字常量。继续读入字符,直至该字符不是数字为止。该段字符串即是下一个单词,该单词的单词内容即是该段字符串,该单词的类型是 INTCON
  • 当前字符是 \"\' :那么下一个单词是字符串常量或者字符常量。继续读入字符,直至该字符不是对应的 "' 为止。应注意,在读入字符时,有可能出现转义字符 \"\' ,需要进行特殊判断(当前字符是 \ 时下一个字符是否是 "' ),不能作为字符或字符串结束标志。该段字符串即是下一个单词,该单词的单词内容即是该段字符串,该单词的类型是 STRCONCHRCON
  • 当前字符是字母或 _ :那么下一个单词是标识符或保留字。继续读入字符,直至该字符不是字母、数字或 _ 。将该段字符串内容和保留词表比对,判断是否是保留词以及是什么类型的保留词。该段字符串即是下一个单词,该单词的单词内容即是该段字符串,该单词的类型是 IDENFR 或相应的保留词类型。
  • 当前字符是 +-*%;,()[]{} :那么下一个单词是保留词。这些字符是保证只能单独出现的保留词,因此直接将该字符与保留词表比对,判断是什么类型的保留词。
  • 当前字符是 !=>< :那么下一个单词是保留词。这些字符可能组合出现,产生不同于当前字符含义的保留词。继续读入字符,直至该字符不是这些字符。将该段字符串内容和保留词表比对,判断是什么类型保留词。
  • 当前字符是 &| :那么下一个单词是保留词。由于该字符可能出现错误,因此单列出来考虑。继续读入字符,若该字符与当前字符不同,词法分析部分出现错误;若相同则与保留词表比对,判断是什么类型保留词。该单词的类型是 ANDOR
  • 当前字符是 / :那么接下来可能是保留词 / 或注释 ///**/ 。读入下一个字符,若该字符是 /* ,那么接下来是注释,若不是,则是保留词 / ,类型为 DIV 。如果这两个字符是 // ,继续读入字符,直至读入 \n ;如果这两个字符是 /* ,继续读入字符,直至读入 */
  • 当前字符是 \n :源程序进行了换行,当前行数加一。
  • 当前字符是其他字符:跳过即可。

获得单词后,将其存入单词列表 tokens 中,并正确输出结果。


语法分析设计

词法分析部分分析出的单词列表存储在编译器中。语法分析部分的主要任务是按照 SysY 语言文法自顶向下将单词列表转化为语法树。

SysY 语言文法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
编译单元 CompUnit → {Decl} {FuncDef} MainFuncDef 
声明 Decl → ConstDecl | VarDecl
常量声明 ConstDecl → 'const' BType ConstDef { ',' ConstDef } ';'
基本类型 BType → 'int' | 'char'
常量定义 ConstDef → Ident [ '[' ConstExp ']' ] '=' ConstInitVal
常量初值 ConstInitVal → ConstExp | '{' [ ConstExp { ',' ConstExp } ] '}' | StringConst
变量声明 VarDecl → BType VarDef { ',' VarDef } ';'
变量定义 VarDef → Ident [ '[' ConstExp ']' ] | Ident [ '[' ConstExp ']' ] '=' InitVal
变量初值 InitVal → Exp | '{' [ Exp { ',' Exp } ] '}' | StringConst
函数定义 FuncDef → FuncType Ident '(' [FuncFParams] ')' Block
主函数定义 MainFuncDef → 'int' 'main' '(' ')' Block
函数类型 FuncType → 'void' | 'int' | 'char'
函数形参表 FuncFParams → FuncFParam { ',' FuncFParam }
函数形参 FuncFParam → BType Ident ['[' ']']
语句块 Block → '{' { BlockItem } '}'
语句块项 BlockItem → Decl | Stmt
语句 Stmt → LVal '=' Exp ';'
| [Exp] ';'
| Block
| 'if' '(' Cond ')' Stmt [ 'else' Stmt ]
| 'for' '(' [ForStmt] ';' [Cond] ';' [ForStmt] ')' Stmt
| 'break' ';' | 'continue' ';'
| 'return' [Exp] ';'
| LVal '=' 'getint''('')'';'
| LVal '=' 'getchar''('')'';'
| 'printf''('StringConst {','Exp}')'';'
语句 ForStmt → LVal '=' Exp
表达式 Exp → AddExp
条件表达式 Cond → LOrExp
左值表达式 LVal → Ident ['[' Exp ']']
基本表达式 PrimaryExp → '(' Exp ')' | LVal | Number | Character
数值 Number → IntConst
字符 Character → CharConst
一元表达式 UnaryExp → PrimaryExp | Ident '(' [FuncRParams] ')' | UnaryOp UnaryExp
单目运算符 UnaryOp → '+' | '−' | '!' 注:'!'仅出现在条件表达式中
函数实参表 FuncRParams → Exp { ',' Exp }
乘除模表达式 MulExp → UnaryExp | MulExp ('*' | '/' | '%') UnaryExp
加减表达式 AddExp → MulExp | AddExp ('+' | '−') MulExp
关系表达式 RelExp → AddExp | RelExp ('<' | '>' | '<=' | '>=') AddExp
相等性表达式 EqExp → RelExp | EqExp ('==' | '!=') RelExp
逻辑与表达式 LAndExp → EqExp | LAndExp '&&' EqExp
逻辑或表达式 LOrExp → LAndExp | LOrExp '||' LAndExp
常量表达式 ConstExp → AddExp 注:使用的 Ident 必须是常量

首先应该建立语法成分接口 Syntax 。后续建立语法树 AST 时所有节点都应该实现 Syntax 接口,以保证统一性。对于每一个语法成分,都应建立一个类,存储它作为语法树节点所有的子节点。

在语法分析器 Parser 中,读入单词列表,目标是生成语法树。按照 SysY 语言文法,自顶向下为每个节点类建立分析函数。同时为了不进行回溯,应在每个分析函数开始部分建立是否为当前语法成分的判断部分(判断首单词是否符合当前推导规则):

  • CompUnit :语法树的最顶层节点,代表整个待编译程序。其子节点可能为 DeclFuncDefMainFuncDef ,其中,前两部分可能存在若干次,因此使用 for 循环进行解析,直至无法解析出 DeclFuncDef 。对于第三部分 MainFuncDef ,直接解析即可。
  • Decl :代表声明部分,分为常量声明 ConstDecl 和变量声明 VarDecl 。先假定为常量声明进行解析,若返回值为空,按照变量声明解析即可。
  • FuncDef :代表普通函数(非主函数)声明部分。当需要解析函数时,首先应该判断是否符合函数定义格式以及是否是主函数。对于函数定义,最大的特征标识是函数名标识符 Ident 后的小括号 ( ,如果成立则可以判定为函数。对于是否是主函数的判定,只需要判定函数名标识符 Ident 是否是 main 即可。判定确实为普通函数后,存储该函数的函数类型 FuncType 、函数名标识符 Ident ,并对标识符后的函数形参进行解析及存储(存储形参个数、形参名、形参类型等)。之后对函数主体的块 Block 进行解析即可完成整个函数的解析。
  • MainFuncDef :代表主函数声明部分。其大体流程与普通函数声明部分相似:判断是否符合函数定义格式以及是否是主函数。对于函数定义,最大的特征标识是函数名标识符 Ident 后的括号 ( ,如果成立则可以判定为函数。对于是否是主函数的判定,只需要判定函数名标识符 Ident 是否是 main 即可。需注意,二者都必须进行,否则可能出现被命名为 main 的变量被判定为主函数而导致的解析错误。判定为主函数后,存储相应函数信息,不需要对函数形参进行解析,直接对函数主体的块 Block 进行解析即可完成整个主函数的解析。
  • Block :代表由大括号包覆的块。其内部含有若干各块组成 BlockItem ,因此使用 for 循环进行解析,直至无法解析出 BlockItem (实际上是到达块的后一个大括号 } )。最后,将块的后一个大括号 } 解析即可。
  • ConstDecl :代表常量声明部分。首先应通过当前单词是否是 const 判断是否是常量定义,若不是常量定义则返回空值。如果是常量定义,则存储之后定义的常量的数据类型 BType 。由于一句常量声明中可以存在多条常量定义语句,因此使用 for 循环进行解析,直至无法解析出 ConstDef
  • VarDecl :代表变量声明部分。首先应当判定是否是变量定义:当前单词应该是变量的数据类型 BType ,且不应该出现 BType Ident ( 的情况。如果是变量定义,则存储之后定义的变量的数据类型 BType 。由于一句变量声明中可以存在多条变量定义语句,因此使用 for 循环进行解析,直至无法解析出 VarDef
  • ConstDef :代表常量定义部分。首先需要判断是否是常量定义,由于 ConstDef 只能从 ConstDecl 获得,因此只需要通过是否存在定义常量标识符 Ident 判定是否是定义语句即可。如果是常量定义,则存储当前常量标识符 Ident 。该标识符所代表常量可能是常量数组,需要通过标识符后是否是中括号 [ 来判断。如果该常量是常量数组,则需要对中括号 [ 后的常量表达式 ConstExp 进行解析,以获得该数组的元素个数。如果不是常量数组,正常进行后续操作即可。对于常量定义部分,还需要解析为定义常量所赋的初值。对等号 = 后的常量表达式 ConstExp 进行解析并存储即可。
  • VarDef :代表变量定义部分。首先需要判断是否是变量定义,由于 VarDef 只能从 VarDecl 获得,因此只需要通过是否存在定义变量标识符 Ident 判定是否是定义语句即可。如果是变量定义,则存储当前变量标识符 Ident 。该标识符所代表变量可能是变量数组,需要通过标识符后是否是中括号 [ 来判断。如果该变量是变量数组,则需要对中括号 [ 后的变量表达式 VarExp 进行解析,以获得该数组的元素个数。如果不是变量数组,正常进行后续操作即可。对于变量定义部分,还需要判定是否存在为定义变量赋初值的情况,通过标识符 Ident 或中括号 ] 后是否存在等号 = 来判断。如果存在,解析为定义变量所赋的初值。对等号 = 后的普通表达式 Exp 进行解析并存储即可。
  • ConstInitVal :代表常量定义时的初值,可能为字符串常量,常量数组元素集合,常量表达式。对于字符串常量,直接存储即可。对于数组元素集合,通过大括号 { 来判定,如果是则解析后续出现的若干常量表达式即可;对于常量表达式,直接解析即可。
  • InitVal :代表变量定义时的初值,可能为字符串常量,变量数组元素集合,普通表达式。对于字符串常量,直接存储即可。对于数组元素集合,通过大括号 { 来判定,如果是则解析后续出现的若干普通表达式即可;对于普通表达式,直接解析即可。
  • FuncFParams :代表函数形参声明部分。函数形参声明可能有若干形参,因此使用 for 循环进行解析,直至无法解析出 FuncFParam 为止。根据解析出的形参个数,判定是否存在函数形参声明部分。即如果解析出的形参个数为0,则不存在函数形参部分。
  • FuncFParam :代表每个函数形参的定义部分。对于每个函数形参的定义,存储该形参的数据类型 BType 和形参标识符 Ident 。部分形参可能是数组(实际上是有着数组形式的指针),识别标识符后的中括号 [] ,并将类型存储。
  • BlockItem :代表每个块组成。其可能是声明 Decl 或语句 Stmt 。先假定为声明进行解析,若返回值为空,按照语句解析即可。
  • Stmt :代表块中可能出现的语句,推导规则比较复杂,大体可以分为三类:赋值型,特殊功能型,块或表达式。对于赋值型,即 LVal '=' Exp ';' , LVal '=' 'getint''('')'';' , LVal '=' 'getchar''('')'';' ,需要通过等号 = 来判定,如果是赋值型,直接存储即可;如果不是赋值型,此时已经对左值表达式 LVal 进行了解析,需要进行回溯,因此在解析前应保留解析位置,以便后期回溯。对于特殊型,可以直接通过关键词判断类型: 'if' '(' Cond ')' Stmt [ 'else' Stmt ] 可以通过 if 判定,并解析 if 的条件 Cond 和需要执行的语句 Stmt ,如果存在 else ,则额外解析 else 需要执行的语句 Stmt'for' '(' [ForStmt] ';' [Cond] ';' [ForStmt] ')' Stmt 可以通过 for 判定,并解析 for 的条件 Cond 和两个 for 语句 ForStmt (如果存在的话),以及需要执行的语句 Stmt';''break' ';''continue' ';' 语句中全部是保留词,直接解析即可; 'return' [Exp] ';' 可以通过 return 判定,并解析之后可能出现的表达式 Exp'printf''('StringConst {','Exp}')'';' 可以通过 printf 判定,存储保留词和字符串常量,并解析之后可能出现的若干表达式 Exp 。对于块或表达式,在排除上述所有情况后,直接解析即可,不会再出现需要回溯的情况。
  • ForStmt :代表 for 语句,只可能出现左值表达式的赋值。因此解析被赋值的左值表达式 LVal 和待赋值的普通表达式 Exp 即可。需注意,判定是 ForStmt 的标识是赋值的等号 = ,此时已经对左值表达式 LVal 进行了解析,如果不是 ForStmt 则需要进行回溯,因此在解析前应保留解析位置,以便后期回溯。
  • Cond :代表各类语句的条件,只能是逻辑表达式,按照文法,只能是逻辑或表达式 LOrExp 。因此,直接按照逻辑或表达式 LOrExp 对其解析即可。
  • LOrExp :代表逻辑或表达式,由逻辑或表达式 LOrExp 、逻辑或符号 || 、逻辑与表达式 LAndExp 组成。由于该文法是左递归文法,如果直接解析会出现无限下降的问题。因此,改写文法,先解析逻辑与表达式 LAndExp ,如果存在逻辑或符号 || ,则将其种类变换为逻辑或表达式 LOrExp ,并按照上述方法继续进行。
  • LAndExp :代表逻辑与表达式,由逻辑与表达式 LAndExp 、逻辑与符号 && 、相等性表达式 EqExp 组成。由于该文法是左递归文法,因此,按照 LOrExp 中相似的方法进行解析。
  • EqExp :代表相等性表达式,由相等性表达式 EqExp 、相等性符号 ==!= 、关系表达式 RelExp 组成。由于该文法是左递归文法,因此,按照 LOrExp 中相似的方法进行解析。
  • RelExp :代表关系表达式,由关系表达式 RelExp 、关系符号 ><>=<= 、加减表达式 AddExp 组成。由于该文法是左递归文法,因此,按照 LOrExp 中相似的方法进行解析。
  • ConstExp :代表常量表达式,只能是加减表达式。因此,直接按照加减表达式 AddExp 对其解析即可。同时应指出,由文法规定,常量表达式中出现的全部标识符必须是常量。
  • Exp :代表普通表达式,只能是加减表达式。因此,直接按照加减表达式 AddExp 对其解析即可。
  • AddExp :代表关系表达式,由加减表达式 AddExp 、加减符号 +- 、乘除表达式 MulExp 组成。由于该文法是左递归文法,因此,按照 LOrExp 中相似的方法进行解析。
  • MulExp :代表乘除表达式,由乘除表达式 MulExp 、乘除符号 */% 、一元表达式 UnaryExp 组成。由于该文法是左递归文法,因此,按照 LOrExp 中相似的方法进行解析。
  • UnaryExp :代表一元表达式,可能是带一元符号的一元表达式,基本表达式或函数调用。对于带一元符号的一元表达式,通过医院符号进行判定,若是则解析一元表达式 UnaryExp 即可。对于函数调用,通过标识符后的小括号 ( 判定,若是函数调用,则对括号内进行函数实参 FuncRParams 解析。排除函数调用的情况后,一定是基本表达式,对基本表达式 PrimaryExp 进行解析即可。
  • FuncRParams :代表函数实参。其由若干普通表达式组成,因此使用 for 循环进行解析,直至无法解析出 Exp 为止。
  • PrimaryExp :代表基本表达式。可能是 int 型常量、 char 型常量、带小括号的普通表达式或左值表达式 LVal 。对于 int 型常量、 char 型常量,按照数据类型直接解析存储即可。对于带小括号的普通表达式,通过小括号 ( 判定,若是则对后续普通表达式解析即可。对于左值表达式 LVal ,直接解析即可。
  • LVal :代表左值表达式,主要是标识符(普通变量常量标识符,变量常量数组标识符,带偏移的变量常量数组标识符)。对于不带偏移的标识符(即不存在 [Exp] ),直接当作标识符 Ident 处理即可。对于带偏移的变量常量数组标识符,在存储标识符的同时也需要对偏移量表达式解析存储。

对于每一种实现 Syntax 的语法节点,其应有属性 syntaxes ,表示其含有的全部子节点。获得语法树 AST 各节点后,将其存入父节点的 syntaxes 中,并设置函数,使其可以正确输出必要信息。


语义分析设计

语法分析部分分析出的语法树 AST 会存储在编译器中。语义分析部分的主要任务是解析出现的标识符(函数标识符,常量标识符,变量标识符),构建符号表,以便后续代码生成时方便查阅。

对于各类符号,在不同域定义的标识使用范围不同:在外层定义的标识可以在内层使用,而在内层定义的标识不可以在外层使用。相同层不同域定义的标识不可以互相使用。因此,需要建立一种双向树结构,每个节点代表每个符号表项,相同层节点代表相同层的符号表项。建立符号表项类 Table ,其属性有符号表项序号 id ,符号表项的父符号表项 father ,符号表项所处的层数 floor ,符号表项内存储的符号 symbols 。这样在查找符号时,从当前表项出发,从底向上逐层遍历,直至获得当前符号:

1
2
3
4
5
6
7
public Symbol searchDefinition(String symbolName) {
for (Symbol symbol : symbols) {
if (symbol.getName().equals(symbolName)) return symbol;
}
if (father != null) return father.searchDefinition(symbolName);
return null;
}

需要储存的符号有函数,变量,常量,变量数组以及常量数组。每种符号都有其类型和要储存的属性,应该建立类,规定相应属性:

  • Symbol :总的符号类,其他符号类的父类,存储有各类符号的基本信息。包括符号序号 id ,符号所在的符号表项 table ,符号名 name , 符号对应的单词 token ,符号类型 type (可能为 "intVar""charVar""intArray""charArray""intFunc""charFunc""voidFunc" )。对于每个属性,建立 setget 方法,并设置函数,使其可以正确输出必要信息。
  • VarSymbol :变量常量符号类,继承 Symbol 。除去 Symbol 中存储的信息,还应额外存储符号数据类型 bType ,符号是否代表常量 con 。在创建该类时,应给出全部必要信息。
  • ArraySymbol :变量常量数组符号类,继承 Symbol 。除去 Symbol 中存储的信息,还应额外存储符号数据类型 bType ,符号是否代表常量 con ,数组维度 dim (应指出 SysY 语言规定数组为一维)。在创建该类时,应给出全部必要信息。
  • FuncSymbol :函数符号类,继承 Symbol 。除去 Symbol 中存储的信息,还应额外存储函数数据类型 funcType ,函数的形参个数 paramNum ,函数各形参的类型 paramTypes (可能为 "intVar""charVar""intArray""charArray" )。在创建该类时,应给出全部必要信息。

完成必需类准备后,可以进行符号的解析与符号表的构建。在语义分析器(符号管理器) Manager 中,读入并遍历语法树 AST ,解析各语法成分,获得新出现的符号,存入相应的符号表项。在遍历过程中,由于函数、块等的存在,符号会出现不同的定义与使用域,因此在遍历过程中,需要注意和维护当前符号所在层数,在层数改变时改变当前符号表(层数增加时新建符号表项,层数减少时转移至当前表项的父符号表项,如不存在则报错)。由于层数改变出现较为频繁,因此封装函数 createTable()quitTable()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public void createTable() {
Table father = nowTable;
curFloor++;
curSerial = tables.size(); // 当前符号表序号
curIndex = 0; // 当前符号表中当前符号序号
nowTable = new Table(curSerial, father.getId(), father, curFloor);
tables.add(nowTable);
}

public void quitTable() {
curFloor--;
curSerial = nowTable.getFather().getId();
curIndex = nowTable.getFather().getSize();
nowTable = nowTable.getFather();
}

会发生符号表变更的位置是(主)函数定义,带块的 iffor ,普通的块。应指出,虽然这几个位置产生表的变化都是因为块,但不可将符号表项的变更放在块的解析中。这是因为函数定义中可能出现形参,需要存入新符号表项中,因此应该具体问题具体分析。对于不带块的 iffor ,根据文法,其后只能是 [Exp] ';' ,不能出现新的声明 Decl ,因此不新建符号表。这些符号表项变化主要出现在 MainFuncDefFuncDefStmt 中。

  • MainFuncDef :主函数内的符号表变化,不含形参。主要是在主函数的块内,因此在进入块前调用 createTable() ,完成块的解析并退出块后调用 quitTable()
  • FuncDef :普通函数内的符号表变化,不含形参。主要是在普通函数的形参声明以及块内,因此在函数进行形参声明前调用 createTable() ,完成块的解析并退出块后调用 quitTable()
  • Stmt :主要是带块的 iffor 和 普通的块。在这里,可以进行合并,统一在 Stmt → Block 的过程中进行。在进入块前调用 createTable() ,完成块的解析并退出块后调用 quitTable()

对于三种符号 FuncSymbolVarSymbolArraySymbol ,第一种主要在函数定义中出现( main 函数可以直接获得符号,无需特殊说明),后两种主要在变量常量定义,函数形参解析中出现。

  • FuncDef :根据解析的语法成分,获得函数标识符并建立 FuncSymbol 存储在当前符号表项中。
  • ConstDef / VarDef :根据解析的语法成分(标识符和数据类型),建立 VarSymbolArraySymbol 存储在当前符号表项中。
  • FuncFParam :建立符号的方法基本与 ConstDef / VarDef 相同,建立 VarSymbolArraySymbol 存储在当前符号表项中。

错误处理设计

按照文法设计与实验安排,本次实验中总共有 种错误,其中 a 类错误会出现在词法分析部分,ijk 类错误会出现在语法分析部分,其余错误会出现在语义分析部分。同时,为了统一性和复用性,在实验中建立了错误判定器类 Judgement ,判断每类错误时输入必要信息,获得判断结果。同时 Judgement 内含属性 errors ,存储分析过程中抛出的全部错误。现对每种错误进行分析:

  • a 类错误:非法符号 &| ,应该将其当做 &&|| 进行处理,并在记录单词名称的时候正确记录。这个错误主要在词法分析对 &| 的分析中出现,判断当前字符与下一个字符是否相同即可。由于出现在词法分析部分,为了错误检测的正常运行(以检测出全部错误),按照出现的第一个字符添加或更改字符,将其作为正确单词存入。判断函数输入的必要信息是当前字符、下一个字符以及当前字符所在行数。
  • b 类错误:符号名在当前域下重复定义。应指出,变量在同一级域下重定义会判定错误,不同级作用域下,内层会覆盖外层定义。这个错误主要出现在语义分析符号定义阶段。将当前符号放入当前符号表项搜索,判断是否出现相同名字的符号。判断函数输入的必要信息是当前符号以及当前符号表项。
  • c 类错误:使用未定义的标识符。使用当前符号表项或其父符号表项中不含有的的符号,主要出现在语义分析的语句和表达式的解析中。将当前符号在当前符号表以及父符号表等递归搜索,判断是否出现相同名字的符号。判断函数输入的必要信息是当前标识符单词以及当前符号表项。
  • d 类错误:函数的形参和实参个数不匹配。主要出现在语义分析的函数调用阶段。在函数定义阶段,有对函数的形参进行存储。在调用阶段( UnaryExp 的解析阶段),以 , 作为分隔符解析函数实参,存储实参的个数。比较形参数组的大小和实参个数即可。判断函数输入的必要信息是当前函数的符号以及实参个数。
  • e 类错误:函数的形参和实参类型不匹配。主要出现在语义分析的函数调用阶段。在函数定义阶段,有对函数的形参进行存储。在调用阶段( UnaryExp 的解析阶段),以 , 作为分隔符解析函数实参,存储各实参的类型。比较各形参和各实参的类型是否匹配即可。应注意,类型匹配过程中,普通变量常量允许出现数据转换,即 intVarcharVar 可以认为是匹配的。其余类型必须完全匹配才可。判断函数输入的必要信息是当前函数的符号以及实参类型数组。
  • f 类错误: void 类型的函数存在不匹配的 return 语句。 void 类型函数如果存在返回语句,返回语句后一定不能出现 Exp 。主要出现在语义分析的语句分析阶段。在语句分析的过程中,如果出现 return ,应解析其后单词,判断是否出现 Exp 。判断函数输入的必要信息是当前函数符号,当前 return 所在行数以及当前 return 是否存在返回值。
  • g 类错误: intchar 类型的函数在函数末尾不存在 return 语句。为简化问题,实验规定, intchar 类型的函数在函数末尾必须存在 return 语句,即使在 if-else 控制流语句中出现也不能省略。主要出现在语义分析的函数定义阶段。在控制流外,如果出现 return 语句,则存储该语句所在域层数 returnFloor (这表明该变量一定保留的是最后一个 return 语句所在的层数,该变量初始化为 );如果 returnFloor 的值不等于 funcFloor + 1 (为 时证明不存在 return 语句,大于 funcFloor + 1 时证明该 return 语句在控制流内),则证明其存在该类错误。判断函数输入的必要信息是函数符号,最后一个 return 语句所在的层数以及函数块 } 的行号。
  • h 类错误:尝试修改常量的值。主要出现在语义分析的语句和表达式的解析中。在构建语法树时,对于标识符,有属性 con 判断其是否是常量。因此,在赋值时根据该属性判断是否存在错误即可。判断函数输入的必要信息是常量符号以及是否是对该常量赋值。
  • i 类错误:缺少必要的 ; 。这个错误主要出现在语法分析部分,在构建语法树的过程中按照正确文法检测相应位置是否存在 ; 即可。由于出现在语法分析部分,为了错误检测的正常运行(以检测出全部错误),添加缺少的 ; ,以保证当前语法成分的正确性。判断函数输入的必要信息是当前字符、上一个字符所在行数。
  • j 类错误:缺少必要的 ) 。这个错误主要出现在语法分析部分,在构建语法树的过程中按照正确文法检测相应位置是否存在 ) 即可。由于出现在语法分析部分,为了错误检测的正常运行(以检测出全部错误),添加缺少的 ) ,以保证当前语法成分的正确性。判断函数输入的必要信息是当前字符、上一个字符所在行数。
  • k 类错误:缺少必要的 ] 。这个错误主要出现在语法分析部分,在构建语法树的过程中按照正确文法检测相应位置是否存在 ] 即可。由于出现在语法分析部分,为了错误检测的正常运行(以检测出全部错误),添加缺少的 ] ,以保证当前语法成分的正确性。判断函数输入的必要信息是当前字符、上一个字符所在行数。
  • l 类错误: printf 字符串中格式字符数量与表达式个数不匹配。文法规定 printf 函数中可以出现格式字符 %d / %c ,用于输出 intchar 类型的算式。该类错误即是格式字符 %d / %c 与要输出的表达式的数量不同。 主要出现在语义分析的语句 printf 的解析中。在 printf 语句解析时,使用正则表达式,获得输出字符串中格式字符的匹配数量,并与解析的表达式个数进行比较。判断函数输入的必要信息是输出字符串以及表达式数量。
  • m 类错误:在非循环块中使用 breakcontinue 语句。主要出现在语义分析的语句 breakcontinue 的解析中。要判断当前语句是否位于循环块中,需要动态维护循环层数 forFloor 。该属性初值为 ,仅在进入循环时增加,在退出循环时减少。解析 break / continue 语句时,只需要判断循环层数 forFloor 是否大于 即可。 判断函数输入的必要信息是break / continue 语句的行号以及循环层数 forFloor

错误处理主要出现在词法分析、语法分析和语义分析部分。为了尽可能全面地检查出全部错误,按照流程,应保证词法分析和语法分析部分遇到错误不仅需要抛出相应错误,还需要进行修正,以便后续流程进行。错误处理完成后,获取 errors 属性,并输出相应信息。同时 errors 也将作为判断是否能进行代码生成的充分条件。


代码生成设计

在获得语法树 AST 后,可以按照树型结构将其转换为目标代码树。直接转化为目标代码难度较大,因此优先转化为中间代码树,在转换为目标代码树。本次实验中选择的目标代码是MIPS,中间代码是LLVMIR。

中间代码生成设计

在中间代码生成器 Visitor 中,读入语法分析中生成的抽象语法树 AST 和语义分析中生成的符号表 tables ,目标是生成中间代码树 MCT 。对于特定的语法成分,可以对应生成相应的中间代码节点 IR 。LLVMIR中的成分与结构如下图所示:

中间代码树结构

LLVMIR中的成分主要有模块 Module ,对应语法成分中的 CompUnit ;全局值 GlobalValue ,对应语法成分中直接声明的全局变量常量;函数 Function ,对应语法成分中的 FuncDef ;基本块 BasicBlock ,存有需要先后执行的指令(一个基本块中的指令一定是一同执行的);指令 Instrunction ,LLVMIR的基本指令,也是转化为MIPS指令的中间形式。一些常用的LLVMIR基本指令如下:

LLVM IR 使用方法 简介
add <result> = add <ty> <op1>, <op2> /
sub <result> = sub <ty> <op1>, <op2> /
mul <result> = mul <ty> <op1>, <op2> /
sdiv <result> = sdiv <ty> <op1>, <op2> 有符号除法
srem <result> = srem <type> <op1>, <op2> 有符号取余
icmp <result> = icmp <cond> <ty> <op1>, <op2> 比较指令
and <result> = and <ty> <op1>, <op2> 按位与
or <result> = or <ty> <op1>, <op2> 按位或
call <result> = call [ret attrs] <ty> <name>(<...args>) 函数调用
alloca <result> = alloca <type> 分配内存
load <result> = load <ty>, ptr <pointer> 读取内存
store store <ty> <value>, ptr <pointer> 写内存
getelementptr <result> = getelementptr <ty>, ptr <ptrval> {, <ty> <idx>}* 计算目标元素的位置(数组部分会单独详细说明)
phi <result> = phi [fast-math-flags] <ty> [<val0>, <label0>], ... /
zext..to <result> = zext <ty> <value> to <ty2> tyvalue 的 type 扩充为 ty2(zero extend)
trunc..to <result> = trunc <ty> <value> to <ty2> tyvalue 的 type 缩减为 ty2(truncate)
br br i1 <cond>, label <iftrue>, label <iffalse>
br label <dest>
改变控制流
ret ret <type> <value>, ret void 退出当前函数,并返回值

想要对中间代码成分进行分析,首先应为各成分建立类型类 ValurTypeIR 。根据代码生成中出现的变量类型,可将其分为两类,基本类型和复合类型。

  • 基本变量类型:
    • IntegerTypeIR :位数据类型,含有属性位数 bits ,代表数据所占的位数。 bits = 32 ,代表 int 类型; bits = 8 ,代表 char 类型; bits = 1 ,代表 bool 类型。
    • VoidTypeIRvoid 数据类型,仅 FunctionIR 中会出现。
    • LabelTypeIR :标签类型,代表当前变量的类型为标签。
  • 复合变量类型:
    • ArrayTypeIR :数组类型,含有属性数组元素类型 typeIR 、数组元素个数 elementNum 。其中,本实验中规定数组元素类型 typeIR 仅能为位数据类型 IntegerTypeIR ,即只存在 int / char 类型的数组。
    • PointerTypeIR :指针类型,含有属性指针对象类型 typeIR 。其中,本实验中规定指针所指对象类型为数组类型 ArrayTypeIR (当含有为数组申请地址的指令时)和位数据类型 IntegerTypeIR (当含有普通地址指令时)。
    • FunctionTypeIR :函数类型,含有属性函数数据类型 typeIR 、函数形参数据类型 paramTypes

在建立成分类型类后,为各中间代码成分构建类,并对语法树 AST 遍历解析获得中间代码成分。所有的中间代码节点(除顶层模块),均继承于中间代码值类 ValueIR 。该类主要代表各种中间代码索要使用的值,最常用的子类是 FunctionIR (用于函数调用等指令中)、 BasicBlockIR (用于跳转等指令中)等。构建的中间代码成分类如下:

  • ModuleIR :中间代码中的模块,代表整个程序。由于本次实验中涉及的待编译 SysY 语言文件都是单文件,因此 ModuleIR 唯一。其含有属性全局变量声明 globalVariables 、函数声明 functions 。在 CompUnit 解析中获得ModuleIR

  • GlobalVariableIR :中间代码中的全局变量声明。其含有属性变量名 name 、初始值 initValue 、是否是输出字符串 isPrint 。在 Decl 中,根据当前变量常量所在层数判断其是否是全局变量常量。

  • FunctionIR :中间代码中的函数(包括主函数)。其含有属性函数名 name 、函数参数 params 、函数含有的基本块 blocks 、函数中变量数量 numVar 。在 FuncDef 解析中获得 FunctionIR 。LLVMIR中变量以序号表示,每个函数中的变量编号均从 开始,因此可以内置函数 allocVar ,为函数申请新变量。

    1
    2
    3
    4
    5
    public VariableIR allocVar(ValueTypeIR typeIR) {
    VariableIR var = new VariableIR(typeIR, numVar, name);
    numVar++;
    return var;
    }
  • BasicBlockIR :中间代码中的基本块,存储需要一同执行的LLVMIR指令。其含有属性基本块标签 label 、基本块是否可以添加指令 canAddInstr 、基本块含有的指令 instrs 。在 Block 的解析中获得 BasicBlockIR (由于申请到的块在 Block 外可能需要使用,因此将新 BasicBlockIR 的申请放在 FuncDefStmt 的解析中)。在 iffor 这种含有条件 Cond 解析的语句中,需要注意额外新 BasicBlockIR 的申请。

    对于普通的 Cond 判断,其结构应该是 condBlock-trueBlock-falseBlock 。条件判断块 condBlock 中含有跳转指令 br cond, trueLabel, falseLabel 。在条件 Cond 的解析中,需要满足短路求值原则。即对于逻辑与表达式和逻辑或表达式,当左侧表达式可以确定整体表达式值时,不访问右侧表达式。这意味着,当前访问表达式之前的表达式计算结果为真。对于不同形式的表达式,由于短路求值原则,跳转的标签也有所不同。

    • 对于逻辑或表达式 B1 || B2B1 对应的基本块 block1 的跳转指令,为真跳转到 trueLabel ,为假跳转到 B2LabelB2 对应的基本块 block2 的跳转指令,为真跳转到 trueLabel ,为假跳转到 falseLabel
    • 对于逻辑与表达式 B1 && B2B1 对应的基本块 block1 的跳转指令,为真跳转到 B2Label ,为假跳转到 falseLabelB2 对应的基本块 block2 的跳转指令,为真跳转到 trueLabel ,为假跳转到 falseLabel
    • 对于逻辑与或组合表达式 B1 && B2 || B3B1 对应的基本块 block1 的跳转指令,为真跳转到 B2Label ,为假跳转到 B3LabelB2 对应的基本块 block2 的跳转指令,为真跳转到 trueLabel ,为假跳转到 B3LabelB3 对应的基本块 block3 的跳转指令,为真跳转到 trueLabel ,为假跳转到 falseLabel
    • 对于逻辑与或组合表达式 B1 || B2 && B3B1 对应的基本块 block1 的跳转指令,为真跳转到 trueLabel ,为假跳转到 B2LabelB2 对应的基本块 block2 的跳转指令,为真跳转到 B3Label ,为假跳转到 falseLabelB3 对应的基本块 block3 的跳转指令,为真跳转到 trueLabel ,为假跳转到 falseLabel

    由于在 Cond 的解析函数中,无法获得具体的表达式的数量,因此只能在逻辑与或表达式的解析中创建新的基本块。在解析与或表达式时,出现与或符号,证明还存在下一个表达式,则在解析当前表达式之前应该建立新的基本块。解析逻辑或表达式新建的基本块( nextOrBlock ,下一个逻辑或表达式块)应当一同传入逻辑与表达式的解析函数中。同时可以观察到,像 B1 || B2 && B3 这种形式, B2 后不存在下一个逻辑或表达式块,为假时跳转到 falseLabel 。因此,在逻辑或表达式新建基本块时应判断当前是否是最后一个逻辑或表达式,以决定跳转指令的目标。

    同时,应注意,LLVMIR规定,一个基本块中只能出现一个改变控制流的指令( brret ),因此在基本块出现该类指令后将 canAddInstr 置为 false ,后续产生的指令不能再添加到该基本块中。

  • ConstIR :中间代码中的常量。其含有属性常量数值 value

    • ConstArrayIR :中间代码中的数组常量,继承 ConstIR 。其含有属性数组元素 values
    • ConstStringIR :中间代码中的字符串常量,继承 ConstIR 。区别于普通的字符串常量(那将在字符数组的初始化中解决),这个字符串常量专指 printf 中的输出字符串。其含有属性字符串名 name 、字符串值 value 、字符串长度 length
  • VariableIR :中间代码中的变量。其含有属性变量名 name 、变量编号 pos 、变量值 value 、是否是全局变量 isGlobal 。对于普通变量和全局变量,其所使用的属性不同:全局变量使用变量名、变量值,局部变量使用变量编号、变量值。

  • InstrIR :中间代码中的指令。其含有属性操作符号 op

    • AllocateIR :申请地址的指令,含有属性申请结果 result 、申请类型 type 。主要出现在变量常量声明中,为要声明的标识申请足够的存储地址。
    • StoreIR :存储变量的指令,含有属性存储值 value 、存储地址 address 。主要出现在赋值语句中,将新值存储在变量对应的地址。
    • LoadIR :加载变量的指令,含有属性加载到的值 value 、加载地址 address 。主要出现在对变量的使用语句中,将变量的值从对应地址中取出并参与运算。
    • CallIR :调用函数的指令,含有属性调用函数 function 、函数返回值 result (函数是 void 型时为空)、函数实参 params 。主要出现在一元表达式的函数调用中,确定调用函数,将解析的表达式作为实参存入指令,获得返回值。
    • GetElementIR :计算数组元素地址的指令,含有属性元素地址 result 、数组基地址 address 、元素索引 index 。主要出现在数组元素的访问中。
    • BinaryIR :二元有符号计算指令,含有属性左值 lValue 、右值 rValue 、结果 result 。主要出现在加减乘除表达式的解析中,根据计算类型设置指令类型。
    • BTypeTranIR :数据类型转换指令,含有属性转换值 value 、转换结果 result 。主要出现在各种数据类型转换的赋值中。
    • IcmpIR :数据比较指令,含有属性左值 lValue 、右值 rValue 、结果 result 。主要出现在关系表达式和相等性表达式。
    • BrIR :跳转指令,含有属性是否存在条件 haveCond 、跳转块 block 、跳转条件 cond 、为真时跳转块 trueBlock 、为假时跳转块 falseBlock 。有条件的跳转指令使用跳转条件、为真时跳转块、为假时跳转块;无条件的跳转指令使用跳转块。主要出现在各 BasicBlock 末尾改变控制流。
    • RetIR :返回指令,含有属性返回结果 result (函数是 void 型时为空)。主要出现在 return 语句的解析中。

完成全部预备类构建后,即可遍历语法树 AST ,按照上文中所述的解析方式,构建对应的LLVMIR成分,组成中间代码树 MCT

目标代码生成设计

在目标代码翻译器 Translator 中,读入生成的中间代码树 MCT ,目标是生成目标代码树 OCT 。目标代码的结构与中间代码相似,顶层模块 ModuleMips 、全局声明 GlobalMips 、函数 FunctionIR 、基本块 BlockMips 、指令 InstrMips 。因此,目标代码生成的主要思路是遍历中间代码树,生成对应的目标代码部分。

对于模块 ModuleIR 、全局声明 GlobalVariableIR 、基本块 BasicBlockIR 的遍历解析,难度较小,基本为直译,因此不过多赘述。主要说明函数 FunctionIR 和指令 InstrIR 的翻译。此次实验中使用到的MIPS指令如下:

MIPS 使用方法 简介
La La $1, <label> 从标签加载数值
Li Li $1, <value> 从立即数加载数值
move move $1, $2 将寄存器值赋给另一寄存器
mfhi mfhi $1 hi 寄存器
mflo mflo $1 lo 寄存器
addu addu $1, $2, $3 无符号加
addiu addiu $1, $2, <value> 无符号加立即数
subu subu $1, $2, $3 无符号减
mult mult $1, $2 有符号乘
div div $1, $2 有符号除
slt slt $1, $2, $3 有符号小于置1
andi andi $1, $2, $3 与立即数
sll sll $1, $2, <offset> 逻辑左移
srl srl $1, $2, <offset> 逻辑右移
sra sra $1, $2, <offset> 算术右移
lw lw $1, <offset>($2) 加载字
sw sw $1, <offset>($2) 存储字
beq beq $1, $2, <label> 相等时转移
bne bne $1, $2, <label> 不等时转移
bgez bgez $1, <label> 大于等于0时转移
blez blez $1, <label> 小于等于0时转移
bgtz bgtz $1, <label> 大于0时转移
bltz bltz $1, <label> 小于0时转移
j j <label> 跳转
jal jal <label> 跳转并链接
jr jr $1 跳转至寄存器
syscall syscall 系统调用

对于MIPS指令的翻译,最重要的是寄存器的存储和分配系统的建立。建立寄存器类 Register ,其含有属性名称 name 、编号 id 、是否正在使用 use 、寄存器中值 value 。同时建立寄存器表类 RegisterTable ,负责寄存器的管理。对于寄存器的分配,采用的策略是如果有未在使用的寄存器,即分配该寄存器;如果全部寄存器都在使用,则将 $t0 的值压入数据栈中,分配 $t0 。对于该种分配方法,核心是寄存器应该在何时释放。寄存器的释放原则是每次存入寄存器的值只能使用一次,使用后立即释放(出于正确性考虑,再使用寄存器前,应检查该寄存器内数据是否是当前变量),以防止寄存器数量不够用以及控制流改变引发的寄存器冲突问题。寄存器的释放过程在指令中进行。

由于每一个 VariableIR 可能被不止一次使用,因此应该建立中间代码变量符号表,以便后续使用。建立中间代码符号类 SymbolMips ,其含有属性变量名 name 、是否是全局声明 isGlobal 、是否是立即数 isImm 、是否是绝对地址 isAbsolute 、分配的寄存器 register 、数值 value 、存储的相对地址 addrOffset 。对于普通变量或立即数,填写相应属性即可。对于代表地址的变量,则需要要特殊处理。对于存储全局声明标签的变量,需要标记 isAbsolute 为真,后续使用时可能出现相对地址与绝对地址之间的转化。

在完成预备类的构建后,进行 InstrIR 的翻译:

  • AllocateIR :为变量申请地址,不增加MIPS指令,只改变当前的 offset 。为变量构建中间代码变量符号,并将当前相对 的偏移量 offset 赋值给相应属性即可。按照申请的地址大小,增加 offset 的值( int / char 型均按照一个字节存储,变量为数组时按照元素数量改变 offset 的值)。
  • StoreIR :存储变量,使用 sw $1, <offset>($2) 即可。应注意判断存储到的指针变量类型:若该变量是相对地址,则使用 sw $1, <offset>($sp) ;若该变量是绝对地址,则使用 sw $1, 0($2) 。完成存储后,释放分配给值变量和地址变量的寄存器。
  • LoadIR :加载变量,使用 lw $1, <offset>($2) 即可。对于待加载的指针变量类型的判定和 StoreIR 相同。完成加载后,释放分配给地址变量的寄存器。
  • CallIR :调用函数,使用 jal <label> 跳转到函数位置即可。
  • GetElementIR :计算数组元素地址, elementAddr = baseAddr + index * 4elementAddr 统一使用绝对地址。如果基地址为绝对地址,直接使用 slladdu 指令计算即可。如果基地址为相对地址,则需要额外加 $sp 。完成计算后,释放分配给基地址变量和索引变量的寄存器。
  • BinaryIR :二元有符号计算,使用对应的二元计算指令即可。应注意,为防止计算中的数值溢出,加减指令应使用 addusubu 。对于乘除模,使用 multdiv 指令搭配 mfhimflo 指令即可。完成计算后,释放分配给算式左值右值的寄存器。
  • BTypeTranIR :数据类型转换,由于 int / char 型均按照一个字节存储,理论上结果变量使用值变量的寄存器即可。但应注意,对于 trunc 指令,将 int 型缩减到 char 型,高24位数据应当舍弃。因此若为 trunc 指令,则添加 andi $1, $2, 0xff
  • IcmpIR :二元有符号比较,按照比较类型添加指令即可。由于LLVMIR中是先比较得到结果再进行跳转,而MIPS中只有 slt 一种比较指令,其他类型比较只有直接的跳转指令。因此为了不破坏LLVMIR指令的原子性,需要使用组合的 slt 指令。将 <= / >= 转化为 !> / !<== / != 转化为 !> && !< / > || < ,使用 slt 搭配逻辑运算指令即可实现其他类型的比较。完后比较后,释放分配给比较式左值右值的寄存器。
  • BrIR :各类跳转。对于 br label ,可直接翻译为 j <label> ;对于 br cond, trueLabel, falseLabel ,可翻译为 beq cond, 0, <falseLabel>j <trueLabel> 的组合。对于后者,完成比较后,释放分配给条件的寄存器。
  • RetIR :函数返回。对于存在返回值的 RetIR 指令,首先需要将返回值赋值给 $v0$v1 。由于文法中规定,返回值至多为一个值,因此只使用 $v0 即足够。完成赋值后, jr $ra 返回调用前下一条指令即可。

由于MIPS中只存在全局寄存器和数据栈,不存在函数形参和实参的概念,因此 FunctionMips 的处理与 FunctionIR 的处理有所不同。在调用函数时,首先应进行压栈,将目前全部寄存器中的数据压入栈中,并按照压栈的数据数量改变 $sp 的值,防止函数解析时改变寄存器值而造成的错误。将实参解析后存入 $a0 - $a3 寄存器中,如果实参数量超过 个,则将多余的实参压栈。进入函数解析后,首先应将 $sp 和实参取出并存在栈中。由于可能存在参数数量超过 个的情况, $a0 对应的参数应当存入 offset = 4 的地址,会造成形参的污染,因此形参的存储应从后向前进行。函数解析中如果出现调用其他函数的情况,重复上述操作即可。行进到 ret 语句时取出 $sp ,并加入 jr $ra 。完成函数解析并返回调用前下一条指令后,将调用前压入栈中的数据从栈中取出(在出栈前可以先释放全部临时寄存器,以防函数遗留数据导致寄存器后期无法使用),并按照压栈的数据数量改变 $sp 的值。


代码优化

中间代码优化

中间代码优化主要有以SSA形式建立中间语言、死代码删除、GVN&GCN建立、常量提取等。由于时间原因,本次实验中编译器未进行中间代码优化。

目标代码优化

目标代码优化主要有指令选择、图着色寄存器分配、乘除法优化、基本块合并和窥孔优化。本次实验中,主要完成了指令选择、无用指令删除、乘除法优化。

指令选择

为了实现更多功能,以及更加方便翻译高级语言,MIPS提供了大量的伪指令使用,例如subimoveble 等,能够缩短指令的条数,增加代码的可读性。但实际上,伪指令会被翻译为多条实际指令进行使用。以 subi 为例,一条 subi $1, $2, 1000 的指令,会被MIPS的模拟器Mars翻译为 addi $3, $0, 0x3e8sub $1, $2, $3 两条指令;但是如果直接使用 addi $1, $2, -1000 的指令,不会被翻译为额外指令,可以提高程序执行效率。因此,将全部 subi 指令替换为 addi 指令。程序中涉及到 subi 指令的部分主要是调用函数时 $sp 的改变,将这部分指令替换即可。

在目标代码翻译的过程中,出于保护LLVMIR指令原子性的考虑,将 IcmpIR 翻译为 slt 和逻辑运算指令搭配的形式,如 %res = icmp eq i32 %1, %2 ,翻译为MIPS指令就是 slt $t2, $t1, $t0slt $t1, $t0, $t1or $t2, $t2, $t1xor $t2, $t2, 1 四条指令,最终寄存器 $t2 存储的就是比较结果。由于比较式出现在 Cond 中,其后通常伴随着跳转指令,因此可以对其进行优化。比如说源代码 if(i == 1) {...} ,直接生成LLVMIR指令如下:

1
2
3
4
5
6
%main_var2 = load i32, i32* %main_var1
%main_var3 = icmp eq i32 %main_var2, 1
br i1 %main_var3, label %trueLabel, label %falseLabel
trueLabel:
...
falseLabel:

优化前优化后的MIPS指令如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 优化前:
lw $t0, 4($sp)
move $t1, $t0
li $t0, 1
slt $t2, $t1, $t0
slt $t1, $t0, $t1
or $t2, $t2, $t1
xor $t2, $t2, 1
beq $t2, $zero, falseLabel
trueLabel:
...
falseLabel:

#优化后
lw $t0, 4($sp)
move $t1, $t0
li $t0, 1
bne $t1, $t0, falseLabel
trueLabel:
...
falseLabel:

其他比较类型 != | >= | <= 与上述优化相似。

无用指令删除

在MIPS指令中,每个基本块不强制要求出现改变控制流的跳转指令,如果该块不存在跳转指令,则默认按照顺序执行。由于在目标代码翻译的过程中,对于带条件的 br 指令 br cond, trueLabel, falseLabel ,采取的翻译模式是 beq cond, 0, <falseLabel>j <trueLabel> 的组合,而通常来讲 j <trueLabel> 跳转到的 trueBlock 即是下一个基本块。同时,在中间代码生成的过程中,可能会出现仅含有 br 指令的基本块。这类块在目标代码翻译中会被翻译为 j <label> ,且通常跳转到下一个基本块。在这些情况中,会出现多余的跳转指令。因此可以特判跳转指令,如果跳转到的 <label> 和下一个块的标签相同,则省略这条跳转指令。

乘除法优化

在MIPS指令中,乘除法计算指令 multdiv 消耗时间相比 sllsrl 等移位指令, addusubu 等计算指令过大,需要进行一定优化。按照程序设置,在中间代码生成的过程中,当表达式左右两侧都为常量值时,会直接计算出结果。因此在目标代码翻译的过程中,只可能出现两个变量计算或一个变量一个常量值计算。乘除法优化基于一个变量一个常量值计算进行,主要原理是通过判定常量值是否是特定数值,优化乘除计算指令。在优化过程中,我们保证除数为正,当除数为负数时添加 subu $1, $0, $1$ 即可。

乘法优化
  • 数值为 :可以直接对结果寄存器赋 值。
  • 数值为 的幂数:使用 sll 指令,计算该常量对于 的幂次,左移相应位数。
  • 数值与小于它的最大 的幂数差小于 :按照实验设定, mult 的时间消耗是 slladdu 倍,因此部分乘数可以分解为 sll + n * addu 的形式,如数值为 10 = 8 + 2 ,此时将 x * 10 就可以转变为 x << 3 + x + x ,时间变短。数值与大于它的最小 的幂数差小于 的同理,分解为 sll + n * subu 的形式。
  • 其他数值:无法进行优化,正常添加指令即可。
除法优化
  • 数值为1:可以直接对结果寄存器赋原值。

  • 数值为2的幂数:使用 sra 指令,计算该常量对于2的幂次,右移相应位数。移位计算的结果是除法结果向下取整,如 。若被除数小于 ,除法结果应当是移位计算结果加1。因此,首先使用 srl $2, $1, 31 获得被除数的符号位,并与移位结果相加(若被除数为正,符号位为 ,除法结果等于移位计算结果;若被除数为负,符号位为 ,除法结果等于移位计算结果加 )。

  • 其他数值:按照实验设定, div 指令的时间消耗远大于 mult 的,因此可以借助乘法来优化除法。基本原理是先对原被除数乘一个大数,再右移获得最终结果。这个大数既要保证乘之后可以保证原除法结果,又要保证除法结果已经全部位于 hi 寄存器中。对于被除数 、除数 、大数 ,存在关系: ,其中大数 满足 ,常数 保证该大数满足要求。为简化大数 的取值,设定 。可以得到代码如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    int l = 1;
    int m = (int) (Math.pow(2, 32 + l) / valueAbs);
    if (!(Math.pow(2, 32 + l) <= ((long) m) * valueAbs &&
    ((long) m) * valueAbs <= Math.pow(2, 32 + l) + Math.pow(2, l))) {
    m++;
    } // 保证m满足条件
    SymbolMips rSym = new SymbolMips(m);
    symUseReg(rSym);
    curBlock.addInstr(new MultMips(lSym.getRegister(), rSym.getRegister()));
    curBlock.addInstr(new MfhiMips(resSym.getRegister()));
    curBlock.addInstr(new SraMips(resSym.getRegister(), resSym.getRegister(), l));
    curBlock.addInstr(new SrlMips(lSym.getRegister(), lSym.getRegister(), 31));
    curBlock.addInstr(new AdduMips(resSym.getRegister(), resSym.getRegister(), lSym.getRegister()));
    rSym.getRegister().freeReg();

对于乘法优化,左值右值为常量时均可进行;对于除法优化,被除数为常量时不能进行,除数为常量时可以进行。


实验感想

本次实验确实是一次量大难度高的任务,极具挑战性。在实验中,需要按照流程建立一套编译程序,从读入程序字符串出发,直到翻译为机器码,并能够执行获得结果。实验的架构设计是较为明确的,课程组提供的流程十分科学,可以进行模块化开发,词法分析、语法分析、符号表建立、中间代码生成、目标代码翻译,每一个模块相互独立,建立接口实现两两之间的信息交互。本次实验更像是在学完各种课程后的一次实战演练,将之前在计算机组成、操作系统等课中学到的各类知识结合运用起来。实验涉及的知识点很多,字符串解析、层次分析法、有限状态机等。可以使用的设计理念也有很多,如单例模式、工厂模式、观察者模式等。本次实验也是继OO课设后第一次自行设计实现大型项目,十分强调程序的层次化模块化抽象化,不仅锻炼了我的编码能力,还锻炼了我的架构设计思维。在实现编译流程后,还进行了一定的代码优化工作。这些工作向我展示了编译程序的底层优化逻辑,从程序结构和底层机器码的角度出发,既有对于无用部分的删除,也有偏硬方面的优化。美中不足的是,由于各项课程事务繁多,无法再对设计的编译器做进一步优化。整体来说,实验难度很大,从高级语言、LLVMIR到MIPS,结构理念都有很大的不同,每一次转换都是一种挑战,每一个语言下的Bug都曾令我困扰良久。但看着完成的七八千行代码,以及可以成功编译执行的各个代码文件,内心十分有成就感。文档末尾,是对一学期课程的反思与告别。也想感谢理论课老师、两位帮助我颇多的助教和每一位一同进步的同学,使得我能够在这条艰难的路上一直走下去。