# 第2章 C与JavaScript互操作

# 2.1 JavaScript 调用 C 函数

# 定义函数导出宏

一个具备实用功能的 WebAssembly 模块,必然提供了供外部调用的函数接口

为了方便函数导出,我们需要先定义一个函数导出宏,该宏需要完成以下功能:

  1. 使用 C 风格符号修饰
  2. 避免函数因为缺乏引用而导致在编译时被优化器删除
  3. 为了保持足够的兼容性,宏需要根据不同的环境——原生 NativeCode 环境与 Emscripten 环境、纯 C 环境与 C++环境等——自动切换合适的行为

为了满足上述要求,定义 EM_PORT_API 宏如下

vim em_port_api.h
//em_port_api.h
#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

在上述代码中:

  • __EMSCRIPTEN__宏用于探测是否是 Emscripten 环境
  • __cplusplus用于探测是否 C++环境
  • EMSCRIPTEN_KEEPALIVE是 Emscripten 特有的宏,用于告知编译器后续函数在优化时必须保留,并且该函数将被导出至 JavaScript

EMSCRIPTEN_KEEPALIVE可以这样理解

export function

这样我们就只需要统一使用EM_PORT_API定义函数声明

EM_PORT_API(int) Func(int param);

等同于如下

#include <emscripten.h>
extern "C" int EMSCRIPTEN_KEEPALIVE Func(int param);

# 在 JavaScript 中调用 C 导出函数

  1. 写 c

echo > export1.cc
vim export1.cc
//export1.cc
#include "em_port_api.h"
#include <stdio.h>

EM_PORT_API(int) show_me_the_answer() {
	return 42;
}

EM_PORT_API(float) add(float a, float b) {
	return a + b;
}
  1. 编译

emcc export1.cc -o export1.js
  1. 在浏览器环境调用

<!DOCTYPE html>

<html>
  <head>
    <meta charset="utf-8" />
    <title>Emscripten:Export1</title>
  </head>
  <body>
    <script>
      Module = {};
      Module.onRuntimeInitialized = function () {
        console.log(Module._show_me_the_answer());
        console.log(Module._add(3, 0.14159265358979323846));
      };
    </script>
    <script src="export1.js"></script>
  </body>
</html>
  1. 打印结果

// 42
// 3.1415927410125732

注意 js 和 wasm 文件要在同一个文件夹,原因请看如下代码

//export1.js
var wasmBinaryFile;
wasmBinaryFile = "export1.wasm";
if (!isDataURI(wasmBinaryFile)) {
  wasmBinaryFile = locateFile(wasmBinaryFile);
}

在 JavaScript 环境中,如果给出的参数个数多于函数形参个数,多余的参数被舍弃(从左至右);如果参数个数少于形参个数,不足的参数会自动以 undefined 填充。因此下列 JavaScript 调用都是合法的:

console.log(Module._show_me_the_answer(10));
console.log(Module._add(2, 3, 4));
console.log(Module._add(12));
// 42
// 5
// NaN

# 总结

在 c 中定义函数

#include "em_port_api.h"
#include <stdio.h>

EM_PORT_API(int) show_me_the_answer() {
	return 42;
}

EM_PORT_API(float) add(float a, float b) {
	return a + b;
}

在 js 中调用

Module = {};
Module.onRuntimeInitialized = function () {
  console.log(Module._show_me_the_answer());
  console.log(Module._add(3, 0.14159265358979323846));
};

# 预览

emcc export1.cc -o export1.js -s EXPORT_NAME="'Module2'"
<script>
  Module2 = {};
  Module2.onRuntimeInitialized = function () {
    console.log(Module2._show_me_the_answer());
    console.log(Module2._add(12, 1.0));
  };
</script>
<script src="https://static.gausszhou.top/data/text/wasm/export1.js"></script>

# 2.2 JavaScript 函数注入 C

Emscripten 提供了多种在 C 环境调用 JavaScript 的方法,包括:

EM_JS/EM_ASM 宏内联 JavaScript 代码 emscripten_run_script 函数 JavaScript 函数注入(更准确的描述为:“Implement C API in JavaScript”,既在 JavaScript 中实现 C 函数 API) 本节将重点介绍其中的最后一种。

# 在 C 中进行函数声明

在 C 环境中,我们经常碰到这种情况:模块 A 调用了由模块 B 实现的函数——既在模块 A 中创建函数声明,在模块 B 中实现函数体。 在 Emscripten 中,C 代码部分是模块 A,JavaScript 代码部分是模块 B。例如创建 capi_js.cc 如下:

//capi_js.cc
#include "em_port_api.h"

EM_PORT_API(int) js_add(int a, int b);
EM_PORT_API(void) js_console_log_int(int param);

EM_PORT_API(void) print_the_answer() {
    int i = js_add(21, 21);
    js_console_log_int(i);
}

print_the_answer()调用了函数 js_add 计算 21+21,然后调用 js_console_log_int()来打印结果,后二者仅仅给出了声明,函数实现将在 JavaScript 中完成。

# 在 JavaScript 中实现函数

//pkg.js
mergeInto(LibraryManager.library, {
  js_add: function (a, b) {
    console.log("js_add");
    return a + b;
  },
  js_console_log_int: function (param) {
    console.log("js_console_log_int:" + param);
  }
});

代码按照两个 C 函数各自的声明定义了两个对象 js_add 以及 js_console_log_int,并将其合并到 LibraryManager.library 中

TIP

LibraryManager.library 可以简单的理解为 JavaScript 注入 C 环境的库,既 2.2.1 中所说的“模块 B”。 虽然事实上它远比这要复杂,但这种简单的类比足以应对大部分常规应用。

emcc capi_js.cc --js-library pkg.js -o capi_js.js

--js-library pkg.js 意为将 pkg.js 作为附加库参与链接。命令执行后得到 capi_js.js 以及 capi_js.wasm

# 闭包限制及解决办法

使用“mergeInto(LibraryManager.library……”注入的方法不能直接使用闭包。当然这可以通过在注入方法中调用其他 JavaScript 方法来间接实现。比如我们创建 closure.cc 如下:

//closure.cc
#include "em_port_api.h"
#include <stdio.h>

EM_PORT_API(int) show_me_the_answer();

EM_PORT_API(void) func() {
    printf("%d\n", show_me_the_answer());
}

show_me_the_answer()函数在 closure_pkg.js 中实现

//closure_pkg.js
mergeInto(LibraryManager.library, {
  show_me_the_answer: function () {
    return jsShowMeTheAnswer();
  }
});
emcc closure.cc --js-library closure_pkg.js -o closure.js

show_me_the_answer()调用了 jsShowMeTheAnswer(),后者将在网页中实现

<script>
  function f1() {
    var answer = 42;
    return function f2() {
      return answer;
    };
  }
  var jsShowMeTheAnswer = f1();
  Module3_1 = {};
  Module3_1.onRuntimeInitialized = function () {
    console.log("Initialized");
    Module3_1._print_the_answer();
  };
</script>
<script src="closure.js"></script>

# JavaScript 函数注入的优缺点

  • 优点:使用 JavaScript 函数注入可以保持 C 代码的纯净——既 C 代码中不包含任何 JavaScript 的成分;
  • 缺点:该方法需要额外创建一个.js 库文件,维护略为麻烦

# 预览

emcc capi_js.cc --js-library pkg.js -o capi_js.js -s EXPORT_NAME="'Module3'"
<script>
  Module3 = {};
  Module3.onRuntimeInitialized = function () {
    Module3._print_the_answer();
  };
</script>
<script src="https://static.gausszhou.top/data/text/wasm/capi_js.js"></script>
emcc closure.cc --js-library closure_pkg.js -o closure.js -s EXPORT_NAME="'Module3_1'"
<script>
  function f1() {
    var answer = 42;
    return function f2() {
      return answer;
    };
  }
  var jsShowMeTheAnswer = f1();
  Module3_1 = {};
  Module3_1.onRuntimeInitialized = function () {
    console.log("Initialized");
    Module3_1._print_the_answer();
  };
</script>
<script src="https://static.gausszhou.top/data/text/wasm/closure.js"></script>

# 2.3 单向透明的内存模型

# Module

Module 是一个全局 JavaScript 对象,具有 Emscripten 生成的代码在其执行过程中的各个点调用的属性

开发人员可以提供一个实现 Module 来控制代码的执行。

当 Emscripten 应用程序启动时,它会查看 Module 对象上的值并应用它们。请注意,启动后更改值通常不起作用;

# Module.buffer

C/C 代码能直接通过地址访问的数据全部在内存中(包括运行时堆、运行时栈),而内存对应Module.buffer对象,C/C 代码能直接访问的数据事实上被限制在Module.buffer内部,JavaScript 环境中的其他对象无法被 C/C++直接访问——因此我们称其为单向透明的内存模型。

由于在当前版本的 Emscripten 中,指针(既地址)类型为 int32,因此单一模块的最大可用内存范围为 2GB-1。未定义的情况下,内存默认容量为 16MB,其中栈容量为 5MB。

# Module.HEAPX

ArrayBuffer 无法直接访问,必须通过某种类型的 TypedArray 方可对其进行读写。例如下列 JavaScript 代码创建了一个容量为 12 字节的 ArrayBuffer,并在其上创建了类型为 int32 的 TypedArray,通过该 View 依次向其中存入了 1111111、2222222、3333333 三个 int32 型的数

// 首先,我们创建一个16字节固定长度的缓冲
var buffer = new ArrayBuffer(16);
// 我们将创建一个视图,此视图将把缓冲内的数据格式化
var int32View = new Int32Array(buffer);
int32View[0] = 1111111;
int32View[1] = 2222222;
int32View[2] = 3333333;

二进制数据 (opens new window)

TIP

ArrayBuffer 与 TypedArray 的关系可以简单理解为:ArrayBuffer 是实际存储数据的容器,在其上创建的 TypedArray 则是把该容器当作某种类型的数组来使用

Emscripten 已经为 Module.buffer 创建了常用类型的 TypedArray,见下表:

对象 TypedArray 对应 C 数据类型
Module.HEAP8 Int8Array int8
Module.HEAP16 Int16Array int16
Module.HEAP32 Int32Array int32
Module.HEAPU8 Uint8Array uint8
Module.HEAPU16 Uint16Array uint16
Module.HEAPU32 Uint32Array uint32
Module.HEAPF32 Float32Array float
Module.HEAPF64 Float64Array double

# 在 JavaScript 中访问 C/C++内存

下面通过一个简单的例子展示如何在 JavaScript 中访问 C/C++内存

//mem.cc
#include "em_port_api.h"
#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);
}
emcc mem.cc  -o mem.js -s EXPORT_NAME="'Module4'"
<script>
  Module4 = {};
  Module4.onRuntimeInitialized = function () {
    Module4._print_data();

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

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

    Module4.HEAP32[int_ptr >> 2] = 13;
    Module4.HEAPF64[double_ptr >> 3] = 123456.789;
    Module4._print_data();
  };
</script>
<script src="https://static.gausszhou.top/data/text/wasm/mem.js"></script>

内存地址转数组下标

var int_ptr = Module4._get_int_ptr();
var int_value = Module4.HEAP32[int_ptr >> 2];

我们在 JavaScript 中调用了 C 函数get_int_ptr(),获取了全局变量g_int的地址,然后通过Module.HEAP32[int_ptr >> 2]获取了该地址对应的int32值。

TIP

由于 Module.HEAP32 每个元素占用 4 字节,因此 int_ptr 需除以 4(既右移 2 位)方为正确的索引。 同理 Module4.HEAPF64 每个元素占用八个字节,因此 double_ptr 需要除以 8,也就是右移三位。

C{g_int:42}
C{g_double:3.141593}
JS{int_value:42}
JS{double_value:3.1415926}
C{g_int:13}
C{g_double:123456.789000}

# 2.4 JavaScript 与 C 交换数据

# 参数和返回值

在之前章节中,我们有意忽略了一个基础性的问题:JavaScript 与 C/C++相互调用的时候,参数与返回值究竟是如何传递的?

答案是:一切皆为Number

从语言角度来说,JavaScript 与 C/C++有完全不同的数据体系,Number 是二者唯一的交集,因此本质上二者相互调用时,都是在交换 Number。

注意由于 C/C++是强类型语言,因此来自 JavaScript 的 Number 传入时,会发生隐式类型转换。例如 C 代码如下:

//type_conv.cc
#include "em_port_api.h"

#include <stdio.h>

EM_PORT_API(void) print_int(int a) {
  printf("C{print_int() a:%d}\n", a);
}

EM_PORT_API(void) print_float(float a) {
  printf("C{print_float() a:%f}\n", a);
}

EM_PORT_API(void) print_double(double a) {
  printf("C{print_double() a:%lf}\n", a);
}
emcc type_conv.cc  -o type_conv.js -s EXPORT_NAME="'Module5'"
<script>
  Module5 = {};
  Module5.onRuntimeInitialized = function () {
    Module5._print_int(3.4);
    Module5._print_int(4.6);
    Module5._print_int(-3.4);
    Module5._print_int(-4.6);
    Module5._print_float(2000000.03125);
    Module5._print_double(2000000.03125);
  };
</script>
<script src="https://static.gausszhou.top/data/text/wasm/type_conv.js"></script>

结果如下

C{print_int() a:3}
C{print_int() a:4}
C{print_int() a:-3}
C{print_int() a:-4}
C{print_float() a:2000000.000000}
C{print_double() a:2000000.031250}

# 通过内存交换大块数据

需要在 JavaScript 与 C/C++之间交换大块的数据时,直接使用参数传递数据显然不可行,此时可以通过内存来交换数据。如下例,JavaScript 调用 C 函数在内存中生成斐波拉契数列后输出,C 代码:

//fibonacci.cc
#include "em_port_api.h"

#include <stdio.h>
#include <malloc.h>

EM_PORT_API(int*) fibonacci(int count) {
  if (count <= 0) return NULL;

  int* re = (int*)malloc(count * 4);
  if (NULL == re) {
    printf("Not enough memory.\n");
    return NULL;
  }

  re[0] = 1;
  int i0 = 0, i1 = 1;
  for (int i = 1; i < count; i++){
    re[i] = i0 + i1;
    i0 = i1;
    i1 = re[i];
  }

  return re;
}

EM_PORT_API(void) free_buf(void* buf) {
	free(buf);
}
emcc fibonacci.cc  -o fibonacci.js -s EXPORT_NAME="'Module5_1'"
<script>
  Module5_1 = {};
  Module5_1.onRuntimeInitialized = function () {
    var ptr = Module5_1._fibonacci(10);
    if (ptr == 0) return;
    var str = "";
    for (var i = 0; i < 10; i++) {
      str += Module5_1.HEAP32[(ptr >> 2) + i];
      str += " ";
    }
    console.log(str);
    Module5_1._free_buf(ptr);
  };
</script>
<script src="https://static.gausszhou.top/data/text/wasm/fibonacci.js"></script>

打印结果如下

1 1 2 3 5 8 13 21 34 55

TIP

在上述例子中,C 函数 fibonacci()在堆上分配了空间,在 JavaScript 中调用后需要调用 free_buf()将其释放以免内存泄漏

# 在 JavaScript 中分配内存 ★

之前给出的例子都是在 C/C 环境中分配内存,在 JavaScript 中读取; 有时候 JavaScript 需要将大块的数据送入 C/C 环境,而 C/C++无法预知数据块的大小,此时可以在 JavaScript 中分配内存并装入数据,然后将数据指针传入,调用 C 函数进行处理。

这种用法之所以可行,核心原因在于:Emscripten 导出了 C 的malloc()/free()函数。

//sum.cc
#include "em_port_api.h"

EM_PORT_API(int) sum(int* ptr, int count) {
  int total = 0;
  for (int i = 0; i < count; i++){
    total += ptr[i];
  }
  return total;
}

注意编译的时候指定导出函数 -s EXPORTED_FUNCTIONS="['_malloc','_free']"

emscripten/issues/6882 (opens new window)

emcc sum.cc -o sum.js -s EXPORT_NAME="'Module5_2'" -s EXPORTED_FUNCTIONS="['_malloc','_free']"
<script>
  Module5_2 = {};
  Module5_2.onRuntimeInitialized = function () {
    var count = 50;
    var ptr = Module5_2._malloc(4 * count);
    for (var i = 0; i < count; i++) {
      Module5_2.HEAP32[ptr / 4 + i] = i + 1;
    }
    console.log(Module5_2._sum(ptr, count));
    Module5_2._free(ptr);
  };
</script>
<script src="https://static.gausszhou.top/data/text/wasm/sum.js"></script>

打印结果

1275

TIP

C/C++的内存没有 gc 机制,在 JavaScript 中使用 malloc()函数分配的内存使用结束后,别忘了使用 free()将其释放。

# 小结

归根到底,JavaScript 代码与 C/C++之间交换数据主要有 2 条途径:

  • 通过数值类型的参数直接传递;
  • 通过 C/C++内存间接传递。

使用动态分配的内存地址传递数据的时候,要特别注意释放已不再使用的内存避免泄漏。

内存的分配和释放应遵循谁分配谁释放的原则——既在 JavaScript 中分配的内存应由 JavaScript 代码释放,在 C/C 中分配的内存应由 C/C 代码释放。

# 2.5 EM_ASM 系列宏

很多编译器支持在 C/C 代码直接嵌入汇编代码,Emscripten 采用类似的方式,提供了一组以“EM_ASM”为前缀的宏,用于以内联的方式在 C/C 代码中直接嵌入 JavaScript 代码。

# 2.5.1 EM_ASM

EM_ASM 使用很简单,只需要将欲执行的 JavaScript 代码置于参数中,例如:

#include <emscripten.h>

int main() {
	EM_ASM(console.log('你好,Emscripten!'));
	return 0;
}

# 2.6 emscripten_run_script 系列函数

# 2.6.1 emscripten_run_script()

EM_ASM 系列宏只能接受硬编码常量字符串,而 emscripten_run_script 系列函数可以接受动态输入的字符串,该系列辅助函数可以类比于 JavaScript 中的 eval()方法。

void emscripten_run_script(const char *script)
#include <emscripten.h>
int main(){
	emscripten_run_script("console.log(42);");
	return 0;
}

# 2.7 ccall/cwrap

JavaScript 调用 C/C++时只能使用 Number 作为参数,因此如果参数是字符串、数组等非 Number 类型,则需要拆分为以下步骤:

  • 使用 Module._malloc()在 Module 堆中分配内存,获取地址 ptr;
  • 将字符串/数组等数据拷入内存的 ptr 处;
  • 将 ptr 作为参数,调用 C/C++函数进行处理;
  • 使用 Module._free()释放 ptr。

这个调用过程相当繁琐,尤其当非 Number 参数个数较多时,JavaScript 侧的调用代码会急剧膨胀。为了简化调用过程,Emscripten 提供了 ccall/cwrap 封装函数。

# ccall

语法

var result = Module.ccall(ident, returnType, argTypes, args);

参数

  • ident :C 导出函数的函数名(不含“_”下划线前缀);
  • returnType :C 导出函数的返回值类型,可以为'boolean'、'number'、'string'、'null',分别表示函数返回值为布尔值、数值、字符串、无返回值;
  • argTypes :C 导出函数的参数类型的数组。参数类型可以为'number'、'string'、'array',分别代表数值、字符串、数组;
  • args :参数数组
vim ccall_wrap.cc
//ccall_wrap.cc
#include "em_port_api.h"
#include <stdio.h>
#include <string.h>

EM_PORT_API(double) add(double a, int b) {
	return a + (double)b;
}

EM_PORT_API(int) sum(uint8_t* ptr, int count) {
	int total = 0, temp;
	for (int i = 0; i < count; i++){
		memcpy(&temp, ptr + i * 4, 4);
		total += temp;
	}
	return total;
}

EM_PORT_API(void) print_string(const char* str) {
	printf("C:print_string(): %s\n", str);
}

EM_PORT_API(const char*) get_string() {
	const static char str[] = "This is a test.";
	return str;
}

TIP

上述例子的 C 代码中,我们使用memcpy(&temp, ptr + i * 4, 4);获取自然数列的第 i 个元素的值,使用该方法的原因是:输入地址 ptr 有可能未对齐

这里也需要导出对应的函数 -s EXPORTED_RUNTIME_METHODS="['ccall', 'cwrap']"

emcc ccall_wrap.cc  -o ccall_wrap.js -s EXPORT_NAME="'Module7'" -s EXPORTED_RUNTIME_METHODS="['ccall', 'cwrap']"

然后在 JS 中我们可以这样调用

var result = Module.ccall("add", "number", ["number", "number"], [13.0, 42]);

这与直接调用Module._add(13.0, 42)是等价的。

ccall的优势在于可以直接使用字符串/Uint8Array/Int8Array作为参数。

# cwrap

显然call调用比直接调用要显得繁琐,ccall虽然封装了字符串等数据类型,但调用时仍然需要填入参数类型数组、参数列表等,为此cwrap进行了进一步封装:

语法

var func = Module.cwrap(ident, returnType, argTypes);

参数

  • ident :C 导出函数的函数名(不含“_”下划线前缀);
  • returnType :C 导出函数的返回值类型,可以为'boolean'、'number'、'string'、'null',分别表示函数返回值为布尔值、数值、字符串、无返回值;
  • argTypes :C 导出函数的参数类型的数组。参数类型可以为'number'、'string'、'array',分别代表数值、字符串、数组;

<script>
  Module7 = {};
  Module7.onRuntimeInitialized = function () {
    var count = 50;
    var buf = new ArrayBuffer(count * 4);
    var uint8View = new Uint8Array(buf);
    var int32View = new Int32Array(buf);
    // 为了和memcpy(&temp, ptr + i * 4, 4);对齐,这里使用Int32Array视图来操作ArrayBuffer
    for (var i = 0; i < count; i++) {
      int32View[i] = i + 1;
    }
    var c_add = Module7.cwrap("add", "number", ["number", "number"]);
    var c_print_string = Module7.cwrap("print_string", "null", ["string"]);
    var c_sum = Module7.cwrap("sum", "number", ["array", "number"]);
    var c_get_string = Module7.cwrap("get_string", "string");

    console.log("add 25 51 :", c_add(25.0, 41));
    c_print_string("hello");
    console.log(c_get_string());
    console.log("sum 1 to 50 :", c_sum(uint8View, count));
  };
</script>
<script src="https://static.gausszhou.top/data/text/wasm/ccall_wrap.js"></script>

打印结果

add 25 51 : 66
C:print_string(): hello
This is a test.
sum 1 to 50 : 1275

WARNING

虽然 ccall/cwrap 可以简化字符串参数的交换,但这种便利性是有代价的;

当输入参数类型为'string'/'array'时,ccall/cwrap 在 C 环境的栈上分配了相应的空间,并将数据拷入了其中,然后调用相应的导出函数。

相对于堆来说,栈空间是很稀缺的资源,因此使用 ccall/cwrap 时需要格外注意传入的字符串/数组的大小,避免爆栈