• 集成Emscripten+wasm至React项目踩坑记录


    前言

    需求是有一个C++写的工具包(负责大规模的数据运算)。
    需要用emscripten是把C/C++编译成WebAssembly,便于在JS环境之后执行。
    最终在React项目中调用工具包。

    数据类型通信

    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;
    }
    
    • 1
    • 2
    • 3

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

    extern "C"{
      int add(int x, int y){
    	 return x+y;
      }
      int sub(int x, int y){
      	 return x-y; 
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    值得注意的是,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.cwrap("add", "number",["number","number"]);
        console.log("cwrap: ", add(1,2));
        // 直接使用ccall
        console.log("ccall: ", instance.ccall("add","number", ["number","number"], [1,2]));
    }
    test();
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    如果不支持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));
        // 直接使用ccall
        console.log("ccall: ", instance.ccall("add","number", ["number","number"], [1,2]));
    );
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    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.cwrap("add", "number", ["number","number"]);
    
    • 1

    第一个参数是要注册的函数名(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]));
    
    • 1

    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();
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

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

    Remark:

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

    3.4 补充

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