2.2 文本格式与类型系统 本节摘要:WAT 是模块的透明包装——与二进制严格等价、语法古老却锋利的 S 表达式。本节讲清 WAT 的两种书写风格、四个数值类型与引用类型的分工、函数签名与局部变量的声明方式,并示范如何读懂验证报错。旧版单列的"类型系统"内容并入本节:类型声明本就是 WAT 的骨架。 别以为 WAT 只是一种给规范文档配图用的伪代码——它是工程里每天都在用的活工具:读别人的编译产物、手写测试桩、在基准测试里微调热点函数,都靠它。本节目标有明确的先后:先能读,再能写,最后能在报错里定位问题。 一、S 表达式:把模块写成嵌套清单 WAT 的语法载体是 S 表达式——Lisp 家族用了半个多世纪的记法。每个括号对表达一个节点,节点头部是关键字,后面跟属性或子节点。
本节摘要:WAT 是模块的透明包装——与二进制严格等价、语法古老却锋利的 S 表达式。本节讲清 WAT 的两种书写风格、四个数值类型与引用类型的分工、函数签名与局部变量的声明方式,并示范如何读懂验证报错。旧版单列的"类型系统"内容并入本节:类型声明本就是 WAT 的骨架。
别以为 WAT 只是一种给规范文档配图用的伪代码——它是工程里每天都在用的活工具:读别人的编译产物、手写测试桩、在基准测试里微调热点函数,都靠它。本节目标有明确的先后:先能读,再能写,最后能在报错里定位问题。
WAT 的语法载体是 S 表达式——Lisp 家族用了半个多世纪的记法。每个括号对表达一个节点,节点头部是关键字,后面跟属性或子节点。整个模块就是这样一棵层层嵌套的树:
(module (type $t (func (param i32 i32) (result i32))) (func $mul (type $t) local.get 0 local.get 1 i32.mul) (export "mul" (func $mul)))
注意 local.get 0 与 local.get 1:它们是兄弟节点而非嵌套,这种"一条条指令平铺"的写法叫平铺格式,与栈式执行一一对应。WAT 还提供折叠格式(folded format),把指令连同操作数写成嵌套括号:
(func $mul_folded (param i32 i32) (result i32) (i32.mul (local.get 0) (local.get 1)))
折叠格式在编译期会被展开成平铺格式,两者完全等价。工程经验是:读编译产物用平铺(它就是真实执行顺序),手写小函数用折叠(少犯顺序错误)。两种风格可以在同一文件混用,按需切换。
注释风格有两条:行内双分号 ;; 到行尾,以及块注释 (; ... ;)。给关键函数标注上一句人话,未来排查时省的时间远超敲注释的成本。
Wasm MVP 的类型清单短得惊人:四个数值类型加后引入的引用类型。短,是刻意为之——类型越少,验证算法越简单,引擎优化路径越确定。
| 类型 | 位宽 | 用途与备注 |
|---|---|---|
| i32 | 32 位整数 | 指针宽度、布尔值、内存偏移的事实标准 |
| i64 | 64 位整数 | 大计数、哈希、时间戳 |
| f32 | 32 位浮点 | 图形与游戏的传统主场 |
| f64 | 64 位浮点 | 与 JavaScript 的 number 对齐,科学计算的默认 |
| funcref / externref | 引用 | 指向表内函数 / 宿主任意值,无数字语义 |
四个数值类型有三条共同纪律,理解它们比背清单重要。其一,无符号与有符号语义属于指令而不属于类型:i32 只是一串 32 位,i32.lt_u 按无符号比较、i32.lt_s 按有符号比较——选错指令是真实世界的头号数值 bug 来源。其二,位宽之间不隐式转换:i32 到 i64 必须经 i64.extend_i32_s(带符号扩展)或 i64.extend_i32_u(零扩展)显式声明意图。其三,没有 NaN 打包之外的位级花活:f32 与 i32 之间要经过 reinterpret 指令做位重解释,这在密码学与图形代码里常见,但语义上仍是显式操作。
函数签名是类型系统的主战场:参数类型序列加返回值类型序列。多值返回在扩展后成为标准,函数可以返回一组值而不是一个;这对编译器后端意义重大——返回结构体不必再塞进线性内存绕路。局部变量在函数体内声明,类型固定、初始化为零值;参数本质上是编号靠前的局部变量,这就是为什么 2.1 的转储里 local.get 0 取到的是首个参数。
引用类型值得单独交代:funcref 只能指向函数表中的条目,是间接调用的合法凭证;externref 是宿主任意值的透明信封,模块看不见里面装什么、只能原样传递——这是宿主对象安全进入模块世界的通道,垃圾回收提案落地后它还是与宿主对象互操作的基石。
把摄氏转华氏写成折叠格式,体会类型标注的日常手感:
(module (func $c_to_f (param $c f64) (result f64) (f64.add (f64.mul (local.get $c) (f64.const 1.8)) (f64.const 32))) (export "c_to_f" (func $c_to_f)))
再故意写一个类型错误的版本,看看验证器怎么接住它:
(module (func $broken (param $x i32) (result i32) local.get $x f64.const 3.14 i32.add))
用 wat2wasm 编译,会得到类似下面的报错:
$ wat2wasm broken.wat -o broken.wasm broken.wat:4:5: error: type mismatch in i32.add, expected [i32, i32] but got [i32, f64] i32.add ^^^^^^^
报错三要素齐了:位置(文件与行列)、指令、期望栈形与实际栈形。排查方法论就是沿着栈形倒推——从报错行往上读,找出哪个指令压入了与预期不符的类型。九成以上的 WAT 验证报错都能在这条路径上三分钟内解决;剩下的一成多是索引越界,比如引用了不存在的函数编号,同样会在报错里点名。
⚠️ 常见坑:从高级语言反编译出的 WAT 动辄上千行,直接手改很容易破坏栈形一致性。修改编译产物前先用工具导出,改完立刻重编译回二进制验证,别用文本编辑器硬碰。
💡 关键直觉:把每个 WAT 函数当作"带类型标注的算式"来读——栈形是它的语法检查器。读不顺时先把折叠格式展开成平铺,执行顺序立刻显形。

类型和签名的纸面契约就绪,下一节去看契约背后真正的仓库:线性内存与表,模块状态的全部家当都在那里。