IT博客汇
  • 首页
  • 精华
  • 技术
  • 设计
  • 资讯
  • 扯淡
  • 权利声明
  • 登录 注册

    [WebAssembly]初学笔记 从Javascript调用C++函数

    罗佳(博主)发表于 2024-08-15 17:22:38
    love 0

    如果有看不明白的地方请先看前置说明文章

    要在js中使用c++编写的函数或者在c++中运行js代码,则需要有一些方法打通这两个环境,官方已经写了一个列表列举出了所有的方法,可以参考:https://emscripten.org/docs/porting/connecting_cpp_and_javascript/Interacting-with-code.html

    在此我会对部分方法简单进行举例。

    上一篇笔记说到如果不需要自动执行main,可以不写main函数,此时我们需要使用wasm中编写的函数就需要手动设置需要导出的函数。


    函数导出

    要从C++中导出函数给js环境使用,有两种方法:一种不用改动代码,需要在编译参数上进行指定,另一种需要在代码中添加宏来指定。

    这两种方法的使用场景我认为是这样:如果有一份源码不是你写的,或者说就是引用别处的一个库,那么我们去修改其源码肯定不便于后期维护,因此在编译参数中指定导出的方法最稳妥,但如果要编译的源码就是你专门为了这个wasm项目写的,那么使用宏指定可以让编译参数更加简洁,在代码中也可以更清晰地看出导出了哪些函数。

    方法一:需要在编译时添加参数指明要导出的函数名称:

    -sEXPORTED_FUNCTIONS=_func1,_func2,_func3,其中func1,func2,func3是在c++中你定义的函数名称,作为导出函数时,需要在其名称前面添加一个下划线,然后这些函数就会被添加给wasm的实例对象。

    现在将test.cpp的内容修改为下:

    #include <iostream>
    extern "C" { // 由于C++导出方法会对方法进行重命名,这是用来指定以C形式导出方法
    
    //定义一个无返回值函数,参数为一个int
    void poi1(const int val) {
    	std::cout << "poi1: " << val << std::endl;//输出val的值
    }
    //定义一个返回整数的函数,参数为两个int
    int poi2(const int val, const int val2) {
    	std::cout << "poi2: " << val << " " << val2 << std::endl;//输出两个val的值
    	return 114514;
    }
    //定义一个无返回值函数,参数为一个bool
    void boolTest(bool val) {
    	std::cout << "bool: " << val << std::endl;
    }
    
    }

    然后执行命令编译:emcc -sMODULARIZE=1 -sASSERTIONS -sEXPORTED_FUNCTIONS=_poi1,_poi2,_boolTest -o dist/test.mjs test.cpp

    接着修改start.js来手动调用我们导出的函数,同时我也把执行结果备注在每个调用后面了:

    (async () => {
    	const { default: wasm } = await import('./dist/test.mjs');
    	const instance = await wasm();
    	// console.dir(instance);//取消该行注释以查看实例上的属性
    	instance._poi1(123);//输出 "poi1: 123"
    	instance._poi1("234");//输出 "poi1: 234"
    		//↑此处可见导出的方法会尝试把输入参数转换为原函数定义的参数类型
    	instance._poi2("123", 345);//输出 "poi2: 123 345"
    	instance._poi2("lll", "345");//输出 "poi2: 0 345"
    		//↑此处可见无法转换为目标类型的参数变成了0
    	//让poi2函数缺一个参数调用
    	const result=instance._poi2(456);//输出 "poi2: 456 0",并将函数的返回值赋值给变量
    		//↑此处可见缺失的参数变成了0
    	console.log(result);//输出函数返回的数字 114514
    
    	instance._boolTest(1);//输出 "bool: 1"
    	instance._boolTest(0);//输出 "bool: 0"
    	instance._boolTest(true);//输出 "bool: 1"
    	instance._boolTest(false);//输出 "bool: 0"
    	instance._boolTest('1');//输出 "bool: 1"
    	instance._boolTest('0');//输出 "bool: 0"
    	instance._boolTest('啊?');//输出 "bool: 0"
    	instance._boolTest([]);//输出 "bool: 0"
    	instance._boolTest([1]);//输出 "bool: 1"
    	//以上布尔值测试大概可以推测输入参数同样会被转为number,如果不能转的就是0
    })();

    方法二:引入`emscripten.h`头文件,然后在需要导出的函数前面加上 EMSCRIPTEN_KEEPALIVE 宏,简单举例:

    #include <iostream>
    #include <emscripten.h>
    extern "C" { // 由于C++导出方法会对方法进行重命名,这是用来指定以C形式导出方法
    
    EMSCRIPTEN_KEEPALIVE
    void boolTest(bool val) {
    	std::cout << "bool: " << val << std::endl;
    }
    
    }

    另外以上直接调用下划线开头函数的方法是文档中说最快的方式,但使用更复杂,比如我们需要传递字符串作为参数的话,就需要自己做一些转换,以下我也举一个例子。

    传递字符串参数示例(点击展开)

    把test.cpp内容修改如下:

    #include <iostream>
    #include <emscripten.h>
    extern "C" { // 由于C++导出方法会对方法进行重命名,这是用来指定以C形式导出方法
    
    EMSCRIPTEN_KEEPALIVE
    void stringTest(const char* str) {
    	std::cout << "string: " << str << std::endl;
    	free((void *)str);//传入的字符串指针是专为这次函数调用申请的空间,不要忘了释放内存
    }
    
    }

    然后编译:emcc -sMODULARIZE=1 -sASSERTIONS -sEXPORTED_RUNTIME_METHODS=stringToNewUTF8 -o dist/test.mjs test.cpp

    这里在编译参数中添加了一个参数:

    • –sEXPORTED_RUNTIME_METHODS=stringToNewUTF8,这个参数用于把指定的运行时内置方法添加到wasm实例对象上,这里添加的是stringToNewUTF8方法,用于为js中的字符串创建wasm中对应的内存数据并返回其内存地址。
    • 关于还有哪些内置方法可以导出,可以看这个文档。奇怪的是上面这个方法没有出现在这个文档里,在整个文档中搜索也没有其它地方对其进行说明,只有用例,不知道是不是漏写了。

    然后修改start.js,其中正确和错误用法以及其调用结果我也都注释在后面了:

    (async () => {
    	const { default: wasm } = await import('./dist/test.mjs');
    	const instance = await wasm();
    	// console.dir(instance);//取消该行注释以查看实例上多出的方法
    	//以下是错误示范
    	instance._stringTest(123456);//输出 "output:"
    	instance._stringTest("123456");//输出 "output:" 
    	instance._stringTest("佳佳菌");//输出 "output: emscgT��" 乱码
    	// 上一行调用出现警告"warning: Invalid UTF-8 leading byte 0xfffffffe encountered when deserializing a UTF-8 string in wasm memory to a JS string!"
    	// 这个函数原本的第一个参数是char*,也就是一个字符串指针,所以在js中调用时参数也需要传递字符串的指针,
    	// 因此上面使用数字或者数字字符串(实际也被转换为了数字)是被当成了指针地址,而在那个地址上正好没有数据,于是没有输出字符。
    	// 并且上面的数字参数设置过大时甚至会报超出内存边界的错误,也说明传入的数字被认为是指针地址
    
    	//以下是正确示范,注意stringToNewUTF8方法要在-sEXPORTED_RUNTIME_METHODS编译参数中指定才会存在
    	instance._stringTest(instance.stringToNewUTF8("啦啦啦"));//输出 "string: 啦啦啦"
    })();

     


    使用ccall或cwrap调用c++函数

    前面一节讲的是官方指导文档中“直接调用C函数”的部分(虽然说是直接但其实也是间接的),文档中的这一节实际上没有什么内容。官方文档中在这之前注重介绍了另一类调用c函数的方法,我之所以把这两种方法调换了顺序来讲,一是因为前一节介绍的调用方法理论速度更快,另一个原因是这节介绍的方法需要在前一节介绍的编译参数中添加新的值。

    • -sEXPORTED_RUNTIME_METHODS=ccall,cwrap,参数的意义在前一节讲过了。添加的 ccall,cwrap 这两个值是两个要被添加到实例对象上的内置方法名称,其作用就是作为一层“胶水”层来调用c函数(两个是单独使用的,所以也可以只添加其中一个,这里我两个一起举例)。

    下面直接放出用例,将test.cpp内容修改如下:

    #include <emscripten.h>
    #include <iostream>
    using std::cout;
    using std::endl;
    extern "C" { // 由于C++导出方法会对方法进行重命名,这是用来指定以C形式导出方法
    EMSCRIPTEN_KEEPALIVE
    void stringTest(const char *str) {
    	cout << "string: " << str << endl;
    	free((void *)str); // 传入的字符串指针是专为这次函数调用申请的空间,不要忘了释放内存
    }
    EMSCRIPTEN_KEEPALIVE
    void boolTest(bool val) {
    	cout << "bool: " << val << endl;
    }
    EMSCRIPTEN_KEEPALIVE
    void intTest(int val, int val2) {
    	cout << "int: " << val << " " << val2 << endl;
    }
    EMSCRIPTEN_KEEPALIVE
    void doubleTest(double val) {
    	cout << "double: " << val << endl;
    }
    EMSCRIPTEN_KEEPALIVE
    void arrayTest(uint8_t *arr, int length) {
    	for (int i = 0; i < length; i++) {
    		//注意对类型进行转换,cout会把uint8_t当作字符指针
    		cout << "array[" << i << "]: " << (unsigned int)arr[i] << endl;
    	}
    	free((void *)arr); // 不要忘了释放内存
    }
    
    }

    执行命令编译:emcc -sMODULARIZE=1 -sASSERTIONS -sEXPORTED_RUNTIME_METHODS=ccall,cwrap -o dist/test.mjs test.cpp

    接着把start.js修改为以下内容进行测试,我把测试结果注释在了每个函数调用之后:

    (async () => {
    	const { default: wasm } = await import('./dist/test.mjs');
    	const instance = await wasm();
    	// console.dir(instance);//取消该行注释以查看实例上多出的方法
    
    	//doubleTest示例
    	//直接调用
    	instance._doubleTest(114.514);//输出 "double: 114.514"
    
    	//使用ccall调用
    	instance.ccall(
    		'doubleTest', //调用方法名称
    		null,//返回值类型,没有返回值填null,其他可用值为 "number"|"string"|"array"
    		['number'],//参数类型列表
    		[3.1415926],//调用参数列表
    	);//输出 "double:3.14159"
    
    	// 使用cwarp包装函数后调用
    	const doubleTest = instance.cwrap(
    		'doubleTest', //调用方法名称
    		null, //返回值类型
    		['number'],//参数类型列表
    	);
    	doubleTest(987654321);//输出 "double: 9.87654e+08"
    	
    	// stringTest示例
    	instance.ccall(
    		'stringTest',null,['string'],['苟利国家生死以'],
    	);//输出 "string: 苟利国家生死以"
    	// 可以看到不再需要使用stringToNewUTF8这个方法传递指针,ccall会自动处理
    
    	// 使用boolTest方法进行举例
    	instance.ccall('boolTest',null,['number'],[true]);//输出 "bool: 1"
    	instance.ccall('boolTest',null,['number'],[false]);//输出 "bool: 0"
    
    	// intTest示例
    	instance.ccall('intTest',null,['number'],[1234567890]);//输出 "int: 1234567890 0"
    	//我们编写的intTest函数有两个int参数,这里我故意缺少一个参数,可以看到缺少的参数会变成0
    	instance.cwrap('intTest',null,['number','number'])('666',999);//输出 "int: 666 999"
    	//同时可以观察到这种调用也会尝试把参数转换到目标类型
    
    	// arrayTest示例
    	const arr=[8,7,6,2,4];
    	instance.ccall('arrayTest',null,['array','number'],[arr,arr.length]);
    	/* 输出:
    		array[0]: 8
    		array[1]: 7
    		array[2]: 6
    		array[3]: 2
    		array[4]: 4
    	*/
    	//经测试,对于数组参数的传入似乎只能接受uint8_t类型的数组,在c++端使用其它类型我无法获得正确的值
    	//对于上面的结论如果有误请留言纠正
    })();

    以上就是使用ccall和cwarp调用c函数的用法。


    使用Embind调用c++函数

    由于embind是单独的实现这类功能一个库,有很多功能,所以我单独写了一篇笔记,请参考这篇。



沪ICP备19023445号-2号
友情链接