内存与引用
在上一章的最后,我们提到,我们暂时将注意力集中在高级语言的基本数字类型与WASM的数字类型之间的转化,而在本章中,我们就来谈谈对于高级的复杂聚合类型,WASM的处理方法是什么。不仅如此,我们还要谈一谈,无论在高级语言还是汇编语言中,都非常常见的概念——「内存」与「引用」。
为什么要引入内存
首先我们需要解决的问题是,为什么WASM中需要内存这个概念?在高级语言或者汇编语言中的内存提供的功能中,有哪些是WASM目前没有内存这一概念就做不了的事。
第一,在高级语言中,当我们需要临时存储一个值的时候,往往会使用局部变量,而局部变量往往会以内存的形式存在,更具体地说,是使用内存的「栈区」这一概念。但是在WASM中,我们不仅有局部变量、全局变量这种机制,而且通过模拟栈机的peek和poke,也可以实现临时存储值这一功能。因此这一功能并不需要内存就可以实现。
内存除了栈区,还有堆区。在C语言中,我们往往使用malloc
、free
等来操作堆区的内存,而在Rust中,Box
等指针往往也会使用堆区的内存。在高级语言中,堆区往往有两个作用:
-
用来分配动态大小的内存
对于在运行时才能知道大小的数组等大小是动态的对象,我们往往将其分配在堆区,以提高程序的优化程度。在这种情况下,WASM不用内存确实很难做到。
-
用来分配所有权不定的对象
对于所有权难以确定的对象,高级语言往往有很多种处理方法。而将其放在堆区,也是一个很常用的手段。尽管有不放在堆区的方法,但是WASM大部分情况下还是由高级语言编译而来,所以难以避免这种情形。
内存除了这些用途之外,还有一个特性离不开内存,那就是「引用」。高级语言中的引用,在汇编层面,往往会编译为一个存有内存地址的指针。在常见的C ABI中,如果函数的返回值类型是结构体,往往也会要求底层实现是以指针的形式实现。而在WASM中,尽管有局部变量、栈机机制,但是如果没有内存,就没有办法实现指针、引用这一机制。
WASM内存的定义与使用
在WASM中,定义一个内存十分简单:
(module
(memory 1)
)
这里的1
表示WASM的内存至少有1页大小(WASM中定义一页为64KB)。
那我们该如何使用WASM的内存呢?我们可以用一句话粗略地理解:WASM中的内存是用数组模拟的内存。我们通过(memory 1)
申请的,至少有64KB大小的内存,实际上可以看做一个数组[u8; 1 << 16]
,也就是一个长度为64KB的字节数组。
这意味着什么事呢?我们一般意义上的「内存地址」,实际上变成了这个数组的索引值。因此,也就是取值范围为0到64K的i32类型的数(目前是i32
类型,Memory64提案将引入i64
类型的数也作为索引类型)。
理解了这一点,我们就可以轻松理解WASM中的内存使用方法了:
i32.const 0
i32.load
i32.load
指令接收一个操作数,也就是第一句i32.const 0
。这条指令执行的结果,就是在当前模块的内存中,取地址为0的i32
类型的数。
类似地:
i32.const 0
i32.const 323
i32.store
这些指令的结果是,将i32
类型的数323存储到当前模块内存中地址为0的位置。
关于内存读写指令,还有两点需要注意的:
第一,WASM提供了offset
机制,能够更方便地做内存读写:
i32.const 0
i32.load offset=4
这些指令的结果是,从内存地址0开始,偏移值为4(也就是内存地址为4)处读取i32
类型的值。
WASM提供这种机制的原因是,在很多情况下,内存值是动态确定的(例如某个结构体的地址),但是偏移值是确定的(例如读取这个结构体的某个字段)。这样可以减少内存地址的计算。
第二,内存中的值,最小的单位是1字节,而WASM中指令操作的单位是i32
或者i64
。因此,WASM在读取内存时,提供了诸如i32.load8_s
、i32.load16_u
、i32.store8
等操作,分别对应读取、写入的比特数(读取时还应考虑有无符号扩展)。熟悉汇编指令的开发者对这个一定了如指掌,这里不再赘述。
在有了这些概念以及指令之后,我们之前提到的,在WASM中使用内存的必要性的两个问题就得到了解决。
通过内存与外界交互
在拥有了内存之后,我们终于能够解决之前提出的,怎样在高级语言与WASM之间,传递复杂聚合对象的问题了。而解决这个问题的方法,就是通过内存的导入与导出。
与函数、全局变量类似,内存也可以导入与导出。我们可以写
(module
(memory (export "memory") 1)
)
来声明我们当前模块导出至外界一个大小至少为64KB的内存。而外界也可以通过与上一章中类似的方法,获取内存。以wasmer为例,我们可以通过
let memory = instance.exports.get_memory("memory")?;
let memory_view = memory.view(&store);
来获取导出的内存,然后对memory_view
进行读写。这里需要注意的一点是,memory_view
需要获取对store
的共享引用,因此在每一次我们调用WASM导出的函数时,会对store
进行独占引用,因此在调用后,我们需要重新使用.view
函数来获取memory_view
。这么做的原因是,在WASM执行的过程中,可能会对内存进行扩容等操作,而这些操作可能会让内存地址产生改变,从而需要重新获取内存。
我们在高级语言中可以对WASM的内存进行读写,这有什么好处呢?我们知道,之前之所以我们没有办法将高级语言中的复杂聚合类型传入WASM的函数,是因为我们缺少相对的表达能力。WASM的函数大部分只接受基本数字类型,我们没法传结构体进去;WASM也不能读写外界的内存,所以我们传数组的首地址进去更是无可奈何。
而通过读写WASM内存,我们就有了一种传递复杂聚合类型的能力。以下面这个例子为例:
我们创建transformer.wat
:
(module
(memory (export "memory") 1)
(func (export "transform") (param $index i32) (param $length i32)
(local $ch i32)
loop $main_loop
(i32.le_u (local.get $length) (i32.const 0))
if
return
end
(i32.store8
(local.get $index)
(i32.add
(i32.load8_u (local.get $index))
(i32.const 1)
)
)
(local.set $index (i32.add (local.get $index) (i32.const 1)))
(local.set $length (i32.sub (local.get $length) (i32.const 1)))
br $main_loop
end
)
)
然后我们在Rust中,仍然使用wasmer,在实例化WASM之后,使用
let transform: TypedFunction<(u32, u32), ()> = instance
.exports
.get_typed_function(&mut store, "transform")?;
let memory = instance.exports.get_memory("memory")?;
// Prepare source
let source: [u8; 6] = [1, 1, 4, 5, 1, 4];
let memory_view = memory.view(&store);
memory_view.write(0, &source)?;
transform.call(&mut store, 0, 6)?;
// Retrieve transformed source
let memory_view = memory.view(&store);
let mut transformed_source = [0; 6];
memory_view.read(0, &mut transformed_source)?;
println!("Transformed source is {transformed_source:?}");
下面我们来解释这个例子的功能。
- 我们在WASM中,导出了WASM的内存,同时提供了一个函数
transform
。这个函数接受两个参数:WASM内存中的地址,以及相应对象的长度。 - 我们在Rust中,创建了一个长度为6字节数组,其内容为1, 1, 4, 5, 1, 4。将其写入内存地址0后,调用
transform
函数时,第一个参数就传入的是0,也就是这个数组在WASM内存中的首地址,而第二个参数传入的是6,也就是这个数组的长度。 - 在
transform
函数中,我们遍历WASM中,从首地址0开始,长度为6的每个字节,将其加1后写回 - 在Rust中,调用WASM导出的
transform
之后,我们再次读取内存中相应的片段,并输出,可以发现值变成了2, 2, 5, 6, 2, 5。
这就是利用读写WASM内存,在高级语言与WASM之间传递复杂聚合类型的一种途径。
沙盒
为什么传递复杂聚合类型要这么麻烦?我们可以注意到,如果需要传递复杂聚合类型,我们需要两次整段内存的复制!一次从嵌入环境复制到WASM中,一次是从WASM中复制回嵌入环境中。
如果经常在各种讨论版中关注WASM的开发者一定会注意到,往往会有一些WASM与原生JavaScript性能的比较,有些情况WASM会偏慢,然后就会有人评论说,你这测试方法不标准,偏慢的时间应该是在传递数据,而不是在计算。并且也会有很多专业的架构师,正是考虑到传递数据时偏慢,才会三思要不要使用WASM。
事实上,这么做的原因,是WASM的一个招牌特性——沙盒(Sandbox)。我们其实可以注意到,如果外界不向WASM导入内存读写函数,那么,WASM永远无法读写除了自身那一段内存以外的内存。也就是说,WASM的程序是跑在一个沙盒内的,绝对不会影响宿主环境的内存。
字符串
我们之前一直没有在WASM中真正处理过字符串,但是字符串确实是一个非常常见的编程元素。在大部分高级编程语言中,字符串在底层的实现都是数组。而我们了解了WASM的内存概念,是不是终于可以处理字符串了呢!
事实上,WASM特地为字符串常量设计了一个非常方便的语法:数据段(Data segment)。
(module (memory 1) (data (i32.const 0) "Hello") )
这一段代码的意思是,在WASM的内存中,地址为0开始,定义一串字符串"Hello"。当我们实例化这个WASM模块时,它的内存从0开始就会有这一串字符串。
有了这个工具,我们终于可以随心所欲在WASM中输出文本了。
我们编写string_in_wasm.wat
:
(module
(import "outer" "memory" (memory 1))
(import "outer" "log" (func $log (param i32 i32)))
(data (i32.const 0) "关注希月萌奈喵")
(func (export "output_inside_string")
(call $log (i32.const 0) (i32.const 21))
)
)
以及index.html
(代码改编自WebAssembly Memory):
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>WASM Test</title>
</head>
<body>
<script>
const memory = new WebAssembly.Memory({ initial: 1 });
const importObject = {
outer: {
log: (index, length) => {
const bytes = new Uint8Array(memory.buffer, index, length);
const string = new TextDecoder("utf8").decode(bytes);
console.log(string);
},
memory: memory
}
};
WebAssembly.instantiateStreaming(fetch("./string_in_wasm.wasm"), importObject)
.then(obj => {
obj.instance.exports.output_inside_string();
});
</script>
</body>
</html>
这段代码是什么意思呢?
在WASM中,我们定义了内存中的一个字符串“关注希月萌奈喵”(这串中文字符串的长度为21字节)。随后,我们需要导入一个log
函数。这个函数接受两个i32
类型的参数,其实现位于我们的JavaScript代码中。从代码中我们可以了解到,这段代码可以从WASM的内存中,index
开始,读取length
长度个字节,然后将其解码为UTF8字符串,然后输出。因此,我们在WASM中调用这段代码,并传入参数0和21。当我们测试这段程序时,我们可以发现,在控制台上,真的输出了:
「关注希月萌奈喵」
这串字符串。
值得注意的是,这里我们采用JavaScript作为示例代码,是因为Rust中会有一些麻烦。敏感的开发者一定已经注意到了,在JavaScript代码中,memory
这个变量,如果转成Rust会有些麻烦。因为这个变量在作为导入函数传给store时,所有权已经给store了,但是log
还引用了这个变量,十分难搞。在wasmer中,我们需要使用FunctionEnv
等方法来处理这件事,具体可以看官方文档WasmerEnv
is removed in favor of FunctionEnv
。
函数指针
几乎所有的和内存相关的问题都解决了,但是,「函数指针」怎么解决???
我们知道,在C、Rust等高级语言中,一定会存在「函数指针」。也就是说,我们要调用的函数具体是哪个,需要在运行时决定。在底层实现中,往往是由「间接调用」来解决,也就是将目标函数的地址存储在寄存器中,跳转时读取寄存器的值作为调用目标。
但是在WASM中,对函数的调用指令call
的操作数不是地址,而是一个编号,也就是例如我们之前的call $log
,这里$log
并不是一个真正的数字类型,它只是一个函数编号而已。第二个问题,我们存储在WASM内存中的值,只能是数字类型,因此内存中也没法存储函数的地址。这咋办捏?
WASM引入了table机制(代码改编自WebAssembly Tables):
(module
(table 2 funcref)
(elem (i32.const 0) $home $birth)
(func $home (result i32)
i32.const 323
)
(func $birth (result i32)
i32.const 1219
)
(type $func_t (func (result i32))
(func $call_by_index (param $func_index $i32) (result i32)
(call_indirect (local.get $func_index) (type $func_t))
)
)
这段代码实现了啥功能呢?用C语言来看,可以粗略看成:
int home(void) { return 323; }
int birth(void) { return 1219; }
typedef int(*func_t)(void);
func_t my_func_table[2] = { home, birth };
int call_by_index(int func_index) {
return (my_func_table[func_index])();
}
WASM的table
语句,类似之前的memory
,定义了一个元素个数为2的表。随后,我们通过elem
语句,类似之前的data
语句,声明了这个表的内容,其两个元素分别是我们定义的$home
和$birth
。
在调用时,我们需要专门使用call_indirect
指令。这个指令的第一个参数就是目标函数在这个表中的位置,第二个参数,则是目标函数的类型。
通过这种方式,WASM可以有效避免一般的控制流劫持技术,也就是攻击者想将函数跳转的地址指向攻击者自己编写的函数。而这里,call_indirect
指令,必须跳转到事先已经写在table
中的函数,并且类型还必须一致。