为 Wasm 编写一个 C 库脚本

有时,您需要使用仅以 C 或 C++ 代码的形式提供的库。 从传统意义上来说,这是放弃操作。嗯,不是,因为现在 EmscriptenWebAssembly (或 Wasm)!

工具链

我设定的目标是研究如何编译一些现有的 C 代码, Wasm。LLVM 的 Wasm 后端存在一些噪声, 于是我开始深入研究这个问题。虽然 你可以获取简单的程序来 这样,当您想要使用 C 语言的标准库,甚至 您很可能会遇到问题于是我进入了 学习要点:

虽然 Emscripten 曾经用于 C-to-asm.js 编译器,但后来已经发展为 目标 Wasm 是 传递到官方 LLVM 后端。Emscripten 还提供 C 标准库的 Wasm 兼容实现。使用 Emscripten。它 执行大量隐藏工作 可模拟文件系统、提供内存管理、使用 WebGL 封装 OpenGL 很多事情你其实并不需要亲自开发就能体验

虽然这听起来您可能会担心膨胀,但我确实很担心 — Emscripten 编译器会移除不需要的所有内容。在我的 生成的 Wasm 模块大小已针对逻辑进行了 Emscripten 和 WebAssembly 团队正在努力 使其更小

如需获取 Emscripten,请按照其 website 或使用 HomeBra。如果你喜欢 也不想只是在你的系统上安装 有一个维护良好的 您可以使用的 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 告知编译器积极优化。您可以选择较低的 以缩短构建时间,但这也会让最终的 bundle 生成 因为编译器可能不会移除未使用的代码。

运行此命令后,最终应该会有一个名为 a.out.js 和一个名为 a.out.wasm 的 WebAssembly 文件。Wasm 文件(或 “module”)包含经过编译的 C 代码,应该非常小。通过 JavaScript 文件负责加载和初始化 Wasm 模块, 提供了更好的 API如果需要,它还负责设置 堆栈、堆以及通常由 与操作系统不同。因此,JavaScript 文件 更大,约为 19KB(在 gzip 压缩后约为 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。核心 另一个应用场景则是采用现有的 C 语言生态系统 并允许开发者在网络上使用它们这些库往往 依赖于 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 调 并依赖编译器来剔除所有 不需要。看起来效果非常好!

    $ 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>

我们将在 output

显示正确版本的开发者工具控制台屏幕截图
数字。

将图片从 JavaScript 提取到 Wasm 中

获取编码器的版本号固然很好, 这样就会给人留下深刻的印象,对吧?那我们就来吧。

我们必须回答的第一个问题是:如何将图像上传到 Wasm land 中? 观察 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 land 提供了这张图片。现在可以调用 WebP 编码器, 发挥作用!观察 WebP 文档WebPEncodeRGBA 看起来非常合适。该函数会获取一个指向输入图片的指针, 尺寸以及 0 到 100 之间的质量选项还会分配 为我们分配一个输出缓冲区,完成上述操作后,我们需要使用 WebPFree() 释放该缓冲区。 对 WebP 图像做的调整。

编码操作的结果是输出缓冲区及其长度。因为 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-land 缓冲区。

    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 图片的辉煌

开发者工具的 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 模块没有内存可供使用 除非你为他们提供内存向 Wasm 模块提供 任何东西都要使用 imports 对象,即 instantiateStreaming 函数。Wasm 模块可以访问其中的所有内容 imports 对象除外,而不会使用任何其他对象。按照惯例 由 Emscripting 编译的 JavaScript 代码 环境:

  • 首先是 env.memory。Wasm 模块不知道外部 因此需要 使用一些内存进入 WebAssembly.Memory。 它代表一段(可选择扩展的)线性内存。大小 参数均采用“以 WebAssembly 网页为单位”,即上述代码 分配 1 页内存,其中每个页面的大小为 64 KiB。不提供 maximum 从理论上讲,内存增长是无限的(Chrome 目前 硬性限制为 2GB)。大多数 WebAssembly 模块不需要设置 最大值。
  • env.STACKTOP 定义堆栈应开始增长的位置。堆栈 进行函数调用以及为局部变量分配内存时需要使用它。 由于我们没有在 斐波那契程序,我们只需将整个内存用作堆栈, STACKTOP = 0