卷 V · 令CH 17深度 17/20

一台栈机器

上一章说要造一台虚拟机,你可能以为它复杂。恰恰相反 —— 它是这本书里最简单的一段代码。它的全部家当:一个栈、一个「读到第几条指令」的指针、一个大循环。指令干的事只有两种:往栈上、从栈上。这一章我们造出它,然后你单步看一串字节码在栈上压压弹弹,亲手把它跑出结果。

虚拟机取指-执行操作码

整台机器,就一个循环

栈式虚拟机的骨架,短得可以整个贴出来。它就是「从头到尾读指令,每读一条就照做」:

function run(chunk) {
  const stack = [];      // 运算栈:放中间结果
  let ip = 0;            // 指令指针:读到第几条了

  while (ip < chunk.code.length) {
    const instr = chunk.code[ip++];    // 取一条指令,指针后移
    switch (instr.op) {                // 看它是什么,照做
      case "CONST": stack.push(instr.value); break;
      case "ADD":   { const b = stack.pop(), a = stack.pop(); stack.push(a + b); break; }
      case "SUB":   { const b = stack.pop(), a = stack.pop(); stack.push(a - b); break; }
      case "MUL":   { const b = stack.pop(), a = stack.pop(); stack.push(a * b); break; }
      case "DIV":   { const b = stack.pop(), a = stack.pop(); stack.push(a / b); break; }
      case "NEG":   stack.push(-stack.pop()); break;
      case "PRINT": console.log(stack.pop()); break;
    }
  }
}

就这些。没有递归,没有树,没有指针乱跳。一个 while,读一条、做一件事、读下一条 —— 这个「取指 → 执行 → 取下一条」的循环,是所有处理器(不管是这台虚拟机,还是你电脑里那颗真 CPU)最核心的心跳。

压和弹:所有运算的唯一语法

盯住 ADD 那一行,它是理解栈机器的钥匙:

case "ADD": {
  const b = stack.pop();   // 弹出栈顶(右操作数)
  const a = stack.pop();   // 再弹一个(左操作数)
  stack.push(a + b);       // 把和压回去
  break;
}

二元运算的套路永远一样:弹两个,算,压回一个。它不关心这两个数从哪来 —— 可能是 CONST 刚压上去的,也可能是上一个运算算出来压上去的。栈,就是指令之间传递中间结果的公共传送带。正因为「操作数都在栈顶等着、结果也压回栈顶」这个约定如此统一,每条指令才能做得这么蠢、这么快 —— 它们完全不需要知道彼此,只跟栈打交道。

注意弹出的顺序:先弹的是右操作数。因为压栈时是「先压左、后压右」,栈是后进先出,弹的时候自然先弹到右边那个。对 +* 无所谓,但对 -/,顺序错了结果就反了 —— 这是写栈机器最容易栽的一个小跟头。

◆ 为什么用栈,而不是别的

你可能会问:为什么中间结果要放栈上,而不是放几个变量里?因为栈完美匹配表达式的嵌套结构1 + 2 * 3 里,2 * 3 是「内层、先算」的,它的结果得先攒着,等外层的 + 来用 —— 「先算的先压上、等着被后面的用」正是栈「后进先出」的行为。表达式嵌套多深,栈就临时堆多高,一层层嵌套算完,栈就一层层落回去。

这不是巧合。栈的形状,天生就是「嵌套计算」的形状 —— 这也是为什么调用栈(第 14 章)和运算栈都用栈:它们处理的都是「嵌套的、要暂存的、后开的先结束」的东西。

单步跑一台真虚拟机

下面这台栈机器是真的 —— 它就是本书引擎里那台。选一个程序(比如 print 1 + 2 * 3;),按「执行一条」单步走,左边看指令指针往下移,右边看栈的压压弹弹:

跟着 1 + 2 * 3 走一遍,你会看到栈这样起伏:先 CONST 三次,栈变成 [1, 2, 3];MUL 弹掉 23、压回 6,栈成 [1, 6];ADD 弹掉 16、压回 7,栈成 [7];最后 PRINT 弹出 7 打印。整个过程没有一棵树、没有一次递归,就是一根指针从上往下扫,一个栈涨涨落落。对比第 11 章那台树遍历求值器算同一个式子 —— 结果一样,但这台跑得笨、跑得直、跑得快。

回流

⟲ 回流 · 你的代码,最终都变成了「压和弹」

这台又笨又快的栈机器,不是玩具 —— 它就是 JVM、CPython、WASM 运行时的心脏形状。

JVM 是一台栈机器。你把一段 Kotlin 反编译成字节码,会看到 iconst_1(压个 1)、iload_0(把局部变量 0 压栈)、iadd(弹两个相加)、invokevirtual(调方法)—— 和你刚造的这台机器,是同一个套路:一个栈、一串指令、压压弹弹。你写的每一行 Kotlin,最终都被翻译成了这种「在栈上搬运数据」的指令,由 JVM 这个大 while 循环执行。

你调试字节码 / 分析性能时,这个模型直接有用。看懂「这段代码编译出多少条指令、栈有多深」,能帮你理解为什么某种写法比另一种快。而 CI 的后半本 clox,造的就是一台完整的栈式虚拟机(带上函数调用、闭包、垃圾回收都在这台机器上跑)—— 你现在造的这台,是它的最小内核,把内核吃透了,读 clox 就是往上加零件而已。

这一章的一句话

栈机器是本书最简单的一段代码:一个栈、一个指令指针、一个大循环;所有运算都归结为在栈上压和弹 —— 它笨,而笨正是它快的原因。

可这些字节码是从哪来的?上一章我是手写给你看的。下一章,我们造一个编译器,让它自动把语法树翻译成这串指令 —— 卷 V 就此合龙。