我第一次涉足 WebAssembly 的经验教训

它最初是一个水分类解谜程序,构造类似于我的英国方块解谜程序。它几乎可以玩,所以我用 SDL2 添加了一个用户界面。我妻子很喜欢在她的台式机上玩,但希望能在她的手机上玩。于是,我需要用 JavaScript 重写它,并希望解算器的速度仍能满足实时使用的要求,或者找出 WebAssembly (WASM)。我成功了,现在我的游戏可以在浏览器中运行了(源代码)。和之前一样,接下来我把我的 pkg-config 克隆移植到了 WASM 系统接口(WASI),制作了一个概念验证用户界面,它也能在浏览器中运行了。两者都没有使用语言运行时,因此 WASM 二进制文件分别只有 8kB 和 28kB。在本文中,我将分享我的经验和技术。

WASM 是一种规范,定义了哈佛架构的抽象堆栈机和相关格式。它只有四种类型:i32、i64、f32 和 f64。它还具有从 0 开始的 “线性 ”八位可寻址内存,在加载和存储时没有对齐限制。零地址是一个有效的、可写的地址,这就再次出现了一些老式高级语言在空指针方面的难题。指针有 32 位和 64 位两种,但后者仍处于试验阶段。这很适合我:我喜欢在 64 位主机上使用较小的指针,而且我希望能经常使用(例如 x32)。

就浏览器技术而言,他们选择了一个恰当的名字:WebAssembly 之于网络,就像 JavaScript 之于 Java。

WebAssembly 有许多不同的组成部分,而网上的许多讨论并没有很好地划分它们之间的界限:

  • WASM 模块: 一个经过编译和链接的映像(就像 ELF 或 PE),包含代码、类型、全局、导入表、导出表等部分。导出表列出了模块的入口点。它有一个可选的起始部分,指示哪个函数初始化加载的映像。(WASM 模块只能通过导入函数来影响外部世界。WASM 本身没有为 WASM 程序定义任何外部接口,甚至没有打印或日志功能。
  • WASM 运行时: 加载 WASM 模块,将导入表项链接到模块中。由于 WASM 模块包含类型,运行时可以在加载时对链接进行类型检查。导入解决后,运行时将执行起始函数(如果有),然后执行零个或多个入口点,希望这些入口点调用的导入函数能产生有用的结果,或者仅仅返回有用的输出。
  • WASM 编译器: 将高级语言转换为低级 WASM。为此,编译器需要某种应用程序二进制接口(ABI),以便将高级语言概念映射到机器上。这通常会引入额外的执行元素,我们必须将它们与抽象机器的执行元素区分开来。尽管有很多编译器,但 Clang 是本文要讨论的唯一编译器。在编译过程中,函数索引是未知的,因此需要通过链接器将引用补上。
  • WASM 链接器: 确定 WASM 模块的形状,并将编译器发出的函数链接起来。LLVM 自带 wasm-ld,作为编译器与 Clang 并驾齐驱。
  • 语言运行时: 除非你是手写原始 WASM,否则你的高级语言可能有一个带有操作系统接口的标准库。如 C 标准库、POSIX 接口等。这种运行时可能会映射到一些标准化的导入集上,最有可能的就是前面提到的 WASI,它定义了一系列类似 POSIX 的函数,WASM 模块可以导入这些函数。因为我认为我们可以做得更好,所以在本文中,我们将摒弃语言运行时,直接针对原始 WASI 进行编码。你仍然可以轻松访问哈希表和动态数组。

编译器-链接器-运行时的组合通常被称为工具链。不过,由于几乎所有 Clang 安装都可以直接针对 WASM,而且我们跳过了语言运行时,因此只需使用 Clang(隐式调用 wasm-ld),就可以编译本文讨论的任何程序,包括我的游戏。如果你有 WASM 运行时(包括浏览器),你也可以运行它们!不过本文主要关注的是 WASI,您需要一个支持 WASI 的运行时来运行这些示例,其中并不包括浏览器(除了用 JavaScript 实现 API 之外)。

我对我试用过的 WASM 运行时并不特别满意,所以我不能热心地推荐其中一种。我很希望能指着一个运行时说:“使用与编译 WASM 相同的 Clang 来编译该运行时!” 唉,我在编译时遇到了问题,运行时有漏洞,或者 WASI 不完整。不过,对我来说,wazero(Go)是最容易使用的,而且效果也足够好,所以我将在示例中使用它:

$ go install github.com/tetratelabs/wazero/cmd/wazero@latest

在使用 WASM 时,WASM 二进制工具包(WABT)是很好的工具,尤其是用于检查 WASM 模块的 wasm2wat,有点像 objdump readelf。它能将 WASM 转换为 WebAssembly 文本格式(WAT)。

在学习 WASM 的过程中,我很难找到相关信息。尽管 WASM 规范篇幅很长,但它只是生态系统中的一小部分,除此以外,重要的技术细节散落在各个地方。有些只是源代码,有些埋藏在 GitHub 问题的注释中,还有些随着软件源的转移而消失在死链接后面。LLVM 的很多部分都没有文档,只提到存在。WASI 没有网络友好格式的文档–因此当我提到它的系统调用时,我没有任何链接–只有 Git 仓库中的一些 IDL 源。一个旧的 wasi.h 是我能找到的最可读、最完整的真相来源。

幸运的是,WASM 的历史已经足够悠久,法律硕士们对它都非常熟悉,因此简单地询问问题或获取使用示例比在网上搜索更有效。如果您对如何在 WASM 生态系统中实现某些功能感到困惑,可以尝试向最先进的大语言模型(LLM) 求助。

示例程序

让我们通过具体示例来奠定一些基础。请看这个简单的 C 函数:

float norm(float x, float y)
{
    return x*x + y*y;
}

要使用 Clang 编译 WASM(32 位),我们需要使用 --target=wasm32

$ clang -c --target=wasm32 -O example.c

对象文件 example.o 是 WASM 格式,因此 WABT 可以检查它。下面是 wasm2wat -f 的输出结果,其中 -f 以 “折叠 “格式输出,这也是我喜欢的阅读方式。

(module
  (type (;0;) (func (param f32 f32) (result f32)))
  (import "env" "__linear_memory" (memory (;0;) 0))
  (func $norm (type 0) (param f32 f32) (result f32)
    (f32.add
      (f32.mul
        (local.get 0)
        (local.get 0))
      (f32.mul
        (local.get 1)
        (local.get 1)))))

我们可以看到 ABI 的雏形: Clang 已将 float 映射到 f32。它同样将 charshortint long 映射到 i32 中。在 64 位 WASM 中,Clang 的 ABI 是 LP64,并将 long 映射到 i64。还有一个 $norm 函数,它接收两个 f32 参数并返回一个 f32

这就有点复杂了:

__attribute((import_name("f")))
void f(int *);

__attribute((export_name("example")))
void example(int x)
{
    f(&x);
}

import_name 功能属性表示模块不会定义它,即使在另一个翻译单元中也不会定义它,而且模块打算导入它。也就是说,wasm-ld 将把它放在导入表中。export_name 函数属性表示它是一个入口点,因此 wasm-ld 会将其列在导出表中。链接它可以让事情更清楚一些:

$ clang --target=wasm32 -nostdlib -Wl,--no-entry -O example.c

-nostdlib是因为我们不会使用语言运行时,而--no-entry则是为了告诉链接器不要隐式导出一个函数(默认值:_start)作为入口点。你可能认为这与 WASM start 函数有关,但 wasm-ld 完全不支持 start 部分!我们稍后会用到入口点。折叠后的 WAT:

(module $a.out
  (type (;0;) (func (param i32)))
  (import "env" "f" (func $f (type 0)))
  (func $example (type 0) (param i32)
    (local i32)
    (global.set $__stack_pointer
      (local.tee 1
        (i32.sub
          (global.get $__stack_pointer)
          (i32.const 16))))
    (i32.store offset=12
      (local.get 1)
      (local.get 0))
    (call $f
      (i32.add
        (local.get 1)
        (i32.const 12)))
    (global.set $__stack_pointer
      (i32.add
        (local.get 1)
        (i32.const 16))))
  (table (;0;) 1 1 funcref)
  (memory (;0;) 2)
  (global $__stack_pointer (mut i32) (i32.const 66560))
  (export "memory" (memory 0))
  (export "example" (func $example)))

有很多事情要展开:

  • 指针被映射到 i32 上。指针是一个高级概念,线性内存是通过积分偏移寻址的。这毕竟是汇编的典型特征。
  • 现在有了 __stack_pointer ,它是 Clang ABI 的一部分,而不是 WASM 的一部分。WASM 抽象机器是一个堆栈机器,但堆栈并不存在于线性内存中。因此,您无法获取 WASM 堆栈中的值的地址。C 语言需要从堆栈中获取很多 WASM 无法提供的东西。因此,除了 WASM 堆栈,Clang 还在线性内存中维护另一个向下增长的堆栈,以实现这些目的,而 __stack_pointer 全局是其 ABI 的堆栈寄存器。我们可以看到它为堆栈分配了大约 64kB 的空间。(
  • 在不了解 WASM 的情况下,应该基本可以读懂:函数减去一个 16 字节的堆栈帧,在其中存储参数的副本,然后使用其内存偏移量作为导入 f 的第一个参数。为什么只需要 4 个字节,却需要 16 个字节?因为堆栈保持 16 字节对齐。在返回之前,函数会恢复堆栈指针。

如前所述,尽管取消引用在 C 语言中仍未定义,但就 WASM 运行时而言,零地址是有效的。如果指针为空,该函数很可能会在零地址读取一个零,然后程序继续运行:

int get(int *p)
{
    return *p;
}

In WAT:

(func $get (type 0) (param i32) (result i32)
  (i32.load
    (local.get 0)))

既然 “硬件 “不会为我们出错,那就请 Clang 代替我们出错:

$ clang ... -fsanitize=undefined -fsanitize-trap ...

Now in WAT:

(module
  (type (;0;) (func (param i32) (result i32)))
  (import "env" "__linear_memory" (memory (;0;) 0))
  (func $get (type 0) (param i32) (result i32)
    (block  ;; label = @1
      (block  ;; label = @2
        (br_if 0 (;@2;)
          (i32.eqz
            (local.get 0)))
        (br_if 1 (;@1;)
          (i32.eqz
            (i32.and
              (local.get 0)
              (i32.const 3)))))
      (unreachable))
    (i32.load
      (local.get 0))))

如果指针为空,get 会执行unreachable指令,导致运行时陷阱。实际上这是无法恢复的。考虑一下:没有任何东西可以恢复 __stack_pointer ,因此堆栈将 “泄漏 “现有的帧。(这可以通过 --export linker 标志导出 __stack_pointer __stack_high 来解决,然后在运行时陷阱后恢复堆栈指针)。

WASM 扩展了大容量内存操作,因此有 memset memmove 的单指令,Clang 将其映射到内置指令中:

void clear(void *buf, long len)
{
    __builtin_memset(buf, 0, len);
}

在 WAT 中,我们将其视为 memory.fill

(module
  (type (;0;) (func (param i32 i32)))
  (import "env" "__linear_memory" (memory (;0;) 0))
  (func $clear (type 0) (param i32 i32)
    (block  ;; label = @1
      (br_if 0 (;@1;)
        (i32.eqz
          (local.get 1)))
      (memory.fill
        (local.get 0)
        (i32.const 0)
        (local.get 1)))))

太好了!真希望在 WASM 之外也能有这么好的效果。毕竟,这也是 w64devkit 拥有 -lmemory 的原因之一。同样,__builtin_trap() 也映射到了unreachable 指令上,因此我们也可以可靠地生成这些指令。

最后,结构呢?它们是按地址传递的。参数结构放在堆栈上,然后传递其地址。要返回结构体,函数会接受一个隐式 out 参数,并将返回信息写入其中。这并不稀奇,只是在跨模块边界(即在导入和导出中)的管理上具有挑战性,因为调用者和被调用者处于不同的地址空间。从导出中返回结构体尤其困难,因为调用者必须以某种方式在被调用者的地址空间中为结果分配空间。

水排序游戏

一些你可能意想不到的事情: 我的水排序游戏没有导入任何函数!它只导出了三个函数:

void      game_init(i32 seed);
DrawList *game_render(i32 width, i32 height, i32 mousex, i32 mousey);
void      game_update(i32 input, i32 mousex, i32 mousey, i64 now);

游戏使用 IMGUI 风格的渲染。调用者通过输入,游戏会返回一个显示列表,告诉它要绘制什么。在 SDL 版本中,这些信息将转化为 SDL 渲染器调用。而在网页版中,这些会变成画布绘制,“鼠标 ”输入可能是触摸事件。在这两个平台上,游戏的玩法和感觉都是一样的。很简单!

我当时并没有意识到,但首先构建 SDL 版本对我的工作效率至关重要。调试 WASM 程序非常困难!WASM 工具还赶不上 1995 年,更不用说 2025 年了。源代码级调试仍处于试验阶段,不切实际。在 WASM 平台上开发应用程序。这就像在 MS-DOS 中开发一样不符合人体工程学。与其如此,不如在更适合的平台上开发,然后在解决了问题后再将应用程序移植到 WASM 平台。WASM 专用代码写得越少越好,即使这意味着要写更多的代码。就像对待一些奇怪的嵌入式目标一样对待它。

游戏自带 10,000 个种子。我生成了约 2 亿道谜题,按难度对它们进行了排序,并略去了最具挑战性的前 10K 道谜题。在游戏中,这些谜题仍按难度递增排序,因此难度会随着你的进步而增加。

WASM 系统接口

WASI 可以让我们更容易上手。让我们从一个 Hello World 程序开始。WASI 应用程序导出一个传统的 _start 入口点,它不返回任何内容,也不接收任何参数。我还将设置一些基本的类型定义:

typedef unsigned char       u8;
typedef   signed int        i32;
typedef   signed long long  i64;
typedef   signed long       iz;

void _start(void)
{
}

wasm-ld 会自动导出此函数,因此我们不需要 export_name 属性。这个程序成功地什么也没做:

$ clang --target=wasm32 -nostdlib -o hello.wasm hello.c
$ wazero run hello.wasm && echo ok
ok

为了写入输出,WASI 定义了 fd_write()

typedef struct {
    u8 *buf;
    iz  len;
} IoVec;

#define WASI(s) __attribute((import_module("wasi_unstable"),import_name(s)))
WASI("fd_write")  i32  fd_write(i32, IoVec *, iz, iz *);

从技术上讲,这些 iz 变量应该是 size_t,以 i32 的形式通过 WASM,但这是一个外来函数,我知道 ABI,所以我可以随心所欲。我非常喜欢 WASI 几乎不使用空尾字符串,甚至连路径也不使用,这真是一股清流,但他们还是用无符号大小破坏了 API。我选择忽略这一点。

该函数与 POSIX writev() 类似,但不会移动文件光标。这需要使用 fd_seek,或者更好的办法是自己跟踪并使用 fd_pwrite,但这对本程序并不重要。我还设置了导入,包括模块名称。WASI 最古老、最稳定的版本叫做 wasi_unstable。(我想,在这个生态系统中查找信息很困难也就不足为奇了)。

每个返回的 WASI 函数都会返回一个 errno 值,0 代表成功,而不是某种带内信号。因此,最后的 out 参数与 POSIX writev() 不同。

有了这个函数,我们就来使用它:

void _start(void)
{
    u8    msg[] = "hello world\n";
    IoVec iov   = {msg, sizeof(msg)-1};
    iz    len   = 0;
    fd_write(1, &iov, 1, &len);
}

Then:

$ clang --target=wasm32 -nostdlib -o hello.wasm hello.c
$ wazero run hello.wasm
hello world

继续下去,不久你就能写出类似 printf 的东西了。如果写入失败,我们可能至少应该用退出状态来说明错误。因为 _start 不会返回状态,所以我们需要退出,为此我们有了 proc_exit。它不会返回,所以没有 errno 返回值。

WASI("proc_exit") void proc_exit(i32);

void _start(void)
{
    // ...
    i32 err = fd_write(1, &iov, 1, &len);
    proc_exit(!!err);
}

要获取命令行参数,先调用 args_sizes_get 获取参数大小,然后分配一些内存,再调用 args_get 读取参数。使用类似的函数对环境也是如此。这些大小不包含空指针终结符,这是明智的。

既然你已经知道如何查找和使用这些函数,就不需要我逐一介绍了。不过,打开文件是一个复杂的特殊情况:

WASI("path_open") i32 path_open(i32,i32,u8*,iz,i32,i64,i64,i32,i32*);

这就有 9 个参数了–我还以为 Win32 CreateFileW 已经很高了。它比看上去还要复杂。它的工作原理更像 POSIX openat(),只是没有当前工作目录,所以也没有 AT_FDCWD。每个文件和目录都是相对于另一个目录打开的,绝对路径无效。如果没有 AT_FDCWD,那么如何打开第一个目录呢?这就是所谓的预打开,它是 WASI 文件系统安全机制的核心。

WASM 运行时会在启动程序前预打开零个或多个目录,并为它们分配从文件描述符 3 开始的最低编号的文件描述符(在标准输入、输出和错误之后)。打算使用 path_open 的程序必须首先遍历文件描述符,用 fd_prestat_get 探测预打开的目录,并用 fd_prestat_dir_name 获取它们的路径名。这个路径名可能会也可能不会映射到真实的系统路径上,因此这是 WASM 模块的一种虚拟文件系统。探针会在第一次出错时停止。

要打开一个绝对路径,它必须找到一个匹配的预打开路径,然后从中构建一个相对于该目录的路径。这部分我很不喜欢,因为即使在简单的情况下,模块也必须包含复杂的路径分析功能。打开文件是整个 API 中最复杂的部分。

我之前提到过,程序数据位于 Clang 堆栈之下。随着堆栈向下增长,这听起来是个坏主意。堆栈溢出会悄无声息地吞噬你的数据,而且很难识别。更明智的做法是将堆栈放在底部,这样它就会从内存底部溢出并导致快速故障。幸运的是,有一个开关可以解决这个问题:

$ clang --target=wasm32 ... -Wl,--stack-first ...

这可能是您希望默认使用的,我不知道为什么链接器不默认使用它。

u-config

以上是 u-config WASM 移植中的操作。您可以下载网络演示中使用的 WASM 模块 pkg-config.wasm,在您喜欢的支持 WASI 的 WASM 运行时中运行它:

$ wazero run pkg-config.wasm --modversion pkg-config
0.33.3

虽然没有预开启,所以它无法读取任何文件。-mount 选项将实际文件系统路径映射为预打开路径。这会将整个根文件系统挂载为只读 (ro),即 /

$ wazero run -mount /::ro pkg-config.wasm --cflags sdl2
-I/usr/include/SDL2 -D_REENTRANT

我怀疑这是否有用,但它是学习和尝试 WASM 的工具,而且结果也很不错。

 

你也许感兴趣的:

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注