前言
需求是有一个C++写的工具包(负责大规模的数据运算)。
需要用emscripten是把C/C++编译成WebAssembly,便于在JS环境之后执行。
最终在React项目中调用工具包。
数据类型通信
em在通信层,对数据类型的支持非常稀少。
如下所示只有3种。
JS侧 | C侧 |
---|---|
number | C integer, float, or general pointer |
string | char* |
array | array |
不过实践起来这三种似乎也够用…
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
表示我们即将用ccall
和cwrap
的方式调用这些导出的函数。
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.js
和 hello.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编码
cwrap
和ccall
的原理,都是在接收到 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后,跑在浏览器中,就读不到任何东西了。
但实际开发中,必然存在访问某个本地文件的需求。
一种比较传统的解决方案是
- 通过html组件获得文件句柄 (浏览器安全策略)
<input type="file" />
- 通过句柄访问文件获得内容
- 保存内容至页面根目录下
- 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/