2.1 二进制格式规范


文档摘要

2.1 二进制格式规范 本节摘要:Wasm 二进制格式是一份"为验证而生"的容器规范:固定封条标识文件类型,带序号的节承载全部内容,变长整数压缩每一个数字。本节从八个字节的头部读到验证器的判定逻辑,并带你用标准工具转储一个真实模块,把十六进制数逐段读出含义。 LEB128、魔数、节——这些行话背后是同一个设计意图:让验证器用尽量少的字节、尽量简单的算法,完成对整箱货物的合规判定。本节先拆格式的三层结构(头部、节、编码),再进到验证器的断案逻辑,最后动手转储一份真实模块对照印证。 从八个字节的封条读起 每个合法的 Wasm 二进制文件都以同八个字节开头: 。

2.1 二进制格式规范

本节摘要:Wasm 二进制格式是一份"为验证而生"的容器规范:固定封条标识文件类型,带序号的节承载全部内容,变长整数压缩每一个数字。本节从八个字节的头部读到验证器的判定逻辑,并带你用标准工具转储一个真实模块,把十六进制数逐段读出含义。

LEB128、魔数、节——这些行话背后是同一个设计意图:让验证器用尽量少的字节、尽量简单的算法,完成对整箱货物的合规判定。本节先拆格式的三层结构(头部、节、编码),再进到验证器的断案逻辑,最后动手转储一份真实模块对照印证。

从八个字节的封条读起

每个合法的 Wasm 二进制文件都以同八个字节开头:00 61 73 6d 01 00 00 00。前四个字节是魔数,ASCII 里恰好拼出 "asm",其中首字节为零——这是刻意安排:防止文件被误当成文本处理,也防止某些传输层把它当空白裁剪。后四个字节是版本号,当前恒为 1。引擎加载时先核封条:魔数或版本不符,直接拒绝,连第一节的门都进不了。

封条之后是节的序列。每个节由三段构成:单字节节 ID、LEB128 编节的载荷长度、载荷本体。长度前置是个关键决定——引擎可以按节跳读、并行解析、甚至只加载感兴趣的节,而不必线性扫描整个文件。节的 ID 同时规定了顺序语义,下表是常用节的全家福:

节 ID 名称 装载内容
0 自定义节 调试信息、名称段等,可出现在任意位置,验证器跳过
1 类型节 函数签名清单:参数与返回值的类型序列
2 导入节 申报的外部函数、内存、表、全局变量
3 函数节 每个本地函数到类型节中签名的索引
4 表节 间接调用所用函数表的声明
5 内存节 线性内存的初始页数与最大页数声明
6 全局节 常量或可变全局变量的类型与初值
7 导出节 对外开放的函数、内存、表、全局及其名字
8 起始节 实例化时自动执行的启动函数索引
9 元素节 写入函数表初始内容的段落
10 代码节 全部函数体的指令序列
11 数据节 写入线性内存初始内容的段落

两个值得咀嚼的细节:函数节与代码节为什么分家?函数节只是一列索引(每个函数一个 LEB128 数字),代码节才是真正的指令体——分开放让引擎能在读完函数节时就知道全部签名,尽早开始签名相关的验证与链接规划,不必等庞大的代码节到齐。自定义节为什么 ID 是零且可乱序?它被明确豁免出语义世界:验证器对它视而不见,于是调试符号、源码映射这类"附属文件"可以搭同一辆货车而不干扰合规判定。

变长整数:字节的钉子

节与节之间、指令与指令之间,钉满了 LEB128(Little-Endian Base 128)编码的数字。它的思路是:小数字用少字节。数值按七位一组切分,低位组在前,每组最高位做续传标志——置一表示后面还有组。于是 6 编成一个字节的 06,624485 编成三个字节的 e5 8e e5

这套编码对格式的影响是全方位的:节长度、函数数量、类型索引、跳转偏移,几乎所有数字字段都在省字节。它也带来一条实用的调试直觉——二进制里"看起来乱"的数字段,往往不是数据而是长度或索引;转储工具会把它们解码成人可读的形式,别盯着十六进制硬啃。

动手转储:对照十六进制读模块

光读规范容易困,用一个最小的加法模块走一遍完整流程。先写 WAT 源文件:

(module (func $add (param $a i32) (param $b i32) (result i32) local.get $a local.get $b i32.add) (export "add" (func $add)))

用 wabt 工具包把它编译成二进制,再查看头部十六进制:

$ wat2wasm add.wat -o add.wasm $ xxd add.wasm | head -4 00000000: 0061 736d 0100 0000 0107 0160 027f 7f01 .asm.......`.. . 00000010: 7f03 0201 0007 0701 0361 6464 0000 0a09 .add......add... 00000020: 0107 0020 0020 016a 0b ..... . .j.

对照前表逐段解码,这串字节就"开箱"了:

  • 00 61 73 6d 01 00 00 00——封条与版本;
  • 01 07 01 60 02 7f 7f 01 7f——类型节(ID 一):载荷七个字节,含一条签名,60 是函数类型的标记,两个参数加一个返回值,7f 是 i32 的类型编码;
  • 03 02 01 00——函数节(ID 三):一个函数,签名指向类型节的零号条目;
  • 07 07 01 03 61 64 64 00 00——导出节(ID 七):一个导出,名字三字节长的 "add",种类零表示函数,索引零;
  • 0a 09 01 07 00 20 00 20 01 6a 0b——代码节(ID 十):一个函数体,六个字节的指令——20 00 取局部变量零,20 01 取局部变量一,6a 是 i32.add,0b 是函数结束。

四十一个字节,一个完整可验证、可调用的模块。体积之小印证了第一章的判断:这是一份把每个数字都抠到最省的格式。

验证器:不执行任何指令的断案

格式讲完,讲这份格式真正的客户——验证器。它在模块加载阶段跑一遍完整的静态检查,全程不执行任何指令,结论只有收货或拒收。断案分两步。

第一步核结构:节顺序是否符合规范(例如函数节必须在代码节之前)、每个节的载荷长度是否与声明一致、索引是否越界(函数节引用的类型索引必须真实存在于类型节)。第二步核类型:对代码节里的每个函数体,验证器模拟栈式执行——不是真算,而是跟踪每个位置的虚拟操作数栈的"类型形状"。取局部变量的指令必须指向存在的局部;加法指令要求栈顶压着两个同宽整数;分支要求合并点的栈形状一致。任何一步对不上,验证失败,报错会带偏移量,形如"类型不匹配:i32.add 需要 i32,实际得到 i64,位于函数体偏移某处"。

这套设计的效果是釜底抽薪式的:坏模块根本没有"运行起来再出错"的机会。第二章开头说的"验关员",指的就是它。第三章讲指令集时,你会发现每条指令的语义定义都配着对应的验证规则——格式与指令是同一份契约的正文与附则。

图 2-A:模块二进制的节布局与载荷结构

图 2-A:模块二进制的节布局与载荷结构

本节要点回顾

  • 头部八个字节:魔数加版本号,引擎的入口检查,任何不符即刻拒收;
  • 节是基本格间:ID、LEB128 长度、载荷三段式;长度前置让引擎可跳读与并行解析;
  • 函数节与代码节分家:签名早到、函数体晚到,利于提前验证与链接规划;
  • LEB128 无处不在:小数字省字节的变长编码,是二进制紧凑性的主要来源;
  • 验证器分两步断案:先核结构再核类型,全程静态,坏模块在实例化之前就被淘汰;
  • 报错自带偏移:验证失败的报错精确到函数体偏移量,是排查 WAT 笔误的第一线索。

二进制看懂了,下一节换透明包装:WAT 文本格式与类型系统,让模块结构变得可以直接阅读与手写。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U