集成Emscripten+wasm至React项目踩坑记录
创始人
2024-04-01 16:34:08
0

前言

emscripten的主要用途,是把C/C++编译到JS环境。
具体可以分为 浏览器环境,NodeJS环境等。

数据类型通信

em在通信层,对数据类型的支持非常稀少。
如下所示只有3种。

JS侧C侧
numberC integer, float, or general pointer
stringchar*
arrayarray

不过实践起来这三种似乎也够用…
boolean: C侧转Int后传给JS侧作为number。

麻烦在于其他复杂数据类型,各种嵌套结构体、嵌套类怎么办。

我选择用 JSON String 传递复杂的数据类型Object
JS侧,所有Object通过 JSON.stringfy() => string , 然后传给C侧。
C侧, 复杂Object通过 第三方库转为JSON String,再转换为char* ,传给JS侧。

接口注册

虽然emscripten支持C++,不过我实践下来,最好还是把它当成C编译工具来用比较靠谱。

1. C侧导出

需要对外暴露的接口函数,都需要以C方式导出,避免C++的变量名破坏(name mangling)。

修饰符extern "C"有两种用法。
一种是普通内联(inline),直接写在函数体前面。

extern "C" int add(int x, int y){return x+y;
}

一种是作用域(block)写法,适合一次性导出多个函数。

extern "C"{int add(int x, int y){return x+y;}int sub(int x, int y){return x-y; }
}

值得注意的是,extern "C"要求包裹整个函数的实现。
所以不能写在只有函数签名的.h 文件里。

2. 编译时指名

通过上述代码,我们在C侧导出了名为add的函数。
第二步,需要在编译时指名。

emcc helloworld.cpp -o hello.js -s EXPORTED_FUNCTIONS=_add -s EXPORTED_RUNTIME_METHODS=ccall,cwrap

-s EXPORTED_FUNCTIONS指名时,需要在函数名前添加_下划线,以显示这是一个导出函数。
这是em的规范。(详见 https://emscripten.org/docs/porting/connecting_cpp_and_javascript/Interacting-with-code.html)
(你肯定想问如果本来函数名前面就有下划线怎么办,答案是再多加一个)

后面的-s EXPORTED_RUNTIME_METHODS=ccall,cwrap表示我们即将用ccallcwrap的方式调用这些导出的函数。
ccall or cwrap是可选的,可以不加,但推荐新手使用。

Remark1:指名多个函数时,逗号分割

emcc helloworld.cpp -s EXPORTED_FUNCTIONS=_add,_sub

Remark2: 如果你看一些老版本的教程,可能会提到另一种 String of Array 格式,这种格式is depreciated

emcc helloworld.cpp -s EXPORTED_FUNCTIONS=‘[ “_add” , “_sub” ]’

注意,如果非要用上述写法,只能外层单引号,内层双引号。不能反过来。

3.JS侧注册 & 使用

上面步骤我们得到了 .js.wasm,下面要在js侧使用导出的函数。
具体又可以分为,在 NodeJS or Web浏览器 环境下使用。

3.1 NodeJS环境下使用

node环境默认引入包的方式是require
为了配合这点,需要在编译的时候加上 -sMODULARIZE

emcc helloworld.cpp -o hello.js -s EXPORTED_FUNCTIONS=_add -s EXPORTED_RUNTIME_METHODS=ccall,cwrap -sMODULARIZE

-sMODULARIZE-s MODULARIZE
效果是在require时返回一个工厂函数。
工厂函数会返回一个Promise,告诉你何时runtime compliance完成。

见代码

const hello= require('hello.js');
async function test(){const instance = await hello();// 直接使用instance._add(1,2);// cwrap 注册后使用const add = instance.add("add", "number",["number","number"]);console.log("cwrap: ", add(1,2));// 直接使用ccallconsole.log("ccall: ", instance.ccall("add","number", ["number","number"], [1,2]));
}
test();

如果不支持ES6语法,也可以用ES5的方式测试。

const hello= require('hello.js');
hello().then((instance)=>{// 直接使用instance._add(1,2);// cwrap 注册后使用const add = instance.add("add", "number",["number","number"]);console.log("cwrap: ", add(1,2));// 直接使用ccallconsole.log("ccall: ", instance.ccall("add","number", ["number","number"], [1,2]));
);

3.2 cwrap 与 ccall

在上一节中,我们演示了cwrap 与 ccall的使用方式。
下面介绍语法格式。

cwrap的作用是将 C Funcition 注册成一个 JS Function

const jsFunction = {yourModuleInstance}.cwrap( “funcName”, “return type”, [“arg1 type”, “arg2 type”, …])

结合示例:

const add = instance.add(“add”, “number”, [“number”,“number”]);

第一个参数是要注册的函数名(C中的名字),
第二个参数是返回值类型。 我在“数据类型通信”这一节写了,EM仅支持 number,string,array 三种js侧的数据类型。如果C函数是void无返回值的,那么此处填入JS的null
第三个参数是一个JS数组,内容依次表示函数参数的类型。同样的,参数类型只能是 number,string,array 之一。如果这个C函数是无入参的,那么cwrap的第三个参数可以省略不写。

ccall的参数和cwrap类似,区别在于多了第四项。
第四个参数是一个JS数组,内容是本次调用的入参。

结合示例:

console.log("ccall: ", instance.ccall(“add”,“number”, [“number”,“number”], [1,2]));

Remark:

cwrap和ccall是官方为我们自动做了数据类型的翻译工作。但支持的类型比较少。
C中的一些自定义结构体、类肯定是翻译不过来的。
对于复杂数据,我采用JSON字符串传递到JS侧,再JSON.parse。
https://emscripten.org/docs/porting/connecting_cpp_and_javascript/Interacting-with-code.html?highlight=exported_functions#call-compiled-c-c-code-directly-from-javascript
https://emscripten.org/docs/api_reference/val.h.html#val-as-handle

3.3 Web浏览器环境下使用

编译到web环境,

emcc helloworld.cpp -o hello.js -s EXPORTED_FUNCTIONS=_add,_sub -s EXPORTED_RUNTIME_METHODS=cwrap,ccall -sENVIRONMENT=web -s MODULARIZE=1 -s EXPORT_NAME=‘createModule’ -s EXPORT_ES6=1 -s USE_ES6_IMPORT_META=0

  • -sENVIRONMENT=web 是核心代码,emcc会删除一些非web环境的全局功能模块。
  • 编译的时候需要加 -s MODULARIZE=1 使之模块化,和我们在§3.1的操作一样。
  • -s EXPORT_NAME='createModule' 不重要,只是约定一个名称,而且在ES6的导出语法下这个名称可以随便给。
  • -s EXPORT_ES6=1-s USE_ES6_IMPORT_META=0 是为了兼容我的项目代码。不用启用ES6的话,emcc的编译结果会写成CJS那种module.exports的格式。开启ES6,导出的.js 就会是export default xxx

这样操作完,还是会得到 hello.jshello.wasm 2个文件。

然后在项目中

import createModule from './hello.js';async function loadModule(){const module = await createModule();const res = module.ccall("add","number", ["number","number"], [1,2]));console.log('add result:',res);
}
loadModule();

cwrap的部分同3.1,不再赘述。
上面是基本用法。

Remark:

如果你在使用由create-react-app创建的react项目,那么上面的步骤是不够的。
因为脚手架中默认的webpack并不能正确地打包引用到的.wasm文件。
还需要对webpack做一点配置。见下文

3.4 补充

另一种很常见的引入方式是将hello.js放在