能在运行前决定的就先决定 — 字节码
一句话总结
不再每次都遍历树,而是把树一次性翻译成扁平的指令列表(字节码),再用一个循环去运行这个列表,这就是字节码 VM。值在同一个栈上压入、弹出,名字在编译时被换成槽位编号,if 和 while 则变成稍后回填的跳转偏移量。
为什么需要它
树解释器计算一个 a + b,也要比较节点名字、递归下去,并在环境链里查找名字。循环里跑一百万次,这些事就要做一百万次。然而其中相当一部分,在程序运行之前就已经确定了。x 是哪个声明,那个声明是函数的第几个局部变量,while 的结尾在哪里——只看代码就都能知道。
字节码编译器把这些决定只做一次,并写死在指令里。GET_LOCAL 2 不是“沿着链向上找 x”,而是“把帧里的第 2 格压入栈”。CPython、JVM、Lua 全都是这种结构(CPython 可以用 python3 -m dis 查看)。
工作原理
chunk 与常量池。一个函数的代码叫作 chunk。指令是 [이름, 인자, 줄, 칸](占位符依次为指令名、参数、行、列),数字、布尔值、全局名字和函数 chunk 各只放入常量池一次,指令使用它们的编号。相同的值只放一次,但在 Python 里 True == 1,哈希也相同,所以只用值作键,true 和 1 就会共用一个格子——必须把类型也放进键里。
只有一个栈。这个 VM 只有一个栈。脚本的局部变量、被调用的函数值与参数,以及计算中的临时值,全都堆在同一个栈上。帧是(chunk,下一条指令的位置,栈底),栈底是这个帧的槽位 0 所在的栈位置。
fn f(a, b) { let c = a * b; return c + 1; } print f(3, 4);
스크립트: GET_GLOBAL f · CONST 3 · CONST 4 · CALL 2 · PRINT
스택 [ … | <fn f> | 3 | 4 ] CALL 2 → 새 프레임의 바닥 = 스택 길이 − 2 = 'a' 칸
f: GET_LOCAL 0 · GET_LOCAL 1 · MUL [ … | <fn f> | 3 | 4 | 12 ] ← 12 가 곧 c(슬롯 2)
GET_LOCAL 2 · CONST 1 · ADD · RETURN RETURN: 결과를 내리고 바닥 − 1(함수 값)까지 걷고 결과를 올림
CONST 0 · RETURN return 없이 끝날 때를 위한 꼬리(여기서는 닿지 않는다)
用 let 创建的局部变量,值已经压入的那个位置就是槽位,不会另外搬动。作为代价,离开块时,必须按这个块的局部变量个数执行相应次数的 POP,栈才能回到原位。表达式语句(f(1);)的值也必须用 POP 丢掉。二者只要漏掉一个,每循环一次栈就会长一格——结果是对的,内存却在泄漏。所以本实验会把栈的最大深度与参考逐格对照。
跳转回填。翻译 if (c) { A } else { B } 时,在发出 JUMP_IF_FALSE 的那一刻,还不知道 else 从哪里开始。先把参数位置留空发出去(emit_jump),等把 A 全部翻译完再回来填上(patch_jump)。偏移量从跳转的下一条指令算起——目的地 = 跳转位置 + 1 + 偏移量。是从跳转位置数,还是从下一个位置数,差的这一格是最常见的 bug,而这个 bug 通常表现为“偶尔会跳过一行”。
if 없이 else: 조건 · JUMP_IF_FALSE →끝 · then
else 가 있으면: 조건 · JUMP_IF_FALSE →else · then · JUMP →끝 · else
while: [처음] 조건 · JUMP_IF_FALSE →끝 · 몸체 · JUMP →처음(음수 오프셋)
&&: 왼쪽 · JUMP_IF_FALSE_OR_POP →끝 · 오른쪽 (거짓이면 값을 남긴 채 건너뛴다)
||: 왼쪽 · JUMP_IF_TRUE_OR_POP →끝 · 오른쪽
这个 VM 只允许在最外层声明函数。函数里的函数要抓住外层的局部变量(闭包),就需要一个把变量从栈中取出、搬到堆上的机制(Lua、clox 的 upvalue),而本课程把这部分只放在阅读里,VM 只处理全局函数和递归。
在现场相遇的样子
python3 -m dis。反汇编一个 Python 函数,会看到LOAD_FAST 0(局部槽位)、LOAD_CONST、POP_JUMP_IF_FALSE。与本模块做出来的东西,形状几乎一样。一行表达式语句之后的POP_TOP也能看到。- JVM 的栈映射。Java 字节码验证器会确认,在每个跳转目的地,栈深度和类型是否一致。如果编译器漏掉了块末尾的 POP 或表达式语句的 POP,它生成的类会在加载阶段就被拒绝——这说明栈深度和“结果”一样,都是重要的契约。
- 寄存器 VM。Lua 5 和 Dalvik 是用虚拟寄存器代替栈的 VM。指令数量减少了,但每条指令变大了。不管哪一边,“能在运行前确定的就先确定好”的想法是一样的。
下一项实验要做什么
轮流扩展 compiler.py 和 vm.py——常量池与表达式指令(以及供人阅读的 disassemble)、算术 VM、全局与局部槽位、跳转回填、跳转执行、函数与 CALL/RETURN,以及从源码连到 VM 的流水线。评分器在编译器这边逐条对照指令,在 VM 这边对照输出、执行的指令数和栈的最大深度。最后,用你的 VM 运行四个固定程序,把数字写成报告留下来。