软件技术学习笔记

个人博客,记录软件技术与程序员的点点滴滴。

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++代码之后,wasmTableasmLibraryArg的内容可能需要重新适配,不容易维护。原因是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