WebAssembly原理与核心技术
上QQ阅读APP看书,第一时间看更新

4.1 基本结构

Wasm文本格式使用S-表达式描述模块。这种表达式源自Lisp语言,使用了大量圆括号,特别适合描述类似抽象语法树(Abstract Syntax Tree,简称AST)的树形结构。下面是Wasm文本格式的整体结构。


(module
  (type   ... ) ;; 详见4.1.1小节
  (import ... ) ;; 详见4.1.2小节
  (func   ... ) ;; 详见4.1.3小节
  (table  ... ) ;; 详见4.1.4小节
  (memory ... ) ;; 详见4.1.5小节
  (global ... ) ;; 详见4.1.6小节
  (export ... ) ;; 详见4.1.2小节
  (start  ... ) ;; 详见4.1.7小节
  (elem   ... ) ;; 详见4.1.4小节
  (data   ... ) ;; 详见4.1.5小节
)

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

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

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

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

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

接下来我们仔细看看各个域的写法。

4.1.1 类型域

类型域用于定义函数类型,下面这个例子定义了一个接收两个i32类型参数、返回一个i32类型结果的函数类型。


(module
  (type (func (param i32) (param i32) (result i32)))
)

圆括号是WAT语言主要的分隔符(Separator),module、type、func、param、result等是WAT语言的关键字(keyword,以小写字母开头)。由于WAT语言较为简单,大部分语法规则都可以通过示例代码理解,因此本章没有给出形式化的词法和语法规则。喜欢通过词法和语法规则学习语言的读者可以参考Wasm规范第6章或者查看本书附录C。

我们可以给函数类型分配一个标识符(Identifier,以$符开头),换句话说,就是给它起一个名字,这样就可以在其他地方通过调用名字来引用函数类型,不必直接使用索引。另外,多个参数可以简写在同一个param块里,多个返回值可以简写在同一个result块里。下面的例子展示了标识符以及参数和返回值的简写形式。


(module
  (type $ft1 (func (param i32 i32) (result i32)))
  (type $ft2 (func (param f64) (result f64 f64)))
)

4.1.2 导入和导出域

Wasm模块可以导入或者导出函数、表、内存和全局变量这4种类型的元素。因此,导入和导出域也支持这4种类型,下面的例子展示了导入域的写法。


(module
  (type $ft1 (func (param i32 i32) (result i32)))
  (import "env" "f1" (func   $f1 (type $ft1)))
  (import "env" "t1" (table  $t 1 8 funcref))
  (import "env" "m1" (memory $m 4 16))
  (import "env" "g1" (global $g1 i32))       ;; immutable
  (import "env" "g2" (global $g2 (mut i32))) (;; mutable ;;)
)

可以看到,在导入域中,需要指明模块名、导入元素名,以及导入元素的具体类型。模块名和元素名用字符串指定,以双引号分隔。和类型域一样,导入域也可以附带一个标识符,这样就可以在后面通过名字引用被导入的元素。顺便说明一下,WAT支持两种类型的注释。单行注释以;;开始,直到行尾。跨行注释以(;;开始,以;;)结束。

在上面的例子中,类型域是单独出现的,并在导入函数中通过名字(也可以通过索引)引用。当多个导入函数有相同的类型时,这种写法可以避免代码重复出现。为了方便,如果某个函数类型只被使用一次,也可以把它内联进导入域中,如下所示。


(module
  (import "env" "f1" 
    (func $f1 
      (param i32 i32) (result i32) ;; inline function type
    )
  )
)

相比导入域,导出域的写法要简单一些,因为导出域只须指定导出名和元素索引。当然,更好的做法是通过标识符引用元素,实际索引交给WAT编译器去计算。导出名在整个模块内必须是唯一的,这点一定要注意。下面的例子展示了导出域的写法。


(module
  ;; ...
  (export "f1" (func   $f1))
  (export "f2" (func   $f2))
  (export "t1" (table  $t ))
  (export "m1" (memory $m ))
  (export "g1" (global $g1))
  (export "g2" (global $g2))
)

导入域和导出域可以内联在函数、表、内存、全局域中,下面的例子展示了导入域的内联写法。


(module
  (type $ft1 (func (param i32 i32) (result i32)))
  (func   $f1 (import "env" "f1") (type $ft1))
  (table  $t1 (import "env" "t" ) 1 8 funcref)
  (memory $m1 (import "env" "m" ) 4 16)
  (global $g1 (import "env" "g1") i32)
  (global $g2 (import "env" "g2") (mut i32))
)

下例展示了导出域的内联写法(4.1.3节~4.1.6节会介绍函数域、表域、内存域和全局域的完整写法)。


(module
  (func   $f (export "f1") ... ) 
  (table  $t (export "t" ) ... )
  (memory $m (export "m" ) ... )
  (global $g (export "g1") ... )
)

4.1.3 函数域

函数域定义函数的类型和局部变量,并给出函数的指令。WAT编译器会把函数域拆开,把类型索引放在函数段中,把局部变量信息和字节码放在代码段中。下面的例子展示了函数域的写法(指令的写法将在4.2节中详细介绍)。


(module
  (type $ft1 (func (param i32 i32) (result i32)))
  (func $add (type $ft1)
    (local i64 i64)

    (i64.add (local.get 2) (local.get 3)) (drop)
    (i32.add (local.get 0) (local.get 1))
  )
)

函数的参数本质上也是局部变量,同函数域里定义的局部变量一起构成了函数的局部变量空间,索引从0开始递增,这一点在第7章还会详细介绍。

上面给出的是函数域的精简写法,通过调用函数名字引用函数类型,并且使用了参数和局部变量的简写方式。实际上我们可以把函数类型内联进函数域,并把param块拆成多个参数,这样就可以给参数起名字。同样,也可以把local块拆成多个变量,这样就可以给局部变量起名字。给参数和局部变量起了名字,就可以在变量指令中通过名字而非索引定位参数或局部变量,这样有助于提高代码的可读性。我们把上面的例子改写一下,内联类型定义,并给参数和局部变量分配标识符,代码如下所示。


(module
  (func $add (param $a i32) (param $b i32) (result i32)
    (local $c i64) (local $d i64)

    (i64.add (local.get $c) (local.get $d)) (drop)
    (i32.add (local.get $a) (local.get $b))
  )
)

4.1.4 表域和元素域

我们已经知道,模块最多只能导入或者定义一张表,所以表域最多只能出现一次,但元素域可以出现多次。表域需要描述表的类型,包括限制和元素类型(目前只能是funcref)。元素域可以指定若干个函数索引,以及第一个索引的表内偏移量。第2章简单介绍了表段和元素段,第9章讨论间接函数调用指令时还会做进一步介绍。下面的例子展示了表域和元素域的写法。


(module
  (func $f1) (func $f2) (func $f3)
  (table 10 20 funcref)
  (elem (offset (i32.const 5)) $f1 $f2 $f3)
)

表和内存偏移量以及全局变量的初始值是通过常量指令指定的,后面不再赘述。表域中也可以内联一个元素域,但使用这种方式无法指定表的限制(只能由编译器根据内联元素进行推测),也无法指定元素的表内偏移量(只能从0开始)。下面的例子展示了元素域的内联写法。


(module
  (func $f1) (func $f2) (func $f3)
  (table funcref       ;; min=3, max=3
    (elem $f1 $f2 $f3) ;; inline elem, offset=0
  )
)

4.1.5 内存域和数据域

和表相似,模块最多只能导入或定义一块内存,所以内存域最多也只能出现一次,数据域则可以出现多次。内存域需要描述内存的类型(即页数上下限),数据域需要指定内存的偏移量和初始数据。第2章简单介绍了内存和数据段,第6章讨论内存指令时还会做进一步介绍。下面的例子展示了内存和数据域的写法。


(module
  (memory 4 16)
  (data (offset (i32.const 100)) "Hello, ")
  (data (offset (i32.const 108)) "World!\n")
)

可以看到,内存初始数据是以字符串形式指定的。除了普通的字符,还可以使用转义序列在字符串中嵌入回车换行等特殊符号、十六进制编码的任意字节,以及Unicode代码点。具体内容请参考Wasm规范6.3.3节[1]或者本书附录C。

和表域相似,内存域中也可以内联一个数据域,但是使用这种方式无法指定内存的页数(只能由编译器根据内联数据进行推测),也无法指定内存的偏移量(只能从0开始)。另外,数据域中的数据还可以写成多个字符串。下面的例子展示了数据域的内联写法。


(module
  (memory                       ;; min=1, max=1
    (data "Hello, " "World!\n") ;; inline data, offset=0
  )
)

[1] 参考链接:https://webassembly.github.io/spec/core/text/values.html#strings。

4.1.6 全局域

全局域定义全局变量,需要描述全局变量的类型和可变性,并给定初始值。和其他元素一样,全局域也可以指定标识符,这样就可以在变量指令中使用全局变量的名字而非索引。我们将在第7章详细讨论全局变量,下面的例子展示了全局域的写法。


(module
  (global $g1 (mut i32) (i32.const 100)) ;; mutable
  (global $g2 (mut i32) (i32.const 200)) ;; mutable
  (global $g3 f32 (f32.const 3.14))      ;; immutable
  (global $g4 f64 (f64.const 2.71))      ;; immutable
  (func
    (global.get $g1) (global.set $g2)
  )
)

4.1.7 起始域

起始域的写法最为简单,只须指定一个起始函数名或索引。下面的例子展示了起始域的写法。


(module
  (func $main ... )
  (start $main)
)

到这里,Wasm文本格式的基本结构就介绍完毕了。下一节我们深入函数内部,看看各种指令的具体写法。