Emscripten docker构建WebAssembly (C++)
C++的构建环境比别的计算机语言要复杂得多,一切从头开始安装是最麻烦的,比如:安装编译器、第三方库等。在容器时代,使用已准备好的Docker镜像就省事多了。在开发WebAssembly C++时,可直接使用Emscripten SDK docker镜像。虽然C++ WebAssembly的构建比较复杂,但是,其生成的目标文件(*.wasm)比GoLang的要小很多,更适合于Web网络传输。
示例代码,见: hello-web-assembly-cpp
1. 编写C++代码
为了减少结果wasm文件的大小,与IO相关的函数优先使用C函数,比如:使用 printf
代替std::cout
。
我们编写4个文件,导出几个函数:testCall
, printNumber
, square
, multiply
。导出的函数必需使用extern "C"
修饰。
multiply
函数使用到C++14的std::make_unique,也验证C++ class继承/多态的构造与析构次序。
// hello.hpp
extern "C" {
int main(int argc, char const *argv[]);
void testCall();
void printNumber(int f);
int square(int c);
int multiply(int a, int b);
}
// hello.cpp
#include <iostream>
#include <memory>
#ifdef __EMSCRIPTEN__
#include <emscripten.h>
#endif
#include "hello.hpp"
#include "my_class.hpp"
int main(int argc, char const *argv[])
{
// std::cout << "Hello World!" << std::endl;
printf("Hello World!\n");
return 0;
}
void testCall()
{
printf("function was called!\n");
}
void printNumber(int f) {
printf("Printing the number %d\n", f);
}
int square(int c)
{
return c*c;
}
int multiply(int a, int b) {
std::unique_ptr<MyClassIF> ptr = std::make_unique<MyClassImpl>();
return ptr->Multiply(a, b);
}
// my_class.hpp
class Base {
public:
Base();
virtual ~Base();
};
class MyClassIF : public Base {
public:
virtual int Multiply(int a, int b) = 0;
};
class MyClassImpl : public MyClassIF {
public:
MyClassImpl();
virtual ~MyClassImpl() override;
virtual int Multiply(int a, int b) override;
};
// my_class.cpp
#include <stdio.h>
#ifdef __EMSCRIPTEN__
#include <emscripten.h>
#endif
#include "my_class.hpp"
Base::Base() {
printf("calling: Base::Base()\n");
}
Base::~Base() {
printf("calling: Base::~Base()\n");
}
MyClassImpl::MyClassImpl() {
printf("calling: MyClassImpl::MyClassImpl()\n");
}
MyClassImpl::~MyClassImpl() {
printf("calling: MyClassImpl::~MyClassImpl()\n");
}
int MyClassImpl::Multiply(int a, int b) {
printf("calling: MyClassImpl::Multiply()\n");
return a * b;
}
2. 构建脚本 build.sh
为了不让C/C++模块退出,需要添加-s NO_EXIT_RUNTIME=1
。emcc的参数跟clang的差不多一样。
#!/bin/sh
rm -Rf output
mkdir output
docker run \
--rm \
-v $(pwd):$(pwd) \
-u $(id -u):$(id -g) \
emscripten/emsdk \
emcc $(pwd)/src/hello.cpp $(pwd)/src/my_class.cpp \
--std=c++14 --shell-file shell_minimal.html \
--emrun -o $(pwd)/output/hello.html -s NO_EXIT_RUNTIME=1 \
-s EXPORTED_FUNCTIONS="['_main', '_testCall', '_printNumber','_square', '_multiply']" \
-s EXTRA_EXPORTED_RUNTIME_METHODS="['cwrap','ccall']" -s WASM=1
执行build.sh之后,在output目录生成hello.html、hello.js、hello.wasm。
NOTE: 构建WASM时,链接的lib均是静态库。添加emscripten未包含的第三方库时,可以从emscripten/emsdk镜像继承,再使用emcc构建出第三方的static lib,做成自己的myname/emsdk Docker镜像,以后使用该镜像做构建镜像。
3. 运行结果
到output目录,执行 anywhere -p 8080
启动本地服务,浏览 hello.html 文件。
在浏览器console输入:Module._multiply(2, 3)
输出如下:
calling: Base::Base()
calling: MyClassImpl::MyClassImpl()
calling: MyClassImpl::Multiply()
calling: MyClassImpl::~MyClassImpl()
calling: Base::~Base()
4. 分析WASM加载
虽然生成的hello.js有118KB,但是,其核心仍然是WebAssembly.instantiateStreaming(source, importObject)
函数的importObject
参数。在生成环境中,我们可以提取自己的importObject,避免文件太大。
4.1. 提取启动文件
在示例代码的output_mini/wasm_exec_cpp.js
就是已提取的启动文件,其文件大小从原先的118KB减到11KB。核心代码是组装asmLibraryArg对象,它包含传递给C++ WASM的许多函数、memory与table。其尾部片段如下:
// wasm_exec_cpp.js
function createAsmArg() {
...
var wasmTable = new WebAssembly.Table({
'initial': 27,
'maximum': 27 + 0,
'element': 'anyfunc'
});
...
const asmGlobalArg = {};
const asmLibraryArg = {
__cxa_atexit: ___cxa_atexit,
__handle_stack_overflow: ___handle_stack_overflow,
abort: _abort,
emscripten_get_sbrk_ptr: _emscripten_get_sbrk_ptr,
emscripten_memcpy_big: _emscripten_memcpy_big,
emscripten_resize_heap: _emscripten_resize_heap,
fd_write: _fd_write,
memory: wasmMemory,
setTempRet0: _setTempRet0,
table: wasmTable
};
return {asmGlobalArg, asmLibraryArg};
}
global.Cpp = class Cpp {
constructor() {
const arg = createAsmArg();
this.importObject = {
env: arg.asmLibraryArg,
wasi_snapshot_preview1: arg.asmLibraryArg,
};
}
};
global.Cpp.createWasm = (url) => {
const cpp = new global.Cpp();
return WebAssembly.instantiateStreaming(fetch(url), cpp.importObject).then((asm) => {
const asmExports = asm.instance.exports;
// Init global objects
if (asmExports.__wasm_call_ctors) {
asmExports.__wasm_call_ctors();
}
// Call main
if (asmExports.main) {
if (asmExports.__set_stack_limit) {
const STACK_MAX = 4400;
asmExports.__set_stack_limit(STACK_MAX);
}
asmExports.main();
// No exit here!
}
return asm;
});
};
注意:虽然我们这样提取是可以运行的,但是每次修改C++代码之后,wasmTable
与asmLibraryArg
的内容可能需要重新适配,不容易维护。原因是C++部分依赖的importObject
有变化,或者asm.instance.exports
有变化。
4.2. 启动WASM
新建hello.html,使用Cpp.createWasm()
函数加载WASM实例,最后使用instance.exports导出的函数。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hello Web Assembly</title>
</head>
<body>
<p id="result">Loading...</p>
<script src="wasm_exec_cpp.js"></script>
<script>
Cpp.createWasm('hello.wasm').then((asm) => {
window.helloExports = asm.instance.exports;
const a = 2;
const b = 3;
const result = helloExports.multiply(a, b);
document.getElementById('result').textContent = `${a} x ${b} = ${result}`;
}, (err) => {
console.error(err);
});
</script>
</body>
</html>
打开页面,我们可以看到:
2 x 3 = 6