使用 Cranelift JIT 实现一个简单语言的演示(翻译)
您好!
这是一个使用 Cranelift JIT 实现一个简单语言的演示。
使用开发中的新 JIT 接口 。JIT 负责管理符号表、分配内存和执行重定位,提供相对简单的 API。
这个Demo受到了 Ulysse Carion 的 llvm-rust-getting-started 和 Jonathan Turner 的rustyjit的启发。
Cranelift 简介: Cranelift 是一个编译器后端。它是轻量级的,支持no_std模式,本身不使用浮点,并且可以有效地利用内存。
Cranelift 的架构允许人们灵活地使用它。有时,这种灵活性可能是一种负担,我们最近开始在一组新的 crate 中解决这个问题,cranelift-module,cranelift-jit、 和 cranelift-faerie它们将各个部分组合成一些易于使用的配置,以便同时处理多个功能。cranelift-module 是一个通用接口,用于同时处理多个功能和数据接口。这个接口可以位于上面cranelift-jit,它将代码和数据写入内存,在那里可以执行和访问它们。而且,它可以位于 .o 之上cranelift-faerie,后者将代码和数据写入本机 .o 文件,这些文件可以链接到本机可执行文件。
这篇文章通过使用cranelift-jit来介绍 Cranelift 。目前,此演示适用于 Linux x86-64 平台。它也可以在 Mac x86-64 平台上运行,尽管我还没有专门测试过。Cranelift 的设计目的是在未来支持许多其他类型的平台。
演练
首先,让我们快速浏览一下使用的语言。这是一种非常简单的语言,其中所有变量都有type isize。(Cranelift 确实完全支持其他整数和浮点类型,所以这只是为了保持语言的简单)。
为了快速了解一下,这是我们在语言中的第一个示例:
fn foo(a, b) -> (c) {
c = if a {
if b {
30
} else {
40
}
} else {
50
}
c = c + 2
}
这个语言的语法在这里定义,他使用peg解析器生成器库为其生成实际的解析器代码。
解析的输出是自定义的 AST 类型:
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),
}
它非常简单明了。IfElse可以返回一个值,以显示在 Cranelift 中是如何完成的(见下文)。
我们要做的第一件事是创建一个我们的实例JIT:
let mut jit = jit::JIT::new();
该类在此处定义并包含几个字段:
builder_context
- Cranelift 使用它在编译多个函数之间重用动态分配。ctx
- 编译函数 main 的Context。data_ctx
- 类似于ctx,但用于“编译”数据部分。module
- Module保存有关当前定义的所有函数和数据对象的信息。
在我们进一步讨论之前,让我们在这里讨论一下底层模型。Module将世界分为两类:函数和数据对象。函数和数据对象都具有_名称_,可以导入到模块中定义并仅在本地引用,或者定义并导出以在外部代码中使用。函数是不可变的,而数据对象可以声明为只读或可写。
函数和数据对象都可以包含对其他函数和数据对象的引用。Cranelift旨在允许低级部分独立地操作每个函数和数据对象,因此每个函数和数据对象都维护其自己的导入名称的单独命名空间。模块结构负责维护一组声明,以供跨多个函数和数据对象使用。
这些概念非常通用,它们适用于JITing和本机对象文件(下面将进行更多讨论!),而模块提供了对两者进行抽象的接口。
一旦我们初始化了 JIT 数据结构,我们就可以使用我们的JIT来编译一些函数。
JIT函数接受一个包含语言函数的字符串。compile它将字符串解析为 AST,然后将AST 转换为 Cranelift IR。
我们的玩具语言只支持一种类型,因此为了方便起见,我们首先声明该类型。
然后,我们通过将函数参数和返回类型添加到 Cranelift 函数签名来开始翻译函数。
然后我们创建一个FunctionBuilder ,它是一个用于构建 Cranelift IR 函数内容的实用程序。正如我们将在下面看到的,FunctionBuilder包括自动构建 SSA 表单的功能,因此用户不必担心它。
接下来,我们启动一个初始的基本块(block),它是函数的入口块,也是我们要插入一些代码的地方。
- 一个基本块是一个 IR 指令序列,它有一个入口点,直到最后没有分支,所以执行总是从顶部开始,一直到最后。
Cranelift 的基本块可以有参数。这些取代了其他 IR 中的 PHI 功能。
这是一个块的示例,显示了位于块末尾的分支 (brif和),并演示了一些块参数jump。
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库将负责自动插入块参数,因此不需要直接使用,尽管它们确实出现了一个地方是函数的传入参数表示为块入口块的参数。我们必须告诉 Cranelift 添加参数,使用 append_block_params_for_function_params like so。
FunctionBuilder跟踪要插入新指令的“当前”块;接下来,我们使用通知它我们的新块, switch_to_block以便我们可以开始向其中插入指令。
关于块的一个主要概念是FunctionBuilder想知道何时可以看到所有可以分支到块的分支,此时它可以_密封_块,从而允许它执行 SSA 构建。所有块必须在函数结束时密封。我们 用密封一个块使用seal_block。
接下来,我们的玩具语言没有显式的变量声明,所以我们遍历 AST 来发现所有变量,这样我们就 可以 在FunctionBuilder. 这些变量不必采用 SSA 形式;将 FunctionBuilder负责在内部构建 SSA 表单。
为了方便遍历函数体,这里的演示使用了一个FunctionTranslator对象,该对象包含FunctionBuilder、当前 Module以及用于查找变量的符号表。现在我们可以开始遍历函数体了。
AST 翻译利用FunctionBuilder. 让我们从一个翻译整数文字的简单示例开始:
所以这里的逻辑其实是: 遍历ast语法树, 将对应的token插入到后端接口中。
Expr::Literal(literal) => {
let imm: i32 = literal.parse().unwrap();
self.builder.ins().iconst(self.int, i64::from(imm))
}
第一部分只是从 AST 中提取整数值。下一行是构建行:
返回一个“.ins()插入对象”,它允许在当前活动块的末尾插入一条指令。
iconst 是用于在 Cranelift中创建整数常量的构建器例程的名称。IR 中的每条指令都可以通过这样的函数调用直接创建。
添加节点和其他算术运算的转换同样简单明了。
变量引用的翻译主要由use_var函数处理:
Expr::Identifier(name) => {
// `use_var` is used to read the value of a variable.
let variable = self.variables.get(&name).expect("variable not defined");
self.builder.use_var(*variable)
}
use_var用于读取(非 SSA)变量的值。(在内部, FunctionBuilder构造 SSA 形式以满足所有用途)
类似的功能是def_var,用于写入(非 SSA)变量的值,我们用它来实现赋值操作:
fn translate_assign(&mut self, name: String, expr: Expr) -> Value {
// `def_var` is used to write the value of a variable. Note that
// variables can have multiple definitions. Cranelift will
// convert them into SSA form for itself automatically.
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:
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 循环:
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我们使用 hello 字符串的内容初始化一个DataContext,并声明一个数据对象。然后我们使用DataContext对象来定义对象。到那时,我们已经完成了DataContext对象并可以清除它。然后我们调用finalize_data来执行链接(虽然一个简单的 hello 字符串不做任何引用,所以也不需要任何处理)并获取数据的最终运行时地址,然后为了方便起见,我们将其转换回 Rust 切片。
并且为了展示 jit 后端的一个方便的功能,它可以使用 查找符号libc::dlsym,因此您可以调用 libc 函数,例如puts(小心以 NUL 终止您的字符串!)。不幸的是,printf需要可变参数,但 Cranelift 还不支持。
有了这一切,我们可以说“hello Cranelift!”。
本机目标文件
由于Module抽象,这个演示可以适应写出一个 ELF .o 文件,而不是将代码 JIT 到内存中,只需进行微小的更改,我已经在此处的一个分支中这样做了。这将写入一个test.o文件,在 x86-64 ELF 平台上,您可以链接 cc test.o该文件,并生成一个可执行文件,调用生成的函数,包括打印“hello world!”。
另一个分支展示了如何编写 Mach-O 目标文件。
目标文件是使用faerie 库编写的。
玩得开心!
Cranelift 仍在不断发展,所以如果这里有一些令人困惑或尴尬的事情,请通过github 问题或在gitter 聊天中 停下来告诉我们。目前,Cranelift 的设计中几乎没有什么是一成不变的,我们真的很想听听人们关于什么是有意义的,什么是没有意义的。
项目地址: github.com/bytecodealliance/cranel...
ps: 黄色类似与笔记,红色是个人理解(并不是原文内容)
如果你喜欢我的作品,请考虑赞助我,以保持它们的可持续性。
本作品采用《CC 协议》,转载必须注明作者和本文链接