3.1 栈虚拟机模型


3.1 栈虚拟机模型

本节摘要: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 里天然可用,但宿主通常对调用深度设限(浏览器默认栈大小有限),无限递归一样会"栈溢出"式陷阱。

图 3-A:一次函数调用的栈帧推演

图 3-A:一次函数调用的栈帧推演

陷阱:当场熄火的错误哲学

栈机之外,本节还有半场戏:陷阱(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、甚至芯片级实现,栈机语义都能最直接地兑现,寄存器机的分配策略则会偏向某一类实现。规范层保持中性,让实现层自由竞争,这是字节码标准屡经验证的设计公理。

本节要点回顾

  • 栈机的账:指令最省、验证可线性扫描、翻译到任意硬件都直接,三条设计目标在此交汇;
  • 操作数顺序由压栈序决定:减法、除法、比较指令的次序笔误是手写 WAT 的高发错误;
  • 调用帧隔离:每个活动函数有独立的操作数栈与局部区,返回值留在调用方栈上;
  • 陷阱统一收口:一切运行期错误当场熄火、逐帧回退、上报宿主,无未定义行为;
  • 虚拟栈是规范虚构:真实引擎整段编译为机器码,栈槽多半落进寄存器——栈机决定语义,不决定性能上限。

顺序推演的语法到手,下一节给推演装上岔路:结构化控制流,看 Wasm 如何不靠 goto 表达一切逻辑。


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