# 第2章 C与JavaScript互操作
# 2.1 JavaScript 调用 C 函数
# 定义函数导出宏
一个具备实用功能的 WebAssembly 模块,必然提供了供外部调用的函数接口
为了方便函数导出,我们需要先定义一个函数导出宏,该宏需要完成以下功能:
- 使用 C 风格符号修饰
- 避免函数因为缺乏引用而导致在编译时被优化器删除
- 为了保持足够的兼容性,宏需要根据不同的环境——原生 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 导出函数
- 写 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;
}
- 编译
emcc export1.cc -o export1.js
- 在浏览器环境调用
<!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>
- 打印结果
// 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://www.gausszhou.top/static/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://www.gausszhou.top/static/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://www.gausszhou.top/static/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;
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://www.gausszhou.top/static/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://www.gausszhou.top/static/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://www.gausszhou.top/static/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://www.gausszhou.top/static/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://www.gausszhou.top/static/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 时需要格外注意传入的字符串/数组的大小,避免爆栈