有时,您需要使用仅以 C 或 C++ 代码形式提供的库。按照传统,您应放弃。不用了,因为现在我们有了 Emscripten 和 WebAssembly(或 Wasm)!
工具链
我给自己设定的目标是要弄清楚如何将一些现有的 C 代码编译到 Wasm 中。LLVM 的 Wasm 后端有一些噪声,所以我开始深入研究此问题。虽然您可以使用简单的程序来以这种方式编译,但是当您想要使用 C 的标准库,甚至编译多个文件时,很可能会遇到问题。于是,我学习到了以下重要课程:
虽然 Emscripten 曾经是 C-to-asm.js 编译器,但此后它已经成熟,以 Wasm 为目标,并且正在在内部切换到官方 LLVM 后端。Emscripten 还提供了 C 标准库的 Wasm 兼容实现。使用 Emscripten。它承载许多隐藏工作、模拟文件系统、提供内存管理、使用 WebGL 封装 OpenGL - 其中许多功能您真的无需亲自开发。
虽然这听起来似乎有点担心出现膨胀,但我确实很担心,但 Emscripten 编译器会移除不需要的所有内容。在我的实验中,生成的 Wasm 模块的大小可根据其包含的逻辑进行相应调整,Emscripten 和 WebAssembly 团队正努力在未来进一步缩减这些模块的大小。
您可以按照 Emscripten 网站上的说明或使用 Homeborn 来获取该 Emscripten。如果您像我一样喜欢使用 Docker 化命令,但不想仅仅为了体验 WebAssembly 而在系统上安装资源,您可以使用维护良好的 Docker 映像来代替:
$ docker pull trzeci/emscripten
$ docker run --rm -v $(pwd):/src trzeci/emscripten emcc <emcc options here>
编译一些简单的内容
让我们来看一个几乎规范的 C 语言示例,该示例使用 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(经过 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 编写的。不过,WebAssembly 的核心用例是利用现有的 C 库生态系统,并允许开发者在网页上使用这些库。这些库通常依赖于 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>
我们将在输出中看到更正的版本号:
将图像从 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-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);
总结
让 C 库在浏览器中运行并非是一蹴而就的事情,但是一旦您了解了整体过程和数据流的工作原理,就会变得更加容易,并且取得的效果也会令人惊叹。
WebAssembly 在网络上为处理、数字处理和游戏开辟了许多新的可能性。请记住,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
。