本文主要介绍使用c/c++进行WebAssembly开发及编译的方法。关于WebAssembly的基础知识可以参考https://emscripten.org/index.html

一. Emscripten入门

LeftAlignedColCenterAlignedColRightAlignedCol
sampleTextsampleTextsampleText
leftTextcentered TextrightText

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, 页面如下:
!](https://img-blog.csdnimg.cn/50813c8246634a868f435c9b417b3ed3.png)

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中测试:
在这里插入图片描述

打开控制查看结果
[外链图片转存中...(img-DEz8t349-1699235778651)]

三. 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> 

点击按钮测试结果:

[外链图片转存中...(img-Q1WPW9wL-1699235778651)]

显然,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虚拟文件系统架构如下:
[外链图片转存中...(img-Rh4yBiSP-1699235778651)]

如上图所示,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入门

LeftAlignedColCenterAlignedColRightAlignedCol
sampleTextsampleTextsampleText
leftTextcentered TextrightText

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宏导出。

Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐