使用 C 语言和 Emscripten 编写自己的 WebAssembly 模块
✨小透明・宸✨
2020-10-10 11:24:34

和 JavaScript 一样,WebAssembly 也是一种可以在浏览器中运行的编程语言,不过它的性质更相当于汇编语言。wasm 文件由二进制的字节码组成(也可以转换为类似于汇编语言的文本格式查看),可以从 C/C++、Rust 等编译执行的编程语言生成。当然,WebAssembly 也是跨平台的,编译出来的 wasm 文件在不同平台的浏览器中都可以运行。

WebAssembly 最大的特性就是和解释执行的 JS 相比的超高性能,甚至还可以将用其它语言编写的项目通过编译到 wasm 的方式移植到浏览器中运行(例如这个使用 wasm 版的 DOSBox 运行的《东方封魔录》,运行起来也十分流畅)。不过我开这篇并不是为了去编译那些大型项目的……由于几乎每个接受过计算机相关教育的人都学习过 C 语言,自己用 C 写一个简单的 wasm 模块来提升前端代码的性能,是门槛并不高而且也很有意思的一件事情。

从运行一个简单的模块开始

要将 C/C++ 代码编译到 wasm 模块,我们需要准备好 Emscripten 环境。

即使是 Windows 用户,也可以在 WSL 里面直接安装。

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

WebAssembly 的运行环境本身只能执行计算任务,加载 wasm 模块时还需要导入一个包含 JS 函数、内存区等属性的对象,wasm 和浏览器的交互只能通过调用这些 JS 函数完成,内存区则是和运行环境外面的 JS 代码共享,可以用来交换数据。Emscripten 的功能不仅是编译 C/C++ 代码生成 wasm,它还可以生成一大堆 JS 的“胶水代码”用来连接浏览器和 WebAssembly 运行环境,实现 C/C++ 的那些标准库,对内存分配、输入输出等进行处理。

MDN 上的教程就给出了一个从 Hello world 编译出 wasm 文件和对应的胶水代码、HTML 文件的例子。不过这胶水代码比 wasm 文件本身还大了不少……如果模块比较简单的话还是不太好的。好在 Emscripten 支持通过编译选项 -s SIDE_MODULE=1 使编译出来的 wasm 文件作为一个单独的动态库,这样就不需要那一堆胶水代码了,不过与 JS 部分的交互也需要自己完成。

从一个简单的计算两数相乘的模块开始。因为这只是一个函数库,所以 main 函数是不需要的,而且就算写了也不会在加载的时候自动执行。

int multiply(int a, int b) {
    return a * b;
}

编译,-O3 代表使用最高等级的编译优化:

emcc multiply.c -O3 -s SIDE_MODULE=1 -o multiply.wasm

在 HTML 中使用以下的 JS 代码加载:

const wasmModule = await fetch('multiply.wasm')
    .then(response => response.arrayBuffer())
    .then(buffer => WebAssembly.compile(buffer))
    .then(module => new WebAssembly.Instance(module));

(使用 WebAssembly.instantiateStreaming 加载更简洁,但是需要提供正确的 MIME 类型 application/wasm

加载完成后,就可以通过 wasmModule.exports 找到模块导出的函数了。WebAssembly 也是强类型的(32/64 位整数/浮点数),所以在这个例子中如果使用了小数会自动进行类型转换,并不能得到正确的结果,另外也需要注意溢出的问题。

以下是对应的“汇编代码”,$multiply 就是上面的相乘函数了:

(module
  (type $t0 (func))
  (type $t1 (func (param i32 i32) (result i32)))
  (func $__wasm_apply_relocs (type $t0)
    nop
  )
  (func $multiply (type $t1) (param $p0 i32) (param $p1 i32) (result i32)
    local.get $p0
    local.get $p1
    i32.mul
  )
  (global $__dso_handle i32 (i32.const 0))
  (export "__wasm_apply_relocs" (func $__wasm_apply_relocs))
  (export "multiply" (func $multiply))
  (export "__dso_handle" (global 0))
  (export "__post_instantiate" (func $__wasm_apply_relocs))
)

使用 VSCode 的扩展 WebAssembly 可以以文本格式显示打开的 wasm 文件,并且可以在两种格式之间相互转换。

使用外部函数、内存和指针

接下来是做一件每种编程语言的初学者都喜欢做的事:输出一个 Hello world。

#include <stdio.h>

void hello_world() {
    puts("Hello world!");
    puts("WebAssembly模块测试");
}

编译……等下,既然是编译到 WebAssembly 了,那从哪里找到标准库 stdio.h 和这个 puts 呢?看一下文本格式代码:

(module
  (type $t0 (func))
  (type $t1 (func (param i32) (result i32)))
  (import "env" "puts" (func $env.puts (type $t1)))
  (import "env" "__memory_base" (global $env.__memory_base i32))
  (import "env" "memory" (memory $env.memory 0))
  (func $__wasm_apply_relocs (type $t0)
    nop
  )
  (func $hello_world (type $t0)
    (local $l0 i32)
    global.get $env.__memory_base
    local.tee $l0
    call $env.puts
    drop
    local.get $l0
    i32.const 13
    i32.add
    call $env.puts
    drop
  )
  (global $__dso_handle i32 (i32.const 0))
  (export "__wasm_apply_relocs" (func $__wasm_apply_relocs))
  (export "hello_world" (func $hello_world))
  (export "__dso_handle" (global 1))
  (export "__post_instantiate" (func $__wasm_apply_relocs))
  (data $d0 (global.get $env.__memory_base) "Hello world!\00WebAssembly\e6\a8\a1\e5\9d\97\e6\b5\8b\e8\af\95\00")
)

puts 是需要自己从 JS 中导入的。在 C 的代码里实际上也可以不写 #include <stdio.h>,直接用 int puts(const char *str); 声明也是没问题的。

字符串被全部写进了一个数据段(汉字部分和源代码一样使用了 UTF-8 编码)。在加载 wasm 模块的时候需要提供一块内存区域用来从某个偏移位置开始保存这些数据。

这里就先使用 console.log 代替 puts,并且创建一个 WebAssembly.Memory 作为内存区域,然后加载:

const wasmMemory = new WebAssembly.Memory({ initial: 1 });
const wasmBuffer = new Uint8Array(wasmMemory.buffer);
const wasmModule = await fetch('hello-world.wasm')
    .then(response => response.arrayBuffer())
    .then(buffer => WebAssembly.compile(buffer))
    .then(module => new WebAssembly.Instance(module, {
        env: {
            puts: console.log,
            memory: wasmMemory,
            __memory_base: 0,
        }
    }));

WebAssembly.Memory 是一块用于 wasm 模块的内存,以 64KB 为一“页”作为分配的基本单位,也可以在创建后动态扩容。在 JS 中可以通过 TypedArray 对它进行读写。

加载并执行一下试试看:

字符串被写入了内存中从 __memory_base 开始的位置,而和 C 语言一样,调用 puts(实际上是 console.log)的参数是字符串开始的指针(数组下标)。如果自己实现一个 puts,就可以将字符串输出到控制台(或者是页面上某个 DOM 的 innerText)了。

顺便一提,有个叫 Locutus 的库尝试使用 JS 实现其他语言的标准库,虽然对于 C 的实现并不多……

比如要实现 sprintfprintf,就可以参考这里

const wasmMemory = new WebAssembly.Memory({ initial: 1 });
const wasmBuffer = new Uint8Array(wasmMemory.buffer);
const wasmModule = await fetch('hello-world.wasm')
    .then(response => response.arrayBuffer())
    .then(buffer => WebAssembly.compile(buffer))
    .then(module => new WebAssembly.Instance(module, {
        env: {
            // 将从指针开始到下一个0x00之间的数据用UTF-8解码,然后用console.log输出
            puts: ptr => console.log(
                (new TextDecoder).decode(
                    wasmBuffer.slice(
                        ptr,
                        wasmBuffer.findIndex((e, i) => i >= ptr && !e)
                    )
                )
            ),
            memory: wasmMemory,
            __memory_base: 0,
        }
    }));

试着将内存中的第一个单元的数据改成 0x41(也就是字母 A),再次调用函数,可以看到输出的内容也发生了相应的变化。当然,实际使用的时候,如果需要向内存中写入数据,就要根据 __memory_base 和代码中数据段的大小自己预留好内存空间,避免覆盖了 wasm 模块内部使用的数据。

一个使用 WebAssembly 将性能提高到 30x 的例子

接下来就用一个实际的例子,来展示 WebAssembly 和 JS 相比在计算密集型任务上的超高性能。我分别用 WebAssembly 和 JS 试着实现了一遍 RC4 加解密算法。为什么用了 RC4 作为例子?

  • 加密和解密涉及到大量的计算操作
  • RC4 的算法简短,且加密和解密是同一套算法,易于实现
  • RC4 是流密码,对明文和密钥的长度要求不多,也不需要像分组密码一样处理不同的工作模式,易于使用
  • 有一定的实用性(虽然现在 RC4 的安全性已经比较有限了……)

以下是来自 Wikipedia 的伪代码:

for i from 0 to 255
    S[i] := i
endfor
j := 0
for( i=0 ; i<256 ; i++)
    j := (j + S[i] + key[i mod keylength]) % 256
    swap values of S[i] and S[j]
endfor
i := 0
j := 0
while GeneratingOutput:
    i := (i + 1) mod 256
    j := (j + S[i]) mod 256
    swap values of S[i] and S[j]
    k := inputByte ^ S[(S[i] + S[j]) % 256]
    output K
endwhile

照着伪代码,用 JS 实现一遍(经检验,实现完全正确,检验过程略):

使用 JS 的实现
/**
 * @param {Uint8Array} key
 * @param {Uint8Array} input
 * @returns {Uint8Array}
 */
const jsRc4 = (key, input) => {
    let i = 0;
    let j = 0;
    const keyLength = key.length;
    const inputLength = input.length;

    const sbox = new Uint8Array(256);
    for (i = 0; i < 256; ++i) sbox[i] = i;
    for (i = 0; i < 256; ++i) {
        j = (j + sbox[i] + key[i % keyLength]) & 0xFF;
        [sbox[i], sbox[j]] = [sbox[j], sbox[i]];
    }

    const output = new Uint8Array(inputLength);
    i = j = 0;
    for (let k = 0; k < inputLength; ++k) {
        i = (i + 1) & 0xFF;
        j = (j + sbox[i]) & 0xFF;
        [sbox[i], sbox[j]] = [sbox[j], sbox[i]];
        output[k] = input[k] ^ sbox[(sbox[i] + sbox[j]) & 0xFF];
    }

    return output;
}

然后移植到 C 语言 一看就是一个模子刻出来的

使用 C 语言的实现,以及相关的 JS 胶水代码
static unsigned char sbox[256];

void rc4(unsigned char *key, unsigned char *input, unsigned int keyLength, unsigned int inputLength) {
    unsigned short i = 0;
    unsigned char j = 0;
    unsigned char temp;

    for (i = 0; i < 256; ++i) sbox[i] = i;
    for (i = 0; i < 256; ++i) {
        // j = (j + sbox[i] + key[i % keyLength]) & 0xFF;
        j += sbox[i] + key[i % keyLength];
        temp = sbox[i];
        sbox[i] = sbox[j];
        sbox[j] = temp;
    }

    i = j = 0;
    for (unsigned int k = 0; k < inputLength; ++k) {
        i = (i + 1) & 0xFF;
        // j = (j + sbox[i]) & 0xFF;
        j += sbox[i];
        temp = sbox[i];
        sbox[i] = sbox[j];
        sbox[j] = temp;
        input[k] ^= sbox[(sbox[i] + sbox[j]) & 0xFF];
    }
}
const wasmMemory = new WebAssembly.Memory({ initial: 1 });
const wasmInstance = await fetch('rc4.wasm')
    .then(response => response.arrayBuffer())
    .then(buffer => WebAssembly.compile(buffer))
    .then(module => new WebAssembly.Instance(module, {
        env: {
            memory: wasmMemory,
            __memory_base: 0,
        },
    }));

/**
 * @param {Uint8Array} key
 * @param {Uint8Array} input
 * @returns {Uint8Array}
 */
const wasmRc4 = (key, input) => {
    // 内存的分布:前256字节保留给S盒,然后是x字节的密钥和x字节的输入
    // 如果内存不足则进行扩容
    const sboxOffset = 256;
    const memoryRequired = key.length + input.length + sboxOffset;
    if (memoryRequired > wasmMemory.buffer.byteLength) {
        wasmMemory.grow(Math.ceil((memoryRequired - wasmMemory.buffer.byteLength) / 65536));
    }

    const wasmBuffer = new Uint8Array(wasmMemory.buffer);
    wasmBuffer.set(key, sboxOffset);
    wasmBuffer.set(input, sboxOffset + key.length);
    wasmInstance.exports.rc4(sboxOffset, sboxOffset + key.length, key.length, input.length);
    return new Uint8Array(wasmBuffer.slice(sboxOffset + key.length, sboxOffset + key.length + input.length));
}

测试则是使用 1KB 的随机密钥,分别使用两种实现来加密 4KB、8KB……128MB 的同一份随机数据,并比较执行时间。

测完才意识到其实并不需要这么长的密钥……超过前 256 字节的部分是没有意义的,不过这也不会影响测试结果,所以就不重新测啦!

测试代码
const results = [];
let inputLength = 2048;
for (let i = 0; i < 16; i++) {
    inputLength <<= 1;
    const rc4Key = new Uint8Array(1024).map(() => Math.random() * 256);
    const rc4Input = new Uint8Array(inputLength).map(() => Math.random() * 256);

    performance.mark('wasm-start');
    const wasmEncrypted = wasmRc4(rc4Key, rc4Input);
    performance.mark('wasm-end');
    performance.mark('js-start');
    const jsEncrypted = jsRc4(rc4Key, rc4Input);
    performance.mark('js-end');
    performance.measure('wasm', 'wasm-start', 'wasm-end');
    performance.measure('js', 'js-start', 'js-end');
    for (let j = 0; j < inputLength; j++) if (wasmEncrypted[j] !== jsEncrypted[j]) throw 'Not equal';

    results.push({
        length: inputLength,
        wasmTime: performance.getEntriesByName('wasm')[0].duration,
        jsTime: performance.getEntriesByName('js')[0].duration,
    });

    performance.clearMarks();
    performance.clearMeasures();
}
results.push(results.reduce(
    (acc, cur) => {
        for (const key in acc) acc[key] += cur[key];
        return acc;
    },
    {
        length: 0,
        wasmTime: 0,
        jsTime: 0,
    }
));
results.forEach(r => {
    r.wasmSpeed = r.length / r.wasmTime;
    r.jsSpeed = r.length / r.jsTime;
    r.ratio = r.wasmSpeed / r.jsSpeed;
});
console.table(results);

至于测试结果嘛……首先是在我自己日常用的 Firefox 上测试,性能直接提升了 8x!(最后一行是以上测试结果的总和)

然后是 Chrome 上的测试结果。即使是面对使用了性能最强的 V8 引擎的 Chrome,WebAssembly 的性能优势仍然能达到 2x(虽然速度和 Firefox 那边差不多……)

至于标题上的 30x……是在已经弃坑的旧版 Edge 里面测出来的,不过不论是 WebAssembly 还是 JS 这执行速度都比另外两个差了一大截。

(IE 不资辞 WebAssembly,直接爬吧)

你也可以在这里自己试试看~(wasm 文件已经以 Data URL 的形式嵌入了)

结论是显而易见的,WebAssembly 的性能比 JS 不知道高到哪里去了。而且如果不写清楚函数名的话,直接对着它的“汇编代码”很难看出是 RC4 算法,所以把一些关键操作放进 wasm 模块或许也是个防止前端代码被逆向的好主意?

即使不熟悉 C 语言和 Emscripten 的那一套工具,GitHub 上也有 WaltAssemblyScript 这样的工具可以直接使用 TypeScript 的语法编写 wasm 模块,以后再试试看吧~

封面图:Pixiv ID: 82627900 「After school~」 by 呱子

本作品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。
本文作者:✨小透明・宸✨
本文链接:https://akarin.dev/2020/10/10/build-webassembly-module-with-c/
下一篇 chevron_right