15.1 栈式字节码与寄存器式字节码
原生机器码会把程序绑定到某个具体的指令集。字节码(bytecode)则面向虚拟指令集,由解释器、JIT 编译器或二者共同执行。多出的这一层可以提高可移植性、紧凑性、可检查性与启动速度,也让运行时(runtime)能方便地控制动态类型、垃圾回收、异常、沙箱与性能分析(profiling)。
字节码格式(bytecode format)是一份工程契约。它定义了操作码(opcode)、操作数编码(operand encoding)、值表示法(value representation)、控制流目标(control-flow target)、函数元数据(function metadata)、常量池(constant pool)、异常表(exception table)与版本控制(versioning)。一旦文件被分发或缓存,修改这份契约就像修改硬件指令集架构(ISA)一样需要长期的兼容规划。最常见的两类指令模型是基于栈的(stack-based)与基于寄存器的(register-based)字节码。
栈式机器让操作数隐式存在
栈指令(stack instruction)从操作数栈(operand stack)顶部消费值,再把结果压回去。表达式 (a + b) * c 可编译成:
load_local 0 ; stack: [a]
load_local 1 ; stack: [a, b]
add ; stack: [a+b]
load_local 2 ; stack: [a+b, c]
mul ; stack: [(a+b)*c]
returnadd 不需要寄存器操作数,因为输入与输出位置是隐式的。单条指令因此较小,代码生成更简单,也能自然配合表达式树遍历。JVM(Java 虚拟机)字节码、WebAssembly 的验证模型和许多紧凑语言虚拟机(VM)都使用栈语义(stack semantics),尽管它们的类型系统与二进制编码并不相同。
编译器必须在每个程序点(program point)跟踪栈高度(stack height)与值类型(value types)。进入同一基本块(block)的所有控制流前驱必须对预期栈状态(stack state)达成一致,否则字节码(bytecode)非法。校验器(verifier)无需运行具体值就能模拟栈效应(stack effect):load 增加一个槽位(slot),二元 add 取走两个再放回一个,return 消费声明的结果。它还要检查栈下溢(underflow)、最大深度、操作数类型(operand types)、分支目标(branch targets)与异常处理器入口(exception-handler entries)。
寄存器式机器显式命名数据流
基于寄存器的虚拟机(Register-based VM)使用带编号的虚拟寄存器(virtual registers)或栈帧槽(frame slots):
load r0, local0
load r1, local1
add r2, r0, r1
load r3, local2
mul r4, r2, r3
return r4许多表达式在这种形式下需要更少的指令,因为值可以保持具名,不必反复 push、pop、duplicate 或 swap。它非常接近三地址码中间表示(three-address IR),能简化到原生机器码的翻译,并让定义-使用(def-use)关系显式可见。代价是指令更宽:add 必须编码一个目的操作数(destination)与两个源操作数(sources)。紧凑的格式可让常见函数使用 8 位寄存器索引(register index),寄存器更多时再使用宽前缀(wide prefix)或替代操作码(opcode)。
有些设计使用 add r0, r1 这类二地址操作(two-address operation),其计算结果会覆盖其中一个输入;另一些则使用三地址操作(three-address operation)。这里的“寄存器”通常不是物理 CPU 寄存器,而是虚拟机栈帧(VM frame)中按索引访问的槽位。解释器可把它们存在数组中,JIT 编译器在编译时再把热值映射到真正的物理寄存器上。
编码、控制流与格式演进
操作码频次(Opcode frequency)非常重要。常见操作值得使用短编码,罕见操作可以接受更长的多字节形式。操作数(Operand)可使用固定宽度(fixed width)、可变长整数(variable-length integer)或常量池索引(constant pool index)。固定宽度解码快,也便于直接按指令索引进行跳转;可变宽度节省体积,却让指令边界发现与随机访问变得更复杂。wide 前缀(prefix)能节约常见情况下的字节数,但会增加一条需要仔细测试的解码器路径(decoder path)。
分支跳转(Branch)可编码为字节偏移量(byte offset)、指令索引(instruction index)或由汇编器(assembler)解析的符号标签(label)。发射器(Emitter)通常先进行代码布局、计算位移量(displacement),再把字节放不下的短分支(short branch)扩宽为长跳转。异常处理器(Exception handler)需要保护区间(protected range)与目标栈/栈帧状态(stack/frame state)。调试信息表(Debug table)负责将字节码偏移量(bytecode offset)映射回源码位置。对于带垃圾回收的运行时(runtime),通常还需要栈映射(stack map),在安全点(safepoint)标识哪些帧槽(frame slots)中保存了对象引用。
栈式与寄存器式字节码并没有绝对的赢家。应当在代表性的工作负载(workload)上仔细测量序列化后的大小(serialized size)、解码复杂度(decode complexity)、指令/分发计数(instruction/dispatch count)、校验开销(verifier cost)、解释器速度(interpreter speed)、JIT 翻译开销(JIT translation cost)以及调试质量(debugging quality)。混合设计在实践中也很常见:例如外部采用栈格式(stack format),但在 VM 内部预先解码为类寄存器的指令(register-like instruction),或者将高频相邻的操作码(opcodes)融合成超级指令(superinstruction)。优秀的格式设计应当具备明确、可验证、可演进的不变量,并能与真正执行它的运行时架构完美匹配。