前端小魔女

V1

2023/03/24阅读:28主题:蔷薇紫

WebAssembly-C与JS互相操作

机会成本opportunity cost 是为了得到这种东西所放弃的东西 --《经济学原理》

大家好,我是柒八九

今天,我们继续WebAssemby的探索。我们来谈谈关于C与JS互相操作的相关知识点。

如果,想了解该系列的文章,可以参考我们已经发布的文章。如下是往期文章。

文章list

  1. 浏览器第四种语言-WebAssembly

你能所学到的知识点

  1. JS调用C函数 推荐阅读指数 ⭐️⭐️⭐️⭐️
  2. JS函数注入C环境 推荐阅读指数 ⭐️⭐️⭐️⭐️
  3. 单向透明的内存模型 推荐阅读指数 ⭐️⭐️⭐️
  4. JS与C/C++交换数据 推荐阅读指数 ⭐️⭐️⭐️⭐️⭐️
  5. EM_ASM系列宏 推荐阅读指数 ⭐️⭐️⭐️
  6. emscripten_run_script() 推荐阅读指数 ⭐️⭐️⭐️
  7. 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;
}

这个宏定义了一个求平方的函数,可以用来计算任意整数的平方。在编译时,所有使用这个宏的地方都会被替换成对应的代码,从而实现了代码的复用和简化。


下面我们进入正题。

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

  1. 使用C风格的符号修饰
    • 当试图将main()函数之外的全局函数导出至JS语言环境时,必须强制使用C风格的符号修饰,以保证函数名在C/C++语言环境以及JS语言环境中有统一的对应规则
  2. 避免函数因为缺乏引用,而导致在编译链接时被优化器删除。
    • 需要提前告知编译器:该函数必须保留,不能删除,不能改名
  3. 为了保持兼容性,宏需要根据不同的环境--原生代码环境与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宏的定义中,使用了一些条件编译的技巧,以便在不同的编译环境中都能正确地工作。具体来说,

  1. __EMSCRIPTEN__宏用于探测是否是Emscripten环境
  2. __cplusplus用于探测是否是C++环境
  3. EMSCRIPTEN_KEEPALIVEEmscripten特有的宏,用于告知编译器后续函数在优化时必须保留,并且该函数被导出至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>

在浏览器的控制台中,依次显示7893的代码输出。

在JS中调用C导出的函数,需要在原有导出函数签名的前面加_

  • Module._show_me_your_nameModule._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代码部分是模块Ajs代码部分是模块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_addjs_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.wasmmain.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还是wasmC/C++代码中的内存空间实际上对应的都是Emscripten提供的ArrayBuffer对象:Module.bufferC/C++内存地址与Module.buffer数组下标一一对应。

ArrayBufferJS中用于保存二进制数据的一维数组。

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


JS与C/C++交换数据

参数及返回值

JSC/C++之间只能通过number进行参数返回值传递

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

number数值类型从JS传入C/C++有两种途径。

  1. JS调用带参数的C导出函数,通过参数传入number
  2. C调用由JS实现的函数,通过注入函数的返回值传入number

由于C/C++强类型语言,因此对于来自JSnumber传入,会发生隐式类型转换

  • 若目标类型是int,将执行向0取整
  • 若目标类型是float,类型转换时有可能损失精度

通过内存交换数据

当需要在JSC/C++之间交换大块的数据时,可以通过内存来交换数据

有如下目录结构

fibonacci.c

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

// 省略EM_PORT_API 定义

EM_PORT_API(int *) fibonacci(int count){
    if(count<=0return 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.jsC进行编译处理。然后在指定的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提供了一组辅助函数用于二者的转换

  1. UTF8ToString()
    • 该函数可以将C/C++的字符串转换为JS字符串
  2. 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_DOUBLEEM_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类型,需要拆分以下几步

  1. 使用Module._malloc()函数在Module堆中分配内存,获取地址ptr
  2. 将字符串/数组等数据复制到内存的ptr
  3. ptr作为参数,调用C/C++函数进行处理
  4. 使用Module._free()释放ptr

为了简化调用过程,Emscripten提供了ccall()封装函数。

ccal()用于在JS中调用导出的C函数,该方法会自动完成类型为字符串、数组的参数及返回值的转换及传递

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

后记

分享是一种态度

参考地址

  1. emscripten.org
  2. WebAssembly
  3. 面向WebAssembly编程

全文完,既然看到这里了,如果觉得不错,随手点个赞和“在看”吧。

分类:

前端

标签:

前端

作者介绍

前端小魔女
V1

微信公众号:前端柒八九