Open metroluffy opened 5 years ago
大概在7月份的时候,我重构了公司直播项目中的语音录制模块,基本收敛了线上已有的问题,并大幅减少了录音文件编码时间。核心部分是opus-recorder,这个项目最新版本使用WebAssembly重构了录音文件的编码过程。效果很好,5min以上的录音基本上从之前的20s+的编码时间缩短到了5s左右。也因此对WebAssembly产生了兴趣,花了一些时间去看文章和写demo尝试。以下是一篇不错的入门文章,英文内容,这里翻译一下。初次翻译,如有疏漏或错误之处,还望大家指正。当然在这个时间节点WebAssembly并不是什么新鲜玩意儿,网上也有很多教程了,仅效抛砖引玉,也希望在一些地方可以提供帮助。
原文链接:https://medium.com/@tdeniffel/pragmatic-compiling-from-c-to-webassembly-a-guide-a496cc5954b8 语雀链接:https://www.yuque.com/metroluffy/fe-notes/compiling-from-c-to-webassembly-a-guide
原文链接:https://medium.com/@tdeniffel/pragmatic-compiling-from-c-to-webassembly-a-guide-a496cc5954b8
大多数我认识的C程序员都听说过WebAssembly,但很多人在开始阶段就遇到了麻烦。本指南将为您带来一个简单的“Hello World”实例,一个在C和JavaScript之间具有交互能力的状态应用程序。在超出最小限度之外我没找到一篇单独的文章。实际上从最简单的“Hello World”到系统,需要花费很多精力才能解决现实中的实际问题。本文目的即是这个。对了,最终的代码放在这个仓库(https://github.com/tom-010/webassembly-example),但是遵循本教程更加有意义。注意:本文没有讲到如何把数组从JS传到WebAssembly中,相关内容在这篇文章本文并不介绍WebAssembly本身或者讨论为什么你该使用它,所以开头并没有大段讲演的动机。虽然如此这里还是列出WebAssembly官方的定义:
WebAssembly(缩写为Wasm)是基于堆栈的虚拟机的二进制指令格式。 Wasm被设计为可移植目标,用于编译高级语言(如C / C ++ / Rust),从而可以在Web上为客户端和服务器应用程序进行部署。
官网主页列出了四个使用它的重要理由:高效,速度,安全打开和可调试的,以及属于开放式网络平台的一部分。然而,老实讲,唯一的理由就是第一点:高效和速度。其他任何功能JS实现都更好一些。
那么,让我们开始吧!
我使用的是Ubuntu 18.10,以及C++的标准构建工具:
$ apt install build-essential cmake python git
这些工具在 Windows 和MacOS上同样也有良好的支持。
首先,我们需要工具链(基于Clang)。最好的起点是这篇文章: Getting Started Guide(这里也有其他操作系统的步骤)。
$ mkdir ~/tmp && cd ~/tmp git clone https://github.com/juj/emsdk.git cd emsdk ./emsdk install --build=Release sdk-incoming-64bit binaryen-master-64bit ./emsdk activate --build=Release sdk-incoming-64bit binaryen-master-64bit
这会花点时间(在其他Clang编译好之前),同时也需要一些磁盘空间。你可以随意花点时间来“WebAssembly Explorer”上体验一把WebAssembly(类似编译器的浏览工具)。
现在来编译“Hello World”吧:
$ cat hello.cpp #include<iostream> int main() { std::cout << "Hello World" << std::endl; return 0; }
在编译前,我们先要初始化已经编译好的工具链。在你之前clone的目录下执行下面这条命令:
$ source ./emsdk_env.sh --build=Release
更好的做法是在你'.bashrc'文件追加下面的这行代码(同样可以在clone好的文件夹下面运行通过):
$ echo "source $(pwd)/emsdk_env.sh --build=Release > /dev/null" >> ~/.bashrc
$ em++ hello.cpp -s WASM=1 -o hello.html
编译结果是一个hello.html文件还有更为重要的hello.wasm文件。后者包含已编译的代码。
如果你在浏览器直接打开index.html的话,会遇到CORS-Problems问题。你必须通过Web服务器为其提供服务。EmScripten自带了这个服务:
$ emrun --port 8080
上面的命令会启动一个Web服务,打开浏览器并导航至当前目录。只需单击新创建的hello.html,瞧:
执行结果Emscripten 提供了一个控制台来执行你的代码。
浏览器中的控制台很nice但是在生产环境中不那么好用。来写个最小化脚本来调用我们的“Hello World”。Emscriptes同时也生成了一个hello.html和一个hello.js文件。HTML文件过分臃肿,其中没有任何对于进一步使用特别有用的文件。hello.js文件则非常有帮助,它加载并实例化我们的WebAssembly代码,并为其提供JavaScript接口。因此,我们保留它并用以下内容替换HTML文件:
$ cat index.html <html> <body> <script src="hello.js"></script> </body> </html>
刷新浏览器,打开开发者工具中console面板,然后可以看到我们得到:
“Hello World” in the web-console我无法像HTML那样使代码变得更简单,所以我很擅长一些更复杂的东西。大多数教程到此结束,但是没有项目只有一个文件!
来个一次性代码(斐波拉切数字):
$ cat fib.cpp int fib(int x) { if (x < 1) return 0; if (x == 1) return 1; return fib(x-1)+fib(x-2); } $ cat hello.cpp #include<iostream> #include "fib.cpp" int main() { std::cout << "fib(5) = " << fib(5) << std::endl; return 0; }
执行以下命令进行编译:
$ em++ hello.cpp -s WASM=1
我的Web服务还在跑着,所以刷新后显示如下:
fib(5) = 5
好消息是:它跑起来了。坏消息是:我的hello.html文件被重写了。解决方法是指定输出‘hello.js’来取代‘hello.html’:$ em++ hello.cpp -s WASM=1 -o hello.js重新输出的文件就只有‘hello.wasm’ 和 ‘hello.js’文件,没有‘hello.html’。来一点自动化工作,一个构建脚本和一个适当的文件夹结构:
$ em++ hello.cpp -s WASM=1 -o hello.js
$ cat build.shrm build/ -rfmkdir build $ cd build $ em++ ../cpp/hello.cpp -s WASM=1 -o hello.js $ mv hello.js ../web/gen/ $ mv hello.wasm ../web/gen/
在新创建的目录中:
$ tree .. ├── build ├── build.sh ├── cpp │ ├── fib.cpp │ └── hello.cpp ├── serve.sh └── web ├── gen │ ├── hello.js │ └── hello.wasm └── index.html
再来一个小小的方便输入的运行脚本:
$ cat serve.sh emrun --port 8080 web/
一个调整过的index.html文件:
$ cat web/index.html <html> <body> <script src="gen/hello.js"></script> </body> </html>
很棒。现在我们可以独立开发Web应用,并将生成的源代码放在一个额外的文件夹中,这样就很容易记住,你不应该修改它们(就像任何生成的源文件一样)。
声称前面的例子有多文件是作弊的(没有头文件,等等)。所以我们把fib代码分拆成头文件和实现:
$ cat fib.h #ifndef FIB #define FIBint fib(int x);#endif$ cat fib.cpp#include "fib.h"int fib(int x) { if (x < 1) return 0; if (x == 1) return 1; return fib(x-1)+fib(x-2); } $cat hello.cpp #include<iostream> #include "fib.h" int main() { std::cout << "fib(6) = " << fib(6) << std::endl; return 0; }
注意hello.cpp不引用fib.cpp文件,而仅仅是头文件。因此必须进行链接过程,这就是为什么编译失败的原因了:
$ ./build.sh error: undefined symbol: _Z3fibi warning: To disable errors for undefined symbols use `-s ERROR_ON_UNDEFINED_SYMBOLS=0` Error: Aborting compilation due to previous errors shared:ERROR: '/home/thomas/tmp/emsdk/node/8.9.1_64bit/bin/node /home/thomas/tmp/emsdk/emscripten/incoming/src/compiler.js /tmp/tmpDR9qjf.txt /home/thomas/tmp/emsdk/emscripten/incoming/src/library_pthread_stub.js' failed (1)
把fib.cpp加进编译脚本中可以修复这个问题:
$ cat build.sh rm build/ -rf mkdir build cd build em++ ../cpp/hello.cpp ../cpp/fib.cpp -s WASM=1 -o hello.js || exit 1 mv hello.js ../web/gen/ mv hello.wasm ../web/gen/
注意:‘|| exit 1’会让脚本在编译失败后终止!
$ ./build.sh $ ./serve.sh
构建任务现在通过了。请注意,我把fib函数的参数改成了6:
int main() { std::cout << "fib(6) = " << fib(6) << std::endl; return 0; }
因此我们现在可以看到实际的差异了:
成功编译了多个文件!只要你能扩展那个简易构建脚本,这种方式就行得通。稍后我们将使用构建系统(CMake)来处理更复杂的项目。但在此之前先让我们来看下参数传递的问题吧!
有时候把你的代码反汇编成S-表达式是很有用的(比如下一节)。你可以通过下面这条命令来实现:
$ wasm-dis hello.wasm -o hello.wast
在这个文件(hello.wast)里,你可以轻松找到全局函数等。S-表达式是WebAssembly的文本表示形式。要了解构建中的模块,请查看MDN的出色指南。当C++代码使用‘eemc … -s STANDALONE_WASM …’标记编译时反汇编会变得特别有用。然后,结果只有几行,您可以仔细分析它。
正如C. Gerard Gallant在评论中所建议的那样,emscripten还可以在编译命令带有标志“ -g”时直接生成wast文件。
$ cat build.shrm build/ -rf mkdir build cd build em++ ../cpp/hello.cpp ../cpp/fib.cpp -g -s WASM=1 -o hello.js || exit 1 mv hello.js ../web/gen/ mv hello.wasm ../web/gen/
现在你可以在构建文件夹(build/hello.wast)中找到始终最新的“wast”文件。
控制台所展示的“fib(6) = 8”来自C++程序主函数中的数字,会在加载完后执行。现在我想在JavaScript中调用fib函数:
$ cat index.html <html> <body> <script src="gen/hello.js"></script> <script> console.log( fib(10) ); </script> </body> </html>
现在,我面临两个问题:1、当我想执行fib(10)时,程序尚未加载2、eemc没有从C ++中导出函数fib,因此在JS不可用
注意:要获取所有导出的函数,可以通过“ wasm-dis hello.wasm -o hello.wast”反编译wasm文件,并在wast文件中搜索“(export”。由于C ++函数名带有前缀。该文件用 S-expression写成。_如果不进行修改,则仅导出“ main”函数。我们必须更改构建脚本:
$ cat build.shrm build/ -rf mkdir build cd build em++ ../cpp/hello.cpp ../cpp/fib.cpp -s WASM=1 -s EXPORT_ALL=1 -o hello.js || exit 1 mv hello.js ../web/gen/ mv hello.wasm ../web/gen/
使用“ -s EXPORT_ALL = 1”选项就可以导出fib函数,但函数名会被C++重命名为“ __Z3fibi”。我通过查看反编译的代码找到了这个函数名(不用担心,这很容易)。
如你所见,编译后的内容有好几个KB大(在我的例子里是9.6KB)。仅仅是一个非常简单的算法。大部分内容来自‘iostream’模块。当你删除这个引用和调用,并在build.sh中设置标志时,只有我们的代码应出现在生成的wasm文件中,该编译文件变得更加简便:
$ cat build.shrm build/ -rf mkdir build cd build em++ ../cpp/hello.cpp ../cpp/fib.cpp -s WASM=1 -s STANDALONE_WASM -s EXPORT_ALL=1 -O3 -o hello.js || exit 1 mv hello.js ../web/gen/ mv hello.wasm ../web/gen/ $ cat hell.cpp // #include<iostream> #include "fib.h"int main() { fib(10); return 0; }
现在文件就只有96Bytes大小了。这样就比较合适了。然后我们来调用C++函数:
$ cat hello.html<html> <body> <script>function loadWasm(fileName) { return fetch(fileName) .then(response => response.arrayBuffer()) .then(bits => WebAssembly.compile(bits)) .then(module => { return new WebAssembly.Instance(module) }); }; loadWasm('gen/hello.wasm') .then(instance => { let fib = instance.exports.__Z3fibi; console.log(fib(1)); console.log(fib(20)); }); </script> </body> </html>
注意,这里我们不再引用‘gen/hello.js’了。我们自己做了最小化的实现来加载wasm文件。在第一个代码块里,我们加载,编译和初始化了程序,在第二段里则执行了它。并不复杂。在本文的后面,我将处理一些内存方面的问题,但现在可以运行了。
Nice.这是通过JavaScript调用的第一份C++代码。
想象一下,我们的代码越来越大。我通过重新引入‘iostream’和在构建脚本中移除相应的flag来模拟这个情况:
$ cat build.sh... $ em++ ../cpp/hello.cpp ../cpp/fib.cpp -s WASM=1 -s EXPORT_ALL=1 -O3 ... $ cat hello.cpp#include<iostream> #include "fib.h"int main() { std::cout << fib(10) << std::endl; return 0; }
hello.wasm文件回到了1654KB大小。这对于模拟部分情况下的代码足够真实了。我们没有更改绑定或算法的任何内容,所以代码应该可以正常运行:
错误发生了:
RangeError: WebAssembly.Instance is disallowed on the main thread, if the buffer size is larger than 4KB
这是有道理的。我们不想通过加载,编译和加载WebAssembly文件来阻塞主线程。关于如何高效地处理WebAssembly,Google提供了 优秀的文档。实际上,从现在开始它变得非常讨厌,因为我们将为每个导出的函数定义内容。否则,我们会收到许多有线错误。可以为某些功能定义所有绑定,但是请记住,我们导出了包括所有“ iostream”的所有功能。我尝试了几个小时,才意识到EmScripten生成的代码是目前最简单的方法。因此:
$ cat index.html <html> <body> <script src="gen/hello.js"></script> <script> Module.onRuntimeInitialized = function() { console.log(Module.__Z3fibi(30)); } </script> </body> </html>
我仍然使用fib函数的前缀名称。我注册了函数“ Module.onRuntimeInitialized”,该函数可以确保在加载,编译和实例化(大型)程序之后执行该函数。起作用了:
虽然不是最好的解决方案,但它可以工作。 WebAssembly仍然非常脆弱(至少在工具方面),因此我们必须忍受这一点。第一步是将导出的功能列入白名单。
仅在极少数情况下,调用无状态函数才有意义。因此,我想设计一个简单的类:
$ cat fib.h#ifndef FIB #define FIBclass Fib { public: Fib(); int next();private: int curr = 1; int prev = 1; }; #endif $ cat fib.cpp #include "fib.h" Fib::Fib() {} int Fib::next() { int next = curr + prev; prev = curr; curr = next; return next; } $ cat hello.cpp #include "fib.h" #include <iostream> int main() { Fib fib{}; std::cout << fib.next() << std::endl; std::cout << fib.next() << std::endl; std::cout << fib.next() << std::endl; std::cout << fib.next() << std::endl; std::cout << fib.next() << std::endl; return 0; } $ g++ hello.cpp fib.cpp -o fib && ./fib2 3 5 8 13
这里没什么特别的。只是一个转储和有状态的类。让我们通过JavaScript使用它。我从小处着手,并在C ++中进行实例化:
$ cat hello.cpp #include "fib.h" int fib() { static Fib fib = Fib(); return fib.next(); } int main() { fib(); return 0; }
注意:我在main中使用了fib调用,因为编译器不会优化我的fib函数。我的“ fib”绑定的名称已更改,因此我的JS代码如下所示:
$ cat index.html <html> <body> <script src="gen/hello.js"></script> <script> Module.onRuntimeInitialized = function() { console.log(Module.__Z3fibv()); console.log(Module.__Z3fibv()); console.log(Module.__Z3fibv()); console.log(Module.__Z3fibv()); } </script> </body> </html>
开箱即用:
每个公有函数一个对象(状态)是不够的。下一个最简单的方法是进行调度:
$ cat hello.cpp #include "fib.h" #include <vector> auto instances = std::vector<Fib>(); int next_val(int fib_instance) { return instances[fib_instance].next(); } int new_fib() { instances.push_back(Fib()); return instances.size() - 1; } int main() { int fib1 = new_fib(); next_val(fib1); return 0; }
这个想法很简单。我从函数式编程中学到了它。“ new_fib”是我们的构造函数,而整数是其“地址”。也许不是最优雅的解决方案,但是它有效且易于理解,因此可以更改。对于所需的功能,我们有两个名称:
__Z7new_fibv __Z8next_vali
调用很简单:
$ cat index.html <html> <body> <script src="gen/hello.js"></script> <script> Module.onRuntimeInitialized = function() { let fib1 = Module.__Z7new_fibv(); let fib2 = Module.__Z7new_fibv(); console.log(Module.__Z8next_vali(fib1)); console.log(Module.__Z8next_vali(fib1)); console.log(Module.__Z8next_vali(fib1)); console.log(Module.__Z8next_vali(fib2)); console.log(Module.__Z8next_vali(fib2)); console.log(Module.__Z8next_vali(fib2)); } </script> </body> </html>
注:门面模式(Facade)又称外观模式,用于为子系统中的一组接口提供一个一致的界面。对这个概念不理解?可参考外观模式 或自行Google。
是时候抽象这些丑陋的C ++接口了:
$ cat index.html <html> <body> <script src="gen/hello.js"></script> <script> class Fib { constructor() { this.cppInstance = Module.__Z7new_fibv(); } next() { return Module.__Z8next_vali(this.cppInstance); } } Module.onRuntimeInitialized = function() { let fib1 = new Fib(); let fib2 = new Fib(); console.log(fib1.next()); console.log(fib1.next()); console.log(fib1.next()); console.log(fib2.next()); console.log(fib2.next()); console.log(fib2.next()); } </script> </body> </html>
输出仍然相同,但是现在我们有了一个非常漂亮的JS接口并封装了C ++部分:
当然,下一步可能是在JS中实际调用“ Fib”类的构造函数。但是,对我来说这很有意义(现在)。‘new_fib’也是为JS优化的专业构造函数,并且是与语言无关的。用C替换我们的方法在实例化中不需要任何概念上的改变。我下一步是用Map取代向量,并提供删除方法以摆脱不再需要的对象。
如您所知,函数名在每次重构后都会更改,这导致我们的集成失败。这些奇怪的函数名来自于C++的名字修饰(name mangling)。
为防止这种情况,我们将其签名导出为C代码:
$ cat hello.cpp #include "fib.h" #include <vector> extern "C" { int new_fib(); int next_val(int fib_instance); } auto instances = std::vector<Fib>(); int next_val(int fib_instance) { return instances[fib_instance].next(); } int new_fib() { instances.push_back(Fib()); return instances.size() - 1; } int main() { int fib1 = new_fib(); next_val(fib1); return 0; }
这很好,因为我本来就想列出导出的函数。现在,我们有了更一致的函数名(只是带有下划线的前缀):
$ cat hello.cpp <html> <body> <script src="gen/hello.js"></script> <script> class Fib { constructor() { this.cppInstance = Module._new_fib(); } next() { return Module._next_val(this.cppInstance); } } Module.onRuntimeInitialized = function() { // ... } </script> </body> </html>
你可能会认识到“ script.js”的大小以及反编译的“wast”文件中大量导出的函数,以及JS上下文中“Module”的结果大小。所有的这些都来自‘EXPORT_ALL’:
$ cat build.sh $ rm build/ -rf $ mkdir build $ cd build $ em++ ../cpp/hello.cpp ../cpp/fib.cpp -s WASM=1 -s EXPORT_ALL=1 -o hello.js || exit 1 $ mv hello.js ../web/gen/ $ mv hello.wasm ../web/gen/
EmScripten会导出所有引入软件包的所有函数,并为其生成绑定。使用一致的函数名,我们可以仅导出所需的内容。
$ cat build.sh rm build/ -rf mkdir build cd build em++ ../cpp/hello.cpp ../cpp/fib.cpp -s WASM=1 -s EXPORTED_FUNCTIONS="[_new_fib, _next_val]" -o hello.js || exit 1 mv hello.js ../web/gen/ mv hello.wasm ../web/gen/
We can specify with ‘EXPORTED_FUNCTION,’ what we want to specify. The generated ‘hello.js’ is now much smaller, and we don’t leak trough internals anymore (check it out).
我们可以使用“ EXPORTED_FUNCTION”进行指定。现在,生成的“ hello.js”要小得多,而且我们也不会再泄漏内部实现了(可以检查看看)。
到目前为止,我对C ++和JavaScript的集成感到满意。然而,工程中有太多的动态部分了,就像C函数的导出,构建脚本,门面模式以及它的使用。这种复杂性要求进行集成测试。
请记住,如果逻辑上有缺陷,那么仅当两个组件本身的集成不再起作用时,集成测试才应该中断。本文不是有关JS-testing-frameworks的教程。因此,我只是编写没有测试运行程序等的普通JavaScript。你可以在你选择的框架中自由整合相关逻辑。
$ cat index.html <html> <body> <script src="gen/hello.js"></script> <script> class Fib { constructor() { this.cppInstance = Module._new_fib(); } next() { return Module._next_val(this.cppInstance); } } function functionExists(f) { return f && typeof f === "function"; } function isNumber(n) { return typeof n === "number"; } function testFunctionBinding() { assert(functionExists(Module._new_fib)); assert(functionExists(Module._next_val)); } // int is part of the interface function testNextValReturnsInt() { assert(isNumber(new Fib().next())); } Module.onRuntimeInitialized = function() { testFunctionBinding(); testNextValReturnsInt(); } </script> </body> </html>
以上代码将检查函数是否可用以及next是否返回整数,这是接口的一部分。有了这个,我可以轻松地重构管道中的步骤,并确信我不会破坏任何东西,例如构建系统(仍然很糟糕)。
可以说,当前的“构建系统”不是最佳的:
因此,我在cpp目录中创建了以下“ CMakeLists.txt”:
$ cat cpp/CMakeLists.txt set(project "hello")project(${project}) cmake_minimum_required(VERSION 3.12) set(src hello.cpp fib.cpp ) set(exports _new_fib _next_val ) # process exported functions set(exports_string "") list(JOIN exports "," exports_string) # set compiler and flags SET(CMAKE_C_COMPILER emcc) SET(CMAKE_CPP_COMPILER em++) set( CMAKE_CXX_FLAGS "-s EXPORTED_FUNCTIONS=\"[${exports_string}]\"" ) # specify the project add_executable(${project}.html ${src})
现在我可以修改构建脚本了:
$ cat build.sh rm build/ -rf mkdir build cd build cmake ../cpp make mv hello.js ../web/gen/ mv hello.wasm ../web/gen/
将其余的内容加入CMake也是可以的,但我认为这没有意义,因为这些是项目和平台的特定内容,我可能不会再对此进行修改。
这里是我关于WebAssembly一些随想的合集。当我有新见解时,我会更新在下面。你可以不理会它或提出一些观点。
您无法在WebAssembly中访问DOM。因此在逻辑(C ++)和UI(JavaScript)之间强制性地存在自然而然的边界。这也意味着您必须仔细定义模块,因为您无法轻松地从边界的一侧重构到另一侧: 语言是不同的。
如果您可以将JavaScript编译为WebAssembly,然后由引擎决定编译哪些部分,那会很好吗?有道理吗?
如果您执行不带“ \ n”的“ printf()”,则会在chrome开发者控制台中收到一条警告,内容没有刷新,也没有输出。添加“ \ n”可解决此问题
选项‘eemc … -s SIDE_MODULE=1 …’表示不生成HTML和JS文件。
选项‘eemc … -s STANDALONE_WASM …’ 不生成HTML和JS文件,并且仅编译自己编写的代码,甚至没有“ stdio”,stdlib-stuff也已编译。这使得生成的wasm文件方式更小。
要反编译wasm文件,你可以使用“ wasm-dis hello.wasm -o hello.wast”。这会将文件转换为以S表达式编码的WebAssembly文本格式。 您可以在这份有关MDN的出色指南中找到有关文件结构的详细信息。
只是我在将C ++与WebAssembly实际项目一起应用时认识到的东西的集合。
每当您的浏览器挂掉并且Chrome表示网站崩溃时,都可能与WebAssembly相关。try{…} catch (Exception e){…}在大多数情况下会有所帮助。
我在编译C++类并暴露C风格接口后,用html直接调用wasm文件是不可用的,只能调用编译生成的js文件才可用,不知道是为啥
🤣这篇文章比较久了,可能信息已经过时。不过从 wasm 到 js 会有一层胶水层,没法直接调用的
从C++编译WebAssembly的实用指南
大概在7月份的时候,我重构了公司直播项目中的语音录制模块,基本收敛了线上已有的问题,并大幅减少了录音文件编码时间。核心部分是opus-recorder,这个项目最新版本使用WebAssembly重构了录音文件的编码过程。效果很好,5min以上的录音基本上从之前的20s+的编码时间缩短到了5s左右。也因此对WebAssembly产生了兴趣,花了一些时间去看文章和写demo尝试。
以下是一篇不错的入门文章,英文内容,这里翻译一下。初次翻译,如有疏漏或错误之处,还望大家指正。当然在这个时间节点WebAssembly并不是什么新鲜玩意儿,网上也有很多教程了,仅效抛砖引玉,也希望在一些地方可以提供帮助。
从C++编译至WebAssembly的实用指南
大多数我认识的C程序员都听说过WebAssembly,但很多人在开始阶段就遇到了麻烦。本指南将为您带来一个简单的“Hello World”实例,一个在C和JavaScript之间具有交互能力的状态应用程序。
在超出最小限度之外我没找到一篇单独的文章。实际上从最简单的“Hello World”到系统,需要花费很多精力才能解决现实中的实际问题。本文目的即是这个。
对了,最终的代码放在这个仓库(https://github.com/tom-010/webassembly-example),但是遵循本教程更加有意义。
注意:本文没有讲到如何把数组从JS传到WebAssembly中,相关内容在这篇文章
本文并不介绍WebAssembly本身或者讨论为什么你该使用它,所以开头并没有大段讲演的动机。虽然如此这里还是列出WebAssembly官方的定义:
官网主页列出了四个使用它的重要理由:高效,速度,安全打开和可调试的,以及属于开放式网络平台的一部分。
然而,老实讲,唯一的理由就是第一点:高效和速度。其他任何功能JS实现都更好一些。
那么,让我们开始吧!
关于操作系统
我使用的是Ubuntu 18.10,以及C++的标准构建工具:
这些工具在 Windows 和MacOS上同样也有良好的支持。
编译工具链
首先,我们需要工具链(基于Clang)。最好的起点是这篇文章: Getting Started Guide(这里也有其他操作系统的步骤)。
这会花点时间(在其他Clang编译好之前),同时也需要一些磁盘空间。
你可以随意花点时间来“WebAssembly Explorer”上体验一把WebAssembly(类似编译器的浏览工具)。
首次编译:“Hello World”
现在来编译“Hello World”吧:
在编译前,我们先要初始化已经编译好的工具链。在你之前clone的目录下执行下面这条命令:
更好的做法是在你'.bashrc'文件追加下面的这行代码(同样可以在clone好的文件夹下面运行通过):
编译
编译结果是一个hello.html文件还有更为重要的hello.wasm文件。后者包含已编译的代码。
运行代码
如果你在浏览器直接打开index.html的话,会遇到CORS-Problems问题。你必须通过Web服务器为其提供服务。
EmScripten自带了这个服务:
$ emrun --port 8080
上面的命令会启动一个Web服务,打开浏览器并导航至当前目录。只需单击新创建的hello.html,瞧:
执行结果
Emscripten 提供了一个控制台来执行你的代码。
自行调用(JavaScript)
浏览器中的控制台很nice但是在生产环境中不那么好用。来写个最小化脚本来调用我们的“Hello World”。
Emscriptes同时也生成了一个hello.html和一个hello.js文件。HTML文件过分臃肿,其中没有任何对于进一步使用特别有用的文件。hello.js文件则非常有帮助,它加载并实例化我们的WebAssembly代码,并为其提供JavaScript接口。因此,我们保留它并用以下内容替换HTML文件:
刷新浏览器,打开开发者工具中console面板,然后可以看到我们得到:
“Hello World” in the web-console
我无法像HTML那样使代码变得更简单,所以我很擅长一些更复杂的东西。大多数教程到此结束,但是没有项目只有一个文件!
两个或更多的文件
来个一次性代码(斐波拉切数字):
执行以下命令进行编译:
我的Web服务还在跑着,所以刷新后显示如下:
fib(5) = 5
好消息是:它跑起来了。坏消息是:我的hello.html文件被重写了。解决方法是指定输出‘hello.js’来取代‘hello.html’:
$ em++ hello.cpp -s WASM=1 -o hello.js
重新输出的文件就只有‘hello.wasm’ 和 ‘hello.js’文件,没有‘hello.html’。来一点自动化工作,一个构建脚本和一个适当的文件夹结构:
在新创建的目录中:
再来一个小小的方便输入的运行脚本:
一个调整过的index.html文件:
很棒。现在我们可以独立开发Web应用,并将生成的源代码放在一个额外的文件夹中,这样就很容易记住,你不应该修改它们(就像任何生成的源文件一样)。
头文件
声称前面的例子有多文件是作弊的(没有头文件,等等)。所以我们把fib代码分拆成头文件和实现:
注意hello.cpp不引用fib.cpp文件,而仅仅是头文件。因此必须进行链接过程,这就是为什么编译失败的原因了:
把fib.cpp加进编译脚本中可以修复这个问题:
注意:‘|| exit 1’会让脚本在编译失败后终止!
构建任务现在通过了。请注意,我把fib函数的参数改成了6:
因此我们现在可以看到实际的差异了:
成功编译了多个文件!只要你能扩展那个简易构建脚本,这种方式就行得通。稍后我们将使用构建系统(CMake)来处理更复杂的项目。但在此之前先让我们来看下参数传递的问题吧!
反汇编
有时候把你的代码反汇编成S-表达式是很有用的(比如下一节)。你可以通过下面这条命令来实现:
在这个文件(hello.wast)里,你可以轻松找到全局函数等。S-表达式是WebAssembly的文本表示形式。要了解构建
中的模块,请查看MDN的出色指南。
当C++代码使用‘eemc … -s STANDALONE_WASM …’标记编译时反汇编会变得特别有用。然后,结果只有几行,您可以仔细分析它。
wast-file作为构建的一部分
正如C. Gerard Gallant在评论中所建议的那样,emscripten还可以在编译命令带有标志“ -g”时直接生成wast文件。
现在你可以在构建文件夹(build/hello.wast)中找到始终最新的“wast”文件。
函数调用和传递参数
控制台所展示的“fib(6) = 8”来自C++程序主函数中的数字,会在加载完后执行。现在我想在JavaScript中调用fib函数:
现在,我面临两个问题:
1、当我想执行fib(10)时,程序尚未加载
2、eemc没有从C ++中导出函数fib,因此在JS不可用
从C++中导出函数
注意:要获取所有导出的函数,可以通过“ wasm-dis hello.wasm -o hello.wast”反编译wasm文件,并在wast文件中搜索“(export”。由于C ++函数名带有前缀。该文件用 S-expression写成。
_
如果不进行修改,则仅导出“ main”函数。我们必须更改构建脚本:
使用“ -s EXPORT_ALL = 1”选项就可以导出fib函数,但函数名会被C++重命名为“ __Z3fibi”。我通过查看反编译的代码找到了这个函数名(不用担心,这很容易)。
控制编译的内容
如你所见,编译后的内容有好几个KB大(在我的例子里是9.6KB)。仅仅是一个非常简单的算法。大部分内容来自‘iostream’模块。当你删除这个引用和调用,并在build.sh中设置标志时,只有我们的代码应出现在生成的wasm文件中,该编译文件变得更加简便:
现在文件就只有96Bytes大小了。这样就比较合适了。然后我们来调用C++函数:
注意,这里我们不再引用‘gen/hello.js’了。我们自己做了最小化的实现来加载wasm文件。在第一个代码块里,我们加载,编译和初始化了程序,在第二段里则执行了它。并不复杂。在本文的后面,我将处理一些内存方面的问题,但现在可以运行了。
Nice.这是通过JavaScript调用的第一份C++代码。
加载超过4KB的文件
想象一下,我们的代码越来越大。我通过重新引入‘iostream’和在构建脚本中移除相应的flag来模拟这个情况:
hello.wasm文件回到了1654KB大小。这对于模拟部分情况下的代码足够真实了。我们没有更改绑定或算法的任何内容,所以代码应该可以正常运行:
错误发生了:
这是有道理的。我们不想通过加载,编译和加载WebAssembly文件来阻塞主线程。
关于如何高效地处理WebAssembly,Google提供了 优秀的文档。
实际上,从现在开始它变得非常讨厌,因为我们将为每个导出的函数定义内容。
否则,我们会收到许多有线错误。可以为某些功能定义所有绑定,但是请记住,我们导出了包括所有“ iostream”的所有功能。我尝试了几个小时,才意识到EmScripten生成的代码是目前最简单的方法。因此:
我仍然使用fib函数的前缀名称。我注册了函数“ Module.onRuntimeInitialized”,该函数可以确保在加载,编译和实例化(大型)程序之后执行该函数。起作用了:
虽然不是最好的解决方案,但它可以工作。 WebAssembly仍然非常脆弱(至少在工具方面),因此我们必须忍受这一点。第一步是将导出的功能列入白名单。
有状态的C ++代码
仅在极少数情况下,调用无状态函数才有意义。因此,我想设计一个简单的类:
这里没什么特别的。只是一个转储和有状态的类。让我们通过JavaScript使用它。我从小处着手,并在C ++中进行实例化:
注意:我在main中使用了fib调用,因为编译器不会优化我的fib函数。
我的“ fib”绑定的名称已更改,因此我的JS代码如下所示:
开箱即用:
通过调度的多个对象/状态
每个公有函数一个对象(状态)是不够的。下一个最简单的方法是进行调度:
这个想法很简单。我从函数式编程中学到了它。“ new_fib”是我们的构造函数,而整数是其“地址”。也许不是最优雅的解决方案,但是它有效且易于理解,因此可以更改。对于所需的功能,我们有两个名称:
调用很简单:
封装C++:构建一个门面(Facade)
是时候抽象这些丑陋的C ++接口了:
输出仍然相同,但是现在我们有了一个非常漂亮的JS接口并封装了C ++部分:
更多C++对象的实例化
当然,下一步可能是在JS中实际调用“ Fib”类的构造函数。但是,对我来说这很有意义(现在)。‘new_fib’也是为JS优化的专业构造函数,并且是与语言无关的。用C替换我们的方法在实例化中不需要任何概念上的改变。我下一步是用Map取代向量,并提供删除方法以摆脱不再需要的对象。
C ++和JavaScript之间的稳定接口
如您所知,函数名在每次重构后都会更改,这导致我们的集成失败。这些奇怪的函数名来自于C++的名字修饰(name mangling)。
一致的函数名
为防止这种情况,我们将其签名导出为C代码:
这很好,因为我本来就想列出导出的函数。现在,我们有了更一致的函数名(只是带有下划线的前缀):
仅导出实际使用的函数
你可能会认识到“ script.js”的大小以及反编译的“wast”文件中大量导出的函数,以及JS上下文中“Module”的结果大小。所有的这些都来自‘EXPORT_ALL’:
EmScripten会导出所有引入软件包的所有函数,并为其生成绑定。使用一致的函数名,我们可以仅导出所需的内容。
We can specify with ‘EXPORTED_FUNCTION,’ what we want to specify. The generated ‘hello.js’ is now much smaller, and we don’t leak trough internals anymore (check it out).
我们可以使用“ EXPORTED_FUNCTION”进行指定。现在,生成的“ hello.js”要小得多,而且我们也不会再泄漏内部实现了(可以检查看看)。
集成
到目前为止,我对C ++和JavaScript的集成感到满意。然而,工程中有太多的动态部分了,就像C函数的导出,构建脚本,门面模式以及它的使用。这种复杂性要求进行集成测试。
集成测试
请记住,如果逻辑上有缺陷,那么仅当两个组件本身的集成不再起作用时,集成测试才应该中断。本文不是有关JS-testing-frameworks的教程。因此,我只是编写没有测试运行程序等的普通JavaScript。你可以在你选择的框架中自由整合相关逻辑。
以上代码将检查函数是否可用以及next是否返回整数,这是接口的一部分。有了这个,我可以轻松地重构管道中的步骤,并确信我不会破坏任何东西,例如构建系统(仍然很糟糕)。
集成CMake
可以说,当前的“构建系统”不是最佳的:
因此,我在cpp目录中创建了以下“ CMakeLists.txt”:
现在我可以修改构建脚本了:
将其余的内容加入CMake也是可以的,但我认为这没有意义,因为这些是项目和平台的特定内容,我可能不会再对此进行修改。
相关资源和教程
随想和经验
这里是我关于WebAssembly一些随想的合集。当我有新见解时,我会更新在下面。你可以不理会它或提出一些观点。
您无法在WebAssembly中访问DOM。因此在逻辑(C ++)和UI(JavaScript)之间强制性地存在自然而然的边界。这也意味着您必须仔细定义模块,因为您无法轻松地从边界的一侧重构到另一侧: 语言是不同的。
如果您可以将JavaScript编译为WebAssembly,然后由引擎决定编译哪些部分,那会很好吗?有道理吗?
如果您执行不带“ \ n”的“ printf()”,则会在chrome开发者控制台中收到一条警告,内容没有刷新,也没有输出。添加“ \ n”可解决此问题
选项‘eemc … -s SIDE_MODULE=1 …’表示不生成HTML和JS文件。
选项‘eemc … -s STANDALONE_WASM …’ 不生成HTML和JS文件,并且仅编译自己编写的代码,甚至没有“ stdio”,stdlib-stuff也已编译。这使得生成的wasm文件方式更小。
要反编译wasm文件,你可以使用“ wasm-dis hello.wasm -o hello.wast”。这会将文件转换为以S表达式编码的WebAssembly文本格式。 您可以在这份有关MDN的出色指南中找到有关文件结构的详细信息。
得到的教训
只是我在将C ++与WebAssembly实际项目一起应用时认识到的东西的集合。
每当您的浏览器挂掉并且Chrome表示网站崩溃时,都可能与WebAssembly相关。try{…} catch (Exception e){…}在大多数情况下会有所帮助。