序言
出于对成为编译器工程师的向往,我开始深入挖掘各项编译技术的细节。作为一名前端工程师,我决定首先从 WebAssembly 技术开始学习。在阅读完 WebAssembly 规范后,我准备着手深入了解如何实现一个 WebAssembly 运行时。
考虑到我只熟悉 Rust,我选择从 Wasmtime 开始,这是一个由 Rust 编写的 WebAssembly 运行时。它严格遵循 WebAssembly 规范并采用 JIT 技术以提高运行速度,是个相当优秀的实现。
Cranelift 是 Wasmtime 实现 JIT 的核心,在上文中我们通过示例项目 cranelift-jit-demo,对它进行了初步的了解。示例项目中最主要的工作,就是将玩具语言翻译成 Cranelift 的 IR,而本文将深入了解 Cranelift 中 IR 的设计。
介绍
Cranelift 中间表示([IR])主要有两种形式:一种是代码生成库使用的内存数据结构,另一种是用于测试用例和调试输出的文本格式。包含 Cranelift 文本 IR 的文件使用 .clif
文件名扩展名。
文本使用文本格式来描述 IR 的语义,并描述文本格式的词法和句法结构的细节。
整体结构
Cranelift 独立编译函数。一个 .clif
IR 文件可能包含多个函数,编程式 API 可以同时创建多个函数句柄,但是这些函数不共享任何数据,也不直接引用彼此。
以下是一个简单的 C 函数,计算浮点数组的平均值:
c
float average(const float *array, size_t count)
{
double sum = 0;
for (size_t i = 0; i < count; i++)
sum += array[i];
return sum / count;
}
这是将该函数编译成 Cranelift IR 的结果:
ini
test verifier
function %average(i32, i32) -> f32 system_v {
ss0 = explicit_slot 8 ; Stack slot for `sum`.
block1(v0: i32, v1: i32):
v2 = f64const 0x0.0
stack_store v2, ss0
brif v1, block2, block5 ; Handle count == 0.
block2:
v3 = iconst.i32 0
jump block3(v3)
block3(v4: i32):
v5 = imul_imm v4, 4
v6 = iadd v0, v5
v7 = load.f32 v6 ; array[i]
v8 = fpromote.f64 v7
v9 = stack_load.f64 ss0
v10 = fadd v8, v9
stack_store v10, ss0
v11 = iadd_imm v4, 1
v12 = icmp ult v11, v1
brif v12, block3(v11), block4 ; Loop backedge.
block4:
v13 = stack_load.f64 ss0
v14 = fcvt_from_uint.f64 v1
v15 = fdiv v13, v14
v16 = fdemote.f32 v15
return v16
block5:
v100 = f32const +NaN
return v100
}
函数定义的第一行提供了函数名和[函数签名],声明了参数和返回类型。然后是[函数前导],它声明了可以在函数内部引用的一些实体。在上述示例中,前导声明中了一个明确的堆栈插槽,ss0
。
在前导之后是[函数体],它由[基本块](BB)组成,第一个是[入口块]。每个 BB 都以[终止器指令]结束,因此执行过程永远不会在没有明确分支的情况下进入下一个 BB。
.clif
文件由一系列独立的函数定义组成:
css
function_list : { function }
function : "function" function_name signature "{" preamble function_body "}"
preamble : { preamble_decl }
function_body : { basic_block }
静态单赋值形式
函数体中的指令使用和产生的值都是在 SSA(Static Single Assignment,静态单赋值)形式中。这就意味着每个值只被定义一次,且每次使用一个值前必须对其定义。
Cranelift 并没有 phi 指令,但是使用了[基本块参数(BB parameter)]。一个基本块(BB)可以使用一组带类型的参数进行定义。每次控制流转移到 BB 时,必须为参数提供参数值。当进入函数时,传入的函数参数会作为参数传递给入口 BB 的参数。
指令定义了零个、一个或多个结果值。所有的 SSA 值都是 BB 参数或者指令结果。
在上面的示例中,循环指标变量 i
被表示为三个 SSA 值:在 block2
中,v3
是初始值。在循环块 block3
中,BB 参数 v4
表示每次迭代中指标变量的值。最后,v11
被计算为下一次迭代中指标变量的值。
cranelift_frontend
crate 包含了将对同一变量的多次赋值的程序转化为 Cranelift [IR] 的 SSA 形式的工具。
此类变量也可以被呈现为 Cranelift 的[栈插槽(stack slot)]。栈插槽可以通过 stack_store
和 stack_load
指令进行访问,并且可以通过 stack_addr
取得它们的地址,这就支持了像 C 语言那样的编程语言,其中的局部变量可以取得它们的地址。
值类型
所有的 SSA(静态单赋值)值都有一个类型,这个类型确定了值的大小和形状(对于 SIMD 向量)。许多指令是多态的 -- 它们可以操作不同的类型。
整数类型
整数值具有固定的大小,可以被解释为有符号或无符号。一些指令将操作数解释为有符号或无符号数,其他则不关心。
- i8
- i16
- i32
- i64
- i128
在这些类型中,i32 和 i64 经过最重度测试,因为它们被 Wasmtime 使用。i8、i16 和 i128 没有已知错误,但它们可能并不支持所有的后端指令(也就是说,它们可能会导致编译器在代码生成期间崩溃,并出现一个指令不支持的错误)。
函数生成器 fuzzgen 中的 valid_for_target
函数包含了哪些指令支持哪些类型的信息。
浮点数类型
浮点数类型具有大多数硬件支持的 IEEE 754 语义,但目前不支持非默认的舍入模式、未屏蔽的异常和异常标志。
目前还不支持高精度类型,如四倍精度、双倍精度或扩展精度,也不支持较窄精度类型,如半精度。
NaN(非数字)是按照 IEEE 754-2008 的推荐进行编码的,其中静默 NaN(quiet NaN)的编码方式是将尾数的最高位(MSB)设为 1,而信号 NaN(signaling NaNs)则通过将尾数的最高位设为 0 来表示。
除了位操作和内存指令外,从算术指令返回的 NaN 编码如下:
- 如果一个指令的所有 NaN 输入都是静默 NaN,并且除最高位外的所有尾数位都设为 0,那么结果是一个静默 NaN,它的符号位是不确定的,而所有尾数位除了最高位外都设为 0。
- 否则,结果是一个静默 NaN,它的符号位是不确定的,而所有尾数位除了最高位外都被设为不确定的值。
- f32
- f64
SIMD 向量类型
SIMD(Single Instruction, Multiple Data,单指令流多数据流)向量类型表示的是来自某一标量类型(整数或浮点数)的值的向量。SIMD 类型中的每个标量值称为一个通道(lane)。通道的数量必须是 2-256 范围内的 2 的幂。
iBxN 表示的是一个整数的 SIMD 向量。通道类型 iB 是整数类型之一,可以是 i8 到 i64。
go
整数向量类型有 `i32x4`、`i64x8` 和 `i16x4`。
SIMD 整数向量在内存中的大小为 `N * B / 8` 字节。
f32xN 是一个单精度浮点数的 SIMD 向量。
go
一些具体的 `f32` 向量类型包括:`f32x2`,`f32x4`,和 `f32x8`。
`f32` 向量在内存中的大小为 `4N` 字节。
f64xN 是一个双精度浮点数的 SIMD 向量。
go
一些具体的 `f64` 向量类型包括:`f64x2`,`f64x4`,和 `f64x8`。
`f64` 向量在内存中的大小为 `8N` 字节。
伪类型和类型类
这些并不是具体的类型,而是在这个参考中用来引用实际类型的便利名称。
iAddr 一个表示地址的指针大小的整数。
go
这个类型可以是 `i32` 或 `i64`,具体取决于目标平台的指针是 32 位还是 64 位。
iB 可以是任何标量整数类型,从 i8
到 i64
。
Int 表示任何标量或向量整数类型:iB
或 iBxN
。
fB 表示浮点标量类型之一:f32
或 f64
。
Float 表示任何标量或向量浮点类型:fB
或 fBxN
。
TxN 表示任何 SIMD 向量类型。
Mem 表示任何可以存储在内存中的类型:Int
或 Float
。
Testable iN
立即操作数类型
这些类型不是正常 SSA 类型系统的一部分。它们被用来表示指令上的不同种类的立即数操作数。
imm64 是一个 64 位的立即整数。此操作数的值被解释为一个有符号的二进制补码整数。指令编码可能会限制有效范围。
go
在文本格式中,`imm64` 立即数以十进制或十六进制字面量的形式出现,使用与 C 语言相同的语法。
offset32 是一个带符号的 32 位立即地址偏移。
go
在文本格式中,`offset32` 立即数总是有明确的符号,且 0 偏移量可以省略。
ieee32 是一个 32 位立即浮点数,采用 IEEE 754-2008 binary32 交换格式。所有的比特模式都是允许的。
ieee64 是一个 64 位立即浮点数,采用 IEEE 754-2008 binary64 交换格式。所有的比特模式都是允许的。
intcc 是一个整数条件码。请参看 icmp
指令获取更多详情。
floatcc 是一个浮点数条件码。请参看 fcmp
指令获取更多详情。
两种 IEEE 浮点数立即数类型 ieee32
和 ieee64
在文本 [IR] 格式中被显示为十六进制浮点数字面量。不允许用十进制浮点数字面量,因为一些计算机系统在转换为二进制时会有不同的舍入方式。十六进制浮点数格式大致上与 C99 使用的一样,但是扩展了以表示所有的 NaN 比特模式:
正常数字:与 C99 兼容:-0x1.Tpe
,其中 T
为十六进制编码的尾数位,e
为十进制的无偏指数。ieee32
有 23 个尾数位。它们被填充了一个额外的 LSB 以生成 6 个十六进制数字。对于 ieee64
这是不必要的,因为它有 52 个尾数位形成 13 个无需填充的十六进制数字。
零:正零和负零分别被显示为 0.0
和 -0.0
。
正规数:与 C99 兼容:-0x0.Tpemin
,其中 T
为十六进制编码的尾数位,emin
为十进制的最小指数。
无穷大:可以是 -Inf
或 Inf
。
静默 NaN:静默 NaN 的尾数位的最高位被设置。如果尾数位的其余部分都为零,那么该值被显示为 -NaN
或 NaN
。否则,为 -NaN:0xT
,其中 T 为十六进制编码的尾数位。
信号 NaN:被显示为 -sNaN:0xT
。
控制流
分支指令会将控制权转移给新的基本块(BB),并为目标 BB 的参数(如果有的话)提供值。条件分支指令会终止一个 BB,并在条件满足时转移到第一个 BB,否则转移到第二个 BB。
跳转表指令 br_table v, BB(args), [BB1(args)...BBn(args)]
在给定的第三个参数即内联跳转表中查找索引 v
,并跳转到那个 BB。如果 v
超出了跳转表的范围,那么会使用默认 BB(第二个参数)。
陷阱指令会在遇到某些错误时停止程序。具体的行为取决于目标指令集体系结构和操作系统。下面定义了一些显式的陷阱指令,但是某些指令在某些输入值下也可能会导致陷阱。例如,当除数为零时,无符号除法指令 udiv
就会触发陷阱。
函数调用
函数调用需要一个目标函数和一个[函数签名]。目标函数可以在运行时动态确定,但在编译函数调用时必须已知签名。函数签名描述了如何调用函数,包括参数、返回值和调用约定:
bash
signature : "(" [paramlist] ")" ["->" retlist] [call_conv]
paramlist : param { "," param }
retlist : paramlist
param : type [paramext] [paramspecial]
paramext : "uext" | "sext"
paramspecial : "sarg" ( num ) | "sret" | "vmctx" | "stack_limit"
callconv : "fast" | "cold" | "system_v" | "windows_fastcall"
| "wasmtime_system_v" | "wasmtime_fastcall"
| "apple_aarch64" | "wasmtime_apple_aarch64"
| "probestack"
函数的调用约定确切地确定了如何传递参数和返回值,以及如何管理堆栈帧。由于所有这些细节都取决于指令集体系结构和可能的操作系统,因此函数的调用约定只能由一个 (TargetIsa, CallConv)
元组完全确定。
名称 | 描述 |
---|---|
sarg | 指向给定大小结构参数的指针 |
sret | 指向内存中返回值的指针 |
vmctx | 包含指向堆等的指针的虚拟机上下文指针 |
stack_limit | 栈大小的限制值 |
名称 | 描述 |
---|---|
fast | 为了最佳性能而使用的非 ABI 稳定约定 |
cold | 用于不经常执行的代码的非 ABI 稳定约定 |
system_v | 在许多平台上使用的 System V 风格的约定 |
fastcall | Windows 的 "fastcall" 约定,也用于 x64 和 ARM |
"not-ABI-stable" 约定不遵循外部规范,并且可能在不同版本的 Cranelift 之间发生变化。
"fastcall" 约定尚未实现。
参数和返回值有标志,其含义大多数情况下取决于目标。这些标志支持与其他编译器生成的代码进行接口交互。
直接调用的函数必须在[函数前言]中声明:
FN = [colocated] NAME signature 声明一个函数,使其可以直接调用。
ruby
如果存在 colocated 关键字,那么符号的定义将与当前函数一起定义,从而可以使用更高效的寻址。
:arg NAME: 函数的名称,传递给链接器以进行解析。
:arg signature: 函数签名。参见下文。
:result FN: 可以与 `call` 一起使用的函数标识符。
这个简单的例子展示了直接函数调用和签名:
rust
test verifier
function %gcd(i32 uext, i32 uext) -> i32 uext system_v {
fn0 = %divmod(i32 uext, i32 uext) -> i32 uext, i32 uext
block1(v0: i32, v1: i32):
brif v1, block2, block3
block2:
v2, v3 = call fn0(v0, v1)
return v2
block3:
return v0
}
间接函数调用使用在前言中声明的签名。
内存
Cranelift 提供了用于访问内存的完全通用的加载和存储指令,以及扩展加载和截断存储。
如果给定地址的内存不是[可寻址的],那么这些指令的行为是未定义的。如果它是可寻址的但不是[可访问的],那么它们会[陷阱]。
还有更多受限的操作用于访问特定类型的内存对象。
此外,还提供了用于处理多寄存器寻址的指令。
内存操作标志
加载和存储可以有标志,这些标志可以放宽它们的语义,以便启用优化。
标志 | 描述 |
---|---|
notrap | 假设内存是[可访问的] |
aligned | 允许对错位访问进行陷阱处理 |
readonly | 在调用此函数和退出时,指定地址的数据不会被修改 |
当设置了可访问标志时,如果内存不是[可访问的],那么行为是未定义的。 |
如果导致的地址不是预期对齐的倍数,那么加载和存储就是错位的。默认情况下,允许错位的加载和存储,但当设置了对齐标志时,允许对错位的内存访问进行[陷阱]处理。
显式栈插槽
一组受限的内存操作访问当前函数的栈帧。栈帧被划分为固定大小的栈槽,这些槽在[函数前言]中分配。栈槽没有类型,它们只表示栈帧中连续的[可访问]字节序列。
SS = explicit_slot 字节,标志... 在前言中分配一个栈槽。
ruby
如果没有指定对齐,Cranelift 将根据栈槽的大小和访问模式选择适当的对齐。
:arg Bytes: 栈槽大小(字节)。
:flag align(N): 请求至少 N 字节对齐。
:result SS: 栈槽索引。
专用的栈访问指令对于编译器来说很容易理解,因为栈槽和偏移量在编译时是固定的。例如,可以从偏移量和栈槽对齐来推断这些栈内存访问的对齐。
也可以获取栈槽的地址,该地址可以用于无限制的加载和存储。
stack_addr 指令可以在指令选择之前用于宏展开栈访问指令:
ini
v0 = stack_load.f64 ss3, 16
; 扩展为:
v1 = stack_addr ss3, 16
v0 = load.f64 v1
当 Cranelift 代码在沙箱中运行时,也可能需要在序言中包含栈溢出检查。
全局值
全局值是一个在编译时无法知道其值的对象。该值是由 global_value 在运行时计算的,可能使用链接器通过重定位提供的信息。存在多种类型的全局值,它们使用不同的方法来确定其值。Cranelift 不跟踪全局值的类型,因为它们只是存储在非栈内存中的值。
当 Cranelift 为虚拟机环境生成代码时,全局变量可以用于访问 VM 的运行时中的数据结构。这需要函数能够访问 VM 上下文指针,该指针用作基地址。通常,VM 上下文指针作为隐藏的函数参数传递给 Cranelift 函数。
可以形成全局值表达式的链,但不允许出现循环。这些循环会被 IR 验证器捕获。
GV = vmctx 声明 VM 上下文结构地址的全局值。
表
从 WebAssembly 编译的代码经常需要访问其线性内存之外的对象。WebAssembly 使用表格使得程序可以通过整数索引来引用不透明值。
在函数前导中声明表,可以使用 table_addr 指令来访问,该指令在越界访问时[陷阱]。表地址可以小于本机指针大小,例如在 64 位架构上的无符号 i32 偏移。
表格在地址空间中连续排列,概念上分为固定大小的元素,这些元素由其索引标识。内存是[可访问的]。
表边界是表中当前的元素数量。这是 table_addr 对其进行检查的边界。
表在调整大小时可以被重新定位到不同的基地址,其边界可以动态移动。表的边界存储在一个全局值中。
T = dynamic Base, min MinElements, bound BoundGV, element_size ElementSize 在前导中声明一个表。
lua
:arg Base: 全局值,保存表的基地址。
:arg MinElements: 保证的最小表大小(以元素计)。
:arg BoundGV: 包含当前表边界的全局值(以元素计)。
:arg ElementSize: 每个元素的大小。
常量实例化
少数几个指令有接受立即数操作数的变体,但是通常需要一个指令来将常数加载到 SSA 值中:iconst、f32const、f64const 和 bconst 就是这个目的。
位运算
位运算符可以对任何值类型进行操作:整数和浮点数。当对整数或浮点类型进行位运算时,位运算符在值的二进制表示上进行操作。
移位和旋转操作仅适用于整数类型(标量和向量)。移位量不必与被移位的值具有相同的类型。只有移位量的低 B 位是有意义的。
当对整数向量类型进行操作时,移位量仍然是标量类型,并且所有位移的大小都是相同的。移位量被掩码为位于通道中的位数,而不是向量类型的完整大小。
位计数指令仅适用于标量。
浮点数运算
这些操作通常遵循 IEEE 754-2008 规定的语义。
扩展加载和截断存储
大多数 ISA 提供了加载一个小于寄存器的整数值并将其扩展到寄存器宽度的指令。类似地,只写入整数寄存器的低位的存储指令也很常见。
除了正常的加载和存储指令外,Cranelift 还为 8、16 和 32 位的内存访问提供了扩展加载和截断存储。
这些指令在与正常加载和存储相同的条件下成功、陷阱或具有未定义的行为。
术语表
markdown
addressable
在该内存中,加载和存储具有定义的行为。他们要么成功,要么[陷阱],具体取决于内存是否[可访问]。
accessible
[可寻址]的内存,其中加载和存储始终成功,不会[陷阱],除非另有规定(例如,带有 `aligned` 标志)。堆、全局变量、表和堆栈可能包含可访问的、仅可寻址的和完全不可寻址的区域。可能还有其他未明确声明的可寻址和/或可访问内存区域。
basic block
一种最大的指令序列,只能从顶部进入,并且除最后一条指令外,不包含任何分支或终止指令。
entry block
在函数中首先执行的[BB]。目前,Cranelift 函数必须有且仅有一个入口块,且必须是函数中的第一个块。入口块的参数类型必须与函数签名中的参数类型匹配。
BB parameter
BB 的形式参数是一个 SSA 值,它支配 BB 中的所有内容。对于 BB 声明的每个参数,当分支到 BB 时,必须传递相应的参数值。函数的入口 BB 具有与函数参数相对应的参数。
BB argument
类似于函数参数,当分支到声明了形式参数的 BB 时,必须提供 BB 参数。当在 BB 的顶部开始执行时,形式参数具有分支中传递的参数的值。
function signature
函数签名描述了如何调用函数。它由以下部分组成:
- 调用约定。
- 参数和返回值的数量。(函数可以返回多个值。)
- 每个参数的类型和标志。
- 每个返回值的类型和标志。
并非所有函数属性都是签名的一部分。例如,一个永远不返回的函数可以被标记为 `noreturn`,但在调用它时并不需要知道这一点,所以它只是一个属性,而不是签名的一部分。
function preamble
一个声明在函数体中使用的实体的列表。在前言中可以声明的一些实体包括:
- 栈槽。
- 被直接调用的函数。
- 用于间接函数调用的函数签名。
- 不属于签名的函数标志和属性。
function body
包含函数中所有可执行代码的基本块。函数体跟在函数前言之后。
intermediate representation
IR
用于描述函数给 Cranelift 的语言。此参考说明了 Cranelift IR 的语法和语义。IR 有两种形式:文本和内存数据结构。
stack slot
在当前函数的激活帧中的固定大小的内存分配。这包括[显式栈槽]和[溢出栈槽]。
explicit stack slot
在当前函数的激活帧中的固定大小的内存分配。它们与[溢出栈槽]的区别在于,它们可以由前端创建,并且可能获取其地址。
spill stack slot
在当前函数的激活帧中的固定大小的内存分配。它们与[显式栈槽]的区别在于,它们只在寄存器分配期间创建,且可能不获取其地址。
terminator instruction
控制流指令,无条件地将执行流程引导到其他地方。执行永远不会在终结器指令之后的指令处继续执行。
基本的终止器指令包括 `br`、`return` 和 `trap`。条件分支和条件陷阱的指令并不是终止器指令。
trap
traps
trapping
终止当前线程的执行。陷阱后的具体行为取决于底层的操作系统。例如,一个常见的行为是发送信号,具体的信号取决于触发它的事件。