WebAssembly 实践:如何写代码

63

本文不讨论 WebAssembly 的发展,只是一步一步地教你怎么写 WebAssembly 的各种 demo。文中给出的例子我都放在 GitHub 中了(仓库地址),包含了编译脚本和编译好的可执行文件,只需再有一个支持 WebAssembly 的浏览器就可以直接运行。

配置开发调试环境

安装编译工具

略。 参考官方 Developer’s GuideAdvanced Tools,需要安装的工具有:

安装过程挺繁琐的,得本地 clone 代码再编译。

安装浏览器

作为一个新技术,之所以说 WebAssembly 前途明媚,不仅是因为 W3C 成立了专门的 Webassembly Community Group,被标准认可;也是因为这次各大主流浏览器厂商(难得的)达成了一致,共同参与规范的讨论,在自家的浏览器里都实现了。

体验新技术,建议使用激进版浏览器,最新版本中都已经支持了 WebAssembly。

除了上边几个激进的浏览器,在主流版本里开启 flag 也是可以使用 WebAssembly 的:

  • Chrome: 打开 chrome://flags/#enable-webassembly,选择 enable

  • Firefox: 打开 about:configjavascript.options.wasm 设置为 true

快速体验 WebAssembly

想快速体验 WebAssembly ?最简单的办法就是找个支持 WebAssembly 的浏览器,打开控制台,把下列代码粘贴进去。

WebAssembly.compile(new Uint8Array(`
  00 61 73 6d  01 00 00 00  01 0c 02 60  02 7f 7f 01
  7f 60 01 7f  01 7f 03 03  02 00 01 07  10 02 03 61
  64 64 00 00  06 73 71 75  61 72 65 00  01 0a 13 02
  08 00 20 00  20 01 6a 0f  0b 08 00 20  00 20 00 6c
  0f 0b`.trim().split(/[\s\r\n]+/g).map(str => parseInt(str, 16))
)).then(module => {
  const instance = new WebAssembly.Instance(module)
  const { add, square } = instance.exports

  console.log('2 + 4 =', add(2, 4))
  console.log('3^2 =', square(3))
  console.log('(2 + 5)^2 =', square(add(2 + 5)))

})

里边这一坨奇怪的数字,就是 WebAssembly 的二进制源码。

运行结果

如果报错,说明你的浏览器不支持 WebAssembly ;如果没报错,代码的运行结果如下(还会返回一个 Promise):

2 + 4 = 6
3^2 = 9
(2 + 5)^2 = 49

其中 addsquare 虽然做的事情很简单,就是计算加法和平方,但那毕竟是由 WebAssembly 编译出来的接口,是硬生生地用二进制写出来的!

解释代码

上边的二进制源码一行 16 个数,有 4 行零两个,一共有 66 个数;每个数都是 8 位无符号十六进制整数,一共占 66 Byte。

WebAssembly 提供了 JS API,其中 WebAssembly.compile 可以用来编译 wasm 的二进制源码,它接受 BufferSource 格式的参数,返回一个 Promise。

那些代码里的前几行,目的就是把字符串转成 ArrayBuffer。先将字符串分割成普通数组,然后将普通数组转成 8 位无符号整数的数组;里的数字是十六进制的,所有用了 parseInt(str, 16)

new Uint8Array(
  `...`.trim().split(/[\s\r\n]+/g).map(str => parseInt(str, 16))
)

如果浏览器支持通过 <script type="module"> 的方式引入 wasm 文件,这些步骤都是多余的(他们有这个计划)。

然后,如果 WebAssembly.compile 返回的 Promise fulfilled 了,resolve 方法的第一个参数就是 WebAssembly 的模块对象,是 WebAssembly.Module 的实例。

然后使用 WebAssembly.Instance 将模块对象转成 WebAssembly 实例(第二个参数可以用来导入变量)。

通过 instance.exports 可以拿到 wasm 代码输出的接口,剩下的代码就和和普通 javascript 一样了。

注意数据类型

WebAssembly 是有明确的数据类型的,我那个例子里用的都是 32 位整型数(是不是看不出来…… 二进制里那些 7f 表示 i32 指令,意思就是32位整数),所以用 WebAssembly 编译出来的时候要注意数据类型。

如果你乱传数据,WebAssembly 程序也不会报错,因为在执行时会被动态转换(dynamic_cast),它支持传递模糊类型的数据引用。但是你如果给函数传了个字符串或者超大的数,具体会被转成什么就说不清了,通常是转成 0。

console.log(square('Tom')) // 0
console.log(add(2e+66, 3e+66)) // 0
console.log(2e+66 + 3e+66) // 5e+66

想了解更多关于数据类型的细节,可以参考:Data Types

把 C/C++ 编译成 WebAssembly

有一个在线 C++ 转 wasm 的工具: WasmExplorer

二进制代码简直不是人写的😂,还有其他方式能写 WebAssembly 吗?

有,那就是把其他语言编译成 WebAssembly 的二进制。想实现这个效果,不得不用到各种编译工具了。其中一个比较关键的工具是 Emscripten,它基于 LLVM ,可以将 C/C++ 编译成 asm.js,使用 WASM 标志也可以直接生成 WebAssembly 二进制文件(后缀是 .wasm)。

         Emscripten
source.c   ----->  target.js

     Emscripten (with flag)
source.c   ----->  target.wasm

工具如何安装就不讲了,在此只提醒一点:emcc 在 1.37 以上版本才支持直接生成 wasm 文件。

编写 C 代码

项目代码地址

首先新建一个 C 语言文件,假设叫 math.c 吧,在里边实现 addsquare 方法:

// math.c

int add (int x, int y) {
  return x + y;
}

int square (int x) {
  return x * x;
}

然后执行 emcc math.c -Os -s WASM=1 -s SIDE_MODULE=1 -o math.wasm 就可以生成 wasm 文件了。

代码解释

C 语言代码一目了然,就是写了两个函数,由于 C 语言里的函数都是全局的,这两个函数默认都会被模块导出。

不知道你有没有注意到,这个文件里没写 main 函数!没写入口函数,它自身什么也执行不了,但是可以把它当成一个库文件使用,所以我在也是用模块的方式编译生成的 wasm 文件。

在 WebAssembly 官方给出的例子中,是写了 main 函数,而且是直接把 C 文件编译生成了 html + js + wasm 文件,实际上是生成了一个可以运行 demo,简单粗暴。生成的代码体积比较大,很难看懂里边具体做了什么。为了代码简洁,我这里只是生成 wasm 模块,没有其他多余文件,要想把它运行起来还需要自己写 html 和 js 读取并执行 wasm 文件。(完整代码

如果你也想直接生成可用的 demo,你可以再写个 main 函数,然后执行 emcc math.c -s WASM=1 -o math.html 就可以了。

如何运行 WebAssembly 二进制文件?

现在有了 wasm 文件,也有了支持 WebAssembly 的浏览器,怎么把它运行起来呢?

目前只有一种方式能调用 wasm 里的提供接口,那就是:用 javascript !

官方网站中有一篇 Understanding the JS API 介绍了如何用 JS API 加载并执行 wasm 文件,写的比较粗略。

WebAssembly 目前只设计也只实现了 javascript API,就像我刚开始提供的那个例子一样,只有通过 js 代码来编译、实例化才可以调用其中的接口。这也很好的说明了 WebAssembly 并不是要替代 javascript ,而是用来增强 javascript 和 Web 平台的能力的。我觉得 WebAssembly 更适合用于写模块,承接各种复杂的计算,如图像处理、3D运算、语音识别、视音频编码解码这种工作,主体程序还是要用 javascript 来写的。

编写加载函数 (loader)

在最开始的例子里,已经很简化的将执行 WebAssembly 的步骤写出来了,其实就是 【加载文件】->【转成 buffer】->【编译】->【实例化】。

function loadWebAssembly (path) {
  return fetch(path)                   // 加载文件        
    .then(res => res.arrayBuffer())    // 转成 ArrayBuffer
    .then(WebAssembly.instantiate)     // 编译 + 实例化
    .then(mod => mod.instance)         // 提取生成都模块
}

代码其实很简单,使用了 Fetch API 来获取 wasm 文件,然后将其转换成 ArrayBuffer,然后使用 WebAssembly.instantiate 这个一步到位的方法来编译并初始化一个 WebAssembly 的实例。最后一步是从生成的模块中提取出真正的实例对象。

完成了上边的操作,就可以直接使用 loadWebAssembly 这个方法加载 wasm 文件了,它相当于是一个 wasm-loader ;返回值是一个 Promise,使用起来和普通的 js 函数没什么区别。从 instance.exports 中可以找到 wasm 文件输出的接口。

loadWebAssembly('path/to/math.wasm')
  .then(instance => {
    const { add, square } = instance.exports
    // ...
  })

返回 Promise 不只是因为 fetch 函数,即使像最开始的例子那样把二进制硬编码,也必须要用 Promise 。因为 WebAssembly.compileWebAssembly.instantiate 这些接口都是异步的,本身就返回 Promise 。

更完整的加载函数

如果你直接使用上边那个 loadWebAssembly 函数,有可能会执行失败,因为在 wasm 文件里,可能还会引入一些环境变量,在实例化的同时还需要初始化内存空间和变量映射表,也就是 WebAssembly.MemoryWebAssembly.Table

/**
 * @param {String} path wasm 文件路径
 * @param {Object} imports 传递到 wasm 代码中的变量
 */
function loadWebAssembly (path, imports = {}) {
  return fetch(path)
    .then(response => response.arrayBuffer())
    .then(buffer => WebAssembly.compile(buffer))
    .then(module => {
      imports.env = imports.env || {}

      // 开辟内存空间
      imports.env.memoryBase = imports.env.memoryBase || 0
      if (!imports.env.memory) {
        imports.env.memory = new WebAssembly.Memory({ initial: 256 })
      }

      // 创建变量映射表
      imports.env.tableBase = imports.env.tableBase || 0
      if (!imports.env.table) {
        // 在 MVP 版本中 element 只能是 "anyfunc"
        imports.env.table = new WebAssembly.Table({ initial: 0, element: 'anyfunc' })
      }

      // 创建 WebAssembly 实例
      return new WebAssembly.Instance(module, imports)
    })
}

这个 loadWebAssembly 函数还接受第二个参数,表示要传递给 wasm 的变量,在初始化 WebAssembly 实例的时候,可以把一些接口传递给 wasm 代码。

调用 wasm 导出的接口

有了 loadWebAssembly 就可以调用 wasm 代码导出的接口了。

loadWebAssembly('./math.wasm')
  .then(instance => {
    const add = instance.exports._add
    const square = instance.exports._square

    console.log('2 + 4 =', add(2, 4))
    console.log('3^2 =', square(3))
    console.log('(2 + 5)^2 =', square(add(2 + 5)))
  })

比较奇怪的一点是,用 C/C++ 导出的模块,属性名上默认都带了 _ 前缀,asm.js 转成了 wasm 模块就不带。

在浏览器中的运行效果

参考刚才用 C 语言写出来的项目(代码地址),直接用浏览器打开 index.html 即可。能看到这样的输出(我使用的是 Chrome Canany 浏览器):

Preview in Browser

如果你打开开发者工具的 Source 面板,能够看到 wasm 的源代码,浏览器已经将二进制转换成了对等的文本指令)。

View Source

虽然是一个 wasm 文件,浏览器将它解析成了两个(也有可能更多),是因为我们输出了两个接口,每个文件都对应了一个接口的定义。可以理解为 Canary 浏览器为了方便看源码实现的 sourcemap 功能。

把 asm.js 编译成 WebAssembly

项目代码地址

刚才也介绍了 Emscripten 可以将 C/C++ 编译成 asm.js ,这是它的默认功能,加上 flag 才能生成 wasm 。

asm.js 是 javascript 的子集,是一种语法(不是一个前端工具库!),用了很多底层语法来标注数据类型,目的是提高 javascript 的运行效率,本身就是作为 C/C++ 编译的目标设计的(不是给人写的),可以理解为一种中间表示层语法 (IR, Intermediate Representation)。asm.js 出生于 WebAssembly 之前, WebAssembly 借鉴了这个思路,做的更彻底一些,直接跳过 javascript ,设计了一套新的平台指令。

编写 asm.js 代码

// math.js

function () {
  "use asm";

  function add (x, y) {
    x = x | 0;
    y = y | 0;
    return x + y | 0;
  }

  function square (x) {
    x = x | 0;
    return x * x | 0;
  }

  return {
    add: add,
    square: square
  };
}

上边定义了一个函数,并且声明了 "use asm",这样一来,这个函数就会被视为 asm.js 的模块,里边可以添加方法,通过 return 暴露给外部使用。

不过,目前只有 asm.js 才能转成 wasm,普通 javascript 是不行的! 因为 javascript 是弱类型语言,用法也比较灵活,本身就很难编译成强类型的指令。

使用 Binaryen 和 WABT

虽然 Emscripten 能生成 asm.js 和 wasm ,但是却不能把 asm.js 转成 wasm 。因为它是基于 LLVM 的,然而 asm.js 没法编译成 LLVM IR (Intermediate Representation)。想要把 asm.js 编译成 WebAssembly,就要用到他们官方提供的 BinaryenWABT (WebAssembly Binary Toolkit) 工具了。

原理和编译方法参考官方文档,整个过程大概是这样的:

        Binaryen             WABT
math.js   --->   math.wast   --->   math.wasm

用脚本描述大概是这样:

asm2wasm math.js -o math.wast
wast2wasm math.wast -o math.wasm

wast 是什么格式?

WebAssembly 除了定义了二进制格式以外,还定义了一份对等的文本描述。官方给出的是线性表示的例子,而 wast 是用 S-表达式(s-expressions) 描述的另一种文本格式。

上边的 asm.js 代码编译生成的 wast 文件是这样的:

(module
  (export "add" (func $add))
  (export "square" (func $square))

  (func $add (param $x i32) (param $y i32) (result i32)
    (return
      (i32.add
        (get_local $x)
        (get_local $y)
      )
    )
  )

  (func $square (param $x i32) (result i32)
    (return
      (i32.mul
        (get_local $x)
        (get_local $x)
      )
    )
  )
)

和 lisp 挺像的,反正比二进制宜读多了😂。能看出来最外层声明了是一个模块,然后导出了两个函数,下边紧接着是两个函数的定义,包含了参数列表和返回值的类型声明。如果对这种类似 lisp 的语法比较熟悉,完全可以手写 wast 嘛,只要装个 wast2wasm 小工具就可以生成 wasm 了。或者在这个在线 wast -> wasm 转换工具 里写 wast 代码,可以实时预览编译的结果,也可以下载生成的 wasm 文件。

在 WebAssembly 中调用 Web API

在 js 里能调用 wasm 里定义的方法,反过来,wasm 里能不能调用 javascript 写的方法呢?能不能调用平台提供的方法(Web API)呢?

当然是可以的。不过在 MVP (Minimum Viable Product) 版本里实现的功能有限。要想在 wasm 里调用 Web API,需要在创建 WebAssembly 实例的时候把 Web API 传递过去才可以,具体做法可以参考上边写的那个比较复杂的 loader。(通过 WebAssembly.Table 传变量相当麻烦)。

向 wasm 中传递 js 变量

在有了 loadWebAssembly 这个方法之后,就可以给 wasm 代码传递 js 变量和函数了。

const imports = {
  Math,
  objects: {
    count: 2333
  },
  methods: {
    output (message) {
      console.log(`-----> ${message} <-----`)
    }
  }
}

loadWebAssembly('path/to/source.wasm', imports)
  .then(instance => {
    // ...
  })

上边的代码里给 wasm 模块传递了三个对象: Mathobjectsmethods,分别对应了 Web API 、普通 js 对象、使用了 Web API 的 js 函数。属性名和变量名都并没什么限制,是可以随便起的,把它传递给 loadWebAssembly 方法的第二个参数就可以传递到 wasm 模块中了。

真正实现传递的是 loadWebAssembly 的这行代码:

new WebAssembly.Instance(module, imports)

获取并使用从 js 传递的变量

既然 wasm 的代码最外层声明的是一个模块,我们能向外 export 接口,当然也可以 import 接口。完整代码如下:

(module
  (import "objects" "count" (global $count f32))
  (import "methods" "output" (func $output (param f32)))
  (import "Math" "sin" (func $sin (param f32) (result f32)))

  (export "test" (func $test))
  (func $test (param $x f32)
    (call $output (f32.const 42))
    (call $output (get_global $count))
    (call $output (get_local $x))
    (call $output
      (call $sin
        (get_local $x)
      )
    )
  )
)

这段代码也是在最外层声明了一个 module,然后前三行是 import 语句。首先从 objects 中导入 count 属性,并且在代码里声明为全局的 $count 变量,格式是 32 位浮点数。

(import "objects" "count" (global $count f32))

然后从 methods 中导入 output 方法,声明为一个接受 32 位浮点数作为参数的函数 $output

(import "methods" "output" (func $output (param f32)))

最后从 Math 中导入 sin 方法,声明为一个接受 32 位浮点数作为参数的函数 $sin,返回值也是 32 位浮点数。这样一来就把 js 传递的对象转成了自身模块中可以使用变量。

(import "Math" "sin" (func $sin (param f32) (result f32)))

调用 js 中定义的接口

接下来是定义并且导出了一个 test 函数,接受一个 32 位浮点数作为参数。在 wast 的语法里 call 指令用来调用函数,get_global 用来获取全局变量的值,get_local 用来获取局部变量的值,只能在函数定义中使用。这样来看,test 函数 里执行了四条命令,首先调用 $output 输出了一个常量 42;然后调用 $output 输出全局变量 $count ,这个值是通过 import 获取来的;接着又输出了函数的参数 $x;最后输出了函数参数 $x 调用 Web API $sin 计算后的结果。

  (func $test (param $x f32)
    (call $output (f32.const 42))
    (call $output (get_global $count))
    (call $output (get_local $x))
    (call $output
      (call $sin
        (get_local $x)
      )
    )
  )

编译执行

通过 west2wasm source.wast -o source.wasm 可以生成 wasm 文件,然后使用 loadWebAssembly 编译 wasm 文件。

loadWebAssembly('path/to/source.wasm', imports)
  .then(instance => {
    const { test } = instance.exports
    test(2333)
  })

会得到如下结果:

-----> 42 <-----
-----> 666 <-----
-----> 2333 <-----
-----> 0.9332447648048401 <-----

代码虽然简单,但是实现了向 wasm 中传递变量,并且能在 wasm 中调用 Mathconsole 这种平台接口。如果想要绕过 javascript 直接给 wasm 传参,或者在 wasm 里直接引用 DOM API,就得看他们下一步的计划了。参考 GC / DOM / Web API Integration

结语

根据这篇《如何画马》的教程,相信你很快就能用 WebAssembly 写出来 Angry Bots 这样的游戏啦~ 💪

如何画马

你可能感兴趣的

30 条评论
shauvet · 2017年03月02日

最后一段结语说明了真相。。。

+4 回复

李惟 · 2017年02月20日

没懂,这个用来做什么的?web game吗?上面的事例代码,CHROME 56 运行报错。。。。

回复

0

你没打开webassembly的权限

zhuyingda · 2017年02月24日
0

@zhuyingda 怎么开?

李惟 · 2017年02月24日
0

@李惟 在文中一开始就说了的

苍穹自由无限 · 2017年05月04日
40DU · 2017年02月28日

在Emscripten SDK的网站上只看到1.35版本的然后下载之后试着compile了一个.c的文件虽然出来了wasm文件但是里面的内容似乎包括了很多别的、另外github上的c/c++例子并不能运行、不知道我是不是少安装了什么东西?求博主解答

回复

0

我文章里提了,用 1.37 版才能直接编译生成 wasm ,需要手动升级 emcc 版本。我是只编译了模块,不是直接输出整个可用的 js + html,在 C/C++ 【代码解释】那部分也提了。其实我是用的 standalone build ,参考 https://github.com/kripken/em...

Hanks10100 作者 · 2017年02月28日
0

浏览器需要开启 flag 才能支持 WebAssembly,如果是 Canary 报的错,那可能是最新的实现和编译的二进制有些不匹配,目前功能还不稳定,我升级到最新版也遇到个错误,换其他浏览器试试,Mac 下用 Safari Technology Preview ,Windows 的话可以用 Firefox Nightly

Hanks10100 作者 · 2017年02月28日
0

我尝试了手动升级emcc但是虽然能检测到1.37版本但是每当我install latest的时候总会安装1.35版本.

40DU · 2017年02月28日
Ming_Up · 2017年04月12日

版本 55.0.2883.87 m (64-bit), 已经打开了webassembly,但是发现还是报错了。
WebAssembly.compile(new Uint8Array(`
00 61 73 6d 01 00 00 00 01 0c 02 60 02 7f 7f 01
7f 60 01 7f 01 7f 03 03 02 00 01 07 10 02 03 61
64 64 00 00 06 73 71 75 61 72 65 00 01 0a 13 02
08 00 20 00 20 01 6a 0f 0b 08 00 20 00 20 00 6c
0f 0b`.trim().split(/[srn]+/g).map(str => parseInt(str, 16))
)).then(module => {
const instance = new WebAssembly.Instance(module)
const { add, square } = instance.exports

console.log('2 + 4 =', add(2, 4))
console.log('3^2 =', square(3))
console.log('(2 + 5)^2 =', square(add(2 + 5)))

})
Promise {[[PromiseStatus]]: "rejected", [[PromiseValue]]: Error: WebAssembly.compile(): Wasm decoding failedResult = expected version 0c 00 00 00, found 01 00…}
VM81:1 Uncaught (in promise) Error: WebAssembly.compile(): Wasm decoding failedResult = expected version 0c 00 00 00, found 01 00 00 00 @+4

at <anonymous>:1:13

(anonymous) @ VM81:1

回复

0

请问你的解决了么?我用上面的命令得出的wasm文件,但是运行就报:CompileError: Wasm decoding failedResult = expected magic word 00 61 73 6d, found 42 43 c0 ……,然后我用WASMExport(http://mbebenita.github.io/Wa...来进行编译,把wasm文件下载下来,最后就能成功,但是它会把原来的名字给自己重命名。比如square变成了_Z6squarei。这挺蛋疼的,还得去函数名

苍穹自由无限 · 2017年05月04日
0

添加一个stackoverflow的地址:http://stackoverflow.com/ques...

苍穹自由无限 · 2017年05月04日
0

如果是报的 CompileError: Wasm decoding ...expected version 0x ... 很可能是浏览器实现有些问题,因为 wasm 文件的前边有一个字节描述的是版本,之前浏览器各自指定的,现在已经固定成 0x01 了,旧版本的浏览器可能不支持 0x01,最新版都改过来了。

Hanks10100 作者 · 2017年05月04日
messset · 2017年06月05日

是否能请教手动升级emcc至1.37的管道及步骤
我试了https://github.com/juj/emsdk
http://webassembly.org.cn/get...
都遇到些问题
感谢!

回复

0

如果有错的话,那得看错误是什么了,逐步修正。链接文章里写的步骤都是对的,另外还可以参考 MDN 的文章 https://developer.mozilla.org...

Hanks10100 作者 · 2017年06月06日
0

应该是工作地方有挡些网络通讯
后来在自己电脑装成功了
再把档案拷贝过来用
谢谢了!

messset · 2017年06月08日
猹少年 · 2017年07月07日

在加载的js里会报错,return fetch(path) 这一行报错,URL scheme must be "http" or "https" for CORS request.

回复

glin · 2017年09月10日

安装编译工具那里太麻烦了,emsdk install 实在慢

回复

小透明 · 2017年10月10日

emsdk install 是噩梦..根本无法安装成功.怎么办啊大佬

回复

0

你需要科学上网

JefferyLiang · 1月10日
0

翻墙安装,安装过程中还可能要根据错误提示手动下载一些包放在对应目录

蜗牛 · 1月10日
童永亮 · 6月19日

你已经用于生产了吗

回复

天凉好个秋 · 7月27日

// 创建 WebAssembly 实例

                return new WebAssembly.Instance(module, imports)

这步报错了啊,求指教
Uncaught (in promise) LinkError: WebAssembly Instantiation: table import 0 is smaller than initial 4, got 0

回复

0

上一行改成 imports.env.table = new WebAssembly.Table({ initial: 4, element: 'anyfunc' })
又报错:Uncaught (in promise) LinkError: WebAssembly Instantiation: Import #3 module="env" function="abort" error: function import requires a callable

天凉好个秋 · 7月27日
0

懂了,imports.env.abort 要声明下,但不懂为什么我就要声明这个。。。

天凉好个秋 · 7月27日
0

imports.env.abort 如何进行初始化

客渡 · 9月12日
triton · 8月7日

请问大佬现在项目里面还有更好的实践么?

回复

路过风雨有傲气 · 5 天前

请问大佬,wasm文件怎么在vue里面引用?vue框架能加载wasm文件吗?

回复

载入中...