2.2 二进制格式分析
在第1章,我们已经用Rust语言编写了一个“Hello,World!”程序,并把它编译成了Wasm二进制格式在浏览器中运行。下面我们结合xxd命令和WABT提供的wasm-objdump命令分析一下这个程序,深入到细节中观察Wasm二进制格式。为了便于对照,除2.2.0节和2.2.12节以外,其余小节的编号和段ID一致。
2.2.0 魔数和版本号
Wasm二进制格式的魔数占4个字节,内容是\0asm。Wasm二进制格式的版本号也占4个字节,当前版本是1。我们把魔数和版本号定义成常量,代码如下所示。
const ( MagicNumber = 0x6D736100 // `\0asm` Version = 0x00000001 // 1 )
使用xxd命令观察二进制文件可以看到,前8个字节的确是魔数\0asm和版本号1。
$ xxd -u -g 1 code/js/ch01_hw.wasm 00000000: 00 61 73 6D 01 00 00 00 01 19 05 60 01 7F 00 60 .asm.......`...` 00000010: 00 00 60 02 7F 7F 01 7F 60 01 7F 01 7F 60 03 7F ..`.....`....`.. 00000020: 7F 7F 00 02 12 01 03 65 6E 76 0A 70 72 69 6E 74 .......env.print ...
请注意,由于Wasm二进制格式采用小端(Little-Endian)方式编码数值,所以魔数和版本号的4个字节都是倒着排列的。使用wasm-objdump命令观察二进制文件也可以看到,版本号的确是1。
$ wasm-objdump -x code/js/ch01_hw.wasm ch01_hw.wasm: file format wasm 0x1 ...
接下来我们从类型段开始,按照段在Wasm二进制的排列顺序分析每一个段,自定义段留到最后分析。
2.2.1 类型段
在Wasm二进制格式里,每个段都以1字节的段ID开始。除了自定义段,其他段的结构都完全定义好了。由于自定义段的内容可能是未知的,所以段ID后面存储了段的实际内容字节数,这样Wasm实现就可以根据字节数跳过不认识的自定义段。对于非自定义段,内容字节数并非解码必须的,但却有助于在需要时快速跳过某些段。为了便于理解,本书将使用一种类似正则表达式的简单语法来描述Wasm二进制格式,下面给出段的统一编码格式。
sec: id|byte_count|byte+ byte_count: u32 # LEB128编码的32位无符号整数
注意代码中竖线仅在描述时起分隔作用,在实际的二进制格式中并不存在,byte后边的加号表示出现至少一次,若是星号则表示出现任意次,若是问号则表示出现零次或一次。另外,为了让二进制格式尽可能紧凑,段的字节数、各种索引等整数值在二进制模块中是按LEB128[1]格式编码后存储的,2.3.1节会详细介绍这种整数编码格式。
由于大部分段都可以包含多个项目,所以必须知道段里一共有几个项目。相应的做法读者应该也猜到了:先记录项目的数量,然后再依次记录每个项目。这种以数量开头后边跟相应数量项目的做法在其他的二进制格式里(比如Java类文件和Lua二进制块)也很常见。我们把这种结构叫作向量,用vec<T>来表示。采用这种写法,段的统一编码格式可以简写为:id|vec<byte>,下面给出类型段的编码格式。
type_sec: 0x01|byte_count|type_count|func_type+ type_sec: 0x01|byte_count|vec<func_type> # 简写
除了起始段和自定义段,其他段在整体上也都有类似的编码格式,只是具体条目有所不同,后面不再赘述。观察xdd输出可以看到,跟在版本号后面的是类型段(ID是1),内容占据25(0x19)个字节,一共记录了5条类型数据。
00000000: 00 61 73 6D 01 00 00 00 01 19 05 60 01 7F 00 60 .asm.......`...` 00000010: 00 00 60 02 7F 7F 01 7F 60 01 7F 01 7F 60 03 7F ..`.....`....`.. 00000020: 7F 7F 00 02 12 01 03 65 6E 76 0A 70 72 69 6E 74 .......env.print ...
由2.1.3节可知,函数类型包括参数数量和类型,以及返回值数量和类型。在Wasm二进制格式里,函数类型以0x60开头,后面是参数数量、参数类型、返回值数量和返回值类型,下面给出函数类型的编码格式。
func_type: 0x60|vec<val_type>|vec<val_type>
观察xxd输出可以看到,类型段里的第一个函数类型(以0x60开头)只有一个(0x01)参数,类型是i32(0x7F),没有(0x00)返回值。
00000000: 00 61 73 6D 01 00 00 00 01 19 05 60 01 7F 00 60 .asm.......`...` 00000010: 00 00 60 02 7F 7F 01 7F 60 01 7F 01 7F 60 03 7F ..`.....`....`.. 00000020: 7F 7F 00 02 12 01 03 65 6E 76 0A 70 72 69 6E 74 .......env.print ...
读者可以自行分析其他4个函数类型,下面是wasm-objdump命令打印出的结果。
... Type[5]: - type[0] (i32) -> nil - type[1] () -> nil - type[2] (i32, i32) -> i32 - type[3] (i32) -> i32 - type[4] (i32, i32, i32) -> nil ...
[1] 参考链接:https://en.wikipedia.org/wiki/LEB128。
2.2.2 导入段
一个模块可以导出4种类型的成员以供其他模块使用,4种类型分别是:函数、表、内存、全局变量。反过来,一个模块可以从其他模块导入这4种类型的成员。通过导入和导出,多个模块可以被链接在一起,共同完成一个复杂的功能。导入和导出项通过名字链接,这些名字也叫作符号(Symbol),我们将在第10章详细讨论模块的链接。导入段中的每一个导入项都需要给出模块名(从哪个模块导入)、成员名(导入该模块中的哪个成员),以及具体描述信息,下面是导入项的结构体定义。
type Import struct { Module string Name string Desc ImportDesc }
模块和成员名可以是任意字符串,用UTF-8格式编码后以vec<byte>方式存储。为了描述更清晰,我们直接使用xxx_name来替代vec<byte>,下面给出导入段和导入项的编码格式。
import_sec : 0x02|byte_count|vec<import> import : module_name|member_name|import_desc
由于需要统一记录函数、表、内存、全局变量这4种不同的信息,Wasm二进制格式在导入描述的最前面安排了一个单字节tag,起区分作用:0表示函数、1表示表、2表示内存、3表示全局变量。这种做法在其他二进制格式(比如Java类文件)中也很常见。Go语言不支持C语言中的联合体,为了不丢失类型信息,我们把4种导入信息分别放在4个字段中。当然,正常解码之后,仅有一个字段是有意义的,下面是导入描述的结构体定义。
const ( ImportTagFunc = 0; ImportTagTable = 1; ImportTagMem = 2; ImportTagGlobal = 3; ) type ImportDesc struct { Tag byte FuncType TypeIdx // tag=0 Table TableType // tag=1 Mem MemType // tag=2 Global GlobalType // tag=3 }
根据前面的说明,可以给出导入描述的编码格式(方括号表示列出的元素只能出现一个,表、内存、全局变量类型的格式将在2.2.4节~2.2.6节进行介绍)。
import_desc: tag|[type_idx, table_type, mem_type, global_type]
观察xxd输出可以看到,类型段之后是导入段(ID是2),内容占据18(0x12)个字节,只有1条导入数据。
00000000: 00 61 73 6D 01 00 00 00 01 19 05 60 01 7F 00 60 .asm.......`...` 00000010: 00 00 60 02 7F 7F 01 7F 60 01 7F 01 7F 60 03 7F ..`.....`....`.. 00000020: 7F 7F 00 02 12 01 03 65 6E 76 0A 70 72 69 6E 74 .......env.print 00000030: 5F 63 68 61 72 00 00 03 0C 0B 01 02 02 02 02 03 _char........... ...
这条导入数据记录的模块名是env(长3字节),成员名是print_char(长10字节),描述的是函数(tag是0),函数签名的索引是0。
... 00000020: 7F 7F 00 02 12 01 03 65 6E 76 0A 70 72 69 6E 74 .......env.print 00000030: 5F 63 68 61 72 00 00 03 0C 0B 01 02 02 02 02 03 _char........... 00000040: 02 04 02 04 03 04 05 01 70 01 01 01 05 03 01 00 ........p....... ...
下面是wasm-objdump命令打印出的结果。
... Import[1]: - func[0] sig=0 <print_char> <- env.print_char ...
2.2.3 函数段
由上文可知,函数段列出了内部函数的签名在类型段中的索引,函数的局部变量信息和字节码则存储在代码段中,下面给出函数段的编码格式。
func_sec: 0x03|byte_count|vec<type_idx>
观察xxd输出可以看到,导入段之后是函数段(ID是3),内容占据12(0x0C)个字节,一共存储了11(0x0B)个函数类型索引。
... 00000030: 5F 63 68 61 72 00 00 03 0C 0B 01 02 02 02 02 03 _char........... 00000040: 02 04 02 04 03 04 05 01 70 01 01 01 05 03 01 00 ........p....... 00000050: 11 06 19 03 7F 01 41 80 80 C0 00 0B 7F 00 41 8E ......A.......A. ...
下面是wasm-objdump命令打印出的结果(由于模块有一个导入函数,占据了函数索引0,所以打印出的函数是从索引1开始的。另外,打印结果中还包含了从自定义段提取出的函数名,不过除了main()函数,其他函数名都是由编译器自动生成的,由于代码太长了,限于篇幅做了省略处理)。
... Function[11]: - func[1] sig=1 <main> - func[2] sig=2 <函数名太长,省略> - func[3] sig=2 <函数名太长,省略> - func[4] sig=2 <函数名太长,省略> - func[5] sig=2 <函数名太长,省略> - func[6] sig=3 <函数名太长,省略> - func[7] sig=2 <函数名太长,省略> - func[8] sig=4 <函数名太长,省略> - func[9] sig=2 <函数名太长,省略> - func[10] sig=4 <函数名太长,省略> - func[11] sig=3 <函数名太长,省略> ...
2.2.4 表段
表段列出模块内定义的表。由前文可知,Wasm规范规定模块最多只能定义一张表,且元素类型必须为函数引用(编码为0x70)。除了元素类型,表类型还需要指定元素数量的限制,其中必须指定下限,上限则可选。限制编码后以1字节tag开头。如果tag是0,表示只指定下限。否则,tag必须为1,表示既指定下限,又指定上限。加上这些限定后,下面给出表段和表类型的编码格式。
table_sec : 0x04|byte_count|vec<table_type> # 目前vec长度只能是1 table_type: 0x70|limits limits : tag|min|max?
虽然“Hello,World!”程序并没有用到表,但是Rust编译器还是生成了表段。观察xxd输出可以看到,函数段之后是表段(ID是4),内容占据5个字节,的确仅记录了一个表类型,这个表类型的元素类型也的确是函数引用(0x70),限制的tag是1,上下限也都是1。
... 00000030: 5F 63 68 61 72 00 00 03 0C 0B 01 02 02 02 02 03 _char........... 00000040: 02 04 02 04 03 04 05 01 70 01 01 01 05 03 01 00 ........p....... 00000050: 11 06 19 03 7F 01 41 80 80 C0 00 0B 7F 00 41 8E ......A.......A. ...
下面是wasm-objdump命令打印出的结果。
... Table[1]: - table[0] type=funcref initial=1 max=1 ...
2.2.5 内存段
内存段列出模块内定义的内存。和表一样,Wasm规范规定模块最多只能定义一块内存。由前文可知,内存类型只须指定内存页数限制。下面给出内存段和内存类型的编码格式。
mem_sec : 0x05|byte_count|vec<mem_type> # 目前vec长度只能是1 mem_type: limits
观察xxd输出可以看到,表段之后是内存段(ID是5),内容占据3个字节,的确仅记录了一个内存类型。限制的tag是0,内存页数的下限是17(0x11),没有指定上限。
... 00000040: 02 04 02 04 03 04 05 01 70 01 01 01 05 03 01 00 ........p....... 00000050: 11 06 19 03 7F 01 41 80 80 C0 00 0B 7F 00 41 8E ......A.......A. 00000060: 80 C0 00 0B 7F 00 41 8E 80 C0 00 0B 07 2C 04 06 ......A......,.. ...
下面是wasm-objdump命令打印出的结果。
... Memory[1]: - memory[0] pages: initial=17 ...
2.2.6 全局段
全局段列出模块内定义的所有全局变量,全局项需要指定全局变量的类型(包括值类型和可变性)以及初始值,下面是全局项的结构体定义。
type Expr interface{} // 第3章再定义 type Global struct { Type GlobalType Init Expr }
我们暂时先忽略指令和表达式,只要知道表达式以0x0B结尾即可,具体细节留到下一章再讨论。下面给出全局段、全局项和全局变量类型的编码格式。
global_sec : 0x06|byte_count|vec<global> global : global_type|init_expr global_type: val_type|mut expr : byte*|0x0B
观察xxd输出可以看到,内存段之后是全局段(ID是6),内容占据25(0x19)个字节,共记录了3个全局变量。其中第一个全局变量的类型是i32(0x7F),可变(1表示可变,0表示不可变)。初始值由常量指令i32.const(操作码0x41,立即数0x100000,详见第3章)给出,以0x0B结尾。
... 00000040: 02 04 02 04 03 04 05 01 70 01 01 01 05 03 01 00 ........p....... 00000050: 11 06 19 03 7F 01 41 80 80 C0 00 0B 7F 00 41 8E ......A.......A. 00000060: 80 C0 00 0B 7F 00 41 8E 80 C0 00 0B 07 2C 04 06 ......A......,.. ...
这3个全局变量都是Rust编译器自动生成的,读者可以自行分析其他两个,下面是wasm-objdump命令打印出的结果。
... Global[3]: - global[0] i32 mutable=1 - init i32=1048576 - global[1] i32 mutable=0 - init i32=1048590 - global[2] i32 mutable=0 - init i32=1048590 ...
2.2.7 导出段
导出段列出模块所有导出成员,只有被导出的成员才能被外界访问,其他成员被很好地“封装”在模块内部。和导入段一样,导出段也可以包含4种导出项:函数、表、内存、全局变量。
相比导入项,导出项要简单一些。第一,只要指定成员名即可,不需要指定模块名。这点很容易理解,一个模块可以从很多模块导入项目,但所有的导出项肯定是共用一个模块名。至于模块名,其实并没有写在Wasm二进制格式里,而是在链接时由链接器指定,这部分内容在第10章会详细介绍。第二,导出项只要指定成员索引即可,不需要指定具体类型。这点也不难理解,因为类型可以通过索引从相应的段里查到,下面是导出项的结构体定义。
type Export struct { Name string Desc ExportDesc }
和导入描述一样,导出描述在Wasm二进制里也是以1字节tag开头:0表示函数、1表示表、2表示内存、3表示全局变量。不管是哪种成员,导出描述只需要指定成员在其索引空间内的索引,所以我们可以共用一个字段来存放索引。下面是导出描述的结构体定义。
const ( ExportTagFunc = 0; ExportTagTable = 1; ExportTagMem = 2; ExportTagGlobal = 3; ) type ExportDesc struct { Tag byte Idx uint32 }
基于以上介绍可以给出导出段、导出项和导出描述的编码格式。
export_sec : 0x07|byte_count|vec<export> export : name|export_desc export_desc: tag|[func_idx, table_idx, mem_idx, global_idx]
观察xxd输出可以看到,全局段之后是导出段(ID是7),内容占据44(0x2C)个字节,共有4个导出项。其中第一个导出项的名字是memory(长6字节),导出描述的tag是2,说明导出的是内存,索引为0。
... 00000060: 80 C0 00 0B 7F 00 41 8E 80 C0 00 0B 07 2C 04 06 ......A......,.. 00000070: 6D 65 6D 6F 72 79 02 00 0A 5F 5F 64 61 74 61 5F memory...__data_ 00000080: 65 6E 64 03 01 0B 5F 5F 68 65 61 70 5F 62 61 73 end...__heap_bas 00000090: 65 03 02 04 6D 61 69 6E 00 01 0A D6 08 0B EA 01 e...main........ ...
第二和第三个导出项描述的是全局变量,第四个导出项描述的是主函数,请读者自行分析。下面是wasm-objdump命令打印出的结果。
... Export[4]: - memory[0] -> "memory" - global[1] -> "__data_end" - global[2] -> "__heap_base" - func[1] <main> -> "main" ...
2.2.8 起始段
起始段比其他段都简单,因为只需要记录一个起始函数索引,下面给出起始段的编码格式。
start_sec: 0x08|byte_count|func_idx
Rust编译器没有给“Hello,World!”程序生成起始段。我们在第4章会讨论Wasm文本格式,到时候读者可以用WAT语言编写一个文本格式的模块,指定起始段,然后用WABT提供的wat2wasm命令编译成二进制格式进行观察。
2.2.9 元素段
元素段存放表初始化数据,每个元素项包含3部分信息:表索引(初始化哪张表)、表内偏移量(从哪里开始初始化)、函数索引列表(给定的初始数据)。我们已经知道,目前模块最多只能导入或定义一张表,所以表索引暂时只起占位作用,值必须为0。等后续版本放开这个限制,表索引就可以真正派上用场了。和全局变量初始值类似,表内偏移量也是用表达式指定的,下面是元素项的结构体定义。
type Elem struct { Table TableIdx Offset Expr Init []FuncIdx }
基于以上介绍可以给出元素段和元素项的编码格式。
elem_sec: 0x09|byte_count|vec<elem> elem : table_idx|offset_expr|vec<func_idx>
虽然Rust编译器给“Hello,World!”程序生成了表段,但并没有生成元素段。我们将在第9章详细讨论表和间接函数调用,在第9章有一个Rust示例,读者可以编译那个例子,然后观察元素段内容。
2.2.10 代码段
可以说前面介绍的段里存放的都是辅助信息,代码段因为存放了函数的字节码,所以是Wasm二进制模块的核心。除了字节码,方法的局部变量信息也在代码段中。为了节约空间,局部变量信息是压缩后存储的:连续多个相同类型的局部变量会被分为一组,统一记录变量数量和类型,下面是代码项和局部变量组的结构体定义。
type Code struct { Locals []Locals Expr Expr // 字节码,详见第3章 } type Locals struct { N uint32 Type ValType }
和其他段相比,代码段有一个特殊之处:每个代码项都以该项所占字节数开头。这显然是冗余信息,目的是方便Wasm实现并行处理(比如验证、分析、编译等)函数字节码。下面给出代码段、代码项和局部变量组的编码格式。
code_sec: 0x0A|byte_count|vec<code> code : byte_count|vec<locals>|expr locals : local_count|val_type
观察xxd输出可以看到,导出段之后是代码段,ID是10(0x0A),内容占据1110(0xD6、0x08)个字节,一共有11(0x0B)个代码项。注意,由于内容字节数1110超过了127,所以LEB128编码后占据了两个字节,详见2.3.1节。
... 00000080: 65 6E 64 03 01 0B 5F 5F 68 65 61 70 5F 62 61 73 end...__heap_bas 00000090: 65 03 02 04 6D 61 69 6E 00 01 0A D6 08 0B EA 01 e...main........ 000000a0: 01 16 7F 23 80 80 80 80 00 21 00 41 20 21 01 20 ...#.....!.A !. 000000b0: 00 20 01 6B 21 02 20 02 24 80 80 80 80 00 41 80 ...k!...$.....A. ...
其中第一个代码项内容占据234(LEB128编码后是0xEA、0x01)个字节,有一个局部变量组,该组共计22(0x16)个i32类型(0x7F)的局部变量(函数的字节码较长,限于篇幅没有完整展示)。
... 00000080: 65 6E 64 03 01 0B 5F 5F 68 65 61 70 5F 62 61 73 end...__heap_bas 00000090: 65 03 02 04 6D 61 69 6E 00 01 0A D6 08 0B EA 01 e...main........ 000000a0: 01 16 7F 23 80 80 80 80 00 21 00 41 20 21 01 20 ...#.....!.A !. 000000b0: 00 20 01 6B 21 02 20 02 24 80 80 80 80 00 41 80 ...k!...$.....A. ...
请读者自行分析其余10个代码项,下面是wasm-objdump命令打印出的结果。
... Code[11]: - func[1] size=234 <main> - func[2] size=19 <自定义段记录的函数名> - func[3] size=47 <自定义段记录的函数名> - func[4] size=19 <自定义段记录的函数名> - func[5] size=47 <自定义段记录的函数名> - func[6] size=37 <自定义段记录的函数名> - func[7] size=47 <自定义段记录的函数名> - func[8] size=219 <自定义段记录的函数名> - func[9] size=5 <自定义段记录的函数名> - func[10] size=102 <自定义段记录的函数名> - func[11] size=319 <自定义段记录的函数名> ...
2.2.11 数据段
数据段和元素段有诸多相似之处:第一,元素段存放表初始化数据,数据段则存放内存初始化数据;第二,数据项也包含三部分信息:内存索引(初始化哪块内存)、内存偏移量(从哪里开始初始化)、初始数据;第三,目前模块最多只能导入或定义一个内存,所以内存索引暂时也只起占位作用,必须是0;第四,内存偏移量也是由表达式指定的,下面是数据项的结构体定义。
type Data struct { Mem MemIdx Offset Expr Init []byte }
基于以上介绍可以给出数据段和数据项的编码格式。
data_sec: 0x0B|byte_count|vec<data> data : mem_idx|offset_expr|vec<byte>
观察xxd输出可以看到,代码段之后是数据段,ID是11(0x0B),内容占据23(0x17)个字节,只有一个数据项。这个数据项的内存索引的确是0,偏移量由一条i32.const指令(操作码0x41,立即数0x100000,详见第3章)给出,以0x0B结尾。
... 000004f0: 1B 0F 0B 0B 17 01 00 41 80 80 C0 00 0B 0E 48 65 .......A......He 00000500: 6C 6C 6F 2C 20 57 6F 72 6C 64 21 0A 00 FC 06 04 llo, World!..... 00000510: 6E 61 6D 65 01 F4 06 0C 00 0A 70 72 69 6E 74 5F name......print_ 00000520: 63 68 61 72 01 04 6D 61 69 6E 02 51 5F 5A 4E 34 char..main.Q_ZN4 ...
初始数据共14(0x0E)字节,内容正是我们所熟悉的Hello,World!\n字符串。
... 000004f0: 1B 0F 0B 0B 17 01 00 41 80 80 C0 00 0B 0E 48 65 .......A......He 00000500: 6C 6C 6F 2C 20 57 6F 72 6C 64 21 0A 00 FC 06 04 llo, World!..... 00000510: 6E 61 6D 65 01 F4 06 0C 00 0A 70 72 69 6E 74 5F name......print_ 00000520: 63 68 61 72 01 04 6D 61 69 6E 02 51 5F 5A 4E 34 char..main.Q_ZN4 ...
下面是wasm-objdump命令打印出的结果。
... Data[1]: - segment[0] memory=0 size=14 - init i32=1048576 - 0100000: 4865 6c6c 6f2c 2057 6f72 6c64 210a Hello, World!. ...
2.2.12 自定义段
自定义段存放自定义数据,和其他段相比,自定义段主要有两点不同。第一,也是最重要的一点,在前文曾提到过,自定义段不参与模块语义。自定义段存放的都是额外信息(比如函数名和局部变量名等调试信息或第三方扩展信息),即使完全忽略这些信息也不影响模块的执行。第二,自定义段可以出现在任何一个非自定义段前后,而且出现的次数不受限制。
Wasm规范要求自定义段的内容必须以一个字符串为开头,这个字符串作为自定义段的名称起到标识作用。对于比较自由的代码格式,不太可能定义整数ID,所以只能使用字符串名称。这种做法在其他二进制格式里也很常见,比如Java类文件也使用字符串名称来标识各种属性。另外,考虑到通用性,Wasm规范在附录7.4中定义了一个标准的自定义段,名字是name,专门用来存放模块名、内部函数名和局部变量名。限于篇幅,本书不展开讨论自定义段,请读者阅读Wasm规范了解更多细节,下面是自定义段的结构体定义。
type CustomSec struct { Name string Bytes []byte }
基于以上介绍可以给出自定义段的统一编码格式。
custom_sec: 0x00|byte_count|name|byte*
观察xxd输出可以看到,数据段之后是自定义段(ID是0),内容占据892(LEB128编码后是0xFC、0x06)个字节。自定义段的名称长4字节,正好对应字符串name的4个字符(自定义数据内容较长,没有完整展示)。
... 00000500: 6C 6C 6F 2C 20 57 6F 72 6C 64 21 0A 00 FC 06 04 llo, World!..... 00000510: 6E 61 6D 65 01 F4 06 0C 00 0A 70 72 69 6E 74 5F name......print_ 00000520: 63 68 61 72 01 04 6D 61 69 6E 02 51 5F 5A 4E 34 char..main.Q_ZN4 ...
下面是wasm-objdump命令打印出的结果(此处省略了大部分输出)。
... Custom: - name: "name" - func[0] <print_char> - func[1] <main> - ...
至此,整个“Hello,World!”程序就分析完毕了。出于完整考虑,下面给出模块的总体格式(完整的模块二进制格式描述见本书附录B)。
module: magic|version|type_sec?|import_sec?|func_sec? |table_sec?|mem_sec?|global_sec?|export_sec? |start_sec?|elem_sec?|code_sec?|data_sec?