65.9K
CodeProject 正在变化。 阅读更多。
Home

WebAssembly 入门(C/C++)

starIconstarIconstarIconstarIconstarIcon

5.00/5 (14投票s)

2019 年 11 月 8 日

CPOL

17分钟阅读

viewsIcon

30222

本文介绍如何使用 C/C++ 语言入门 WebAssembly,这是第一部分。在本部分中,我将介绍 WebAssembly,指导您完成开发工具的设置,并通过几个入门程序进行讲解。

我最近一直在利用 WebAssembly。它得到了所有主流浏览器的支持,允许我们使用已为其他环境编写的现有有用的代码,并提供了比 JavaScript 更好的性能。WebAssembly 具有巨大的潜力和广泛的支持,我想向其他开发者介绍它。我将在本文中使用 C++。但绝不是说 C++ 是唯一可以利用 WebAssembly 的语言。在本文中,我将讨论为什么有人可能想要考虑 WebAssembly 以及如何设置开发环境。

什么是 WebAssembly?

WebAssembly 是一种在浏览器中运行的虚拟机规范。与高度动态的 JavaScript 相比,WebAssembly 可以实现更高的性能。然而,与普遍的误解相反,WebAssembly 并非完全取代 JavaScript。您很可能两者会一起使用。WebAssembly 基于 LLVM(Low Level Virtual Machine),这是一个基于堆栈的虚拟机,编译器可以以其为目标。如果有人想创建一门新的编程语言,他们可以让他们语言的编译器生成 LLVM 代码,然后使用现有的工具链将其编译成特定于平台的代码。构建新语言编译器的开发人员无需为不同的 CPU 架构构建完全独立的系统。基于 LLVM 的 WebAssembly 可以运行由多种语言编写的代码。目前,它还不支持垃圾回收,这限制了目前以它为目标的语言。C/C++、C# 和 Rust 是目前可用于 WebAssembly 的几种语言,未来预计会有更多。

我还能使用其他什么语言?

  • C/C++ - 我将在本文中使用这种语言
  • C#/.NET - 我对此很感兴趣,将来会写相关内容。
  • Elixir
  • Go
  • Java
  • Python
  • Rust - 这是一种较新的语言

为什么使用 WebAssembly?

我主要建议将 WebAssembly 用于计算密集型操作的性能优势。它使用的二进制格式比 JavaScript 更严格,并且更适合计算密集型操作。此外,对于密码学或视频解码器等工作,存在大量现有的、经过测试的 C/C++ 代码,我们可能会想在网页中使用。尽管 JavaScript 具有所有灵活性,但解释执行的 JavaScript 代码的运行速度不如原生二进制文件。对于某些类型的应用程序(例如文字处理器),性能差异并不重要。对于其他应用程序,性能差异会转化为用户体验的差异。

虽然对性能的需求是创建原生二进制文件的动力,但也有安全方面的考虑。原生二进制文件可能比 Web 实现的解决方案拥有更多的系统资源访问权限。人们可能会更担心确保程序(尤其是来自第三方程序)不会做任何恶意的事情或未经授权访问资源。WebAssembly 有助于弥合这两种需求之间的差距;它在沙箱内提供了一个更高性能的执行环境。

WebAssemblySupport

C++?我难道不会导致缓冲区溢出吗?

当然。但仅限于代码运行的沙箱范围内。它可能会导致程序崩溃,但无法导致沙箱外的任意代码执行。另请注意,目前 WebAssembly 没有任何与主机 API 的绑定。当您以 WebAssembly 为目标时,您没有一个允许您绕过 JavaScript 代码运行的安全限制的环境。没有直接的文件系统访问权限,没有程序内存以外的内存访问权限,您仍然只能通过 WebSocket 和不违反 CORS 限制的 HTTP 请求进行通信。

如何设置开发环境

互联网上关于安装 WebAssembly 工具的说明有不同的版本。如果您运行的是 Windows 10,您可能会遇到一组以安装 Windows Subsystem for Linux 开头的说明。请不要使用这些说明;我个人认为它们不必要地复杂。虽然我已安装并运行 Windows Subsystem for Linux 用于其他目的,但这不是我喜欢编译 WebAssembly 代码的地方。

使用您选择的操作系统(Windows 10/8/7、macOS、Linux),克隆 Emscripten git 存储库,运行其中的一些脚本,然后您就可以开始了。以下是使用的命令。如果您使用的是 Windows,请省略命令开头的 ./

git https://github.com/emscripten-core/emsdk.git
cd emsdk
git pull
./emsdk install latest
./emsdk activate latest

安装工具后,您还需要设置一些环境变量。有一个脚本可以完成此操作。在 Windows 10 上,运行

emsdk_env.bat

对于其他操作系统,请运行

source emsdk_env.sh

这些对环境变量所做的更新不是持久的;下次重启时需要再次运行。对于编辑器,我建议使用 Visual Studio Code。在本文中,我将从命令行进行编译。您可以随意使用您选择的编辑器。

WebAssembly Explorer

我在这篇文章中没有使用此工具,但 WebAssembly Explorer 是一个在线工具,用于将 C++ 编译成 WebAssembly,如果您没有安装工具,这是一个不错的选择。https://mbebenita.github.io/WasmExplorer/

Hello World

现在我们已经安装了工具,可以编译和运行一些东西了。我们将创建一个 hello world 程序。键入以下源代码并将其保存为 hello.cpp

#include 
int main(int argc, char**argv) 
{
     printf("Hello World!\n");
    return 0;
}

要从命令行编译代码,请键入以下命令

emcc hello.cpp -o hello.html

编译器运行后,您将看到三个新文件

  • hello.wasm - 您的程序的编译版本
  • hello.html - 用于托管您的 WebAssembly 的 HTML 页面
  • hello.js - 用于将您的 WebAssembly 加载到页面的 JavaScript

如果您尝试直接打开 HTML 文件,您的代码可能无法运行。相反,页面必须通过 HTTP 服务器进行提供。如果您安装了 node,请使用 node http-server。您可以使用以下命令安装 http-server

npm install  http-server -g

然后,从包含 hello.html 的目录启动服务器

http-server . -p 81

在这里,我指示 http-server 在端口 81 上运行。您可以在此处使用您选择的任何端口,只要没有其他服务正在使用它。请记住在其余说明中替换您选择的端口。

打开浏览器并导航到 https://:81/hello.html。您将看到您的代码运行。如果您查看页面源代码,会发现文件中有大量“噪音”。大部分噪音来自嵌入在 HTML 中的显示图像。这对于试玩来说没问题。但您最终会想要一些符合您自己需求的定制内容。

我们可以为编译器提供一个 Shell 或模板文件。Emscripten 在 https://github.com/emscripten-core/emscripten/blob/master/src/shell_minimal.html 提供了一个最小文件。下载该文件。它将用作我们的起点。为了方便分发,将所有内容都放在一个文件中很方便。但我不太喜欢 CSS 和 JavaScript 被嵌入在文件中。这里的 CSS 并非必需,将被删除。我将 JavaScript 移到自己的文件中,并在 HTML 中添加了对它的脚本引用。HTML 和脚本中有一些不一定需要的内容。让我们先看脚本,然后开始让这个最小文件变得更极简。

在脚本的顶部,有三个变量用于指向页面元素,以指示下载和进度。这些不是绝对必需的。我将删除它们。我还需要删除对它们的引用。在 JavaScript 的下方有一个名为 setStatus 的方法。我将用对 console.log() 的调用替换它的主体,以打印传递给它的文本。我将编写的第一组程序将不使用 canvas。目前不需要该元素;我将其注释掉而不是删除,以便以后可以使用它。删除了该文件的前三行以及引用它们的代码后,我将回到 HTML。大部分内容将被删除。我已经注释掉了 canvas 引用。HTML 文件中有一行文本为 {{{ SCRIPT }}}。编译器会将此文件作为模板,并将 {{{ SCRIPT }}} 替换为我们 WebAssembly 文件特定的脚本引用。

    <!doctype html>
    <html lang="en-us">
      <head>
        <meta charset="utf-8">
        <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
        <title>Emscripten-Generated Code</title>
        <link rel="stylesheet" href="./styles/emscripten.css" />
        <script src="scripts/emscripten.js"></script>
         </head>
      <body>
        <!--
        <div class="emscripten_border">
          <canvas class="emscripten" id="canvas" 
          oncontextmenu="event.preventDefault()"></canvas>
        </div>
        -->
        <textarea class="emscripten" id="output" rows="8"></textarea>
        {{{ SCRIPT }}}
      </body>
    </html>      

当 WebAssembly 程序执行 printf() 时,文本将写入 textarea 元素。我将 hello.cpp 文件放在这些文件之间,然后使用以下命令进行编译。

emcc hello.cpp --shell-file shell_minimal.html -o hello.html

--shell-file 参数指示要用作模板的文件。-o 参数指定要写入的 HTML 文件的名称。如果您查看 hello.html,您会发现它几乎与输入模板相同。现在运行该站点,您将看到相同的结果,但界面更简洁。再次运行程序,您将看到相同的结果,但界面更简洁。

绑定函数

我之前提到过,WebAssembly 没有任何操作系统函数的绑定。它也没有浏览器绑定。它也无法访问 DOM。由加载 WebAssembly 的页面来向它公开函数。在 emscripten.js 中,Modules 对象定义了许多将可供 WebAssembly 使用的函数。当 C/C++ 代码调用 printf 时,它将通过此处定义的同名 JavaScript 函数进行传递。函数名不必相同,但为了方便跟踪函数关联,最好保持相同。

从 JavaScript 调用 C/C++

但是,如果您有自己想绑定的函数,以便您的 JavaScript 代码可以调用 C++ 代码呢?Module 对象有一个名为 ccall 的函数,可用于从 JavaScript 调用 C/C++ 代码,还有一个名为 cwrap 的函数,用于构建一个函数对象,我们可以保留它以便重复调用同一函数。要使用这些函数,还需要一些额外的编译标志。

为了演示这两种从 JavaScript 调用 C/C++ 代码的方法,我将在 C++ 代码中声明三个新函数。

  • void testCall() - 不接受参数也不返回值。此方法仅打印一个 string,以便我们知道对它的调用是成功的。
  • void printNumber(int num) - 接受一个整数参数并打印它。这让我们知道该值已成功调用。
  • int square(int c) - 接受一个整数并返回该整数的平方。这让我们看到可以从代码中返回一个值。

C++ 语言执行所谓的名称修饰;编译后代码中的函数名称与未编译代码中的名称不同。对于我们想从 C++ 代码外部使用的函数,我们需要将函数的声明包装在 extern "C" 块中。如果我们的代码是用 C 而不是 C++ 编写的,则不需要这样做。我仍然偏爱 C++,因为它提供了一些特性。通常,我会将这样的声明放在头文件中。但现在,我的 C++ 程序是单个文件。在程序接近顶部的位置,我进行以下声明:

extern "C" {
    void testCall();
    void printNumber(int f);
    int square(int c);
}

函数的实现正如您所预期的。

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;
}

我的 main 方法也有一个更改。我不得不包含一个新的头文件 emscripten.h,因为我将使用它提供的一个函数。在 main 中,添加了以下行。

EM_ASM ( InitWrappers());

这将导致调用一个名为 InitWrappers() 的 JavaScript 函数。我将在后续部分中讨论 EM_ASM 的工作原理。我正在向我的 HTML 文件添加第三个 <script /> 元素。第一个元素包含 Emscripten 提供的代码。第二个元素是插入 {{{ SCRIPT }}} 所在位置的代码。第三个脚本标签跟在后面。第三个脚本标签引用包含 InitWrappers 函数的 JavaScript。

var testCall;
var printNumber;
var square;

function InitWrappers() {
  testCall = Module.cwrap('testCall', 'undefined');
  printNumber = Module.cwrap('testCall', 'undefined', ['number']);
  square= Module.cwrap('square', 'number', ['number']);      
}

我声明了三个将用于保存函数对象的变量。它们通过 cwrap 调用的返回值进行填充。在第一个 cwrap 调用中,参数是要调用的 C/C++ 函数的名称和返回类型。此函数不返回任何值,因此其返回类型设置为 'undefined'。在第二次调用中,传递了一个附加参数;参数类型的列表。此函数仅接受一个参数,并且需要一个仅包含一个元素的列表。在第三次调用中,返回类型的参数设置为 'number',因为此方法将返回一个数值。要调用这些函数,我将一些 JavaScript 添加到 onclick 事件中。

此代码的编译语句有所不同。其中一些更改是可选的。但我将解释所有这些更改。

emcc hello.cpp --std=c++11  --shell-file shell_minimal.html 
--emrun -o hello.html -s NO_EXIT_RUNTIME=1 
-s EXPORTED_FUNCTIONS="['_testCall', '_printNumber','_square','_main']" 
-s EXTRA_EXPORTED_RUNTIME_METHODS="['cwrap','ccall']"  -s WASM=1
  • --std=c++11 - 我从现在起使用此参数启用 C++ 11 语言特性
  • --shell-file shell_minimal.html - 要使用的 Shell HTML 文件的名称
  • --emrun
  • -o hello.html - 要生成的输出 HTML 文件的名称
  • -s NO_EXIT_RUNTIME=1 - 防止运行时在 main 函数退出时关闭。
  • -s EXPORTED_FUNCTIONS="['_testCall', '_printNumber', '_square']" - 这些是将在我们的代码中添加到 Module 对象的方法名称。
  • -s EXTRA_EXPORTED_RUNTIME_METHODS="['cwrap','ccall']" - 这些是将被添加到 Modules 对象中的运行时方法名称。
  • -s WASM=1 发出 WebAssembly。将其设置为 0 将导致发出 ASM.js(此处未讨论)。

从 C/C++ 调用 JavaScript

我们已经隐式地从 C/C++ 调用了 JavaScript。但让我们看看如何显式地从 C/C++ 调用 JavaScript。有两种方法可以做到这一点;您可以直接在 C/C++ 代码中嵌入 JavaScript 代码,或者可以使用函数 emscripten_run_script()。如果您曾经在 C++ 代码中嵌入过汇编语言,那么这两种方法中的第一种就不会完全陌生。

如果您有一个 JavaScript 代码块希望在 C++ 代码中重复使用,您可以使用 EM_JS 编写一个 JavaScript 函数。

EM_JS(void,myAlert,(), {
     alert('hey, I am alerting you!');
     console.log('you have been alerted.')'
});

int main() { 
   myAlert();
   return 0;
}

因此,创建了一个名为 myAlert() 的新函数。如果 JavaScript 代码仅用于一次,则可以使用 EM_ASM 内联编写。

int main() { 
   EM_ASM(
     alert('hey, I am alerting you!');
     console.log('you have been alerted.')'
   );
   return 0;
}

我建议不要在 C/C++ 中嵌入大量代码。最好最多嵌入一个 JavaScript 函数调用;如果代码需要更新,更新 JavaScript 函数比在 C/C++ 代码中进行更改并重新编译更容易。

C++ 中的太阳位置

我想在完成本文第一部分之前展示一个非琐碎的例子。我对天文计算很感兴趣。我决定采用一个 C++ 例程来计算太阳位置,并将其用于网页。经过快速的 Google 搜索,我找到了这个

我需要对它做一些更改才能使用它,但改动不大。原始例程直接在 main 中收集输入。我的 main 中不需要做太多事情。我也不想使用 cin 对象;它会导致输入对话框显示。相反,我想通过一个例程来传递参数。我将保留 cout 调用;它们

在我的代码修改中,main 函数将只初始化包装器。我创建了一个新的 main 函数,它调用 JavaScript 函数来执行初始化。

int main(void){
    EM_ASM ( InitWrappers());
    return 0;
}

原来的 main 函数被重命名为 getSunInformation。我传递了纬度、经度和时区信息,并删除了之前使用 cin 提示用户输入这些信息的部分。

void getSunInformation(double latit, double longit, double tzone);

我还需要从这个调用中获取信息。虽然有不止一种方法可以做到这一点,但现在我将采取一个简单的选择;我将让 C++ 代码调用 JavaScript 代码并传递参数。我可以使用 EM_ASM 来做到这一点。在之前使用此函数时,我是在调用函数。现在我需要传递数据。在 EM_JS 中声明的 JavaScript 与 C++ 处于不同的作用域。它无法看到 C++ 代码中的变量。我们希望传递给 JavaScript 的任何信息都可以通过参数传递。在 JavaScript 中,这些信息可通过以美元符号后跟数字开头的变量(表示参数位置)获得。第一个参数是 $0,下一个是 $1,第三个是 $2,依此类推。

    EM_ASM (
        sunParameters($0,$1,$2, $3, $4, $4, $5, $6, $7);    
        sunNoonParams($8, $9);    
        sunCurrentPosition($10,$11);
        ,year,m,day, jd, latit, longit, tzone, delta*degs, daylen
        ,noont, altmax,
        azim*degs,altit  
    );

我正在使用三个我们尚未见过的函数。sunParameterssunNoonParameterssunCurrentPosition 函数尚未定义。我创建了一个新的 JavaScript 文件来包含它们。emscripten 生成的 JavaScript 文件名为 azimalt.js。我的 JavaScript 文件将追加到该文件之后;我将其命名为 azimAltPost.js。在此文件中,我定义了 InitWrappers 函数以及前面提到的三个 sun 函数。目前,sun 函数会将它们接收到的参数写入控制台。传递给 getSunInformation 的两个值是美国佐治亚州亚特兰大地区纬度和经度。如果您自己运行代码,可能想更改这些值。

var getSunInformation;

function InitWrappers() { 
    getSunInformation = Module.cwrap('getSunInformation', 
                        'undefined', ['number','number','number']);
    getSunInformation(-84,34,-5);
}

function sunParameters(year, month, day, julianDate, latitude, longitude, 
                       timeZone, delta, dayLength) {
    console.log(`${year}-${month}-${day}`);
    console.log(`Julian Date:${julianDate}`);
    console.log(`Latitude:${latitude}, Longitude:${longitude}`);
    console.log(`Time Zone: ${timeZone}`);
    console.log('Delta: ${delta})');
    console.log('Daylength: ${dayLength}')
}

function sunNoonParams(noont,altmax) {
    console.log(`Noont: ${noont}, Altitude Max:${altmax}`);
}

function sunCurrentPosition(azim, alt) {
   console.log(`Azimuth: ${azim} Altitude:${alt}`);
}

我在这里引入了一个新的编译参数 --post-js。此参数指定一个 JavaScript 文件,其内容将在 emscripten 生成的代码之后运行。我将我的 JavaScript 文件作为该参数的值传递。我使用的完整命令行如下:

emcc azimalt.cpp -o azimalt.html --post-js azimaltPost.js 
-s NO_EXIT_RUNTIME=1 -s EXPORTED_FUNCTIONS="['_getSunInformation', '_main']" 
-s EXTRA_EXPORTED_RUNTIME_METHODS="['cwrap','ccall']" -s WASM=1

打开 HTML 文件(确保它已通过 Web 服务器提供服务)并查看输出控制台。您应该会看到您所在地区日出和日落的信息。

自定义日出和日落显示

程序可以工作了,但让我们添加一些图形元素。我想添加一个 24 小时模拟时钟,该时钟可以一目了然地显示日出、日落以及太阳相对于这两者的当前位置。我还想提供一个罗盘显示器来显示方位角,以及一个图形来显示高度角。我将使用 SVG 进行图形绘制。许多元素可以声明式地定义。我设计了以下 UI。

SunriseClock

在将其集成到 WebAssembly 之前,我添加了一些范围滑块和 JavaScript,以确保这些元素按照我期望的方式移动。一旦工作正常,我就将 WASM 脚本复制到我的新 HTML 文件中,它就以图形方式显示了当天的日出和日落时间。如果您想自己查看,可以在 https://j2i.net/apps/sunrise/ 找到。当我尝试在 Web 服务器上托管它时,起初失败了,因为服务器软件不识别 WASM 扩展名。如果您遇到此类问题,请注册该扩展名的 MIME 类型。它是 application/wasm

其他支持的库

通过运行 emcc --show-ports 或查看 https://github.com/emscripten-ports,您可以看到 emscripten 编译器支持的一些其他 API。在撰写本文时,这是在命令行终端运行该命令的输出。

c:\shares\sdks\emsdk>emcc --show-ports
Available ports:
Boost headers v1.70.0 (USE_BOOST_HEADERS=1; Boost license)
icu (USE_ICU=1; Unicode License)
zlib (USE_ZLIB=1; zlib license)
bzip2 (USE_BZIP2=1; BSD license)
libjpeg (USE_LIBJPEG=1; BSD license)
libpng (USE_LIBPNG=1; zlib license)
SDL2 (USE_SDL=2; zlib license)
SDL2_image (USE_SDL_IMAGE=2; zlib license)
SDL2_gfx (zlib license)
ogg (USE_OGG=1; zlib license)
vorbis (USE_VORBIS=1; zlib license)
SDL2_mixer (USE_SDL_MIXER=2; zlib license)
bullet (USE_BULLET=1; zlib license)
freetype (USE_FREETYPE=1; freetype license)
harfbuzz (USE_HARFBUZZ=1; MIT license)
SDL2_ttf (USE_SDL_TTF=2; zlib license)
SDL2_net (zlib license)
cocos2d
regal (USE_REGAL=1; Regal license)

c:\shares\sdks\emsdk>

当您使用这些库之一时,emscripten 编译器将检索该库,在本地构建它,并将其链接到您的项目。

第二部分内容

我有一些关于第二部分文章内容的想法,例如如何处理复杂数据类型和绑定到类。但我希望听到您的声音。您希望看到什么?在评论区分享您的想法和问题;我希望在下一篇文章中对其中一些进行回应。

历史

  • 2019 年 11 月 8 日 - 初始发布
© . All rights reserved.