WebAssembly探索之旅

2023-07-09 14:43:06 浏览数 (2)

本文从最简单函数调用开始,逐步探索c库的调用,多文件/模块链接,WASI,函数指针参数,wasm引用js对象,c函数作为js回调等话题。所有代码均不生成胶水代码,更能体现wasm的本质,所有代码都放github上,都能在node v16.16.0上运行。

不到20行的例子

如下C代码:

代码语言:javascript复制
int add(int a, int b) {
    return a   b;
}

编译:

代码语言:javascript复制
emcc --no-entry -O3 adder.c -o adder.wasm -s EXPORTED_FUNCTIONS="['_add']"
  • 要求输出.wasm文件,表示不需要胶水代码
  • 不加--no-entry会报错,说找不到main函数
  • EXPORTED_FUNCTIONS是导出的函数,导出后可以在js访问

js调用代码:

代码语言:javascript复制
const fs = require('fs');

const wasmSource = new Uint8Array(fs.readFileSync("adder.wasm"));
const wasmModule = new WebAssembly.Module(wasmSource);
const wasmInstance = new WebAssembly.Instance(wasmModule, {
    env: {
    }
});

const result = wasmInstance.exports.add(2, 40);
console.log(result);
  • 前8行是wasm加载,固定套路,这部分本文所有例子都差不多
  • 加载完毕后可以在WebAssembly.Instance.exports访问到编译时声明为导出的函数

运行:

代码语言:javascript复制
node test_adder.js #输出42

调用c库函数

作为一个调包侠,我很自然的问到:怎么调其它库?如果是第三方库,需要自行编译成wasm或者找现成的wasm版本,如果是c/c 标准库,emscripten已经内置了支持,直接使用即可。以malloc和free为例子:

代码语言:javascript复制
//call_malloc.c
#include <stdlib.h>

void* allocStr(int len) {
    int i;
    char* p = (char*)malloc(len   1);
    for(i = 0; i < len; i  ) {
        p[i] = 'a'   (char)i;
    }
    p[len] = 'n';
    return p;
}

void freeStr(char* p) {
    if (p) free(p);
}

调用的js代码:

代码语言:javascript复制
//。。。省略的加载代码

const ptr = wasmInstance.exports.allocStr(6);
console.log(`str(${6}) = ${ptr}`);
wasmInstance.exports.freeStr(ptr);

输出

代码语言:javascript复制
str(6) = 67080

怎么是个数字?不是字符串么?原来WebAssembly(1.0)只有四种类型:int32,int64,float32,float64,而指针,在wasm32用int32表达,代表的是线性内存偏移,我们在allocStr后加两行代码验证下:

代码语言:javascript复制
const ptr = wasmInstance.exports.allocStr(6);
const heap = new Uint8Array(wasmInstance.exports.memory.buffer);
console.log(`arr[0]=${heap[ptr]}, arr[1]=${heap[ptr   1]}`);

打印如下,符合预期,0,1号字节分别是'a','b'的ascii码:

代码语言:javascript复制
arr[0]=97, arr[1]=98

我们可以写个函数,去memory读取内容转成js字符串,篇幅的关系就不贴了,看附带的代码仓库链接。

链接

c代码

foo.c代码

代码语言:javascript复制
#include <stdlib.h>

extern int bar(int* array, int size);

int foo(int size) {
    int i, ret;
    int* p = (int*)malloc(size * 4);
    for(i = 0; i < size; i  ) {
        p[i] = i;
    }
    ret = bar(p, size);
    free(p);
    return ret;
}

静态链接

直接emcc多个文件

代码语言:javascript复制
emcc foo.c bar.c --no-entry  -O3 -o muti_src.wasm -s EXPORTED_FUNCTIONS="['_foo']"

也可以加-c,先编译成.o文件,然后链接在一起:

代码语言:javascript复制
emcc foo.c -c -O3 -o foo.o 
emcc bar.c -c -O3 -o bar.o 
emcc foo.o bar.o --no-entry  -O3 -o muti_src.wasm -s EXPORTED_FUNCTIONS="['_foo']"

"动态"链接

有没可能类似动态库那样,运行时链接呢?我尝试了下:

代码语言:javascript复制
emcc bar.c -O3 -s SIDE_MODULE=1 -o bar.wasm 
emcc foo.c --no-entry  -O3 -o foo.wasm -s EXPORTED_FUNCTIONS="['_foo']" -s WARN_ON_UNDEFINED_SYMBOLS=0

然后在js代码中

代码语言:javascript复制
const fs = require('fs');

const fooWasmSource = new Uint8Array(fs.readFileSync("foo.wasm"));
const fooWasmModule = new WebAssembly.Module(fooWasmSource);
const fooWasmInstance = new WebAssembly.Instance(fooWasmModule, {
    env: {
        bar: function() {
            return barWasmInstance.exports.bar.apply(null, arguments);
        }
    }
});

const barWasmSource = new Uint8Array(fs.readFileSync("bar.wasm"));
const barWasmModule = new WebAssembly.Module(barWasmSource);
const barWasmInstance = new WebAssembly.Instance(barWasmModule, {env: {memory: fooWasmInstance.exports.memory}});

const result = fooWasmInstance.exports.foo(100);
console.log(result);

碰到的难点是:SIDE_MODULE模块的内存由外部传入,为了实现和主模块用同一块内存,所以需要传主模块的memory,但主模块的实例化又依赖于bar模块。这问题通过一个封装的bar函数解决。

应该还有别的方式解决,比如两个文件都以SIDE_MODULE编译,统一使用一个外部构造的Memory,但会带来新问题:SIDE_MODULE似乎不会把c/c 库链接进来,运行会报找不到malloc方法。

WASI

Docker创始人Solomon Hykes说:如果2008年的时候,WASM和 WASI这两个东西已经存在了的话,他就没有必要创立 Docker了。

这WASI是何方神圣?让我们一探究竟。

如下wasi_copy.c代码

代码语言:javascript复制
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>

int main(int argc, char **argv) {
    ssize_t n, m;
    char buf[BUFSIZ];

    if (argc != 3) {
        fprintf(stderr, "usage: %s <from> <to>n, argc expect 3, got %d", argv[0], argc);
        exit(1);
    }

    int in = open(argv[1], O_RDONLY);
    if (in < 0) {
        fprintf(stderr, "error opening input %s: %sn", argv[1], strerror(errno));
        exit(1);
    }

    int out = open(argv[2], O_WRONLY | O_CREAT, 0660);
    if (out < 0) {
        fprintf(stderr, "error opening output %s: %sn", argv[2], strerror(errno));
        exit(1);
    }

    while ((n = read(in, buf, BUFSIZ)) > 0) {
        char *ptr = buf;
        while (n > 0) {
            m = write(out, ptr, (size_t)n);
            if (m < 0) {
                fprintf(stderr, "write error: %sn", strerror(errno));
                exit(1);
            }
            n -= m;
            ptr  = m;
        }
    }

    if (n < 0) {
        fprintf(stderr, "read error: %sn", strerror(errno));
        exit(1);
    }

    return EXIT_SUCCESS;
}

代码里有文件的读写,参数的获取,这也能跑在wasm上?

上面代码用emcc能编译,也能加载,但执行会报文件读取没有权限,我还以为是我代码的问题,各种查阅资料,问ChatGPT也没解决。后面我换了一个编译工具就跑成功了。到这里 下载wasi-sdk并按照其首页介绍方式编译成wasi_copy.wasm。

在js中加载wasm。

代码语言:javascript复制
const wasi  = require( 'wasi');
const fs = require('fs');
const process = require('process');

const wasiInstance = new wasi.WASI({
  args: process.argv.slice(1),
  env: process.env,
  preopens: {
    '/sandbox': '.'
  }
});

const imports = {
    wasi_snapshot_preview1: wasiInstance.wasiImport
};

const wasmSource = new Uint8Array(fs.readFileSync("wasi_copy.wasm"));
const wasmModule = new WebAssembly.Module(wasmSource);
const wasmInstance = new WebAssembly.Instance(wasmModule, imports);

wasiInstance.start(wasmInstance);

然后执行如下命令:

代码语言:javascript复制
node --experimental-wasi-unstable-preview1 test_wasi_copy.js /sandbox/buid.sh /sandbox/buid.sh.bak

即可把当前目录的buid.sh拷贝到buid.sh.bak。

代码说明:

  • 在js里得new一个wasi实例,该实例需要设置命令行参数以及沙箱环境,wasiInstance.start会调用到main函数
  • 初始化wasm需要导入wasi实例的一些api,如果你打印下这些api都有些啥,可以看到是一些文件读写,socket,随机数的支持。
  • node执行需要输入--experimental-wasi-unstable-preview1,而且只能读写沙箱里头的内容。

js函数作为c函数指针参数

函数指针调用在wasm翻译成call_indirect指令,指针会翻译为table的索引,这个table会被导出到__indirect_function_table,我们只要往这个table插入函数,然后调用时,用索引作为函数指针参数就可以了。。吗?试试调用如下c代码:

代码语言:javascript复制
#include <stdlib.h>

typedef int (*compare_t)(const void *, const void*);

void sort_int_array(int *array, size_t len, compare_t compar) {
    qsort(array, len, sizeof(int), compar);
}

编译需加ALLOW_TABLE_GROWTH=1,这可以让我们在外部添加table条目

代码语言:javascript复制
emcc --no-entry -O3 call_qsort.c -o call_qsort.wasm -s EXPORTED_FUNCTIONS="['_sort_int_array']" -s ALLOW_TABLE_GROWTH=1

js调用代码如下:

代码语言:javascript复制
//。。。省略的加载代码

function call_qsort(arr) {
    const savedStack = wasmInstance.exports.stackSave();
    const ptr = wasmInstance.exports.stackAlloc(arr.length * 4);
    const start = ptr >> 2;
    const heap = new Uint32Array(wasmInstance.exports.memory.buffer);
    
    
    for (let i = 0; i < arr.length;   i) {
        heap[start   i] = arr[i];
    }
    
    function cmp(pa, pb) {
        return heap[pa >> 2] - heap[pb >> 2];
    }
    
    let cmpIndex = wasmInstance.exports.__indirect_function_table.grow(1);
    wasmInstance.exports.__indirect_function_table.set(cmpIndex, cmp);

    wasmInstance.exports.sort_int_array(ptr, arr.length, cmpIndex);

    const result = [];
    for (let i = 0; i < arr.length;   i) {
        result.push(heap[start   i]);
    }
    
    wasmInstance.exports.stackRestore(savedStack);
    return result;
}

const numbers = [14, 3, 7, 42];
console.log(numbers, 'becomes', call_qsort(numbers));

上述代码会报错,经过一番周折才得知__indirect_function_table只能插入wasm方法,最后在emcc的胶水代码中找到解决方案:通过一个动态生成的wasm,把js方法先import,然后再export出来即可,最终完整代码见附带的代码仓库链接。

wasm引用js对象(externref)

WebAssembly 1.0只有数字类型,而1.1加入了引用类型:externref和funcref,externref可以用于引用wasm之外的对象,比如js。funcref的例子我们放到下一小节讲。emscripten对externref有一定的支持:在c/c 可以有限使用(比如接下来的例子),不过我尝试定义一个externref全局变量,或者stl容器,会编译失败。 一个能编译的externref c例子如下:

代码语言:javascript复制
typedef char __attribute__((address_space(10)))* externref;

externref pass_externref(externref p) {
    return p;
}

编译需要加-mreference-types参数:

代码语言:javascript复制
emcc --no-entry -O3 pass_externref.c -o pass_externref.wasm -s EXPORTED_FUNCTIONS="['_pass_externref']" -mreference-types

调用pass_externref的nodejs代码:

代码语言:javascript复制
const input = { message: 'Hello, WebAssembly!' };
const output = wasmInstance.exports.pass_externref(input);
console.log('Input:', input);
console.log('Output:', output);

nodejs运行上述脚本需要加--experimental-wasm-reftypes参数:

代码语言:javascript复制
node --experimental-wasm-reftypes test_pass_externref.js

输出结果:

代码语言:javascript复制
Input: { message: 'Hello, WebAssembly!' } 
Output: { message: 'Hello, WebAssembly!' }

c函数作为setTimeout回调

这里需要用到另一个引用类型:funcref。这个例子是我无意中找到的,出处在这里 。这例子实现了一个c函数作为回调调用setTimeout。

被调用的funcref_example.c:

代码语言:javascript复制
typedef char __attribute__((address_space(20)))* funcref;
typedef void* funcref_ptr;

// imported from javascript
extern int setTimeout(funcref callback, int timeout);
extern funcref funcptr_to_funcref(funcref_ptr);
extern void printSomeInfo();

void some_proc() {
    printSomeInfo();
}

void setTimeoutByCFunc(int i) {
    setTimeout(funcptr_to_funcref((funcref_ptr)&some_proc), i);
}

funcptr_to_funcref通过汇编文件funcref_example.support.S实现:

代码语言:javascript复制
.globl __indirect_function_table
.tabletype __indirect_function_table, funcref

.globl funcptr_to_funcref

funcptr_to_funcref:
    .functype funcptr_to_funcref(i32) -> (funcref)
        local.get 0
        table.get __indirect_function_table
    end_function

编译:

代码语言:javascript复制
emcc --no-entry -O3 funcref_example.c funcref_example.support.S -o funcref_example.wasm -s EXPORTED_FUNCTIONS="['_setTimeoutByCFunc']" -mreference-types -s WARN_ON_UNDEFINED_SYMBOLS=0

nodejs调用代码:

代码语言:javascript复制
//。。。省略的加载代码

const wasmInstance = new WebAssembly.Instance(wasmModule, {
    env: {
        printSomeInfo: function() {
            console.log('printSomeInfo')
        },
        setTimeout: setTimeout
    }
});

wasmInstance.exports.setTimeoutByCFunc(2000);

同样,得加--experimental-wasm-reftypes参数:

代码语言:javascript复制
node --experimental-wasm-reftypes test_funcref_example.js

运行2秒后打印printSomeInfo

完整代码仓库

wasm_demo

0 人点赞