AssemblyScript并不是一门全新的编程语言,它的语法是目前非常流行的TypeScript语言语法的严格子集,专门针对WebAssembly(后面简称Wasm)进行了裁剪和定制。下面是JavaScript、TypeScript和AssemblyScript这三种语言的语法关系图。
本文的重点是讨论AssemblyScript程序如何编译为Wasm模块,如果想要了解AssemblyScript语言的基本语法和用法,可以参考AssemblyScript教程。在前面的文章中,我们已经详细的讨论了Wasm二进制格式以及Wasm指令集。我们已经知道,Wasm二进制模块是按段(Section)来组织内容的,目前一共有12种不同类型的段。我们简单回顾一下这些段的内容:
- 自定义段(ID是0),存放函数名等辅助信息。这些信息不影响Wasm执行语义,即使完全丢弃也问题不大。
- 类型段(ID是1),存放函数类型(也叫函数签名)和块类型。
- 导入段(ID是2),存放导入信息。可以导入的项目有四种:函数、表、内存、全局变量。
- 函数段(ID是3),存放内部定义的函数签名信息。这是一个索引表,里面存放的是内部定义的函数的签名在类型段中的索引。
- 表段(ID是4),存放内部定义的表信息。Wasm规范限制模块只能导入或者定义一张表。
- 内存段(ID是5),存放内部定义的内存信息。Wasm规范限制模块只能导入或者定义一块内存。
- 全局段(ID是6),存放内部定义的全局变量信息。
- 导出段(ID是7),存放导出信息。和导入段一样,可以导出的项目也有四种:函数、表、内存、全局变量。
- 起始段(ID是8),存放起始函数索引。
- 元素段(ID是9),存放表的初始化数据。
- 代码段(ID是10),存放内部定义函数的局部变量信息和字节码。
- 数据段(ID是11),存放内存初始化数据。
下面我们就来看看AssemblyScript程序是如何被编译成Wasm模块的,更具体的说,是看看程序运行所需要的关键信息是如何被存放在各种段里的。我们将使用WABT提供的wasm2wat和wasm-objdump命令来观察AssemblyScript编译器生成的二进制模块。
类型段
程序中的所有函数签名会被编译器收集起来放进二进制模块的类型段中,下面请看一个例子:
declare function f1(x: i32): i32;
declare function f2(x: f32, y: f32): f32;
declare function f3(x: f32, y: f32): f32;
export function f4(a: i32, b: i32): i32 {
return f1(b) + f1(b);
}
export function f5(a: f32, b: f32, c: f32): f32 {
return f2(a, b) + f3(b, c);
}
上面的例子声明了三个外部函数,并且定义了两个内部函数。注意我们需要将内部函数标记为导出(或者关闭编译器优化),否则编译器可能会把它们优化掉。编译上面的程序,然后用wasm-objdump命令将生成的二进制模块转换为文本格式,结果如下所示:
(module
(type (;0;) (func (param f32 f32) (result f32)))
(type (;1;) (func (param i32) (result i32)))
(type (;2;) (func (param i32 i32) (result i32)))
(type (;3;) (func (param f32 f32 f32) (result f32)))
(import "index" "f1" (func (;0;) (type 1)))
(import "index" "f2" (func (;1;) (type 0)))
(import "index" "f3" (func (;2;) (type 0)))
(func (;3;) (type 2) (;代码省略;) )
(func (;4;) (type 3) (;代码省略;) )
;; 其他代码省略
)
由于f2()和f3()的签名一样,所以总共有四个函数签名。可以看到,编译器将f2()和f3()的签名放在了最前面,然后是f1()、f4()和f5()的签名。
导入和导出段
如前面所说,模块可以导入或导出四种项目:函数、表、内存、全局变量。从上面的例子可以看到,如果不考虑编译器优化,那么AssemblyScript语言中的函数将被编译成Wasm函数。我们马上还会看到,全局变量也有类似的对应关系。下面的例子展示了如何声明外部的函数和全局变量:
declare function add(a: i32, b: i32): i32;
@external("sub2")
declare function sub(a: i32, b: i32): i32;
@external("math", "mul2")
declare function mul(a: i32, b: i32): i32;
@external("math", "pi")
declare const pi: f32;
export function main(): void {
add(1, 2);
sub(1, 2);
mul(1, pi as i32);
}
在默认情况下,AssemblyScript编译器会把被编译程序的文件名当作外部模块名,把函数或全局变量名当作成员名。但也可以使用@external注解明确单独指定成员名,或者同时指定外部模块名和成员名。表和内存比较特殊,我们将在后面的文章中详细讨论。AssemblyScript编译器提供了--importTable和--importMemory选项,如果在编译时指定了这两个选项,将会在模块的导入段中生成表和内存的导入项(env.table和env.memory)。将上面的例子保存为index.ts,然后使用这两个选项编译,下面是编译结果(已经转换为文本格式):
(module
(type (;0;) (func (param i32 i32) (result i32)))
(type (;1;) (func))
(import "index" "add" (func (;0;) (type 0)))
(import "index" "sub2" (func (;1;) (type 0)))
(import "math" "mul2" (func (;2;) (type 0)))
(import "math" "pi" (global (;0;) f32))
(import "env" "memory" (memory (;0;) 0))
(import "env" "table" (table (;0;) 1 funcref))
(func (;3;) (type 1) (;代码省略;) )
;; 其他代码省略
)
在AssemblyScript语言中被标记为导出的函数和全局变量会被编译器登记到模块的导出段中;内存是默认导出的,但是可以通过--noExportMemory选项关闭;表默认不导出,但是可以通过--exportTable打开。下面来看另一个例子:
export const pi: f32 = 3.14;
export function add(a: i32, b: i32): i32 { return a + b; }
export function sub(a: i32, b: i32): i32 { return a - b; }
export function mul(a: i32, b: i32): i32 { return a * b; }
使用--exportTable选项编译上面的例子,下面是编译后的模块(已经转换为文本格式):
(module
(type (;0;) (func (param i32 i32) (result i32)))
(func (;0;) (type 0) (;代码省略;) )
(func (;1;) (type 0) (;代码省略;) )
(func (;2;) (type 0) (;代码省略;) )
(table (;0;) 1 funcref)
(memory (;0;) 0)
(global (;0;) f32 (f32.const 0x1.91eb86p+1 (;=3.14;)))
(export "memory" (memory 0))
(export "table" (table 0))
(export "pi" (global 0))
(export "add" (func 0))
(export "sub" (func 1))
(export "mul" (func 2))
(elem (;0;) (i32.const 1) func)
)
函数和代码段
如前文所述,模块内定义的函数信息被分开放在两个段里:函数的签名信息在类型段里,函数的局部变量信息和字节码在代码段里。如果完全关闭优化,那么AssemblyScript语言中定义的函数和Wasm模块中的函数应该有一个直接的对应关系。也就是说,语言中定义的每一个函数都会在模块的函数段和代码段中各产生一个条目。下面来看一个例子:
function add(a: i32, b: i32): i32 { return a + b; }
function sub(a: i32, b: i32): i32 { return a - b; }
function mul(a: i32, b: i32): i32 { return a * b; }
export function main(): void {
add(1, 2);
sub(1, 2);
mul(1, 2);
}
当编译器优化打开时,对于非导出的内部函数,这一对应关系就可能会被打破。为了便于观察,我们可以在编译时指定-O0选项关闭优化,下面是编译后的模块(已经转换为文本格式):
(module
(type (;0;) (func (param i32 i32) (result i32)))
(type (;1;) (func))
(func (;0;) (type 0) (;代码省略;) )
(func (;1;) (type 0) (;代码省略;) )
(func (;2;) (type 0) (;代码省略;) )
(func (;3;) (type 1) (;代码省略;) )
(table (;0;) 1 funcref)
(memory (;0;) 0)
(export "memory" (memory 0))
(export "main" (func 3))
(elem (;0;) (i32.const 1) func)
)
用wasm-objdump命令观察函数段和代码段更直观一些,下面是输出结果(省略了无关内容):
...
Section Details:
Type[2]:
- type[0] (i32, i32) -> i32
- type[1] () -> nil
Function[4]:
- func[0] sig=0
- func[1] sig=0
- func[2] sig=0
- func[3] sig=1 <main>
Table[1]: ...
Memory[1]: ...
Export[2]: ...
Elem[1]: ...
Code[4]:
- func[0] size=7
- func[1] size=7
- func[2] size=7
- func[3] size=23 <main>
Custom: ...
表和元素段
Wasm中的表主要用来实现C/C++等语言中的函数指针。AssemblyScript语言和JavaScript/TypeScript语言一样,都支持一等函数,这一特性也是通过Wasm表来实现的。下面来看一个例子:
type op = (a: i32, b: i32) => i32;
function add(a: i32, b: i32): i32 { return a + b; }
function sub(a: i32, b: i32): i32 { return a - b; }
function mul(a: i32, b: i32): i32 { return a * b; }
export function calc(a: i32, b: i32, op: (x:i32, y:i32) => i32): i32 {
return op(a, b);
}
export function main(a: i32, b: i32): void {
calc(a, b, add);
calc(a, b, sub);
calc(a, b, mul);
}
下面是编译后的模块(已经转换为文本格式),请注意观察表段和元素段:
(module
(type (;0;) (func (param i32 i32) (result i32)))
(type (;1;) (func (param i32 i32)))
(type (;2;) (func (param i32 i32 i32) (result i32)))
(func (;0;) (type 2) (param i32 i32 i32) (result i32)
(call_indirect (type 0)
(local.get 0) (local.get 1)
(block (result i32) ;; label = @1
(global.set 0 (i32.const 2))
(local.get 2)
)
)
)
(func (;1;) (type 0) (;代码省略;) )
(func (;2;) (type 0) (;代码省略;) )
(func (;3;) (type 0) (;代码省略;) )
(func (;4;) (type 1) (param i32 i32)
(drop (call 0 (local.get 0) (local.get 1) (i32.const 1)))
(drop (call 0 (local.get 0) (local.get 1) (i32.const 2)))
(drop (call 0 (local.get 0) (local.get 1) (i32.const 3)))
)
(table (;0;) 4 funcref)
(memory (;0;) 0)
(global (;0;) (mut i32) (i32.const 0))
(export "memory" (memory 0))
(export "calc" (func 0))
(export "main" (func 4))
(elem (;0;) (i32.const 1) func 1 2 3)
)
内存和数据段
我们会在后面的文章中详细讨论AssemblyScript内存管理,这里先来看一个简单的例子:
declare function printChar(c: i32): void;
export function main(): void {
const str = "Hello, World!\n";
for (let i = 0; i < str.length; i++) {
printChar(str.charCodeAt(i));
}
}
AssemblyScript字符串内部使用UTF-16编码,字符串字面量会被放在数据段中。下面是编译后的模块(开启了编译器优化),请注意观察内存段和数据段:
(module
(type (;0;) (func))
(type (;1;) (func (param i32)))
(import "index" "printChar" (func (;0;) (type 1)))
(func (;1;) (type 0) (;代码省略;) )
(memory (;0;) 1)
(export "memory" (memory 0))
(export "main" (func 1))
(data (;0;) (i32.const 1024) "\1c\00\00\00\01\00\00\00\01\00\00\00\1c\00\00\00H\00e\00l\00l\00o\00,\00 \00W\00o\00r\00l\00d\00!\00\0a")
)
AssemblyScript编译器还提供了--initialMemory和--maximumMemory这两个选项,允许我们显式控制内存的初始和最大页数,这里就不详细介绍了。
全局段
由前文可知,AssemblyScript语言使用Wasm全局变量来实现语言中的全局变量。在完全关闭编译器优化时,AssemblyScript语言中定义的每一个全局变量都会在生成模块的全局段中占据一个项目。我们来看一个例子:
var g1: i32 = 100;
export var g2: i32 = 200;
export var g3: i64 = 300;
export const pi: f32 = 3.14;
export function main(): i32 {
return g1;
}
下面是编译后的模块(关闭编译器优化),可以看到,四个全局变量全部出现在了全局段中:
(module
(type (;0;) (func (result i32)))
(func (;0;) (type 0) (result i32) (global.get 0))
(table (;0;) 1 funcref)
(memory (;0;) 0)
(global (;0;) (mut i32) (i32.const 100))
(global (;1;) (mut i32) (i32.const 200))
(global (;2;) (mut i64) (i64.const 300))
(global (;3;) f32 (f32.const 0x1.91eb86p+1 (;=3.14;)))
(export "memory" (memory 0))
(export "g2" (global 1))
(export "g3" (global 2))
(export "pi" (global 3))
(export "main" (func 0))
(elem (;0;) (i32.const 1) func)
)
起始段
起始段的作用是指定一个起始函数索引,被指定的函数将在模块实例化之后被自动执行,从而进行一些额外的初始化工作。下面来看一个例子:
declare function max(a: i32, b: i32): i32;
declare function printI32(n: i32): void;
var x = max(123, 456);
export function main(): void {
printI32(x);
}
这个例子声明了两个外部函数,并且定义了一个全局变量x和一个函数main()。AssemblyScript编译器需要把全局变量x的初始化逻辑放到一个函数中,并将该函数的索引放在起始段中。下面请看编译后的模块(起始函数的索引是4):
(module
(type (;0;) (func))
(type (;1;) (func (param i32)))
(type (;2;) (func (param i32 i32) (result i32)))
(import "index" "max" (func (;0;) (type 2)))
(import "index" "printI32" (func (;1;) (type 1)))
(func (;2;) (type 0)
(global.set 0 (call 0 (i32.const 123) (i32.const 456)))
)
(func (;3;) (type 0) (call 1 (global.get 0)))
(func (;4;) (type 0) (call 2))
(table (;0;) 1 funcref)
(memory (;0;) 0)
(global (;0;) (mut i32) (i32.const 0))
(export "memory" (memory 0))
(export "main" (func 3))
(start 4)
(elem (;0;) (i32.const 1) func)
)
自定义段
如前面所述,自定义段主要是存放一些附加信息,例如函数名等调试信息。Wasm规范只定义了一个标准的“name”自定义段,专门用来存放名字信息。默认情况下,AssemblyScript编译器不生成“name”自定义段,但是可以通过--debug选项开启。让我们加上--debug选项来重新编译上面的例子,并通过wasm-objdump -x build/optimized.wasm命令观察生成的二进制模块(省略了部分无关内容):
...
Section Details:
Type[3]:
- type[0] () -> nil
- type[1] (i32) -> nil
- type[2] (i32, i32) -> i32
Import[2]:
- func[0] sig=2 <assembly/index/max> <- index.max
- func[1] sig=1 <assembly/index/printI32> <- index.printI32
Function[3]:
- func[2] sig=0 <start:assembly/index>
- func[3] sig=0 <assembly/index/main>
- func[4] sig=0 <~start>
Table[1]: ...
Memory[1]: ...
Global[1]:
- global[0] i32 mutable=1 - init i32=0
Export[2]: ...
Start:
- start function: 4
Elem[1]: ...
Code[3]:
- func[2] size=12 <start:assembly/index>
- func[3] size=6 <assembly/index/main>
- func[4] size=4 <~start>
Custom:
- name: "name"
- func[0] <assembly/index/max>
- func[1] <assembly/index/printI32>
- func[2] <start:assembly/index>
- func[3] <assembly/index/main>
- func[4] <~start>
Custom:
- name: "sourceMappingURL"
总结
在这篇文章里,我们讨论了AssemblyScript程序是如何编译成Wasm模块的,重点讨论了Wasm模块的各个段中存放了哪些信息。在下一篇文章里,我们将讨论AssemblyScript语言如何利用Wasm指令集实现各种语法要素。
*本文由CoinEx Chain开发团队成员Chase撰写。CoinEx Chain是全球首条基于Tendermint共识协议和Cosmos SDK开发的DEX专用公链,借助IBC来实现DEX公链、智能合约链、隐私链三条链合一的方式去解决可扩展性(Scalability)、去中心化(Decentralization)、安全性(security)区块链不可能三角的问题,能够高性能的支持数字资产的交易以及基于智能合约的Defi应用。