Emscripten胶水代码初探

1,914 阅读9分钟

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模块,其主要逻辑是:

  1. 通过fetch获取arrayBuffer格式的wasm文件
  2. 使用异步方法WebAssembly.compile编译arrayBuffer获取 WebAssembly.Module 对象
  3. 再使用异步方法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;
}

看起来比较复杂,但主要逻辑十分清晰:

  1. 调用getCFunc,根据传入参数获取定义在c/c++中的函数func
  2. 调用stackSave,保存栈指针
  3. 判断参数类型,如果是字符串或者数组,则通过对应处理函数将后续传递给func执行的参数转为内存地址
  4. 通过func.apply的方式,调用c/c++中的函数,获取返回值ret
  5. 调用convertReturnValue,根据returnType将返回值ret转为对应类型
  6. 调用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++中导出的变量

5.参考