关于WebAssembly作为栈机器的思考

Eli Bendersky 新闻

摘要

这篇博客文章回应了关于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">&quot;add_to_byte&quot;</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&#39;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&#39;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])给我。

相似文章

逻辑程序的抽象机

Lobsters Hottest

本文探讨了使用抽象栈机器实现逻辑程序的方法,详细说明了推理规则(如加法)的不同模式分配如何转换为状态机转换以进行计算。

Codex 为 Wasmer 带来的变革

YouTube AI Channels

Wasmer 借助 OpenAI Codex,仅用两周就为边缘 WebAssembly 打造出 C++ JavaScript 运行时,估算节省一年工期;Codex 化身自主队友,负责调试并基本取代传统 IDE。

用 x86_64 汇编写成的 Linux 桌面

Lobsters Hottest

一位开发者借助 Claude Code,用纯 x86_64 汇编重建了完整的 Linux 桌面栈——从 shell、终端、窗口管理器到各种工具,实现微秒级启动,并延长数小时续航。