WebAssembly, 简称WASM, 是一种以安全有效的方式运行可移植程序的新技术,主要针对Web平台。 与 ASM.js类似, WASM的目标是对高级程序中间表示的适当低级抽象,即,WebAssembly代码旨在由编译器生成而不是由人来写。
实际上,称之为“模块(module)”,是因为使用WebAssembly并没有“程序”和“库”之间的区别,只有“模块”,彼此之间可以搭配,亦可通信,每个“模块”都有“main”函数。
首先是模块中用于表示参与编码的WebAssembly版本。接下来是若干段(section),它们都包含了关于模块的信息。模块总是以包含了顺序的标准段开始,并且可选地结束于任何数量的可以包含任何类型数据的自定义段,所有数据都可以被标准的WASM虚拟机忽略。
在WASM版本1中的标准段如下,标记为星号(*)的段都是任何功能模块都必需的:
Type* — 函数签名声明 Import — 导入 声明 Function* — 函数声明 Table — 间接函数表和其他表 Memory — 存储器属性 Global — 全局声明 Export — 导出 Start — 开始函数声明 Element —元素段 Code* — 函数体 Data — 数据段
Type type 段包含了整个模块用到的唯一函数签名列表。也包含了任何导入函数的签名。 列表中的位置是type签名在模块中的唯一索引。例如:
(i32 i32 -> i32) // func_type #0
(i64 -> i64) // func_type #1
( -> ) // func_type #2
WebAssembly只有四种具体类型:32位整数,64位整数,32位浮点数和64位浮点数,其中整数类型是无符号的以及浮点数数字符合IEEE 754-2008标准。 任何复杂类型都可以由编译器构建在这些基本类型之上。 本文的其余部分以及 WebAssembly文档将分别使用简称i32,i64,f32和f64来引用这些基本类型。
Import import 段 通过列出每个函数,值或数据所需的模块名称,字段名称和类型来声明任何外部依赖性:
("dumb-math", "quadruple", (func_type 1)) // func #0
("dumb-math", "pi", (global_type i64 immutable))
由主机系统(例如Web浏览器)来解析这些导入,这是运行时动态链接在WASM中实现的方式,我们将在后面看到,这也用于与非WASM函数交互的外部函数接口。
Function function段 为之后定义于代码段的每一个函数声明索引,其中列表中的位置是函数的索引,及其值的类型。有效的函数索引始于func_type 导入的数, 这意味着模块中有效的函数列表是通过函数段列表过滤的import段列表。
(func_type 1) // func #1
(func_type 1) // func #2
(func_type 0) // func #3
我们随后会通过这些索引来调用这些函数(上面的"func #N",N就是函数的索引)。 Table table段 定义任意数量的 表。表是用于映射不能由WebAssembly表示或不能直接访问的不透明值的机制,例如JavaScript对象或操作系统文件句柄。此功能以边界检查表为代价间接的弥合了底层未受信线性内存和高级不透明句柄或引用。我们不会对表做过多的探究。 Memory memory段 通过定义其初始大小和可选地预期扩展的大小来定义模块的可选存储器。 数据段被用于初始化存储器。 Global global段 为模块声明任意数量的可变或不可变全局变量,等价于C和C++里的 static 变量。 Export export 段声明模块中能被主机环境访问的任意部分,不包含专门的启动函数。
`("half" (func 1))`
上面会把函数1导出为“half”。如果我们看看上面的function段, 可以看到func #1的类型是 func_type #1,所以主机环境看起来是这样:
`function half(arg0 :int64) :int64 `
除了函数之外,模块也将能table、memory段和global导出到外部环境。 Start start段 指定模块加载时要调用函数的索引,该机制可以使模块成为可执行程序,或者用来动态初始化模块的全局和memory段。 Element element段 允许模块初始化从外部导入或在 table段中定义的表的内容。
代码段可能是大多数WebAssembly模块的主体,因为它定义了模块中所有函数的全部代码。 函数体的定义顺序与它们在function段中的相应函数索引相同,但并不包括导入。 我们的“half”函数的body可以这样定义:
get_local 0 // 往栈上push 参数#0 (被除数)
i64.const 2 // 往栈上push int64常量 "2"(除数)
i64.div_u //无符号除法; 往栈上推送结果
end // ends 函数, 在栈顶产生一个i64
你可能已经猜到了: WebAssembly是一个堆栈机 ,这意味着我们可以从虚拟栈里push和pop值。大多数WASM操作符代码(简称“操作码”),采用and或or来为栈中添加明确定义的值。
让我们看看运行“half”函数代码的时候 栈上会发生什么 :
关于此图的一些说明:
这里有很多由WASM 1定义的数字操作符 ,就不在本文列出了,太多了。然而,你可能会欣慰的发现,除了你可能希望的所有基本的操作符之外,还有若干有用的通用数字操作码定义。比如:
还有一个通用的操作符转换库,用于在两个行为明确定义的类型之间进行转换。 最后同样重要的是数据段 ,用于初始化导入或本地内存:
(data_segment
0 // 线性内存索引
(init_expr (i32.const 4)) // 用于放置数据的字节偏移量
(data 0x2a 0x0 0x0 0x0))
我们已经了解WebAssembly使用值堆栈来保存和读取基本的值。还有几种方法可以管理模块中的数据和状态。
堆栈: 机器用来操作那些操作数的地方,并把它们的结果放在栈上。 局部变量(local) 和 全局变量(global) : 可命名,并且可以保存任何与栈相同的基本类型(i32,et al。) 存储器: 随机访问的字节数组,它可以存储任何我们想要的。 元素: 用于外部值的不透明“句柄”(如OS文件句柄)。 局部变量(Local)跟许多高级语言中的变量很相似: 我们可以命名它,用它存储和获取值。 我们使用function-local整数索引来命名它。
参数也是局部变量,局部变量的“名称”索引从第一个函数参数开始为0,并且随着每个附加参数以及函数体中每个列出的局部变量连续递增。局部变量总是初始化为零(所有的位都为0)。
在本文前面的示例中,我们使用操作get_local将第一个函数参数的值推送到了栈上。我们还可以执行相反的操作:从堆栈中弹出一个值并将其存储到局部变量。
i32.const 123 // 演示:把 "123" 推送到栈上
set_local 0 // 从栈上弹出 "123" ,并把它存储到局部变量 #0
// 使用堆栈进行其他操作...
get_local 0 // 把"123"推送到栈顶
局部变量可以在执行其他操作时保存临时值,否则这些操作会替换或移除栈中的值。
全局变量 是带有自己的索引和操作符的模块范围内的局部变量,但是除此之外还共享局部变量的语义。全局索引从任何导入的全局变量开始,并随着在全局段中定义的全局变量递增。我们可以使用get_global和set_global操作加载和存储全局值。
存储器 是最灵活的,因为它允许我们存储任何我们可以想象的数据,但更易使用::
“线性”存储器是指没有可用的随机操作符分配器,模块中的代码使用的所有内存地址都表示为从内存段开始的字节偏移。 WASM是一种低级格式,这样很有意义。它取决于更高级别的语言,目标是WASM在这个线性存储器空间之上提供内存管理(如果需要)。
元素(element) 是一些不透明的 “值句柄”,它是指主机环境的一些东西对于WebAssembly是不透明的。例如,当主机环境运行JavaScript,元素可能表示操作系统文件句柄或JavaScript对象。
元素虽然很酷, 但是 存储器充满了更多的乐趣,所以让我们再回头学习下存储器,看看它还能让我们做些什么。
未来版本的WASM可能允许使用多个memory段,但版本1的每个模块只能使用一个,这简化了很多事情,比如存储器操作符:
以下代码在字节范围[4…7]内存储i32“123”:
i32.const 4 // 地址的字节大小
i32.const 123 // 存储值
i32.store 2 0 // 2 = 32位/4字节对齐, 0 = 偏移
可以稍后将值加载到栈上:
i32.const 4
i32.load 2 0 // 该操作执行后栈顶就是 i32 "123"
内存的灵活性不仅在于我们可以加载过去存储的东西(当然你用局部变量也可以做相同的事),而且它在函数的生命周期内是持久的,这意味着我们可以跨函数访问值 ,将相同的数据解释为不同类型,并且以比局部变量、全局变量和堆栈使用更紧凑的方式按字节来存储值。
想象一下,我们想要调用以好基友命名的函数“Lolcat”。将名称编码为UTF-8文本,每个字符只需要1个字节(因此总共6个字节加上一些额外的标记或长度值)。一种简单又呆板的方式是将每个字符推送到栈上并且使被调用者接受一些预定数量的参数,例如, function(c0,c1,c2,c3,c4,c5 i32):void:
i32.const 0x4c // 'L'
i32.const 0x6f // 'o'
i32.const 0x6c // 'l'
i32.const 0x63 // 'c'
i32.const 0x61 // 'a'
i32.const 0x74 // 't'
call 1 // 调用函数,该函数需要6个参数
这不仅呆板的可笑,而且它还会占用大量栈空间——每一个“栈位”占用一个字。对于32位的系统(或32位虚拟机),这意味着“Lolcat”字符串中的每一个字母占据4个字节,使总数达到24个字节。另一个要考虑的事情是,栈虽然是抽象的但它是有限制的,如果我们将太多的值推送到堆栈中而不弹出,主机就会恐慌并Crash模块。
这是存储器的任务,假设我们调用的函数是一个只包含字符串存储器地址的参数,比如function (addr i32) :void。 首先,我们用“Lolcat”字符串初始化模块内存的一部分:
(data_segment 0
(init_expr (i32.const 4)) // 放置数据的字节偏移量
(data 0x4c 0x6f 0x6c 0x63 0x61 0x74 0x0))
注意在本例中,我们在字符串的末尾放置一个额外的零字节“标记”。 这在C语言中很常见,但通常不鼓励。 如果你正在写一个真正的程序,你可能想用字符串的长度作为前缀,而不是我们例子中的“标记字节”。
我们现在可以使用字符串的地址(4)调用函数:
i32.const 4 // 字节偏移作为参数传递到内存中...
call 1 // 调用接收函数
接收函数将从内存中加载所需的字符串的任何部分。 例如,假设它调用一个导入的“putc”函数(将一个字节放入程序stdout流):
void print_str(i32 addr) {
i32 byte = 0;
while (true) {
byte = i32.load8_u(addr);
if (byte == 0) { break; }
putc(byte);
addr = addr + 1;
continue;
}
}
在WASM中,我们声明了在函数中需要一个额外的局部变量(local #1;字节变量),并且现在可以实现等效功能:
block void // declares a "label" at it's "end"
loop void // declares a "label" right here
// byte = i32.load8_u(addr)
get_local 0 // push addr
i32.load8_u 0 0 // push the byte at addr as an i32
tee_local 1 // store the byte to local 1, but don't pop it
// if (byte == 0) { break }
i32.eqz // (x i32) => i32(x == 0 ? 1 : 0)
br_if 1 // if the byte was zero, jump to end of "block"
// putc(byte)
get_local 1 // push byte
call 0 // call imported "putc" function with the byte
// addr = addr + 1
get_local 0 // push addr
i32.const 1 // push i32 "1"
i32.add // push result from addr + 1
set_local 0 // store new address to "addr" local
// continue
br 0 // jump to "loop" (i.e. continue looping)
end
end // end of "block"
你可能已经注意到了,我们为加载和存储操作运算符提供了两个立即值。第一个立即值是一个对齐提示,会按2的幂次方编码为log2(alignment)。这意味着对齐立即值是以下值之一:0 = 8位,1 = 16位,2 = 32位和3 = 64位。
注意:如果存储器访问的有效地址是其要访问的对齐属性值的倍数,则认为存储器访问是对齐的,否则认为是未对齐的。对齐和未对齐的访问具有相同的行为。1
对齐提示可以认为是对执行我们代码的虚拟机的约定,即“有效地址将以N位对齐”,VM将使用该信息来优化代码。 当我们约定某种对齐,但未能遵守该约定,提供一个未对齐的地址时,操作将可能比我们提供了一个有效对齐地址更慢。
因此,作为经验法则,您应该只约定可以保留的内容 ,提示任何操作的最大可能的对齐,但不大于本机对齐(wasm32为32位,wasm64为64位)。
load和store运算符的第二个立即数是地址偏移量。 这确实有点困扰,所以让来我们一一澄清偏移到底是什么,有效的地址是什么,当我们存储和加载值时内存发生了什么。
这里是线性存储器的起始图示,初始化为零:
有效地址是从存储器起始处测量的字节偏移量,是地址操作数和偏移立即数之和。
`有效地址=地址操作数 + 偏移立即数`(`effective-address = address-operand + offset-immediate` )
你可能会问“如果我有一个地址操作数为什么还需要偏移立即数”? 这是一个好问题。正如在前面的例子中所看到的,我们可以为地址操作数提供一个常数值(例如使用i32.const)。 但是,当我们像之前的“print_str”函数中那样开始使用动态地址时,对于编译器来说是非常有用的,为模块函数的所有内存操作添加一个常量偏移量,以便从存储器的一个区域“重定位”到另一个区域。
WASM为四种基本类型提供了丰富的存储器操作集,允许读取一些字节数作为某种数字。 例如,我们可以通过将值截断为16位整数来存储两个字节的64位整数:
i32.const 3 // 地址操作数 = 3
i64.const 1234 // 值
i64.store16 1 3 // 对齐 = 1 = 16-bit, 偏移立即数 = 3
// 有效地址 = 3 + 3 = 6
由于我们提示了一个16位对齐,并且有效地址是2的偶数倍(2字节= 16位),所以这是一个优化的(对齐)存储。 我们的内存现在看起来像这样,存储的段被标记为深色背景颜色:
无论主机平台的本地“字节次序(Endianess)”是什么,WebAssembly总是对存储的值使用小端编码。 这意味着16位整数“1234”被编码为0xd4 0x04。
如果我们存储的是一个32位或64位的值,那么由于有效地址“6”位于32位和64位字的中间,存储将不对齐并且效率低下:
`i64.store32 2 3 // 对齐 = 2 = 32-bit, 偏移 = 3, 地址 = 6`
红色箭头代表存储对齐的约定(32位边界),我们必须增减2来改变我们的地址(即6±2)。
当有效地址处于约定对齐幅度的边界时,load或store是对齐的。 让我们调整偏移立即数以获得32位对齐存储:
`i64.store32 2 5 // 对齐 = 2 = 32-bit, 偏移 = 5, 地址 = 8`
我们可以自由解释任何字节为任何值。 例如,我们可以加载四个字节作为32位浮点数来表示上面存储的16位整数:
i32.const 4 // address-operand = 4
f32.load 2 4 // align = 2 = 32-bit, offset = 4, addr = 8
堆栈的顶部现在包含一个粗略表示数字“1.7292e-42”的f32,这可能不是你预期的。 记住,我们没有将一个数字从一种类型转换为另一种类型,我们只是将用于表示一种类型的原始数据解释为另一种类型。
如果我们的意图是将i32值加载为等效的f32数字,那么我们使用一个数字转换运算符作为目标类型:
i32.const 4 // address-operand = 4
i32.load 2 4 // align = 2 = 32-bit, offset = 4, addr = 8
f32.convert_u_i32
栈顶现在是一个值为“1234.0”的f32。
浮点数总是“有符号”,但整数可以无符号。 “但是,我们在本文一直在使用i32,并没有看到任何符号特定的运算符!”我们目前使用的大多数运算符只需要知道数据的大小,并不在乎该值是否被解释为有符号或无符号。 WebAssembly整数值基本上为未知符号 。
当然有几个操作需要知道它的操作数是有符号的还是无符号的。 例如,“小于”运算符需要知道它比较的是否为负数。
符号相关的运算符有一个_s’或_u’后缀。 这里有几个:
i32.lt_s // signed-i32 < 符号型-i32
i32.lt_u // unsigned-i32 < 无符号-i32
i32.div_s // signed-i32 / 符号型-i32
...
由于“WebAssembly”在其名称中带有“Web”提示,第一个有用的WebAssembly平台是最流行的Web浏览器的最前沿版本:Chrome,Firefox, Safari 和 Edge。 在撰写本文时,需要通过浏览器的高级设置启用WebAssembly。 在Chrome中,设置位于“flags”(chrome://flags/#enable-webassembly)下。
我不打算对此谈太多,因为已经有大量很好的JavaScript浏览器API的信息了。 这里有一个加载模块的示例,“实例化”它(运行它)并最终从JavaScript调用其导出的函数之一:
WebAssembly.compile(new Uint8Array(
`00 61 73 6d 0d 00 00 00 01 09 02 60 00 00 60 01
7f 01 7f 03 03 02 00 01 05 04 01 00 80 01 07 07
01 03 66 6f 6f 00 01 08 01 00 0a 3a 02 02 00 0b
35 01 01 7f 20 00 41 04 6c 21 01 03 40 01 01 01
0b 03 7f 41 01 0b 20 01 41 e4 00 6c 41 cd 02 20
01 1b 21 01 41 00 20 01 36 02 00 41 00 21 01 41
00 28 02 00 0f 0b 0b 0e 01 00 41 00 0b 08 00 00
00 00 2c 00 00 00`.split(/[\s\r\n]+/g).map(v => parseInt(v, 16))
)).then(mod => {
let m = new WebAssembly.Instance(mod)
console.log('foo(1) =>', m.exports.foo(1))
console.log('foo(2) =>', m.exports.foo(2))
console.log('foo(3) =>', m.exports.foo(3))
})
如果我们将它粘贴到启用了WebAssembly浏览器的控制台,我们应该看到模块做了一些简单计算的结果:
foo(1) => 400
foo(2) => 800
foo(3) => 1200
希望给你在过渡到WebAssembly驱动的应用程序过程中,对WASM如何与JavaScript进行实用有效的互操作有所提示。 也许你会在React和JavaScript中编写你的UI,但在WASM中编写一个像JPEG编码器或文件格式解析器的东西,让它们一起运行得很好。
我把一些用于和WASM协同工作的 TypeScript/JavaScript小部分代码集合称为 wasm-util。这些代码没有任何依赖,并且有一定程度的结构化,对于只需要某些功能的任务只需要选择几个源文件就可用了。
有关完整的功能列表,请参见 wasm-util readme。我发现自己依赖于一个非常复杂的工具链(llvm,binaryen等源码构建),而我所寻找的是倾向于WebAssembly的工具。 wasm-util的主要组件是ast,它提供了一种方便的方法来构建完整的WASM模块。如果使用TypeScript,则具有完全静态类型检查。
以下示例是构建提供阶乘函数的模块。 让我们以一个类似C语法的函数开始:
int64 factorial(int64 n) {
return (n == 0) ?
1
:
n * factorial(n-1);
}
等价的WebAssembly代码看起来像这样:
get_local 0 // push parameter #0 on stack.
i64.const 0 // push constant int64 "0" on stack.
i64.eq // execute "eq" which pops two operands from stack
// and pushes int32 "1" or "0" on stack.
if i64 // pops one int32 from stack; if its not "0":
i64.const 1 // push constant int64 "0" on stack.
else // else (if operand was "0"):
get_local 0 // push parameter #0 on stack. $1
get_local 0 // push parameter #0 on stack.
i64.const 1 // push constant int64 "0" on stack.
i64.sub // execute "sub[tract]" which pops two operands
// from stack (parameter #0 and constant int64 "1")
// and finally pushes the result int64 on stack.
call 0 // call function #0 ("factorial") which pops one
// int64 from the stack and when it returns an
// int64 has been pushed on stack
i64.mul // execute "sub[tract]" which pops two operands
// from stack ($1 and result from function call)
// and finally pushes the resulting int64 on stack
end // ends function, returning one int64 result (on stack.)
// Stack now contains one int64 value that's the result from one of
// the two branches above.
上面的代码是由lbtext打印的,我们提供了一个用 ast模块构建的AST:
import ... 'wasm-util/ast'
const mod = c.module([
type_section([
func_type([i64], i64), // type index = 0
]),
function_section([
varuint32(0), // function index = 0, using type index 0
]),
export_section([
// exports "factorial" as function at index 0
export_entry(str_ascii("factorial"), external_kind.function, varuint32(0)),
]),
code_section([
// body of function at index 0:
function_body([ /* additional local variables here */ ], [
if_(i64, // i64 = result type of `if` expression
i64.eq(get_local(i64, 0), i64.const(0)), // condition
[ // then
i64.const(1)
], [ // else
i64.mul(
get_local(i64, 0),
call(i64, varuint32(0), [ // 0 = function index
i64.sub(get_local(i64, 0), i64.const(1))
]))])])]
)]
)
我们现在可以通过Emittable 接口生成WASM字节码:
const emitbuf = new BufferedEmitter(new ArrayBuffer(mod.z))
mod.emit(emitbuf)
// the array buffer (emitbuf.buffer) now contains the complete module code
或者打印人类可读的AST文本表示:
import { strRepr } from 'wasm-util/repr'
console.log(strRepr(mod))
将在控制台生成以下内容:
(module 13
(section type 6 1
(func_type (i64) i64))
(section function 2 1 0)
(section export 13 1
(export_entry "factorial" external_kind.function 0))
(section code 25 1
(function_body 23 0
(if [i64]
(i64.eq
(get_local [0])
(i64.const [0])
)
(then
(i64.const [1]))
(else
(i64.mul
(get_local [0])
(call [0]
(i64.sub
(get_local [0])
(i64.const [1])
)))) end) end)))