集成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;
}

一种是作用域(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.cwrap("add", "number",["number","number"]);
    console.log("cwrap: ", add(1,2));
    // 直接使用ccall
    console.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));
    // 直接使用ccall
    console.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.cwrap("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放在<script>标签中引入。
略。

4. 将wasm引入 create-react-app项目的实践记录

4.1 问题定位

如果不做任何事情,仅仅像 §3.3中那样import hello.js。
打包后运行

yarn build
serve -s build

会报几个错误。

  • hello.js:1172 wasm streaming compile failed: TypeError: Failed to execute 'compile' on 'WebAssembly': Incorrect response MIME type. Expected 'application/wasm'.
  • failed to asynchronously prepare wasm: CompileError: WebAssembly.instantiate(): expected magic word 00 61 73 6d, found 3c 21 44 4f @+0
  • Aborted(CompileError: WebAssembly.instantiate(): expected magic word 00 61 73 6d, found 3c 21 44 4f @+0)

归根结底是因为。

emcc默认导出的hello.js中,采用相对路径的方式导入hello.wasm
在这里插入图片描述

打包之后 /build/static/js/main.js 也会保留这个性质
在这里插入图片描述
/static/js/main.js 试图导入 hello.wasm时,默认从同级目录下寻找。
也就是寻找 /static/js/hello.wasm 文件 。
我们可以看到打包后的/static/js/ 目录下是不存在这么一个文件的。

这就是问题之所在。
请求/static/js/hello.wasm, 找不到资源,就会返回一个 404页面。
所以报错信息expected magic word 00 61 73 6d, found 3c 21 44 4f @+0)
这几个magic word是返回信息的前几个字节。
“3c 21 44 4f” 作为ASCII码翻译过来是字符串 “<!DO”, 暗示返回是一个404页面的DOM。

现在我们知道问题在于webpack打包之后,找不到 /static/js/hello.wasm 文件。
那么解决方案就呼之欲出了。

一种很简单的方式是,我们改写一下yarn build脚本。
每次webpack打包完之后,都copy一份 hello.wasm/build/static/js/下。
这样对于只有一个.wasm文件的项目是很简单的。
可以作为救急使用,至少保证build之后是可运行的。

但这样做,并不能在DevServer中正确运行wasm,相当于我们放弃了dev能力。
治本的方式还是应该修改webpack配置,让它能正确地把 .wasm 打包进 ./static/js/目录下。
或者打包到其他位置,也修改代码中的路径字符串。

4.2 解决思路

修改webpack配置

编辑webpack.config.js
config.rules 加一条file-loader规则, 对.wasm文件,设置outputPath=‘/static/js’。
且文件名不加hash。

{
    test: /\.wasm$/,
    type: 'asset/resource',
    loader: 'file-loader',
    options: {
      outputPath:'/static/js',
      name: '[name].[ext]',         
    }     
},

再次 yarn build, 可以看到.wasm已经正确地来到 static/js下了
在这里插入图片描述

4.3 其他报错解决

引入到react项目时,可能还会遇到一些其他小错误。

报错Module not found: Error: Can‘t resolve ‘path‘

因为浏览器环境运行的js,默认没有path模块。

yarn add path-browserify

webpack.config.js,config.resolve.fallback域,添加如下配置:
在这里插入图片描述

报错Module not found: Error: Can't resolve 'fs'

node环境才有fs。浏览器环境没有。
会报这个错误,多半是emcc编译时没有加上 -sENVIRONMENT=web
可以加上之后重新编译。

或者直接让webpack无视即可:在这里插入图片描述

报错 Aborted(Cannot enlarge memory arrays to size 21954560 bytes (OOM).

Either
(1) compile with -sINITIAL_MEMORY=X with X higher than the current value 16777216,
(2) compile with -sALLOW_MEMORY_GROWTH which allows increasing the size at runtime, or
(3) if you want malloc to return NULL (0) instead of this abort, compile with -sABORTING_MALLOC=0

报错信息里给解决方案了。
如果会用到超大数组,最好是允许Runtime Memory Growth。
编译指令加上 -sALLOW_MEMORY_GROWTH

设置初始内存的话,要求是64KB的倍数。
-sINITIAL_MEMORY=67,108,864 (64MB = 1024*64KB = 67,108,864 B)。

5. JS与C++字符串通信

5.1 字符串作为返回值测试

如果在C++侧导出一个返回值是std::string的函数,编译时会报警告 warning: 'greet' has C-linkage specified, but returns user-defined type 'string' , which is incompatible with C
研究一下这个警告是否会影响运行,是否需要忽略。

在C++侧导出2个测试函数。
在这里插入图片描述

extern "C" {
    string greet(){
        return "greet world";
    }

    const char* hello(){
        const char* s = "hello world";
        return s;
    }
}

在JS侧写好测试脚本, 在Node环境下测试。(见§3.1)

略有不同的是编译命令

emcc helloworld.cpp -o hello.js -s EXPORTED_FUNCTIONS=_hello,_greet -sMODULARIZE=1 -s EXPORTED_RUNTIME_METHODS=ccall,cwrap,UTF8ToString

需要在EXPORTED_RUNTIME_METHODS加上一个UTF8ToString

测试代码

const demo = require('./hello.js');

async function test(){
    const instance = await demo();
    console.log('then');
    console.log('then',instance._greet()); // 测试直接运行
    console.log('then', instance._hello()); // 测试直接运行
    console.log('then greet', instance.ccall("greet")); // 测试ccall greet
    console.log('then hello', instance.ccall("hello")); // 测试ccall hello
    console.log('then ccall greet', instance.ccall("greet","string")); // 测试规定返回类型
    console.log('then ccall hello', instance.ccall("hello","string")); // 测试规定返回类型

    const ptr = instance.ccall("hello");
    console.log("then str convert hello", instance.UTF8ToString(ptr)); // 测试EM提供的指针转换功能

    
    const greet = instance.cwrap("greet", "string"); // 测试cwrap的内置string转换功能
    const hello = instance.cwrap("hello", "string"); // 测试cwrap的内置string转换功能
    console.log(greet());
    console.log(hello());
}
test();

测试结果
在这里插入图片描述

一行一行看。

async function test(){
    const instance = await demo();
    console.log('then');
    console.log('then',instance._greet()); // undefined,因为greet返回std::string,不能被JS接收。
    console.log('then', instance._hello()); // 5247080,因为hello返回的是const char*,指针地址在JS中被解析为数字。
    console.log('then greet', instance.ccall("greet")); // undefined,std::string不能被JS接收。
    console.log('then hello', instance.ccall("hello")); // 5247104,const char*在JS中被解析为数字。
    console.log('then ccall greet', instance.ccall("greet","string")); // 空字符串。greet返回std::string,在JS侧接收为undefined,被ccall转译成空字符串''。
    console.log('then ccall hello', instance.ccall("hello","string")); // "hello world"。const char*通过ccall,正确转译为JS的string类型。
	
	// 手动实现C++ const char* 转 JS string
    const ptr = instance.ccall("hello"); // const char*, ptr在JS中是一个Number类型
    console.log(typeof ptr);  // number
    // 使用EM提供的UTF8ToString,可以实现const char* 转 JS string
    console.log("then str convert hello", instance.UTF8ToString(ptr)); // 输出 "hello world"

    
    const greet = instance.cwrap("greet", "string"); // 测试cwrap的内置string转换功能
    const hello = instance.cwrap("hello", "string"); // 测试cwrap的内置string转换功能
    console.log(greet()); // 空字符串。 同理,std::string 在JS侧接收为undefined,再被cwrap类型约定转译为空字符串。
    console.log(hello()); // 输出 "hello world"。 const char* 正确被cwrap转译为js string。
}
最佳实践:使用 const char*

EM导出的函数,
无论返回值还是参数,请使用 const char*
不要使用std::string,因为这会导致通信失败。 (最好也不要使用char *

底层原因是 extern "C" 要求函数以C的方式导出,但C中没有 std::string

不过,函数体内部可以使用std::string
最后一步return s.c_str()转换即可,不过这样会报警告address of stack memory associated with local variable 's' returned

5.2 字符串只支持UTF8编码

cwrapccall的原理,都是在接收到 const char* 后,使用UTF8ArrayToString转化为js string。
UTF8ArrayToString可以在导出的.js文件中找到。
在这里插入图片描述
这个函数会检查C++侧传过来的字符串的开头MagicCode,确定字符串是否为UTF8编码。

如果不满足要求UTF8编码,例如对于Windows,可能某些字符串是GBK编码的。
会报错Invalid UTF-8 leading byte 0x88 encountered when deserializing a UTF-8 string in wasm memory to a JS string!

可以使用下面的代码,在C++侧先转为UTF8编码,再返回给JS侧。

//作者:知乎用户
//链接:https://www.zhihu.com/question/61139105/answer/711597486
#if __cplusplus >= 201103L
#include <codecvt>
#include <string>
#include <locale>
#include <vector>
//C++ 11
static std::string gb2312_to_utf8(std::string const &strGb2312)
{
    std::vector<wchar_t> buff(strGb2312.size());
#ifdef _MSC_VER
    std::locale loc("zh-CN");
#else
    std::locale loc("zh_CN.GB18030");
#endif
    wchar_t* pwszNext = nullptr;
    const char* pszNext = nullptr;
    mbstate_t state = {};
    int res = std::use_facet<std::codecvt<wchar_t, char, mbstate_t> >
        (loc).in(state,
            strGb2312.data(), strGb2312.data() + strGb2312.size(), pszNext,
            buff.data(), buff.data() + buff.size(), pwszNext);

    if (std::codecvt_base::ok == res)
    {
        std::wstring_convert<std::codecvt_utf8<wchar_t>> cutf8;
        return cutf8.to_bytes(std::wstring(buff.data(), pwszNext));
    }

    return "";

}

static std::string utf8_to_gb2312(std::string const &strUtf8)
{
    std::wstring_convert<std::codecvt_utf8<wchar_t>> cutf8;
    std::wstring wTemp = cutf8.from_bytes(strUtf8);
#ifdef _MSC_VER
    std::locale loc("zh-CN");
#else
    std::locale loc("zh_CN.GB18030");
#endif
    const wchar_t* pwszNext = nullptr;
    char* pszNext = nullptr;
    mbstate_t state = {};

    std::vector<char> buff(wTemp.size() * 2);
    int res = std::use_facet<std::codecvt<wchar_t, char, mbstate_t> >
        (loc).out(state,
            wTemp.data(), wTemp.data() + wTemp.size(), pwszNext,
            buff.data(), buff.data() + buff.size(), pszNext);

    if (std::codecvt_base::ok == res)
    {
        return std::string(buff.data(), pszNext);
    }
    return "";
}
#endif // __cplusplus >= 201103L

5.3 不直接返回std::string.c_str()

泄露内存地址会引起奇怪的问题。
一般第一次运行没错,相同代码执行第二次就会报错。
怀疑是异步调用的原因导致分配管理有误。

我的做法是,
在栈上分配若干个静态的const char全局指针。
对每个以consr char
返回的导出函数,
实际返回时使用std::strcpy复制到栈上,
最后返回全局指针。

这样多次执行也能确保内存管理正确。
示例:

char* msg = new char[99999];

extern "C" const char* helloWorld(){
	std::string s= "hello world";
    std::strcpy(msg, s.c_str());
    return msg;
}

6. Embind支持绑定cpp函数名

不必extern “C”
详见
https://emscripten.org/docs/porting/connecting_cpp_and_javascript/embind.html#embind

7.错误捕获

-sNO_DISABLE_EXCEPTION_CATCHING

-sEXCEPTION_CATCHING_ALLOWED=[..]

文件 I/O 能力

Web浏览器通常会有安全策略,限制用户对本地文件的访问。
而且现代js通常跑在浏览器的沙箱环境下,无法直接获得当前操作系统的文件目录结构。
不像C++可以通过fopen等直接打开一个本地文件。

所以如果你的c++代码中访问了某个文件,编译成wasm后,跑在浏览器中,就读不到任何东西了。

但实际开发中,必然存在访问某个本地文件的需求。

一种比较传统的解决方案是

  1. 通过html组件获得文件句柄 (浏览器安全策略) <input type="file" />
  2. 通过句柄访问文件获得内容
  3. 保存内容至页面根目录下
  4. c++代码访问页面根目录下的文件

上述步骤3和4,可以压缩为c++直接访问js读取到的内容(以string形式传递),减少两次IO时间。
这样做只把内容保留在内存中,而不是硬盘上。

另一种是采用chrome86起提供的新能力

https://developer.mozilla.org/en-US/docs/Web/API/File_System_Access_API
FireFox、Safari不支持(截止22.10.28)

报错 fatal error: 'windows.h' file not found

默认的clang include不带 windows相关的头文件。
因为浏览器不能直接访问系统api。
所以各种系统api都是不能迁移的。
自然也不会带windows.h

官方回复在https://github.com/emscripten-core/emsdk/issues/153

偷图
在这里插入图片描述

//https://wildsilicon.com/blog/2018/emscripten-webpack/

  • 2
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值