本节摘要:Wasm 选择栈式虚拟机不是怀旧,而是一笔算得过来的工程账:指令最省、验证最简、引擎最容易翻译成任何硬件的原生代码。本节用逐帧栈快照推演一段完整计算,讲清操作数栈与调用栈的分工,并剖析陷阱语义——Wasm 世界里错误当场熄火的哲学。
在寄存器机大行其道的今天,为什么规范偏偏选了栈机做语义模型?这个问题值得较真,因为答案里藏着 Wasm 三条设计目标的交汇点,也是理解后续所有指令行为的地基。
把候选方案摆上台面对比,结论自然浮出来。寄存器机(如 LLVM IR)指令表达力强,但每条指令要写清源寄存器与目标寄存器,编码大;且寄存器分配策略与硬件强相关,规范若定死寄存器模型,就绑死了未来的硬件演化。栈机相反:指令只操作栈顶,无需命名任何存储位置,编码天然紧凑——i32.add 一个字节就表达了"弹出两个数、相加、压回结果"。
更要紧的是验证与翻译的友好性。栈机的验证算法可以线性扫描:维护一条虚拟栈的类型形状,每条指令的进出栈形状在规范里写死,对不上即拒绝。翻译到真实硬件也直接:引擎把栈槽映射到物理寄存器,"压栈出栈"被优化成寄存器搬运,虚拟栈在最终机器码里几乎不留痕迹。规范用栈机换来了三样东西——小体积、快验证、易移植,代价只是文本可读性稍差,而那本来就由 WAT 负责。
空谈不如推演。下面这个函数计算表达式 (a + b) * (a - b),注释给出每条指令的意图:
(func $calc (param $a i32) (param $b i32) (result i32) local.get $a ;; 压入 a local.get $b ;; 压入 b i32.add ;; 弹出 b、a 压回 a+b local.get $a ;; 压入 a local.get $b ;; 压入 b i32.sub ;; 弹出 b、a 压回 a-b i32.mul) ;; 弹出 a-b、a+b 压回乘积
以 a=7、b=3 为输入,栈的状态变化逐帧记录如下:
| 步骤 | 指令 | 执行后的栈(左为栈底) |
|---|---|---|
| 起点 | —— | 空 |
| 一 | local.get a | 7 |
| 二 | local.get b | 7, 3 |
| 三 | i32.add | 10 |
| 四 | local.get a | 10, 7 |
| 五 | local.get b | 10, 7, 3 |
| 六 | i32.sub | 10, 4 |
| 七 | i32.mul | 40 |
读这张表能看出栈机的两条纪律。第一,操作数顺序由压栈顺序决定:i32.sub 弹出时先弹减数、后弹被减数,写成 local.get $b 在前就会算出反差——这是手写 WAT 最常见的笔误。第二,每个函数结束时栈里必须恰好剩下签名要求的返回值:多一个少一个都会在验证阶段被拦下,根本轮不到运行。
函数调用时栈会分层。每个活动函数拥有一条独立的操作数栈,外加自己的局部变量区——这部分统称调用帧。调用指令压入新帧,返回指令弹出当前帧并把返回值留在调用方的栈上。深度递归因此在 Wasm 里天然可用,但宿主通常对调用深度设限(浏览器默认栈大小有限),无限递归一样会"栈溢出"式陷阱。

栈机之外,本节还有半场戏:陷阱(trap)。它是 Wasm 对运行期错误的统一应答——除以零、整数到浮点的转换溢出、越界访问、调用签名不符、执行 unreachable 指令,全部触发同一个动作:当前执行立刻中止,整条调用链逐帧回退,宿主收到一个带着原因的陷阱对象。
这种设计把"C 语言式未定义行为"彻底逐出沙箱。同样的除零操作,在原生代码里可能得到垃圾值、可能静默传染、可能被优化器借题发挥重写你的程序;在 Wasm 里它只有一种结局——熄火。对宿主来说这意味着两件事:故障模式可枚举(排查时先查有限几类陷阱原因),隔离边界可靠(模块崩了,宿主与邻居毫发无损,大不了丢弃实例重建)。
工程上的对应习惯也随之而定:调用 Wasm 导出函数时把陷阱当成与"返回值"并列的正常出口处理——浏览器里是一个异常对象,独立运行时里是显式的 Result。第八章的工程实践节会给出成体系的错误边界设计。
栈机的日常操作还有两组高频指令值得单练。常量入栈(i32.const、f64.const 等)把立即数压上栈,是每段计算的发令枪;局部变量读写(local.get、local.set、local.tee)负责把栈上的中间值暂存起来。local.tee 值得单独点名:它压回的栈顶与写入的局部是同一个值,适合"先存一份再继续算"的场景——没有它,就得 get 与 set 连用两次。
验证器的栈形推演还带来一个实用的调试习惯:把每个函数体当"类型棋局"来读。手写或审查 WAT 时,在脑中维护一条栈形记录(比如 i32, i32),每读一条指令就更新一次,读到函数末尾核对是否恰好剩签名要求的形状。这个习惯初看笨拙,实则是排查栈形错误最快的人肉算法——第三章开头那段 (a + b) * (a - b) 的推演表,就是它的一次完整示范。
顺带回答一个常见疑问:既然真实引擎把栈机编译成寄存器机器码,为什么不直接设计寄存器机格式?答案是规范要为所有实现方式服务——解释器、JIT、AOT、甚至芯片级实现,栈机语义都能最直接地兑现,寄存器机的分配策略则会偏向某一类实现。规范层保持中性,让实现层自由竞争,这是字节码标准屡经验证的设计公理。
顺序推演的语法到手,下一节给推演装上岔路:结构化控制流,看 Wasm 如何不靠 goto 表达一切逻辑。