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
此处 INTEGR_CONST
规则仅用于提供 type,实际词法分析的过程中不会有 token 的 type 会直接设置为 INTEGR_CONST
观察
- token 在分析的过程中是惰性的,例如,对于下述代码
在执行 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 的目的,其继承关系如下
需要 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 判定,可以考虑在文法中添加 #
注解,例如
此时 visitCond
接口会被拆分为 visitCondExp
和 visitCondBinary
接口,而实际的文法结构不会发生变化
对于符号表的设计,其数据结构如下
由于数组类型的等价性无视每个维度的大小,所以在本次实验中不必记录类型值相关的信息,即不必进行编译期计算
由于函数的参数视作在函数内部的那个作用域内,所以不必特殊处理函数产生的块作用域
position
字段记录了该符号起始字符的所在行数 (从 1 开始计数) 和列数 (从 0 开始计数)
该字段会被 SymbolTracker
类记录,该类维护了变量生命周期一致的同名变量的 position 集合
当 define 和 resolve 一个符号时,会修改上述集合,例如,对于下述程序
其对应的 position 集合为
碎碎念
在实验要求中有这样的描述
『请注意,请输出最本质的错误,对其进行合理的恢复后继续分析后续错误』
我们考虑变量赋值这一情形
通过观察 gcc 和 clang 的报错信息,得出了如下一些简单的规律
- 若赋值号两侧操作数本身存在语义错误,则不执行对赋值操作的语义检查
例如对于上述声明,若执行 a[2][3][4] = b;
,则仅会输出
- 按实际的运算顺序对操作数进行分析,当分析到某一步出错后,不分析后续的运算
例如对于上述声明,若执行 a[a[1]][a[1]][4] = b;
,则仅会输出
若执行 a[1][1] = b + a[1] + a[1];
,则仅会输出
若执行 a[1][1] = b + (a[1] + a[1]);
,则仅会输出
为了能够在子规则中快速回退调用栈,来到预先设置的同步恢复点,考虑利用 Java 的异常机制
同步恢复点 (catch exception) 的设计如下
- stmt
- const decl
- var decl
- const def
- var def
- cond
某些同步恢复点会引入 hasError
标记,便于判断在捕获异常后提前结束正常执行流,或者重新抛出异常 (cond)
而对于异常的抛出 (throw) 的时机,通常考虑在子规则 report error 之后
某些中间规则不考虑捕获这些异常,实际上相当于该中间规则也会抛出异常,直至被同步恢复点捕获
还有一些比较反直觉的语义定义
- 允许数组之间赋值
- 函数的形参中若出现重复定义的参数,忽略该参数,该函数仍然置入符号表中
实际上,个人感觉本质的语义错误并没有一个『清晰』的定义,考虑变量重复定义的场景
对于下述程序
gcc 和 clang 会报告变量重复定义
实际上 gcc 都没有检测函数调用参数是否匹配
而当数组下标的表达式为未定义的变量时
gcc 和 clang 就不会报告变量重复定义了
由此可见,编译器汇报的语义错误很大程度上取决于其具体实现,个人认为语义错误检测的基本要求是不能漏报,在此基础上尽可能减少误报
参考
Lab 4
功能实现
利用 LLVM API,实现对 return
语句和常量表达式的翻译
考虑自定义 visitor 在遍历 parser tree 的过程中翻译,其继承关系如下
初始化过程略去,只需要 override 下述三个方法即可
visitStmt
visitNumber
visitExp
注意到最终生成的 LLVM IR 会直接计算出常量表达式的值
碎碎念
由于使用了 maven 添加 llvm 和 javacpp 的依赖,所以需要修改 Makefile 的 compile 和 run 规则
在个人的环境下,通过如下方式获取 classpath
然后对应修改即可
或者考虑修改 maven 的构建配置
Lab 5
功能实现
利用 LLVM API,实现函数的定义、调用和局部变量的声明、定义、使用的翻译
考虑重新设计符号表,在每一个作用域内建立如下的映射关系
对于符号表中每个的 LLVMValueRef
,本质上都是通过 LLVMBuildAlloca
构建的,统一使用 lvalue
命名
对于 GEP 指令,实际上相当于根据一个
lvalue
生成另一个lvalue
当 lvalue
作为右值使用,即出现下述文法结构时
需要使用一次 load 指令
store 指令的范式如下
其中 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
若不满足上述条件,得到的结果为随机值
- 隐式 return 语句
在语义分析阶段,并未检查一个函数是否显式包含了对应的 return 语句
若源代码中省略了 return 语句,会导致生成的中间代码缺少 terminator 指令
考虑到每个基本块的最后必须要有一个 terminator 指令,需要在生成中间代码前进行检查和修复
为此引入 SysYReturnFixer
类
该类仅 override 一个方法 visitFuncDef
,若发现一个函数的最后一条语句不是对应的 return 语句,则通过下述方式进行修复,例如添加 return;
此处利用了 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 并切换,其范式如下
为此,需要使用 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 条件跳转指令,否则作为值使用
碎碎念
如果不实现短路求值,也就是让下面的程序爆栈
可以完全不使用 ParseTreeProperty<LLVMBasicBlockRef>
存储基本块作为继承属性
此时所有的 cond 表达式都会统一返回 i32
类型的值
只有在 if 语句中才会生成 br 条件跳转指令,即将 cond 表达式返回的值与 zero 比较
这种方案可以无心智负担的实现隐式类型转换
Lab 7
功能实现
利用 LLVM API,实现循环语句 (包括 break 和 continue 语句) 的翻译
- 对于 while 循环语句
类似 if 语句,其翻译范式如下
在实际操作中,发现 nextBlock 不必使用
ParseTreeProperty<LLVMBasicBlockRef>
存储,因为 nextBlock 仅会在 if 和 while 语句的开头创建
- 对于 break 和 continue 语句
考虑使用栈存储 while 语句中创建的 beginBlock 和 nextBlock,然后使用 br 无条件跳转指令即可
碎碎念
- 循环次数过多,lli 会 segmentation fault
- 隐式 basic block
之前提过,可以使用 LLVMGetBasicBlockTerminator
这个 API 测试某个基本块是否已经存在 terminator 指令,从而可以避免下述情形对 return 语句的多次翻译
然而,在测试过程中发现,若某个基本块中 terminator 指令不是连续出现的,LLVM 会插入一些隐式 basic block,例如
生成的中间代码可能如下
对于上述中间代码,LLVM 会将其解读为
注意这里多出的 %1
标号,所以在执行过程中会出现
除了 return 语句,break 和 continue 语句也会有类似的结果,本质上,这三类语句生成的 terminator 指令都是与语法结构一一对应的,即语法树中有多少条 return 语句,就会生成多少条 terminator 指令
为了避免出现上述问题,考虑在这三类语句生成 terminator 指令后,添加一个 dummy basic block,这样会导致生成的中间代码中出现没有 predecessors 的 basic block,但是不会影响正常的执行流
- 写一个简单的快速排序
参考
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
例如对于下述代码
则应该利用 GEP 指令进行一次解引用
- 实参类型为
PointerType
例如对于下述代码
则应该利用 LOAD 指令进行一次解引用
- 下标访问
PointerType
类型的变量
例如对于下述代码
则应该利用 GEP 指令进行一次解引用
注意此处的 GEP 指令的 indices 长度为 1 而非 2