WebAssembly基础

穆展鹏
2023-12-01

01 探索WebAssembly

1.1.1 什么是WebAssembly?
现在我们可以做出相当大胆的断言:WebAssembly就 是分布式计算的未来。这是一个非常崇高的目标,如果没有一些证据来支持它就没什么意义了。我们将提供一些证据,但首先让我们深入了解 WebAssembly 本身的一些细节。我们首先需要消除 WebAssembly 只能在 Web 上运行或者它以某种方式设计为仅在 Web 浏览器范围内运行的虚拟机这种既新颖又有些陈旧的思想。我们现在可以说,“WebAssembly 既不是网络也不是程序集”。

WebAssembly 规范致力于其标准不仅适用于浏览器主机,还适用于任何其他兼容的主机运行时(规范称其为嵌入器)。WebAssembly 二进制格式是一种虚拟机格式。顾名思义,虚拟机运行时负责在虚拟机内执行操作。对于像 VirtualBox 和 VMware 这样的软件,这些虚拟机负责处理 CPU 级别的操作——它们伪装成安装了操作系统的计算机。WebAssembly 虚拟机不会伪装成 CPU 或操作系统。WebAssembly 是一个基于堆栈的虚拟机。这意味着运行时仅能负责处理堆栈级别的操作。WebAssembly 操作代码(通常缩写为操作码)无法查看或操作堆栈和其他一些基本组件之外的任何内容。主机运行时(如浏览器或其他嵌入器)的工作是解释这些 WebAssembly(wasm)指令并帮助它们维护堆栈。由于 WebAssembly 相对较小的指令集以及许多其他关键特性,使得其在云或边缘计算中备受追捧。下面让我们更详细地讨论它们。

1.1.2 WebAssembly特性:安全
WebAssembly 是安全的。使用 wasm 根本不可能进行让许多开发人员和运营商很头痛的常见攻击类型。例如,恶意攻击者无法创建缓冲区溢出攻击。简单地说,缓冲区溢出是代码错误地读取或写入超出它们应该读取的位置的值的内存区域。这可能导致应用程序崩溃,但也可用于使应用程序运行恶意代码。WebAssembly 语言操作不能引用模块内部不存在的指令,因此它们不能被用于运行恶意代码。

Wasm 代码也无法脱离其沙箱的限制,没有用于访问操作系统、从可能属于另一个进程的内存读取、与网络通信、与硬件、内核或任何类型的操作系统通信的语言原语。wasm 模块在其沙箱外执行的任何操作都必须通过主机导入,即它要求主机调用的函数。这意味着主机可以随时拒绝该调用。

1.1.3 WebAssembly特性:快速
WebAssembly 解释器最基本的工作是读取操作码,作为响应,将值推入或弹出堆栈, 这种类型的操作非常快速和高效。wasm 解释器可以提供基础的数学、内存和堆栈管理能力,因此可以非常快速地完成其工作,并且开销很小。

当我们在云中构建微服务和“serverless”功能时,我们希望它们尽可能快地运行。如果堆栈机器具有少量操作和且开销很小,并且无法不受限制地访问外部资源时,我们可以开始想象 WebAssembly 将会成为将安全、快速、不可变、可移植的业务逻辑投入生产的理想方式。

1.1.4 WebAssembly特性:可移植
WebAssembly 是可移植的。它的操作码与处理器和操作系统无关。这是 wasm 经常被低估的一个方面,尤其是认为 wasm 是浏览器优先技术的开发人员。

只要主机是有效的 WebAssembly 运行时,任何有效的 WebAssembly 模块都应该能够在任何架构、任何操作系统(甚至微内核!)中部署和运行。这意味着编码到 wasm 中的业务逻辑应该可以在任何地方运行,但更重要的是,你的 wasm 模块从主机导入的功能也应该能够在任何地方工作。

在 wasm 主机运行时无处不在的世界中,您可以通过主机导入安全地授予 WebAssembly 模块访问功能的权限。这些功能可以是任何东西,从对用于控制 LED 和其他硬件的 GPIO 端口的低级访问到与数据库和 Web 服务器、消息代理等通信甚至更多。

小的工作负载不仅仅可以运行在所谓的“serverless”云中,而是可以运行在任何地方,可以真正改变我们构建和设计分布式应用程序的方式。

1.1.5 WebAssembly特性:体积小
WebAssembly 可以生成令人难以置信的小工件。你可以使用 C、C++、Rust、Zig、Go、AssemblyScript 和 WebAssembly 文本格式 (wat) 等语言来构建 wasm 模块。根据你构建的内容和使用的工具链,你甚至可以构建小于 1MB 的全功能模块。wasm 的部署规模比我们今天在容器或无服务器“捆绑包”世界中构建的大部分内容呈指数级减小,这会对我们思考和规划分布式应用程序的方式产生巨大影响。

体积小其加载速度也会更快。

1.1.6 WebAssembly并不是无所不能的
是的。了解WebAssembly擅长的领域会让你更善用这个工具。

02 WebAssembly入门
1.2.1 需要准备什么工具
安装WebAssembly Binary Toolkit

WebAssembly Binary Toolkit 包括以下工具:

wat2wasm

wasm2wat

wasm-objdump

wasm-interp

wasm-strip

安装完成后,输入一下命令验证是否安装成功:

wat2wasm --version
如果你使用的是Visual Studio Code,这里有两个扩展可用:WebAssembly Syntax (Lite) 和WebAssembly Toolkit for VSCode。

1.2.2 创建你的第一个WebAssembly模块
最简单方法就是打开你最喜欢的编辑器(最好是VsCode)然后写一些wat代码,将下面的内容粘贴到编辑器内:

(module
(func $add (param $lhs i32) (param $rhs i32) (result i32)
(i32.add
(get_local $lhs)
(get_local $rhs)
)
)
(export “add” (func $add))
)
将这个文件保存为”add.wat“,通过wabt工具生成一个wasm模块:

wat2wasm add.wat
可以看到在同目录下生成了一个add.wasm文件,我们通过下面的命令查看一下这个二进制文件的更多信息。

wasm-objdump -x add.wasm
add.wasm: file format wasm 0x1

Section Details:

Type[1]:

  • type[0] (i32, i32) -> i32
    Function[1]:
  • func[0] sig=0
    Export[1]:
  • func[0] -> “add”
    Code[1]:
  • func[0] size=7
    现在让我们看看当我们使用另一个工具来反转这个过程并从 wasm 二进制文件中取回一个 wat 文件时会发生什么:

wasm2wat add.wasm
(module
(type (;0;) (func (param i32 i32) (result i32)))
(func (;0;) (type 0) (param i32 i32) (result i32)
local.get 0
local.get 1
i32.add)
(export “add” (func 0)))
代码看起来有点不同,但基本上和我们的写的代码是一样的。这里有趣的一件事是你可以看到在第二个机器生成的代码中,函数参数没有命名,它们是通过索引访问的。参数名称的存在其实为了方便编写 wat 代码的人。

下面我们来尝试使用一下这个add.wasm文件。我们将安装另一个命令行工具,除了许多其他功能外,它还可以让我们调用 wasm 文件中的任意函数:wasmtime (稍后您将看到,wasmtime 也是一个非常强大的 Rust crate,用于在 Rust 中编译和运行 WebAssembly 模块) . 按照 wasmtime 的安装说明进行操作后,你可以执行我们新编写的 add 函数,如下所示:

wasmtime add.wasm --invoke add 1 2
warning: using --invoke with a function that takes arguments is experimental and may break in the future
warning: using --invoke with a function that returns values is experimental and may break in the
future
3
可以看到正确的运行结果。

03 探索更多WebAssembly基础知识
其实,WebAssembly可以做的不仅仅是加减乘除。尽管和我们通常使用的用于构建服务和功能的语言相比,WebAssembly指令集相对来说是比较弱小的,但它还是很强大的且能够支持许多高级功能。在本节中,我们将会介绍更多关于WebAssembly的基础知识,譬如数据类型、控制流、导入、导出和使用线性内存。

虽然没有人会希望你使用原始wat来编写真正的生成代码,但是了解底层指令的功能将会帮助你更好地了解WebAssembly。

1.3.1 数据类型
WebAssembly仅拥有四种数据类型:

i32 - 32位整型

i64 - 64位整型

f32 - 32位浮点数

f64 - 64位浮点数

在很多语言中,基础数字类型是包括有符号(signed)和无符号(unsigned)的。但是在WebAssembly中是不分有符号类型和无符号类型的。但是对数字的操作是分有符号和无符号的。

le_u and le_s - <=

ge_u and ge_s - >=

lt_u and lt_s - <

gt_u and gt_s - >

div_s and div_u - /

em_s and rem_u - 取余/取模

那么现在你可能现在会有疑问,“只有数字类型?我们怎么可能只用四种数字类型开构建所有事物?”。其实我们在计算机上所能表示的一切的底层都是数字,也就是位和字节。我们被高级编程语言宠坏了,这些语言将我们的结构和类转换为内存中的字节块,并将我们的字符串转换为内存中的 unicode 字符值或 ASCII 字节序列。

1.3.2 控制流
正如我们所熟悉并依赖的复杂数据类型最终被归结为简单的数字类型一样,程序中很重要的控制流结构也可以简化为少量的简单指令。

所有的控制指令列表可以在 这里找到,感兴趣的可以自行查看并学习。

我们目前需要学习的控制指令如下所示:

loop - The only loop instruction in WebAssembly (there are no “for” or “while” instructions)

br - Unconditional branch instruction

br_if - Conditional branch instruction

if - A conditional instruction that can optionally return a value

1.3.3 条件和分支
在WebAssembly中有两种主要的分支方式:if语句或显示分支指令。if语句可以提供true和false分支,可以有返回值也可以无返回值。

无返回值的if语句格式如下

(if (condition)
(then …)
(else …)
)
这里的条件可以是任何计算结果为布尔值的东西,所以它可以是一个表达式块或单个表达式。下面是一个例子:

(if
(i32.eq
(get_local $row)
(get_local $col)
)
(then

)
(else

)
)
在这里是如果row的值和col的值是相等的话,then分支内将会被执行,否则else分支将会运行。

在很多时候,我们希望if语句可以返回一个值,那么我们可以像下面这样做:

(if (result i32)
(i32.eq
(get_local $row)
(get_local $col)
)
(then
(i32.const 42)
)
(else
(i32.const 24)
)
)
这里的result i32表示的是返回值的类型。

1.3.4 循环
循环和WebAssembly中的其他概念一样,已经被提炼为一种最简单的表示形式。没有while、until或for语句,也没有列表推导式(列表解析)、流或迭代器。取而代之的是机制是:通过loop语句开启一个循环,然后通过br和br_if来退出循环或回到循环起点。

br和br_if都把标签索引作为第一个参数,为了更好理解,你可以将“br 0”指令视为“返回循环顶部”,将“br 1”视为“退出循环”。其实退出循环的索引值将根据你嵌套的深度而发生变化。

下面是用wat编写的for循环示例:

(module
(func $forLoop (result i32)
(local $x i32)
(local $res i32)

(set_local $x (i32.const 0))
(set_local $res (i32.const 0))

(block
  (loop
    (set_local $x (call $increment (get_local $x)))
    (set_local $res (i32.add (get_local $res) (get_local $x)))
    (br_if 1 (i32.eq (get_local $x) (i32.const 20)))
    (br 0)
)

)

(get_local $res)
)

(func $increment (param $x i32) (result i32)
(i32.add (get_local $x) (i32.const 1))
)

(export “forLoop” (func $forLoop))
)
先来运行一下:

wat2wasm forloop.wat wasmtime forloop.wasm --invoke forLoop

warning: using --invoke with a function that returns values is experimental and may break in the
future
210
计算过程为:0 + 1 + 2 + 3 + … + 20

如果你有兴趣了解构建循环的所有可能的方法的话,可以访问WebAssembly specification test suite for the loop instruction.

1.3.5 import和export
我们之前讨论过wasm模块是安全以及可移植的。安全性和可移植性主要是因为:没有原生WebAssembly指令来与操作系统通信、执行I/O、访问网络、甚至执行我们平时觉得很简单的任务,比如生成随机数或访问时钟。

之前我们已经使用过export指令,我们之前写过的计算器就是因为我们将四个运算函数导出了才能够正常工作。导出函数并让其对主机运行时可视和可用,相反,主机运行时无法访问任何你没有导出的函数。函数导出允许你为该函数命名一个更友好的名字而可以不是你在wasm内部所使用的函数名。

import,顾名思义就是export反义词,也就是导入。如果你希望你的模块可以与外部世界进行交互,那么你需要通过import来做到这一点。函数从命名模块导入,并由该模块内的唯一名词所标识。

(import “utils” “get_random”)

一旦你声明了一个import后,你的WebAssembly模块将不能在缺失该模块的情况下运行。主机运行时有责任确保该模块能够提供此类功能。否则,WebAssembly模块和主机之间的隐式契约将被破坏。

值得一提的是,WebAssembly规范并未指定如何才能满足此导入。它可以完全在另一个WebAssembly模块中定义,也可以是一个完全在宿主进程中运行的函数。这也正是WebAssembly在云中强大的可移植性的最大因素。

1.3.6 使用线性内存
到目前为止,我们遇到的所有指令都与堆栈有关。WebAssembly 是一个堆栈机器,指令通过与堆栈交互来获取它们的参数并返回它们的值。我们不能在没有堆得情况下创建任何有用的和复杂的模块。

在 WebAssembly 中,所谓的线性内存满足了我们传统的堆概念。这听起来很简单。线性内存是线性排列的单个连续内存块。换句话说,它是一个可索引的字节数组。

WebAssembly里的线性内存以页为单位分配,每一页是一个64kb的块。有四个与处理线性内存相关的指令:

memory.size - Returns the size, in pages, of the module’s current linear memory limit

memory.grow - Requests that memory be expanded by the given number of pages

(type).store - Stores a value of the given data type (e.g. i32) at the given memory location

(type).load - Retrieves a value of the given data type from the given memory location

WebAssembly是以小端方式将数字存储在内存中的。

 类似资料: