TOC
Open TOC
Info
https://github.com/courses-at-nju-by-hfwei/compilers-lab-docs/
https://github.com/VGalaxies/SysY-compiler
Ref
https://gitlab.eduxiji.net/nscscc/compiler2022
https://pku-minic.github.io/online-doc/#/
Lab 1
功能实现
通过 antlr 的 lexer 模块进行词法分析,并输出 tokens
若在词法分析中出现错误,则不输出成功匹配的 tokens,这通过自定义 lexer 的 listener 来实现
由于对十六进制和八进制数字常量输出 token 文本时需输出其十进制的值,所以考虑在词法分析的过程中替换其 text 文本,并重置其 type 为 INTEGR_CONST
DECIMAL_INT_CONST : NON_ZERO_DIGIT DIGIT* { setType(INTEGR_CONST); } ;OCTAL_INT_CONST : '0' OCTAL_DIGIT* { setText(NumberUtils.octToDec(getText())); setType(INTEGR_CONST); } ;HEXADECIMAL_INT_CONST : HEXADECIMAL_PREFIX HEXADECIMAL_DIGIT+ { setText(NumberUtils.hexToDec(getText())); setType(INTEGR_CONST); } ;
INTEGR_CONST : DECIMAL_INT_CONST | OCTAL_INT_CONST | HEXADECIMAL_INT_CONST ;
此处 INTEGR_CONST
规则仅用于提供 type,实际词法分析的过程中不会有 token 的 type 会直接设置为 INTEGR_CONST
观察
- token 在分析的过程中是惰性的,例如,对于下述代码
import org.antlr.v4.runtime.*;import org.antlr.v4.runtime.tree.*;
public class Test { public static void main(String[] args) throws Exception { // create a CharStream that reads from standard input ANTLRInputStream input = new ANTLRInputStream(System.in); // create a lexer that feeds off of input CharStream ArrayInitLexer lexer = new ArrayInitLexer(input); // create a buffer of tokens pulled from the lexer CommonTokenStream tokens = new CommonTokenStream(lexer); // create a parser that feeds off the tokens buffer ArrayInitParser parser = new ArrayInitParser(tokens); ParseTree tree = parser.init(); // begin parsing at init rule System.out.println(tree.toStringTree(parser)); // print LISP-style tree }}
在执行 parser.init()
前,不会有任何的 tokens 会被消耗
所以在本次实验中,需要通过 getAllTokens
获得所有的 tokens
-
注意 antlr 对大小写的约定,区分大写的 TOKEN 和小写的 rule
-
由于 OJ 评测时候会替换 Makefile 文件,所以目前所有的源文件只能直接放置在 src 目录中,无法使用 package 机制有效的管理 g4 资源文件及其生成的源文件,也难以利用 junit 框架进行必要的单元测试
参考
https://github.com/antlr/grammars-v4
https://github.com/antlr/antlr4/blob/master/doc/lexer-rules.md
https://stackoverflow.com/questions/6487593/what-does-fragment-mean-in-antlr
Lab 2
功能实现
依据 Lab 1 中词法分析模块生成的 tokens 流,通过 antlr 的 parser 模块进行语法分析
若语法分析的过程中未产生错误,则生成 parser tree,并自定义 visitor 或 listener 在 parser tree 上遍历
否则,通过自定义 parser 的 listener 来输出错误信息,这一部分与 Lab 1 中类似,不再赘述
这里考虑自定义 visitor 实现遍历 parser tree 的目的,其继承关系如下
public class SysYParserDerivedVisitor extends SysYParserBaseVisitor<Void>
需要 override 基类两个方法
visitChildren
输出对应的非终结符信息,注意需要手动将 rule names 的首字母大写,同时控制缩进格式
visitTerminal
输出对应的终结符信息,利用表驱动的方式为 tokens 打印对应的颜色,这会与 SysYLexer
内部的 ruleNames
高度耦合
碎碎念
由于似乎绝大部分测试用例都是没有语法错误的,因此本次实验的正确性高度依赖 SysYParser.g4
中定义的文法结构,在某种程度下限制了自由发挥
另外,考虑到 antlr 相较于传统的语法分析工具 bison 进行了一些抽象与封装,本次实验似乎难以在语法错误上进行过多的考察
对比隔壁传统编译原理的第一次实验中,测试用例中语法错误的占比应该超过了 90 %
参考
https://github.com/antlr/antlr4/blob/master/doc/parser-rules.md
Lab 3
功能实现
使用 ANTLR 提供的 Listener 或 Visitor 机制遍历语法树,在遍历过程中填充符号表,并进行语义分析,若语义分析无误,则对指定位置的变量进行重命名
这里使用了 Visitor 机制遍历语法树,为了传递继承属性和综合属性,考虑引入 context 类记录一些必要的信息,如变量声明的 base type
实际上可以考虑使用
ParseTreeProperty
该 context 类即为 SysYParserBaseVisitor
的泛型类型,子规则由此可以向父规则传递综合属性
为了能够让父规则向子规则传递继承属性,同样利用该 context 类,在全局定义一个该类的实例,在子规则访问属性后,父规则需要将相应的属性及时 reset
正因如此,context 类的属性不可重入
另外,考虑到每个规则可能存在多条产生式,为了避免对可能的终结符或非终结符进行 null 判定,可以考虑在文法中添加 #
注解,例如
cond : exp # CondExp | lhs = cond (op = LT | op = GT | op = LE | op = GE) rhs = cond # CondBinary | lhs = cond (op = EQ | op = NEQ) rhs = cond # CondBinary | lhs = cond op = AND rhs = cond # CondBinary | lhs = cond op = OR rhs = cond # CondBinary ;
此时 visitCond
接口会被拆分为 visitCondExp
和 visitCondBinary
接口,而实际的文法结构不会发生变化
对于符号表的设计,其数据结构如下
name -> {name, type, position, value, is-constant}
由于数组类型的等价性无视每个维度的大小,所以在本次实验中不必记录类型值相关的信息,即不必进行编译期计算
由于函数的参数视作在函数内部的那个作用域内,所以不必特殊处理函数产生的块作用域
position
字段记录了该符号起始字符的所在行数 (从 1 开始计数) 和列数 (从 0 开始计数)
该字段会被 SymbolTracker
类记录,该类维护了变量生命周期一致的同名变量的 position 集合
当 define 和 resolve 一个符号时,会修改上述集合,例如,对于下述程序
int a() { return 42;}
int res = a();
int main(int a) { int b = a; res = a + 4;}
其对应的 position 集合为
{lineNo:7,column:4}{lineNo:5,column:4},{lineNo:9,column:4}{lineNo:8,column:8}{lineNo:1,column:4},{lineNo:5,column:10}{lineNo:9,column:10},{lineNo:8,column:12},{lineNo:7,column:13}
碎碎念
在实验要求中有这样的描述
『请注意,请输出最本质的错误,对其进行合理的恢复后继续分析后续错误』
我们考虑变量赋值这一情形
int a[2][3];int b[2][3];
通过观察 gcc 和 clang 的报错信息,得出了如下一些简单的规律
- 若赋值号两侧操作数本身存在语义错误,则不执行对赋值操作的语义检查
例如对于上述声明,若执行 a[2][3][4] = b;
,则仅会输出
a.c:4:10: error: subscripted value is not an array, pointer, or vector a[2][3][4] = b; ~~~~~~~^~1 error generated.
- 按实际的运算顺序对操作数进行分析,当分析到某一步出错后,不分析后续的运算
例如对于上述声明,若执行 a[a[1]][a[1]][4] = b;
,则仅会输出
a.c:4:6: error: array subscript is not an integer a[1][a[1]][a[1]] = b; ^~~~~1 error generated.
若执行 a[1][1] = b + a[1] + a[1];
,则仅会输出
a.c:4:14: error: invalid operands to binary expression ('int[2][3]' and 'int[3]') a[1][1] = b + a[1] + a[1]; ~ ^ ~~~~1 error generated.
若执行 a[1][1] = b + (a[1] + a[1]);
,则仅会输出
a.c:4:22: error: invalid operands to binary expression ('int[3]' and 'int[3]') a[1][1] = b + (a[1] + a[1]); ~~~~ ^ ~~~~1 error generated.
为了能够在子规则中快速回退调用栈,来到预先设置的同步恢复点,考虑利用 Java 的异常机制
同步恢复点 (catch exception) 的设计如下
- stmt
- const decl
- var decl
- const def
- var def
- cond
某些同步恢复点会引入 hasError
标记,便于判断在捕获异常后提前结束正常执行流,或者重新抛出异常 (cond)
而对于异常的抛出 (throw) 的时机,通常考虑在子规则 report error 之后
某些中间规则不考虑捕获这些异常,实际上相当于该中间规则也会抛出异常,直至被同步恢复点捕获
还有一些比较反直觉的语义定义
- 允许数组之间赋值
- 函数的形参中若出现重复定义的参数,忽略该参数,该函数仍然置入符号表中
int test(int i, int i) { return test(i + 1);}
实际上,个人感觉本质的语义错误并没有一个『清晰』的定义,考虑变量重复定义的场景
对于下述程序
int foo() { return 42;}
int main() { int a[1] = {1}; int a[foo(1)] = {1};}
gcc 和 clang 会报告变量重复定义
> gcc a.ca.c: In function ‘main’:a.c:7:5: error: variable-sized object may not be initialized 7 | int a[foo(1)] = {1}; | ^~~a.c:7:9: error: redeclaration of ‘a’ with no linkage 7 | int a[foo(1)] = {1}; | ^a.c:6:9: note: previous definition of ‘a’ with type ‘int[1]’ 6 | int a[1] = {1}; | ^
> clang a.ca.c:7:16: warning: too many arguments in call to 'foo' int a[foo(1)] = {1}; ~~~ ^a.c:7:9: error: redefinition of 'a' int a[foo(1)] = {1}; ^a.c:6:9: note: previous definition is here int a[1] = {1};
实际上 gcc 都没有检测函数调用参数是否匹配
而当数组下标的表达式为未定义的变量时
int main() { int a[1] = {1}; int a[b] = {1};}
gcc 和 clang 就不会报告变量重复定义了
> gcc a.ca.c: In function ‘main’:a.c:3:11: error: ‘b’ undeclared (first use in this function) 3 | int a[b] = {1}; | ^a.c:3:11: note: each undeclared identifier is reported only once for each function it appears in
> clang a.ca.c:3:11: error: use of undeclared identifier 'b' int a[b] = {1};
由此可见,编译器汇报的语义错误很大程度上取决于其具体实现,个人认为语义错误检测的基本要求是不能漏报,在此基础上尽可能减少误报
参考
> gcc --versiongcc (GCC) 12.2.0Copyright (C) 2022 Free Software Foundation, Inc.This is free software; see the source for copying conditions. There is NOwarranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
> clang --versionclang version 14.0.6Target: x86_64-pc-linux-gnuThread model: posixInstalledDir: /usr/bin
Lab 4
功能实现
利用 LLVM API,实现对 return
语句和常量表达式的翻译
考虑自定义 visitor 在遍历 parser tree 的过程中翻译,其继承关系如下
public class SysYIRGenerator extends SysYParserBaseVisitor<LLVMValueRef>
初始化过程略去,只需要 override 下述三个方法即可
visitStmt
visitNumber
visitExp
注意到最终生成的 LLVM IR 会直接计算出常量表达式的值
碎碎念
由于使用了 maven 添加 llvm 和 javacpp 的依赖,所以需要修改 Makefile 的 compile 和 run 规则
在个人的环境下,通过如下方式获取 classpath
LIBPATH = $(shell (find /usr/local/lib -name "antlr-*-complete.jar" ; find /home/vgalaxy/.m2/repository -name "llvm*.jar" ; find /home/vgalaxy/.m2/repository -name "javacpp*.jar") | xargs | sed 's/ /:/g')
然后对应修改即可
compile: antlr mkdir -p classes $(JAVAC) -classpath $(LIBPATH) $(JAVAFILE) -d classes $(call git_commit, "make")
# make run ARGS="xxx"run: compile java -classpath ./classes:$(LIBPATH) Main $(ARGS)
或者考虑修改 maven 的构建配置
Lab 5
功能实现
利用 LLVM API,实现函数的定义、调用和局部变量的声明、定义、使用的翻译
考虑重新设计符号表,在每一个作用域内建立如下的映射关系
symbol name -> LLVMValueRef
对于符号表中每个的 LLVMValueRef
,本质上都是通过 LLVMBuildAlloca
构建的,统一使用 lvalue
命名
对于 GEP 指令,实际上相当于根据一个
lvalue
生成另一个lvalue
当 lvalue
作为右值使用,即出现下述文法结构时
exp : lVal
需要使用一次 load 指令
store 指令的范式如下
LLVMBuildStore(builder, rvalue, lvalue);
其中 rvalue
可能为
visitExp
的结果- 函数的形参
函数形参的构建,应该在切换基本块之后,访问 block 之前
碎碎念
- 关于助记符
大部分构建 LLVMValueRef
的函数,最后一个参数都是 String s
当传入空字符串时,会根据该 LLVMValueRef
是否被使用选择是否显示助记符,例如调用一个 void 函数
- 关于数组长度
C 标准规定,数组变量在初始化时,若长度给出,则必须为 integer constant expression
An integer constant expression shall have integer type and shall only have operands that are integer constants…
LLVM 中提供了下述 API,可以在运行时将满足上述条件的 LLVMValueRef
转换为 interger
@Cast({"unsigned long long"})public static native long LLVMConstIntGetZExtValue(LLVMValueRef var0);
若不满足上述条件,得到的结果为随机值
- 隐式 return 语句
在语义分析阶段,并未检查一个函数是否显式包含了对应的 return 语句
若源代码中省略了 return 语句,会导致生成的中间代码缺少 terminator 指令
考虑到每个基本块的最后必须要有一个 terminator 指令,需要在生成中间代码前进行检查和修复
为此引入 SysYReturnFixer
类
public class SysYReturnFixer extends SysYParserBaseVisitor<Void>
该类仅 override 一个方法 visitFuncDef
,若发现一个函数的最后一条语句不是对应的 return 语句,则通过下述方式进行修复,例如添加 return;
private void addReturnVoid(SysYParser.BlockContext blockContext) { SysYParser.BlockItemContext blockItemContext = new SysYParser.BlockItemContext(blockContext, -1); SysYParser.StmtContext stmtContext = new SysYParser.StmtContext(blockItemContext, -1);
Token returnToken = new CommonToken(SysYLexer.RETURN); TerminalNode returnNode = new TerminalNodeImpl(returnToken);
Token semicolonToken = new CommonToken(SysYLexer.SEMICOLON); TerminalNode semicolonNode = new TerminalNodeImpl(semicolonToken);
stmtContext.addChild(returnNode); stmtContext.addChild(semicolonNode);
blockItemContext.addChild(stmtContext); blockContext.addChild(blockItemContext);}
此处利用了 ANTLR 中 lexer 和 parser 提供的方法,原地修改语法树结构
另一种更简单的方法,使用
LLVMGetBasicBlockTerminator
测试当前基本块是否存在 terminator 指令
参考
- https://www.llvm.org/docs/GetElementPtr.html
- https://stackoverflow.com/questions/72716896/how-to-fix-variable-length-array-folded-to-constant-array-as-an-extension-with
Lab 6
功能实现
利用 LLVM API,实现全局变量和条件语句的翻译
对于全局变量,只需修改 visitVarDef
和 visitConstDef
的实现,利用下述 API 构造全局变量即可
LLVMAddGlobal
LLVMSetInitializer
LLVMConstArray
对于条件语句,需要对 if 语句和 cond 表达式进行翻译
- 对于 if 语句
考虑统一 if 语句与 ifelse 语句,也就是每一次进入 if 语句的上下文时,都需要生成三个基本块
- trueBlock
- falseBlock
- nextBlock
这里比较关键的是,这些基本块并不会立即被 append 到 cfg 中,而是需要在合适的时机 append 并切换,其范式如下
visit condappend and switch to trueBlockvisit stmt(0)append and switch to falseBlockvisit stmt(1)append and switch to nextBlock
为此,需要使用 LLVMCreateBasicBlockInContext
和 LLVMAppendExistingBasicBlock
这两个 API 完成上述操作
由于每个基本块的最后必须有且仅有一个 terminator 指令,所以可以使用 LLVMGetBasicBlockTerminator
这个 API 判断 trueBlock
或 falseBlock
中是否已经存在 terminator 指令,若不存在,再显式添加 br 无条件跳转指令
使用 ParseTreeProperty<LLVMBasicBlockRef>
存储这些基本块作为继承属性
- 对于 cond 表达式
考虑将其分类为
- exp
- EQ / NEQ / LT / GT / LE / GE
- AND / OR
其中只有第二类 cond 表达式会生成 br 条件跳转指令
若考虑隐式类型转换,则
- 第一类 cond 表达式也会生成 br 条件跳转指令,例如
if (a)
- 第二类 cond 表达式还可能作为值使用,例如
if (a < b < c)
更具体的,当 cond 表达式的 parent context 为 stmt 或第三类 cond 表达式时,需要生成 br 条件跳转指令,否则作为值使用
碎碎念
如果不实现短路求值,也就是让下面的程序爆栈
int foo(int x) { return foo(x); }
int main() { int a = 2; if (a == 2 || foo(a) == 2) { a = 1; } return a;}
可以完全不使用 ParseTreeProperty<LLVMBasicBlockRef>
存储基本块作为继承属性
此时所有的 cond 表达式都会统一返回 i32
类型的值
只有在 if 语句中才会生成 br 条件跳转指令,即将 cond 表达式返回的值与 zero 比较
这种方案可以无心智负担的实现隐式类型转换
Lab 7
功能实现
利用 LLVM API,实现循环语句 (包括 break 和 continue 语句) 的翻译
- 对于 while 循环语句
类似 if 语句,其翻译范式如下
goto beginBlockappend and switch to beginBlockvisit condappend and switch to trueBlockvisit stmtappend and switch to nextBlock
在实际操作中,发现 nextBlock 不必使用
ParseTreeProperty<LLVMBasicBlockRef>
存储,因为 nextBlock 仅会在 if 和 while 语句的开头创建
- 对于 break 和 continue 语句
考虑使用栈存储 while 语句中创建的 beginBlock 和 nextBlock,然后使用 br 无条件跳转指令即可
碎碎念
- 循环次数过多,lli 会 segmentation fault
- 隐式 basic block
之前提过,可以使用 LLVMGetBasicBlockTerminator
这个 API 测试某个基本块是否已经存在 terminator 指令,从而可以避免下述情形对 return 语句的多次翻译
int main() { int a = 42; return a; return a;}
然而,在测试过程中发现,若某个基本块中 terminator 指令不是连续出现的,LLVM 会插入一些隐式 basic block,例如
int main() { int a = 42; return a; int b = 12; return a;}
生成的中间代码可能如下
define i32 @main() {mainEntry: %a = alloca i32, align 4 store i32 42, i32* %a, align 4 %0 = load i32, i32* %a, align 4 ret i32 %0 %b = alloca i32, align 4 store i32 12, i32* %b, align 4 %1 = load i32, i32* %a, align 4 ret i32 %1}
对于上述中间代码,LLVM 会将其解读为
define i32 @main() {mainEntry: %a = alloca i32, align 4 store i32 42, i32* %a, align 4 %0 = load i32, i32* %a, align 4 ret i32 %0%1: %b = alloca i32, align 4 store i32 12, i32* %b, align 4 %1 = load i32, i32* %a, align 4 ret i32 %1}
注意这里多出的 %1
标号,所以在执行过程中会出现
lli: lli: dump.ll:12:3: error: instruction expected to be numbered '%2' %1 = load i32, i32* %a, align 4
除了 return 语句,break 和 continue 语句也会有类似的结果,本质上,这三类语句生成的 terminator 指令都是与语法结构一一对应的,即语法树中有多少条 return 语句,就会生成多少条 terminator 指令
为了避免出现上述问题,考虑在这三类语句生成 terminator 指令后,添加一个 dummy basic block,这样会导致生成的中间代码中出现没有 predecessors 的 basic block,但是不会影响正常的执行流
- 写一个简单的快速排序
int nums[8] = {4, 3, 5, 0, 6, 2, 1, 7};
void swap(int i, int j) { int tmp = nums[i]; nums[i] = nums[j]; nums[j] = tmp;}
int partition(int left, int right) { int i = left, j = right; while (i < j) { while (i < j && nums[j] >= nums[left]) { j = j - 1; } while (i < j && nums[i] <= nums[left]) { i = i + 1; } swap(i, j); } swap(i, left); return i;}
void quickSort(int left, int right) { if (left >= right) { return; } int pivot = partition(left, right); quickSort(left, pivot - 1); quickSort(pivot + 1, right);}
int main() { quickSort(0, 7); int idx = 0; while (idx < 8) { if (nums[idx] != idx) { return 1; } idx = idx + 1; } return 0;}
参考
Lab 8
功能实现
利用 LLVM API,允许一维数组作为函数的参数
需要修改下面三处地方
- 翻译函数形参
此时的形参类型为 LLVMPointerType(i32Type, 0)
其中 LLVMPointerType
的含义如下
This constructs a pointer to an object of the specified type in a numbered address space.
此处传递 0 即可
- 翻译函数实参
需要考虑两种情形
- 实参类型为
ArrayType
例如对于下述代码
int foo(int arr[]) { ...}
int main() { int a[2] = {}; return combine(a);}
则应该利用 GEP 指令进行一次解引用
%0 = getelementptr [2 x i32], [2 x i32]* %a, i32 0, i32 0%1 = call i32 @foo(i32* %0)
- 实参类型为
PointerType
例如对于下述代码
int bar(int arr[]) { ...}
int foo(int arr[]) { bar(arr);}
则应该利用 LOAD 指令进行一次解引用
%0 = load i32*, i32** %arr, align 8%1 = call i32 @bar(i32* %0)
- 下标访问
PointerType
类型的变量
例如对于下述代码
int foo(int arr[]) { return arr[0];}
则应该利用 GEP 指令进行一次解引用
define i32 @foo(i32* %0) {fooEntry: %arr = alloca i32*, align 8 store i32* %0, i32** %arr, align 8 %1 = load i32*, i32** %arr, align 8 %2 = getelementptr i32, i32* %1, i32 0 %3 = load i32, i32* %2, align 4 ret i32 %3}
注意此处的 GEP 指令的 indices 长度为 1 而非 2