1.前言
如果我们想把C/C++代码编译为WebAssembly,那十有八九就会用到Emscripten。在运行Emscripten相关编译指令后,我们可以得到wasm文件和js文件。如果在编译时添加对应的参数,我们还可以使用模板HTML或者将wasm文件放到web worker中执行。
其中Emscripten编译生成的js文件即是所谓的胶水代码,我们只需要在自己的项目中引入这段胶水代码,胶水代码就会帮我们加载wasm模块,并且将定义在C/C++中的函数绑定在全局变量上供我们调用。
本文主要简单的探索Emscripten胶水代码的相关逻辑,在阅读之前,读者最好对WebAssembly有所了解,并且有一定的使用Emscripten的经验。
2.加载wasm模块
在讲解Emscripten胶水代码之前,我们需要花一点时间去了解一般情况下如何加载wasm模块。根据环境的不同,加载wasm模块分为在浏览器中加载和在node环境中加载。
2.1.在浏览器中
2.1.1.以流的方式
在浏览器中,我们可以流的方式编译和实例化wasm模块,其中核心在于使用异步方法WebAssembly.instantiateStreaming(fetch, importObject)
:
(async () => {
const importObject = {
env: {
// 需要提供一个中止函数,如果断言失败就会调用这个中止函数
abort(_msg, _file, line, column) {
console.error('abort called at index.ts:' + line + ':' + column)
}
}
}
// 以流方式编译和实例化这些模块
const module = await WebAssembly.instantiateStreaming(
fetch('/wasm/optimized.wasm'),
importObject
)
// 如果在C/C++中定义了一个add函数,则可以通过如下方式获取
const Add = module.instance.exports.add
// ...
})()
2.1.2.先编译再实例化
WebAssembly.instantiateStreaming
一个方法就可以实现编译和实例化wasm模块,但实际上我们也可以先编译再实例化wasm模块,其主要逻辑是:
- 通过fetch获取arrayBuffer格式的wasm文件
- 使用异步方法WebAssembly.compile编译arrayBuffer获取 WebAssembly.Module 对象
- 再使用异步方法WebAssembly.Instance(WebAssembly.Module, importObject)生成实例
(async () => {
const importObject = {
env: {
// 需要提供一个中止函数,如果断言失败就会调用这个中止函数
abort(_msg, _file, line, column) {
console.error('abort called at index.ts:' + line + ':' + column)
}
}
}
// 先编译,再实例化
const res = await fetch('/wasm/optimized.wasm')
const bytes = await res.arrayBuffer()
const mod = await WebAssembly.compile(bytes)
const instance = new WebAssembly.Instance(mod, importObject)
const Add = instance.exports.add
// ...
})()
2.2.在node中
截止到node版本v14.17.3,node并没有提供类似于浏览器中WebAssembly.instantiateStreaming
的方法来以流的形式编译和实例化wasm模块。在node中,主要有同步和异步两种方式使用WebAssembly。
2.2.1.同步方式
该方式通过new WebAssembly.Module(fs.readFileSync(url))
编译wasm,再使用new WebAssembly.Instance(compiled, imports)
获取实例:
// sync.js
const fs = require('fs')
const path = require('path')
const url = path.resolve(__dirname, '../dist/wasm/optimized.wasm')
// WebAssembly.Module构造函数同步编译给定的 WebAssembly 二进制代码
// 返回 WebAssembly.Module 对象
const compiled = new WebAssembly.Module(fs.readFileSync(url))
const imports = {
env: {
abort(_msg, _file, line, column) {
console.error('abort called at index.ts:' + line + ':' + column)
}
}
}
// WebAssembly.Instance构造函数同步方式实例化一个WebAssembly.Module 对象
// compiled: 要被实例化的 WebAssembly.Module 对象
// imports 可选,一个包含值的对象,导入到新创建的 实例
// WebAssembly.Instance对象 exports属性返回一个对象,该对象包含从WebAssembly模块实例导出的所有函数作为其成员
module.exports = new WebAssembly.Instance(compiled, imports).exports
可以通过如下方式使用:
const addSync = require('./sync').add
console.log(addSync(3, 5))
2.2.2.异步方式
在异步方式下使用异步方法WebAssembly.compile(wasm)
编译wasm文件,再使用异步方法WebAssembly.instantiate(compiled, imports)
生成实例:
// async.js
const fs = require('fs')
const path = require('path')
const url = path.resolve(__dirname, '../dist/wasm/optimized.wasm')
const imports = {
env: {
abort(_msg, _file, line, column) {
console.error('abort called at index.ts:' + line + ':' + column)
}
}
}
function readFile (path) {
return new Promise((resolve, reject) => {
fs.readFile(path, (err, data) => {
if (err) reject(err)
resolve(data)
})
})
}
async function createWasm () {
const wasm = await readFile(url)
const compiled = await WebAssembly.compile(wasm)
const instanced = await WebAssembly.instantiate(compiled, imports)
return instanced.exports
}
module.exports = createWasm
使用如下:
const createWasm = require('./async')
{(async () => {
const { add: addAsync} = await createWasm()
console.log(addAsync(3, 5))
})()}
3.Emscripten胶水代码
胶水代码主要完成两个任务:
- 加载wasm模块
- 导出C/C++函数
3.1.加载wasm模块
胶水代码先尝试通过流的方式编译和创建wasm实例,不行的话再通过编译、实例化分开的方式创建wasm实例。
具体来说就是先尝试通过instantiateStreaming创建wasm实例。如果相关条件不满足使用instantiateStreaming,就先拉取wasm文件,再使用WebAssembly.instantiate创建wasm实例。然后将wasm实例的exports属性赋值给Module[‘asm’]以此暴露wasm中导出的方法。
在胶水代码中,有一个instantiateAsync
函数用于判断instantiateStreaming和fetch方法是否存在,以及wasm文件url是否是线上地址。如果满足前述条件,则通过fetch拉取wasm文件,并通过instantiateStreaming进行编译:
function instantiateAsync() {
if (!wasmBinary &&
typeof WebAssembly.instantiateStreaming === 'function' &&
!isDataURI(wasmBinaryFile) &&
// Don't use streaming for file:// delivered objects in a webview, fetch them synchronously.
!isFileURI(wasmBinaryFile) &&
typeof fetch === 'function') {
return fetch(wasmBinaryFile, { credentials: 'same-origin' }).then(function (response) {
var result = WebAssembly.instantiateStreaming(response, info);
return result.then(receiveInstantiationResult, function(reason) {
// We expect the most common failure cause to be a bad MIME type for the binary,
// in which case falling back to ArrayBuffer instantiation should work.
err('wasm streaming compile failed: ' + reason);
err('falling back to ArrayBuffer instantiation');
return instantiateArrayBuffer(receiveInstantiationResult);
});
});
} else {
return instantiateArrayBuffer(receiveInstantiationResult);
}
}
因为WebAssembly.instantiateStreaming
返回promsie,在胶水代码中,通过receiveInstantiationResult
函数处理该promsie的返回:
function receiveInstantiationResult(result) {
// ...
receiveInstance(result['instance']);
}
receiveInstantiationResult
函数调用receiveInstance
函数将wasm实例的exports挂载到window.Module.asm下:
function receiveInstance(instance, module) {
var exports = instance.exports;
Module['asm'] = exports;
// ...
}
如果WebAssembly.instantiateStreaming
处理失败或者instantiateStreaming方法、fetch方法不存在,或者wasm文件url不是线上地址,则通过instantiateArrayBuffer
方法加载wasm文件,并通过WebAssembly.instantiate创建wasm实例:
function instantiateArrayBuffer(receiver) {
return getBinaryPromise().then(function(binary) {
var result = WebAssembly.instantiate(binary, info);
return result;
}).then(receiver, function(reason) {
err('failed to asynchronously prepare wasm: ' + reason);
// Warn on some common problems.
if (isFileURI(wasmBinaryFile)) {
err('warning: Loading from a file URI (' + wasmBinaryFile + ') is not supported in most browsers. See https://emscripten.org/docs/getting_started/FAQ.html#how-do-i-run-a-local-webserver-for-testing-why-does-my-program-stall-in-downloading-or-preparing');
}
abort(reason);
});
}
instantiateArrayBuffer
尝试通过getBinaryPromise
方法获取arrayBuffer格式的wasm文件:
function getBinaryPromise() {
if (!wasmBinary && (ENVIRONMENT_IS_WEB || ENVIRONMENT_IS_WORKER)) {
if (typeof fetch === 'function'
&& !isFileURI(wasmBinaryFile)
) {
return fetch(wasmBinaryFile, { credentials: 'same-origin' }).then(function(response) {
if (!response['ok']) {
throw "failed to load wasm binary file at '" + wasmBinaryFile + "'";
}
return response['arrayBuffer']();
}).catch(function () {
return getBinary(wasmBinaryFile);
});
}
else {
if (readAsync) {
// fetch is not available or url is file => try XHR (readAsync uses XHR internally)
return new Promise(function(resolve, reject) {
readAsync(wasmBinaryFile, function(response) { resolve(new Uint8Array(/** @type{!ArrayBuffer} */(response))) }, reject)
});
}
}
}
// Otherwise, getBinary should be able to get it synchronously
return Promise.resolve().then(function() { return getBinary(wasmBinaryFile); });
}
getBinaryPromise函数首先检查能不能用fetch获取wasm文件,不能在通过其他方式比如xhr或者node环境下使用fs获取wasm文件
3.2.导出C/C++函数
在js中,我们可以通过五种方式调用C/C++的函数:
- Module.asm.函数名
- Module._函数名
- _函数名
- Module.ccall
- Module.cwrap
总的来说就是通过函数名或者ccall/cwrap两种方式调用C/C++的函数。
3.2.1.通过函数名
在Emscripten的胶水代码中,wasm实例的exports属性被赋值给Module[‘asm’],以此暴露wasm中导出的变量、方法。同时,在生成的胶水代码中,通过如下形式:
var _myFunction = Module["_myFunction"] = createExportWrapper("myFunction");
将wasm实例导出的函数暴露在Module对象和全局对象下,最后不仅可以通过 Module.asm.函数名 的方式进行调用,还可以通过 Module._函数名 或 _函数名 两种方式进行调用。
三种方式区别在于 Module.asm.函数名 是调用wasm实例上的函数,后两种方式其实是通过createExportWrapper
处理后返回的函数,实际是通过asm[name].apply(null, arguments)
的方式调用Module.asm下的函数:
function createExportWrapper(name, fixedasm) {
return function() {
var displayName = name;
var asm = fixedasm;
if (!fixedasm) {
asm = Module['asm'];
}
assert(runtimeInitialized, 'native function `' + displayName + '` called before runtime initialization');
assert(!runtimeExited, 'native function `' + displayName + '` called after runtime exit (use NO_EXIT_RUNTIME to keep it alive after main() exits)');
if (!asm[name]) {
assert(asm[name], 'exported native function `' + displayName + '` not found');
}
return asm[name].apply(null, arguments);
};
}
3.2.2.ccall / cwrap
Moudle下有一个方法ccall可以用于调用c/c++中定义的函数:
Module.ccall(
'myFunction', // name of C function
null, // return type
null, // argument types
null, // arguments
)
其代码如下:
// C calling interface.
/** @param {string|null=} returnType
@param {Array=} argTypes
@param {Arguments|Array=} args
@param {Object=} opts */
function ccall(ident, returnType, argTypes, args, opts) {
// For fast lookup of conversion functions
var toC = {
'string': function(str) {
var ret = 0;
if (str !== null && str !== undefined && str !== 0) { // null string
// at most 4 bytes per UTF-8 code point, +1 for the trailing '\0'
var len = (str.length << 2) + 1;
ret = stackAlloc(len);
stringToUTF8(str, ret, len);
}
return ret;
},
'array': function(arr) {
var ret = stackAlloc(arr.length);
writeArrayToMemory(arr, ret);
return ret;
}
};
function convertReturnValue(ret) {
if (returnType === 'string') return UTF8ToString(ret);
if (returnType === 'boolean') return Boolean(ret);
return ret;
}
var func = getCFunc(ident);
var cArgs = [];
var stack = 0;
assert(returnType !== 'array', 'Return type should not be "array".');
if (args) {
for (var i = 0; i < args.length; i++) {
var converter = toC[argTypes[i]];
if (converter) {
if (stack === 0) stack = stackSave();
cArgs[i] = converter(args[i]);
} else {
cArgs[i] = args[i];
}
}
}
var ret = func.apply(null, cArgs);
ret = convertReturnValue(ret);
if (stack !== 0) stackRestore(stack);
return ret;
}
function getCFunc(ident) {
var func = Module['_' + ident]; // closure exported function
assert(func, 'Cannot call unknown function ' + ident + ', make sure it is exported');
return func;
}
看起来比较复杂,但主要逻辑十分清晰:
- 调用getCFunc,根据传入参数获取定义在c/c++中的函数func
- 调用stackSave,保存栈指针
- 判断参数类型,如果是字符串或者数组,则通过对应处理函数将后续传递给func执行的参数转为内存地址
- 通过func.apply的方式,调用c/c++中的函数,获取返回值ret
- 调用convertReturnValue,根据returnType将返回值ret转为对应类型
- 调用stackRestore,恢复栈指针
ccall对参数(主要是字符串和数组)和返回值(主要是字符串和布尔值)做了转换,实际通过apply的方式调用Module._函数名。
ccall虽然封装了对字符串等数据类型的处理,但调用时仍然需要填入参数类型数组、参数列表等,为此cwrap进行了进一步封装:
var func = Module.cwrap(ident, returnType, argTypes);
// 参数:
// ident :C导出函数的函数名(不含“_”下划线前缀);
// returnType :C导出函数的返回值类型,可以为'boolean'、'number'、'string'、'null',分别表示函数返回值为布尔值、数值、字符串、无返回值;
// argTypes :C导出函数的参数类型的数组。参数类型可以为'number'、'string'、'array',分别代表数值、字符串、数组;
// 返回值:封装方法
后续使用时直接调用func函数,传入参数即可,不必传入参数类型和返回值类型等。
关于ccall和cwrap,后续会有另外一篇博客进行讲解,这里先按下不表。
4.注意
- 如果编译时参数有O3(比如通过命令
emcc ./index.c -o ./build/index.js -O3 -s WASM=1 -s
进行编译),则最终Module[‘asm’]下暴露的方法名是被压缩过的,不是定义在C/C++中的函数名 - 在C/C++中,只有被
EMSCRIPTEN_KEEPALIVE
修饰的函数才会被暴露在Module.asm下 - Emscripten通过与函数相同的方式处理C/C++中导出的变量,但由于实际开发中更多的是调用C/C++导出的函数,所以本文没有单独介绍Emscripten处理C/C++中导出的变量