2024 年全国大学生计算机系统能力大赛-编译系统设计赛-编译系统实现赛,北京大学“一梦全能”队伍 ARM 赛道编译器。
ThunderMonkey 编译器由三部分构成:
- “Zvezda”(Звезда,“星辰”),预处理器与前端
- “Chollima”(천리마,“千里马”),中间表示
- “Hyoksin”(혁신,“革新”),汇编生成器
除 Rust 标准库外,我们使用的库还有:
pest
,一个基于解析表达式文法的解析器生成器。同时依赖pest_derive
。Apache-2.0 或 MIT 许可证。libc
,Rust 官方提供的与 C 互操作的工具库。Apache-2.0 或 MIT 许可证。rustc_hash
,Rust 官方提供的一种哈希表,相比 Rust 标准库的哈希算法,rustc_hash
更加快速,但抗碰撞能力稍弱。Apache-2.0 或 MIT 许可证。genawaiter
,无栈协程库。Apache-2.0 或 MIT 许可证。
ThunderMonkey 以 GPLv3 许可证开源。
ThunderMonkey 对 SysY 做了一些扩展:
- ThunderMonkey 不区分
Exp
与Cond
(见《SysY 语言定义(2022 版)》),只有统一的Expr
- 二进制整数字面量,如
0b111000
; - 位运算符,包括
>>
、<<
、^
、|
、&
和~
; - 赋值不单纯是语句,也可以是表达式,这意味着可以编写
x = y = z
; - 复合赋值运算符,即
+=
等; - 自增自减运算符,其中前缀自增自减返回左值。
语法树的根是 TranslationUnit
(翻译单元),TranslationUnit
实质上是由 Definition
(符号定义)构成的列表。Definition
是一个元组,包含符号的标识符、初始化器和符号的类型。
初始化器是一个 Rust 枚举,分为:
Function(Block, is_entry, arg_handlers)
(函数),其中arg_handlers
记录了各个参数的handler
,Block
存储了函数体;Expr(Expr)
(表达式);ConstInt(i32)
和ConstFloat(f32)
(常量);List(InitList)
(初始化列表);ConstList(ConstInitList)
(常量初始化列表)。
类型也是一个枚举,分为
Int
(整型);Float
(浮点型);Void
(空);Pointer(Box<Type>, Vec<usize>)
(指针),Vec<usize>
存储了各个维度的长度;Array(Box<Type>, Vec<usize>)
(数组),Vec<usize>
存储了各个维度的长度;Function(Box<Type>, Vec<Type>)
(函数),Box<Type>
存储返回值类型,而Vec<Type>
存储各个参数的类型;VAList
,变参函数的参数列表,为putf
保留。
初始化器和类型共同决定了一个符号。例如:
int a;
定义的符号a
- 初始化器为
None
- 类型为
Int
- 初始化器为
int a = 1;
定义的符号a
- 初始化器为
Expr(1)
- 类型为
Int
- 初始化器为
const int a = 1;
定义的符号a
- 初始化器为
Const(1)
- 类型为
Int
- 初始化器为
int a[2][1] = {{1}, {2}};
定义的符号a
- 初始化器为
List(InitList([InitList([Expr(1)]), InitList([Expr(2)])]))
- 类型为
Array(Int, [2, 1])
- 初始化器为
const int a[2][1] = {{1}, {2}};
定义的符号a
- 初始化器为
ConstList(ConstInitList([ConstInitList([1]), ConstInitList([2])]))
- 类型为
Array(Int, [2, 1])
- 初始化器为
- 函数定义
int f(int a) { return 0; }
定义的符号main
- 初始化器为
Function(["a"], Block([Return(0)]))
- 类型为
Function(Int, [Int])
- 初始化器为
实际实现中,初始化器和类型并不存储在 Definition
的内部,而是存储在一个哈希表中;Definition
仅存储指向哈希表的 handler
(u32
)。
Block
(复合语句)是语法树的基础。Block
是由一系列元素组成的列表,这些元素可以是:
Definition
(局部变量定义);Statement
(语句,即表达式(Expr
)、if
、while
、continue
和break
);Block(复合语句),
Block套
Block` 是很正常的。
Expr
(表达式)是语法树的核心,仅摘录一部分:
pub enum ExprInner {
Mul(Box<Expr>, Box<Expr>),
Div(Box<Expr>, Box<Expr>),
// ......
Integer(i32),
Var(Handler),
Func(Handler, Vec<Expr>),
Array(Handler, Vec<Expr>),
}
Zvezda 在预处理器部分负责:
- 换行符替换,
\r\n
、\r
统一替换为\n
- 行拼接,根据 C 语言标准,凡在反斜杠出现于行尾(紧跟换行符)时,删除反斜杠和换行符,把两个物理源码行组合成一个逻辑源码行。这属于我们对 SysY 的扩展,而不是标准 SysY。例如:
int main() {
int a = 1; // \
int b;
int b; // \\\\
int b;
return a;
}
被处理为:
int main() {
int a = 1; // int b;
int b; // \\\ int b;
return a;
}
- 去除注释,预处理器按照 C 语言标准去除注释。
第一次解析中,使用 pest 将代码处理为语法树。因为 pest 设计上的局限性,此处语法树使用的是 pest 中 Pairs<Rule>
的形式。此外,这里还没有对运算符优先级做处理,表达式仍然表现为 Pair
流的形式。
遍历上述 Pairs<Rule>
的语法树,转换为 TranslationUnit
。同时,在这一步使用 pest 提供的 PrattParser<Rule>
处理运算符优先级。
遍历 TranslationUnit
,进行语义检查和常量表达式计算。
为支持作用域嵌套,ThunderMonkey 的符号表设计为一个栈(Vec<HashMap<String, Definition>>
),在查找符号时,优先查找作用域最靠内的符号。
实际实现中,第二次解析和语义检查是在一趟完成的。
Chollima 是一种简单的、基于栈的中间表示。例如,int a[1] = {0}; int x = a[0] / 10.0;
编译为:
load_addr a
push_int 0
add_int
push_int 0
store
load_addr x
load_addr a
push_int 0
add_int
load
cvt_i_f
push_float 10.0
div_float
cvt_f_i
store
全部 Chollima 指令如下:
add_int
:从栈顶依次弹出整数b
、a
,计算和a + b
,并压栈。sub_int
:从栈顶依次弹出整数b
、a
,计算差a - b
,并压栈。mul_int
:从栈顶依次弹出整数b
、a
,计算积a * b
,并压栈。div_int
:从栈顶依次弹出整数b
、a
,计算商a / b
,并压栈。
以上指令的结果均为整数。
add_float
:从栈顶依次弹出浮点数b
、a
,计算和a + b
,并压栈。sub_float
:从栈顶依次弹出浮点数b
、a
,计算差a - b
,并压栈。mul_float
:从栈顶依次弹出浮点数b
、a
,计算积a * b
,并压栈。div_float
:从栈顶依次弹出浮点数b
、a
,计算商a / b
,并压栈。
以上指令的结果均为浮点数。
mod
:从栈顶依次弹出整数b
、a
,计算模a % b
,并压栈。sll
:从栈顶依次弹出整数b
、a
,计算逻辑左移a << b
,并压栈。slr
:从栈顶依次弹出整数b
、a
,计算逻辑右移a >> b
,并压栈。sar
:从栈顶依次弹出整数b
、a
,计算算数左移a >> b
,并压栈。and
:从栈顶依次弹出整数b
、a
,计算按位与a & b
,并压栈。or
:从栈顶依次弹出整数b
、a
,计算按位或a | b
,并压栈。xor
:从栈顶依次弹出整数b
、a
,计算按位异或a ^ b
,并压栈。
以上指令的结果均为整数。
eq_int
:从栈顶依次弹出整数b
、a
,比较a == b
,并将比较结果压栈。ne_int
:从栈顶依次弹出整数b
、a
,比较a != b
,并将比较结果压栈。le_int
:从栈顶依次弹出整数b
、a
,比较a <= b
,并将比较结果压栈。lt_int
:从栈顶依次弹出整数b
、a
,比较a < b
,并将比较结果压栈。ge_int
:从栈顶依次弹出整数b
、a
,比较a >= b
,并将比较结果压栈。gt_int
:从栈顶依次弹出整数b
、a
,比较a > b
,并将比较结果压栈。
以上指令的结果均为整数,且一定为 0
或 1
。
eq_float
:从栈顶依次弹出浮点数b
、a
,比较a == b
,并将比较结果压栈。ne_float
:从栈顶依次弹出浮点数b
、a
,比较a != b
,并将比较结果压栈。le_float
:从栈顶依次弹出浮点数b
、a
,比较a <= b
,并将比较结果压栈。lt_float
:从栈顶依次弹出浮点数b
、a
,比较a < b
,并将比较结果压栈。ge_float
:从栈顶依次弹出浮点数b
、a
,比较a >= b
,并将比较结果压栈。gt_float
:从栈顶依次弹出浮点数b
、a
,比较a > b
,并将比较结果压栈。
以上指令的结果均为整数,且一定为 0
或 1
。
Chollima 没有一元运算指令,因为:
!x
等价于x == 0
,可以用eq_int
或eq_float
表达;-x
等价于0 - x
,可以用sub_int
或sub_float
表达;+x
等价于x
;~x
等价于x ^ 0xffffffff
,可以用xor_int
表达。
-
push_int <imm>
:将整数imm
压入栈顶。 -
push_float <imm>
:将浮点数imm
压入栈顶。 -
pop_words <n>
:从栈顶弹出n
个字。 -
double
:从栈顶弹出 1 个字,并将它重复两次压入栈顶。 -
xchg
:从栈顶依次弹出字a
、b
,并依次压入a
、b
。
以上指令中,一个字为 32 位。
cvt_i_f
:从栈顶弹出一个整数,将其转换为浮点数,并压栈。cvt_f_i
:从栈顶弹出一个浮点数,将其转换为整数,并压栈。
br_z <label>
:从栈顶弹出一个整数,与 0 比较。如果与 0 比较相等,则转移至label
,否则执行下一条指令。br_nz <label>
:从栈顶弹出一个整数,与 0 比较。如果与 0 比较不等,则转移至label
,否则执行下一条指令。jmp <label>
:无条件转移至label
。
call_int <func>, <n>
:以n
个参数调用函数func
,期望它返回的字是一个整数。将这个字压栈。如果这个函数返回的字是浮点数,或没有返回值,是未定义行为。call_float <func>, <n>
:以n
个参数调用函数func
,期望它返回的字是一个浮点数。将这个字压栈。如果这个函数返回的字是整数,或没有返回值,是未定义行为。call_void <func>, <n>
:以n
个参数调用函数func
,期望它没有任何返回值。即便有返回值,也将丢弃。
注意,函数调用前需保证 n
个参数从右向左压栈。这里的“栈”指 Chollima 栈,而非真实机器的栈。有关调用约定请见后文。
函数调用指令保证返回后对栈上参数清理。例如,如果栈顶有变量 x0
,再压栈参数 a0
、a1
、a2
调用 f
,f
返回 r0
,那么函数调用指令执行前的 Chollima 栈是:
+----+
| a0 | ^
+----+ |
| a1 | |
+----+ | 栈增长方向
| a2 | |
+----+ |
| x0 | |
+----+
....
函数调用指令执行后的 Chollima 栈是:
+----+
| r0 | ^
+----+ |
| x0 | | 栈增长方向
+----+ |
....
ret_int
:无条件返回至当前函数的调用者。期望当前函数返回的字是一个整数,或没有返回值。如果当前函数返回的字是浮点数,是未定义行为。ret_float
:无条件返回至当前函数的调用者。期望当前函数返回的字是一个浮点数。如果当前函数返回的字是整数,或没有返回值,是未定义行为。
load_addr <var>
:将变量var
的地址加载到栈顶。load
:从栈顶弹出一个字p
,将p
视为一个指针,将p
指向的字压入栈顶。store
:从栈顶依次弹出两个字d
、p
,将p
视为一个指针,将d
存储在p
指向的字。
label_<label>
:IR 中的标签。
以上“整数”指 32 位补码表示的有符号整数,“浮点数”指 IEEE-754 Binary 32 浮点数。Chollima 假定变量、函数和标签的总和不超过 4294967295 个。以上所有指令的文本形式仅作描述之用,实际的 Chollima 没有文本形式。
在生成表达式的 IR 时,SysY 中的表达式被进一步分为三类:
- 左值表达式(lvalue),这种表达式在 Chollima 栈顶压入一个内存地址。例如,语句
x = 1;
中的x
是左值表达式。 - 右值表达式(rvalue),这种表达式在 Chollima 栈顶压入一个字。例如,
x = y;
中的y
是右值表达式 - 弃值表达式(dvalue),例如,语句
x = 1;
中的x = 1
是弃值表达式,这个语句只会执行赋值的副作用,不改变 Chollima 栈。
Hyoksin 对 Chollima 模式匹配。例如,add_int
会被编译为:
pop {r0, r1}
add r0, r1, r0
push {r0}
而 add_float
会被编译为:
pop {r1}
pop {r0}
mov32 r2, __aeabi_fadd
blx r2
push {r0}
如果启用 hardware_vfp
特性,add_float
会被编译为:
vpop {s0, s1}
vadd.f32 s0, s1, s0
vpush {s0}
这里,mov32
是一个汇编宏,其定义为:
.macro mov32, reg, val
movw \reg, #:lower16:\val
movt \reg, #:upper16:\val
.endm
简单起见,ThunderMonkey 使用了非标准的调用约定:所有参数均通过栈传递,从右向左压栈。在调用 SysY 运行时库函数时,将按照 ARMv7 标准调用约定。
为与 SysY 运行时库一致,ThunderMonkey 以 r7
为帧指针,而非 ARMv7 上传统的 r11
。