文章目录
本文主要介绍使用c/c++进行WebAssembly开发及编译的方法。关于WebAssembly的基础知识可以参考https://emscripten.org/index.html
一. Emscripten入门
LeftAlignedCol | CenterAlignedCol | RightAlignedCol |
---|---|---|
sampleText | sampleText | sampleText |
leftText | centered Text | rightText |
1.1、简介
Emscripten包含一套完整的工具链, 它不依赖任何其它的编译环境。其中最重要的就是emcc和em++,它们类似gcc和g++。emcc使用Clang和LLVM编译出wasm,同时emcc还可以生成JavaScirpt,提供API给Node.js或者HTML中调用。
Emscripten对标准c/c++支持非常全面,Emscripten SDK用于安装整个工具链,包括emcc和LLVM等,它可以在Linux、Windows或者MacOS上安装使用。
1.2、安装Emscripten
Emscripten SDK (emsdk)安装详细指引可以参考:https://emscripten.org/docs/getting_started/downloads.html
emsdk核心驱动是用Python脚本写的,所以需要安装Python 3.6或以上版本(MacOS可能自带)。emsdk可以直接安装, 也可以下载Docker镜像。emsdk安装较简单,步骤如下(MacOS或者Linux):
#下载emsdk仓库
git clone https://github.com/emscripten-core/emsdk.git#进入目录
cd emsdk#运行以下emsdk命令从GitHub获取最新工具,并将其激活
git pull
#Download and install the latest SDK tools.
./emsdk install latest
#激活已安装的Emscripten
./emsdk activate latest#最后在新建的终端窗口中切换到emsdk所在目录,执行
source ./emsdk_env.sh
#现在就可以使用emcc和em++命令进行编译,需要注意的是每打开一个终端窗口都需要执行一次该命令
在Windows上,安装流程类似,区别在于适应emsdk代替./emsdk, emsdk_env.bat 代替source ./emsdk_env.sh。
执行emcc -v 可以查看版本信息
1.3、Hello World
以"Hello, world" 例子入手,介绍如何使用Emscripten编译C/C++代码并运行测。
1.3.1 生成wasm
新建一个test.cpp,代码如下
//test.cpp
#include <stdio.h>
int main() {
printf("hello, world \n");
return 0;
}
进入控制台,记得每次都要先进入emsdk目录运行source ./emsdk_env.sh命令。切换至test.cpp目录,运行
MacBook-Pro:hello zhaohaibo$ em++ test.cpp
编译后目录下生成两个文件如下:
其中a.out.wasm为c/c++源文件编译后生成的的WebAssembly汇编文件;a.out.js是Emscripten生成的胶水代码,其中包含了Emscripten的运行环境和wasm的封装,导入a.out.js即可自动完成.wasm载入、实例化、运行时初始化等繁杂的工作。
使用-o选项可以指定emcc的输出文件,执行下列命令:
MacBook-Pro:hello zhaohaibo$ em++ test.cpp -o hello.js
编译后会生成hello.wasm和hello.js文件
1.3.2 网页测试
c/c++被编译为WebAssembly后无法直接运行, 我们需要将它导入网页并发布后, 通过浏览器执行。在上一步目录下新建一个test.html文件:
<body>
<h1>Hello World test</h1>
<script type="text/javascript" src="hello.js"></script>
</body>
将该目录通过http协议发布:
emrun --no_browser --port 8080 .
使用浏览器打开http://0.0.0.0:8080/test.html, 在控制台可以看到如下输出:
1.3.3 在Node.js中测试
WebAssembly不仅可以在网页中运行,也可以在Node.js中运行,Emscripten自带了Node.js环境, 所以可以直接用!!#ff9900 node!!来测试:
1.3.4 生成测试web页面
使用emcc/em++命令时,若指定输出文件后缀为.html,那么Emscripten不仅会生成.wasm汇编文件、胶水代码.js, 还会额外生成一个Emscripten测试页面, 命令如下:
MacBook-Pro:hello zhaohaibo$ em++ test.cpp -o hello.html
将目录发布后(emrun), 使用浏览器访问hello.html, 页面如下:
Emscripten自动生成的测试页面很方便,但是生成html文件巨大,后面都使用手动编写网页进行测试。
1.4、胶水代码
上面生成的hello.js就是JavaScript胶水代码, 大多数的调用都是围绕着全局对象Module展开,该对象正是Emscripten程序运行时的核心所在,加载wasm模块也是在其中进行,这个加载过程是异步执行的。
胶水代码主要做两件事:
1)加载wasm模块
2)导出c/c++函数
关于wasm的加载可以仔细阅读hello.js中代码及官方文档。
为了方便调用,Emscripten在胶水代码中对c/c++导出函数提供了封装, 在hello.js中,找到大量这样封装的代码
我们可以直接通过Module来调用这些导出函数,在上图中可见main函数被默认导出了,可以在控制台中直接调用Module._main()
本节简单介绍了下胶水代码,关于如何在c/c++代码中导出函数接口,以及在web页面中调用,将在后面讲解。
1.5 编译目标及流程
通常以WebAssembly为编译目标时,c/c++代码会被编译为.wasm文件和对应的.js胶水代码文件。wasm是二进制格式,体积较小, 执行效率高,因此对性能要求较高的模块可以使用c/c++代码实现,然后通过Emscripten编译生成WebAssembly给web调用。
emcc/em++编译C/C++代码的流程如下:
由于内部使用了clang,因此emcc支持绝大多数clang编译选项,可以通过emcc --help查看。
二. C/C++与JavaScript交互
对一个WebAssembly模块而言,大多会提供导出的函数接口供外部调用。Emscripten提供了许多方法来连接JavaScript和编译后的C或C++并进行交互。
2.1 C/C++函数导出
上文中Module._main() 调用的就是c/c++中的main函数,main函数不是必须的,但是如果有的话会默认被导出。通常导出c接口函数有两种方式:(1)编译时通过*-sEXPORTED_FUNCTIONS* 导出;(2)通过宏EMSCRIPTEN_KEEPALIVE声明导出函数。
2.1.1 -sEXPORTED_FUNCTIONS
EXPORTED_FUNCTIONS告诉编译器和链接器保留符号并将其导出。在test.cpp中加一个测试函数:
extern "C" {
int int_sqrt(int x) {
return sqrt(x);
}
}
需要注意的是 extern "C"很重要。c++代码在编译时会发生name mangling,会通过函数名和其参数类型生成唯一标识符,来支持重载,这样函数名会发生修改。为了防止name mangling, 在导出函数一定要使用extern “C” 来修饰。
现在我们重新编译一下test.cpp, 并导出int_sqrt函数 :
MacBook-Pro:hello zhaohaibo$ em++ test.cpp -s EXPORTED_FUNCTIONS='["_int_sqrt",_main]' -o test.js
使用EXPORTED_FUNCTIONS需要指定导出的函数, 包括main,导出时函数前要加"_" 。
打开test.js 可以看到_int_sqrt已经被导出
2.1.2 EMSCRIPTEN_KEEPALIVE
EMSCRIPTEN_KEEPALIVE 同样能导出一个函数, 它跟将函数加到EXPORTED_FUNCTIONS中效果一样。如果导出的接口较多,使用EMSCRIPTEN_KEEPALIVE将更方便。
在跨平台开发中,通常我们会定义一个函数导出宏。导出标准c接口,extern "C"修饰符仍然是必须的。为了简化导出宏修饰,定义了EM_EXPORT_API宏如下:
#ifndef EM_EXPORT_API
#if defined(__EMSCRIPTEN__)
#include <emscripten.h>
#if defined(__cplusplus)
#define EM_EXPORT_API(rettype) extern "C" rettype EMSCRIPTEN_KEEPALIVE
#else
#define EM_EXPORT_API(rettype) rettype EMSCRIPTEN_KEEPALIVE
#endif
#else
#if defined(__cplusplus)
#define EM_EXPORT_API(rettype) extern "C" rettype
#else
#define EM_EXPORT_API(rettype) rettype
#endif
#endif
#endif
使用Emscripten编译,在预编译时__EMSCRIPTEN__总是会被提前定义。导出int_sqrt代码可以这样写:
EM_EXPORT_API(int) int_sqrt(int x) {
return sqrt(x);
}
编译 :
MacBook-Pro:hello zhaohaibo$ em++ test.cpp -o test.js
2.2 JavaScript调用C函数
上文对.js胶水代码分析,我们知道JavaScript环境中的Module对象已经封装了C环境导出的函数,封装方法的名字是下划线_加上C环境的函数名。如我们上文导出的int_sqrt函数:
可以直接通过Module._int_sqrt 调用C函数,另外一种调用方式是使用ccall/cwrap。
2.2.1 ccall/cwrap
如果使用ccall/cwrap,编译时需要添加EXPORTED_RUNTIME_METHODS选项将其导出:
MacBook-Pro:hello zhaohaibo$ em++ test.cpp -s EXPORTED_RUNTIME_METHODS='["ccall", "cwrap"]' -o test.js
在js中使用ccall调用导出接口:
/ Call C from JavaScript
var result = Module.ccall('int_sqrt', // name of C function
'number', // return type
['number'], // argument types
[28]); // arguments
// result is 5
在js中使用ccall调用导出接口:
int_sqrt = Module.cwrap('int_sqrt', 'number', ['number'])
int_sqrt(12)
int_sqrt(28)
cwrap第一个参数是函数名称,第二个是函数的返回类型,第三个是参数类型数组。
2.2.2 JS中调用C导出函数
在js中通过Module直接调用c导出函数, 测试html如下:
<body>
<h1>Test EM Func</h1>
<script type="text/javascript" src="test.js"></script>
<script>
Module._int_sqrt(10);
</script>
</body>
打开控制会发现报错:!!#ff0000 Uncaught RuntimeError: Aborted(Assertion failed: native function int_sqrt
called before runtime initialization)!!
上文说过wasm的加载是异步的,js加载完成时Emscripten的Runtime并未准备就绪,调用接口就会报错。
解决这个问题需要在Runtime准备好后才去调用导出函数。由于main()函数是在Runtime准备好后被调用,所以我们可以在main函数中发出通知, 如下:
#include <emscripten.h>
int main() {
EM_ASM( allReady() );
}
但是对wasm模块来说main函数并不是必须的所以推荐使用不依赖main函数的onRuntimeInitialized回调,有兴趣的可以在胶水js中查看该方法的回调过程。该方法的例子如下:
<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML//EN">
<body>
<h1>Test EM Func</h1>
<script type="text/javascript" src="test.js"></script>
<script>
Module.onRuntimeInitialized = function() {
Module._int_sqrt(10);
}
</script>
</body>
2.3 C/C++中调用JS代码
Emscripten提供了多种在C环境调用JavaScript的方法,包括:
1)EM_JS/EM_ASM宏内联JS代码(faster)
2)emscripten_run_script
3)JavaScript函数注入(Implement a C API in JavaScript)
2.3.1 EM_JS/EM_ASM、emscripten_run_script
EM_JS可以用来在c/c++中直接定义一个JS方法如下:
#include <emscripten.h>
EM_JS(void, call_alert, (), {
alert('hello world!');
throw 'all done';
});
int main() {
call_alert();
return 0;
}
EM_ASM的使用方式与内联汇编代码类似:
```#include <emscripten.h>
int main() {
EM_ASM(
alert('hello world!');
throw 'all done';
);
return 0;
}
emscripten_run_script_int可以直接内联一段js代码,但是其效率较低
emscripten_run_script("alert('hi')");
更快的“inline JavaScript”是使用EM_JS和EM_ASM
2.3.2 Implement a C API in JavaScript
实际上就是在JS中实现一个C接口,这就意味着,函数声明在c/c++代码中,而函数实现却在js中。
第一步,我们在test.cpp中声明两个函数:
//js_function
extern "C" {
int js_add(int v1, int v2);
void js_console_log_int(int p);
}
第二部,我们新建一个js文件,在里面实现这两个函数:
//library.js
mergeInto(LibraryManager.library, {
js_add: function (a, b) {
let ret = a + b;
document.write("<br>js_add("+a+","+b+") = " + ret);
return ret;
},
js_console_log_int: function (param) {
document.write("<br>js_console_log_int: " + param);
}
})
第三步,在编译时候执行
em++ test.cpp --js-library ./scripts/library.js -s EXPORTED_RUNTIME_METHODS='["ccall", "cwrap"]' -o test.js
–js-library ./scripts2/library.js意思是将library.js作为附加库参与链接。
最后开启本地http服务,在test.html中测试:
打开控制查看结果
三. Emscripten runtime environment
3.1 main函数
在一个c/c++写的包含图形界面的app中,在main函数中都会有一个loop循环。在循环的每次迭代中,应用程序都会执行事件响应、处理和渲染,然后进行延迟(“等待”)以保持帧速率不变。这种无限循环在浏览器中是一个问题,这会导致页面卡住,并提出暂停或关闭页面。
通常main函数退出,意味着程序的整个生命周期结束,但是在Emscripten下情况有所不同,来看下面例子:
//test.cpp
#include <stdio.h>
#include <math.h>
#include <string>
#include <iostream>
//可以直接使用EMSCRIPTEN_KEEPALIVE宏定义导出函数
#ifndef EM_EXPORT_API
#if defined(__EMSCRIPTEN__)
#include <emscripten.h>
#if defined(__cplusplus)
#define EM_EXPORT_API(rettype) extern "C" rettype EMSCRIPTEN_KEEPALIVE
#else
#define EM_EXPORT_API(rettype) rettype EMSCRIPTEN_KEEPALIVE
#endif
#else
#if defined(__cplusplus)
#define EM_EXPORT_API(rettype) extern "C" rettype
#else
#define EM_EXPORT_API(rettype) rettype
#endif
#endif
#endif
EM_EXPORT_API(char*) addStringVal (char* v1, char* v2) {
std::string str1 = v1;
std::string str2 = v2;
static std::string strRet = "null";
strRet = std::string("{") + str1 + std::string("++") + str2 + std::string("}");
//printf("addStringVal:%s + %s = %s\n", v1, v2, strRet.c_str());
return (char*)strRet.c_str();
}
int main() {
printf("main : hello, world \n");
return 0;
}
em++ test.cpp -s EXPORTED_FUNCTIONS="['_main',"_malloc", "_free"]" -s EXPORTED_RUNTIME_METHODS='["ccall", "cwrap","allocate","UTF8ToString","allocateUTF8"]' -o test.js
从js传入c/c++的字符串,需要用到运行时函数,可通过EXPORTED_RUNTIME_METHODS导出.测试test.html:
<body>
<button id=btn_test onclick=Test() disabled = true style="width:100px;height:60px">
<font size = 3> test </font>
</button>
<h1>-- Test EM Func --</h1>
<script type="text/javascript" src="test.js"></script>
<script>
function Test(){
var str11 = "135";
var str22 = "asd";
var strPointer1 = Module.allocateUTF8(str11);
var strPointer2 = Module.allocateUTF8(str22);
var strRet = Module._addStringVal(strPointer1, strPointer2);
console.log(Module.UTF8ToString(strRet));
//document.write("<br>[_addStringVal]: " + str11 + "+" + str22 + "=" + Module.UTF8ToString(strRet));
Module._free(strPointer1);
Module._free(strPointer2);
}
Module.onRuntimeInitialized = function() {
var btn = document.getElementById("btn_test");
btn.disabled = false;
}
</script>
</body>
点击按钮测试结果:
显然,main函数退出之后,仍然可以通过Module调用C接口。由此可见,在Emscripten中main函数并不是必须的,运行时生命周期也不由其控制, main函数也并不是必须的。
Emscripten提供emscripten_set_main_loop函数在在main中模拟消息循环,它不会阻塞当前js线程,具体可参考https://emscripten.org/docs/porting/emscripten-runtime-environment.html#browser-main-loop。
3.2 Module object
Module是一个全局的JavaScript对象,通过它可以访问Emscripten API(通过EXPORTED_FUNCTIONS导出的编译后函数,以及通过EXPORTED_RUNTIME_METHODS导出的运行时函数如ccall)。另外,它有很多属性,Emscripten生成的代码在执行的不同时刻会去调用这些属性, 这些属性支持自定义,如上文中的Module.onRuntimeInitialized,它会在运行时初始化完成后被调用。相关详情可参考:https://emscripten.org/docs/api_reference/module.html#creating-the-module-object 。
3.2.1 Module 属性测试
开发人员可以提供Module的实现来控制代码的执行。例如,我们可以实现Module.print属性,更改标准输出,测试html:
<body>
<button id=btn_test onclick=Test() disabled = true style="width:100px;height:60px">
<font size = 3> test </font>
</button>
<h1>-- Test EM Func --</h1>
<script>
Module = {};
Module.print = function(e) {
alert(e);
};
</script>
<script type="text/javascript" src="test.js"></script>
</body>
效果如下:
![[外链图片转存中…(img-8NJFs4NG-1699235778651)](https://img-blog.csdnimg.cn/970c788a93a6472eaeb6e0b8b7451f17.png)
main函数中的printf输出变成了alert弹窗
3.2.2 Module 定制,–pre-js、–post-js
使用emcc的*–pre-js* 编译选项可以将自定义代码插入到胶水js前面;–post-js与之相反可以将自定义代码插入到胶水代码后面。通常当我们修改Module的属性或其他行为时,我们应该使用*–pre-js*编译选项将其放在胶水代码最前面,因为js代码是顺序执行的,这样才能保证我们自定义的属性或行为全局生效。下面通过一个例子来演示。
新建一个pre.js文件,修改print属性:
//pre.js
Module = {};
Module.print = function(e) {
console.log('[pre.js]: ', e);
}
新建一个post.js文件:
//post.js
console.log('post.js');
重新编译:
em++ test.cpp -s EXPORTED_FUNCTIONS="['_main',"_malloc", "_free"]" -s EXPORTED_RUNTIME_METHODS='["ccall", "cwrap","allocate","UTF8ToString","allocateUTF8"]' --pre-js ./scripts/pre.js --post-js ./scripts/post.js -o test.js
运行test.html测试:
可以发现c/c++代码中printf输出前面都加了“[pre.js]:”前缀。先打印“post.js”是因为wasm异步加载的原因,打开胶水代码文件会发现pre.js和post.js中代码分别放在了最前和最后。
在上面的pre.js中,我们能够直接使用运行时函数而并不需要导出,cwrap、allocate等,如果是在外部则要EXPORTED_RUNTIME_METHODS导出。
3.3 File System
跨平台开发中,通常使用fopen()/fread()/fwrite()等libc/libcxx提供的同步文件访问函数。通常JavaScript和C/C++在文件操作上有巨大差异,Emscripten提供了一套虚拟文件系统,以兼容libc/libcxx的同步文件访问函数。Emscripten虚拟文件系统架构如下:
如上图所示,Emscripten提供了5种文件系统,分别为:
1) MEMFS:运行时默认包含的,所有文件都存在内存中,页面重载写入的数据都会消失。
2)NODEFS:此文件系统仅在node.js内部运行时使用,编译时添加-lnodefs.js选项。
3)IDBFS:IndexedDB文件系统,仅在浏览器内运行代码时使用,编译时添加-lidbfs.js选项。
4)WORKERFS :该文件系统只能在一个worker中使用,并且只能只读访问,编译时添加-lworkerfs.js 选项
5)PROXYFS
Emscripten文件系统包含的东西非常多,此处不在介绍,有兴趣的可以参考https://emscripten.org/docs/api_reference/Filesystem-API.html#
3.3 其他
上大部份在native中能实现的高级功能都能在Emscripten环境下实现,如通过pthread或者worker实现多线程、网络访问等等,有兴趣可以查看官方文档。
四. 编译及工程化
4.1 emcc 编译参数
Emscripten编译器前端(emcc)用来从命令行调用编译程序,实际上他是标准编译器(如gcc或clang)的替代品。大部份gcc和clang的编译选项,emcc都能够使用。本文前面用到的的编译选项:
-s EXPORTED_FUNCTIONS=[“_foo”,"bar"] *, 导出接口函数
-s EXPORTED_RUNTIME_METHODS=‘[“ccall”, “cwrap”]’, 导出运行时函数
–pre-js , 在胶水js前插入代码
–post-js , 在胶水js后插入代码
–js-library , 除了Emscripten核心库(src/library)之外,还可以使用的JavaScript库。
-lnodefs.js,-lidbfs.js,-lworkerfs.js, 使用虚拟文件系统
-o , 生成可执行文件, .js生成js和.wasm; .html生成.js, .wasm和测试html
一些其他的编译选项:
–preload-file 使用fopen等c函数打包文件
-O0、 -O1、-O2、-O3、-Og、-Os、-Oz,这些都是编译优化选项。
关于emcc编译参数详情可以参考https://emscripten.org/docs/tools_reference/emcc.html
4.2 大型工程编译
emcc编译时,输入文件可以是c或者cpp源代码文件,也可以是emcc编译后生成的objects文件如.o 或者.a。对一个大型c/c++工程,无法直接使用emcc编译生成目标文件,通常我们需要两步:
1)先编译生成.a静态库,这个文件包含emcc可以编译到最终JavaScript+WebAssembly中的内容。
2)通过emcc 将静态库文件和导出的接口cpp编译链接后生成目标文件(.js,.wasm)。
第一步跟我们使用gcc编译静态库基本一样,如果我们工程使用CMake进行编译,那么我们基本上不需要修改CMakeList.text, 只需要替换下CMake和make命令:
emcmake cmake .. DCMAKE_CXX_COMPILER=em++ -DCMAKE_C_COMPILER=emcc
emmake make
实际上在当前终端窗口已经默认设置了c/c++编译器是emcc/em++,所以可以不需要DCMAKE_CXX_COMPILER去指定。
第二步跟我们前面测试时的编译指令类似:
em++ libSuperSund.a export.cpp \
-O3 \
-o supersound.js \
-s FORCE_FILESYSTEM=1 \
-lidbfs.js \
--js-library ./scripts/library.js \
-s EXPORTED_RUNTIME_METHODS='["ccall", "cwrap", "allocate","UTF8ToString"]' \
--pre-js ./scripts/pre.js \
--post-js ./scripts/post.js \
-s ALLOW_MEMORY_GROWTH=1
执行后就会生成目标胶水js和wasm文件。注意,导出函数较多不建议用EXPORTED_FUNCTIONS选项,而是代码中使用EMSCRIPTEN_KEEPALIVE宏导出。@[toc]
本文主要介绍使用c/c++进行WebAssembly开发及编译的方法。关于WebAssembly的基础知识可以参考https://emscripten.org/index.html
一. Emscripten入门
LeftAlignedCol | CenterAlignedCol | RightAlignedCol |
---|---|---|
sampleText | sampleText | sampleText |
leftText | centered Text | rightText |
1.1、简介
Emscripten包含一套完整的工具链, 它不依赖任何其它的编译环境。其中最重要的就是emcc和em++,它们类似gcc和g++。emcc使用Clang和LLVM编译出wasm,同时emcc还可以生成JavaScirpt,提供API给Node.js或者HTML中调用。
Emscripten对标准c/c++支持非常全面,Emscripten SDK用于安装整个工具链,包括emcc和LLVM等,它可以在Linux、Windows或者MacOS上安装使用。
1.2、安装Emscripten
Emscripten SDK (emsdk)安装详细指引可以参考:https://emscripten.org/docs/getting_started/downloads.html
emsdk核心驱动是用Python脚本写的,所以需要安装Python 3.6或以上版本(MacOS可能自带)。emsdk可以直接安装, 也可以下载Docker镜像。emsdk安装较简单,步骤如下(MacOS或者Linux):
#下载emsdk仓库
git clone https://github.com/emscripten-core/emsdk.git#进入目录
cd emsdk#运行以下emsdk命令从GitHub获取最新工具,并将其激活
git pull
#Download and install the latest SDK tools.
./emsdk install latest
#激活已安装的Emscripten
./emsdk activate latest#最后在新建的终端窗口中切换到emsdk所在目录,执行
source ./emsdk_env.sh
#现在就可以使用emcc和em++命令进行编译,需要注意的是每打开一个终端窗口都需要执行一次该命令
在Windows上,安装流程类似,区别在于适应emsdk代替./emsdk, emsdk_env.bat 代替source ./emsdk_env.sh。
执行emcc -v 可以查看版本信息
1.3、Hello World
以"Hello, world" 例子入手,介绍如何使用Emscripten编译C/C++代码并运行测。
1.3.1 生成wasm
新建一个test.cpp,代码如下
//test.cpp
#include <stdio.h>
int main() {
printf("hello, world \n");
return 0;
}
进入控制台,记得每次都要先进入emsdk目录运行source ./emsdk_env.sh命令。切换至test.cpp目录,运行
MacBook-Pro:hello zhaohaibo$ em++ test.cpp
编译后目录下生成两个文件如下:
其中a.out.wasm为c/c++源文件编译后生成的的WebAssembly汇编文件;a.out.js是Emscripten生成的胶水代码,其中包含了Emscripten的运行环境和wasm的封装,导入a.out.js即可自动完成.wasm载入、实例化、运行时初始化等繁杂的工作。
使用-o选项可以指定emcc的输出文件,执行下列命令:
MacBook-Pro:hello zhaohaibo$ em++ test.cpp -o hello.js
编译后会生成hello.wasm和hello.js文件
1.3.2 网页测试
c/c++被编译为WebAssembly后无法直接运行, 我们需要将它导入网页并发布后, 通过浏览器执行。在上一步目录下新建一个test.html文件:
<body>
<h1>Hello World test</h1>
<script type="text/javascript" src="hello.js"></script>
</body>
将该目录通过http协议发布:
emrun --no_browser --port 8080 .
使用浏览器打开http://0.0.0.0:8080/test.html, 在控制台可以看到如下输出:
1.3.3 在Node.js中测试
WebAssembly不仅可以在网页中运行,也可以在Node.js中运行,Emscripten自带了Node.js环境, 所以可以直接用!!#ff9900 node!!来测试:
1.3.4 生成测试web页面
使用emcc/em++命令时,若指定输出文件后缀为.html,那么Emscripten不仅会生成.wasm汇编文件、胶水代码.js, 还会额外生成一个Emscripten测试页面, 命令如下:
MacBook-Pro:hello zhaohaibo$ em++ test.cpp -o hello.html
将目录发布后(emrun), 使用浏览器访问hello.html, 页面如下:
Emscripten自动生成的测试页面很方便,但是生成html文件巨大,后面都使用手动编写网页进行测试。
1.4、胶水代码
上面生成的hello.js就是JavaScript胶水代码, 大多数的调用都是围绕着全局对象Module展开,该对象正是Emscripten程序运行时的核心所在,加载wasm模块也是在其中进行,这个加载过程是异步执行的。
胶水代码主要做两件事:
1)加载wasm模块
2)导出c/c++函数
关于wasm的加载可以仔细阅读hello.js中代码及官方文档。
为了方便调用,Emscripten在胶水代码中对c/c++导出函数提供了封装, 在hello.js中,找到大量这样封装的代码
我们可以直接通过Module来调用这些导出函数,在上图中可见main函数被默认导出了,可以在控制台中直接调用Module._main()
本节简单介绍了下胶水代码,关于如何在c/c++代码中导出函数接口,以及在web页面中调用,将在后面讲解。
1.5 编译目标及流程
通常以WebAssembly为编译目标时,c/c++代码会被编译为.wasm文件和对应的.js胶水代码文件。wasm是二进制格式,体积较小, 执行效率高,因此对性能要求较高的模块可以使用c/c++代码实现,然后通过Emscripten编译生成WebAssembly给web调用。
emcc/em++编译C/C++代码的流程如下:
由于内部使用了clang,因此emcc支持绝大多数clang编译选项,可以通过emcc --help查看。
二. C/C++与JavaScript交互
对一个WebAssembly模块而言,大多会提供导出的函数接口供外部调用。Emscripten提供了许多方法来连接JavaScript和编译后的C或C++并进行交互。
2.1 C/C++函数导出
上文中Module._main() 调用的就是c/c++中的main函数,main函数不是必须的,但是如果有的话会默认被导出。通常导出c接口函数有两种方式:(1)编译时通过*-sEXPORTED_FUNCTIONS* 导出;(2)通过宏EMSCRIPTEN_KEEPALIVE声明导出函数。
2.1.1 -sEXPORTED_FUNCTIONS
EXPORTED_FUNCTIONS告诉编译器和链接器保留符号并将其导出。在test.cpp中加一个测试函数:
extern "C" {
int int_sqrt(int x) {
return sqrt(x);
}
}
需要注意的是 extern "C"很重要。c++代码在编译时会发生name mangling,会通过函数名和其参数类型生成唯一标识符,来支持重载,这样函数名会发生修改。为了防止name mangling, 在导出函数一定要使用extern “C” 来修饰。
现在我们重新编译一下test.cpp, 并导出int_sqrt函数 :
MacBook-Pro:hello zhaohaibo$ em++ test.cpp -s EXPORTED_FUNCTIONS='["_int_sqrt",_main]' -o test.js
使用EXPORTED_FUNCTIONS需要指定导出的函数, 包括main,导出时函数前要加"_" 。
打开test.js 可以看到_int_sqrt已经被导出
2.1.2 EMSCRIPTEN_KEEPALIVE
EMSCRIPTEN_KEEPALIVE 同样能导出一个函数, 它跟将函数加到EXPORTED_FUNCTIONS中效果一样。如果导出的接口较多,使用EMSCRIPTEN_KEEPALIVE将更方便。
在跨平台开发中,通常我们会定义一个函数导出宏。导出标准c接口,extern "C"修饰符仍然是必须的。为了简化导出宏修饰,定义了EM_EXPORT_API宏如下:
#ifndef EM_EXPORT_API
#if defined(__EMSCRIPTEN__)
#include <emscripten.h>
#if defined(__cplusplus)
#define EM_EXPORT_API(rettype) extern "C" rettype EMSCRIPTEN_KEEPALIVE
#else
#define EM_EXPORT_API(rettype) rettype EMSCRIPTEN_KEEPALIVE
#endif
#else
#if defined(__cplusplus)
#define EM_EXPORT_API(rettype) extern "C" rettype
#else
#define EM_EXPORT_API(rettype) rettype
#endif
#endif
#endif
使用Emscripten编译,在预编译时__EMSCRIPTEN__总是会被提前定义。导出int_sqrt代码可以这样写:
EM_EXPORT_API(int) int_sqrt(int x) {
return sqrt(x);
}
编译 :
MacBook-Pro:hello zhaohaibo$ em++ test.cpp -o test.js
2.2 JavaScript调用C函数
上文对.js胶水代码分析,我们知道JavaScript环境中的Module对象已经封装了C环境导出的函数,封装方法的名字是下划线_加上C环境的函数名。如我们上文导出的int_sqrt函数:
可以直接通过Module._int_sqrt 调用C函数,另外一种调用方式是使用ccall/cwrap。
2.2.1 ccall/cwrap
如果使用ccall/cwrap,编译时需要添加EXPORTED_RUNTIME_METHODS选项将其导出:
MacBook-Pro:hello zhaohaibo$ em++ test.cpp -s EXPORTED_RUNTIME_METHODS='["ccall", "cwrap"]' -o test.js
在js中使用ccall调用导出接口:
/ Call C from JavaScript
var result = Module.ccall('int_sqrt', // name of C function
'number', // return type
['number'], // argument types
[28]); // arguments
// result is 5
在js中使用ccall调用导出接口:
int_sqrt = Module.cwrap('int_sqrt', 'number', ['number'])
int_sqrt(12)
int_sqrt(28)
cwrap第一个参数是函数名称,第二个是函数的返回类型,第三个是参数类型数组。
2.2.2 JS中调用C导出函数
在js中通过Module直接调用c导出函数, 测试html如下:
<body>
<h1>Test EM Func</h1>
<script type="text/javascript" src="test.js"></script>
<script>
Module._int_sqrt(10);
</script>
</body>
打开控制会发现报错:!!#ff0000 Uncaught RuntimeError: Aborted(Assertion failed: native function int_sqrt
called before runtime initialization)!!
上文说过wasm的加载是异步的,js加载完成时Emscripten的Runtime并未准备就绪,调用接口就会报错。
解决这个问题需要在Runtime准备好后才去调用导出函数。由于main()函数是在Runtime准备好后被调用,所以我们可以在main函数中发出通知, 如下:
#include <emscripten.h>
int main() {
EM_ASM( allReady() );
}
但是对wasm模块来说main函数并不是必须的所以推荐使用不依赖main函数的onRuntimeInitialized回调,有兴趣的可以在胶水js中查看该方法的回调过程。该方法的例子如下:
<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML//EN">
<body>
<h1>Test EM Func</h1>
<script type="text/javascript" src="test.js"></script>
<script>
Module.onRuntimeInitialized = function() {
Module._int_sqrt(10);
}
</script>
</body>
2.3 C/C++中调用JS代码
Emscripten提供了多种在C环境调用JavaScript的方法,包括:
1)EM_JS/EM_ASM宏内联JS代码(faster)
2)emscripten_run_script
3)JavaScript函数注入(Implement a C API in JavaScript)
2.3.1 EM_JS/EM_ASM、emscripten_run_script
EM_JS可以用来在c/c++中直接定义一个JS方法如下:
#include <emscripten.h>
EM_JS(void, call_alert, (), {
alert('hello world!');
throw 'all done';
});
int main() {
call_alert();
return 0;
}
EM_ASM的使用方式与内联汇编代码类似:
```#include <emscripten.h>
int main() {
EM_ASM(
alert('hello world!');
throw 'all done';
);
return 0;
}
emscripten_run_script_int可以直接内联一段js代码,但是其效率较低
emscripten_run_script("alert('hi')");
更快的“inline JavaScript”是使用EM_JS和EM_ASM
2.3.2 Implement a C API in JavaScript
实际上就是在JS中实现一个C接口,这就意味着,函数声明在c/c++代码中,而函数实现却在js中。
第一步,我们在test.cpp中声明两个函数:
//js_function
extern "C" {
int js_add(int v1, int v2);
void js_console_log_int(int p);
}
第二部,我们新建一个js文件,在里面实现这两个函数:
//library.js
mergeInto(LibraryManager.library, {
js_add: function (a, b) {
let ret = a + b;
document.write("<br>js_add("+a+","+b+") = " + ret);
return ret;
},
js_console_log_int: function (param) {
document.write("<br>js_console_log_int: " + param);
}
})
第三步,在编译时候执行
em++ test.cpp --js-library ./scripts/library.js -s EXPORTED_RUNTIME_METHODS='["ccall", "cwrap"]' -o test.js
–js-library ./scripts2/library.js意思是将library.js作为附加库参与链接。
最后开启本地http服务,在test.html中测试:
打开控制查看结果
三. Emscripten runtime environment
3.1 main函数
在一个c/c++写的包含图形界面的app中,在main函数中都会有一个loop循环。在循环的每次迭代中,应用程序都会执行事件响应、处理和渲染,然后进行延迟(“等待”)以保持帧速率不变。这种无限循环在浏览器中是一个问题,这会导致页面卡住,并提出暂停或关闭页面。
通常main函数退出,意味着程序的整个生命周期结束,但是在Emscripten下情况有所不同,来看下面例子:
//test.cpp
#include <stdio.h>
#include <math.h>
#include <string>
#include <iostream>
//可以直接使用EMSCRIPTEN_KEEPALIVE宏定义导出函数
#ifndef EM_EXPORT_API
#if defined(__EMSCRIPTEN__)
#include <emscripten.h>
#if defined(__cplusplus)
#define EM_EXPORT_API(rettype) extern "C" rettype EMSCRIPTEN_KEEPALIVE
#else
#define EM_EXPORT_API(rettype) rettype EMSCRIPTEN_KEEPALIVE
#endif
#else
#if defined(__cplusplus)
#define EM_EXPORT_API(rettype) extern "C" rettype
#else
#define EM_EXPORT_API(rettype) rettype
#endif
#endif
#endif
EM_EXPORT_API(char*) addStringVal (char* v1, char* v2) {
std::string str1 = v1;
std::string str2 = v2;
static std::string strRet = "null";
strRet = std::string("{") + str1 + std::string("++") + str2 + std::string("}");
//printf("addStringVal:%s + %s = %s\n", v1, v2, strRet.c_str());
return (char*)strRet.c_str();
}
int main() {
printf("main : hello, world \n");
return 0;
}
em++ test.cpp -s EXPORTED_FUNCTIONS="['_main',"_malloc", "_free"]" -s EXPORTED_RUNTIME_METHODS='["ccall", "cwrap","allocate","UTF8ToString","allocateUTF8"]' -o test.js
从js传入c/c++的字符串,需要用到运行时函数,可通过EXPORTED_RUNTIME_METHODS导出.测试test.html:
<body>
<button id=btn_test onclick=Test() disabled = true style="width:100px;height:60px">
<font size = 3> test </font>
</button>
<h1>-- Test EM Func --</h1>
<script type="text/javascript" src="test.js"></script>
<script>
function Test(){
var str11 = "135";
var str22 = "asd";
var strPointer1 = Module.allocateUTF8(str11);
var strPointer2 = Module.allocateUTF8(str22);
var strRet = Module._addStringVal(strPointer1, strPointer2);
console.log(Module.UTF8ToString(strRet));
//document.write("<br>[_addStringVal]: " + str11 + "+" + str22 + "=" + Module.UTF8ToString(strRet));
Module._free(strPointer1);
Module._free(strPointer2);
}
Module.onRuntimeInitialized = function() {
var btn = document.getElementById("btn_test");
btn.disabled = false;
}
</script>
</body>
点击按钮测试结果:
显然,main函数退出之后,仍然可以通过Module调用C接口。由此可见,在Emscripten中main函数并不是必须的,运行时生命周期也不由其控制, main函数也并不是必须的。
Emscripten提供emscripten_set_main_loop函数在在main中模拟消息循环,它不会阻塞当前js线程,具体可参考https://emscripten.org/docs/porting/emscripten-runtime-environment.html#browser-main-loop。
3.2 Module object
Module是一个全局的JavaScript对象,通过它可以访问Emscripten API(通过EXPORTED_FUNCTIONS导出的编译后函数,以及通过EXPORTED_RUNTIME_METHODS导出的运行时函数如ccall)。另外,它有很多属性,Emscripten生成的代码在执行的不同时刻会去调用这些属性, 这些属性支持自定义,如上文中的Module.onRuntimeInitialized,它会在运行时初始化完成后被调用。相关详情可参考:https://emscripten.org/docs/api_reference/module.html#creating-the-module-object 。
3.2.1 Module 属性测试
开发人员可以提供Module的实现来控制代码的执行。例如,我们可以实现Module.print属性,更改标准输出,测试html:
<body>
<button id=btn_test onclick=Test() disabled = true style="width:100px;height:60px">
<font size = 3> test </font>
</button>
<h1>-- Test EM Func --</h1>
<script>
Module = {};
Module.print = function(e) {
alert(e);
};
</script>
<script type="text/javascript" src="test.js"></script>
</body>
效果如下:
main函数中的printf输出变成了alert弹窗
3.2.2 Module 定制,–pre-js、–post-js
使用emcc的*–pre-js* 编译选项可以将自定义代码插入到胶水js前面;–post-js与之相反可以将自定义代码插入到胶水代码后面。通常当我们修改Module的属性或其他行为时,我们应该使用*–pre-js*编译选项将其放在胶水代码最前面,因为js代码是顺序执行的,这样才能保证我们自定义的属性或行为全局生效。下面通过一个例子来演示。
新建一个pre.js文件,修改print属性:
//pre.js
Module = {};
Module.print = function(e) {
console.log('[pre.js]: ', e);
}
新建一个post.js文件:
//post.js
console.log('post.js');
重新编译:
em++ test.cpp -s EXPORTED_FUNCTIONS="['_main',"_malloc", "_free"]" -s EXPORTED_RUNTIME_METHODS='["ccall", "cwrap","allocate","UTF8ToString","allocateUTF8"]' --pre-js ./scripts/pre.js --post-js ./scripts/post.js -o test.js
运行test.html测试:
可以发现c/c++代码中printf输出前面都加了“[pre.js]:”前缀。先打印“post.js”是因为wasm异步加载的原因,打开胶水代码文件会发现pre.js和post.js中代码分别放在了最前和最后。
在上面的pre.js中,我们能够直接使用运行时函数而并不需要导出,cwrap、allocate等,如果是在外部则要EXPORTED_RUNTIME_METHODS导出。
3.3 File System
跨平台开发中,通常使用fopen()/fread()/fwrite()等libc/libcxx提供的同步文件访问函数。通常JavaScript和C/C++在文件操作上有巨大差异,Emscripten提供了一套虚拟文件系统,以兼容libc/libcxx的同步文件访问函数。Emscripten虚拟文件系统架构如下:
如上图所示,Emscripten提供了5种文件系统,分别为:
1) MEMFS:运行时默认包含的,所有文件都存在内存中,页面重载写入的数据都会消失。
2)NODEFS:此文件系统仅在node.js内部运行时使用,编译时添加-lnodefs.js选项。
3)IDBFS:IndexedDB文件系统,仅在浏览器内运行代码时使用,编译时添加-lidbfs.js选项。
4)WORKERFS :该文件系统只能在一个worker中使用,并且只能只读访问,编译时添加-lworkerfs.js 选项
5)PROXYFS
Emscripten文件系统包含的东西非常多,此处不在介绍,有兴趣的可以参考https://emscripten.org/docs/api_reference/Filesystem-API.html#
3.3 其他
上大部份在native中能实现的高级功能都能在Emscripten环境下实现,如通过pthread或者worker实现多线程、网络访问等等,有兴趣可以查看官方文档。
四. 编译及工程化
4.1 emcc 编译参数
Emscripten编译器前端(emcc)用来从命令行调用编译程序,实际上他是标准编译器(如gcc或clang)的替代品。大部份gcc和clang的编译选项,emcc都能够使用。本文前面用到的的编译选项:
-s EXPORTED_FUNCTIONS=[“_foo”,"bar"] *, 导出接口函数
-s EXPORTED_RUNTIME_METHODS=‘[“ccall”, “cwrap”]’, 导出运行时函数
–pre-js , 在胶水js前插入代码
–post-js , 在胶水js后插入代码
–js-library , 除了Emscripten核心库(src/library)之外,还可以使用的JavaScript库。
-lnodefs.js,-lidbfs.js,-lworkerfs.js, 使用虚拟文件系统
-o , 生成可执行文件, .js生成js和.wasm; .html生成.js, .wasm和测试html
一些其他的编译选项:
–preload-file 使用fopen等c函数打包文件
-O0、 -O1、-O2、-O3、-Og、-Os、-Oz,这些都是编译优化选项。
关于emcc编译参数详情可以参考https://emscripten.org/docs/tools_reference/emcc.html
4.2 大型工程编译
emcc编译时,输入文件可以是c或者cpp源代码文件,也可以是emcc编译后生成的objects文件如.o 或者.a。对一个大型c/c++工程,无法直接使用emcc编译生成目标文件,通常我们需要两步:
1)先编译生成.a静态库,这个文件包含emcc可以编译到最终JavaScript+WebAssembly中的内容。
2)通过emcc 将静态库文件和导出的接口cpp编译链接后生成目标文件(.js,.wasm)。
第一步跟我们使用gcc编译静态库基本一样,如果我们工程使用CMake进行编译,那么我们基本上不需要修改CMakeList.text, 只需要替换下CMake和make命令:
emcmake cmake .. DCMAKE_CXX_COMPILER=em++ -DCMAKE_C_COMPILER=emcc
emmake make
实际上在当前终端窗口已经默认设置了c/c++编译器是emcc/em++,所以可以不需要DCMAKE_CXX_COMPILER去指定。
第二步跟我们前面测试时的编译指令类似:
em++ libSuperSund.a export.cpp \
-O3 \
-o supersound.js \
-s FORCE_FILESYSTEM=1 \
-lidbfs.js \
--js-library ./scripts/library.js \
-s EXPORTED_RUNTIME_METHODS='["ccall", "cwrap", "allocate","UTF8ToString"]' \
--pre-js ./scripts/pre.js \
--post-js ./scripts/post.js \
-s ALLOW_MEMORY_GROWTH=1
执行后就会生成目标胶水js和wasm文件。注意,导出函数较多不建议用EXPORTED_FUNCTIONS选项,而是代码中使用EMSCRIPTEN_KEEPALIVE宏导出。