为 Wasm 编写一个 C 库脚本

有时,您可能需要使用仅以 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 团队正在努力使其在未来变得更小。

您可以按照 Emscripten 网站上的说明或使用 Homebrew 获取 Emscripten。如果您和我一样喜欢使用容器化命令,并且不想仅仅为了玩 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)。

运行简单的脚本

如需加载和运行模块,最简单的方法是使用生成的 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

为了从简单入手,我们尝试编写一个名为 webp.c 的 C 文件,以便将 encode.h 中的 WebPGetEncoderVersion() 公开给 JavaScript:

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

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

这是一个简单的程序,非常适合用于测试我们能否编译 libwebp 的源代码,因为我们不需要任何参数或复杂的数据结构即可调用此函数。

如需编译此程序,我们需要使用 -I 标志告知编译器 libwebp 的头文件所在的位置,并将 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>

我们将在输出中看到更正版本号:

显示正确版本号的 DevTools 控制台的屏幕截图。

将图片从 JavaScript 获取到 Wasm

获取编码器的版本号固然很棒,但编码实际图片会更令人印象深刻,对吗?那就这样吧。

我们首先要回答的问题是:如何将图片导入 Wasm 环境?查看 libwebp 的编码 API,它期望以 RGB、RGBA、BGR 或 BGRA 格式提供字节数组。幸运的是,Canvas API 提供了 getImageData(),它为我们提供了一个包含 RGBA 图片数据的 Uint8ClampedArray

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 无法扩大内存以容纳输入和输出图片的错误:

显示错误的开发者工具控制台的屏幕截图。

幸运的是,错误消息中包含了解决此问题的方法!我们只需将 -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”面板和生成的图片。

总结

让 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 模块提供内存,否则它们将无法使用内存。您可以使用 imports 对象(即 instantiateStreaming 函数的第二个参数)向 Wasm 模块提供任何内容。Wasm 模块可以访问 imports 对象内的所有内容,但无法访问该对象之外的任何内容。按照惯例,由 Emscripting 编译的模块需要从加载的 JavaScript 环境中获取一些内容:

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