Overengineered calculator: Zig + QBE
Overengineered calculator: Zig + QBE
For many who want to understand how compilers work, Crafting Interpreters by Robert Nystrom and Writing An Interpreter In Go (plus Writing A Compiler In Go) by Thorsten Ball are the go-to resources. I worked through both of them. I learned how tokenization works, what language grammars and EBNF notation are about. I built my first recursive descent parser and then brought the code to life by interpreting it directly (slow) and by implementing a bytecode compiler and a virtual machine (faster). 对于许多想要了解编译器工作原理的人来说,Robert Nystrom 的《Crafting Interpreters》以及 Thorsten Ball 的《Writing An Interpreter In Go》(及其续作《Writing A Compiler In Go》)是首选资源。我研读了这两本书,学习了词法分析的工作原理,以及什么是语言文法和 EBNF 记法。我构建了第一个递归下降解析器,并通过直接解释(速度较慢)以及实现字节码编译器和虚拟机(速度较快)让代码运行起来。
What these two books didn’t teach me was how to translate source code to native machine code. Enter: QBE. QBE is a compiler backend that aims to provide 70% of the performance of industrial optimizing compilers in 10% of the code. QBE fosters language innovation by offering a compact, user-friendly, and performant backend. The size limit constrains QBE to focus on the essential and prevents embarking on a never-ending path of diminishing returns. 这两本书没有教我的是如何将源代码翻译成原生机器码。于是,QBE 登场了。QBE 是一个编译器后端,旨在用 10% 的代码量提供工业级优化编译器 70% 的性能。QBE 通过提供一个紧凑、易用且高性能的后端来促进语言创新。其规模限制迫使 QBE 专注于核心要素,避免陷入边际效应递减的无尽深渊。
I came up with a small project demonstrating how to go through all the steps necessary to transform source code into a native executable. Inventing yet another programming language would have been counterproductive. I would have spent my time fighting design decisions instead of focusing on what was important for me: code generation. Arithmetic expressions were an obvious and simple problem to solve. But how to turn 2 + 2 * 2 into 6 instead of 8?
我构思了一个小项目,演示了将源代码转换为原生可执行文件所需的所有步骤。发明另一种编程语言会适得其反,因为我会把时间花在纠结设计决策上,而不是专注于对我而言最重要的部分:代码生成。算术表达式是一个显而易见且简单的问题,但如何让 2 + 2 * 2 的结果是 6 而不是 8 呢?
I started by defining a grammar. The grammar told me what tokens I should expect and how to handle precedence rules. For example: multiplication and division should be performed before addition and subtraction. I ended up with this: 我首先定义了文法。文法告诉了我应该期待哪些标记(token)以及如何处理优先级规则。例如:乘法和除法应该在加法和减法之前执行。最终我得到了如下定义:
Expression = Sum;
Sum = Product , { ( "+" | "-" ) , Product };
Product = Unary , { ( "*" | "/" ) , Unary };
Unary = ( ( "-" | "+" ) , Unary ) | Primary;
Primary = Number | "(" , Expression , ")";
What does it mean? The tokens are: numbers, operators (+, -, , /) and parentheses to group expressions. The precedence rules result from following the grammar rules top to bottom, from the loosest one to the tightest one: 这是什么意思呢?标记包括:数字、运算符(+、-、、/)以及用于分组表达式的括号。优先级规则源于从上到下遵循文法规则,从最宽松到最严格:
-
Expression is a Sum (addition and subtraction)
-
Sum is a list of Product (multiplication and division)
-
Product is a list of Unary (negation)
-
Unary is either Primary or, when there is an operator, another Unary
-
Unary operations can be stacked:
----1 = 1 -
Primary is either a Number or a grouped Expression
-
Expression 是 Sum(加法和减法)
-
Sum 是 Product 的列表(乘法和除法)
-
Product 是 Unary 的列表(取反)
-
Unary 要么是 Primary,要么在有运算符时是另一个 Unary
-
一元运算可以堆叠:
----1 = 1 -
Primary 要么是 Number,要么是分组的 Expression
Tokenizer
Based on the grammar, I implemented a tokenizer in Zig. I took inspiration from a real Zig tokenizer. With a list of token tags:
词法分析器 (Tokenizer)
基于上述文法,我用 Zig 实现了一个词法分析器。我参考了真实的 Zig 词法分析器,并定义了一系列标记标签:
pub const Tag = enum { invalid, eof, left_paren, right_paren, plus, minus, star, slash, number, };
Note: invalid and eof are meta tags to know when to stop, either on invalid token or end of input. The tokenizer became a simple state machine.
注意:invalid 和 eof 是元标签,用于判断何时停止(遇到无效标记或输入结束)。词法分析器变成了一个简单的状态机。
Parser
Then, I created a parser to build the Abstract Syntax Tree (AST) from the list of tokens. The Sum, Product and Unary from the grammar became binary and unary operations.
解析器 (Parser)
接着,我创建了一个解析器,从标记列表中构建抽象语法树(AST)。文法中的 Sum、Product 和 Unary 变成了二元和一元运算。
Interpreter
After reading the books, interpreting the AST directly was straightforward.
解释器 (Interpreter)
读完那些书后,直接解释 AST 就变得非常直观了。
Emitter
It was time for the fun part. First, I built a QBE intermediate language code emitter. The official documentation was an invaluable resource. My add, sub, mul, div and neg operations mapped directly to QBE instructions:
发射器 (Emitter)
现在到了最有趣的部分。首先,我构建了一个 QBE 中间语言代码发射器。官方文档是极其宝贵的资源。我的加、减、乘、除和取反操作直接映射到 QBE 指令:
# add 64-bit 2 to 2 and store the result in a temporary %result
%result =l add 2, 2
Same applied to all other instructions. In QBE, the intermediate language is in static single-assignment (SSA) form. That meant I needed to emit a QBE instruction for all my binary and unary operations and stack them together. 其他所有指令也同理。在 QBE 中,中间语言采用静态单赋值(SSA)形式。这意味着我需要为所有的二元和一元运算发射 QBE 指令并将它们堆叠在一起。
For a 2 + 2 * 2 expression that was:
对于 2 + 2 * 2 表达式,结果如下:
data $fmt = { b "%ld\n", b 0 }
export function w $main() {
@start
%.0 =l mul 2, 2
%.1 =l add 2, %.0
# call C printf function, needs linking with libc, to print the result
call $printf(l $fmt, ..., l %.1)
ret 0
}
For -2 + 2 * (4 / 2) that was:
对于 -2 + 2 * (4 / 2),结果如下:
data $fmt = { b "%ld\n", b 0 }
export function w $main() {
@start
%.0 =l neg 2
%.1 =l div 4, 2
%.2 =l mul 2, %.1
%.3 =l add %.0, %.2
call $printf(...)
}