{机会成本|opportunity cost} 是为了得到这种东西所放弃的东西 --《经济学原理》
今天,我们继续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_JSEM_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导出函数,通过参数传入numberC调用由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编程
全文完,既然看到这里了,如果觉得不错,随手点个赞和“在看”吧。
本文正在参加「金石计划」