读了一本关于 WebAssembly 虚拟机实现的书 ——《WebAssembly 原理与核心技术》,这本书写得非常详细,也有实现的源码讲解。本文是对阅读时所做的粗略的笔记。

1、Wasm (WebAssembly)

1.1、asm.js

asm.js 是 JavaScript 语言的一个严格子集,试图通过减少动态特性和添加类型提示的方式帮助浏览器提升 JavaScript 优化空间。相较于完整的 JavaScript 语言,裁剪后的 asm.js 更靠近底层,更适合作为编译器目标语言。

function MyAsmModule() {    
    "use asm"; // 告诉浏览器这是一个asm.js模块  

    function add(x, y) {        
        x = x|0;        // x是整数        
        y = y|0;        // y也是整数        
        return (x+y)|0; // 返回值也是整数    
    }    

    return { add: add };
}

asm.js 有优点也有缺点。

优点非常明显:asm.js 代码就是 JavaScript 代码,因此完全可以跨浏览器运行。能识别特殊标记的“聪明”浏览器可以根据提示进行激进的 JIT 优化,甚至是 AOT 编译,大幅提升性能。不能识别特殊标记的“笨”浏览器也可以忽略这些提示,直接按普通 JavaScript 代码来执行。

asm.js 的缺点也很明显,那就是“底层”得不够彻底,例如代码仍然是文本格式;代码编写仍然受 JavaScript 语法限制;浏览器仍然需要完成解析脚本、解释执行、收集性能指标、JIT 编译等一系列步骤。

1.2、Wasm

如果采用像 Java 类文件那样的二进制格式,不仅能缩小文件体积,减少网络传输时间和解析时间,还能选用更接近机器的字节码,这样 AOT/JIT 编译器实现起来会更轻松,效果也更好。

按字面意思理解,WebAssembly 就是 Web 汇编,是为 Web 浏览器定制的汇编语言。

1.2.1、编译器支持

除了四大浏览器的一致支持,Wasm 也获得了主流编程语言的强力支持。C/C++ 是最先可以编译为 Wasm 的语言,因为 Emscripten 已经支持 asm.js,只要把 asm.js 编译成 Wasm 即可。由于两项技术比较相似,这个编译工作并不难。

Emscripten 改为默认使用 LLVM 提供的 Wasm 编译后端直接生成 Wasm。

现代的高级编程语言要么是由编译器编译成机器码(Machine Code)然后执行,要么就是由解释器直接解释执行。前者可以理解为预先编译(Ahead-of-Time compilation,AOT),后者可以在执行时将部分“热”代码即时编译成机器码并执行,以提升性能,这就是所谓的即时编译(Just-in-Time Compilation,JIT)。

为了降低代码实现难度、提高可扩展性,现代编译器一般都会按模块化的方式设计和实现。典型的做法是把编译器分成前端(Front End)、中端(Middle End)和后端(Back End)。前端主要负责预处理、词法分析、语法分析、语义分析,生成便于后续处理的中间表示(Intermediate Representation,IR)。中端对 IR 进行分析和各种优化,例如常量折叠、死代码消除、函数内联。后端生成目标代码,把 IR 转化成平台相关的汇编代码,最终由汇编器编译为机器码。

1.2.2、设计理念

第一,层次必须低,尽量接近机器语言,这样浏览器才更容易进行 AOT/JIT 编译,以趋近原生应用的速度运行 Wasm 程序。

第二,要适合作为目标代码,由其他高级语言编译器生成。

而要在浏览器运行,Wasm 又必须满足其他一些要求。首先代码必须安全可控,不能像真正的汇编代码那样可以执行任意操作。然后,代码必须是平台无关的,这样才可以跨浏览器执行。

从高级语言编译器的角度来看,Wasm 是目标代码。但从浏览器的角度来看,Wasm 却更像 IR,最终会被 AOT/JIT 编译器编译成平台相关的机器码。基于以上介绍的这些因素,Wasm 最终采用了虚拟机/字节码技术,并且定义了紧凑的二进制格式。

Wasm 规范定义了两种模块格式:二进制格式和文本格式。如果和传统汇编语言做类比,那么 Wasm 模块的二进制格式相当于目标文件或可执行文件格式,文本格式则相当于汇编语言。使用汇编器可以把文本格式编译为二进制格式,使用反汇编器可以把二进制格式反编译成文本格式。

和 Java 虚拟机(Java Virtual Machine,JVM)一样,Wasm 也采用了栈式虚拟机和字节码。

Wasm 模块必须是安全可靠的,绝不允许有任何恶意行为。为了保证这一点,Wasm 模块包含了大量类型信息,这样绝大多数问题就可以通过静态分析在代码执行前被发现,只有少数问题需要推迟到运行时进行检查。

2、二进制格式介绍

Wasm 二进制格式也是以魔数(Magic Number)和版本号开头。在魔数和版本号之后是模块的主体内容,这些内容被分门别类放在不同的段(Section,也叫Segment)中。

先放个 go 的源码案例,方便对二进制格式里面的内容进行说明。

package main
import "fmt"

// 全局变量
const PI float32 = 3.14

// 类型定义
type type0 = func(a, b int32) int32
type type1 = func()
type type2 = func(ptr, len int32)

// 函数
func Add(a, b int32) int32 { return a + b }
func Sub(a, b int32) int32 { return a - b }
func Mul(a, b int32) int32 { return a * b }
func Div(a, b int32) int32 { return a / b }

func main() {
    fmt.Println("Hello, World!")
}

下面简单介绍 Wasm 二进制模块可能包含的 12 种段。

类型段(ID是1)

该段列出 Wasm 模块用到的所有函数类型(又叫作函数签名,或者函数原型)。

假设函数签名在类型段中的排列顺序和它们在源代码中首次出现的顺序一致,那么前两个签名应该是 (int32,int32)→(int32) 和 ()→()。

导入段和导出段(ID是2和7)

这两个段分别列出模块所有的导入项和导出项,多个模块可以通过导入和导出项链接在一起

有一个全局变量和 4 个函数被导出(首字母大写),因此导出段中应该包含 5 个项目:一个全局变量和 4 个函数。

函数段和代码段(ID是3和10)

内部函数信息被分开存储在两个段中,其中函数段实际上是一个索引表,列出内部函数所对应的签名索引;代码段存储内部函数的局部变量信息和字节码。不难看出,函数和代码段中的项目数量必须一致,且一一对应。

假设函数在函数段和代码段中的排列顺序也和它们在源代码中定义的顺序一致,那么函数段的内容应该是 [0,0,0,0,1],其中前 4 个 0 是加减乘除这 4 个函数的类型索引,1 是main()函数的类型索引(注意并不包含导入的函数)。相应的,代码段也应该包含 5 个项目,依次存放这 5 个函数的信息。

表段和元素段(ID是4和9)

表段列出模块内定义的所有表,元素段列出表初始化数据。Wasm 规范规定模块最多只能导入或定义一张表,所以即使模块有表段,里面也只能有一个项目。表主要和间接函数调用有关

内存段和数据段(ID是5和11)

内存段列出模块内定义的所有内存,数据段列出内存初始化数据。Wasm 规范规定模块最多只能导入或定义一块内存,所以即使模块有内存段,里面也只能有一个项目。

全局段(ID是6)

该段列出模块内定义的所有全局变量信息,包括值类型、可变性(Mutability)和初始值。上面的 Go 代码只定义了一个全局变量(准确地说是一个不可变常量),因此全局段中应该只有一个项目。

起始段(ID是8)

该段给出模块的起始函数索引。和其他段有所不同,起始段只能有一个项目。起始函数主要起到两个作用,一个是在模块加载后进行一些初始化工作;另一个是把模块变成可执行程序。如果模块有起始段,那么 Wasm 实现在加载模块后会自动执行起始函数。上面的 Go 代码有主函数,因此编译后的模块有起始段,里面放着该函数的索引。

自定义段(ID是0)

该段是给编译器等工具使用的,里面可以存放函数名等调试信息,或者其他任何附加信息。自定义段不参与 Wasm 语义,所以即便是完全忽略自定义段也不影响模块的执行。

Note

除了自定义段,其他段必须按 ID 递增的顺序出现。之所以有这样的规定,是因为很多段之间存在信息的依赖关系。

例如,由于导入段、函数段、代码段等都需要知道函数类型信息,所以类型段必须在这 3 个段之前出现;由于导入的函数、表、内存、全局变量在各自索引空间的最前面,所以导入段必须在这 4 个段之前出现;由于导出函数、表、内存、全局变量时需要知道其索引,所以导出段必须在这 4 个段之后出现。

Wasm 二进制格式的设计原则之一是可以一遍(One-pass)完成模块的解析、验证和编译(指AOT或JIT编译)。换句话说,Wasm 实现(比如浏览器)可以在下载模块的同时进行解码、验证和编译。可流式处理(Streamable)是 Wasm 的特点之一,二进制模块中各个段的排列方式一定程度上是为了满足这一特点。另外,之所以要把函数的签名信息和其他信息分别存放在两个段里,也是为了满足这一特点

下面是模块的结构体定义。

type Module struct {    
    Magic      uint32      
    Version    uint32      
    CustomSecs []CustomSec 
    TypeSec    []FuncType  
    ImportSec  []Import    
    FuncSec    []TypeIdx   
    TableSec   []TableType 
    MemSec     []MemType   
    GlobalSec  []Global    
    ExportSec  []Export   
    StartSec   *FuncIdx    
    ElemSec    []Elem      
    CodeSec    []Code      
    DataSec    []Data      
}

这些字段在后面会详细介绍,但这里有两点需要说明:第一,如前文所述,Wasm 规范规定模块最多只能导入或定义一张表,内存也有同样限制。但这些限制只是暂时的

第二,由于起始段只需要记录一个函数索引,所以我们把它定义成指针类型。如果该指针是 nil,则表示没有起始段。

2.1、二进制格式分析

Wasm 二进制格式的魔数占 4 个字节,内容是 \0asm 。Wasm 二进制格式的版本号也占 4 个字节,当前版本是 1。

在 Wasm 二进制格式里,每个段都以 1 字节的段 ID 开始。除了自定义段,其他段的结构都完全定义好了。由于自定义段的内容可能是未知的,所以段 ID 后面存储了段的实际内容字节数,这样 Wasm 实现就可以根据字节数跳过不认识的自定义段。对于非自定义段,内容字节数并非解码必须的,但却有助于在需要时快速跳过某些段。

3、指令集介绍

Wasm 指令包含两部分信息:操作码(Opcode)和操作数(Operands)。操作码是指令的 ID,决定指令将执行的操作;操作数则相当于指令的参数,决定指令的执行结果。

Wasm 指令的操作码固定为一个字节,因此指令集最多只能包含 256 条指令。像这样的代码有一个响亮的名称:字节码(最有名的就是 Java 字节码)。

Wasm 规范一共定义了 178 条指令,这些指令按功能可以分为 5 大类,分别是:
1)控制指令(Control Instructions)
2)参数指令(Parametric Instructions)
3)变量指令(Variable Instructions)
4)内存指令(Memory Instructions)
5)数值指令(Numeric Instructions)

Wasm 只支持 4 种数值类型:i32、i64、f32、f64。Wasm 指令集中很大一部分指令是以这 4 种数值中的某一种为主进行操作,这些指令的操作码助记符以类型和点号为前缀,比如i32.load、i64.const、f32.add、f64.max。

Wasm 规范要求整数采用 2 的补码表示。使用这种编码格式,数据里并没有显式编码整数的符号位,所以数值可以被解释为正数,也可以被解释为负数。对于某些计算(比如加、减、乘),无论怎么解释,最终的计算结果都是一样的;对于另外一些计算(比如除、求余、比较),就必须指定数值有无符号。如果整数指令的结果不受符号影响,则操作码助记符无特别后缀,比如 i32.add。否则,由指令决定将整数解释为有符号(Signed,操作码助记符以 _s 结尾)还是无符号(Unsigned,操作码助记符以 _u 结尾)。强调符号的指令一般成对出现,比如 i64.div_s 和 i64.div_u。

操作数又分为两种:静态操作数和动态操作数。静态操作数直接编码在指令里,跟在操作码的后面。动态操作数在运行时从操作数栈获取。为了避免混淆,我们把静态操作数称为指令的静态立即参数(Static Immediate Arguments),后文简称立即数。如无特别说明,后文出现的操作数特指动态操作数(Dynamic Operands)。

和 Java 虚拟机一样,Wasm 规范实际上也定义了一台概念上的栈式虚拟机。绝大多数的 Wasm 指令都是基于一个虚拟栈工作:从栈顶弹出若干个数,进行计算,然后把结果压栈。如上文所述,我们把这些运行时位于栈顶并被指令操纵的数叫作指令的动态操作数,简称操作数。很自然的,我们称这个栈为操作数栈。为了实现控制指令,Wasm 还需要一个控制栈

由于采用了栈式虚拟机,大部分 Wasm 指令(特别是数值指令)都很短,只有一个操作码,这是因为操作数已经隐含在栈上了。举例来说,i32.add 指令只有一个操作码 0x6A。在执行时,这条指令从栈顶弹出两个 i32 类型的整数,这两个整数相加,然后把结果(也是 i32 类型)压栈。这一设计使得 Wasm 字节码非常紧凑。

还有一些编程语言采用寄存器虚拟机,比如 Lua 语言和 Android 早期的 Dalvik 虚拟机。因为指令中需要包含寄存器索引,所以寄存器虚拟机的指令一般比较长。以 Lua 虚拟机为例,指令固定长度为 4 字节,加法指令可以写成 ADD A B C,表示将寄存器 B 和寄存器 C 中的数相加,写入寄存器 A。

3.1、指令分析

Wasm 支持直接和间接两种函数调用方式。

call 指令(操作码 0x10)进行直接函数调用,函数索引由立即数指定。

call_indirect 指令(操作码 0x11)进行间接函数调用,函数签名的索引由立即数指定,到运行时才能知道具体调用哪个函数。

4、基本结构

从整体上看 Wasm 文本格式和二进制格式基本是一致的。除了表现形式明显不同以外,在结构上,两种格式还有几个较大的不同之处。

1)二进制格式是以段(Section)为单位组织数据的,文本格式则是以域(Field,为了和编程语言中的字段进行区别,本书将其称为域)为单位组织内容。域相当于二进制段中的项目,但不一定要连续出现,WAT 编译器会把同类型的域收集起来,合并成二进制段。

2)在二进制格式中,除了自定义段以外,其他段必须按照 ID 递增的顺序排列,文本格式中的域则没有这么严格的限制。不过,导入域必须出现在函数域、表域、内存域和全局域之前。

3)文本格式中的域和二进制格式中的段基本是一一对应的,但是有两种情况例外。第一种是文本格式没有单独的代码域,只有函数域。WAT 编译器会将函数域收集起来,分别生成函数段和代码段。第二种是文本格式没有自定义域,没办法描述自定义段(已经有提案建议增强 WAT 语法,支持表达自定义数据)。

4)为了便于编写,文本格式提供了多种内联写法。例如:函数域、表域、内存域、全局域可以内联导入或导出域,表域可以内联元素域,内存域可以内联数据域,函数域和导入域可以内联类型域。这些内联写法只是“语法糖”,WAT 编译器会做妥善处理。

5、操作数栈

Wasm 程序的执行环境是一台栈式虚拟机,绝大多数 Wasm 指令都要借助操作数栈来工作:从上面弹出若干数值,对数值进行计算,然后再把计算结果压栈。这台虚拟机还可以附加一块内存,鉴于操作数栈只能存放临时数据,生命力更强的数据则可以放在内存里。此外,这台虚拟机还可以操作表和全局变量。

对于计算机来说,一切信息终究只是 0 和 1 组成的序列。如果把数据的单位放大一些,那么一切信息都只是字节序列而已。一串 0 和 1 代表的含义,取决于我们如何解释它。例如二进制序列 10101001,我们可以认为它是一个无符号整数,表示 169(或者 0xA9);也可以认为它是一个有符号整数,表示 -87;还可以认为它是一个 ASCII 字符,表示 ©。当我们说一个数是整数或者浮点数时,并不是说它所对应的字节序列有什么特别之处,只是因为我们知道它表示一个整数或者符点数,并且要通过某种方式告诉计算机这一点。

5.1、参数指令

drop 指令(操作码 0x1A)从栈顶弹出一个操作数并把它“扔掉”。drop 指令没有立即数,也不会检查操作数的类型。

select 指令(操作码 0x1B)从栈顶弹出 3 个操作数,然后根据最先弹出的操作数从其他两个操作数中选择一个压栈。最先弹出的操作数必须是 i32 类型,其他 2 个操作数是相同类型的就可以。如果最先弹出的操作数不为 0,则把最后弹出的操作数压栈;如果为 0,则把中间的操作数压栈。

6、内存介绍

和真实的机器一样,Wasm 内存是一个随机存取存储器(Random Access Memory,简称RAM)。从本质上讲,它就是一个线性的字节数组,可以按偏移量(也就是内存地址)读写任意字节。数值在 Wasm 内存中按小端方式存储。我们已经知道,Wasm 操作数栈是类型安全的。每一条指令都按照规定的方式改变栈顶操作数,因此在任意时刻我们都知道操作数栈里有多少操作数,分别是何种类型。与操作数栈不同,Wasm 内存则完全是无类型的,没办法在编译器对它做任何类型检查。在运行时能做的检查也只是确保内存读写不会越界。

6.1、内存指令

内存指令一共有 25 条,按操作又可以分为 3 组。第一组是加载指令,共 14 条,从内存中读取数据,压入操作数栈。第二组是存储指令,共 9 条,从栈顶弹出数值,写入内存。第三组是页数获取和增长指令,共 2 条,不读写内存,只获取或者增长内存页数。

加载指令从内存加载数据,转换为适当类型的值,再压入操作数栈。Wasm 使用立即数+操作数的内存寻址方式。所有的加载和存储指令都带有两个立即数:对齐方式和内存偏移量。其中对齐方式存放的是以 2 为底,对齐字节数的对数。

加载和存储指令还需要从操作数栈上弹出一个 i32 类型的数,把它和立即数偏移量相加,即可得到实际内存地址。由于静态的立即数偏移量和动态的操作数偏移量都被解释为 32 位无符号整数,所以 Wasm 实际上拥有 33 比特的地址空间。

6.2、Note

Wasm 内存是一块抽象的 RAM(本质上就是一个线性的字节数组),并且可以在限制范围内按页动态增长。Wasm 提供了丰富的内存指令,用于读写各种基本类型的数值,这些数值在 Wasm 内存中按小端方式存储。简而言之,Wasm 内存和真实内存非常接近,只具备最基本的读写功能,像内存管理、垃圾回收这样的高级功能都要靠高级语言解决。也正是因为贴近底层,Wasm 程序才能够以接近本地程序的速度执行。

7、函数调用介绍

Wasm 提供了直接函数调用和间接函数调用两种函数调用方式。前一种方式通过指令立即数指定的函数索引直接调用函数,因此也可以称为静态函数调用。后一种方式要借助栈顶操作数和表间接调用函数,因此也可以称为动态函数调用。

1.参数和返回值函数的参数由调用方准备。在调用函数之前,调用方应该将参数准备好。更准确地说,是把实际参数按顺序压栈(第一个参数在最下面)。函数调用完毕后,这些参数已经被弹出,取而代之的是函数的返回值(如果有的话)。Wasm 函数可以返回多个值,这些值会按顺序出现在栈顶(第一个返回值在最下面)。

2.局部变量函数指令可以操纵操作数、局部变量、全局变量和内存。操作数的生命周期最短暂,可能只持续几条指令。局部变量的生命周期就是整个函数执行的过程,我们在前文也提到过,函数的参数实际上也是局部变量。全局变量和内存数据的生命最长,在整个模块执行期间都有效。如果全局变量或内存是从外部导入的,那么它的生命周期将更长,很可能会跨越多个模块实例的生命周期。

3.调用栈和调用帧我们知道,函数的指令需要使用操作数栈,函数也需要为参数和局部变量分配空间。另外,为了实现函数调用,往往还需要记录一些其他信息(后面会详细介绍)。我们把这些数据看成一个整体,把它叫作函数的调用帧(Call Frame)。每调用一个函数,就需要创建一个调用帧;当函数执行结束,再把调用帧销毁。我们可以把这一系列调用帧理解成栈结构,将其称为函数的调用栈(Call Stack)。一连串的函数调用就是不停创建和销毁调用帧的过程,但是在任一时刻,只有位于栈顶的调用帧是活跃的,我们称之为当前帧,与之关联的函数称为当前函数。

7.1、函数调用实现

我们需要一个程序计数器(Program Counter,PC)来记录指令执行的位置。当一个控制帧被重新激活(成为栈顶控制帧),可以根据PC恢复指令执行。

铺垫了这么多,终于可以实现 call 指令(操作码 0x10)了。我们已经知道,该指令带有一个立即数,给出被调用函数的索引。指令执行时,会根据被调用函数的类型从栈顶弹出参数。指令执行结束后,被调用函数的返回值会出现在栈顶。

7.2、局部变量指令

local.set 指令(操作码 0x21)用于设置局部变量的值。局部变量的索引由立即数指定,新的值从栈顶弹出(必须和要修改的局部变量是相同类型)。

8、控制指令介绍

对于虚拟机来说,goto/jump 指令已经没那么大危害了。因为大多数情况下,指令是由编译器生成的,程序员基本不需要直接接触代码。但是,允许任意跳转还是存在一个问题:代码不太好验证。仍然以 Java 虚拟机为例,由于 goto 等指令的存在,Java 字节码验证起来非常麻烦。作为对比,Wasm 彻底废弃了任意跳转指令,只支持结构化控制指令和受限制的跳转指令,因此字节码很容易验证,可以用几百行甚至更少的代码实现

跳转标签索引是相对的,是一个抽象的概念,只有针对具体跳转指令才有具体含义。比如某条跳转指令,对它来说标签索引 0 表示该指令所在的控制块定义的跳转标签,1 表示往外一层控制块定义的跳转标签,2 表示再往外一层控制块定义的跳转标签,以此类推。下面这个例子展示了多层 block 指令和跳转标签索引的用法。

8.1、控制指令实现

br_table 指令进行无条件查表跳转。br 和 br_if 指令只能指定一个跳转目标,且在编译期就已经确定。与这两条指令不同,br_table 指令可以指定多个跳转目标,最终使用哪个跳转目标要到运行期间才能决定。更具体地说,br_table 指令的立即数给定了 n+1 个跳转标签索引。其中前 n 个标签索引构成了一个索引表,后一个标签索引是默认索引。当 br_table 指令执行时,要先从操作数栈顶弹出一个 i32 类型的值(假设它为 m)。如果 m 小于等于 n,则跳转到索引表第 m 个索引指向的标签处,否则跳转到默认索引指定的标签处。

9、本地函数调用

我们已经对 Wasm 函数和本地函数进行了足够细致的讨论,现在总结一下二者的异同之处。相同之处:都是函数,都可以被调用,都要(按照签名)从栈顶拿走参数并放回返回值。不同之处:Wasm 函数是在模块内部定义的,有自己的字节码;本地函数是从外部导入的,用本地语言编写。为了便于实现,有必要将二者统一起来。

对于 Wasm 函数,其类型可以通过函数段获得,代码可以从代码段获得。这两个字段都可以通过函数索引获取,不过也可以简化实现。对于本地函数(和其他外部函数),其类型在导入段中描述,实现则是在模块链接时给定。

如果是外部或本地函数,是不需要创建调用帧的,只需要先根据函数签名把参数从栈顶弹出,然后调用本地函数,最后把返回值压栈即可,代码如下所示。

func callExternalFunc(vm *vm, f vmFunc) {    
    args := popArgs(vm, f._type)    
    results := f.goFunc(args)    
    pushResults(vm, f._type, results)
}

参数和返回值的弹出和压入由 popArgs()/pushResults() 函数完成

9.1、间接函数调用

直接函数调用非常简单,要调用哪个函数是在编译期确定的。换句话说,被调用函数的索引硬编码在 call 指令的立即数中。间接函数调用则有所不同:在编译期只能确定被调用函数的类型(call_indirect 指令的立即数里存放的是被调用函数的类型索引),具体调用哪个函数只有在运行期间根据栈顶操作数才能确定。由于这个原因,我们也可以称直接函数调用为静态调用,称间接函数调用为动态调用。

指令执行时,需要先从栈顶弹出一个 i32 类型的操作数(假设是 1),以此为索引查表就可以找到函数引用,继而找到并调用函数。

现在问题来了,为什么不能直接用函数索引作为函数引用呢?如果函数引用只指向内部函数或者导入的外部函数,的确可以这样执行。但实际情况却没有这么简单。表是可以被导入或导出的,所以同一张表可以被多个模块操作。换句话说,对于某个模块来说,其表内函数引用指向的函数可能并不在该模块的函数集合(外部导入的函数+内部定义的函数)之内。

10、定义实例接口

模块从二进制格式到最终执行分为 3 个语义阶段:解码、验证、执行,执行阶段又可以分为实例化和调用两个小阶段。解码阶段将二进制模块解码为内存表示,验证阶段对模块进行严格的验证,实例化阶段根据模块内存创建并准备好模块实例,然后就可以调用模块的公开函数了。

11、AOT 编译器

字节码程序(比如 Wasm 二进制模块或者 Java 类文件)通常有 3 种执行方式:解释执行、预先编译为本地可执行程序,以及在运行时即时编译为本地机器代码。其中预先(Ahead-of-Time)编译方式一般简称为 AOT,即时(Just-in-Time)编译方式一般简称为 JIT。简单来说,解释执行不编译字节码,AOT 在执行前把全部字节码编译为本地代码,JIT 在执行中将部分(热点)字节码编译为本地代码。

11.1、AOT 介绍

AOT 可以预先生成执行效果较好的本地代码,但由于是静态编译,所以无法准确预测代码在运行时的行为,不能达到性能极致。相比而言,JIT 结合了解释器和 AOT 的优点,在运行时收集各种信息和指标,既可以快速启动,又可以生成更加优化的代码,所以能在总体上达到更佳的运行效果,这也是解释器 + JIT 成为目前主流执行方式的原因之一。

生成更高效的代码是需要花费很多时间的,为了在快速启动和高度优化之间取得平衡,很多 JIT/AOT 实现(比如 Java 虚拟机和 Firefox 浏览器)使用了分层(Tiered)编译技术。分层编译一般分为两层,第一层(tier1,叫法因实现而异)编译器可以较快地生成本地代码并执行;而第二层(tier2)编译器在后台线程中生成执行效果更好的代码,并替换 tier 生成的代码。

Logo

华为开发者空间,是为全球开发者打造的专属开发空间,汇聚了华为优质开发资源及工具,致力于让每一位开发者拥有一台云主机,基于华为根生态开发、创新。

更多推荐