WASM 2Master
因为NGIN的缘故,所以相当于是加入了wasmtime这个坑里。
虽然名义上bytecodealliance算是正规军但是有一说一至少这个wasmtime-go做的稀烂啊……
这里就拿rust(写&编译wasm)+wasmtime-go(调用)来介绍。
Beginning
首先得推荐一个工具wasm2wat。既然是想耍wasm那肯定听说过wat,因为现在各个语言对wasm的支持都一言难尽,所以个人经验来说是有事没事转成wat看看挺好的。
然后就是一些基本概念
基本上都在官方文档里
值
WASM底层内部就四种值:字节(Bytes),整数(Integers),浮点数(Floating-Point),还有命名(Names,基本上可以理解为string)
这里的值和后面类型里的值是不一样的,这里是底层实现上的,类型的偏应用上。
类型
值做开发时候一般没啥感知,但是类型就不一样了,处处受它制约。
WASM有这样几个类型:值,结果,函数,约束,内存,表,全局,导出。
好像约束是最近多出来的以前好像没见过(
值
就四种值:i32,i64,f32,f64。其中i表示整型,f表示浮点,32,64就是位,比如f64就是等价于其他语言类型的float64。
结果
表示一系列指令或者函数执行后的结果
结果本质上是一个值(上面那个i32这样的)的队列
函数
函数类型对函数的签名进行分类,将参数映射到结果。
还用于对指令的输入和输出进行分类。
就类似C语言头文件里那个对函数的预定义,就写个名字,参数(类型)和结果(类型)
约束
约束对与内存和表关联的可调整大小的存储的大小范围进行分类。
如果没有给出最大值,则相应的存储可以增长到任何大小。
换句话说就是把WASM沙箱当虚拟机client,约束就控制它的内存存储防止它把host资源用了
内存
内存是个线性的存储器,可以用约束对它进行限制,以页面大小为单位。
表
表就相当于一些语言里的hashMap或者Array。
像内存一样,表格的最小和最大大小也受到约束,单位是条目数。
其中元素类型funcref是所有函数的无限并集。因此,有这个类型的表就相当于包含对函数的引用。
全局
就修饰其他值说明其为全局变量,可变也行不可变也ok。
导出
导出是修饰其他值来说明其可外部访问/调用。
更多
也很可能加别的类型,请告知我更新
指令
WebAssembly代码由指令序列组成。它的计算模型基于堆栈计算机,其中指令在隐式操作数堆栈上操纵值,消耗(pop)参数值并生成或返回(push)结果值。
除了来自堆栈的动态操作数外,某些指令还具有静态直接变量,通常是索引或类型注释,它们是指令本身的一部分。
一些指令的结构形式是,它们将嵌套的指令序列括起来。
指令里包括: 数值,参数,变量,内存,控制,表达式
简单点讲就是对不同的类型的不同使用方式罢了
模块
个人觉得这个很重点,经常考。
WebAssembly中的代码的可分发,可加载和可执行单元称为模块。
在运行时,可以使用一组导入值实例化一个模块以生成一个实例,该实例是一个不可变的元组,引用了正在运行的模块可访问的所有状态。
多个模块实例可以访问相同的共享状态,这是WebAssembly中动态链接(dynamic linking)的基础。 WebAssembly模块还可以在将来与ES6模块集成
一个模块包含以下部分:
- 导入
- 导出
- 程序开始函数
- 全局
- 内存
- 数据
- 表
- 元素
- 函数和代码
一个模块还定义了几个索引空间,这些索引空间由模块中的各种运算符和section字段静态索引:
- 函数索引空间
- 全局索引空间
- 线性内存索引空间
- 表索引空间
导入
一个模块可以声明一系列导入,这些导入在实例化时由宿主机环境提供。有这样几种:
- 函数导入,可以由
call
函数在模块内部调用; - 全局导入,可以由全局操作在模块内部访问;
- 线性内存导入,可以由内存操作在模块内部访问;和
- 表导入,将来可以通过call_indirect和其他表运算符在模块内部访问。
将来可能会增加其他的导入。导入的目的是在允许模块共享代码和数据情况下,同时允许单独的编译和缓存。
所有导入都包括两个显性名称:模块名称和导入名称,必须是有效的UTF-8。这些名称的解释取决于宿主机环境,但旨在允许宿主机环境(如Web)支持两级命名空间。
每种特定的导入类型都定义了额外字段:
函数导入包括用于模块内部导入函数的签名。主机环境定义了针对模块外部导入功能的签名检查。但是,如果导入的函数是WebAssembly函数,则如果签名不匹配,则宿主机环境必须触发实例化时间错误。
全局变量导入包括全局变量的值类型和可变性。这些字段的含义与“全局”部分中的含义相同。在最简化实现中,全局变量导入必须是不可变的。
线性内存导入包括“线性存储器”部分中定义的相同字段集:初始长度和可选的最大长度。主机环境必须仅允许导入WebAssembly线性内存,这些内存的初始长度大于或等于导入中声明的初始长度,并且最大长度<=导入中声明的最大长度。这样可以确保可以进行单独的编译:在声明的初始长度以下的内存访问始终是入站的,在声明的最大长度以上的内存访问始终是越界的,如果初始等于最大值,则该长度是固定的。在最简实现中,每个内存都是默认内存,因此至多可以有一个线性内存导入或线性内存定义。
表导入包括“表”部分中定义的相同字段集:元素类型,初始长度和可选的最大长度。与线性内存部分一样,主机环境必须确保仅导入具有完全匹配的元素类型,初始长度等于或大于最大长度,或等于或小于等于最大长度的WebAssembly表。在MVP中,每个表都是默认表,因此最多可以有一个表导入或表定义。
由于WebAssembly规范未定义如何解释导入名称:
- Web环境将名称定义为UTF8编码的字符串;
- 宿主机环境可以将模块名称解释为文件路径,URL,一组固定的内置模块中的密钥,或者主机环境可以调用用户定义的挂钩将模块名称解析为其中之一;
- 模块名称不需要解析为WebAssembly模块;它可以解析为内置模块(由主机环境实现)或以其他兼容语言编写的模块;和
- 调用导入函数的含义是宿主机定义的。
模块导入的开放性使得它们可以用于向WebAssembly代码公开任意宿主机环境里的函数,类似于本机syscall。例如,一个shell环境可以定义一个带有内置stdio模块puts
的导出。
导出
模块可以声明一系列导出,这些导出在实例化时返回给主机环境。每个导出都有三个字段:一个名称(必须是有效的UTF-8),其名称由主机环境定义;一个类型(用于说明导出是函数,全局,内存还是表),以及指向类型对应索引空间的索引。
所有定义都是可导出的:函数,全局变量,线性内存和表。导出了的定义的实际含义由宿主机环境定义。但是,如果另一个WebAssembly实例导入该定义,则两个实例将共享相同的定义,并且共享了关联的状态(全局变量值,线性内存字节,表元素)。
导出名称必须唯一。
在最小实现中,只能导出不可变的全局变量。
模块启动函数
如果模块已定义起始节点,则在实例实例初始化之后,包括通过“数据”和“元素”部分的“内存”和“表”,以及可调用导出的函数之前,加载器应调用它引用的函数。
- start函数不能接受任何参数或返回任何内容
- 该功能由功能索引标识,可以是导入的,也可以导出的
- 每个模块最多只能有一个起始节点
例如,模块中的起始节点将是:
(start $start_function)
要么
(start 42)
在这第一个示例中,预期环境在调用任何其他模块函数之前先调用函数$start_function。在第二种情况下,预期环境将调用索引为42的模块函数。该数字是从0开始的函数索引(与导出相同)。
一个模块可以:
- 最多只有一个起始节点
- 如果模块包含起始节点,则必须在模块中定义功能
- 在加载模块之后且对模块函数的任何调用完成之前,将调用start函数
全局部分
全局部分提供了零或数个全局变量的内部定义。
每个全局变量内部定义都声明其类型(值类型),可变性(布尔标志)和初始值设定项(初始值设定项表达式)。
线性内存部分
线性内存部分提供了一个线性内存的内部定义。在最小实现中,每个内存都是默认内存,并且最多可以有一个线性内存导入或线性内存定义。
每个线性内存部分都声明一个初始内存大小(随后可以通过grow_memory增加)和一个可选的最大内存大小。
如果尝试增长超过声明的最大值,grow_memory将确保失败。 声明后,实现应(非规范性的)尝试保留最大大小的虚拟内存。 分配初始内存大小失败是运行时错误,而保留最大内存失败则不是。 如果未声明最大内存大小,则在虚拟地址空间有限的体系结构上,引擎应仅分配初始大小并按需重新分配。
数据部分
线性存储器的初始内容为零。
数据节包含一个可能为空的数据段数组,这些数据段指定给定内存的固定(偏移,长度)范围的初始内容,该内容由其线性内存索引指定。
数据部分类似于原生可执行文件的.data
部分。
长度是一个整数常数值(定义给定段的长度)。偏移量是一个初始化表达式。
表部分
表格部分包含零或数个不同表格的定义。在最小实现中,每个表都是默认表,并且至多一个表导入或表定义。
每个表定义都声明一个元素类型,初始长度和可选的最大长度。
在最小实现中,唯一有效的元素类型是anyfunc
,但将来,可能会添加更多元素类型。
在最小实现中,只能通过宿主机定义的API(例如JavaScript的WebAssembly.Table.prototype.grow
)来调整表的大小。将来可能会添加一个grow_table。
在任何一种情况下,如果试图增长到声明的最大值以上,表增长都会失败。与线性内存一样,当声明最大值时,实现应(非规范)尝试将虚拟内存保留为最大大小。分配初始内存大小失败是运行时错误,而保留最大内存失败则不是。如果未声明最大内存大小,则在虚拟地址空间有限的体系结构上,引擎应仅分配初始大小并按需重新分配。
元素部分
表中元素的初始内容是个标记值(如果被调用,则会被捕获)。
元素部分允许模块使用模块中的任何其他定义初始化(在实例化时)任何导入的或内部定义的表的元素。这与数据部分允许模块初始化任何已导入或已定义存储器的字节对称。
元素部分包含元素段的可能为空的数组,这些元素段指定给定表的固定(偏移,长度)范围的初始内容,该范围由表索引指定。
长度是一个整数常数值(定义给定段的长度)。偏移量是一个初始化表达式。元素由它们在相应索引空间中的索引指定。
功能和代码部分
一个逻辑功能定义由以下两个部分决定
- 函数部分声明模块中每个内部函数定义的签名
- 代码部分包含功能部分声明的每个函数的函数主体
此拆分通过将构成模块大部分字节大小的函数主体放在结尾处来帮助进行流式编译,以便在编译开始之前可以使用递归模块加载和并行编译所需的所有元数据。
功能索引空间
函数索引空间对所有导入的和内部定义的函数定义建立索引,并根据模块中定义的顺序(由二进制编码定义)分配单调递增的索引。因此,索引空间从零开始,函数导入(如果有),然后是模块内定义的函数。
函数索引空间由以下部分使用:
- 调用,以识别直接调用的被调用函数。
- 元素。
- 导出,以确定哪些功能公开给嵌入器。
- 启动函数,以确定实例完全初始化后调用哪个函数。
全局索引空间
全局索引空间对所有导入的和内部定义的全局定义进行索引,并根据模块中定义的顺序(由二进制编码定义)分配单调递增的索引。因此,索引空间从零开始,首先是全局导入(如果有),然后是模块内定义的全局。
全局索引空间用于:
- 全局变量访问运算符,标识要读取/写入的全局变量
- 数据段,以将数据段的偏移量(在线性存储器中)定义为全局变量的值
- 线性内存索引空间
- 线性内存索引空间索引所有导入的和内部定义的线性内存定义,并根据模块中定义的顺序(由二进制编码定义)分配单调递增的索引。因此,索引空间从零开始,首先是内存导入(如果有),然后是模块内定义的内存。
线性内存的索引空间仅由数据部分使用。在最小实现中,最多只有一个线性内存,因此该索引空间只是当可以有多个内存时使用的占位符。
表索引空间
表索引空间为所有导入的和内部定义的表定义建立索引,并根据模块中定义的顺序(由二进制编码定义)分配单调递增的索引。因此,索引空间从零开始,首先是表导入(如果有),然后是模块中定义的表。
表索引空间仅由元素部分使用。在最小实现中,最多有一个表,因此该索引空间只是可以存在多个表时的占位符。
初始化程序表达式
初始化程序表达式在实例化时进行执行,现在用于:
- 定义全局变量的初始值
- 定义数据段或元素段的偏移量
一个初始化程序表达式是纯WebAssembly表达式,其编码与WebAssembly表达式相同。并不是所有的WebAssembly运算符都可以或不应该在初始化表达式中得到支持。初始化表达式表示WebAssembly表达式的最小纯子集。
在最小实现中,为了使事情简单,同时仍支持动态链接的基本需求,初始化器表达式仅限于以下空运算符:
- 四个常量运算符
get_global
,其中全局索引必须引用不可变的导入。
将来,可以添加诸如i32.add
之类的运算符,以实现更具表现力的base + offset耗时计算。
Tricks 基本操作
很明显哈,wasm半点都没提到string或者bytes,那么在使用嵌入式的WASM模块时候我们怎么输入string和bytes(Uint8Array)?
先从非语言相关角度看,由于没法传数组没法传不定参数,所以我们需要一个起点和一个长度来定位需要导入的string。因此就直接发送指针位置和长度两个值作为i32到WASM就充当了string。
就相当于,宿主机往内存写入数据,然后把位置和长度告诉WASM,WASM再从内存上取出来。
这里有个增强WASM的rust库,wasm-bindgen,基本上能帮忙把东西都给实现了
【吐槽一下基本上每个WASM相关rust库都能看到alexcrichton在回答issue……劳模啊……
做WASM开发的时候可以把.wasm
看作是一个dll,so或者out,面向的是WASM这个系统,所以就可以意识到为啥内置没string这些高级的类型了【不让你写0101已经很不错了。
那么这么来看wat就是汇编语言了:-)
例
1 | // no_mangle表示不会为函数进行函数名混淆,保证FFI名字不变 |
为什么我们可以通过instance.GetExport("memory").Memory().UnsafeData()
获取Hello, World!
?
因为编译的时候这个Hello world就被写进了wasm里面,是·个·常·量!初始化之后就理所当然进了线性内存(liner memory)里。call string的时候本质就是string乖乖把在WASM上的内存地址给返回了。
也就是说相当于
1 | static HELLO: &'static str = "Hello, World!"; |
WASI
WASI 是个很有趣的东西,他就直接把WASM带到了系统上
在https://github.com/WebAssembly/WASI/issues/223, 有个回答很精妙:At an application level though, the direction WASI is heading is away from “A passes B a string, then then B opens the resource”, and toward “A opens the resource and passes B a handle”. 这其实也就是WASM工作的原理。
NGIN
在链上应用我需要的是其对链上信息(block, tx etc)做出反应。
例如,子网币的发行针对主网矿工,那么其应当在WASM中维护一个account balances,然后对block的事件侦听来实现分发,对tx事件侦听实现交易。
那么换句话说,我们只需要把外部信息(结构体)传递到WASM沙箱中作为事件,然后再加点getter。
当然我们也要提供好存储。