序言
出于对成为编译器工程师的向往,我开始深入挖掘各项编译技术的细节。作为一名前端工程师,我决定首先从 WebAssembly 技术开始学习。在阅读完 WebAssembly 规范后,我准备着手深入了解如何实现一个 WebAssembly 运行时。
考虑到我只熟悉 Rust,我选择从 Wasmtime 开始,这是一个由 Rust 编写的 WebAssembly 运行时。它严格遵循 WebAssembly 规范并采用 JIT 技术以提高运行速度,是个相当优秀的实现。
Cranelift 是 Wasmtime 实现 JIT 的核心,本文将通过使用,来对它进行初步的了解。Cranelift 本身提供了一个示例项目 cranelift-jit-demo,在该项目中,通过 Cranelift 实现一个玩具语言的 JIT 编译器和运行时,演示来介绍 Cranelift。
Cranelift 的简要介绍
Cranelift 是一个编译器后端。它轻量级,支持 no_std
模式,本身不使用浮点数,并且它能有效利用内存。
Cranelift 的设计理念是允许在使用方式上具有灵活性。有时候,这种灵活性可能会带来一些负担,我们最近在一组新的 crates 中开始解决这个问题,这些 crates 包括 cranelift-module
、cranelift-jit
和 cranelift-faerie
,它们将各部分组合在一起,形成了一些易于一次处理多个函数的配置。cranelift-module
是一个公共接口,用于一次处理多个函数和数据接口。这个接口可以基于 cranelift-jit
,它将代码和数据写入内存,这样就可以执行和访问。此外,它也可以基于 cranelift-faerie
,它将代码和数据写入本地 .o 文件,这些文件可以链接到本地可执行文件中。
玩具语言的简要介绍
使用的玩具语言是一种非常简单的语言,其中所有变量的类型都是 isize
。(虽然 Cranelift 完全支持其他整数和浮点类型,但为了保持玩具语言的简单性,只使用 isize 类型)。
为了快速理解,以下是我们在这个玩具语言中的第一个例子:
r
fn foo(a, b) -> (c) {
c = if a {
if b {
30
} else {
40
}
} else {
50
}
c = c + 2
}
这个玩具语言的语法定义在这里,而项目使用了 peg 解析器生成器库为它生成实际的解析器代码。
下面是这门语言的扩展巴科斯-诺尔范式(EBNF, Extended Backus-Naur Form)规则:
bnf
<function> ::= "fn" <identifier> "(" [<identifier> {"," <identifier>}] ")" "->" "(" > <identifier> ")" "{" <statements> "}"
<statements> ::= {<statement>}
<statement> ::= <expression> "\n"
<expression> ::= <if_else> | <while_loop> | <assignment> | <binary_op>
<if_else> ::= "if" <expression> "{" <statements> "}" "else" "{" <statements> "}"
<while_loop> ::= "while" <expression> "{" <statements> "}"
<assignment> ::= <identifier> "=" <expression>
<binary_op> ::= <expression> "==" <expression>
| <expression> "!=" <expression>
| <expression> "<" <expression>
| <expression> "<=" <expression>
| <expression> ">" <expression>
| <expression> ">=" <expression>
| <expression> "+" <expression>
| <expression> "-" <expression>
| <expression> "*" <expression>
| <expression> "/" <expression>
| <identifier> "(" [<expression> {"," <expression>}] ")"
| <identifier>
| <literal>
<identifier> ::= <letter> {<letter> | <digit>}
<literal> ::= <digit> {<digit>}
| "&" <identifier>
解析的输出是如下的自定义 AST,它非常简洁和直接:
rust
pub enum Expr {
Literal(String),
Identifier(String),
Assign(String, Box<Expr>),
Eq(Box<Expr>, Box<Expr>),
Ne(Box<Expr>, Box<Expr>),
Lt(Box<Expr>, Box<Expr>),
Le(Box<Expr>, Box<Expr>),
Gt(Box<Expr>, Box<Expr>),
Ge(Box<Expr>, Box<Expr>),
Add(Box<Expr>, Box<Expr>),
Sub(Box<Expr>, Box<Expr>),
Mul(Box<Expr>, Box<Expr>),
Div(Box<Expr>, Box<Expr>),
IfElse(Box<Expr>, Vec<Expr>, Vec<Expr>),
WhileLoop(Box<Expr>, Vec<Expr>),
Call(String, Vec<Expr>),
GlobalDataAddr(String),
}
基础概念
我们要做的第一件事是创建我们的 JIT
实例:
rust
let mut jit = jit::JIT::new();
JIT
类定义在这里,并包含以下几个字段:
builder_context
- 函数构造器的上下文,它在多个 FunctionBuilder 实例间被复用。ctx
- 这是用于编译函数的 Context 对象。data_description
- 类似于 ctx,但用于编译数据段。module
- 存储当前 JIT 中定义的所有函数和数据对象信息的 Module。
在我们进一步讨论之前,让我们先谈谈这里的基础模型。Module
类将世界分为两种东西:函数和数据对象。函数和数据对象都有名字,可以在模块中导入,只在本地定义和引用,或者定义并导出供外部代码使用。函数是不可变的,而数据对象可以声明为只读或可写。
函数和数据对象都可以包含对其他函数和数据对象的引用。Cranelift 的设计允许底层独立操作每个函数和数据对象,因此每个函数和数据对象都维护其自己的导入名称的单独命名空间。Module
结构负责维护一组用于多个函数和数据对象的声明。
这些概念足够通用,既适用于 JIT,也适用于本地对象文件(下面会有更多讨论!),并且 Module
提供了一个抽象出两者的接口。
一旦我们初始化了 JIT 数据结构,我们就使用我们的 JIT
来编译一些函数。
JIT
的 compile
函数接收一个包含玩具语言函数的字符串。它将字符串解析为一个 AST,然后将 AST 转换为 Cranelift IR。
我们的玩具语言只支持一种类型,因此我们一开始就声明了这种类型。
然后,我们开始通过将函数参数和返回类型添加到 Cranelift 函数签名来翻译函数。
然后我们创建一个 FunctionBuilder,这是一个用于构建 Cranelift IR 函数内容的工具。如下面我们将看到的,FunctionBuilder
包括自动构建 SSA 形式的功能,使得用户不必关心它。
SSA 代表静态单赋值形式(Static Single Assignment form)。这是一种在编译器设计中使用的程序表示方法,其中每个变量只被精确地赋值一次。
SSA 是一种程序中间表示(Intermediate Representation,IR)形式,它能帮助编译器进行各种优化。在 SSA 形式中,每个变量只有一个明确的赋值,这使得许多数据流分析和优化变得相对简单。例如,SSA 可以帮助编译器进行死代码消除、常量传播、强度削减等优化。
接下来,我们开始一个初始的基本块(block),这是函数的入口块,也是我们将插入一些代码的地方。
- 基本块是一序列的 IR 指令,它们有一个单一的入口点,并且直到最后都没有分支,因此执行总是从顶部开始,并直接进行到结束。
Cranelift 的基本块可以有参数。这些参数代替了其他 IR 中的 PHI 函数。
在编译器设计和计算机科学中,PHI 函数(φ 函数)是在静态单赋值形式(SSA)中使用的一个工具。
在 SSA 中,如果有一个变量在多个路径上被赋值,然后在这些路径汇合的地方使用,那么我们需要一种方法来确定在这个点上变量的值是什么。这就是PHI函数的作用。PHI函数在这个汇合点产生一个新的版本的变量,这个新版本的值取决于执行到达这个点时实际走过的路径。
举个例子,如果我们有如下的代码:
cif (cond) { x = a; } else { x = b; } print(x);
在转换为SSA形式后,可能变为:
cif (cond) { x1 = a; } else { x2 = b; } x3 = PHI(x1, x2); print(x3);
在这里,
x3
是PHI函数的结果,其值取决于实际执行的路径:如果cond
为真,那么x3
的值就是x1
(即a
);如果cond
为假,那么x3
的值就是x2
(即b
)。
下面是一个块的示例,显示了位于块的末尾的分支(brif
和 jump
),并演示了一些块参数。
rust
block0(v0: i32, v1: i32, v2: i32, v507: i64):
v508 = iconst.i32 0
v509 = iconst.i64 0
v404 = ifcmp_imm v2, 0
v10 = iadd_imm v2, -7
v405 = ifcmp_imm v2, 7
brif ugt v405, block29(v10)
jump block29(v508)
FunctionBuilder
会自动插入块参数,所以通常不需要直接使用它们的前端通常不需要担心它们,尽管它们确实出现在一个地方,即函数的传入参数被表示为入口块的块参数。我们必须使用 append_block_params_for_function_params
来为 Cranelift 添加参数,就像这样。
FunctionBuilder
会跟踪一个新指令要插入的"当前"块;我们接下来通知它我们的新块,使用 switch_to_block,这样我们就可以开始将指令插入到它中。
关于块的一个主要概念是,FunctionBuilder
想要知道何时已经看到所有可能转向块的分支,此时它可以封闭该块,这使得它可以执行 SSA 构造。函数结束时必须封闭所有块。我们使用 seal_block 封闭一个块。
翻译为 IR 的过程
接下来,我们的玩具语言没有显式的变量声明,所以我们遍历 AST 来发现所有的变量,这样我们可以将它们声明给 FunctionBuilder
。这些变量不需要是 SSA 形式;FunctionBuilder
负责在内部构造 SSA 形式。
为了在遍历函数体时方便,这里的示例使用了 FunctionTranslator
对象,它保存了 FunctionBuilder
,当前的 Module
,以及用于查找变量的符号表。现在我们可以开始遍历函数体。
AST 翻译使用 FunctionBuilder
的指令来构建。让我们从翻译整数字面量,这个简单的示例开始:
rust
Expr::Literal(literal) => {
let imm: i32 = literal.parse().unwrap();
self.builder.ins().iconst(self.int, i64::from(imm))
}
第一部分仅仅是从 AST 中提取整数值。下一行进行构建:
.ins()
返回一个 "插入对象",它允许在当前活动块的末尾插入一个指令。iconst
是 Cranelift 中创建整数常量的指令名称。IR 中的每一条指令都可以通过这样的函数调用直接创建。
Add 节点和其他算术运算的翻译同样直接。
变量引用的翻译主要由 FunctionBuilder
的 use_var
函数处理:
rust
Expr::Identifier(name) => {
// `use_var` 用于读取变量的值。
let variable = self.variables.get(&name).expect("variable not defined");
self.builder.use_var(*variable)
}
use_var
用于读取(非 SSA)变量的值。(在内部,FunctionBuilder
构造 SSA 形式以满足所有使用)。
它的搭档是 def_va
r,用于写入(非 SSA)变量的值,我们用它来实现赋值:
rust
fn translate_assign(&mut self, name: String, expr: Expr) -> Value {
// `def_var` 用于写入变量的值。
// 注意,变量可以有多个定义。Cranelift 会自动将它们转换成 SSA 形式。
let new_value = self.translate_expr(*expr);
let variable = self.variables.get(&name).unwrap();
self.builder.def_var(*variable, new_value);
new_value
}
接下来,让我们深入研究 if-else 表达式。为了演示明确的 SSA 构造,这里的 if-else 表达式返回值。在 Cranelift 中,这个看起来就像 if-else 的真和假分支都有到一个公共合并点的分支,它们各自将他们的 "返回值" 作为一个块参数传递给合并点。
注意,一旦我们知道我们不会有更多的前驱,我们就封闭我们创建的块,这是一个典型的 AST 易于知道的事情。
总的来说,这是演示程序中名为 foo 的函数的 Cranelift IR,其中包含多个 if:
rust
function u0:0(i64, i64) -> i64 system_v {
block0(v0: i64, v1: i64):
v2 = iconst.i64 0
brz v0, block2
jump block1
block1:
v4 = iconst.i64 0
brz.i64 v1, block5
jump block4
block4:
v6 = iconst.i64 0
v7 = iconst.i64 30
jump block6(v7)
block5:
v8 = iconst.i64 0
v9 = iconst.i64 40
jump block6(v9)
block6(v5: i64):
jump block3(v5)
block2:
v10 = iconst.i64 0
v11 = iconst.i64 50
jump block3(v11)
block3(v3: i64):
v12 = iconst.i64 2
v13 = iadd v3, v12
return v13
}
while 循环的转换也很直接。
以下是示例中名为 iterative_fib 的函数的 Cranelift IR,其中包含一个 while 循环:
rust
function u0:0(i64) -> i64 system_v {
block0(v0: i64):
v1 = iconst.i64 0
v2 = iconst.i64 0
v3 = icmp eq v0, v2
v4 = bint.i64 v3
brz v4, block2
jump block1
block1:
v6 = iconst.i64 0
v7 = iconst.i64 0
jump block3(v7, v7)
block2:
v8 = iconst.i64 0
v9 = iconst.i64 1
v10 = isub.i64 v0, v9
v11 = iconst.i64 0
v12 = iconst.i64 1
jump block4(v10, v12, v11)
block4(v13: i64, v17: i64, v18: i64):
v14 = iconst.i64 0
v15 = icmp ne v13, v14
v16 = bint.i64 v15
brz v16, block6
jump block5
block5:
v19 = iadd.i64 v17, v18
v20 = iconst.i64 1
v21 = isub.i64 v13, v20
jump block4(v21, v19, v17)
block6:
v22 = iconst.i64 0
jump block3(v22, v17)
block3(v5: i64, v23: i64):
return v23
}
对于调用,基本步骤是确定调用签名,声明要调用的函数,将要传递的值放入数组中,然后调用 call 函数。
对于全局数据符号的翻译也是类似的;首先向模块声明符号,然后向当前函数声明,然后使用 symbol_value 指令产生值。
有了这些,我们可以回到主 toy.rs 文件并运行更多的示例。有递归和迭代斐波那契的例子,这些例子进一步演示了调用和控制流的使用。
还有一个 hello world 示例,演示了几个其他的特性。
这个程序需要分配一些数据来保存字符串数据。在 jit.rs 中,我们使用 create_data 初始化一个 DataDescription,内容为 hello 字符串,并且也声明了一个数据对象。然后我们使用 DataDescription 对象来定义对象。那时,我们完成了 DataDescription 对象的使用并可以清除它。我们然后调用 finalize_data 来进行链接(虽然我们简单的 hello 字符串没有做任何引用,所以没有什么要做的)并获取数据的最终运行时地址,然后我们将其转换回 Rust 切片以便于使用。
为了展示 jit 后端的一个方便的功能,它可以使用 libc::dlsym 查找符号,所以你可以调用诸如 puts 这样的 libc 函数(小心 NUL-终止你的字符串!)。不幸的是,printf 需要 varargs,而 Cranelift 还不支持。
有了所有这些,我们就可以说 "hello world!"。