Emscripten 将 C 库编译为 Wasm

有时您想使用一个仅以 C 或 C++ 代码形式提供的库。传统上,您会在此处放弃。但是,现在我们有了 EmscriptenWebAssembly (或 Wasm)!

工具链

我为自己设定了目标,即弄清楚如何将一些现有的 C 代码编译为 Wasm。围绕 LLVM 的 Wasm 后端已经有一些声音,所以我开始深入研究它。虽然 您可以让简单的程序通过这种方式进行编译,但是一旦您想要使用 C 的标准库,甚至编译多个文件,您可能会遇到问题。这让我学到了一个主要的教训

虽然 Emscripten 曾经 是一个 C 到 asm.js 的编译器,但它已经成熟到可以针对 Wasm,并且 正在 切换到官方的 LLVM 后端。Emscripten 还提供了与 Wasm 兼容的 C 标准库实现。使用 Emscripten。它 承担了许多隐藏的工作,模拟文件系统,提供内存管理,用 WebGL 包装 OpenGL — 很多您真的不需要自己开发的体验。

虽然这听起来您可能需要担心膨胀 — 我当然担心 — 但 Emscripten 编译器会删除所有不需要的东西。在我的实验中,生成的 Wasm 模块的大小与其包含的逻辑大小相当,并且 Emscripten 和 WebAssembly 团队正在努力使它们在未来变得更小。

您可以通过按照他们的 网站 上的说明或使用 Homebrew 来获取 Emscripten。如果您像我一样是 dockerized 命令的粉丝,并且不想在您的系统上安装东西只是为了玩 WebAssembly,那么有一个维护良好的 Docker 镜像 您可以改用它

    $ docker pull trzeci/emscripten
    $ docker run --rm -v $(pwd):/src trzeci/emscripten emcc <emcc options here>

编译一些简单的东西

让我们以几乎规范的示例为例,用 C 编写一个函数来计算第 n 斐波那契数

    #include <emscripten.h>

    EMSCRIPTEN_KEEPALIVE
    int fib(int n) {
      if(n <= 0){
        return 0;
      }
      int i, t, a = 0, b = 1;
      for (i = 1; i < n; i++) {
        t = a + b;
        a = b;
        b = t;
      }
      return b;
    }

如果您了解 C 语言,那么该函数本身不应该太令人惊讶。即使您不懂 C 语言但懂 JavaScript,您也有望理解这里发生了什么。

emscripten.h 是 Emscripten 提供的头文件。我们只需要它,以便我们可以访问 EMSCRIPTEN_KEEPALIVE 宏,但它 提供了更多功能。这个宏告诉编译器不要删除一个函数,即使它看起来未使用。如果我们省略了该宏,编译器将优化掉该函数 — 毕竟没有人使用它。

让我们将所有这些保存在一个名为 fib.c 的文件中。要将其转换为 .wasm 文件,我们需要使用 Emscripten 的编译器命令 emcc

    $ emcc -O3 -s WASM=1 -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' fib.c

让我们剖析一下这个命令。emcc 是 Emscripten 的编译器。fib.c 是我们的 C 文件。到目前为止,一切都很好。-s WASM=1 告诉 Emscripten 给我们一个 Wasm 文件而不是一个 asm.js 文件。-s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' 告诉编译器在 JavaScript 文件中保留 cwrap() 函数可用 — 稍后会详细介绍此函数。-O3 告诉编译器积极优化。您可以选择较低的数字来减少构建时间,但这也会使生成的捆绑包更大,因为编译器可能不会删除未使用的代码。

运行命令后,您应该得到一个名为 a.out.js 的 JavaScript 文件和一个名为 a.out.wasm 的 WebAssembly 文件。Wasm 文件(或“模块”)包含我们编译的 C 代码,应该相当小。JavaScript 文件负责加载和初始化我们的 Wasm 模块并提供更友好的 API。如果需要,它还将负责设置堆栈、堆和其他通常期望在编写 C 代码时由操作系统提供的功能。因此,JavaScript 文件稍微大一些,大小为 19KB(压缩后约为 5KB gzip)。

运行一些简单的东西

加载和运行模块的最简单方法是使用生成的 JavaScript 文件。加载该文件后,您将拥有一个可供您使用的 Module 全局变量。使用 cwrap 创建一个 JavaScript 原生函数,该函数负责将参数转换为 C 友好的东西并调用包装的函数。cwrap 接受函数名称、返回类型和参数类型作为参数,顺序如下

    <script src="a.out.js"></script>
    <script>
      Module.onRuntimeInitialized = _ => {
        const fib = Module.cwrap('fib', 'number', ['number']);
        console.log(fib(12));
      };
    </script>

如果您 运行此代码,您应该在控制台中看到“144”,这是第 12 个斐波那契数。

圣杯:编译 C 库

到目前为止,我们编写的 C 代码都是考虑到 Wasm 而编写的。然而,WebAssembly 的一个核心用例是利用现有的 C 库生态系统,并允许开发人员在 Web 上使用它们。这些库通常依赖于 C 的标准库、操作系统、文件系统和其他东西。Emscripten 提供了这些大部分功能,尽管存在一些 限制

让我们回到我的最初目标:将 WebP 编码器编译为 Wasm。WebP 编解码器的源代码是用 C 语言编写的,并且在 GitHub 上可用,以及一些广泛的 API 文档。这是一个非常好的起点。

    $ git clone https://github.com/webmproject/libwebp

为了简单起见,让我们尝试将 WebPGetEncoderVersion()encode.h 暴露给 JavaScript,方法是编写一个名为 webp.c 的 C 文件

    #include "emscripten.h"
    #include "src/webp/encode.h"

    EMSCRIPTEN_KEEPALIVE
    int version() {
      return WebPGetEncoderVersion();
    }

这是一个很好的简单程序,可以测试我们是否可以编译 libwebp 的源代码,因为我们不需要任何参数或复杂的数据结构来调用此函数。

要编译这个程序,我们需要告诉编译器在哪里可以找到 libwebp 的头文件,使用 -I 标志,并将它需要的所有 libwebp 的 C 文件传递给它。我要坦诚地说:我只是给了它所有我能找到的 C 文件,并依靠编译器来剥离所有不必要的东西。它似乎工作得非常好!

    $ emcc -O3 -s WASM=1 -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' \
        -I libwebp \
        webp.c \
        libwebp/src/{dec,dsp,demux,enc,mux,utils}/*.c

现在我们只需要一些 HTML 和 JavaScript 来加载我们闪亮的新模块

<script src="/a.out.js"></script>
<script>
  Module.onRuntimeInitialized = async (_) => {
    const api = {
      version: Module.cwrap('version', 'number', []),
    };
    console.log(api.version());
  };
</script>

我们将在 输出 中看到正确的版本号

Screenshot of the DevTools console showing the correct version
number.

从 JavaScript 获取图像到 Wasm

获取编码器的版本号固然很棒,但是编码实际图像会更令人印象深刻,对吧?那我们来做吧。

我们要回答的第一个问题是:我们如何将图像放入 Wasm 领域?查看 libwebp 的编码 API,它期望一个字节数组,格式为 RGB、RGBA、BGR 或 BGRA。幸运的是,Canvas API 具有 getImageData(),它可以为我们提供一个 Uint8ClampedArray,其中包含 RGBA 格式的图像数据

async function loadImage(src) {
  // Load image
  const imgBlob = await fetch(src).then((resp) => resp.blob());
  const img = await createImageBitmap(imgBlob);
  // Make canvas same size as image
  const canvas = document.createElement('canvas');
  canvas.width = img.width;
  canvas.height = img.height;
  // Draw image onto canvas
  const ctx = canvas.getContext('2d');
  ctx.drawImage(img, 0, 0);
  return ctx.getImageData(0, 0, img.width, img.height);
}

现在“仅仅”是将数据从 JavaScript 领域复制到 Wasm 领域的问题了。为此,我们需要暴露两个额外的函数。一个在 Wasm 领域内为图像分配内存,另一个再次释放它

    EMSCRIPTEN_KEEPALIVE
    uint8_t* create_buffer(int width, int height) {
      return malloc(width * height * 4 * sizeof(uint8_t));
    }

    EMSCRIPTEN_KEEPALIVE
    void destroy_buffer(uint8_t* p) {
      free(p);
    }

create_buffer 为 RGBA 图像分配缓冲区 — 因此每个像素 4 个字节。malloc() 返回的指针是该缓冲区的第一个内存单元的地址。当指针返回到 JavaScript 领域时,它被视为只是一个数字。在使用 cwrap 将函数暴露给 JavaScript 后,我们可以使用该数字找到缓冲区的起始位置并复制图像数据。

const api = {
  version: Module.cwrap('version', 'number', []),
  create_buffer: Module.cwrap('create_buffer', 'number', ['number', 'number']),
  destroy_buffer: Module.cwrap('destroy_buffer', '', ['number']),
};
const image = await loadImage('/image.jpg');
const p = api.create_buffer(image.width, image.height);
Module.HEAP8.set(image.data, p);
// ... call encoder ...
api.destroy_buffer(p);

盛大结局:编码图像

图像现在在 Wasm 领域中可用。现在是调用 WebP 编码器来完成其工作的时候了!查看 WebP 文档WebPEncodeRGBA 似乎非常适合。该函数接受指向输入图像及其尺寸的指针,以及 0 到 100 之间的质量选项。它还为我们分配了一个输出缓冲区,一旦我们完成 WebP 图像的处理,我们需要使用 WebPFree() 释放它。

编码操作的结果是一个输出缓冲区及其长度。由于 C 语言中的函数不能将数组作为返回类型(除非我们动态分配内存),所以我求助于静态全局数组。我知道,这不是干净的 C 语言(实际上,它依赖于 Wasm 指针是 32 位的),但是为了保持简单,我认为这是一个合理的快捷方式。

    int result[2];
    EMSCRIPTEN_KEEPALIVE
    void encode(uint8_t* img_in, int width, int height, float quality) {
      uint8_t* img_out;
      size_t size;

      size = WebPEncodeRGBA(img_in, width, height, width * 4, quality, &img_out);

      result[0] = (int)img_out;
      result[1] = size;
    }

    EMSCRIPTEN_KEEPALIVE
    void free_result(uint8_t* result) {
      WebPFree(result);
    }

    EMSCRIPTEN_KEEPALIVE
    int get_result_pointer() {
      return result[0];
    }

    EMSCRIPTEN_KEEPALIVE
    int get_result_size() {
      return result[1];
    }

现在一切就绪,我们可以调用编码函数,获取指针和图像大小,将其放入我们自己的 JavaScript 领域缓冲区中,并释放我们在此过程中分配的所有 Wasm 领域缓冲区。

    api.encode(p, image.width, image.height, 100);
    const resultPointer = api.get_result_pointer();
    const resultSize = api.get_result_size();
    const resultView = new Uint8Array(Module.HEAP8.buffer, resultPointer, resultSize);
    const result = new Uint8Array(resultView);
    api.free_result(resultPointer);

根据您图像的大小,您可能会遇到一个错误,即 Wasm 无法增长足够的内存来容纳输入和输出图像

Screenshot of the DevTools console showing an error.

幸运的是,此问题的解决方案在错误消息中!我们只需要将 -s ALLOW_MEMORY_GROWTH=1 添加到我们的编译命令中即可。

这就是您所拥有的!我们编译了一个 WebP 编码器并将 JPEG 图像转码为 WebP。为了证明它有效,我们可以将我们的结果缓冲区转换为 blob 并在 <img> 元素上使用它

const blob = new Blob([result], { type: 'image/webp' });
const blobURL = URL.createObjectURL(blob);
const img = document.createElement('img');
img.src = blobURL;
document.body.appendChild(img);

看哪,新的 WebP 图像的光辉!

DevTools’ network panel and the generated image.

结论

让 C 库在浏览器中工作并非易事,但是一旦您了解了整个过程以及数据流的工作方式,它就会变得更容易,结果可能会令人大开眼界。

WebAssembly 为 Web 上的处理、数字运算和游戏开辟了许多新的可能性。请记住,Wasm 不是适用于所有事物的灵丹妙药,但是当您遇到这些瓶颈之一时,Wasm 可能是一个非常有用的工具。

奖励内容:以困难的方式运行一些简单的东西

如果您想尝试避免生成的 JavaScript 文件,您或许可以做到。让我们回到斐波那契示例。要自己加载和运行它,我们可以这样做

<!DOCTYPE html>
<script>
  (async function () {
    const imports = {
      env: {
        memory: new WebAssembly.Memory({ initial: 1 }),
        STACKTOP: 0,
      },
    };
    const { instance } = await WebAssembly.instantiateStreaming(
      fetch('/a.out.wasm'),
      imports,
    );
    console.log(instance.exports._fib(12));
  })();
</script>

由 Emscripten 创建的 WebAssembly 模块没有内存可用,除非您为它们提供内存。您为 Wasm 模块提供任何东西的方式是使用 imports 对象 — instantiateStreaming 函数的第二个参数。Wasm 模块可以访问 imports 对象内的所有内容,但不能访问其外部的任何内容。按照惯例,由 Emscripting 编译的模块期望从加载 JavaScript 环境中获得一些东西

  • 首先,有 env.memory。Wasm 模块不知道外部世界,因此它需要获得一些内存才能工作。输入 WebAssembly.Memory。它表示一块(可选地可增长的)线性内存。大小调整参数以“WebAssembly 页面的单位”表示,这意味着上面的代码分配了 1 页内存,每页大小为 64 KiB。如果不提供 maximum 选项,则内存理论上是无限增长的(Chrome 目前的硬性限制为 2GB)。大多数 WebAssembly 模块不需要设置最大值。
  • env.STACKTOP 定义了堆栈应该从哪里开始增长。堆栈是进行函数调用和为局部变量分配内存所必需的。由于我们在我们的小斐波那契程序中没有进行任何动态内存管理操作,因此我们可以只使用整个内存作为堆栈,因此 STACKTOP = 0