内存与引用

在上一章的最后,我们提到,我们暂时将注意力集中在高级语言的基本数字类型与WASM的数字类型之间的转化,而在本章中,我们就来谈谈对于高级的复杂聚合类型,WASM的处理方法是什么。不仅如此,我们还要谈一谈,无论在高级语言还是汇编语言中,都非常常见的概念——「内存」与「引用」。

为什么要引入内存

首先我们需要解决的问题是,为什么WASM中需要内存这个概念?在高级语言或者汇编语言中的内存提供的功能中,有哪些是WASM目前没有内存这一概念就做不了的事。

第一,在高级语言中,当我们需要临时存储一个值的时候,往往会使用局部变量,而局部变量往往会以内存的形式存在,更具体地说,是使用内存的「栈区」这一概念。但是在WASM中,我们不仅有局部变量、全局变量这种机制,而且通过模拟栈机的peek和poke,也可以实现临时存储值这一功能。因此这一功能并不需要内存就可以实现。

内存除了栈区,还有堆区。在C语言中,我们往往使用mallocfree等来操作堆区的内存,而在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_si32.load16_ui32.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:?}");

下面我们来解释这个例子的功能。

  1. 我们在WASM中,导出了WASM的内存,同时提供了一个函数transform。这个函数接受两个参数:WASM内存中的地址,以及相应对象的长度。
  2. 我们在Rust中,创建了一个长度为6字节数组,其内容为1, 1, 4, 5, 1, 4。将其写入内存地址0后,调用transform函数时,第一个参数就传入的是0,也就是这个数组在WASM内存中的首地址,而第二个参数传入的是6,也就是这个数组的长度。
  3. transform函数中,我们遍历WASM中,从首地址0开始,长度为6的每个字节,将其加1后写回
  4. 在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中的函数,并且类型还必须一致。