本文主要包括以下内容:

  • JS 通过 wasm 调用 C/C++ 的方法和原理
  • JS 外部函数调用的类型错误隐患
  • JS/C 内存管理:单向透明的内存模型

JS 调用 C/C++ 的一种方式是通过 WebAssembly,假设有 C++ 文件 function.cpp 如下:

#include <math.h>

extern "C" {

int int_sqrt(int x) {
    return sqrt(x);
}

}

由于 C/C++ 函数名映射到 JS 端时默认是在前面加一个下划线,所以使用 extern "C" 避免 C++ 的名称重整(name mangling)。

使用 emscripten 编译(文档):

emcc function.cpp -o function.html -s EXPORTED_FUNCTIONS='["_int_sqrt"]' -s EXPORTED_RUNTIME_METHODS='["ccall","cwrap"]'

EXPORTED_FUNCTIONS 指明需要暴露的对象,EXPORTED_RUNTIME_METHODS 指明除直接调用外,还想要使用运行时函数 ccallcwrap

编译后生成了一个 .js 文件,一个 .wasm 文件和一个 .html 文件。function.js 中有这样的胶水代码:

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);
  };
}

/** @type {function(...*):?} */
var _int_sqrt = Module["_int_sqrt"] = createExportWrapper("int_sqrt");
};

其中 Module 是 Emscripten 运行时的全局对象,根据名字 int_sqrt 去由 C++ 编译得到的 function.wasm 中找对应的对象并执行:

(func (;1;) (type 0) (param i32) (result i32)
    (local i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 f64 f64 f64)
    global.get 0
    local.set 1
    i32.const 16
    local.set 2
    local.get 1
    local.get 2
    i32.sub
    local.set 3
    local.get 3
    global.set 0
    local.get 3
    local.get 0
    i32.store offset=12
    local.get 3
    i32.load offset=12
    local.set 4
    local.get 4
    call 2
    local.set 13
    local.get 13
    f64.abs
    local.set 14
    f64.const 0x1p+31 (;=2.14748e+09;)
    local.set 15
    local.get 14
    local.get 15
    f64.lt
    local.set 5
    local.get 5
    i32.eqz
    local.set 6
    block  ;; label = @1
      block  ;; label = @2
        local.get 6
        br_if 0 (;@2;)
        local.get 13
        i32.trunc_f64_s
        local.set 7
        local.get 7
        local.set 8
        br 1 (;@1;)
      end
      i32.const -2147483648
      local.set 9
      local.get 9
      local.set 8
    end
    local.get 8
    local.set 10
    i32.const 16
    local.set 11
    local.get 3
    local.get 11
    i32.add
    local.set 12
    local.get 12
    global.set 0
    local.get 10
    return)

(export "int_sqrt" (func 1))

所以在 JS 端直接调用就是 _int_sqrt(10)Module._int_sqrt(10),这种方式更快,但是需要自己保证传入参数的类型和 wasm 实现相匹配(如此例中的 i32)。也可以使用 ccall 调用:

var result = Module.ccall('int_sqrt', // C function name
                          'number',   // return type
                          ['number'], // argument types
                          [10]        // arguments
);

ccall 实现如下:

// 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 writeArrayToMemory(array, buffer) {
  assert(array.length >= 0, 'writeArrayToMemory array must have a length (should be an array or typed array)')
  HEAP8.set(array, buffer);
}

给出了入参为 stringarray 类型时分配 JS 堆的方式,外部函数 stackAlloc 计算存储位置:

/** @type {function(...*):?} */
var stackAlloc = Module["stackAlloc"] = createExportWrapper("stackAlloc");

其 wasm 实现如下:

(func (;5;) (type 0) (param i32) (result i32)
  (local i32 i32)
  global.get 0
  local.get 0
  i32.sub
  i32.const -16
  i32.and
  local.tee 1
  global.set 0
  local.get 1)

(export "stackAlloc" (func 5))

这被称作单向透明的内存模型,即 C/C++ 编译得到的 wasm 的运行时堆和运行时栈全部在 JS 堆(Module.buffer)上,JS 环境中的其他对象无法被 wasm 直接访问。

cwrap 是对 ccall 的封装,方便多次调用:

int_sqrt = Module.cwrap('int_sqrt', 'number', ['number']);
int_sqrt(10);

cwrap 实现如下:

/** @param {string=} returnType
    @param {Array=} argTypes
    @param {Object=} opts */
function cwrap(ident, returnType, argTypes, opts) {
  return function() {
    return ccall(ident, returnType, argTypes, arguments, opts);
  }
}

此外我们可以看到不管是直接调用还是通过 ccall 调用,对于参数个数都没有检查,同时对于非 stringarray 类型的参数也没有类型检查(stringarray 类型的检查机制需要进一步考察内部的函数调用)。

此例接收一个整型参数,我们构造如下的错误:

var result = Module.ccall('int_sqrt',
                          'number',
                          ['number'],
                          ['10']
);
console.log(result); // 1

int_sqrt = Module.cwrap('int_sqrt', 'number', ['number']);
console.log(int_sqrt(10, 5)); // 2

console.log(_int_sqrt('a')); // 3

程序点 1 输出 3(10 的平方根取整),这与 JS 的弱类型是一致的;程序点 2 输出 3 是因为没有参数个数检查,直接忽略了第二位置开始的所有参数;程序点 3 输出 0。三者都没有报错。

我们进一步探究一下内存模型。

设计 C++ 程序如下:

#ifndef EM_PORT_API
#	if defined(__EMSCRIPTEN__)
#		include <emscripten.h>
#		if defined(__cplusplus)
#			define EM_PORT_API(rettype) extern "C" rettype EMSCRIPTEN_KEEPALIVE
#		else
#			define EM_PORT_API(rettype) rettype EMSCRIPTEN_KEEPALIVE
#		endif
#	else
#		if defined(__cplusplus)
#			define EM_PORT_API(rettype) extern "C" rettype
#		else
#			define EM_PORT_API(rettype) rettype
#		endif
#	endif
#endif

#include <stdio.h>

int g_int = 42;
double g_double = 3.1415926;

EM_PORT_API(int*) get_int_ptr() {
	return &g_int;
}

EM_PORT_API(double*) get_double_ptr() {
	return &g_double;
}

EM_PORT_API(void) print_data() {
	printf("C{g_int:%d}\n", g_int);
	printf("C{g_double:%lf}\n", g_double);
}

JS 调用和操作如下:

Module.onRuntimeInitialized = () => {
  console.log("=== orginal C value ===");
  Module._print_data();

  console.log("=== read C memory from JS side ===");

  var int_ptr = Module._get_int_ptr();
  var int_value = Module.HEAP32[int_ptr >> 2];
  console.log("JS{int_value:" + int_value + "}");

  var double_ptr = Module._get_double_ptr();
  var double_value = Module.HEAPF64[double_ptr >> 3];
  console.log("JS{double_value:" + double_value + "}");

  console.log("=== write C memory from JS side ===");
  Module.HEAP32[int_ptr >> 2] = 13;
  Module.HEAPF64[double_ptr >> 3] = 123456.789;
  Module._print_data();
};

输出为:

=== orginal C value ===
C{g_int:42}
C{g_double:3.141593}
=== read C memory from JS side ===
JS{int_value:42}
JS{double_value:3.1415926}
=== write C memory from JS side ===
C{g_int:13}
C{g_double:123456.789000}

可以看到,在 JS 中正确读取了 C/C++(wasm)的内存数据;JS 中写入的数据,在 wasm 中亦能正确获取。

需要注意的是,Module.buffer 是一个 ArrayBuffer 对象,是保存二进制数据的一维数组,无法直接访问,必须通过某种类型的 TypedArray 方可对其进行读写。可以理解为 ArrayBuffer 是实际存储数据的容器,在其上创建的 TypedArray 则是把该容器当作某种类型的数组来使用。常用对应关系如下表:

Object TypedArray C datatype
Module.HEAP8 Int8Array int8
Module.HEAP32 Int32Array int32
Module.HEAPU16 Uint16Array uint16
Module.HEAPF64 Float64Array double

所以在上例中,在 JS 中通过各种类型的 HEAP 对象视图访问 wasm 的内存数据时,地址必须对齐。