3.1 指令集介绍
和汇编代码一样,Wasm二进制模块中的代码(包括代码段中的函数代码、全局段中的初始值表达式,以及元素和数据段中的偏移量表达式)也由一条一条的指令构成。同样,Wasm指令包含两部分信息:操作码(Opcode)和操作数(Operands)。操作码是指令的ID,决定指令将执行的操作;操作数则相当于指令的参数,决定指令的执行结果。
3.1.1 操作码
Wasm指令的操作码固定为一个字节,因此指令集最多只能包含256条指令。像这样的代码有一个响亮的名称:字节码(最有名的就是Java字节码)。Wasm规范一共定义了178条指令,这些指令按功能可以分为5大类,分别是:
1)控制指令(Control Instructions),共13条指令,将在第8章详细介绍;
2)参数指令(Parametric Instructions),共2条指令,将在第5章详细介绍;
3)变量指令(Variable Instructions),共5条指令,将在第7章详细介绍;
4)内存指令(Memory Instructions),共25条指令,将在第6章详细介绍;
5)数值指令(Numeric Instructions),共133条指令,将在第5章详细介绍。
可以看到,在已经定义的指令中,有近3/4属于数值指令。数值指令又可以按操作进一步分为常量指令、测试指令、比较指令、算术运算指令、类型转换指令等几个小类。图3-1所示是指令的操作码分布图(空白区域是未定义操作码,此外,数值指令中的饱和截断指令较为特殊,图中并未体现,详见第5章)。
图3-1 Wasm指令集操作码分布图
根据Wasm规范,我们可以把这178个操作码定义为常量。这里不列出全部代码,读者可以在code/go/ch03/wasm.go/binary/opcodes.go文件里找到完整的常量定义,或者从本书附录A中查看全部操作码,下面是部分代码。
const ( Unreachable = 0x00 // unreachable Nop = 0x01 // nop Block = 0x02 // block rt in* end Loop = 0x03 // loop rt in* end If = 0x04 // if rt in* else in* end Else_ = 0x05 // else End_ = 0x0B // end Br = 0x0C // br l BrIf = 0x0D // br_if l BrTable = 0x0E // br_table l* lN Return = 0x0F // return Call = 0x10 // call x CallIndirect = 0x11 // call_indirect x ... // 其他代码省略 }
以上常量名来自操作码助记符(详见3.1.2节)。由于几乎所有助记符都不符合Go语言常量命名规则或命名风格,所以进行了统一处理:去掉了点号和下划线,整体改成了首字母大写驼峰式。
3.1.2 助记符
操作码毕竟只是一个整数,虽然便于机器处理,但是对人类不友好。为了方便开发者书写和理解,和汇编语言一样,Wasm规范给也给每个操作码定义了助记符(Mnemonic)。比如操作码0x01的助记符是nop,表示无操作(No Operation);操作码0x10的助记符是call,表示函数调用。
如前所述,Wasm规范已经定义了178条指令,好在这些指令中有很多实现的功能近似,理解一条指令就理解了一组指令。这些指令的操作码助记符也有很强的关联性,因此掌握助记符命名规则对以后理解指令功能大有裨益。下面列出最重要的两条助记符命名规则(如果现在觉得不太好理解也没关系,等后面遇到具体指令时自然就明白了)。
1.类型前缀
如第2章所述,Wasm只支持4种数值类型:i32、i64、f32、f64。Wasm指令集中很大一部分指令是以这4种数值中的某一种为主进行操作,这些指令的操作码助记符以类型和点号为前缀,比如i32.load、i64.const、f32.add、f64.max。
2.符号后缀
如第2章所述,Wasm规范要求整数采用2的补码表示。使用这种编码格式,数据里并没有显式编码整数的符号位,所以数值可以被解释为正数,也可以被解释为负数。对于某些计算(比如加、减、乘),无论怎么解释,最终的计算结果都是一样的;对于另外一些计算(比如除、求余、比较),就必须指定数值有无符号。如果整数指令的结果不受符号影响,则操作码助记符无特别后缀,比如i32.add。否则,由指令决定将整数解释为有符号(Signed,操作码助记符以_s结尾)还是无符号(Unsigned,操作码助记符以_u结尾)。强调符号的指令一般成对出现,比如i64.div_s和i64.div_u。
助记符并不是Wasm实现的必要元素,不过它有助于调试代码,而且dump程序也需要用到它。由于Go语言没办法在运行时获取常量名字,所以我们需要定义一个操作码到助记符的映射表,因为操作码不超过256个,所以用切片就足够了。完整的操作码定义在binary/opnames.go文件里,下面展示部分代码。
var opnames = make([]string, 256) func init() { opnames[Unreachable] = "unreachable" opnames[Nop] = "nop" opnames[Block] = "block" opnames[Loop] = "loop" opnames[If] = "if" opnames[Else_] = "else" opnames[End_] = "end" opnames[Br] = "br" opnames[BrIf] = "br_if" opnames[BrTable] = "br_table" opnames[Return] = "return" opnames[Call] = "call" opnames[CallIndirect] = "call_indirect" ... // 其他代码省略 }
3.1.3 立即数
上文提到Wasm指令包含两部分信息:操作码和操作数。这么说其实不太准确,准确地说,操作数又分为两种:静态操作数和动态操作数。静态操作数直接编码在指令里,跟在操作码的后面。动态操作数在运行时从操作数栈(详见3.1.4节)获取。为了避免混淆,我们把静态操作数称为指令的静态立即参数(Static Immediate Arguments),后文简称立即数。如无特别说明,后文出现的操作数特指动态操作数(Dynamic Operands)。
基于以上介绍,可以给出Wasm指令的结构体定义(表达式、指令、立即数相关的结构体全部在binary/instruction.go文件中,后文不再赘述)。
type Instruction struct { Opcode byte Args interface{} } func (instr Instruction) GetOpname() string { return opnames[instr.Opcode] }
其实Wasm大部分指令都是没有立即数的,指令的立即数可以大致分为:数值(包括常量和索引)、内存指令和控制指令参数。为了让指令结构体保持简洁,我们使用Go语言的空接口(可以存放任意类型的值)来统一存放立即数。立即数类型取决于操作码,需要强制转换后使用。常量和索引直接用数值表示就可以了,下面来看一下内存指令和控制指令的参数定义。
1.内存指令
内存加载/存储系列指令需要指定内存偏移量和对齐提示(详见第6章),下面是内存指令立即数的结构体定义。
type MemArg struct { Align uint32 Offset uint32 }
2.block和loop指令
我们都知道GOTO语句是有害的,会导致代码无法阅读。Wasm彻底摒弃了低级的GOTO和JUMP指令,采用更为高级的结构化控制指令。简而言之,就是使用block、loop和if这3种指令定义顺序、循环和分支结构的起点。这3种指令都必须以end指令为终点,形成内部是嵌套的指令序列。我们可以使用br系列指令跳出block和if块,或者重新开始loop块。另外,这3种指令很像内联函数,可以带有参数值和结果值(详见第8章)。
除了跳转目标不同,block指令和loop指令在代码结构上是完全一样的,所以可以共用一个立即数结构体。为了反映结构化控制指令的嵌套性,简化实现难度,我们把内嵌的指令序列也当作指令的立即数。下面是block指令和loop指令的立即数结构体定义。
type BlockType = int32 type BlockArgs struct { BT BlockType Instrs []Instruction }
在多返回值提案被接受之前,块类型非常简单:不能有参数,且最多只能有一个结果。当时,块类型是用一个字节来表示的:0x7F表示有一个i32类型结果、0x7E表示有一个i64类型结果、0x7D表示有一个f32类型结果、0x7C表示有一个f64类型结果、0x40表示没有结果。随着限制的放开,块类型在Wasm二进制中被重新解释为LEB128有符号整数,解码后的数值对应两种可能:
1)负数:必须是–1、–2、–3、–4或者–64,对应限制放开前的5种结果;
2)非负数:必须是有效的类型索引(块类型也存在类型段中)。
块类型的解码逻辑将在3.3节详细介绍。如果觉得控制指令不好理解也不用担心,后面会结合实例进行分析,第8章将进一步介绍并实现这些指令。结构化控制指令使得Wasm字节码非常容易验证,第11章将详细说明这一点。
3.if指令
相比block和loop块,if块稍微复杂一些,因为其内部的指令序列被else指令一分为二。我们用两个指令切片来表示这两条分支,下面是if指令的立即数结构体定义。
type IfArgs struct { BT BlockType Instrs1 []Instruction Instrs2 []Instruction }
4.br_table指令
前面提到的br系列指令包括4条:br、br_if、br_table和return。其中return指令没有立即数,br和br_if指令的立即数是索引类型,没必要单独定义。只有br_table指令的立即数比较复杂,包括一个跳转表和默认跳转标签,下面是它的立即数结构体定义。
type BrTableArgs struct { Labels []LabelIdx Default LabelIdx }
以上就是需要专门定义的指令立即数结构体,表3-1对全部指令的立即数类型和用途进行了汇总(饱和截断指令比较特殊,详见第5章)。
表3-1 指令立即数对照表
3.1.4 操作数
和Java虚拟机一样,Wasm规范实际上也定义了一台概念上的栈式虚拟机。绝大多数的Wasm指令都是基于一个虚拟栈工作:从栈顶弹出若干个数,进行计算,然后把结果压栈。如上文所述,我们把这些运行时位于栈顶并被指令操纵的数叫作指令的动态操作数,简称操作数。很自然的,我们称这个栈为操作数栈。为了实现控制指令,Wasm还需要一个控制栈(详见第8章)。在不至于产生歧义的地方,我们将操作数栈简称为栈。
由于采用了栈式虚拟机,大部分Wasm指令(特别是数值指令)都很短,只有一个操作码,这是因为操作数已经隐含在栈上了。举例来说,i32.add指令只有一个操作码0x6A。在执行时,这条指令从栈顶弹出两个i32类型的整数,这两个整数相加,然后把结果(也是i32类型)压栈。这一设计使得Wasm字节码非常紧凑。
顺便说一下,Python和Ruby等语言内部使用的也是栈式虚拟机。作为对比,还有一些编程语言采用寄存器虚拟机,比如Lua语言和Android早期的Dalvik虚拟机。因为指令中需要包含寄存器索引,所以寄存器虚拟机的指令一般比较长。以Lua虚拟机为例,指令固定长度为4字节,加法指令可以写成ADD A B C,表示将寄存器B和寄存器C中的数相加,写入寄存器A。
基于栈的虚拟机和基于寄存器的虚拟机各有利弊,这里就不展开讨论了。读完本书之后,读者应该会对栈式虚拟机有一定的了解。如果想再深入了解寄存器虚拟机,可以参考我写的另外一本书《自己动手实现Lua:虚拟机、编译器和标准库》。