关于WebAssembly作为栈机器的思考
摘要
这篇博客文章回应了关于WebAssembly不是纯栈机器的说法,通过讨论其带局部变量的设计并与Forth进行比较,论证它仍然符合栈机器的定义,并且其类似寄存器的局部变量提高了可读性和性能。
<p>本周,文章<a class="reference external" href="https://purplesyringa.moe/blog/wasm-is-not-quite-a-stack-machine/">Wasm is not quite a stack machine</a>流传甚广,引起了我的注意。该文章称WASM不是纯栈机器,因为它有局部变量,并且缺少一些如<tt class="docutils literal">dup</tt>和<tt class="docutils literal">swap</tt>的栈操作指令。</p>
<p>虽然我并非不同意,但在我看来,这有点像是语义上的讨论——据我所知,栈机器并没有形式化的定义。例如,维基百科说:</p>
<blockquote>
[...],栈机器是一种计算机处理器或进程虚拟机,其主要交互是将短生命周期的临时值压入和弹出下推栈。</blockquote>
<p>WASM显然符合这个定义;其主要交互是通过栈进行的,尽管WASM还增加了无限的寄存器文件(局部变量)。更纯粹的栈机器如Forth仅限于栈和内存(指向内存的指针在栈上管理);WASM也具有这些,此外还有寄存器。</p>
<p>说到Forth,提到<tt class="docutils literal">dup</tt>让我想起了自己用该语言编程的印象,这在我在<a class="reference external" href="https://eli.thegreenplace.net/2025/implementing-forth-in-go-and-c/">《用Go和C实现Forth》</a>一文中有所记录。那里我强调了Forth的以下基本库函数;它向内存中存储的值添加一个加数。</p>
<div class="highlight"><pre><span></span><span class="kn">:</span><span class="w"> </span><span class="nc">+!</span><span class="w"> </span><span class="c1">( addend addr -- )</span><span class="w"></span>
<span class="w"> </span><span class="k">tuck</span><span class="w"> </span><span class="c1">( addr addend addr )</span><span class="w"></span>
<span class="w"> </span><span class="k">@</span><span class="w"> </span><span class="c1">( addr addend value-at-addr )</span><span class="w"></span>
<span class="w"> </span><span class="k">+</span><span class="w"> </span><span class="c1">( addr updated-value )</span><span class="w"></span>
<span class="w"> </span><span class="k">swap</span><span class="w"> </span><span class="c1">( updated-value addr )</span><span class="w"></span>
<span class="w"> </span><span class="k">!</span><span class="w"> </span><span class="k">;</span><span class="w"></span>
</pre></div>
<p>并感叹没有旁边的详细栈视图注释,这样的代码多么难以理解。</p>
<p>我发现推理以下WASM代码要简单得多:</p>
<div class="highlight"><pre><span></span><span class="p">(</span><span class="k">func</span> <span class="p">(</span><span class="k">export</span> <span class="s2">"add_to_byte"</span><span class="p">)</span> <span class="p">(</span><span class="k">param</span> <span class="nv">$addr</span> <span class="kt">i32</span><span class="p">)</span> <span class="p">(</span><span class="k">param</span> <span class="nv">$delta</span> <span class="kt">i32</span><span class="p">)</span>
<span class="p">(</span><span class="nb">i32.store8</span>
<span class="p">(</span><span class="nb">local.get</span> <span class="nv">$addr</span><span class="p">)</span>
<span class="p">(</span><span class="nb">i32.add</span>
<span class="p">(</span><span class="nb">i32.load8_u</span> <span class="p">(</span><span class="nb">local.get</span> <span class="nv">$addr</span><span class="p">))</span>
<span class="p">(</span><span class="nb">local.get</span> <span class="nv">$delta</span><span class="p">)))</span>
<span class="p">)</span>
</pre></div>
<p>你可能会说这是作弊,因为折叠的WASM指令提高了可读性,它们只是语法糖;好吧,这里是线性代码:</p>
<div class="highlight"><pre><span></span><span class="nb">local.get</span> <span class="nv">$addr</span>
<span class="nb">local.get</span> <span class="nv">$addr</span>
<span class="nb">i32.load8_u</span>
<span class="nb">local.get</span> <span class="nv">$delta</span>
<span class="nb">i32.add</span>
<span class="nb">i32.store8</span>
</pre></div>
<p>它仍然非常可读,因为——虽然栈用于所有计算和实际命令——但部分数据存在于命名的“寄存器”中,而不是栈上。因此我们不需要那些tuck-swap的扭曲操作来把东西按正确顺序排列。</p>
<p>有人可能会担心重复的<tt class="docutils literal">local.get $addr</tt>;真正的<tt class="docutils literal">dup</tt>是否更好?嗯,从可读性角度来说,我们已经讨论过,并非如此。性能方面呢?由于栈虚拟机只是一个抽象,执行此代码的底层CPU无论如何都是寄存器机器,所以答案是不——这完全无关紧要。</p>
<p>现代编译器工程师是在C及其后继语言的烈火中锻造出来的;任意的控制流、任意的寄存器和内存访问,无所不有。编译器非常复杂。让我们看看<tt class="docutils literal">wasmtime</tt>如何将我们的<tt class="docutils literal">add_to_byte</tt>编译为本地代码(使用<tt class="docutils literal">wasmtime explore</tt>及其默认的<tt class="docutils literal"><span class="pre">opt-level=2</span></tt>);注释由我添加:</p>
<div class="highlight"><pre><span></span><span class="c1">// Prologue</span>
<span class="n">push</span><span class="w"> </span><span class="n">rbp</span><span class="w"></span>
<span class="n">mov</span><span class="w"> </span><span class="n">rbp</span><span class="p">,</span><span class="w"> </span><span class="n">rsp</span><span class="w"></span>
<span class="c1">// wasmtime's VM context pointer lives in rdi; 0x38 is likely its offset</span>
<span class="c1">// to the default linear memory. Therefore, r10 will hold the base address</span>
<span class="c1">// of the linear memory buffer</span>
<span class="n">mov</span><span class="w"> </span><span class="n">r10</span><span class="p">,</span><span class="w"> </span><span class="n">qword</span><span class="w"> </span><span class="n">ptr</span><span class="w"> </span><span class="p">[</span><span class="n">rdi</span><span class="w"> </span><span class="o">+</span><span class="w"> </span><span class="mh">0x38</span><span class="p">]</span><span class="w"></span>
<span class="c1">// The first parameter ($addr) is in edx; since WASM values are i32, it's</span>
<span class="c1">// zero-extended into the 64-bit r11 by copying into r11d</span>
<span class="n">mov</span><span class="w"> </span><span class="n">r11d</span><span class="p">,</span><span class="w"> </span><span class="n">edx</span><span class="w"></span>
<span class="c1">// r10+r11 is memory[$addr]; this loads the current value into rsi</span>
<span class="c1">// (zero-extending from 8 bits)</span>
<span class="n">movzx</span><span class="w"> </span><span class="n">rsi</span><span class="p">,</span><span class="w"> </span><span class="n">byte</span><span class="w"> </span><span class="n">ptr</span><span class="w"> </span><span class="p">[</span><span class="n">r10</span><span class="w"> </span><span class="o">+</span><span class="w"> </span><span class="n">r11</span><span class="p">]</span><span class="w"></span>
<span class="c1">// ecx is the first parameter ($delta); this adds the addend to the</span>
<span class="c1">// current value</span>
<span class="n">add</span><span class="w"> </span><span class="n">esi</span><span class="p">,</span><span class="w"> </span><span class="n">ecx</span><span class="w"></span>
<span class="c1">// Store cur_value+addend back into memory[$addr]</span>
<span class="n">mov</span><span class="w"> </span><span class="n">byte</span><span class="w"> </span><span class
查看缓存全文
缓存时间: 2026/05/16 03:35
# 关于 WebAssembly 作为栈式机器的思考
来源:https://eli.thegreenplace.net/2026/thoughts-on-webassembly-as-a-stack-machine
本周,文章《Wasm 并非纯粹的栈式机器》(https://purplesyringa.moe/blog/wasm-is-not-quite-a-stack-machine/)被广泛传播,引起了我的注意。该文声称 WASM 并非纯粹的栈式机器,因为它拥有局部变量,并且缺少一些像 `dup` 和 `swap` 这样的栈操作指令。
虽然我并不完全反对,但恕我直言,这更像是一场语义上的讨论——因为据我所知,并没有关于什么是栈式机器的*正式*定义。例如,维基百科上这样说:
> “[……]栈式机器是一种计算机处理器或进程虚拟机,其主要的交互方式是将短期存在的临时值压入和弹出下推栈。”
WASM 确实符合这一定义;*主要*的交互通过栈进行,尽管 WASM 还通过无限寄存器文件(局部变量)进行了增强。更纯粹的栈式机器如 Forth,仅局限于栈和内存(内存中的指针由栈管理);WASM 也拥有这些,外加寄存器。
说到 Forth,文中提到的 `dup` 让我想起了自己在该语言中编程的体会,记录在我的文章《用 Go 和 C 实现 Forth》(https://eli.thegreenplace.net/2025/implementing-forth-in-go-and-c/)中。在那里,我强调了以下 Forth 的基本库函数;它将一个加数加到存储在内存中的值上。
```
: +! ( addend addr -- )
tuck ( addr addend addr )
@ ( addr addend value-at-addr )
+ ( addr updated-value )
swap ( updated-value addr )
! ;
```
并感叹若没有旁边详细的栈视图注释,理解这样的代码是多么困难。
我发现下面这段 WASM 代码更容易理解:
```
(func (export "add_to_byte") (param $addr i32) (param $delta i32)
(i32.store8
(local.get $addr)
(i32.add
(i32.load8_u (local.get $addr))
(local.get $delta)))
)
```
你可能会说这是作弊,因为折叠的 WASM 指令提高了可读性,而它们只是语法糖;好吧,这是线性的代码:
```
local.get $addr
local.get $addr
i32.load8_u
local.get $delta
i32.add
i32.store8
```
它仍然非常易读,因为——虽然栈用于所有计算和实际命令——但某些数据存在于命名的“寄存器”中,而不是栈上。所以我们不需要为了调整顺序而进行那些 `tuck-swap` 的复杂操作。
有人可能会担心重复的 `local.get $addr`;难道一个真正的 `dup` 不是更好吗?嗯,从可读性角度来说,并不更好,正如我们已经讨论过的。那么性能呢?由于栈 VM 只是一个抽象,而执行这段代码的底层 CPU 本质上都是寄存器机器,答案是否定的——这根本无关紧要。
现代编译器工程师是在 C 及其衍生语言的火炉中锻造出来的;任意的控制流、任意的寄存器和内存访问,一切皆有可能。编译器已经非常复杂了。让我们看看 `wasmtime` 是如何将我们的 `add_to_byte` 编译为本地代码的(使用 `wasmtime explore` 及其默认的 `opt-level=2`);注释是我添加的:
```
// Prologue
push rbp
mov rbp, rsp
// wasmtime 的 VM 上下文指针在 rdi 中;0x38 很可能是指向
// 默认线性内存的偏移量。因此,r10 将保存线性内存缓冲区的基址
mov r10, qword ptr [rdi + 0x38]
// 第一个参数 ($addr) 在 edx 中;由于 WASM 值是 i32,它被
// 零扩展到 64 位的 r11 中(通过复制到 r11d)
mov r11d, edx
// r10+r11 是 memory[$addr];这会将当前值加载到 rsi 中
// (从 8 位零扩展)
movzx rsi, byte ptr [r10 + r11]
// ecx 是第一个参数 ($delta);这会将加数加到当前值上
add esi, ecx
// 将 cur_value+addend 存回 memory[$addr]
mov byte ptr [r10 + r11], sil
// Epilogue
mov rsp, rbp
pop rbp
ret
```
这几乎就是为 C 语句 `mem[addr] += addend` 所期望生成的代码,或者如果手动编写 x86-64 汇编的话也是类似的。编译器毫不费力地发现,对同一个 WASM 局部变量的两次连续加载会产生相同的值,并且实际上不必重复加载。WASM 模型使得这一点相当容易,因为你不能为局部变量创建别名;只要没有中间写入同一个局部变量,多次读取就会产生相同的值(冗余加载消除)。
---
如需评论,请发送[电子邮件](mailto:[email protected])给我。
相似文章
SBCL: 终极汇编代码面包板 (2014)
一篇技术博客文章,探讨如何使用SBCL作为汇编代码的面包板,重点介绍基于堆栈的虚拟机技术,如旋转堆栈和高效的原语操作分发,并引用了F18处理器和x87堆栈。
一种受Forth启发的用于编写网站的语言
Beto介绍了Forge,一种受Forth启发的基于栈的语言,用于构建网站,通过WebAssembly编译器实现服务端和客户端渲染。
WATaBoy:将Game Boy指令即时编译为Wasm,性能超越原生解释器
本文介绍了WATaBoy,一个Game Boy模拟器,它使用即时编译到WebAssembly的方式,实现了超越原生解释器的性能,是JIT到Wasm在模拟领域的一个概念验证。
Windows堆栈限制检查回顾,后续
Raymond Chen跟进了他之前关于ARM64堆栈限制检查的文章,指出了堆栈探测函数中x15寄存器的非常规使用细节,并比较了多个架构的寄存器使用。
你这个周末打算做什么?
一位开发者分享了他这个周末的项目,即构建一种编译为WebAssembly的低级中缀语言,并提供了从上下文自动补全到前沿模型等AI编码工具的个人排名。