
前端小魔女
2023/03/24阅读:28主题:蔷薇紫
WebAssembly-C与JS互相操作
❝机会成本 是为了得到这种东西所放弃的东西 --《经济学原理》
❞
大家好,我是「柒八九」。
今天,我们继续「WebAssemby」的探索。我们来谈谈关于「C与JS互相操作」的相关知识点。
如果,想了解该系列的文章,可以参考我们已经发布的文章。如下是往期文章。
文章list
你能所学到的知识点
❝❞
JS调用C函数 「推荐阅读指数」 ⭐️⭐️⭐️⭐️ JS函数注入C环境 「推荐阅读指数」 ⭐️⭐️⭐️⭐️ 单向透明的内存模型 「推荐阅读指数」 ⭐️⭐️⭐️ JS与C/C++交换数据 「推荐阅读指数」 ⭐️⭐️⭐️⭐️⭐️ EM_ASM系列宏 「推荐阅读指数」 ⭐️⭐️⭐️ emscripten_run_script()
「推荐阅读指数」 ⭐️⭐️⭐️ccall()
「推荐阅读指数」 ⭐️⭐️⭐️
好了,天不早了,干点正事哇。

JS 调用C函数
❝一个具备使用功能的
❞WebAssembly
模块必然提供让外部调用的函数接口。
我们来介绍普通C
函数导出用于供JS
调用的方法
定义函数导出宏
在进行代码讲解前,先来了解一个概念 --宏
宏是什么
❝宏是一种「编程语言的特性」,它可以在编译时将一段代码替换成另一段代码,从而实现代码的复用和简化。
❞
宏可以用来定义 常量
、函数
、类
等,也可以用来进行代码的重构和优化。
用C++
代码定义一个简单的宏
#define SQUARE(x) ((x) * (x))
#include <iostream>
int main() {
int a = 5;
std::cout << a << " 的平方是 " << SQUARE(a) << std::endl;
return 0;
}
这个宏定义了一个求平方的函数,可以用来计算任意整数的平方。在编译时,所有使用这个宏的地方都会被替换成对应的代码,从而实现了代码的复用和简化。
下面我们进入正题。
为了方便函数导出,需要先定义一个函数导出宏。该宏需要完成以下功能。
-
使用 C
风格的「符号修饰」-
当试图将 main()
函数之外的全局函数导出至JS
语言环境时,必须「强制使用」C
风格的符号修饰,以保证函数名在C/C++
语言环境以及JS
语言环境中「有统一的对应规则」
-
-
避免函数因为缺乏引用,而导致在「编译链接」时被优化器删除。 -
需要提前告知「编译器」:该函数必须保留,不能删除,不能改名
-
-
为了保持兼容性,宏需要根据「不同的环境」--原生代码环境与 Emscripten
环境、纯C
环境与C++
环境等,自动切换合适的行为。
为了满足上述3点要求,定义EM_PORT_API
宏如下:
#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
这段代码是一个宏定义,它定义了一个名为EM_PORT_API
的宏。这个宏的作用是为了方便地定义导出到JavaScript
的函数。
在Emscripten
中,我们可以使用EMSCRIPTEN_KEEPALIVE
宏来告诉编译器将函数导出到JavaScript
中。但是,这个宏「只能用在函数声明和定义中,不能用在函数类型中」。因此,我们需要定义一个新的宏来解决这个问题。
EM_PORT_API
宏的定义中,使用了一些「条件编译的技巧」,以便在不同的编译环境中都能正确地工作。具体来说,
-
__EMSCRIPTEN__
宏用于探测是否是Emscripten
环境 -
__cplusplus
用于探测是否是C++
环境 -
EMSCRIPTEN_KEEPALIVE
是Emscripten
特有的宏,用于告知「编译器」后续函数在优化时必须保留,并且该函数被导出至JS
环境
使用EM_PORT_API
定义函数声明
EM_PORT_API(int) Func(int param);
在Emscripten
中,上述函数声明被展开为
#include <emscripten.h>
extern "C" int EMSCRIPTEN_KEEPALIVE Func(int param);
在JS中调用C导出函数
在胶水代码中,JS
环境中的Module
对象已经封装了C
环境下的「导出函数」。封装方法的名字就是下划线加上C环境的函数名
创建一个main.c
#include <stdio.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
EM_PORT_API(int) show_me_your_name() {
return 789;
}
EM_PORT_API(float) add(float a,float b){
return a + b;
}
使用emcc
命令将其编译为wasm
`
emcc mian.c -o main.js
我们还是构建一个如下的项目结构
webAssemblyWorkSpace
├── index.html // html文件
├── main.c // 存放C代码
├── main.js // 通过emscripten准换后的js代码
├── main.wasm // wasm代码
└── server.js // 用于起一个前端服务
我们可以在index.html
中引入刚才C
导出的两个函数show_me_your_name/add
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>JS和C互相操作</title>
</head>
<body>
<script>
Module = {};
Module.onRuntimeInitialized = function (){
console.log(Module._show_me_your_name());
console.log(Module._add(1,2))
}
</script>
<script src="./main.js"></script>
</body>
</html>
在浏览器的控制台中,依次显示789
和3
的代码输出。

❝在JS中调用
C
导出的函数,需要在原有导出函数签名的前面加_
。❞
如 Module._show_me_your_name
和Module._add
由于JS
是弱类型语言,在调用函数时,并不要求调用方与被调用方的签名一直。
❝在
JS
环境中,❞
如果给出的参数个数多余形参个数,多余的参数将被「舍弃」(从左到右) 如果参数个数少于形参个数,会自动以 undefined
填充不足的参数
JS函数注入C环境
Emscripten
提供了多种在C
环境调用JS
函数的方法
-
EM_JS
-
EM_ASM
宏内联JS代码 -
emscripten_run_scripten()
函数 -
JS
函数注入C
环境
C函数声明
在C
环境中,存在这种情况:模块A
调用了由模块B
实现的函数,换句话说就是:在模块A
中创建函数声明,在模块B
中实现函数体。
在Emscripten
中,C
代码部分是模块A
,js
代码部分是模块B
。
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(7,89);
js_console_log_int(i);
}
其中,print_the_answer()
调用了函数js_add()
计算(7+89)
,然后调用了js_console_log_int
函数来打印结果,这两个函数在C
环境中仅仅给出了「声明」,函数实现是在JS
中完成。
JS实现C函数
我们在JS
中实现在C
环境中声明的函数。
对应的文档目录如下
创建一个JS
源文件pkg.js
mergeInto(LibraryManager.library,{
js_add:function(a,b){
console.log("js_add被调用了");
return a + b;
},
js_console_lot_int:function(param){
console.log(`js_console_lot_int被调用了,参数为${param}`)
}
})
上面代码中,按照两个C
函数各自声明定义了两个对象js_add
和js_console_log_int
,并将其合并到LibraryManager.library
中。
❝❞
LibraryManager.library
可以简单的理解「为JS
注入C
环境的库」,即模块B
执行如下编译指令
emcc main.c --js-library pkg.js -o main.js
--js-library pkg.js
表示将pkg.js
作为附加库参与链接。命令执行后得到如下的目录结构。即新增了main.wasm
和main.js
文件。
然后,还是熟悉的配方,我们在html
中引入。
<body>
<script>
Module = {};
Module.onRuntimeInitialized = function (){
Module._print_the_answer();
}
</script>
<script src="./main.js"></script>
</body>

JS函数注入C环境的优缺点
-
优点:使用 JS
函数注入可以保持C
代码的纯净,即C
代码中不包含任何JS
的成分。-
对于「跨语言」环境使用的库,这点很重要
-
-
缺点:该处理方式,需要「额外」创建一个 .js
库文件。即上文的pkg.js
单向透明的内存模型
Module.buffer
无论编译目标是asm.js
还是wasm
,C/C++
代码中的内存空间实际上对应的都是Emscripten
提供的ArrayBuffer
对象:Module.buffer
。 C/C++
内存地址与Module.buffer
数组下标一一对应。
❝❞
ArrayBuffer
是JS
中用于保存二进制数据的一维数组。
由于C/C++
代码中能「直接通过地址访问的数据全部在内存中」(包括运行时堆、栈),而C/C++
代码中的内存空间对应的是Module.buffer
对象,因此其直接访问的数据事实上被限制在Module.buffer
内部,JS
环境中的其他对象无法被C/C++
直接访问,我们称其为「单向透明的内存模型」。
JS与C/C++交换数据
参数及返回值
❝❞
JS
与C/C++
之间只能通过number
进行「参数」和「返回值」传递
从语言角度来说,JS
与C/C++
有完全不同的数据体系,number
类型是二者唯一的交集,因此本质上二者互相调用时,都是在交换 number 数据类型
number
数值类型从JS
传入C/C++
有两种途径。
-
JS
调用带参数的C
导出函数,通过参数传入number
-
C
调用由JS
实现的函数,通过注入函数的返回值传入number
由于C/C++
是「强类型语言」,因此对于来自JS
的number
传入,会发生「隐式类型转换」
-
若目标类型是 int
,将执行向0
取整 -
若目标类型是 float
,类型转换时有可能损失精度
通过内存交换数据
❝当需要在
❞JS
与C/C++
之间交换「大块」的数据时,可以通过内存来交换数据
有如下目录结构

fibonacci.c
#include <stdio.h>
#include <malloc.h>
// 省略EM_PORT_API 定义
EM_PORT_API(int *) fibonacci(int count){
if(count<=0) return NULL;
int* re = (int*) malloc(count *4);
if(NULL == re){
printf("内存不够\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.c -o fibonacci.js
对C
进行编译处理。然后在指定的index.html
中引用
<script>
Module = {};
Module.onRuntimeInitialized = function (){
let ptr = Module._fibonacci(10);
if(ptr ==0 ) return;
let str = '';
for(let i=0;i<10;i++){
str += Module.HEAP32[(ptr>>2)+i];
str +=' ';
}
console.log(str);
Module._free_buf(ptr);
}
</script>

❝❞
C
函数fibonacci
在堆上分配了空间,在JS
中调用后需要用free_buf()
将其释放,以免内存泄漏
字符串
C/C++
中字符串表达方式与JS
完全不兼容。Emscripten
提供了一组「辅助函数」用于二者的转换
-
UTF8ToString()
-
该函数可以将 C/C++
的字符串转换为JS
字符串
-
-
allocateUTF8()
-
该函数将在 C/C++
内存中分配最够大的空间,并将字符串按UTF8
格式复制到分配的内存中。
-
EM_ASM 系列宏
很多「编译器」支持在C/C++
代码中直接嵌入「汇编语言」,Emscripten
采用类似的方式提供了一组以EM_ASM
为前缀的宏,用于以「内嵌」的方式在C/C++
代码中直接嵌入JS
代码。
EM_ASM
EM_ASM
宏只需要将要执行的JS
代码放置在参数位置。
#include <emscripten.h>
int main(){
EM_ASM(console.log("前端柒八九"));
return 0;
}
在index.html
控制台中就会输出对应的前端柒八九
。
❝❞
EM_ASM
宏只能执行嵌入的JS
代码,无法传入参数或获取返回结果
EM_ASM_/EM_ASM_DOUBLE
EM_ASM_
支持输入数值类型的「可变参数」,同时返回整数类型的结果。EM_ASM_
宏嵌入的JS
代码必须放到{}包围的代码块中,且至少必须含有有一个输入参数。
嵌入的JS
代码通过$n
访问第n+1
个参数。
int sum = EM_ASM_({return $0 + $1 + $2;},1,2,3);
EM_ASM_DOUBLE
和EM_ASM_
用法基本一致,「区别」是EM_ASM_DOUBLE
返回值为double
emscripten_run_script()
EM_ASM
系列宏只能接受硬编码常量字符串,而emscripten_run_script()
系列函数可以接收「动态输入」的字符串,该系列辅助函数类比与JS
中的eval()
方法。
函数声明:void emscripten_run_script(const char *script)
函数使用
int main(){
emscripten_run_script("console.log("前端柒八九")");
return 0;
}
由于传入的脚本最终会通过JS
中的eval()
方法执行,因此传入的脚本可以是「任意」的JS
代码
int main(){
emscripten_run_script(R"{
function my_print(s){
console.log("JS代码输出",s)
}
my_print("前端柒八九")
}");
return 0;
}
其他函数
-
emscripten_run_script_int()
与emscripten_run_script
类似,区别是它会将输入的脚本的执行结果作为「整数」返回 -
emscripten_run_script_string()
与emscripten_run_script_int()
类似,区别是返回值为「字符串」
ccall()
前面提到,JS
调用C/C++
时只能使用number
类型作为参数,因此如果参数是字符串、数组等「非number类型」,需要拆分以下几步
-
使用 Module._malloc()
函数在Module
堆中分配内存,获取地址ptr
-
将字符串/数组等数据复制到内存的 ptr
处 -
将 ptr
作为参数,调用C/C++
函数进行处理 -
使用 Module._free()
释放ptr
为了简化调用过程,Emscripten
提供了ccall()
封装函数。
ccal()
用于在JS
中调用导出的C
函数,该方法会「自动」完成类型为字符串、数组的参数及返回值的转换及传递
-
优势在于:可以「直接使用字符串/Uint8Array/Int8Array」作为参数
后记
「分享是一种态度」。
参考地址
-
emscripten.org -
WebAssembly -
面向WebAssembly编程
「全文完,既然看到这里了,如果觉得不错,随手点个赞和“在看”吧。」

作者介绍

前端小魔女
微信公众号:前端柒八九