基础为零?如何将 C++ 编译成 WebAssembly(二)

阅读数:15 2019 年 12 月 16 日 18:29

基础为零?如何将 C++ 编译成 WebAssembly(二)

如果是 Mac 电脑,遇到安全提示,在【系统偏好设置】-【安全与隐私】-【通用】里,找到“允许以下位置下载的 App”的配置,下方应该有提示信息,点击允许就可以了。

打出来包之后,可以用 file out/hello-wasi.wasm 命令检查一下生成的包格式对不对,有如下输出才是正确的,否则你打出来的很可能是个原生的二进制文件。

复制代码
hello-wasi.wasm: WebAssembly (wasm) binary module version 0x1 (MVP)

同样,可以用 wasm2wat 工具生成可读的文本格式,方便看代码:

复制代码
wasm2wat out/hello-wasi.wasm -o out/hello-wasi.wat

想要运行这个 wasm 包,需要有独立的运行时,理论上讲,所有实现了标准 wasi 接口的 runtime 都可以执行这个包。以 Wasmtime ( https://wasmtime.dev/ ) 为例,安装好后,用下方命令就可以执行这个 wasm 文件,会看到控制台有 Hello World! 的输出:

复制代码
wasmtime out/hello-wasi.wasm

另外,如果不想在电脑上装这些独立运行时,还有个神奇的网站( https://wasi.dev/polyfill/)可以在线运行基于 wasi 接口的 wasm 包。这个网站在浏览器环境里实现了一份 polyfill,对等实现了原生 wasm 运行时的一部分功能。把刚编译好的 hello-wasi.wasm 文件传上去,也可以看到 Hello World! 的输出。

基础为零?如何将 C++ 编译成 WebAssembly(二)

编译独立模块

然而在实际情况中,并不是所有包都想自执行,不一定都有 main 函数,大部分 wasm 包是想提供一些 api 供外部调用。自己打印 Hello World 没有任何意义,要和宿主环境有交互才行。

下面以斐波那契数列为例,介绍如何编译一个独立的 wasm 模块。C 语言代码如下:

复制代码
int fib (int n) {
if (n <= 0) return 0;
if (n <= 2) return 1;
return fib(n - 2) + fib(n - 1);
}

▐ 使用 Emscripten 编译

这次代码里没了 main 函数,只有一个 fib 函数,而 Emscripten 默认只导出 main 函数,所以在编译时加上 EXPORTED_FUNCTIONS 的配置指定导出的接口,其他同上:

复制代码
emcc fib.c -s EXPORTED_FUNCTIONS='["_fib"]' -O3 -o out/fib-emcc.wasm

编译 C/C++ 的时候函数名会默认加上 _ 前缀,所以导出的接口名是 _fib 而不是 fib 。

这次生成的包很小,把它转成文本格式后只有 27 行,代码如下:

基础为零?如何将 C++ 编译成 WebAssembly(二)

可以看到这个包只导出一个了 _fib 函数,函数接受 i32 数字为参数,返回一个 i32 数字。想要在 JS 环境里运行起来这个包,需要用 js 代码来加载执行这个包,可以封装如下函数:

复制代码
// 编译并实例化 wasm 模块,返回导出的接口
async function loadWebAssembly (filename, env) {
const filePath = path.resolve(__dirname, filename)
// 读入 wasm 文件的二进制代码
const buffer = fs.readFileSync(filePath)
// 将 wasm 包实例化并传入外部接口,因为没有外部依赖,不传 env 也可以的
const results = await WebAssembly.instantiate(buffer, {
env: Object.assign({
'__memory_base': 0,
'__table_base': 0,
memory: new WebAssembly.Memory({ initial: 256, maximum: 256 }),
table: new WebAssembly.Table({ initial: 0, maximum: 128, element: 'anyfunc' })
}, env)
})
// 返回实例化好之后的接口
if (results && results.instance) {
return results.instance.exports
}
}

然后使用这个函数加载 wasm 文件:

复制代码
loadWebAssembly('./out/fib-emcc.wasm').then(apis => {
console.log(apis._fib(13)) // 输出 233
})

完整代码: https://github.com/Hanks10100/cpp2wasm/blob/master/loader.js

▐ 使用 wasi-sdk 编译

这次 wasi-sdk 的编译选项稍微复杂了一些:

复制代码
~/wasi-sdk-8.0/bin/clang --sysroot ~/wasi-sdk-8.0/share/wasi-sysroot fib.c \
-nostartfiles -fvisibility=hidden -Wl,--no-entry,--export=fib \
-O3 -o out/fib-wasi.wasm

第一行和第三行其实没变,只是加了第二行指定导出的接口并添加其他的优化编译选项,具体每个字段的含义(我也不懂)我就不解释了,感兴趣的话自行搜索吧。

生成的包也很小,转成文本格式后代码如下:

基础为零?如何将 C++ 编译成 WebAssembly(二)

同样是内部定义了 fib 函数并 export 出来,同时还定义了 table 和 memory 而且把 memory 导出,table 是用来存放间接调用的函数表,memory 定义了初始内存大小,这个例子里并没用到,可删掉。

运行这个包的方式和上面一样,加上 --invoke 可以指定调用的接口,可以传递参数:

复制代码
wasmtime out/fib-wasi.wasm --invoke fib 7

上面的命令会输出 13 。

另外,因为这个例子没有外部依赖,所以生成的包对环境没什么要求,上面用 Emscripten 生成的 fib-emcc.wasm 这个包,也是可以用 Wasmtime 来执行的,调用方法一致:

复制代码
wasmtime out/fib-emcc.wasm --invoke _fib 13

简单分析

一个简单的 hello world 为什么编译出来这么多代码?编译工具到底干了啥?为什么还要有个 js 文件?

这些问题与 WebAssembly 的技术特点有关。WebAssembly 本质上讲就是一种二进制格式而已,一个 wasm 文件可以认为就是一个独立的模块,模块的包格式如下:
基础为零?如何将 C++ 编译成 WebAssembly(二)

开头是个固定的硬编码,然后是各种 section ⑫,所有 section 都是可选的,其中 type section 是声明函数类型的,还有 function, code, data 这些 section 都是封装内部逻辑的,start section 声明了自执行的函数,另外还有 table, global, memory, element 等。最需要外部关注的,与外界环境交互的是 import section 和 export section,分别定义了导入和导出的接口。

简单粗暴点讲 WebAssembly 只定义了导入和导出的接口和内部的运算逻辑,想要使用到宿主环境的能力,只能声明出依赖的接口,然后由宿主环境来注入。例如发送网络请求、读写文件、打日志等等,不同宿主环境中的接口是不一样的,wasm 包里声明了一套自己想要的接口,宿主环境在实例化 wasm 模块的时候,按照 wasm 自己定义的格式,把当前环境的真实接口传递给它。

以 hello.c 为例,它有对 <stdio.h> 头文件的依赖,虽然代码没有读写文件,但是头文件里包含了这类接口,所以生成的 wasm 包里声明了需要导入 io 相关的接口。下面是 Emscripten 生成的 wasm 包对应的文本描述(不含 -O3 优化),hello-emcc.wat 文件开头的一部分:

基础为零?如何将 C++ 编译成 WebAssembly(二)

可以看到它依赖宿主环境注册大量 env 接口,只有正确注入了这些接口才能确保 wasm 包可以正确的运行起来。在 Emscripten 同时生成的那个 js 文件里,就包含了这些接口的实现,在实例化 wasm 的时候自动注入进来,是它的内部逻辑,外部使用的时候不必关心。

这些接口都是什么玩意儿……?看起来像非标准的东西, __syscall140 和 __syscall6 分别是干啥的?不知道函数功能也不知道参数含义,不用 Emscripten 生成的 js 文件,完全不知道该怎么实例化这个 wasm 包,所以在运行 wasm 的时候就必须带上一份厚重的“js glue”。

再来看一下 wasi-sdk 生成的文件,需要导入的接口就可读多了:

基础为零?如何将 C++ 编译成 WebAssembly(二)
里面的 proc_exit 和 fd_write 等接口,就是 wasi 定义的标准接口 ⑬,只要宿主环境按照规范实现了这些接口,就可以运行这个 wasm 包。而且这些 wasi 接口也不是用 js 实现的,性能更好一些,也完全不依赖 js 引擎。

其实在 Emscripten 里生成的 __syscall140 就是要查找文件,基本等价于 fd_seek , __syscall6 基本等价于 fd_close ,但是前者没有语义而且非标准,强依赖 Emscripten 生成的 js 文件才能运行,而 wasi 接口就具备了更好的性能和跨平台能力。这就是标准化的力量,也是 WebAssembly 的一个发展方向。

性能对比

就不到十行代码还好意思做性能对比…… 我觉得低于 200 行代码跑出来的性能测试都不太靠谱。而且执行 js 和执行 wasm 的链路不一样,编译工具的优化程度不一样,编译出来的包依赖的接口也不一样,太多不确定性,测出的数据里都是噪声。在之后的文章里,我会用复杂的例子来测试 WebAssembly 的性能。(挖坑)

接下来干什么

文章写的很浅显,目的是让不懂 C/C++ 不懂 WebAssembly 可以快速入门。我觉得 WebAssembly 目前的一个问题是没有很明确、很具体的使用场景,大部分人都或多或少了解这个技术,知道整体的发展方向,但是觉得无从下手,最多是在某个环节中做小规模尝试。

我也尝试着把一个完整的 C++ 项目(约 2W+ 行代码)编译成了 WebAssembly,并且能在浏览器和 Node.js 环境里跑起来,只是为了深入研究 WebAssembly 这项技术,未必是一个很适合使用 WebAssembly 的场景。写 demo 和真的把 WebAssembly 用起来,中间的差距还是很大的,这篇文章是一个引子,我在下一篇文章里详细介绍一下我在过程中遇到的问题和解决方案。

文中链接:
1 https://webassembly.org/
2 https://github.com/WebAssembly/spec
3 https://developer.mozilla.org/en-US/docs/WebAssembly
4 https://hacks.mozilla.org/author/lclarkmozilla-com/
5 https://emscripten.org/index.html
6 https://wasi.dev/
7 https://github.com/bytecodealliance/wasmtime
8 https://github.com/bytecodealliance/wasm-micro-runtime
9 https://github.com/CraneStation/wasi-sdk
10 https://github.com/emscripten-core/emscripten/wiki/WebAssembly-Standalone
11 https://github.com/WebAssembly/wabt
12 http://webassembly.github.io/spec/core/binary/modules.html#sections
13 https://github.com/bytecodealliance/wasmtime/blob/master/docs/WASI-api.md

本文转载自淘系技术公众号。

原文链接: https://mp.weixin.qq.com/s/XrOHuoJB4vwkozBDI4t1yA

评论

发布